@craft-native/ios 0.0.92 → 0.0.94

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.
@@ -34,7 +34,42 @@ import ActivityKit
34
34
  extension Notification.Name {
35
35
  static let craftPushToken = Notification.Name("craftPushToken")
36
36
  static let craftPushRegistrationError = Notification.Name("craftPushRegistrationError")
37
- static let craftNotificationResponse = Notification.Name("craftNotificationResponse")
37
+ }
38
+
39
+ /// Which failed page loads mean the remote origin is out of reach (#252).
40
+ ///
41
+ /// That is the only failure the bundled copy stands in for. Anything else
42
+ /// either is not a failure at all or is one the bundle would hide: a TLS error
43
+ /// or a bad response means the server *was* reached, and swapping in a local
44
+ /// copy would bury a real fault under a page that half-works.
45
+ ///
46
+ /// Written against NSError's domain and code rather than WebKit's types, so it
47
+ /// compiles with Foundation alone — which is how `compile-templates` runs it on
48
+ /// its own, on the CI host, rather than only reading its text.
49
+ enum CraftLoadFailure {
50
+ static func isUnreachable(_ error: Error) -> Bool {
51
+ let error = error as NSError
52
+ // Everything outside NSURLErrorDomain is excluded, WebKit's own
53
+ // frame-load-interrupted (WebKitErrorDomain 102) among them: a load
54
+ // replaced by a newer one is not a load that failed.
55
+ guard error.domain == NSURLErrorDomain else { return false }
56
+ switch error.code {
57
+ case NSURLErrorNotConnectedToInternet,
58
+ NSURLErrorCannotFindHost,
59
+ NSURLErrorCannotConnectToHost,
60
+ NSURLErrorTimedOut,
61
+ NSURLErrorNetworkConnectionLost,
62
+ NSURLErrorDNSLookupFailed,
63
+ NSURLErrorDataNotAllowed,
64
+ NSURLErrorInternationalRoamingOff:
65
+ return true
66
+ default:
67
+ // NSURLErrorCancelled (-999) lands here. WebKit reports it for two
68
+ // quick taps, a redirect, or a `location.assign` while a load is in
69
+ // flight — and it used to end the session on the bundled copy.
70
+ return false
71
+ }
72
+ }
38
73
  }
39
74
 
40
75
  final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
@@ -68,11 +103,23 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
68
103
  }
69
104
 
70
105
  func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
71
- NotificationCenter.default.post(name: .craftNotificationResponse, object: response.notification.request.content.userInfo)
106
+ // Through the manager, not NotificationCenter. A tap that launches a
107
+ // killed app arrives here before SwiftUI has built the Coordinator, and
108
+ // NotificationCenter keeps nothing for an observer that does not exist
109
+ // yet — so the tap was dropped, and the page opened wherever it opens
110
+ // rather than where the notification pointed. The manager exists from
111
+ // process start and holds the tap until the page is ready, the way it
112
+ // already does for a home-screen shortcut.
113
+ CraftEventManager.shared.handleNotificationResponse(response.notification.request.content.userInfo)
72
114
  completionHandler()
73
115
  }
74
116
 
75
117
  func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
118
+ // Told to the page as well as shown. A page that is open when a push
119
+ // lands used to hear about it only if someone tapped the banner, so it
120
+ // could not refresh what the push was about while it was on screen.
121
+ // The banner still shows: the page is told, not asked.
122
+ CraftEventManager.shared.handleNotificationReceived(notification.request.content.userInfo)
76
123
  completionHandler([.banner, .badge, .sound])
77
124
  }
78
125
  }
@@ -108,6 +155,30 @@ class CraftEventManager {
108
155
  sendToWeb("craftShortcut", data: ["type": shortcut.type])
109
156
  }
110
157
 
158
+ /// A tap on a notification, carrying the payload it was sent with.
159
+ ///
160
+ /// Keys that are not strings are dropped rather than failing the whole
161
+ /// event: an APNs payload's keys always are, and the page could not index
162
+ /// by anything else anyway.
163
+ func handleNotificationResponse(_ userInfo: [AnyHashable: Any]) {
164
+ sendToWeb("craftNotificationResponse", data: Self.pageData(userInfo))
165
+ }
166
+
167
+ /// A notification that arrived while the app was in front, in the same
168
+ /// shape as a tap so one handler can read both.
169
+ func handleNotificationReceived(_ userInfo: [AnyHashable: Any]) {
170
+ sendToWeb("craftNotificationReceived", data: Self.pageData(userInfo))
171
+ }
172
+
173
+ private static func pageData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
174
+ var data: [String: Any] = [:]
175
+ for (key, value) in userInfo {
176
+ guard let key = key as? String else { continue }
177
+ data[key] = value
178
+ }
179
+ return data
180
+ }
181
+
111
182
  func handleSiriActivity(_ activity: NSUserActivity) -> Bool {
112
183
  let prefix = "\(Bundle.main.bundleIdentifier ?? "{{BUNDLE_ID}}")."
113
184
  guard activity.activityType.hasPrefix(prefix) else { return false }
@@ -495,6 +566,21 @@ struct CraftWebView: UIViewRepresentable {
495
566
  private var pendingPushCallbackId: String?
496
567
  private var loadedBundledFallback = false
497
568
 
569
+ /// The highest `cb_<n>` this process has seen the page hand out.
570
+ ///
571
+ /// The page's own counter restarts at 0 on every injection, which is
572
+ /// every reload, navigation and web-content crash recovery, and
573
+ /// nothing native records which load a call came from. So an answer
574
+ /// to a call made before a reload was delivered to whichever call on
575
+ /// the new page drew the same number: `getDeviceInfo` settled with a
576
+ /// Siri result, or rejected with the earlier call's TIMEOUT (#226).
577
+ ///
578
+ /// Seeding each injection above everything already handed out makes
579
+ /// an id unique for the life of the process. A late answer then names
580
+ /// a callback no page has, and is dropped where it lands — on either
581
+ /// runtime, and without native having to track page loads at all.
582
+ private var highestCallbackId = 0
583
+
498
584
  // Location
499
585
  private var locationManager: CLLocationManager?
500
586
  private var singleLocationCallbackId: String?
@@ -584,7 +670,7 @@ struct CraftWebView: UIViewRepresentable {
584
670
  }
585
671
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
586
672
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
587
- NotificationCenter.default.addObserver(self, selector: #selector(receiveNotificationResponse(_:)), name: .craftNotificationResponse, object: nil)
673
+ NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
588
674
  setupNetworkMonitoring()
589
675
  }
590
676
 
@@ -607,6 +693,9 @@ struct CraftWebView: UIViewRepresentable {
607
693
  private func setupNetworkMonitoring() {
608
694
  networkMonitor = NWPathMonitor()
609
695
  networkMonitor?.pathUpdateHandler = { [weak self] path in
696
+ // Read before it is overwritten: the retry below wants the
697
+ // transition, not the state.
698
+ let wasConnected = self?.isConnected ?? true
610
699
  self?.isConnected = path.status == .satisfied
611
700
  if path.usesInterfaceType(.wifi) {
612
701
  self?.connectionType = "wifi"
@@ -621,6 +710,16 @@ struct CraftWebView: UIViewRepresentable {
621
710
  "isConnected": self?.isConnected ?? false,
622
711
  "type": self?.connectionType ?? "unknown"
623
712
  ])
713
+ // Only on false → true. Retrying whenever the path is merely
714
+ // satisfied would loop: a server that is down on a network
715
+ // that is up leaves the path satisfied throughout, so every
716
+ // update would retry, fail, fall back and retry again. That
717
+ // case recovers on the next foreground instead.
718
+ if !wasConnected, self?.isConnected == true {
719
+ DispatchQueue.main.async {
720
+ self?.returnFromBundledFallback(because: "the network came back")
721
+ }
722
+ }
624
723
  }
625
724
  networkMonitor?.start(queue: DispatchQueue.global())
626
725
  }
@@ -635,6 +734,9 @@ struct CraftWebView: UIViewRepresentable {
635
734
  let action = body["action"] as? String else { return }
636
735
 
637
736
  let callbackId = body["callbackId"] as? String
737
+ // Before Zig is offered the call, because the seed has to cover
738
+ // every id the page drew, not only the ones Swift went on to serve.
739
+ noteCallbackId(callbackId)
638
740
 
639
741
  // The Zig runtime first, when there is one.
640
742
  //
@@ -845,6 +947,8 @@ struct CraftWebView: UIViewRepresentable {
845
947
  case "setBadge":
846
948
  if let count = body["count"] as? Int {
847
949
  setBadgeCount(count, callbackId: callbackId)
950
+ } else {
951
+ rejectCallback(callbackId, error: "setBadge needs a whole number of badges", code: "INVALID_ARGUMENT")
848
952
  }
849
953
  case "clearBadge":
850
954
  setBadgeCount(0, callbackId: callbackId)
@@ -859,6 +963,8 @@ struct CraftWebView: UIViewRepresentable {
859
963
  case "setFlashlight":
860
964
  if let enabled = body["enabled"] as? Bool {
861
965
  setFlashlight(enabled: enabled, callbackId: callbackId)
966
+ } else {
967
+ rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
862
968
  }
863
969
  // Vibrate pattern
864
970
  case "vibrate":
@@ -873,6 +979,8 @@ struct CraftWebView: UIViewRepresentable {
873
979
  if let urlString = body["url"] as? String, let url = URL(string: urlString) {
874
980
  UIApplication.shared.open(url)
875
981
  resolveCallback(callbackId, result: true)
982
+ } else {
983
+ rejectCallback(callbackId, error: "openURL needs a URL it can parse", code: "INVALID_ARGUMENT")
876
984
  }
877
985
  // App state
878
986
  case "getAppState":
@@ -1280,6 +1388,8 @@ struct CraftWebView: UIViewRepresentable {
1280
1388
  case "setShortcuts":
1281
1389
  if let shortcuts = body["shortcuts"] as? [[String: Any]] {
1282
1390
  setAppShortcuts(shortcuts: shortcuts, callbackId: callbackId)
1391
+ } else {
1392
+ rejectCallback(callbackId, error: "setShortcuts needs a list of shortcuts", code: "INVALID_ARGUMENT")
1283
1393
  }
1284
1394
  case "clearShortcuts":
1285
1395
  clearAppShortcuts(callbackId: callbackId)
@@ -1290,16 +1400,22 @@ struct CraftWebView: UIViewRepresentable {
1290
1400
  let value = body["value"] as? String {
1291
1401
  let group = body["group"] as? String
1292
1402
  setSharedKeychainItem(key: key, value: value, group: group, callbackId: callbackId)
1403
+ } else {
1404
+ rejectCallback(callbackId, error: "setSharedItem needs both a key and a value", code: "INVALID_ARGUMENT")
1293
1405
  }
1294
1406
  case "getSharedItem":
1295
1407
  if let key = body["key"] as? String {
1296
1408
  let group = body["group"] as? String
1297
1409
  getSharedKeychainItem(key: key, group: group, callbackId: callbackId)
1410
+ } else {
1411
+ rejectCallback(callbackId, error: "getSharedItem needs a key", code: "INVALID_ARGUMENT")
1298
1412
  }
1299
1413
  case "removeSharedItem":
1300
1414
  if let key = body["key"] as? String {
1301
1415
  let group = body["group"] as? String
1302
1416
  removeSharedKeychainItem(key: key, group: group, callbackId: callbackId)
1417
+ } else {
1418
+ rejectCallback(callbackId, error: "removeSharedItem needs a key", code: "INVALID_ARGUMENT")
1303
1419
  }
1304
1420
 
1305
1421
  // MARK: - Local Auth Persistence
@@ -1307,6 +1423,8 @@ struct CraftWebView: UIViewRepresentable {
1307
1423
  if let enabled = body["enabled"] as? Bool {
1308
1424
  let duration = body["duration"] as? Double ?? 300 // 5 min default
1309
1425
  setBiometricPersistence(enabled: enabled, duration: duration, callbackId: callbackId)
1426
+ } else {
1427
+ rejectCallback(callbackId, error: "setBiometricPersistence needs true or false", code: "INVALID_ARGUMENT")
1310
1428
  }
1311
1429
  case "checkBiometricPersistence":
1312
1430
  checkBiometricPersistence(callbackId: callbackId)
@@ -1390,6 +1508,8 @@ struct CraftWebView: UIViewRepresentable {
1390
1508
  case "updateWidget":
1391
1509
  if let data = body["data"] as? [String: Any] {
1392
1510
  updateWidget(data: data, callbackId: callbackId)
1511
+ } else {
1512
+ rejectCallback(callbackId, error: "updateWidget needs a data object", code: "INVALID_ARGUMENT")
1393
1513
  }
1394
1514
  case "reloadWidgets":
1395
1515
  reloadAllWidgets(callbackId: callbackId)
@@ -1399,20 +1519,28 @@ struct CraftWebView: UIViewRepresentable {
1399
1519
  if let phrase = body["phrase"] as? String,
1400
1520
  let action = body["shortcutAction"] as? String {
1401
1521
  registerSiriShortcut(phrase: phrase, action: action, callbackId: callbackId)
1522
+ } else {
1523
+ rejectCallback(callbackId, error: "registerSiriShortcut needs both a phrase and a shortcutAction", code: "INVALID_ARGUMENT")
1402
1524
  }
1403
1525
  case "removeSiriShortcut":
1404
1526
  if let action = body["shortcutAction"] as? String {
1405
1527
  removeSiriShortcut(action: action, callbackId: callbackId)
1528
+ } else {
1529
+ rejectCallback(callbackId, error: "removeSiriShortcut needs a shortcutAction", code: "INVALID_ARGUMENT")
1406
1530
  }
1407
1531
 
1408
1532
  // MARK: - Watch Connectivity
1409
1533
  case "sendToWatch":
1410
1534
  if let message = body["message"] as? [String: Any] {
1411
1535
  sendMessageToWatch(message: message, callbackId: callbackId)
1536
+ } else {
1537
+ rejectCallback(callbackId, error: "sendToWatch needs a message object", code: "INVALID_ARGUMENT")
1412
1538
  }
1413
1539
  case "updateWatchContext":
1414
1540
  if let context = body["context"] as? [String: Any] {
1415
1541
  updateWatchContext(context: context, callbackId: callbackId)
1542
+ } else {
1543
+ rejectCallback(callbackId, error: "updateWatchContext needs a context object", code: "INVALID_ARGUMENT")
1416
1544
  }
1417
1545
  case "isWatchReachable":
1418
1546
  isWatchReachable(callbackId: callbackId)
@@ -1453,10 +1581,17 @@ struct CraftWebView: UIViewRepresentable {
1453
1581
  }
1454
1582
 
1455
1583
  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1456
- loadBundledFallback(in: webView)
1584
+ fallBackIfUnreachable(webView, after: error)
1457
1585
  }
1458
1586
 
1459
1587
  func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
1588
+ fallBackIfUnreachable(webView, after: error)
1589
+ }
1590
+
1591
+ /// Both used to fall back on *any* error, -999 included, so a second
1592
+ /// tap before the first page committed ended the session on the bundle.
1593
+ private func fallBackIfUnreachable(_ webView: WKWebView, after error: Error) {
1594
+ guard CraftLoadFailure.isUnreachable(error) else { return }
1460
1595
  loadBundledFallback(in: webView)
1461
1596
  }
1462
1597
 
@@ -1465,9 +1600,35 @@ struct CraftWebView: UIViewRepresentable {
1465
1600
  config.devServerURL != nil,
1466
1601
  let bundledURL = URL(string: "craft://app/index.html") else { return }
1467
1602
  loadedBundledFallback = true
1603
+ // Kept for the way back: `returnFromBundledFallback` has no view of
1604
+ // its own to reload.
1605
+ self.webView = webView
1468
1606
  webView.load(URLRequest(url: bundledURL))
1469
1607
  }
1470
1608
 
1609
+ /// Leave the bundled copy for the remote origin again (#252).
1610
+ ///
1611
+ /// It used to be one-way for the life of the process, and the bundle
1612
+ /// cannot reach a frontend's API by relative path — so one dropped
1613
+ /// connection meant no API until the app was killed.
1614
+ ///
1615
+ /// Called when connectivity comes back and when the app returns to the
1616
+ /// foreground, never on every path update. If the remote is still out
1617
+ /// of reach, the load fails with a connectivity error and falls back
1618
+ /// again: one round trip per trigger, not a loop.
1619
+ private func returnFromBundledFallback(because reason: String) {
1620
+ guard loadedBundledFallback,
1621
+ let webView,
1622
+ let remote = config.devServerURL.flatMap(URL.init(string:)) else { return }
1623
+ loadedBundledFallback = false
1624
+ print("Craft: \(reason); loading \(remote) again instead of the bundled copy")
1625
+ webView.load(URLRequest(url: remote))
1626
+ }
1627
+
1628
+ @objc private func appWillEnterForeground(_ notification: Notification) {
1629
+ returnFromBundledFallback(because: "the app returned to the foreground")
1630
+ }
1631
+
1471
1632
  private func isTrustedURL(_ url: URL?) -> Bool {
1472
1633
  guard let url = url else { return false }
1473
1634
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
@@ -1509,6 +1670,16 @@ struct CraftWebView: UIViewRepresentable {
1509
1670
  return allowed.contains(origin)
1510
1671
  }
1511
1672
 
1673
+ /// Raise the seed to cover an id the page has just used.
1674
+ ///
1675
+ /// Every call the page makes arrives here first, including the ones
1676
+ /// Zig serves, so this sees the whole range a load hands out.
1677
+ private func noteCallbackId(_ callbackId: String?) {
1678
+ guard let callbackId, callbackId.hasPrefix("cb_"),
1679
+ let drawn = Int(callbackId.dropFirst(3)) else { return }
1680
+ if drawn > highestCallbackId { highestCallbackId = drawn }
1681
+ }
1682
+
1512
1683
  private func injectNativeBridge() {
1513
1684
  let laContext = LAContext()
1514
1685
  var biometricAvailable = false
@@ -1548,7 +1719,9 @@ struct CraftWebView: UIViewRepresentable {
1548
1719
  },
1549
1720
 
1550
1721
  _callbacks: {},
1551
- _callbackId: 0,
1722
+ // Not 0: an id must not repeat one an earlier load of this
1723
+ // page is still waiting on an answer for (#226).
1724
+ _callbackId: \(highestCallbackId),
1552
1725
 
1553
1726
  _createCallback: function() {
1554
1727
  var id = 'cb_' + (++this._callbackId);
@@ -1952,10 +2125,10 @@ struct CraftWebView: UIViewRepresentable {
1952
2125
  return this.geolocation.getCurrentPosition({});
1953
2126
  },
1954
2127
  watchPosition: function(callback) {
1955
- return this.geolocation.watchPosition(callback);
2128
+ return this.location.watchPosition(callback);
1956
2129
  },
1957
2130
  clearWatch: function(watchId) {
1958
- return this.geolocation.clearWatch(watchId);
2131
+ return this.location.clearWatch(watchId);
1959
2132
  },
1960
2133
  getContacts: function() {
1961
2134
  return this._invoke('getContacts');
@@ -2951,6 +3124,60 @@ struct CraftWebView: UIViewRepresentable {
2951
3124
  };
2952
3125
  })(window.craft);
2953
3126
 
3127
+ // A notification tap that arrived before anything subscribed.
3128
+ //
3129
+ // On a cold launch the tap is flushed the moment the bridge is
3130
+ // ready, and a page that wires its listener once its router has
3131
+ // hydrated — which is most single-page apps — was not listening
3132
+ // yet, so "open the thing this notification is about" opened the
3133
+ // home screen instead. Deep links hit the same wall (#198) and
3134
+ // are held the same way: state on window, so a second injection
3135
+ // into the same page does not start a fresh buffer, and the first
3136
+ // subscriber is handed whatever it missed.
3137
+ (function installNotificationTapReplay(craft) {
3138
+ var replay = window.__craftNotificationTapReplay;
3139
+ if (!replay) {
3140
+ replay = window.__craftNotificationTapReplay = {undelivered: [], subscribed: false};
3141
+ window.addEventListener('craftNotificationResponse', function(e) {
3142
+ if (!replay.subscribed) replay.undelivered.push(e.detail);
3143
+ });
3144
+ }
3145
+ craft._subscribeNotificationTaps = function(callback) {
3146
+ var active = true;
3147
+ var listener = function(e) { callback(e.detail); };
3148
+ window.addEventListener('craftNotificationResponse', listener);
3149
+ if (!replay.subscribed) {
3150
+ replay.subscribed = true;
3151
+ setTimeout(function() {
3152
+ var pending = replay.undelivered;
3153
+ replay.undelivered = [];
3154
+ if (!active) return;
3155
+ pending.forEach(function(detail) { callback(detail); });
3156
+ }, 0);
3157
+ }
3158
+ return function() {
3159
+ active = false;
3160
+ window.removeEventListener('craftNotificationResponse', listener);
3161
+ };
3162
+ };
3163
+ if (craft.notifications) {
3164
+ craft.notifications.onTap = function(callback) {
3165
+ return craft._subscribeNotificationTaps(callback);
3166
+ };
3167
+ // A notification that arrived while the page was open.
3168
+ // Live only, with nothing held: it matters to a page that
3169
+ // is running, and one that subscribes later has no use for
3170
+ // an arrival it was not there to see.
3171
+ craft.notifications.onReceive = function(callback) {
3172
+ var listener = function(e) { callback(e.detail); };
3173
+ window.addEventListener('craftNotificationReceived', listener);
3174
+ return function() {
3175
+ window.removeEventListener('craftNotificationReceived', listener);
3176
+ };
3177
+ };
3178
+ }
3179
+ })(window.craft);
3180
+
2954
3181
  (function installCraftMobileContract(craft) {
2955
3182
  var legacyShare = craft.share.bind(craft);
2956
3183
  var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -3026,21 +3253,80 @@ struct CraftWebView: UIViewRepresentable {
3026
3253
  };
3027
3254
 
3028
3255
  var locationWatchCallbacks = new Map();
3256
+ var legacyLocationWatchCallbacks = [];
3029
3257
  var nextLocationWatchId = 0;
3258
+ var locationWatchStarted = false;
3259
+ var locationWatchStart = null;
3260
+ var locationWatchGeneration = 0;
3261
+
3262
+ function hasLocationWatchers() {
3263
+ return locationWatchCallbacks.size > 0 || legacyLocationWatchCallbacks.length > 0;
3264
+ }
3265
+
3266
+ function startLocationWatch() {
3267
+ if (locationWatchStarted) return Promise.resolve(true);
3268
+ if (locationWatchStart) return locationWatchStart;
3269
+
3270
+ var generation = locationWatchGeneration;
3271
+ var pending = craft._invoke('watchPosition');
3272
+ var managed = pending.then(function(result) {
3273
+ if (generation === locationWatchGeneration) {
3274
+ locationWatchStarted = hasLocationWatchers();
3275
+ }
3276
+ if (locationWatchStart === managed) locationWatchStart = null;
3277
+ return result;
3278
+ }, function(error) {
3279
+ if (generation === locationWatchGeneration) {
3280
+ locationWatchCallbacks.clear();
3281
+ legacyLocationWatchCallbacks.length = 0;
3282
+ locationWatchStarted = false;
3283
+ }
3284
+ if (locationWatchStart === managed) locationWatchStart = null;
3285
+ throw error;
3286
+ });
3287
+ locationWatchStart = managed;
3288
+ return managed;
3289
+ }
3290
+
3291
+ function stopLocationWatchIfUnused() {
3292
+ if (hasLocationWatchers()) return Promise.resolve(true);
3293
+ if (!locationWatchStarted && !locationWatchStart) return Promise.resolve(true);
3294
+
3295
+ locationWatchGeneration += 1;
3296
+ locationWatchStarted = false;
3297
+ locationWatchStart = null;
3298
+ return craft._invoke('clearWatch');
3299
+ }
3300
+
3030
3301
  window.addEventListener('craftLocationUpdate', function(event) {
3031
3302
  locationWatchCallbacks.forEach(function(callback) { callback(event.detail); });
3303
+ legacyLocationWatchCallbacks.slice().forEach(function(callback) { callback(event.detail); });
3032
3304
  });
3305
+ legacyGeolocation.watchPosition = function(callback) {
3306
+ legacyLocationWatchCallbacks.push(callback);
3307
+ return startLocationWatch();
3308
+ };
3309
+ legacyGeolocation.clearWatch = function() {
3310
+ legacyLocationWatchCallbacks.length = 0;
3311
+ return stopLocationWatchIfUnused();
3312
+ };
3033
3313
  craft.location = {
3034
3314
  getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
3035
3315
  watchPosition: function(callback) {
3036
3316
  var id = ++nextLocationWatchId;
3037
3317
  locationWatchCallbacks.set(id, callback);
3038
- if (locationWatchCallbacks.size === 1) void craft._invoke('watchPosition');
3318
+ void startLocationWatch().catch(function() {});
3039
3319
  return id;
3040
3320
  },
3041
3321
  clearWatch: function(id) {
3042
- locationWatchCallbacks.delete(id);
3043
- if (locationWatchCallbacks.size === 0) void craft._invoke('clearWatch');
3322
+ if (typeof id === 'undefined') {
3323
+ locationWatchCallbacks.clear();
3324
+ legacyLocationWatchCallbacks.length = 0;
3325
+ void stopLocationWatchIfUnused().catch(function() {});
3326
+ return;
3327
+ }
3328
+ if (!locationWatchCallbacks.delete(id)) return;
3329
+ void stopLocationWatchIfUnused().catch(function() {});
3044
3330
  },
3045
3331
  startRecording: function() { return craft._invoke('startLocationRecording'); },
3046
3332
  pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
@@ -3387,36 +3673,68 @@ struct CraftWebView: UIViewRepresentable {
3387
3673
  }
3388
3674
 
3389
3675
  // MARK: - Push Notifications
3676
+
3677
+ /// The deadline covering the APNs round trip, once authorization is in.
3678
+ private var pendingPushDeadline: UUID?
3679
+
3390
3680
  private func registerPushNotifications(callbackId: String?) {
3681
+ // A second call used to overwrite the first's callbackId, and the
3682
+ // first promise then never settled at all. Displacing a call is an
3683
+ // answer the caller can act on, the way a replaced
3684
+ // getCurrentPosition request is rejected rather than dropped.
3685
+ if let displaced = pendingPushCallbackId {
3686
+ if let token = pendingPushDeadline { _ = claimDeadline(token) }
3687
+ pendingPushDeadline = nil
3688
+ rejectCallback(displaced, error: "Replaced by another push registration", code: "CANCELLED")
3689
+ }
3391
3690
  pendingPushCallbackId = callbackId
3392
3691
  UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in
3393
3692
  if granted {
3394
3693
  DispatchQueue.main.async {
3694
+ guard let self else { return }
3695
+ // Armed here rather than at dispatch: until the person
3696
+ // answers the prompt there is someone to wait for, and
3697
+ // only the APNs round trip afterwards is unattended.
3698
+ self.pendingPushDeadline = self.armDeadline(
3699
+ Coordinator.pushRegistrationDeadline,
3700
+ callbackId: callbackId,
3701
+ error: "APNs did not call back within \(Int(Coordinator.pushRegistrationDeadline))s"
3702
+ )
3703
+ self.pendingPushCallbackId = callbackId
3395
3704
  UIApplication.shared.registerForRemoteNotifications()
3396
3705
  }
3397
3706
  } else {
3398
- self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3399
- self?.pendingPushCallbackId = nil
3707
+ DispatchQueue.main.async { [weak self] in
3708
+ guard let self, self.pendingPushCallbackId == callbackId else { return }
3709
+ self.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3710
+ self.pendingPushCallbackId = nil
3711
+ }
3400
3712
  }
3401
3713
  }
3402
3714
  }
3403
3715
 
3716
+ /// Whether this push callback is still the one waiting, claiming it if so.
3717
+ private func claimPushRegistration() -> String?? {
3718
+ guard let callbackId = pendingPushCallbackId else { return nil }
3719
+ if let token = pendingPushDeadline, !claimDeadline(token) { return nil }
3720
+ pendingPushDeadline = nil
3721
+ pendingPushCallbackId = nil
3722
+ return .some(callbackId)
3723
+ }
3724
+
3404
3725
  @objc private func receivePushToken(_ notification: Notification) {
3405
3726
  guard let token = notification.object as? String else { return }
3406
- resolveCallback(pendingPushCallbackId, result: token)
3407
- pendingPushCallbackId = nil
3727
+ // The event goes out either way: a token that arrives after its
3728
+ // deadline is still this app's token, and a page listening for
3729
+ // craftPushToken is not the caller that timed out.
3730
+ if let claimed = claimPushRegistration() { resolveCallback(claimed, result: token) }
3408
3731
  sendToWeb("craftPushToken", data: ["token": token])
3409
3732
  }
3410
3733
 
3411
3734
  @objc private func receivePushRegistrationError(_ notification: Notification) {
3412
3735
  let message = notification.object as? String ?? "Push registration failed"
3413
- rejectCallback(pendingPushCallbackId, error: message, code: "PUSH_REGISTRATION_ERROR")
3414
- pendingPushCallbackId = nil
3415
- }
3416
-
3417
- @objc private func receiveNotificationResponse(_ notification: Notification) {
3418
- let data = notification.object as? [String: Any] ?? [:]
3419
- sendToWeb("craftNotificationResponse", data: data)
3736
+ guard let claimed = claimPushRegistration() else { return }
3737
+ rejectCallback(claimed, error: message, code: "PUSH_REGISTRATION_ERROR")
3420
3738
  }
3421
3739
 
3422
3740
  // MARK: - Secure Storage (Keychain)
@@ -3507,6 +3825,13 @@ struct CraftWebView: UIViewRepresentable {
3507
3825
  let status = CBManager.authorization
3508
3826
  resolveCallback(callbackId, result: permissionStatus(status == .allowedAlways, denied: status == .denied, restricted: status == .restricted))
3509
3827
  case "notifications":
3828
+ // Zig answers UnknownAction for this permission, so Swift
3829
+ // serves it on both runtimes and this is the only answer.
3830
+ let token = armDeadline(
3831
+ Coordinator.notificationSettingsDeadline,
3832
+ callbackId: callbackId,
3833
+ error: "UNUserNotificationCenter.getNotificationSettings did not answer within \(Int(Coordinator.notificationSettingsDeadline))s"
3834
+ )
3510
3835
  UNUserNotificationCenter.current().getNotificationSettings { settings in
3511
3836
  let status: String
3512
3837
  switch settings.authorizationStatus {
@@ -3515,7 +3840,10 @@ struct CraftWebView: UIViewRepresentable {
3515
3840
  case .notDetermined: status = "undetermined"
3516
3841
  @unknown default: status = "undetermined"
3517
3842
  }
3518
- self.resolveCallback(callbackId, result: status)
3843
+ DispatchQueue.main.async { [weak self] in
3844
+ guard let self, self.claimDeadline(token) else { return }
3845
+ self.resolveCallback(callbackId, result: status)
3846
+ }
3519
3847
  }
3520
3848
  default:
3521
3849
  resolveCallback(callbackId, result: "undetermined")
@@ -3850,9 +4178,23 @@ struct CraftWebView: UIViewRepresentable {
3850
4178
  }
3851
4179
 
3852
4180
  func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
4181
+ let nativeError = error as NSError
4182
+ // kCLErrorLocationUnknown is Core Location saying it has no fix
4183
+ // *yet*, not that it failed. requestLocation gives up on it, so a
4184
+ // one-shot asks again shortly instead of failing a caller whose
4185
+ // fix was seconds off; its timeout still bounds the wait (#260).
4186
+ // Nor is it an error event: a watch keeps running through it.
4187
+ if nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.locationUnknown.rawValue {
4188
+ if let waiting = singleLocationCallbackId {
4189
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { [weak self] in
4190
+ guard let self, self.singleLocationCallbackId == waiting else { return }
4191
+ self.locationManager?.requestLocation()
4192
+ }
4193
+ }
4194
+ return
4195
+ }
3853
4196
  let callbackId = singleLocationCallbackId
3854
4197
  finishSingleLocationRequest()
3855
- let nativeError = error as NSError
3856
4198
  let code = nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.denied.rawValue
3857
4199
  ? "PERMISSION_DENIED"
3858
4200
  : "POSITION_UNAVAILABLE"
@@ -4164,6 +4506,10 @@ struct CraftWebView: UIViewRepresentable {
4164
4506
  content.body = data["body"] as? String ?? ""
4165
4507
  if let subtitle = data["subtitle"] as? String { content.subtitle = subtitle }
4166
4508
  if let badge = data["badge"] as? Int { content.badge = NSNumber(value: badge) }
4509
+ // The page's `data`, which is what a tap (#255) and an arrival
4510
+ // (#256) hand back. It was dropped, so a tap on a scheduled
4511
+ // notification reached the page as {} (#258).
4512
+ if let info = data["data"] as? [String: Any] { content.userInfo = info }
4167
4513
  content.sound = .default
4168
4514
 
4169
4515
  let id = data["id"] as? String ?? UUID().uuidString
@@ -4214,6 +4560,13 @@ struct CraftWebView: UIViewRepresentable {
4214
4560
 
4215
4561
  // MARK: - In-App Purchase
4216
4562
  private func getProducts(_ productIds: [String], callbackId: String?) {
4563
+ // Nobody is in front of a StoreKit fetch, and it reaches the
4564
+ // network: a request that never returns leaves the page waiting.
4565
+ let token = armDeadline(
4566
+ Coordinator.storeKitDeadline,
4567
+ callbackId: callbackId,
4568
+ error: "StoreKit did not return products within \(Int(Coordinator.storeKitDeadline))s"
4569
+ )
4217
4570
  Task {
4218
4571
  do {
4219
4572
  let products = try await Product.products(for: Set(productIds))
@@ -4226,9 +4579,15 @@ struct CraftWebView: UIViewRepresentable {
4226
4579
  "displayPrice": product.displayPrice
4227
4580
  ]
4228
4581
  }
4229
- resolveCallback(callbackId, result: productData)
4582
+ await MainActor.run { [weak self] in
4583
+ guard let self, self.claimDeadline(token) else { return }
4584
+ self.resolveCallback(callbackId, result: productData)
4585
+ }
4230
4586
  } catch {
4231
- rejectCallback(callbackId, error: error.localizedDescription)
4587
+ await MainActor.run { [weak self] in
4588
+ guard let self, self.claimDeadline(token) else { return }
4589
+ self.rejectCallback(callbackId, error: error.localizedDescription)
4590
+ }
4232
4591
  }
4233
4592
  }
4234
4593
  }
@@ -4948,9 +5307,17 @@ struct CraftWebView: UIViewRepresentable {
4948
5307
  durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
4949
5308
  progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
4950
5309
  )
5310
+ let token = armDeadline(
5311
+ Coordinator.liveActivityDeadline,
5312
+ callbackId: callbackId,
5313
+ error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s"
5314
+ )
4951
5315
  Task {
4952
5316
  await activity.update(ActivityContent(state: state, staleDate: nil))
4953
- resolveCallback(callbackId, result: ["updated": true])
5317
+ await MainActor.run { [weak self] in
5318
+ guard let self, self.claimDeadline(token) else { return }
5319
+ self.resolveCallback(callbackId, result: ["updated": true])
5320
+ }
4954
5321
  }
4955
5322
  }
4956
5323
 
@@ -4991,9 +5358,17 @@ struct CraftWebView: UIViewRepresentable {
4991
5358
  } else {
4992
5359
  finalContent = nil
4993
5360
  }
5361
+ let token = armDeadline(
5362
+ Coordinator.liveActivityDeadline,
5363
+ callbackId: callbackId,
5364
+ error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s"
5365
+ )
4994
5366
  Task {
4995
5367
  await activity.end(finalContent, dismissalPolicy: .default)
4996
- resolveCallback(callbackId, result: ["ended": true])
5368
+ await MainActor.run { [weak self] in
5369
+ guard let self, self.claimDeadline(token) else { return }
5370
+ self.resolveCallback(callbackId, result: ["ended": true])
5371
+ }
4997
5372
  }
4998
5373
  }
4999
5374
 
@@ -5622,6 +5997,54 @@ struct CraftWebView: UIViewRepresentable {
5622
5997
 
5623
5998
  // Removals waiting on their completion, each under a token of its own:
5624
5999
  // callbackId can be nil, and restarts when the page reloads.
6000
+ /// One entry per call whose only answer is a framework callback.
6001
+ ///
6002
+ /// Each of these waits on something no person is in front of — a
6003
+ /// notification-settings read, an APNs registration, a StoreKit fetch,
6004
+ /// a Live Activity update — and nothing else settles the page's
6005
+ /// promise if the callback never comes (#224). #211 found the same
6006
+ /// shape in a Siri deletion, twice, on CI.
6007
+ ///
6008
+ /// Whoever claims the token first answers: the callback or the
6009
+ /// deadline. Both run on the main queue, so the race is decided there
6010
+ /// rather than by a lock, and the loser finds nothing and does
6011
+ /// nothing. The deadline never claims the work happened — it may well
6012
+ /// have — only that nothing reported back in time.
6013
+ private var pendingDeadlines: [UUID: DispatchWorkItem] = [:]
6014
+
6015
+ /// How long each of them waits. Comfortably under the 30s the page's
6016
+ /// own `_invoke` allows, so the caller hears the native answer rather
6017
+ /// than its own timeout, and long enough that a slow device is not
6018
+ /// cut off mid-answer.
6019
+ private static let notificationSettingsDeadline: TimeInterval = 10
6020
+ private static let pushRegistrationDeadline: TimeInterval = 20
6021
+ private static let storeKitDeadline: TimeInterval = 20
6022
+ private static let liveActivityDeadline: TimeInterval = 10
6023
+
6024
+ /// Arm a deadline for a call, and return the token that claims it.
6025
+ private func armDeadline(_ seconds: TimeInterval, callbackId: String?, error: String) -> UUID {
6026
+ let token = UUID()
6027
+ let work = DispatchWorkItem { [weak self] in
6028
+ guard let self, self.pendingDeadlines.removeValue(forKey: token) != nil else { return }
6029
+ self.rejectCallback(callbackId, error: error, code: "TIMEOUT")
6030
+ }
6031
+ pendingDeadlines[token] = work
6032
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
6033
+ return token
6034
+ }
6035
+
6036
+ /// Claim the right to answer. False means the deadline already did.
6037
+ ///
6038
+ /// Call it on the main queue: the callbacks below hop there first,
6039
+ /// because the queue a framework completion arrives on is its own
6040
+ /// business and a `zig:` hand-off reply reaches `evaluateJavaScript`
6041
+ /// with no hop of its own.
6042
+ private func claimDeadline(_ token: UUID) -> Bool {
6043
+ guard let work = pendingDeadlines.removeValue(forKey: token) else { return false }
6044
+ work.cancel()
6045
+ return true
6046
+ }
6047
+
5625
6048
  private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:]
5626
6049
  // The only thing that settles craft.siri.remove, which arms no timeout
5627
6050
  // of its own; the same as Zig's.