craft-native 0.0.87 → 0.0.88

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -338,6 +338,12 @@ struct CraftWebView: UIViewRepresentable {
338
338
  webView.navigationDelegate = context.coordinator
339
339
  webView.isOpaque = false
340
340
 
341
+ // Before any message can be offered. Zig evaluates every reply against
342
+ // this webview, and without it an action would run, succeed, and reach
343
+ // `error.NoWebView` on the way back — the page hearing nothing while
344
+ // the work happened.
345
+ CraftZigRuntime.attach(webView)
346
+
341
347
  // Register with DeepLinkManager
342
348
  DeepLinkManager.shared.setWebView(webView)
343
349
 
@@ -363,6 +369,13 @@ struct CraftWebView: UIViewRepresentable {
363
369
 
364
370
  func updateUIView(_ webView: WKWebView, context: Context) {}
365
371
 
372
+ /// The teardown half of `CraftZigRuntime.attach`, called by SwiftUI when
373
+ /// this representable goes away. Without it Zig keeps an unretained
374
+ /// pointer to a deallocated webview and answers into freed memory.
375
+ static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
376
+ CraftZigRuntime.detach(uiView)
377
+ }
378
+
366
379
  func makeCoordinator() -> Coordinator {
367
380
  let coordinator = Coordinator(config: config)
368
381
  // The Zig dispatcher finds CraftSwiftShim by class name; the shim finds
@@ -444,7 +457,12 @@ struct CraftWebView: UIViewRepresentable {
444
457
  locationManager?.desiredAccuracy = kCLLocationAccuracyBest
445
458
  locationManager?.activityType = .fitness
446
459
  locationManager?.pausesLocationUpdatesAutomatically = false
447
- restoreLocationRecordingState()
460
+ // Zig owns the recorder when it is linked, including the
461
+ // relaunch restore — see `CraftZigRuntime.adoptLocationRecording`.
462
+ // Running both would leave two managers appending to one track.
463
+ if !CraftZigRuntime.adoptLocationRecording() {
464
+ restoreLocationRecordingState()
465
+ }
448
466
  }
449
467
  if config.enableContacts {
450
468
  contactStore = CNContactStore()
@@ -516,7 +534,24 @@ struct CraftWebView: UIViewRepresentable {
516
534
  guard let body = message.body as? [String: Any],
517
535
  let action = body["action"] as? String else { return }
518
536
 
519
- dispatch(action: action, body: body, callbackId: body["callbackId"] as? String)
537
+ let callbackId = body["callbackId"] as? String
538
+
539
+ // The Zig runtime first, when there is one.
540
+ //
541
+ // `offer` returns true when Zig has taken responsibility for the
542
+ // call — served it, or refused it and settled the page's promise
543
+ // on the way. Either way the answer is on its way and this method
544
+ // must not produce a second one. False means no Zig module
545
+ // recognised the action, which is every action still listed in the
546
+ // switch below and nothing else.
547
+ //
548
+ // In an app with no Zig runtime linked, `offer` is a `dlsym` miss
549
+ // that answers false for everything, and the switch serves the
550
+ // whole surface exactly as it did before. That is why this is one
551
+ // line and not a build flag.
552
+ if CraftZigRuntime.offer(action: action, body: body, callbackId: callbackId) { return }
553
+
554
+ dispatch(action: action, body: body, callbackId: callbackId)
520
555
  }
521
556
 
522
557
  /// The action switch, reachable from two directions: the
@@ -531,13 +566,19 @@ struct CraftWebView: UIViewRepresentable {
531
566
  func dispatch(action: String, body: [String: Any], callbackId: String?) {
532
567
  switch action {
533
568
  case "startListening":
534
- if config.enableSpeechRecognition { startSpeechRecognition() }
569
+ if config.enableSpeechRecognition {
570
+ startSpeechRecognition()
571
+ } else {
572
+ rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED")
573
+ }
535
574
  case "stopListening":
536
575
  stopSpeechRecognition()
537
576
  case "haptic":
538
577
  if config.enableHaptics {
539
578
  let style = body["style"] as? String ?? "medium"
540
579
  triggerHaptic(style: style)
580
+ } else {
581
+ rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
541
582
  }
542
583
  case "share":
543
584
  if config.enableShare {
@@ -555,39 +596,62 @@ struct CraftWebView: UIViewRepresentable {
555
596
  if config.enableCamera {
556
597
  pendingCallbackId = callbackId
557
598
  openCamera()
599
+ } else {
600
+ rejectCallback(callbackId, error: "Camera is disabled", code: "CAPABILITY_DISABLED")
558
601
  }
559
602
  case "pickImage":
560
603
  if config.enableCamera {
561
604
  pendingCallbackId = callbackId
562
605
  pickImage()
606
+ } else {
607
+ rejectCallback(callbackId, error: "Camera is disabled", code: "CAPABILITY_DISABLED")
563
608
  }
564
609
  case "authenticate":
565
610
  if config.enableBiometric {
566
611
  let reason = body["reason"] as? String ?? "Authenticate to continue"
567
612
  authenticate(reason: reason, callbackId: callbackId)
613
+ } else {
614
+ rejectCallback(callbackId, error: "Biometric authentication is disabled", code: "CAPABILITY_DISABLED")
568
615
  }
569
616
  case "registerPush":
570
617
  if config.enablePushNotifications {
571
618
  registerPushNotifications(callbackId: callbackId)
619
+ } else {
620
+ rejectCallback(callbackId, error: "Push notifications are disabled", code: "CAPABILITY_DISABLED")
572
621
  }
573
622
  case "secureSet":
574
- if config.enableSecureStorage,
575
- let key = body["key"] as? String,
576
- let value = body["value"] as? String {
577
- let success = secureStore(key: key, value: value)
578
- resolveCallback(callbackId, result: success)
623
+ if config.enableSecureStorage {
624
+ if let key = body["key"] as? String,
625
+ let value = body["value"] as? String {
626
+ let success = secureStore(key: key, value: value)
627
+ resolveCallback(callbackId, result: success)
628
+ } else {
629
+ rejectCallback(callbackId, error: "secureSet was called without the values it needs", code: "INVALID_ARGUMENT")
630
+ }
631
+ } else {
632
+ rejectCallback(callbackId, error: "Secure storage is disabled", code: "CAPABILITY_DISABLED")
579
633
  }
580
634
  case "secureGet":
581
- if config.enableSecureStorage,
582
- let key = body["key"] as? String {
583
- let value = secureRetrieve(key: key)
584
- resolveCallback(callbackId, result: value as Any)
635
+ if config.enableSecureStorage {
636
+ if let key = body["key"] as? String {
637
+ let value = secureRetrieve(key: key)
638
+ resolveCallback(callbackId, result: value as Any)
639
+ } else {
640
+ rejectCallback(callbackId, error: "secureGet was called without the values it needs", code: "INVALID_ARGUMENT")
641
+ }
642
+ } else {
643
+ rejectCallback(callbackId, error: "Secure storage is disabled", code: "CAPABILITY_DISABLED")
585
644
  }
586
645
  case "secureRemove":
587
- if config.enableSecureStorage,
588
- let key = body["key"] as? String {
589
- let success = secureRemove(key: key)
590
- resolveCallback(callbackId, result: success)
646
+ if config.enableSecureStorage {
647
+ if let key = body["key"] as? String {
648
+ let success = secureRemove(key: key)
649
+ resolveCallback(callbackId, result: success)
650
+ } else {
651
+ rejectCallback(callbackId, error: "secureRemove was called without the values it needs", code: "INVALID_ARGUMENT")
652
+ }
653
+ } else {
654
+ rejectCallback(callbackId, error: "Secure storage is disabled", code: "CAPABILITY_DISABLED")
591
655
  }
592
656
  case "secureClear":
593
657
  if config.enableSecureStorage {
@@ -650,14 +714,22 @@ struct CraftWebView: UIViewRepresentable {
650
714
  readLocationRecording(callbackId: callbackId)
651
715
  // Clipboard
652
716
  case "clipboardWrite":
653
- if config.enableClipboard, let text = body["text"] as? String {
654
- UIPasteboard.general.string = text
655
- resolveCallback(callbackId, result: true)
656
- }
717
+ if config.enableClipboard {
718
+ if let text = body["text"] as? String {
719
+ UIPasteboard.general.string = text
720
+ resolveCallback(callbackId, result: true)
721
+ } else {
722
+ rejectCallback(callbackId, error: "clipboardWrite was called without the values it needs", code: "INVALID_ARGUMENT")
723
+ }
724
+ } else {
725
+ rejectCallback(callbackId, error: "Clipboard is disabled", code: "CAPABILITY_DISABLED")
726
+ }
657
727
  case "clipboardRead":
658
728
  if config.enableClipboard {
659
729
  let text = UIPasteboard.general.string ?? ""
660
730
  resolveCallback(callbackId, result: text)
731
+ } else {
732
+ rejectCallback(callbackId, error: "Clipboard is disabled", code: "CAPABILITY_DISABLED")
661
733
  }
662
734
  // Device Info
663
735
  case "getDeviceInfo":
@@ -705,49 +777,80 @@ struct CraftWebView: UIViewRepresentable {
705
777
  case "getContacts":
706
778
  if config.enableContacts {
707
779
  getContacts(callbackId: callbackId)
780
+ } else {
781
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
708
782
  }
709
783
  case "addContact":
710
- if config.enableContacts,
711
- let contactData = body["contact"] as? [String: Any] {
712
- addContact(contactData, callbackId: callbackId)
784
+ if config.enableContacts {
785
+ if let contactData = body["contact"] as? [String: Any] {
786
+ addContact(contactData, callbackId: callbackId)
787
+ } else {
788
+ rejectCallback(callbackId, error: "addContact was called without the values it needs", code: "INVALID_ARGUMENT")
789
+ }
790
+ } else {
791
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
713
792
  }
714
-
715
793
  // MARK: - Calendar
716
794
  case "getCalendarEvents":
717
795
  if config.enableCalendar {
718
796
  let startDate = body["startDate"] as? Double
719
797
  let endDate = body["endDate"] as? Double
720
798
  getCalendarEvents(startDate: startDate, endDate: endDate, callbackId: callbackId)
799
+ } else {
800
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
721
801
  }
722
802
  case "createCalendarEvent":
723
- if config.enableCalendar,
724
- let eventData = body["event"] as? [String: Any] {
725
- createCalendarEvent(eventData, callbackId: callbackId)
803
+ if config.enableCalendar {
804
+ if let eventData = body["event"] as? [String: Any] {
805
+ createCalendarEvent(eventData, callbackId: callbackId)
806
+ } else {
807
+ rejectCallback(callbackId, error: "createCalendarEvent was called without the values it needs", code: "INVALID_ARGUMENT")
808
+ }
809
+ } else {
810
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
726
811
  }
727
812
  case "deleteCalendarEvent":
728
- if config.enableCalendar,
729
- let eventId = body["eventId"] as? String {
730
- deleteCalendarEvent(eventId, callbackId: callbackId)
813
+ if config.enableCalendar {
814
+ if let eventId = body["eventId"] as? String {
815
+ deleteCalendarEvent(eventId, callbackId: callbackId)
816
+ } else {
817
+ rejectCallback(callbackId, error: "deleteCalendarEvent was called without the values it needs", code: "INVALID_ARGUMENT")
818
+ }
819
+ } else {
820
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
731
821
  }
732
-
733
822
  // MARK: - Local Notifications
734
823
  case "scheduleNotification":
735
- if config.enableLocalNotifications,
736
- let notifData = body["notification"] as? [String: Any] {
737
- scheduleLocalNotification(notifData, callbackId: callbackId)
824
+ if config.enableLocalNotifications {
825
+ if let notifData = body["notification"] as? [String: Any] {
826
+ scheduleLocalNotification(notifData, callbackId: callbackId)
827
+ } else {
828
+ rejectCallback(callbackId, error: "scheduleNotification was called without the values it needs", code: "INVALID_ARGUMENT")
829
+ }
830
+ } else {
831
+ rejectCallback(callbackId, error: "Local notifications are disabled", code: "CAPABILITY_DISABLED")
738
832
  }
739
833
  case "cancelNotification":
740
- if config.enableLocalNotifications,
741
- let notifId = body["id"] as? String {
742
- cancelLocalNotification(notifId, callbackId: callbackId)
834
+ if config.enableLocalNotifications {
835
+ if let notifId = body["id"] as? String {
836
+ cancelLocalNotification(notifId, callbackId: callbackId)
837
+ } else {
838
+ rejectCallback(callbackId, error: "cancelNotification was called without the values it needs", code: "INVALID_ARGUMENT")
839
+ }
840
+ } else {
841
+ rejectCallback(callbackId, error: "Local notifications are disabled", code: "CAPABILITY_DISABLED")
743
842
  }
744
843
  case "cancelAllNotifications":
745
844
  if config.enableLocalNotifications {
746
845
  cancelAllLocalNotifications(callbackId: callbackId)
846
+ } else {
847
+ rejectCallback(callbackId, error: "Local notifications are disabled", code: "CAPABILITY_DISABLED")
747
848
  }
748
849
  case "getPendingNotifications":
749
850
  if config.enableLocalNotifications {
750
851
  getPendingNotifications(callbackId: callbackId)
852
+ } else {
853
+ rejectCallback(callbackId, error: "Local notifications are disabled", code: "CAPABILITY_DISABLED")
751
854
  }
752
855
 
753
856
  // MARK: - Deep Links
@@ -755,6 +858,8 @@ struct CraftWebView: UIViewRepresentable {
755
858
  if config.enableDeepLinks {
756
859
  // Handler is registered in JS
757
860
  resolveCallback(callbackId, result: true)
861
+ } else {
862
+ rejectCallback(callbackId, error: "Deep links are disabled", code: "CAPABILITY_DISABLED")
758
863
  }
759
864
  case "getInitialURL":
760
865
  if config.enableDeepLinks {
@@ -769,46 +874,73 @@ struct CraftWebView: UIViewRepresentable {
769
874
  } else {
770
875
  resolveCallback(callbackId, result: NSNull())
771
876
  }
877
+ } else {
878
+ rejectCallback(callbackId, error: "Deep links are disabled", code: "CAPABILITY_DISABLED")
772
879
  }
773
880
 
774
881
  // MARK: - In-App Purchase
775
882
  case "getProducts":
776
- if config.enableInAppPurchase,
777
- let productIds = body["productIds"] as? [String] {
778
- getProducts(productIds, callbackId: callbackId)
883
+ if config.enableInAppPurchase {
884
+ if let productIds = body["productIds"] as? [String] {
885
+ getProducts(productIds, callbackId: callbackId)
886
+ } else {
887
+ rejectCallback(callbackId, error: "getProducts was called without the values it needs", code: "INVALID_ARGUMENT")
888
+ }
889
+ } else {
890
+ rejectCallback(callbackId, error: "In-app purchase is disabled", code: "CAPABILITY_DISABLED")
779
891
  }
780
892
  case "purchase":
781
- if config.enableInAppPurchase,
782
- let productId = body["productId"] as? String {
783
- purchaseProduct(productId, callbackId: callbackId)
893
+ if config.enableInAppPurchase {
894
+ if let productId = body["productId"] as? String {
895
+ purchaseProduct(productId, callbackId: callbackId)
896
+ } else {
897
+ rejectCallback(callbackId, error: "purchase was called without the values it needs", code: "INVALID_ARGUMENT")
898
+ }
899
+ } else {
900
+ rejectCallback(callbackId, error: "In-app purchase is disabled", code: "CAPABILITY_DISABLED")
784
901
  }
785
902
  case "restorePurchases":
786
903
  if config.enableInAppPurchase {
787
904
  restorePurchases(callbackId: callbackId)
905
+ } else {
906
+ rejectCallback(callbackId, error: "In-app purchase is disabled", code: "CAPABILITY_DISABLED")
788
907
  }
789
908
 
790
909
  // MARK: - Keep Awake
791
910
  case "setKeepAwake":
792
- if config.enableKeepAwake,
793
- let enabled = body["enabled"] as? Bool {
794
- setKeepAwake(enabled, callbackId: callbackId)
911
+ if config.enableKeepAwake {
912
+ if let enabled = body["enabled"] as? Bool {
913
+ setKeepAwake(enabled, callbackId: callbackId)
914
+ } else {
915
+ rejectCallback(callbackId, error: "setKeepAwake was called without the values it needs", code: "INVALID_ARGUMENT")
916
+ }
917
+ } else {
918
+ rejectCallback(callbackId, error: "Keep awake is disabled", code: "CAPABILITY_DISABLED")
795
919
  }
796
-
797
920
  // MARK: - Orientation Lock
798
921
  case "lockOrientation":
799
- if config.enableOrientationLock,
800
- let orientation = body["orientation"] as? String {
801
- lockOrientation(orientation, callbackId: callbackId)
922
+ if config.enableOrientationLock {
923
+ if let orientation = body["orientation"] as? String {
924
+ lockOrientation(orientation, callbackId: callbackId)
925
+ } else {
926
+ rejectCallback(callbackId, error: "lockOrientation was called without the values it needs", code: "INVALID_ARGUMENT")
927
+ }
928
+ } else {
929
+ rejectCallback(callbackId, error: "Orientation lock is disabled", code: "CAPABILITY_DISABLED")
802
930
  }
803
931
  case "unlockOrientation":
804
932
  if config.enableOrientationLock {
805
933
  unlockOrientation(callbackId: callbackId)
934
+ } else {
935
+ rejectCallback(callbackId, error: "Orientation lock is disabled", code: "CAPABILITY_DISABLED")
806
936
  }
807
937
 
808
938
  // MARK: - QR/Barcode Scanner
809
939
  case "scanQRCode":
810
940
  if config.enableQRScanner {
811
941
  scanQRCode(callbackId: callbackId)
942
+ } else {
943
+ rejectCallback(callbackId, error: "QR scanning is disabled", code: "CAPABILITY_DISABLED")
812
944
  }
813
945
 
814
946
  // MARK: - File Picker
@@ -816,42 +948,59 @@ struct CraftWebView: UIViewRepresentable {
816
948
  if config.enableFilePicker {
817
949
  let types = body["types"] as? [String]
818
950
  pickFile(types: types, callbackId: callbackId)
951
+ } else {
952
+ rejectCallback(callbackId, error: "File picker is disabled", code: "CAPABILITY_DISABLED")
819
953
  }
820
954
 
821
955
  // MARK: - File Download
822
956
  case "downloadFile":
823
- if config.enableFileDownload,
824
- let url = body["url"] as? String,
825
- let filename = body["filename"] as? String {
826
- downloadFile(url: url, filename: filename, callbackId: callbackId)
827
- }
957
+ if config.enableFileDownload {
958
+ if let url = body["url"] as? String, let filename = body["filename"] as? String {
959
+ downloadFile(url: url, filename: filename, callbackId: callbackId)
960
+ } else {
961
+ rejectCallback(callbackId, error: "downloadFile was called without the values it needs", code: "INVALID_ARGUMENT")
962
+ }
963
+ } else {
964
+ rejectCallback(callbackId, error: "File download is disabled", code: "CAPABILITY_DISABLED")
965
+ }
828
966
  case "saveFile":
829
- if config.enableFileDownload,
830
- let data = body["data"] as? String,
831
- let filename = body["filename"] as? String {
832
- saveFile(data: data, filename: filename, callbackId: callbackId)
833
- }
834
-
967
+ if config.enableFileDownload {
968
+ if let data = body["data"] as? String, let filename = body["filename"] as? String {
969
+ saveFile(data: data, filename: filename, callbackId: callbackId)
970
+ } else {
971
+ rejectCallback(callbackId, error: "saveFile was called without the values it needs", code: "INVALID_ARGUMENT")
972
+ }
973
+ } else {
974
+ rejectCallback(callbackId, error: "File download is disabled", code: "CAPABILITY_DISABLED")
975
+ }
835
976
  // MARK: - Social Auth
836
977
  case "signInWithApple":
837
978
  if config.enableSocialAuth {
838
979
  signInWithApple(callbackId: callbackId)
980
+ } else {
981
+ rejectCallback(callbackId, error: "Social sign-in is disabled", code: "CAPABILITY_DISABLED")
839
982
  }
840
983
 
841
984
  // MARK: - Audio Recording
842
985
  case "startAudioRecording":
843
986
  if config.enableAudioRecording {
844
987
  startAudioRecording(callbackId: callbackId)
988
+ } else {
989
+ rejectCallback(callbackId, error: "Audio recording is disabled", code: "CAPABILITY_DISABLED")
845
990
  }
846
991
  case "stopAudioRecording":
847
992
  if config.enableAudioRecording {
848
993
  stopAudioRecording(callbackId: callbackId)
994
+ } else {
995
+ rejectCallback(callbackId, error: "Audio recording is disabled", code: "CAPABILITY_DISABLED")
849
996
  }
850
997
 
851
998
  // MARK: - Video Recording
852
999
  case "startVideoRecording":
853
1000
  if config.enableVideoRecording {
854
1001
  startVideoRecording(callbackId: callbackId)
1002
+ } else {
1003
+ rejectCallback(callbackId, error: "Video recording is disabled", code: "CAPABILITY_DISABLED")
855
1004
  }
856
1005
 
857
1006
  // MARK: - Motion Sensors
@@ -859,6 +1008,8 @@ struct CraftWebView: UIViewRepresentable {
859
1008
  if config.enableMotionSensors {
860
1009
  let interval = body["interval"] as? Double ?? 100
861
1010
  startMotionUpdates(interval: interval, callbackId: callbackId)
1011
+ } else {
1012
+ rejectCallback(callbackId, error: "Motion sensors are disabled", code: "CAPABILITY_DISABLED")
862
1013
  }
863
1014
  case "stopMotionUpdates":
864
1015
  stopMotionUpdates()
@@ -866,22 +1017,33 @@ struct CraftWebView: UIViewRepresentable {
866
1017
 
867
1018
  // MARK: - Local Database
868
1019
  case "dbExecute":
869
- if config.enableLocalDatabase,
870
- let sql = body["sql"] as? String {
871
- let params = body["params"] as? [Any]
872
- dbExecute(sql: sql, params: params, callbackId: callbackId)
1020
+ if config.enableLocalDatabase {
1021
+ if let sql = body["sql"] as? String {
1022
+ let params = body["params"] as? [Any]
1023
+ dbExecute(sql: sql, params: params, callbackId: callbackId)
1024
+ } else {
1025
+ rejectCallback(callbackId, error: "dbExecute was called without the values it needs", code: "INVALID_ARGUMENT")
1026
+ }
1027
+ } else {
1028
+ rejectCallback(callbackId, error: "Local database is disabled", code: "CAPABILITY_DISABLED")
873
1029
  }
874
1030
  case "dbQuery":
875
- if config.enableLocalDatabase,
876
- let sql = body["sql"] as? String {
877
- let params = body["params"] as? [Any]
878
- dbQuery(sql: sql, params: params, callbackId: callbackId)
1031
+ if config.enableLocalDatabase {
1032
+ if let sql = body["sql"] as? String {
1033
+ let params = body["params"] as? [Any]
1034
+ dbQuery(sql: sql, params: params, callbackId: callbackId)
1035
+ } else {
1036
+ rejectCallback(callbackId, error: "dbQuery was called without the values it needs", code: "INVALID_ARGUMENT")
1037
+ }
1038
+ } else {
1039
+ rejectCallback(callbackId, error: "Local database is disabled", code: "CAPABILITY_DISABLED")
879
1040
  }
880
-
881
1041
  // MARK: - Bluetooth
882
1042
  case "startBluetoothScan":
883
1043
  if config.enableBluetooth {
884
1044
  startBluetoothScan(callbackId: callbackId)
1045
+ } else {
1046
+ rejectCallback(callbackId, error: "Bluetooth is disabled", code: "CAPABILITY_DISABLED")
885
1047
  }
886
1048
  case "stopBluetoothScan":
887
1049
  stopBluetoothScan()
@@ -891,6 +1053,8 @@ struct CraftWebView: UIViewRepresentable {
891
1053
  case "scanNFC":
892
1054
  if config.enableNFC {
893
1055
  scanNFC(callbackId: callbackId)
1056
+ } else {
1057
+ rejectCallback(callbackId, error: "NFC is disabled", code: "CAPABILITY_DISABLED")
894
1058
  }
895
1059
 
896
1060
  // MARK: - Health
@@ -898,13 +1062,20 @@ struct CraftWebView: UIViewRepresentable {
898
1062
  if config.enableHealthKit {
899
1063
  let types = body["types"] as? [String] ?? []
900
1064
  requestHealthAuthorization(types: types, callbackId: callbackId)
1065
+ } else {
1066
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
901
1067
  }
902
1068
  case "getHealthData":
903
- if config.enableHealthKit,
904
- let dataType = body["type"] as? String {
905
- let startDate = body["startDate"] as? Double
906
- let endDate = body["endDate"] as? Double
907
- getHealthData(type: dataType, startDate: startDate, endDate: endDate, callbackId: callbackId)
1069
+ if config.enableHealthKit {
1070
+ if let dataType = body["type"] as? String {
1071
+ let startDate = body["startDate"] as? Double
1072
+ let endDate = body["endDate"] as? Double
1073
+ getHealthData(type: dataType, startDate: startDate, endDate: endDate, callbackId: callbackId)
1074
+ } else {
1075
+ rejectCallback(callbackId, error: "getHealthData was called without the values it needs", code: "INVALID_ARGUMENT")
1076
+ }
1077
+ } else {
1078
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
908
1079
  }
909
1080
  case "saveHealthWorkout":
910
1081
  if config.enableHealthKit {
@@ -929,38 +1100,62 @@ struct CraftWebView: UIViewRepresentable {
929
1100
  case "takeScreenshot":
930
1101
  if config.enableScreenCapture {
931
1102
  takeScreenshot(callbackId: callbackId)
1103
+ } else {
1104
+ rejectCallback(callbackId, error: "Screen capture is disabled", code: "CAPABILITY_DISABLED")
932
1105
  }
933
1106
 
934
1107
  // MARK: - Background Tasks
935
1108
  case "registerBackgroundTask":
936
- if config.enableBackgroundTasks,
937
- let taskId = body["taskId"] as? String {
938
- registerBackgroundTask(taskId: taskId, callbackId: callbackId)
1109
+ if config.enableBackgroundTasks {
1110
+ if let taskId = body["taskId"] as? String {
1111
+ registerBackgroundTask(taskId: taskId, callbackId: callbackId)
1112
+ } else {
1113
+ rejectCallback(callbackId, error: "registerBackgroundTask was called without the values it needs", code: "INVALID_ARGUMENT")
1114
+ }
1115
+ } else {
1116
+ rejectCallback(callbackId, error: "Background tasks are disabled", code: "CAPABILITY_DISABLED")
939
1117
  }
940
1118
  case "scheduleBackgroundTask":
941
- if config.enableBackgroundTasks,
942
- let taskId = body["taskId"] as? String {
943
- let delay = body["delay"] as? Double ?? 900 // 15 minutes default
944
- let requiresNetwork = body["requiresNetwork"] as? Bool ?? false
945
- let requiresCharging = body["requiresCharging"] as? Bool ?? false
946
- scheduleBackgroundTask(taskId: taskId, delay: delay, requiresNetwork: requiresNetwork, requiresCharging: requiresCharging, callbackId: callbackId)
1119
+ if config.enableBackgroundTasks {
1120
+ if let taskId = body["taskId"] as? String {
1121
+ let delay = body["delay"] as? Double ?? 900 // 15 minutes default
1122
+ let requiresNetwork = body["requiresNetwork"] as? Bool ?? false
1123
+ let requiresCharging = body["requiresCharging"] as? Bool ?? false
1124
+ scheduleBackgroundTask(taskId: taskId, delay: delay, requiresNetwork: requiresNetwork, requiresCharging: requiresCharging, callbackId: callbackId)
1125
+ } else {
1126
+ rejectCallback(callbackId, error: "scheduleBackgroundTask was called without the values it needs", code: "INVALID_ARGUMENT")
1127
+ }
1128
+ } else {
1129
+ rejectCallback(callbackId, error: "Background tasks are disabled", code: "CAPABILITY_DISABLED")
947
1130
  }
948
1131
  case "cancelBackgroundTask":
949
- if config.enableBackgroundTasks,
950
- let taskId = body["taskId"] as? String {
951
- cancelBackgroundTask(taskId: taskId, callbackId: callbackId)
1132
+ if config.enableBackgroundTasks {
1133
+ if let taskId = body["taskId"] as? String {
1134
+ cancelBackgroundTask(taskId: taskId, callbackId: callbackId)
1135
+ } else {
1136
+ rejectCallback(callbackId, error: "cancelBackgroundTask was called without the values it needs", code: "INVALID_ARGUMENT")
1137
+ }
1138
+ } else {
1139
+ rejectCallback(callbackId, error: "Background tasks are disabled", code: "CAPABILITY_DISABLED")
952
1140
  }
953
1141
  case "cancelAllBackgroundTasks":
954
1142
  if config.enableBackgroundTasks {
955
1143
  cancelAllBackgroundTasks(callbackId: callbackId)
1144
+ } else {
1145
+ rejectCallback(callbackId, error: "Background tasks are disabled", code: "CAPABILITY_DISABLED")
956
1146
  }
957
1147
 
958
1148
  // MARK: - PDF Viewer
959
1149
  case "openPDF":
960
- if config.enablePDFViewer,
961
- let source = body["source"] as? String {
962
- let page = body["page"] as? Int ?? 0
963
- openPDF(source: source, page: page, callbackId: callbackId)
1150
+ if config.enablePDFViewer {
1151
+ if let source = body["source"] as? String {
1152
+ let page = body["page"] as? Int ?? 0
1153
+ openPDF(source: source, page: page, callbackId: callbackId)
1154
+ } else {
1155
+ rejectCallback(callbackId, error: "openPDF was called without the values it needs", code: "INVALID_ARGUMENT")
1156
+ }
1157
+ } else {
1158
+ rejectCallback(callbackId, error: "PDF viewing is disabled", code: "CAPABILITY_DISABLED")
964
1159
  }
965
1160
  case "closePDF":
966
1161
  closePDF(callbackId: callbackId)
@@ -970,6 +1165,8 @@ struct CraftWebView: UIViewRepresentable {
970
1165
  if config.enableContacts {
971
1166
  let multiple = body["multiple"] as? Bool ?? false
972
1167
  pickContact(multiple: multiple, callbackId: callbackId)
1168
+ } else {
1169
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
973
1170
  }
974
1171
 
975
1172
  // MARK: - App Shortcuts
@@ -1014,44 +1211,74 @@ struct CraftWebView: UIViewRepresentable {
1014
1211
  if config.enableAR {
1015
1212
  let options = body["options"] as? [String: Any] ?? [:]
1016
1213
  startAR(options: options, callbackId: callbackId)
1214
+ } else {
1215
+ rejectCallback(callbackId, error: "AR is disabled", code: "CAPABILITY_DISABLED")
1017
1216
  }
1018
1217
  case "stopAR":
1019
1218
  if config.enableAR {
1020
1219
  stopAR(callbackId: callbackId)
1220
+ } else {
1221
+ rejectCallback(callbackId, error: "AR is disabled", code: "CAPABILITY_DISABLED")
1021
1222
  }
1022
1223
  case "placeARObject":
1023
- if config.enableAR,
1024
- let model = body["model"] as? String {
1025
- let position = body["position"] as? [String: Double]
1026
- placeARObject(model: model, position: position, callbackId: callbackId)
1224
+ if config.enableAR {
1225
+ if let model = body["model"] as? String {
1226
+ let position = body["position"] as? [String: Double]
1227
+ placeARObject(model: model, position: position, callbackId: callbackId)
1228
+ } else {
1229
+ rejectCallback(callbackId, error: "placeARObject was called without the values it needs", code: "INVALID_ARGUMENT")
1230
+ }
1231
+ } else {
1232
+ rejectCallback(callbackId, error: "AR is disabled", code: "CAPABILITY_DISABLED")
1027
1233
  }
1028
1234
  case "removeARObject":
1029
- if config.enableAR,
1030
- let objectId = body["objectId"] as? String {
1031
- removeARObject(objectId: objectId, callbackId: callbackId)
1235
+ if config.enableAR {
1236
+ if let objectId = body["objectId"] as? String {
1237
+ removeARObject(objectId: objectId, callbackId: callbackId)
1238
+ } else {
1239
+ rejectCallback(callbackId, error: "removeARObject was called without the values it needs", code: "INVALID_ARGUMENT")
1240
+ }
1241
+ } else {
1242
+ rejectCallback(callbackId, error: "AR is disabled", code: "CAPABILITY_DISABLED")
1032
1243
  }
1033
1244
  case "getARPlanes":
1034
1245
  if config.enableAR {
1035
1246
  getARPlanes(callbackId: callbackId)
1247
+ } else {
1248
+ rejectCallback(callbackId, error: "AR is disabled", code: "CAPABILITY_DISABLED")
1036
1249
  }
1037
1250
 
1038
1251
  // MARK: - ML (Core ML / Vision)
1039
1252
  case "classifyImage":
1040
- if config.enableMLKit,
1041
- let imageBase64 = body["image"] as? String {
1042
- classifyImage(imageBase64: imageBase64, callbackId: callbackId)
1253
+ if config.enableMLKit {
1254
+ if let imageBase64 = body["image"] as? String {
1255
+ classifyImage(imageBase64: imageBase64, callbackId: callbackId)
1256
+ } else {
1257
+ rejectCallback(callbackId, error: "classifyImage was called without the values it needs", code: "INVALID_ARGUMENT")
1258
+ }
1259
+ } else {
1260
+ rejectCallback(callbackId, error: "Vision is disabled", code: "CAPABILITY_DISABLED")
1043
1261
  }
1044
1262
  case "detectObjects":
1045
- if config.enableMLKit,
1046
- let imageBase64 = body["image"] as? String {
1047
- detectObjects(imageBase64: imageBase64, callbackId: callbackId)
1263
+ if config.enableMLKit {
1264
+ if let imageBase64 = body["image"] as? String {
1265
+ detectObjects(imageBase64: imageBase64, callbackId: callbackId)
1266
+ } else {
1267
+ rejectCallback(callbackId, error: "detectObjects was called without the values it needs", code: "INVALID_ARGUMENT")
1268
+ }
1269
+ } else {
1270
+ rejectCallback(callbackId, error: "Vision is disabled", code: "CAPABILITY_DISABLED")
1048
1271
  }
1049
1272
  case "recognizeText":
1050
- if config.enableMLKit,
1051
- let imageBase64 = body["image"] as? String {
1052
- recognizeText(imageBase64: imageBase64, callbackId: callbackId)
1273
+ if config.enableMLKit {
1274
+ if let imageBase64 = body["image"] as? String {
1275
+ recognizeText(imageBase64: imageBase64, callbackId: callbackId)
1276
+ } else {
1277
+ rejectCallback(callbackId, error: "recognizeText was called without the values it needs", code: "INVALID_ARGUMENT")
1278
+ }
1279
+ } else {
1280
+ rejectCallback(callbackId, error: "Vision is disabled", code: "CAPABILITY_DISABLED")
1053
1281
  }
1054
-
1055
1282
  // MARK: - Widget
1056
1283
  case "updateWidget":
1057
1284
  if let data = body["data"] as? [String: Any] {
@@ -1063,11 +1290,11 @@ struct CraftWebView: UIViewRepresentable {
1063
1290
  // MARK: - Siri Shortcuts
1064
1291
  case "registerSiriShortcut":
1065
1292
  if let phrase = body["phrase"] as? String,
1066
- let action = body["action"] as? String {
1293
+ let action = body["shortcutAction"] as? String {
1067
1294
  registerSiriShortcut(phrase: phrase, action: action, callbackId: callbackId)
1068
1295
  }
1069
1296
  case "removeSiriShortcut":
1070
- if let action = body["action"] as? String {
1297
+ if let action = body["shortcutAction"] as? String {
1071
1298
  removeSiriShortcut(action: action, callbackId: callbackId)
1072
1299
  }
1073
1300
 
@@ -2164,7 +2391,15 @@ struct CraftWebView: UIViewRepresentable {
2164
2391
  register: function(phrase, action) {
2165
2392
  var self = window.craft;
2166
2393
  var id = 'cb_' + (++self._callbackId);
2167
- window.webkit.messageHandlers.craft.postMessage({action: 'registerSiriShortcut', phrase: phrase, action: action, callbackId: id});
2394
+ // `shortcutAction`, not `action`. This object had two
2395
+ // keys called `action` — the message's own, and the
2396
+ // shortcut's — and the later one wins, so every call
2397
+ // arrived labelled with the shortcut's identifier
2398
+ // instead of 'registerSiriShortcut'. No switch arm
2399
+ // matched, nothing answered, and these wrappers park in
2400
+ // `_callbacks` with no timeout: the promise never
2401
+ // settled at all.
2402
+ window.webkit.messageHandlers.craft.postMessage({action: 'registerSiriShortcut', phrase: phrase, shortcutAction: action, callbackId: id});
2168
2403
  return new Promise(function(resolve, reject) {
2169
2404
  self._callbacks[id] = {resolve: resolve, reject: reject};
2170
2405
  });
@@ -2172,7 +2407,8 @@ struct CraftWebView: UIViewRepresentable {
2172
2407
  remove: function(action) {
2173
2408
  var self = window.craft;
2174
2409
  var id = 'cb_' + (++self._callbackId);
2175
- window.webkit.messageHandlers.craft.postMessage({action: 'removeSiriShortcut', action: action, callbackId: id});
2410
+ // The same duplicate key as `register` above.
2411
+ window.webkit.messageHandlers.craft.postMessage({action: 'removeSiriShortcut', shortcutAction: action, callbackId: id});
2176
2412
  return new Promise(function(resolve, reject) {
2177
2413
  self._callbacks[id] = {resolve: resolve, reject: reject};
2178
2414
  });
@@ -2421,6 +2657,27 @@ struct CraftWebView: UIViewRepresentable {
2421
2657
  }
2422
2658
  })(window.craft);
2423
2659
 
2660
+ // The reply route for actions the Zig runtime serves.
2661
+ //
2662
+ // Zig owns one wire format and calls these two functions by name;
2663
+ // this page owns `_callbacks`, keyed by the 'cb_<n>' ids `_invoke`
2664
+ // hands out. The whole adaptation is turning the numeric id Zig
2665
+ // carries back into that key. Nothing else is translated, because
2666
+ // nothing else differs: `craftSpeechStart` and friends already
2667
+ // arrive as plain CustomEvents from both sides.
2668
+ //
2669
+ // A null id means the page sent no callback — the tray-style
2670
+ // fire-and-forget posts — so there is nothing to settle and
2671
+ // dropping it is correct rather than lossy.
2672
+ window.__craftBridgeResult = function (action, result, id) {
2673
+ if (id === null || id === undefined) return;
2674
+ window.craft._resolveCallback('cb_' + id, result);
2675
+ };
2676
+ window.__craftBridgeError = function (ctx) {
2677
+ if (!ctx || ctx.id === null || ctx.id === undefined) return;
2678
+ window.craft._rejectCallback('cb_' + ctx.id, ctx.message, ctx.code);
2679
+ };
2680
+
2424
2681
  // Dispatch ready event
2425
2682
  window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
2426
2683
  console.log('Craft iOS bridge initialized');
@@ -5193,6 +5450,147 @@ extension CraftWebView.Coordinator: WCSessionDelegate {
5193
5450
 
5194
5451
  // MARK: - Zig hand-off shim
5195
5452
 
5453
+ /// The seam the page's messages reach the Zig runtime through.
5454
+ ///
5455
+ /// The opposite direction from `CraftSwiftShim` below, and the two together
5456
+ /// are the whole hand-off: this class offers each message to Zig, Zig serves
5457
+ /// what it has migrated and declines the rest, and for anything Zig *did*
5458
+ /// take that needs Swift work, `CraftSwiftShim` is how it comes back.
5459
+ ///
5460
+ /// Discovery is `dlsym`, exactly as the shim's is, and for the same reason:
5461
+ /// neither side links the other. An app built without the Zig static library
5462
+ /// finds nothing here, `offer` answers false for every action, and the
5463
+ /// coordinator's switch serves the entire surface as it always has. There is
5464
+ /// no build flag and no second code path — the absence of a symbol is the
5465
+ /// off switch.
5466
+ @objc(CraftZigRuntime)
5467
+ final class CraftZigRuntime: NSObject {
5468
+ private typealias SetWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
5469
+ private typealias ClearWebViewFn = @convention(c) (UnsafeMutableRawPointer?) -> Void
5470
+ private typealias HandleActionFn = @convention(c) (
5471
+ UnsafePointer<CChar>, UInt, UnsafePointer<CChar>, UInt, Int64
5472
+ ) -> Bool
5473
+ private typealias AdoptLocationRecordingFn = @convention(c) () -> Bool
5474
+
5475
+ private static let image: UnsafeMutableRawPointer? = dlopen(nil, RTLD_NOW)
5476
+
5477
+ private static let setWebViewFn: SetWebViewFn? = {
5478
+ guard let sym = dlsym(image, "craft_ios_set_webview") else { return nil }
5479
+ return unsafeBitCast(sym, to: SetWebViewFn.self)
5480
+ }()
5481
+
5482
+ private static let clearWebViewFn: ClearWebViewFn? = {
5483
+ guard let sym = dlsym(image, "craft_ios_clear_webview") else { return nil }
5484
+ return unsafeBitCast(sym, to: ClearWebViewFn.self)
5485
+ }()
5486
+
5487
+ private static let handleActionFn: HandleActionFn? = {
5488
+ guard let sym = dlsym(image, "craft_ios_handle_action") else { return nil }
5489
+ return unsafeBitCast(sym, to: HandleActionFn.self)
5490
+ }()
5491
+
5492
+ private static let adoptLocationRecordingFn: AdoptLocationRecordingFn? = {
5493
+ guard let sym = dlsym(image, "craft_ios_adopt_location_recording") else { return nil }
5494
+ return unsafeBitCast(sym, to: AdoptLocationRecordingFn.self)
5495
+ }()
5496
+
5497
+ /// Whether this build has a Zig runtime at all. Read by nothing here; kept
5498
+ /// because "is Zig linked" is the first question to ask when an action
5499
+ /// behaves like the Swift one after a migration was supposed to move it.
5500
+ @objc static var isLinked: Bool { handleActionFn != nil }
5501
+
5502
+ /// Give Zig the webview its replies are evaluated against.
5503
+ ///
5504
+ /// Unretained on purpose, on both sides: this is the app's own root view
5505
+ /// and it outlives the runtime. A retain here would be a cycle with
5506
+ /// nothing to break it.
5507
+ static func attach(_ webView: WKWebView) {
5508
+ setWebViewFn?(Unmanaged.passUnretained(webView).toOpaque())
5509
+ }
5510
+
5511
+ /// Hand a recording that outlived the last launch to whichever runtime owns
5512
+ /// the recorder.
5513
+ ///
5514
+ /// Returns true when Zig took it, and the caller must then *not* run
5515
+ /// `restoreLocationRecordingState()`. This is the one place the usual
5516
+ /// "offer it to Zig, fall back on false" pattern cannot be used through
5517
+ /// `handleAction`: there is no page message at launch, and the restore
5518
+ /// happens in `Coordinator.init` — before `attach`, because SwiftUI builds
5519
+ /// the coordinator before the view. Whoever restores first owns the
5520
+ /// `CLLocationManager` for the rest of the launch, so the decision has to
5521
+ /// be made here rather than at the first `stopLocationRecording`.
5522
+ ///
5523
+ /// False in a build with no Zig runtime, which is exactly when Swift's own
5524
+ /// restore is still the right thing to run.
5525
+ static func adoptLocationRecording() -> Bool {
5526
+ adoptLocationRecordingFn?() ?? false
5527
+ }
5528
+
5529
+ /// Forget this webview, if Zig is still holding it.
5530
+ ///
5531
+ /// `attach` hands over an unretained pointer, so the moment SwiftUI
5532
+ /// deallocates the view Zig is holding freed memory and every later reply
5533
+ /// is an `objc_msgSend` into it. Nothing called this before, and nothing
5534
+ /// had to while the app had exactly one webview for its whole life — but
5535
+ /// a `UIViewRepresentable` is rebuilt whenever its identity changes, and
5536
+ /// the second rebuild is where that assumption stops holding.
5537
+ ///
5538
+ /// The webview is passed rather than implied: Zig clears only if this is
5539
+ /// still the pointer it has, so a rebuild that makes the replacement
5540
+ /// before dismantling the original cannot blank the live one.
5541
+ static func detach(_ webView: WKWebView) {
5542
+ clearWebViewFn?(Unmanaged.passUnretained(webView).toOpaque())
5543
+ }
5544
+
5545
+ /// Offer one page message to the Zig dispatcher.
5546
+ ///
5547
+ /// Returns true when Zig has taken responsibility — the caller must not
5548
+ /// answer as well. False when no Zig module recognised the action, or when
5549
+ /// there is no runtime to ask.
5550
+ static func offer(action: String, body: [String: Any], callbackId: String?) -> Bool {
5551
+ guard let handleActionFn else { return false }
5552
+
5553
+ // `_invoke` flattens the payload into the message —
5554
+ // `Object.assign({}, payload, {action, callbackId})` — so the payload
5555
+ // Zig's handlers parse is the message minus the two envelope keys.
5556
+ // This is the exact inverse of what `CraftSwiftShim.handleAction` does
5557
+ // when a call travels the other way, and the two must stay inverses:
5558
+ // leaving `action` in would put a key in the payload no handler
5559
+ // expects, and dropping a real field would hand a handler defaults the
5560
+ // page never asked for.
5561
+ var payload = body
5562
+ payload.removeValue(forKey: "action")
5563
+ payload.removeValue(forKey: "callbackId")
5564
+
5565
+ // A payload that will not serialise is not offered. Falling through to
5566
+ // the Swift switch is the safe direction: it reads `body` directly and
5567
+ // never needs the round trip through JSON that just failed.
5568
+ guard JSONSerialization.isValidJSONObject(payload),
5569
+ let data = try? JSONSerialization.data(withJSONObject: payload),
5570
+ let json = String(data: data, encoding: .utf8)
5571
+ else { return false }
5572
+
5573
+ return action.withCString { a in
5574
+ json.withCString { p in
5575
+ handleActionFn(a, UInt(strlen(a)), p, UInt(strlen(p)), requestId(from: callbackId))
5576
+ }
5577
+ }
5578
+ }
5579
+
5580
+ /// "cb_7" -> 7, and -1 for a message with no callback waiting on it.
5581
+ ///
5582
+ /// -1 rather than 0, matching what `craft_ios_deliver_result` already
5583
+ /// treats as "no id": zero is a perfectly good callback number, and a
5584
+ /// sentinel that collides with a real id would deliver one caller's answer
5585
+ /// to another.
5586
+ private static func requestId(from callbackId: String?) -> Int64 {
5587
+ guard let callbackId, callbackId.hasPrefix("cb_"),
5588
+ let n = Int64(callbackId.dropFirst(3))
5589
+ else { return -1 }
5590
+ return n
5591
+ }
5592
+ }
5593
+
5196
5594
  /// The seam the Zig dispatcher hands unmigrated actions through.
5197
5595
  ///
5198
5596
  /// Discovery is symmetric and both directions are runtime-only. Zig finds this