craft-native 0.0.89 → 0.0.91

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.
Files changed (29) hide show
  1. package/dist/android/src/index.js +285 -44
  2. package/dist/android/src/promise-runtime.d.ts +3 -0
  3. package/dist/android/templates/CraftBridge.kt.template +1951 -1193
  4. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  5. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  6. package/dist/android/templates/CraftNative.kt.template +2231 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  8. package/dist/android/templates/MainActivity.kt.template +25 -2
  9. package/dist/android/templates/proguard-rules.pro.template +4 -1
  10. package/dist/android/templates/test-bridges.html +10 -33
  11. package/dist/api/index.d.ts +1 -1
  12. package/dist/api/ios-advanced.d.ts +8 -5
  13. package/dist/api/live-activity-handle.d.ts +6 -0
  14. package/dist/api/mobile.d.ts +13 -5
  15. package/dist/api/window.d.ts +2 -0
  16. package/dist/cli.js +404 -128
  17. package/dist/index.cjs +65 -17
  18. package/dist/index.js +65 -17
  19. package/dist/ios/src/index.js +22 -4
  20. package/dist/ios/templates/CraftApp.swift +473 -60
  21. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  22. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  23. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  24. package/dist/ios/templates/project.yml.template +10 -4
  25. package/dist/mobile.js +36 -13
  26. package/dist/scaffold-version.d.ts +5 -0
  27. package/package.json +1 -1
  28. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  29. package/dist/android/templates/CraftWidgetProvider.kt.template +0 -246
@@ -40,9 +40,24 @@ extension Notification.Name {
40
40
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
41
41
  func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
42
42
  UNUserNotificationCenter.current().delegate = self
43
+ if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
44
+ CraftEventManager.shared.handleShortcut(shortcut)
45
+ // Returning false tells UIKit the launch-time item was handled and
46
+ // prevents a second performActionFor callback for the same tap.
47
+ return false
48
+ }
43
49
  return true
44
50
  }
45
51
 
52
+ func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
53
+ CraftEventManager.shared.handleShortcut(shortcutItem)
54
+ completionHandler(true)
55
+ }
56
+
57
+ func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
58
+ CraftEventManager.shared.handleSiriActivity(userActivity)
59
+ }
60
+
46
61
  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
47
62
  let token = deviceToken.map { String(format: "%02x", $0) }.joined()
48
63
  NotificationCenter.default.post(name: .craftPushToken, object: token)
@@ -62,6 +77,71 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
62
77
  }
63
78
  }
64
79
 
80
+ // MARK: - Native Event Manager
81
+ class CraftEventManager {
82
+ static let shared = CraftEventManager()
83
+
84
+ private weak var webView: WKWebView?
85
+ private var isReady = false
86
+ private var pendingEvents: [(name: String, data: [String: Any])] = []
87
+
88
+ private init() {}
89
+
90
+ func setWebView(_ webView: WKWebView) {
91
+ self.webView = webView
92
+ }
93
+
94
+ func setLoading() {
95
+ isReady = false
96
+ }
97
+
98
+ func setReady() {
99
+ isReady = true
100
+ let events = pendingEvents
101
+ pendingEvents.removeAll()
102
+ for event in events {
103
+ dispatch(event.name, data: event.data)
104
+ }
105
+ }
106
+
107
+ func handleShortcut(_ shortcut: UIApplicationShortcutItem) {
108
+ sendToWeb("craftShortcut", data: ["type": shortcut.type])
109
+ }
110
+
111
+ func handleSiriActivity(_ activity: NSUserActivity) -> Bool {
112
+ let prefix = "\(Bundle.main.bundleIdentifier ?? "{{BUNDLE_ID}}")."
113
+ guard activity.activityType.hasPrefix(prefix) else { return false }
114
+
115
+ let action = activity.userInfo?["action"] as? String
116
+ ?? String(activity.activityType.dropFirst(prefix.count))
117
+ var data: [String: Any] = [:]
118
+ for (key, value) in activity.userInfo ?? [:] {
119
+ guard let key = key as? String, key != "action" else { continue }
120
+ data[key] = value
121
+ }
122
+ sendToWeb("craftSiriShortcut", data: ["action": action, "data": data])
123
+ return true
124
+ }
125
+
126
+ private func sendToWeb(_ event: String, data: [String: Any]) {
127
+ guard isReady, webView != nil else {
128
+ pendingEvents.append((event, data))
129
+ return
130
+ }
131
+ dispatch(event, data: data)
132
+ }
133
+
134
+ private func dispatch(_ event: String, data: [String: Any]) {
135
+ guard let webView = webView,
136
+ let jsonData = try? JSONSerialization.data(withJSONObject: data),
137
+ let json = String(data: jsonData, encoding: .utf8) else { return }
138
+ let script = "window.dispatchEvent(new CustomEvent('\(event)', {detail: \(json)}));"
139
+ DispatchQueue.main.async {
140
+ webView.evaluateJavaScript(script, completionHandler: nil)
141
+ }
142
+ }
143
+ }
144
+
65
145
  // MARK: - App Entry Point
66
146
  @main
67
147
  struct CraftApp: App {
@@ -117,7 +197,7 @@ class DeepLinkManager {
117
197
  initialURL = url
118
198
  }
119
199
 
120
- if isReady, let webView = webView {
200
+ if isReady && webView != nil {
121
201
  dispatchDeepLink(url)
122
202
  } else {
123
203
  // Store for later when web view is ready
@@ -346,6 +426,7 @@ struct CraftWebView: UIViewRepresentable {
346
426
 
347
427
  // Register with DeepLinkManager
348
428
  DeepLinkManager.shared.setWebView(webView)
429
+ CraftEventManager.shared.setWebView(webView)
349
430
 
350
431
  // Parse background color
351
432
  let bgColor = UIColor(hex: config.backgroundColor) ?? .black
@@ -400,7 +481,9 @@ struct CraftWebView: UIViewRepresentable {
400
481
  // Location
401
482
  private var locationManager: CLLocationManager?
402
483
  private var singleLocationCallbackId: String?
484
+ private var singleLocationTimeoutWorkItem: DispatchWorkItem?
403
485
  private var locationPermissionCallbackId: String?
486
+ private var locationPermissionRequiresAlways = false
404
487
  private var isWatchingLocation = false
405
488
  private var isRecordingLocation = false
406
489
  private var isLocationRecordingPaused = false
@@ -683,7 +766,7 @@ struct CraftWebView: UIViewRepresentable {
683
766
  // Geolocation
684
767
  case "getCurrentPosition":
685
768
  if config.enableGeolocation {
686
- getCurrentPosition(callbackId: callbackId)
769
+ getCurrentPosition(body: body, callbackId: callbackId)
687
770
  } else {
688
771
  rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
689
772
  }
@@ -1094,7 +1177,7 @@ struct CraftWebView: UIViewRepresentable {
1094
1177
  case "updateLiveActivity":
1095
1178
  updateLiveActivity(body: body, callbackId: callbackId)
1096
1179
  case "endLiveActivity":
1097
- endLiveActivity(callbackId: callbackId)
1180
+ endLiveActivity(body: body, callbackId: callbackId)
1098
1181
 
1099
1182
  // MARK: - Screen Capture
1100
1183
  case "takeScreenshot":
@@ -1315,6 +1398,10 @@ struct CraftWebView: UIViewRepresentable {
1315
1398
  }
1316
1399
  }
1317
1400
 
1401
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
1402
+ CraftEventManager.shared.setLoading()
1403
+ }
1404
+
1318
1405
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1319
1406
  self.webView = webView
1320
1407
  guard isTrustedURL(webView.url) else { return }
@@ -1782,6 +1869,114 @@ struct CraftWebView: UIViewRepresentable {
1782
1869
  };
1783
1870
  },
1784
1871
 
1872
+ // Flat SDK compatibility methods. The native dispatcher already
1873
+ // owns these actions; keep the public CraftBridge shape callable
1874
+ // while the versioned API below provides the namespaced form.
1875
+ scanNFC: function() {
1876
+ return this._invoke('scanNFC');
1877
+ },
1878
+ scanQRCode: function() {
1879
+ return this._invoke('scanQRCode');
1880
+ },
1881
+ takeScreenshot: function() {
1882
+ return this._invoke('takeScreenshot');
1883
+ },
1884
+ startAudioRecording: function() {
1885
+ return this._invoke('startAudioRecording');
1886
+ },
1887
+ stopAudioRecording: function() {
1888
+ return this._invoke('stopAudioRecording');
1889
+ },
1890
+ startVideoRecording: function() {
1891
+ return this._invoke('startVideoRecording');
1892
+ },
1893
+ pickFile: function(types) {
1894
+ return this._invoke('pickFile', {types: types || []});
1895
+ },
1896
+ downloadFile: function(url, filename) {
1897
+ return this._invoke('downloadFile', {url: url, filename: filename});
1898
+ },
1899
+ saveFile: function(data, filename, mimeType) {
1900
+ return this._invoke('saveFile', {data: data, filename: filename, mimeType: mimeType});
1901
+ },
1902
+ startMotionUpdates: function() {
1903
+ return this._invoke('startMotionUpdates');
1904
+ },
1905
+ stopMotionUpdates: function() {
1906
+ return this._invoke('stopMotionUpdates');
1907
+ },
1908
+ getCurrentPosition: function() {
1909
+ return this.geolocation.getCurrentPosition({});
1910
+ },
1911
+ watchPosition: function(callback) {
1912
+ return this.geolocation.watchPosition(callback);
1913
+ },
1914
+ clearWatch: function(watchId) {
1915
+ return this.geolocation.clearWatch(watchId);
1916
+ },
1917
+ getContacts: function() {
1918
+ return this._invoke('getContacts');
1919
+ },
1920
+ addContact: function(contact) {
1921
+ return this._invoke('addContact', {contact: contact});
1922
+ },
1923
+ getCalendarEvents: function(startDate, endDate) {
1924
+ return this._invoke('getCalendarEvents', {startDate: startDate, endDate: endDate});
1925
+ },
1926
+ createCalendarEvent: function(event) {
1927
+ return this._invoke('createCalendarEvent', {event: event});
1928
+ },
1929
+ deleteCalendarEvent: function(eventId) {
1930
+ return this._invoke('deleteCalendarEvent', {eventId: eventId});
1931
+ },
1932
+ scheduleNotification: function(notification) {
1933
+ return this._invoke('scheduleNotification', {notification: notification});
1934
+ },
1935
+ cancelNotification: function(id) {
1936
+ return this._invoke('cancelNotification', {id: id});
1937
+ },
1938
+ cancelAllNotifications: function() {
1939
+ return this._invoke('cancelAllNotifications');
1940
+ },
1941
+ getPendingNotifications: function() {
1942
+ return this._invoke('getPendingNotifications');
1943
+ },
1944
+ getProducts: function(productIds) {
1945
+ return this.iap.getProducts(productIds);
1946
+ },
1947
+ purchase: function(productId) {
1948
+ return this.iap.purchase(productId);
1949
+ },
1950
+ restorePurchases: function() {
1951
+ return this._invoke('restorePurchases');
1952
+ },
1953
+ signInWithApple: function() {
1954
+ return this._invoke('signInWithApple');
1955
+ },
1956
+ signInWithGoogle: function() {
1957
+ return Promise.reject(new Error('Google Sign-In is unavailable on iOS'));
1958
+ },
1959
+ startBluetoothScan: function() {
1960
+ return this._invoke('startBluetoothScan');
1961
+ },
1962
+ stopBluetoothScan: function() {
1963
+ return this._invoke('stopBluetoothScan');
1964
+ },
1965
+ requestHealthAuthorization: function(types) {
1966
+ return this._invoke('requestHealthAuthorization', {types: types || []});
1967
+ },
1968
+ getHealthData: function(type, startDate, endDate) {
1969
+ var start = startDate instanceof Date ? startDate.getTime() : startDate;
1970
+ var end = endDate instanceof Date ? endDate.getTime() : endDate;
1971
+ return this._invoke('getHealthData', {type: type, startDate: start, endDate: end});
1972
+ },
1973
+ requestFitnessAuthorization: function() {
1974
+ return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
1975
+ },
1976
+ getFitnessData: function() {
1977
+ return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
1978
+ },
1979
+
1785
1980
  haptic: function(style) {
1786
1981
  window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium'});
1787
1982
  },
@@ -1863,12 +2058,58 @@ struct CraftWebView: UIViewRepresentable {
1863
2058
 
1864
2059
  // Geolocation
1865
2060
  geolocation: {
1866
- getCurrentPosition: function() {
2061
+ getCurrentPosition: function(options) {
2062
+ options = options || {};
1867
2063
  var self = window.craft;
1868
2064
  var id = 'cb_' + (++self._callbackId);
1869
- window.webkit.messageHandlers.craft.postMessage({action: 'getCurrentPosition', callbackId: id});
2065
+ var requestedTimeout = Number(options.timeout);
2066
+ var timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout >= 0
2067
+ ? Math.min(requestedTimeout, 2147483647)
2068
+ : 30000;
1870
2069
  return new Promise(function(resolve, reject) {
1871
- self._callbacks[id] = {resolve: resolve, reject: reject};
2070
+ var timeout;
2071
+ self._callbacks[id] = {
2072
+ resolve: function(value) { clearTimeout(timeout); resolve(value); },
2073
+ reject: function(error) {
2074
+ clearTimeout(timeout);
2075
+ var locationErrorCode = error && ({
2076
+ 'PERMISSION_DENIED': 1,
2077
+ 'POSITION_UNAVAILABLE': 2,
2078
+ 'NATIVE_CALL_FAILED': 2,
2079
+ 'TIMEOUT': 3,
2080
+ 'LOCATION_TIMEOUT': 3
2081
+ })[error.code];
2082
+ if (locationErrorCode) {
2083
+ error.name = 'GeolocationPositionError';
2084
+ error.code = locationErrorCode;
2085
+ }
2086
+ reject(error);
2087
+ }
2088
+ };
2089
+ timeout = setTimeout(function() {
2090
+ if (!self._callbacks[id]) return;
2091
+ delete self._callbacks[id];
2092
+ var error = new Error('Location request timed out after ' + timeoutMs + 'ms');
2093
+ error.name = 'GeolocationPositionError';
2094
+ error.code = 3;
2095
+ error.bridge = true;
2096
+ reject(error);
2097
+ }, timeoutMs);
2098
+ try {
2099
+ window.webkit.messageHandlers.craft.postMessage({
2100
+ action: 'getCurrentPosition',
2101
+ callbackId: id,
2102
+ enableHighAccuracy: options.enableHighAccuracy === true,
2103
+ timeout: timeoutMs,
2104
+ maximumAge: Number.isFinite(Number(options.maximumAge))
2105
+ ? Math.max(0, Number(options.maximumAge))
2106
+ : 0
2107
+ });
2108
+ } catch (error) {
2109
+ clearTimeout(timeout);
2110
+ delete self._callbacks[id];
2111
+ reject(error);
2112
+ }
1872
2113
  });
1873
2114
  },
1874
2115
  watchPosition: function(callback) {
@@ -1938,7 +2179,14 @@ struct CraftWebView: UIViewRepresentable {
1938
2179
  });
1939
2180
  },
1940
2181
  onNetworkChange: function(callback) {
1941
- window.addEventListener('craftNetworkChange', function(e) { callback(e.detail); });
2182
+ this.offNetworkChange();
2183
+ this._networkChangeHandler = function(e) { callback(e.detail); };
2184
+ window.addEventListener('craftNetworkChange', this._networkChangeHandler);
2185
+ },
2186
+ offNetworkChange: function() {
2187
+ if (!this._networkChangeHandler) return;
2188
+ window.removeEventListener('craftNetworkChange', this._networkChangeHandler);
2189
+ this._networkChangeHandler = null;
1942
2190
  },
1943
2191
 
1944
2192
  // App review
@@ -1957,9 +2205,15 @@ struct CraftWebView: UIViewRepresentable {
1957
2205
  var id = 'cb_' + (++this._callbackId);
1958
2206
  window.webkit.messageHandlers.craft.postMessage({action: 'setFlashlight', enabled: enabled, callbackId: id});
1959
2207
  return new Promise(function(resolve, reject) {
1960
- self._callbacks[id] = {resolve: resolve, reject: reject};
2208
+ self._callbacks[id] = {
2209
+ resolve: function(value) { self._flashlightEnabled = enabled; resolve(value); },
2210
+ reject: reject
2211
+ };
1961
2212
  });
1962
2213
  },
2214
+ toggleFlashlight: function() {
2215
+ return this.setFlashlight(!this._flashlightEnabled);
2216
+ },
1963
2217
 
1964
2218
  // Vibrate
1965
2219
  vibrate: function(pattern) {
@@ -1985,6 +2239,18 @@ struct CraftWebView: UIViewRepresentable {
1985
2239
  self._callbacks[id] = {resolve: resolve, reject: reject};
1986
2240
  });
1987
2241
  },
2242
+ onAppStateChange: function(callback) {
2243
+ this.offAppStateChange();
2244
+ this._appStateChangeHandler = function() {
2245
+ callback(document.visibilityState === 'visible' ? 'active' : 'background');
2246
+ };
2247
+ document.addEventListener('visibilitychange', this._appStateChangeHandler);
2248
+ },
2249
+ offAppStateChange: function() {
2250
+ if (!this._appStateChangeHandler) return;
2251
+ document.removeEventListener('visibilitychange', this._appStateChangeHandler);
2252
+ this._appStateChangeHandler = null;
2253
+ },
1988
2254
 
1989
2255
  // Contacts
1990
2256
  contacts: {
@@ -2471,8 +2737,6 @@ struct CraftWebView: UIViewRepresentable {
2471
2737
  ota: {
2472
2738
  _config: null,
2473
2739
  _status: 'idle',
2474
- _progressCallbacks: [],
2475
- _statusCallbacks: [],
2476
2740
 
2477
2741
  // OTA is not implemented natively. These five used to post
2478
2742
  // to actions the switch below does not handle, and register
@@ -2513,12 +2777,10 @@ struct CraftWebView: UIViewRepresentable {
2513
2777
  };
2514
2778
  },
2515
2779
  onProgress: function(callback) {
2516
- this._progressCallbacks.push(callback);
2517
- window.addEventListener('craftOTAProgress', function(e) { callback(e.detail); });
2780
+ throw new Error('craft.ota.onProgress is not implemented on this platform');
2518
2781
  },
2519
2782
  onStatusChange: function(callback) {
2520
- this._statusCallbacks.push(callback);
2521
- window.addEventListener('craftOTAStatus', function(e) { callback(e.detail.status); });
2783
+ throw new Error('craft.ota.onStatusChange is not implemented on this platform');
2522
2784
  }
2523
2785
  },
2524
2786
 
@@ -2629,8 +2891,14 @@ struct CraftWebView: UIViewRepresentable {
2629
2891
  };
2630
2892
  craft.liveActivity = {
2631
2893
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
2632
- update: function(options) { return craft._invoke('updateLiveActivity', options || {}); },
2633
- end: function() { return craft._invoke('endLiveActivity'); }
2894
+ update: function(idOrState, state) {
2895
+ if (typeof idOrState !== 'string') return craft._invoke('updateLiveActivity', idOrState || {});
2896
+ return craft._invoke('updateLiveActivity', Object.assign({}, state || {}, {id: idOrState}));
2897
+ },
2898
+ end: function(id, finalState) {
2899
+ if (typeof id !== 'string') return craft._invoke('endLiveActivity');
2900
+ return craft._invoke('endLiveActivity', Object.assign({}, finalState || {}, {id: id}));
2901
+ }
2634
2902
  };
2635
2903
  var shareApi = function(text) { return legacyShare(text); };
2636
2904
  shareApi.share = function(options) { return craft._invoke('share', {options: options || {}}); };
@@ -2686,6 +2954,7 @@ struct CraftWebView: UIViewRepresentable {
2686
2954
 
2687
2955
  // Mark DeepLinkManager as ready
2688
2956
  DeepLinkManager.shared.setReady()
2957
+ CraftEventManager.shared.setReady()
2689
2958
  }
2690
2959
 
2691
2960
  // MARK: - Callback Helpers
@@ -2899,7 +3168,15 @@ struct CraftWebView: UIViewRepresentable {
2899
3168
  func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
2900
3169
  picker.dismiss(animated: true)
2901
3170
 
2902
- if let image = info[.originalImage] as? UIImage,
3171
+ if let movieURL = info[.mediaURL] as? URL {
3172
+ do {
3173
+ let movieData = try Data(contentsOf: movieURL, options: .mappedIfSafe)
3174
+ let base64 = movieData.base64EncodedString()
3175
+ resolveCallback(pendingCallbackId, result: "data:video/quicktime;base64," + base64)
3176
+ } catch {
3177
+ rejectCallback(pendingCallbackId, error: "Failed to process video: \(error.localizedDescription)")
3178
+ }
3179
+ } else if let image = info[.originalImage] as? UIImage,
2903
3180
  let imageData = image.jpegData(compressionQuality: 0.8) {
2904
3181
  let base64 = imageData.base64EncodedString()
2905
3182
  resolveCallback(pendingCallbackId, result: [
@@ -3036,7 +3313,7 @@ struct CraftWebView: UIViewRepresentable {
3036
3313
  }
3037
3314
  switch permission {
3038
3315
  case "location", "locationAlways":
3039
- let status = CLLocationManager.authorizationStatus()
3316
+ let status = (locationManager ?? CLLocationManager()).authorizationStatus
3040
3317
  let granted = status == .authorizedAlways || (permission == "location" && status == .authorizedWhenInUse)
3041
3318
  resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
3042
3319
  case "camera":
@@ -3084,11 +3361,27 @@ struct CraftWebView: UIViewRepresentable {
3084
3361
  }
3085
3362
  switch permission {
3086
3363
  case "location", "locationAlways":
3364
+ guard let manager = locationManager else {
3365
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3366
+ return
3367
+ }
3368
+ manager.delegate = self
3369
+ let requiresAlways = permission == "locationAlways" || config.enableBackgroundLocation
3370
+ let status = manager.authorizationStatus
3371
+ let alreadyGranted = status == .authorizedAlways || (!requiresAlways && status == .authorizedWhenInUse)
3372
+ if alreadyGranted || status == .denied || status == .restricted {
3373
+ resolveCallback(callbackId, result: permissionStatus(alreadyGranted, denied: status == .denied, restricted: status == .restricted))
3374
+ return
3375
+ }
3376
+ if let pendingCallbackId = locationPermissionCallbackId {
3377
+ rejectCallback(pendingCallbackId, error: "A newer location permission request replaced this request", code: "REQUEST_REPLACED")
3378
+ }
3087
3379
  locationPermissionCallbackId = callbackId
3088
- if permission == "locationAlways" || config.enableBackgroundLocation {
3089
- locationManager?.requestAlwaysAuthorization()
3380
+ locationPermissionRequiresAlways = requiresAlways
3381
+ if requiresAlways {
3382
+ manager.requestAlwaysAuthorization()
3090
3383
  } else {
3091
- locationManager?.requestWhenInUseAuthorization()
3384
+ manager.requestWhenInUseAuthorization()
3092
3385
  }
3093
3386
  case "camera":
3094
3387
  AVCaptureDevice.requestAccess(for: .video) { granted in
@@ -3134,11 +3427,60 @@ struct CraftWebView: UIViewRepresentable {
3134
3427
  }
3135
3428
 
3136
3429
  // MARK: - Geolocation
3137
- private func getCurrentPosition(callbackId: String?) {
3138
- locationManager?.delegate = self
3430
+ private func getCurrentPosition(body: [String: Any], callbackId: String?) {
3431
+ guard let manager = locationManager else {
3432
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3433
+ return
3434
+ }
3435
+ manager.delegate = self
3436
+ manager.desiredAccuracy = body["enableHighAccuracy"] as? Bool == true
3437
+ ? kCLLocationAccuracyBest
3438
+ : kCLLocationAccuracyHundredMeters
3439
+
3440
+ if let pendingCallbackId = singleLocationCallbackId {
3441
+ finishSingleLocationRequest()
3442
+ rejectCallback(pendingCallbackId, error: "A newer location request replaced this request", code: "POSITION_UNAVAILABLE")
3443
+ }
3139
3444
  singleLocationCallbackId = callbackId
3445
+ let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
3446
+ if maximumAge > 0,
3447
+ let cachedLocation = manager.location,
3448
+ max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
3449
+ finishSingleLocationRequest()
3450
+ resolveCallback(callbackId, result: locationData(cachedLocation))
3451
+ return
3452
+ }
3453
+
3454
+ let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
3455
+ let timeoutWorkItem = DispatchWorkItem { [weak self] in
3456
+ guard let self, self.singleLocationCallbackId == callbackId else { return }
3457
+ self.finishSingleLocationRequest()
3458
+ self.rejectCallback(callbackId, error: "Location request timed out", code: "LOCATION_TIMEOUT")
3459
+ }
3460
+ singleLocationTimeoutWorkItem = timeoutWorkItem
3461
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
3140
3462
  requestLocationAuthorization()
3141
- locationManager?.requestLocation()
3463
+ manager.requestLocation()
3464
+ }
3465
+
3466
+ private func finishSingleLocationRequest() {
3467
+ singleLocationTimeoutWorkItem?.cancel()
3468
+ singleLocationTimeoutWorkItem = nil
3469
+ singleLocationCallbackId = nil
3470
+ locationManager?.desiredAccuracy = kCLLocationAccuracyBest
3471
+ }
3472
+
3473
+ private func locationData(_ location: CLLocation) -> [String: Any] {
3474
+ [
3475
+ "latitude": location.coordinate.latitude,
3476
+ "longitude": location.coordinate.longitude,
3477
+ "altitude": location.altitude,
3478
+ "accuracy": location.horizontalAccuracy,
3479
+ "altitudeAccuracy": location.verticalAccuracy,
3480
+ "heading": location.course,
3481
+ "speed": location.speed,
3482
+ "timestamp": location.timestamp.timeIntervalSince1970 * 1000
3483
+ ]
3142
3484
  }
3143
3485
 
3144
3486
  private func watchPosition(callbackId: String?) {
@@ -3326,20 +3668,11 @@ struct CraftWebView: UIViewRepresentable {
3326
3668
 
3327
3669
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
3328
3670
  guard let location = locations.last else { return }
3329
- let data: [String: Any] = [
3330
- "latitude": location.coordinate.latitude,
3331
- "longitude": location.coordinate.longitude,
3332
- "altitude": location.altitude,
3333
- "accuracy": location.horizontalAccuracy,
3334
- "altitudeAccuracy": location.verticalAccuracy,
3335
- "heading": location.course,
3336
- "speed": location.speed,
3337
- "timestamp": location.timestamp.timeIntervalSince1970 * 1000
3338
- ]
3671
+ let data = locationData(location)
3339
3672
 
3340
3673
  if let callbackId = singleLocationCallbackId {
3674
+ finishSingleLocationRequest()
3341
3675
  resolveCallback(callbackId, result: data)
3342
- singleLocationCallbackId = nil
3343
3676
  }
3344
3677
 
3345
3678
  appendRecordedLocation(data)
@@ -3349,8 +3682,13 @@ struct CraftWebView: UIViewRepresentable {
3349
3682
  }
3350
3683
 
3351
3684
  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
3352
- rejectCallback(singleLocationCallbackId, error: error.localizedDescription)
3353
- singleLocationCallbackId = nil
3685
+ let callbackId = singleLocationCallbackId
3686
+ finishSingleLocationRequest()
3687
+ let nativeError = error as NSError
3688
+ let code = nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.denied.rawValue
3689
+ ? "PERMISSION_DENIED"
3690
+ : "POSITION_UNAVAILABLE"
3691
+ rejectCallback(callbackId, error: error.localizedDescription, code: code)
3354
3692
  sendToWeb("craftLocationError", data: ["message": error.localizedDescription])
3355
3693
  }
3356
3694
 
@@ -3358,9 +3696,11 @@ struct CraftWebView: UIViewRepresentable {
3358
3696
  guard let callbackId = locationPermissionCallbackId else { return }
3359
3697
  let status = manager.authorizationStatus
3360
3698
  if status == .notDetermined { return }
3361
- let granted = status == .authorizedAlways || status == .authorizedWhenInUse
3699
+ if locationPermissionRequiresAlways && status == .authorizedWhenInUse { return }
3700
+ let granted = status == .authorizedAlways || (!locationPermissionRequiresAlways && status == .authorizedWhenInUse)
3362
3701
  resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
3363
3702
  locationPermissionCallbackId = nil
3703
+ locationPermissionRequiresAlways = false
3364
3704
  }
3365
3705
 
3366
3706
  // MARK: - Memory Usage (for Profiling)
@@ -3478,7 +3818,11 @@ struct CraftWebView: UIViewRepresentable {
3478
3818
 
3479
3819
  // MARK: - Contacts
3480
3820
  private func getContacts(callbackId: String?) {
3481
- contactStore?.requestAccess(for: .contacts) { [weak self] granted, error in
3821
+ guard let store = contactStore else {
3822
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
3823
+ return
3824
+ }
3825
+ store.requestAccess(for: .contacts) { [weak self] granted, error in
3482
3826
  guard granted else {
3483
3827
  self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3484
3828
  return
@@ -3489,7 +3833,7 @@ struct CraftWebView: UIViewRepresentable {
3489
3833
 
3490
3834
  var contacts: [[String: Any]] = []
3491
3835
  do {
3492
- try self?.contactStore?.enumerateContacts(with: request) { contact, _ in
3836
+ try store.enumerateContacts(with: request) { contact, _ in
3493
3837
  var phones: [String] = []
3494
3838
  for phone in contact.phoneNumbers {
3495
3839
  phones.append(phone.value.stringValue)
@@ -3515,9 +3859,13 @@ struct CraftWebView: UIViewRepresentable {
3515
3859
  }
3516
3860
 
3517
3861
  private func addContact(_ data: [String: Any], callbackId: String?) {
3518
- contactStore?.requestAccess(for: .contacts) { [weak self] granted, error in
3862
+ guard let store = contactStore else {
3863
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
3864
+ return
3865
+ }
3866
+ store.requestAccess(for: .contacts) { [weak self] granted, error in
3519
3867
  guard granted else {
3520
- self?.rejectCallback(callbackId, error: "Permission denied")
3868
+ self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3521
3869
  return
3522
3870
  }
3523
3871
 
@@ -3535,7 +3883,7 @@ struct CraftWebView: UIViewRepresentable {
3535
3883
  saveRequest.add(contact, toContainerWithIdentifier: nil)
3536
3884
 
3537
3885
  do {
3538
- try self?.contactStore?.execute(saveRequest)
3886
+ try store.execute(saveRequest)
3539
3887
  self?.resolveCallback(callbackId, result: contact.identifier)
3540
3888
  } catch {
3541
3889
  self?.rejectCallback(callbackId, error: error.localizedDescription)
@@ -3545,17 +3893,23 @@ struct CraftWebView: UIViewRepresentable {
3545
3893
 
3546
3894
  // MARK: - Calendar
3547
3895
  private func getCalendarEvents(startDate: Double?, endDate: Double?, callbackId: String?) {
3548
- eventStore?.requestAccess(to: .event) { [weak self] granted, error in
3896
+ guard let store = eventStore else {
3897
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
3898
+ return
3899
+ }
3900
+ store.requestAccess(to: .event) { [weak self] granted, error in
3549
3901
  guard granted else {
3550
3902
  self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3551
3903
  return
3552
3904
  }
3553
3905
 
3554
- let start = startDate != nil ? Date(timeIntervalSince1970: startDate! / 1000) : Date()
3555
- let end = endDate != nil ? Date(timeIntervalSince1970: endDate! / 1000) : Calendar.current.date(byAdding: .month, value: 1, to: Date())!
3906
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
3907
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) }
3908
+ ?? Calendar.current.date(byAdding: .month, value: 1, to: Date())
3909
+ ?? Date()
3556
3910
 
3557
- let predicate = self?.eventStore?.predicateForEvents(withStart: start, end: end, calendars: nil)
3558
- let events = self?.eventStore?.events(matching: predicate!) ?? []
3911
+ let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
3912
+ let events = store.events(matching: predicate)
3559
3913
 
3560
3914
  let eventData: [[String: Any]] = events.map { event in
3561
3915
  return [
@@ -3574,13 +3928,17 @@ struct CraftWebView: UIViewRepresentable {
3574
3928
  }
3575
3929
 
3576
3930
  private func createCalendarEvent(_ data: [String: Any], callbackId: String?) {
3577
- eventStore?.requestAccess(to: .event) { [weak self] granted, error in
3931
+ guard let store = eventStore else {
3932
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
3933
+ return
3934
+ }
3935
+ store.requestAccess(to: .event) { [weak self] granted, error in
3578
3936
  guard granted else {
3579
- self?.rejectCallback(callbackId, error: "Permission denied")
3937
+ self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3580
3938
  return
3581
3939
  }
3582
3940
 
3583
- let event = EKEvent(eventStore: self!.eventStore!)
3941
+ let event = EKEvent(eventStore: store)
3584
3942
  event.title = data["title"] as? String ?? ""
3585
3943
  event.location = data["location"] as? String
3586
3944
  event.notes = data["notes"] as? String
@@ -3592,11 +3950,15 @@ struct CraftWebView: UIViewRepresentable {
3592
3950
  event.endDate = Date(timeIntervalSince1970: end / 1000)
3593
3951
  }
3594
3952
  event.isAllDay = data["isAllDay"] as? Bool ?? false
3595
- event.calendar = self?.eventStore?.defaultCalendarForNewEvents
3953
+ event.calendar = store.defaultCalendarForNewEvents
3596
3954
 
3597
3955
  do {
3598
- try self?.eventStore?.save(event, span: .thisEvent)
3599
- self?.resolveCallback(callbackId, result: event.eventIdentifier)
3956
+ try store.save(event, span: .thisEvent)
3957
+ guard let identifier = event.eventIdentifier else {
3958
+ self?.rejectCallback(callbackId, error: "Saved event has no identifier")
3959
+ return
3960
+ }
3961
+ self?.resolveCallback(callbackId, result: identifier)
3600
3962
  } catch {
3601
3963
  self?.rejectCallback(callbackId, error: error.localizedDescription)
3602
3964
  }
@@ -3604,13 +3966,17 @@ struct CraftWebView: UIViewRepresentable {
3604
3966
  }
3605
3967
 
3606
3968
  private func deleteCalendarEvent(_ eventId: String, callbackId: String?) {
3607
- guard let event = eventStore?.event(withIdentifier: eventId) else {
3969
+ guard let store = eventStore else {
3970
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
3971
+ return
3972
+ }
3973
+ guard let event = store.event(withIdentifier: eventId) else {
3608
3974
  rejectCallback(callbackId, error: "Event not found")
3609
3975
  return
3610
3976
  }
3611
3977
 
3612
3978
  do {
3613
- try eventStore?.remove(event, span: .thisEvent)
3979
+ try store.remove(event, span: .thisEvent)
3614
3980
  resolveCallback(callbackId, result: true)
3615
3981
  } catch {
3616
3982
  rejectCallback(callbackId, error: error.localizedDescription)
@@ -4388,10 +4754,25 @@ struct CraftWebView: UIViewRepresentable {
4388
4754
  }
4389
4755
 
4390
4756
  private func updateLiveActivity(body: [String: Any], callbackId: String?) {
4391
- guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4757
+ guard #available(iOS 16.2, *) else {
4392
4758
  rejectCallback(callbackId, error: "No Live Activity is running")
4393
4759
  return
4394
4760
  }
4761
+ let activities = Activity<CraftActivityAttributes>.activities
4762
+ let activity: Activity<CraftActivityAttributes>
4763
+ if let activityId = body["id"] as? String {
4764
+ guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
4765
+ rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
4766
+ return
4767
+ }
4768
+ activity = matchingActivity
4769
+ } else {
4770
+ guard let currentActivity = activities.first else {
4771
+ rejectCallback(callbackId, error: "No Live Activity is running")
4772
+ return
4773
+ }
4774
+ activity = currentActivity
4775
+ }
4395
4776
  let current = activity.content.state
4396
4777
  let state = CraftActivityAttributes.ContentState(
4397
4778
  status: body["status"] as? String ?? current.status,
@@ -4405,13 +4786,45 @@ struct CraftWebView: UIViewRepresentable {
4405
4786
  }
4406
4787
  }
4407
4788
 
4408
- private func endLiveActivity(callbackId: String?) {
4409
- guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4789
+ private func endLiveActivity(body: [String: Any], callbackId: String?) {
4790
+ guard #available(iOS 16.2, *) else {
4410
4791
  resolveCallback(callbackId, result: ["ended": false])
4411
4792
  return
4412
4793
  }
4794
+ let activities = Activity<CraftActivityAttributes>.activities
4795
+ let activity: Activity<CraftActivityAttributes>
4796
+ if let activityId = body["id"] as? String {
4797
+ guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
4798
+ rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
4799
+ return
4800
+ }
4801
+ activity = matchingActivity
4802
+ } else {
4803
+ guard let currentActivity = activities.first else {
4804
+ resolveCallback(callbackId, result: ["ended": false])
4805
+ return
4806
+ }
4807
+ activity = currentActivity
4808
+ }
4809
+ let hasFinalState = body["status"] != nil
4810
+ || body["distanceMeters"] != nil
4811
+ || body["durationSeconds"] != nil
4812
+ || body["progress"] != nil
4813
+ let finalContent: ActivityContent<CraftActivityAttributes.ContentState>?
4814
+ if hasFinalState {
4815
+ let current = activity.content.state
4816
+ let state = CraftActivityAttributes.ContentState(
4817
+ status: body["status"] as? String ?? current.status,
4818
+ distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
4819
+ durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
4820
+ progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
4821
+ )
4822
+ finalContent = ActivityContent(state: state, staleDate: nil)
4823
+ } else {
4824
+ finalContent = nil
4825
+ }
4413
4826
  Task {
4414
- await activity.end(nil, dismissalPolicy: .default)
4827
+ await activity.end(finalContent, dismissalPolicy: .default)
4415
4828
  resolveCallback(callbackId, result: ["ended": true])
4416
4829
  }
4417
4830
  }