craft-native 0.0.90 → 0.0.92

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 (31) hide show
  1. package/dist/android/src/index.d.ts +36 -1
  2. package/dist/android/src/index.js +332 -44
  3. package/dist/android/src/promise-runtime.d.ts +3 -0
  4. package/dist/android/templates/CraftBridge.kt.template +2167 -1177
  5. package/dist/android/templates/CraftHealthConnect.kt.template +45 -6
  6. package/dist/android/templates/CraftHealthConnectStub.kt.template +7 -1
  7. package/dist/android/templates/CraftNative.kt.template +2250 -0
  8. package/dist/android/templates/LocationRecordingService.kt.template +34 -9
  9. package/dist/android/templates/MainActivity.kt.template +42 -8
  10. package/dist/android/templates/proguard-rules.pro.template +4 -1
  11. package/dist/android/templates/test-bridges.html +10 -33
  12. package/dist/api/index.d.ts +1 -1
  13. package/dist/api/ios-advanced.d.ts +8 -5
  14. package/dist/api/live-activity-handle.d.ts +6 -0
  15. package/dist/api/mobile.d.ts +25 -7
  16. package/dist/api/window.d.ts +2 -0
  17. package/dist/cli.js +452 -129
  18. package/dist/index.cjs +77 -22
  19. package/dist/index.js +77 -22
  20. package/dist/ios/src/index.d.ts +1 -1
  21. package/dist/ios/src/index.js +23 -5
  22. package/dist/ios/templates/CraftApp.swift +706 -92
  23. package/dist/ios/templates/CraftWatchApp.swift.template +8 -0
  24. package/dist/ios/templates/WatchApp.Info.plist.template +2 -0
  25. package/dist/ios/templates/WatchExtension.Info.plist.template +34 -0
  26. package/dist/ios/templates/project.yml.template +10 -4
  27. package/dist/mobile.js +52 -21
  28. package/dist/scaffold-version.d.ts +5 -0
  29. package/package.json +1 -1
  30. package/dist/android/templates/CraftBridgeExtensions.kt.template +0 -383
  31. 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 {
@@ -92,9 +172,16 @@ class DeepLinkManager {
92
172
  static let shared = DeepLinkManager()
93
173
 
94
174
  private var initialURL: URL?
95
- private var pendingURL: URL?
175
+ // Every link that arrived while no page could receive it, in order. This
176
+ // was a single slot, so a second link before the bridge was ready replaced
177
+ // the first; on a cold start, that could be the link that launched the app.
178
+ private var pendingURLs: [URL] = []
96
179
  private weak var webView: WKWebView?
97
180
  private var isReady = false
181
+ // Whether a page has ever become ready. A link that arrives before that is
182
+ // the one the app was opened with; a link that arrives later is not, and
183
+ // is no answer to getInitialURL.
184
+ private var hasBeenReady = false
98
185
 
99
186
  private init() {}
100
187
 
@@ -102,26 +189,33 @@ class DeepLinkManager {
102
189
  self.webView = webView
103
190
  }
104
191
 
192
+ // A navigation started, so the page that would have received a link is
193
+ // going away. Without this a link arriving during a reload was dispatched
194
+ // into the page being torn down and lost.
195
+ func setLoading() {
196
+ isReady = false
197
+ }
198
+
105
199
  func setReady() {
106
200
  isReady = true
107
- // If there's a pending URL, dispatch it now
108
- if let url = pendingURL {
109
- dispatchDeepLink(url)
110
- pendingURL = nil
201
+ let firstPage = !hasBeenReady
202
+ hasBeenReady = true
203
+ let urls = pendingURLs
204
+ pendingURLs.removeAll()
205
+ for url in urls {
206
+ dispatchDeepLink(url, initial: firstPage && url == initialURL)
111
207
  }
112
208
  }
113
209
 
114
210
  func handleURL(_ url: URL) {
115
- // Store as initial URL if this is the first one
116
- if initialURL == nil {
211
+ if initialURL == nil && !hasBeenReady {
117
212
  initialURL = url
118
213
  }
119
214
 
120
- if isReady, let webView = webView {
121
- dispatchDeepLink(url)
215
+ if isReady && webView != nil {
216
+ dispatchDeepLink(url, initial: false)
122
217
  } else {
123
- // Store for later when web view is ready
124
- pendingURL = url
218
+ pendingURLs.append(url)
125
219
  }
126
220
  }
127
221
 
@@ -129,7 +223,7 @@ class DeepLinkManager {
129
223
  return initialURL
130
224
  }
131
225
 
132
- private func dispatchDeepLink(_ url: URL) {
226
+ private func dispatchDeepLink(_ url: URL, initial: Bool) {
133
227
  guard let webView = webView else { return }
134
228
 
135
229
  // Parse URL components
@@ -138,7 +232,10 @@ class DeepLinkManager {
138
232
  "scheme": url.scheme ?? "",
139
233
  "host": url.host ?? "",
140
234
  "path": url.path,
141
- "query": url.query ?? ""
235
+ "query": url.query ?? "",
236
+ // Lets the page tell the launch link apart from later ones, so a
237
+ // page that reads getInitialURL is not also handed it again.
238
+ "initial": initial
142
239
  ]
143
240
 
144
241
  // Parse query parameters
@@ -346,6 +443,7 @@ struct CraftWebView: UIViewRepresentable {
346
443
 
347
444
  // Register with DeepLinkManager
348
445
  DeepLinkManager.shared.setWebView(webView)
446
+ CraftEventManager.shared.setWebView(webView)
349
447
 
350
448
  // Parse background color
351
449
  let bgColor = UIColor(hex: config.backgroundColor) ?? .black
@@ -400,7 +498,9 @@ struct CraftWebView: UIViewRepresentable {
400
498
  // Location
401
499
  private var locationManager: CLLocationManager?
402
500
  private var singleLocationCallbackId: String?
501
+ private var singleLocationTimeoutWorkItem: DispatchWorkItem?
403
502
  private var locationPermissionCallbackId: String?
503
+ private var locationPermissionRequiresAlways = false
404
504
  private var isWatchingLocation = false
405
505
  private var isRecordingLocation = false
406
506
  private var isLocationRecordingPaused = false
@@ -567,16 +667,23 @@ struct CraftWebView: UIViewRepresentable {
567
667
  switch action {
568
668
  case "startListening":
569
669
  if config.enableSpeechRecognition {
670
+ // Answers that the request was taken. Authorization and the
671
+ // recognizer answer later, as craftSpeech* events; waiting
672
+ // for them here would leave the promise open on every path
673
+ // that ends without one.
570
674
  startSpeechRecognition()
675
+ resolveCallback(callbackId, result: true)
571
676
  } else {
572
677
  rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED")
573
678
  }
574
679
  case "stopListening":
575
680
  stopSpeechRecognition()
681
+ resolveCallback(callbackId, result: true)
576
682
  case "haptic":
577
683
  if config.enableHaptics {
578
684
  let style = body["style"] as? String ?? "medium"
579
685
  triggerHaptic(style: style)
686
+ resolveCallback(callbackId, result: true)
580
687
  } else {
581
688
  rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
582
689
  }
@@ -683,7 +790,7 @@ struct CraftWebView: UIViewRepresentable {
683
790
  // Geolocation
684
791
  case "getCurrentPosition":
685
792
  if config.enableGeolocation {
686
- getCurrentPosition(callbackId: callbackId)
793
+ getCurrentPosition(body: body, callbackId: callbackId)
687
794
  } else {
688
795
  rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
689
796
  }
@@ -1094,7 +1201,7 @@ struct CraftWebView: UIViewRepresentable {
1094
1201
  case "updateLiveActivity":
1095
1202
  updateLiveActivity(body: body, callbackId: callbackId)
1096
1203
  case "endLiveActivity":
1097
- endLiveActivity(callbackId: callbackId)
1204
+ endLiveActivity(body: body, callbackId: callbackId)
1098
1205
 
1099
1206
  // MARK: - Screen Capture
1100
1207
  case "takeScreenshot":
@@ -1315,6 +1422,11 @@ struct CraftWebView: UIViewRepresentable {
1315
1422
  }
1316
1423
  }
1317
1424
 
1425
+ func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
1426
+ CraftEventManager.shared.setLoading()
1427
+ DeepLinkManager.shared.setLoading()
1428
+ }
1429
+
1318
1430
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
1319
1431
  self.webView = webView
1320
1432
  guard isTrustedURL(webView.url) else { return }
@@ -1358,11 +1470,29 @@ struct CraftWebView: UIViewRepresentable {
1358
1470
 
1359
1471
  private func isTrustedURL(_ url: URL?) -> Bool {
1360
1472
  guard let url = url else { return false }
1361
- if url.scheme == "craft" && url.host == "app" { return true }
1362
1473
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
1363
1474
  }
1364
1475
 
1476
+ /// The single answer to "may this origin reach native?".
1477
+ ///
1478
+ /// The bundled app is served from craft://app by
1479
+ /// `BundledAssetSchemeHandler`, and it is the only content a generated
1480
+ /// app loads when no dev server is configured. That origin used to be
1481
+ /// trusted for *navigation* only: `isTrustedURL` carried the clause,
1482
+ /// while the `userContentController` guard called this function
1483
+ /// directly and fell through to the https/localhost check. So every
1484
+ /// bridge call from the app's own page was answered with
1485
+ ///
1486
+ /// Blocked Craft bridge message from untrusted origin: craft://app
1487
+ ///
1488
+ /// and its promise never settled — the whole native surface was dead
1489
+ /// in the default configuration, which is the one every generated app
1490
+ /// ships with. Two guards that had to agree, and did not. There is one
1491
+ /// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted
1492
+ /// simulator so a future hardening pass cannot quietly take the bridge
1493
+ /// away again.
1365
1494
  private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1495
+ if scheme == "craft" && host == "app" { return true }
1366
1496
  if scheme == "file" { return true }
1367
1497
  guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false }
1368
1498
  let defaultPort = scheme == "https" ? 443 : 80
@@ -1782,20 +1912,170 @@ struct CraftWebView: UIViewRepresentable {
1782
1912
  };
1783
1913
  },
1784
1914
 
1915
+ // Flat SDK compatibility methods. The native dispatcher already
1916
+ // owns these actions; keep the public CraftBridge shape callable
1917
+ // while the versioned API below provides the namespaced form.
1918
+ scanNFC: function() {
1919
+ return this._invoke('scanNFC');
1920
+ },
1921
+ scanQRCode: function() {
1922
+ return this._invoke('scanQRCode');
1923
+ },
1924
+ takeScreenshot: function() {
1925
+ return this._invoke('takeScreenshot');
1926
+ },
1927
+ startAudioRecording: function() {
1928
+ return this._invoke('startAudioRecording');
1929
+ },
1930
+ stopAudioRecording: function() {
1931
+ return this._invoke('stopAudioRecording');
1932
+ },
1933
+ startVideoRecording: function() {
1934
+ return this._invoke('startVideoRecording');
1935
+ },
1936
+ pickFile: function(types) {
1937
+ return this._invoke('pickFile', {types: types || []});
1938
+ },
1939
+ downloadFile: function(url, filename) {
1940
+ return this._invoke('downloadFile', {url: url, filename: filename});
1941
+ },
1942
+ saveFile: function(data, filename, mimeType) {
1943
+ return this._invoke('saveFile', {data: data, filename: filename, mimeType: mimeType});
1944
+ },
1945
+ startMotionUpdates: function() {
1946
+ return this._invoke('startMotionUpdates');
1947
+ },
1948
+ stopMotionUpdates: function() {
1949
+ return this._invoke('stopMotionUpdates');
1950
+ },
1951
+ getCurrentPosition: function() {
1952
+ return this.geolocation.getCurrentPosition({});
1953
+ },
1954
+ watchPosition: function(callback) {
1955
+ return this.geolocation.watchPosition(callback);
1956
+ },
1957
+ clearWatch: function(watchId) {
1958
+ return this.geolocation.clearWatch(watchId);
1959
+ },
1960
+ getContacts: function() {
1961
+ return this._invoke('getContacts');
1962
+ },
1963
+ addContact: function(contact) {
1964
+ return this._invoke('addContact', {contact: contact});
1965
+ },
1966
+ getCalendarEvents: function(startDate, endDate) {
1967
+ return this._invoke('getCalendarEvents', {startDate: startDate, endDate: endDate});
1968
+ },
1969
+ createCalendarEvent: function(event) {
1970
+ return this._invoke('createCalendarEvent', {event: event});
1971
+ },
1972
+ deleteCalendarEvent: function(eventId) {
1973
+ return this._invoke('deleteCalendarEvent', {eventId: eventId});
1974
+ },
1975
+ scheduleNotification: function(notification) {
1976
+ return this._invoke('scheduleNotification', {notification: notification});
1977
+ },
1978
+ cancelNotification: function(id) {
1979
+ return this._invoke('cancelNotification', {id: id});
1980
+ },
1981
+ cancelAllNotifications: function() {
1982
+ return this._invoke('cancelAllNotifications');
1983
+ },
1984
+ getPendingNotifications: function() {
1985
+ return this._invoke('getPendingNotifications');
1986
+ },
1987
+ getProducts: function(productIds) {
1988
+ return this.iap.getProducts(productIds);
1989
+ },
1990
+ purchase: function(productId) {
1991
+ return this.iap.purchase(productId);
1992
+ },
1993
+ restorePurchases: function() {
1994
+ return this._invoke('restorePurchases');
1995
+ },
1996
+ signInWithApple: function() {
1997
+ return this._invoke('signInWithApple');
1998
+ },
1999
+ signInWithGoogle: function() {
2000
+ return Promise.reject(new Error('Google Sign-In is unavailable on iOS'));
2001
+ },
2002
+ startBluetoothScan: function() {
2003
+ return this._invoke('startBluetoothScan');
2004
+ },
2005
+ stopBluetoothScan: function() {
2006
+ return this._invoke('stopBluetoothScan');
2007
+ },
2008
+ requestHealthAuthorization: function(types) {
2009
+ return this._invoke('requestHealthAuthorization', {types: types || []});
2010
+ },
2011
+ getHealthData: function(type, startDate, endDate) {
2012
+ var start = startDate instanceof Date ? startDate.getTime() : startDate;
2013
+ var end = endDate instanceof Date ? endDate.getTime() : endDate;
2014
+ return this._invoke('getHealthData', {type: type, startDate: start, endDate: end});
2015
+ },
2016
+ requestFitnessAuthorization: function() {
2017
+ return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
2018
+ },
2019
+ getFitnessData: function() {
2020
+ return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
2021
+ },
2022
+
2023
+ // Resolves true once UIKit has taken the haptic, and rejects
2024
+ // CAPABILITY_DISABLED when enableHaptics is off.
1785
2025
  haptic: function(style) {
1786
- window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium'});
2026
+ var self = window.craft;
2027
+ var id = 'cb_' + (++self._callbackId);
2028
+ window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium', callbackId: id});
2029
+ return new Promise(function(resolve, reject) {
2030
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2031
+ });
1787
2032
  },
1788
2033
 
2034
+ // Resolves true once native has taken the request, not once
2035
+ // audio is flowing. What happens next (a prompt declined, no
2036
+ // recognizer, a transcript) still arrives as craftSpeech*
2037
+ // events, because none of it is known when this answers.
1789
2038
  startListening: function() {
1790
- window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
2039
+ var self = window.craft;
2040
+ var id = 'cb_' + (++self._callbackId);
2041
+ window.webkit.messageHandlers.craft.postMessage({action: 'startListening', callbackId: id});
2042
+ return new Promise(function(resolve, reject) {
2043
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2044
+ });
1791
2045
  },
1792
2046
 
1793
2047
  stopListening: function() {
1794
- window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
2048
+ var self = window.craft;
2049
+ var id = 'cb_' + (++self._callbackId);
2050
+ window.webkit.messageHandlers.craft.postMessage({action: 'stopListening', callbackId: id});
2051
+ return new Promise(function(resolve, reject) {
2052
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2053
+ });
1795
2054
  },
1796
2055
 
1797
2056
  share: function(text) {
1798
- window.webkit.messageHandlers.craft.postMessage({action: 'share', text: text});
2057
+ return this._share({text: text});
2058
+ },
2059
+
2060
+ // Both share entry points come through here, because the
2061
+ // native side answers them the same way: `true` when the
2062
+ // person finished an activity, `false` when they dismissed
2063
+ // the sheet, a rejection when sharing is disabled or there is
2064
+ // nothing to share. The flat `share` used to post with no
2065
+ // callbackId, so every one of those answers stopped at the nil
2066
+ // guard in resolveCallback and the page got `undefined`.
2067
+ //
2068
+ // No timeout, unlike `_invoke`'s thirty seconds. The sheet
2069
+ // waits on a person, and someone slower than that to pick an
2070
+ // app would be told the share failed while it was still on
2071
+ // screen, with the real answer then dropped.
2072
+ _share: function(payload) {
2073
+ var self = this;
2074
+ var id = 'cb_' + (++this._callbackId);
2075
+ window.webkit.messageHandlers.craft.postMessage(Object.assign({}, payload, {action: 'share', callbackId: id}));
2076
+ return new Promise(function(resolve, reject) {
2077
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2078
+ });
1799
2079
  },
1800
2080
 
1801
2081
  openCamera: function() {
@@ -1863,20 +2143,79 @@ struct CraftWebView: UIViewRepresentable {
1863
2143
 
1864
2144
  // Geolocation
1865
2145
  geolocation: {
1866
- getCurrentPosition: function() {
2146
+ getCurrentPosition: function(options) {
2147
+ options = options || {};
1867
2148
  var self = window.craft;
1868
2149
  var id = 'cb_' + (++self._callbackId);
1869
- window.webkit.messageHandlers.craft.postMessage({action: 'getCurrentPosition', callbackId: id});
2150
+ var requestedTimeout = Number(options.timeout);
2151
+ var timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout >= 0
2152
+ ? Math.min(requestedTimeout, 2147483647)
2153
+ : 30000;
1870
2154
  return new Promise(function(resolve, reject) {
1871
- self._callbacks[id] = {resolve: resolve, reject: reject};
2155
+ var timeout;
2156
+ self._callbacks[id] = {
2157
+ resolve: function(value) { clearTimeout(timeout); resolve(value); },
2158
+ reject: function(error) {
2159
+ clearTimeout(timeout);
2160
+ var locationErrorCode = error && ({
2161
+ 'PERMISSION_DENIED': 1,
2162
+ 'POSITION_UNAVAILABLE': 2,
2163
+ 'NATIVE_CALL_FAILED': 2,
2164
+ 'TIMEOUT': 3,
2165
+ 'LOCATION_TIMEOUT': 3
2166
+ })[error.code];
2167
+ if (locationErrorCode) {
2168
+ error.name = 'GeolocationPositionError';
2169
+ error.code = locationErrorCode;
2170
+ }
2171
+ reject(error);
2172
+ }
2173
+ };
2174
+ timeout = setTimeout(function() {
2175
+ if (!self._callbacks[id]) return;
2176
+ delete self._callbacks[id];
2177
+ var error = new Error('Location request timed out after ' + timeoutMs + 'ms');
2178
+ error.name = 'GeolocationPositionError';
2179
+ error.code = 3;
2180
+ error.bridge = true;
2181
+ reject(error);
2182
+ }, timeoutMs);
2183
+ try {
2184
+ window.webkit.messageHandlers.craft.postMessage({
2185
+ action: 'getCurrentPosition',
2186
+ callbackId: id,
2187
+ enableHighAccuracy: options.enableHighAccuracy === true,
2188
+ timeout: timeoutMs,
2189
+ maximumAge: Number.isFinite(Number(options.maximumAge))
2190
+ ? Math.max(0, Number(options.maximumAge))
2191
+ : 0
2192
+ });
2193
+ } catch (error) {
2194
+ clearTimeout(timeout);
2195
+ delete self._callbacks[id];
2196
+ reject(error);
2197
+ }
1872
2198
  });
1873
2199
  },
2200
+ // Resolves true once updates have started, before any
2201
+ // authorization answer. A later refusal arrives as a
2202
+ // craftLocationError event.
1874
2203
  watchPosition: function(callback) {
2204
+ var self = window.craft;
2205
+ var id = 'cb_' + (++self._callbackId);
1875
2206
  window.addEventListener('craftLocationUpdate', function(e) { callback(e.detail); });
1876
- window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition'});
2207
+ window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition', callbackId: id});
2208
+ return new Promise(function(resolve, reject) {
2209
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2210
+ });
1877
2211
  },
1878
2212
  clearWatch: function() {
1879
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2213
+ var self = window.craft;
2214
+ var id = 'cb_' + (++self._callbackId);
2215
+ window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch', callbackId: id});
2216
+ return new Promise(function(resolve, reject) {
2217
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2218
+ });
1880
2219
  }
1881
2220
  },
1882
2221
 
@@ -1938,7 +2277,14 @@ struct CraftWebView: UIViewRepresentable {
1938
2277
  });
1939
2278
  },
1940
2279
  onNetworkChange: function(callback) {
1941
- window.addEventListener('craftNetworkChange', function(e) { callback(e.detail); });
2280
+ this.offNetworkChange();
2281
+ this._networkChangeHandler = function(e) { callback(e.detail); };
2282
+ window.addEventListener('craftNetworkChange', this._networkChangeHandler);
2283
+ },
2284
+ offNetworkChange: function() {
2285
+ if (!this._networkChangeHandler) return;
2286
+ window.removeEventListener('craftNetworkChange', this._networkChangeHandler);
2287
+ this._networkChangeHandler = null;
1942
2288
  },
1943
2289
 
1944
2290
  // App review
@@ -1957,13 +2303,24 @@ struct CraftWebView: UIViewRepresentable {
1957
2303
  var id = 'cb_' + (++this._callbackId);
1958
2304
  window.webkit.messageHandlers.craft.postMessage({action: 'setFlashlight', enabled: enabled, callbackId: id});
1959
2305
  return new Promise(function(resolve, reject) {
1960
- self._callbacks[id] = {resolve: resolve, reject: reject};
2306
+ self._callbacks[id] = {
2307
+ resolve: function(value) { self._flashlightEnabled = enabled; resolve(value); },
2308
+ reject: reject
2309
+ };
1961
2310
  });
1962
2311
  },
2312
+ toggleFlashlight: function() {
2313
+ return this.setFlashlight(!this._flashlightEnabled);
2314
+ },
1963
2315
 
1964
2316
  // Vibrate
1965
2317
  vibrate: function(pattern) {
1966
- window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern});
2318
+ var self = window.craft;
2319
+ var id = 'cb_' + (++self._callbackId);
2320
+ window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern, callbackId: id});
2321
+ return new Promise(function(resolve, reject) {
2322
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2323
+ });
1967
2324
  },
1968
2325
 
1969
2326
  // Open URL
@@ -1985,6 +2342,18 @@ struct CraftWebView: UIViewRepresentable {
1985
2342
  self._callbacks[id] = {resolve: resolve, reject: reject};
1986
2343
  });
1987
2344
  },
2345
+ onAppStateChange: function(callback) {
2346
+ this.offAppStateChange();
2347
+ this._appStateChangeHandler = function() {
2348
+ callback(document.visibilityState === 'visible' ? 'active' : 'background');
2349
+ };
2350
+ document.addEventListener('visibilitychange', this._appStateChangeHandler);
2351
+ },
2352
+ offAppStateChange: function() {
2353
+ if (!this._appStateChangeHandler) return;
2354
+ document.removeEventListener('visibilitychange', this._appStateChangeHandler);
2355
+ this._appStateChangeHandler = null;
2356
+ },
1988
2357
 
1989
2358
  // Contacts
1990
2359
  contacts: {
@@ -2128,7 +2497,7 @@ struct CraftWebView: UIViewRepresentable {
2128
2497
 
2129
2498
  // Deep Links
2130
2499
  onDeepLink: function(callback) {
2131
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2500
+ return window.craft._subscribeDeepLinks(callback);
2132
2501
  },
2133
2502
 
2134
2503
  // Background Tasks
@@ -2456,6 +2825,7 @@ struct CraftWebView: UIViewRepresentable {
2456
2825
  deepLinks: {
2457
2826
  getInitialURL: function() {
2458
2827
  var self = window.craft;
2828
+ self._claimInitialDeepLink();
2459
2829
  var id = 'cb_' + (++self._callbackId);
2460
2830
  window.webkit.messageHandlers.craft.postMessage({action: 'getInitialURL', callbackId: id});
2461
2831
  return new Promise(function(resolve, reject) {
@@ -2463,7 +2833,7 @@ struct CraftWebView: UIViewRepresentable {
2463
2833
  });
2464
2834
  },
2465
2835
  onLink: function(callback) {
2466
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2836
+ return window.craft._subscribeDeepLinks(callback);
2467
2837
  }
2468
2838
  },
2469
2839
 
@@ -2471,8 +2841,6 @@ struct CraftWebView: UIViewRepresentable {
2471
2841
  ota: {
2472
2842
  _config: null,
2473
2843
  _status: 'idle',
2474
- _progressCallbacks: [],
2475
- _statusCallbacks: [],
2476
2844
 
2477
2845
  // OTA is not implemented natively. These five used to post
2478
2846
  // to actions the switch below does not handle, and register
@@ -2513,12 +2881,10 @@ struct CraftWebView: UIViewRepresentable {
2513
2881
  };
2514
2882
  },
2515
2883
  onProgress: function(callback) {
2516
- this._progressCallbacks.push(callback);
2517
- window.addEventListener('craftOTAProgress', function(e) { callback(e.detail); });
2884
+ throw new Error('craft.ota.onProgress is not implemented on this platform');
2518
2885
  },
2519
2886
  onStatusChange: function(callback) {
2520
- this._statusCallbacks.push(callback);
2521
- window.addEventListener('craftOTAStatus', function(e) { callback(e.detail.status); });
2887
+ throw new Error('craft.ota.onStatusChange is not implemented on this platform');
2522
2888
  }
2523
2889
  },
2524
2890
 
@@ -2530,6 +2896,61 @@ struct CraftWebView: UIViewRepresentable {
2530
2896
  // Stable, versioned mobile contract consumed by craft-native/mobile.
2531
2897
  // Legacy flat methods remain available while every public SDK method
2532
2898
  // is routed through this nested contract.
2899
+ // Links that arrive before anything is listening (#198).
2900
+ //
2901
+ // The native side dispatches a link the moment this script has
2902
+ // run, and on a cold start that is the link the app was opened
2903
+ // with. No page code can have called onLink by then: onLink is
2904
+ // defined by this very script. So every such link was dispatched
2905
+ // to nobody, and a page that subscribes, rather than asking
2906
+ // getInitialURL, never learned how it was opened.
2907
+ //
2908
+ // Held here instead, and handed to the first subscriber on the
2909
+ // next turn, so an unsubscribe returned in the same tick still
2910
+ // applies. The launch link belongs to getInitialURL once the page
2911
+ // has called it, whether native has dispatched it yet or not, so a
2912
+ // page that does both in the same tick, in either order, gets it
2913
+ // once. That includes a craftReady handler, which runs before
2914
+ // native dispatches anything. A page that asks getInitialURL only
2915
+ // later, after an await, should skip `initial` in onLink.
2916
+ //
2917
+ // The same block runs on Android (#215), where this script can run
2918
+ // twice in one document, so its state lives on window.
2919
+ (function installDeepLinkReplay(craft) {
2920
+ var replay = window.__craftDeepLinkReplay;
2921
+ if (!replay) {
2922
+ replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
2923
+ window.addEventListener('craftDeepLink', function(e) {
2924
+ if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
2925
+ });
2926
+ }
2927
+ function claimed(detail) {
2928
+ return replay.initialClaimed && detail && detail.initial;
2929
+ }
2930
+ craft._subscribeDeepLinks = function(callback) {
2931
+ var active = true;
2932
+ var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
2933
+ window.addEventListener('craftDeepLink', listener);
2934
+ if (!replay.subscribed) {
2935
+ replay.subscribed = true;
2936
+ setTimeout(function() {
2937
+ var pending = replay.undelivered;
2938
+ replay.undelivered = [];
2939
+ if (!active) return;
2940
+ pending.forEach(function(detail) { callback(detail); });
2941
+ }, 0);
2942
+ }
2943
+ return function() {
2944
+ active = false;
2945
+ window.removeEventListener('craftDeepLink', listener);
2946
+ };
2947
+ };
2948
+ craft._claimInitialDeepLink = function() {
2949
+ replay.initialClaimed = true;
2950
+ replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
2951
+ };
2952
+ })(window.craft);
2953
+
2533
2954
  (function installCraftMobileContract(craft) {
2534
2955
  var legacyShare = craft.share.bind(craft);
2535
2956
  var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -2561,14 +2982,25 @@ struct CraftWebView: UIViewRepresentable {
2561
2982
  getInfo: function() { return craft.getDeviceInfo(); },
2562
2983
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
2563
2984
  };
2985
+ // Feedback, as on Android and the web, where there is no
2986
+ // motor to fire: an app that left enableHaptics off gets
2987
+ // nothing played and a settled promise, so `await
2988
+ // haptics.selection()` in the middle of a flow does not stop
2989
+ // the flow. A native failure still rejects, and
2990
+ // craft.haptic() itself reports the refusal.
2991
+ function hapticFeedback(answer) {
2992
+ return answer.then(function() {}, function(error) {
2993
+ if (error && error.code === 'CAPABILITY_DISABLED') return;
2994
+ throw error;
2995
+ });
2996
+ }
2564
2997
  craft.haptics = {
2565
- impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
2998
+ impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
2566
2999
  notification: function(type) {
2567
- craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light');
2568
- return Promise.resolve();
3000
+ return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
2569
3001
  },
2570
- selection: function() { craft.haptic('soft'); return Promise.resolve(); },
2571
- vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
3002
+ selection: function() { return hapticFeedback(craft.haptic('soft')); },
3003
+ vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
2572
3004
  };
2573
3005
  craft.permissions = {
2574
3006
  check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); },
@@ -2608,9 +3040,7 @@ struct CraftWebView: UIViewRepresentable {
2608
3040
  },
2609
3041
  clearWatch: function(id) {
2610
3042
  locationWatchCallbacks.delete(id);
2611
- if (locationWatchCallbacks.size === 0) {
2612
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2613
- }
3043
+ if (locationWatchCallbacks.size === 0) void craft._invoke('clearWatch');
2614
3044
  },
2615
3045
  startRecording: function() { return craft._invoke('startLocationRecording'); },
2616
3046
  pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
@@ -2629,11 +3059,17 @@ struct CraftWebView: UIViewRepresentable {
2629
3059
  };
2630
3060
  craft.liveActivity = {
2631
3061
  start: function(options) { return craft._invoke('startLiveActivity', options || {}); },
2632
- update: function(options) { return craft._invoke('updateLiveActivity', options || {}); },
2633
- end: function() { return craft._invoke('endLiveActivity'); }
3062
+ update: function(idOrState, state) {
3063
+ if (typeof idOrState !== 'string') return craft._invoke('updateLiveActivity', idOrState || {});
3064
+ return craft._invoke('updateLiveActivity', Object.assign({}, state || {}, {id: idOrState}));
3065
+ },
3066
+ end: function(id, finalState) {
3067
+ if (typeof id !== 'string') return craft._invoke('endLiveActivity');
3068
+ return craft._invoke('endLiveActivity', Object.assign({}, finalState || {}, {id: id}));
3069
+ }
2634
3070
  };
2635
3071
  var shareApi = function(text) { return legacyShare(text); };
2636
- shareApi.share = function(options) { return craft._invoke('share', {options: options || {}}); };
3072
+ shareApi.share = function(options) { return craft._share({options: options || {}}); };
2637
3073
  craft.share = shareApi;
2638
3074
  craft.lifecycle = {
2639
3075
  getState: function() { return document.visibilityState === 'visible' ? 'active' : 'background'; },
@@ -2686,6 +3122,7 @@ struct CraftWebView: UIViewRepresentable {
2686
3122
 
2687
3123
  // Mark DeepLinkManager as ready
2688
3124
  DeepLinkManager.shared.setReady()
3125
+ CraftEventManager.shared.setReady()
2689
3126
  }
2690
3127
 
2691
3128
  // MARK: - Callback Helpers
@@ -2899,7 +3336,15 @@ struct CraftWebView: UIViewRepresentable {
2899
3336
  func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey : Any]) {
2900
3337
  picker.dismiss(animated: true)
2901
3338
 
2902
- if let image = info[.originalImage] as? UIImage,
3339
+ if let movieURL = info[.mediaURL] as? URL {
3340
+ do {
3341
+ let movieData = try Data(contentsOf: movieURL, options: .mappedIfSafe)
3342
+ let base64 = movieData.base64EncodedString()
3343
+ resolveCallback(pendingCallbackId, result: "data:video/quicktime;base64," + base64)
3344
+ } catch {
3345
+ rejectCallback(pendingCallbackId, error: "Failed to process video: \(error.localizedDescription)")
3346
+ }
3347
+ } else if let image = info[.originalImage] as? UIImage,
2903
3348
  let imageData = image.jpegData(compressionQuality: 0.8) {
2904
3349
  let base64 = imageData.base64EncodedString()
2905
3350
  resolveCallback(pendingCallbackId, result: [
@@ -3036,7 +3481,7 @@ struct CraftWebView: UIViewRepresentable {
3036
3481
  }
3037
3482
  switch permission {
3038
3483
  case "location", "locationAlways":
3039
- let status = CLLocationManager.authorizationStatus()
3484
+ let status = (locationManager ?? CLLocationManager()).authorizationStatus
3040
3485
  let granted = status == .authorizedAlways || (permission == "location" && status == .authorizedWhenInUse)
3041
3486
  resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
3042
3487
  case "camera":
@@ -3084,11 +3529,27 @@ struct CraftWebView: UIViewRepresentable {
3084
3529
  }
3085
3530
  switch permission {
3086
3531
  case "location", "locationAlways":
3532
+ guard let manager = locationManager else {
3533
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3534
+ return
3535
+ }
3536
+ manager.delegate = self
3537
+ let requiresAlways = permission == "locationAlways" || config.enableBackgroundLocation
3538
+ let status = manager.authorizationStatus
3539
+ let alreadyGranted = status == .authorizedAlways || (!requiresAlways && status == .authorizedWhenInUse)
3540
+ if alreadyGranted || status == .denied || status == .restricted {
3541
+ resolveCallback(callbackId, result: permissionStatus(alreadyGranted, denied: status == .denied, restricted: status == .restricted))
3542
+ return
3543
+ }
3544
+ if let pendingCallbackId = locationPermissionCallbackId {
3545
+ rejectCallback(pendingCallbackId, error: "A newer location permission request replaced this request", code: "REQUEST_REPLACED")
3546
+ }
3087
3547
  locationPermissionCallbackId = callbackId
3088
- if permission == "locationAlways" || config.enableBackgroundLocation {
3089
- locationManager?.requestAlwaysAuthorization()
3548
+ locationPermissionRequiresAlways = requiresAlways
3549
+ if requiresAlways {
3550
+ manager.requestAlwaysAuthorization()
3090
3551
  } else {
3091
- locationManager?.requestWhenInUseAuthorization()
3552
+ manager.requestWhenInUseAuthorization()
3092
3553
  }
3093
3554
  case "camera":
3094
3555
  AVCaptureDevice.requestAccess(for: .video) { granted in
@@ -3134,11 +3595,60 @@ struct CraftWebView: UIViewRepresentable {
3134
3595
  }
3135
3596
 
3136
3597
  // MARK: - Geolocation
3137
- private func getCurrentPosition(callbackId: String?) {
3138
- locationManager?.delegate = self
3598
+ private func getCurrentPosition(body: [String: Any], callbackId: String?) {
3599
+ guard let manager = locationManager else {
3600
+ rejectCallback(callbackId, error: "Geolocation is disabled", code: "CAPABILITY_DISABLED")
3601
+ return
3602
+ }
3603
+ manager.delegate = self
3604
+ manager.desiredAccuracy = body["enableHighAccuracy"] as? Bool == true
3605
+ ? kCLLocationAccuracyBest
3606
+ : kCLLocationAccuracyHundredMeters
3607
+
3608
+ if let pendingCallbackId = singleLocationCallbackId {
3609
+ finishSingleLocationRequest()
3610
+ rejectCallback(pendingCallbackId, error: "A newer location request replaced this request", code: "POSITION_UNAVAILABLE")
3611
+ }
3139
3612
  singleLocationCallbackId = callbackId
3613
+ let maximumAge = max(0, (body["maximumAge"] as? NSNumber)?.doubleValue ?? 0)
3614
+ if maximumAge > 0,
3615
+ let cachedLocation = manager.location,
3616
+ max(0, Date().timeIntervalSince(cachedLocation.timestamp) * 1000) <= maximumAge {
3617
+ finishSingleLocationRequest()
3618
+ resolveCallback(callbackId, result: locationData(cachedLocation))
3619
+ return
3620
+ }
3621
+
3622
+ let timeoutMs = max(0, (body["timeout"] as? NSNumber)?.doubleValue ?? 30_000)
3623
+ let timeoutWorkItem = DispatchWorkItem { [weak self] in
3624
+ guard let self, self.singleLocationCallbackId == callbackId else { return }
3625
+ self.finishSingleLocationRequest()
3626
+ self.rejectCallback(callbackId, error: "Location request timed out", code: "LOCATION_TIMEOUT")
3627
+ }
3628
+ singleLocationTimeoutWorkItem = timeoutWorkItem
3629
+ DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(min(timeoutMs + 100, Double(Int.max)))), execute: timeoutWorkItem)
3140
3630
  requestLocationAuthorization()
3141
- locationManager?.requestLocation()
3631
+ manager.requestLocation()
3632
+ }
3633
+
3634
+ private func finishSingleLocationRequest() {
3635
+ singleLocationTimeoutWorkItem?.cancel()
3636
+ singleLocationTimeoutWorkItem = nil
3637
+ singleLocationCallbackId = nil
3638
+ locationManager?.desiredAccuracy = kCLLocationAccuracyBest
3639
+ }
3640
+
3641
+ private func locationData(_ location: CLLocation) -> [String: Any] {
3642
+ [
3643
+ "latitude": location.coordinate.latitude,
3644
+ "longitude": location.coordinate.longitude,
3645
+ "altitude": location.altitude,
3646
+ "accuracy": location.horizontalAccuracy,
3647
+ "altitudeAccuracy": location.verticalAccuracy,
3648
+ "heading": location.course,
3649
+ "speed": location.speed,
3650
+ "timestamp": location.timestamp.timeIntervalSince1970 * 1000
3651
+ ]
3142
3652
  }
3143
3653
 
3144
3654
  private func watchPosition(callbackId: String?) {
@@ -3326,20 +3836,11 @@ struct CraftWebView: UIViewRepresentable {
3326
3836
 
3327
3837
  func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
3328
3838
  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
- ]
3839
+ let data = locationData(location)
3339
3840
 
3340
3841
  if let callbackId = singleLocationCallbackId {
3842
+ finishSingleLocationRequest()
3341
3843
  resolveCallback(callbackId, result: data)
3342
- singleLocationCallbackId = nil
3343
3844
  }
3344
3845
 
3345
3846
  appendRecordedLocation(data)
@@ -3349,8 +3850,13 @@ struct CraftWebView: UIViewRepresentable {
3349
3850
  }
3350
3851
 
3351
3852
  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
3352
- rejectCallback(singleLocationCallbackId, error: error.localizedDescription)
3353
- singleLocationCallbackId = nil
3853
+ let callbackId = singleLocationCallbackId
3854
+ finishSingleLocationRequest()
3855
+ let nativeError = error as NSError
3856
+ let code = nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.denied.rawValue
3857
+ ? "PERMISSION_DENIED"
3858
+ : "POSITION_UNAVAILABLE"
3859
+ rejectCallback(callbackId, error: error.localizedDescription, code: code)
3354
3860
  sendToWeb("craftLocationError", data: ["message": error.localizedDescription])
3355
3861
  }
3356
3862
 
@@ -3358,9 +3864,11 @@ struct CraftWebView: UIViewRepresentable {
3358
3864
  guard let callbackId = locationPermissionCallbackId else { return }
3359
3865
  let status = manager.authorizationStatus
3360
3866
  if status == .notDetermined { return }
3361
- let granted = status == .authorizedAlways || status == .authorizedWhenInUse
3867
+ if locationPermissionRequiresAlways && status == .authorizedWhenInUse { return }
3868
+ let granted = status == .authorizedAlways || (!locationPermissionRequiresAlways && status == .authorizedWhenInUse)
3362
3869
  resolveCallback(callbackId, result: permissionStatus(granted, denied: status == .denied, restricted: status == .restricted))
3363
3870
  locationPermissionCallbackId = nil
3871
+ locationPermissionRequiresAlways = false
3364
3872
  }
3365
3873
 
3366
3874
  // MARK: - Memory Usage (for Profiling)
@@ -3478,7 +3986,11 @@ struct CraftWebView: UIViewRepresentable {
3478
3986
 
3479
3987
  // MARK: - Contacts
3480
3988
  private func getContacts(callbackId: String?) {
3481
- contactStore?.requestAccess(for: .contacts) { [weak self] granted, error in
3989
+ guard let store = contactStore else {
3990
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
3991
+ return
3992
+ }
3993
+ store.requestAccess(for: .contacts) { [weak self] granted, error in
3482
3994
  guard granted else {
3483
3995
  self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3484
3996
  return
@@ -3489,7 +4001,7 @@ struct CraftWebView: UIViewRepresentable {
3489
4001
 
3490
4002
  var contacts: [[String: Any]] = []
3491
4003
  do {
3492
- try self?.contactStore?.enumerateContacts(with: request) { contact, _ in
4004
+ try store.enumerateContacts(with: request) { contact, _ in
3493
4005
  var phones: [String] = []
3494
4006
  for phone in contact.phoneNumbers {
3495
4007
  phones.append(phone.value.stringValue)
@@ -3515,9 +4027,13 @@ struct CraftWebView: UIViewRepresentable {
3515
4027
  }
3516
4028
 
3517
4029
  private func addContact(_ data: [String: Any], callbackId: String?) {
3518
- contactStore?.requestAccess(for: .contacts) { [weak self] granted, error in
4030
+ guard let store = contactStore else {
4031
+ rejectCallback(callbackId, error: "Contacts access is disabled", code: "CAPABILITY_DISABLED")
4032
+ return
4033
+ }
4034
+ store.requestAccess(for: .contacts) { [weak self] granted, error in
3519
4035
  guard granted else {
3520
- self?.rejectCallback(callbackId, error: "Permission denied")
4036
+ self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3521
4037
  return
3522
4038
  }
3523
4039
 
@@ -3535,7 +4051,7 @@ struct CraftWebView: UIViewRepresentable {
3535
4051
  saveRequest.add(contact, toContainerWithIdentifier: nil)
3536
4052
 
3537
4053
  do {
3538
- try self?.contactStore?.execute(saveRequest)
4054
+ try store.execute(saveRequest)
3539
4055
  self?.resolveCallback(callbackId, result: contact.identifier)
3540
4056
  } catch {
3541
4057
  self?.rejectCallback(callbackId, error: error.localizedDescription)
@@ -3545,17 +4061,23 @@ struct CraftWebView: UIViewRepresentable {
3545
4061
 
3546
4062
  // MARK: - Calendar
3547
4063
  private func getCalendarEvents(startDate: Double?, endDate: Double?, callbackId: String?) {
3548
- eventStore?.requestAccess(to: .event) { [weak self] granted, error in
4064
+ guard let store = eventStore else {
4065
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
4066
+ return
4067
+ }
4068
+ store.requestAccess(to: .event) { [weak self] granted, error in
3549
4069
  guard granted else {
3550
4070
  self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3551
4071
  return
3552
4072
  }
3553
4073
 
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())!
4074
+ let start = startDate.map { Date(timeIntervalSince1970: $0 / 1000) } ?? Date()
4075
+ let end = endDate.map { Date(timeIntervalSince1970: $0 / 1000) }
4076
+ ?? Calendar.current.date(byAdding: .month, value: 1, to: Date())
4077
+ ?? Date()
3556
4078
 
3557
- let predicate = self?.eventStore?.predicateForEvents(withStart: start, end: end, calendars: nil)
3558
- let events = self?.eventStore?.events(matching: predicate!) ?? []
4079
+ let predicate = store.predicateForEvents(withStart: start, end: end, calendars: nil)
4080
+ let events = store.events(matching: predicate)
3559
4081
 
3560
4082
  let eventData: [[String: Any]] = events.map { event in
3561
4083
  return [
@@ -3574,13 +4096,17 @@ struct CraftWebView: UIViewRepresentable {
3574
4096
  }
3575
4097
 
3576
4098
  private func createCalendarEvent(_ data: [String: Any], callbackId: String?) {
3577
- eventStore?.requestAccess(to: .event) { [weak self] granted, error in
4099
+ guard let store = eventStore else {
4100
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
4101
+ return
4102
+ }
4103
+ store.requestAccess(to: .event) { [weak self] granted, error in
3578
4104
  guard granted else {
3579
- self?.rejectCallback(callbackId, error: "Permission denied")
4105
+ self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3580
4106
  return
3581
4107
  }
3582
4108
 
3583
- let event = EKEvent(eventStore: self!.eventStore!)
4109
+ let event = EKEvent(eventStore: store)
3584
4110
  event.title = data["title"] as? String ?? ""
3585
4111
  event.location = data["location"] as? String
3586
4112
  event.notes = data["notes"] as? String
@@ -3592,11 +4118,15 @@ struct CraftWebView: UIViewRepresentable {
3592
4118
  event.endDate = Date(timeIntervalSince1970: end / 1000)
3593
4119
  }
3594
4120
  event.isAllDay = data["isAllDay"] as? Bool ?? false
3595
- event.calendar = self?.eventStore?.defaultCalendarForNewEvents
4121
+ event.calendar = store.defaultCalendarForNewEvents
3596
4122
 
3597
4123
  do {
3598
- try self?.eventStore?.save(event, span: .thisEvent)
3599
- self?.resolveCallback(callbackId, result: event.eventIdentifier)
4124
+ try store.save(event, span: .thisEvent)
4125
+ guard let identifier = event.eventIdentifier else {
4126
+ self?.rejectCallback(callbackId, error: "Saved event has no identifier")
4127
+ return
4128
+ }
4129
+ self?.resolveCallback(callbackId, result: identifier)
3600
4130
  } catch {
3601
4131
  self?.rejectCallback(callbackId, error: error.localizedDescription)
3602
4132
  }
@@ -3604,13 +4134,17 @@ struct CraftWebView: UIViewRepresentable {
3604
4134
  }
3605
4135
 
3606
4136
  private func deleteCalendarEvent(_ eventId: String, callbackId: String?) {
3607
- guard let event = eventStore?.event(withIdentifier: eventId) else {
4137
+ guard let store = eventStore else {
4138
+ rejectCallback(callbackId, error: "Calendar access is disabled", code: "CAPABILITY_DISABLED")
4139
+ return
4140
+ }
4141
+ guard let event = store.event(withIdentifier: eventId) else {
3608
4142
  rejectCallback(callbackId, error: "Event not found")
3609
4143
  return
3610
4144
  }
3611
4145
 
3612
4146
  do {
3613
- try eventStore?.remove(event, span: .thisEvent)
4147
+ try store.remove(event, span: .thisEvent)
3614
4148
  resolveCallback(callbackId, result: true)
3615
4149
  } catch {
3616
4150
  rejectCallback(callbackId, error: error.localizedDescription)
@@ -4388,10 +4922,25 @@ struct CraftWebView: UIViewRepresentable {
4388
4922
  }
4389
4923
 
4390
4924
  private func updateLiveActivity(body: [String: Any], callbackId: String?) {
4391
- guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4925
+ guard #available(iOS 16.2, *) else {
4392
4926
  rejectCallback(callbackId, error: "No Live Activity is running")
4393
4927
  return
4394
4928
  }
4929
+ let activities = Activity<CraftActivityAttributes>.activities
4930
+ let activity: Activity<CraftActivityAttributes>
4931
+ if let activityId = body["id"] as? String {
4932
+ guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
4933
+ rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
4934
+ return
4935
+ }
4936
+ activity = matchingActivity
4937
+ } else {
4938
+ guard let currentActivity = activities.first else {
4939
+ rejectCallback(callbackId, error: "No Live Activity is running")
4940
+ return
4941
+ }
4942
+ activity = currentActivity
4943
+ }
4395
4944
  let current = activity.content.state
4396
4945
  let state = CraftActivityAttributes.ContentState(
4397
4946
  status: body["status"] as? String ?? current.status,
@@ -4405,13 +4954,45 @@ struct CraftWebView: UIViewRepresentable {
4405
4954
  }
4406
4955
  }
4407
4956
 
4408
- private func endLiveActivity(callbackId: String?) {
4409
- guard #available(iOS 16.2, *), let activity = Activity<CraftActivityAttributes>.activities.first else {
4957
+ private func endLiveActivity(body: [String: Any], callbackId: String?) {
4958
+ guard #available(iOS 16.2, *) else {
4410
4959
  resolveCallback(callbackId, result: ["ended": false])
4411
4960
  return
4412
4961
  }
4962
+ let activities = Activity<CraftActivityAttributes>.activities
4963
+ let activity: Activity<CraftActivityAttributes>
4964
+ if let activityId = body["id"] as? String {
4965
+ guard let matchingActivity = activities.first(where: { $0.id == activityId }) else {
4966
+ rejectCallback(callbackId, error: "No Live Activity with id \(activityId) is running")
4967
+ return
4968
+ }
4969
+ activity = matchingActivity
4970
+ } else {
4971
+ guard let currentActivity = activities.first else {
4972
+ resolveCallback(callbackId, result: ["ended": false])
4973
+ return
4974
+ }
4975
+ activity = currentActivity
4976
+ }
4977
+ let hasFinalState = body["status"] != nil
4978
+ || body["distanceMeters"] != nil
4979
+ || body["durationSeconds"] != nil
4980
+ || body["progress"] != nil
4981
+ let finalContent: ActivityContent<CraftActivityAttributes.ContentState>?
4982
+ if hasFinalState {
4983
+ let current = activity.content.state
4984
+ let state = CraftActivityAttributes.ContentState(
4985
+ status: body["status"] as? String ?? current.status,
4986
+ distanceMeters: body["distanceMeters"] as? Double ?? current.distanceMeters,
4987
+ durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
4988
+ progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
4989
+ )
4990
+ finalContent = ActivityContent(state: state, staleDate: nil)
4991
+ } else {
4992
+ finalContent = nil
4993
+ }
4413
4994
  Task {
4414
- await activity.end(nil, dismissalPolicy: .default)
4995
+ await activity.end(finalContent, dismissalPolicy: .default)
4415
4996
  resolveCallback(callbackId, result: ["ended": true])
4416
4997
  }
4417
4998
  }
@@ -5039,9 +5620,42 @@ struct CraftWebView: UIViewRepresentable {
5039
5620
  resolveCallback(callbackId, result: ["registered": true, "action": action, "phrase": phrase])
5040
5621
  }
5041
5622
 
5623
+ // Removals waiting on their completion, each under a token of its own:
5624
+ // callbackId can be nil, and restarts when the page reloads.
5625
+ private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:]
5626
+ // The only thing that settles craft.siri.remove, which arms no timeout
5627
+ // of its own; the same as Zig's.
5628
+ private static let siriRemovalDeadline: TimeInterval = 15
5629
+
5042
5630
  private func removeSiriShortcut(action: String, callbackId: String?) {
5631
+ // The completion comes from a system daemon, and sometimes it never
5632
+ // comes (#211). The deadline settles the call instead, and never
5633
+ // with removed: true, because the deletion may still have happened.
5634
+ // Both run on the main queue and remove the same entry, so only one
5635
+ // of them answers.
5636
+ let token = UUID()
5637
+ let deadline = DispatchWorkItem { [weak self] in
5638
+ guard let self, self.pendingSiriRemovals.removeValue(forKey: token) != nil else { return }
5639
+ self.rejectCallback(
5640
+ callbackId,
5641
+ error: "NSUserActivity.deleteSavedUserActivities did not call its completion handler within \(Int(Coordinator.siriRemovalDeadline))s",
5642
+ code: "TIMEOUT"
5643
+ )
5644
+ }
5645
+ pendingSiriRemovals[token] = deadline
5646
+ DispatchQueue.main.asyncAfter(deadline: .now() + Coordinator.siriRemovalDeadline, execute: deadline)
5647
+
5043
5648
  NSUserActivity.deleteSavedUserActivities(withPersistentIdentifiers: [action]) {
5044
- self.resolveCallback(callbackId, result: ["removed": true, "action": action])
5649
+ // The queue this runs on is not documented, and a zig: hand-off
5650
+ // reply goes to evaluateJavaScript without a hop of its own.
5651
+ DispatchQueue.main.async { [weak self] in
5652
+ guard let self, let pending = self.pendingSiriRemovals.removeValue(forKey: token) else {
5653
+ print("removeSiriShortcut: the completion for \(action) arrived after its deadline; ignored")
5654
+ return
5655
+ }
5656
+ pending.cancel()
5657
+ self.resolveCallback(callbackId, result: ["removed": true, "action": action])
5658
+ }
5045
5659
  }
5046
5660
  }
5047
5661