@craft-native/ios 0.0.91 → 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 }
@@ -172,9 +243,16 @@ class DeepLinkManager {
172
243
  static let shared = DeepLinkManager()
173
244
 
174
245
  private var initialURL: URL?
175
- private var pendingURL: URL?
246
+ // Every link that arrived while no page could receive it, in order. This
247
+ // was a single slot, so a second link before the bridge was ready replaced
248
+ // the first; on a cold start, that could be the link that launched the app.
249
+ private var pendingURLs: [URL] = []
176
250
  private weak var webView: WKWebView?
177
251
  private var isReady = false
252
+ // Whether a page has ever become ready. A link that arrives before that is
253
+ // the one the app was opened with; a link that arrives later is not, and
254
+ // is no answer to getInitialURL.
255
+ private var hasBeenReady = false
178
256
 
179
257
  private init() {}
180
258
 
@@ -182,26 +260,33 @@ class DeepLinkManager {
182
260
  self.webView = webView
183
261
  }
184
262
 
263
+ // A navigation started, so the page that would have received a link is
264
+ // going away. Without this a link arriving during a reload was dispatched
265
+ // into the page being torn down and lost.
266
+ func setLoading() {
267
+ isReady = false
268
+ }
269
+
185
270
  func setReady() {
186
271
  isReady = true
187
- // If there's a pending URL, dispatch it now
188
- if let url = pendingURL {
189
- dispatchDeepLink(url)
190
- pendingURL = nil
272
+ let firstPage = !hasBeenReady
273
+ hasBeenReady = true
274
+ let urls = pendingURLs
275
+ pendingURLs.removeAll()
276
+ for url in urls {
277
+ dispatchDeepLink(url, initial: firstPage && url == initialURL)
191
278
  }
192
279
  }
193
280
 
194
281
  func handleURL(_ url: URL) {
195
- // Store as initial URL if this is the first one
196
- if initialURL == nil {
282
+ if initialURL == nil && !hasBeenReady {
197
283
  initialURL = url
198
284
  }
199
285
 
200
286
  if isReady && webView != nil {
201
- dispatchDeepLink(url)
287
+ dispatchDeepLink(url, initial: false)
202
288
  } else {
203
- // Store for later when web view is ready
204
- pendingURL = url
289
+ pendingURLs.append(url)
205
290
  }
206
291
  }
207
292
 
@@ -209,7 +294,7 @@ class DeepLinkManager {
209
294
  return initialURL
210
295
  }
211
296
 
212
- private func dispatchDeepLink(_ url: URL) {
297
+ private func dispatchDeepLink(_ url: URL, initial: Bool) {
213
298
  guard let webView = webView else { return }
214
299
 
215
300
  // Parse URL components
@@ -218,7 +303,10 @@ class DeepLinkManager {
218
303
  "scheme": url.scheme ?? "",
219
304
  "host": url.host ?? "",
220
305
  "path": url.path,
221
- "query": url.query ?? ""
306
+ "query": url.query ?? "",
307
+ // Lets the page tell the launch link apart from later ones, so a
308
+ // page that reads getInitialURL is not also handed it again.
309
+ "initial": initial
222
310
  ]
223
311
 
224
312
  // Parse query parameters
@@ -478,6 +566,21 @@ struct CraftWebView: UIViewRepresentable {
478
566
  private var pendingPushCallbackId: String?
479
567
  private var loadedBundledFallback = false
480
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
+
481
584
  // Location
482
585
  private var locationManager: CLLocationManager?
483
586
  private var singleLocationCallbackId: String?
@@ -567,7 +670,7 @@ struct CraftWebView: UIViewRepresentable {
567
670
  }
568
671
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushToken(_:)), name: .craftPushToken, object: nil)
569
672
  NotificationCenter.default.addObserver(self, selector: #selector(receivePushRegistrationError(_:)), name: .craftPushRegistrationError, object: nil)
570
- NotificationCenter.default.addObserver(self, selector: #selector(receiveNotificationResponse(_:)), name: .craftNotificationResponse, object: nil)
673
+ NotificationCenter.default.addObserver(self, selector: #selector(appWillEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
571
674
  setupNetworkMonitoring()
572
675
  }
573
676
 
@@ -590,6 +693,9 @@ struct CraftWebView: UIViewRepresentable {
590
693
  private func setupNetworkMonitoring() {
591
694
  networkMonitor = NWPathMonitor()
592
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
593
699
  self?.isConnected = path.status == .satisfied
594
700
  if path.usesInterfaceType(.wifi) {
595
701
  self?.connectionType = "wifi"
@@ -604,6 +710,16 @@ struct CraftWebView: UIViewRepresentable {
604
710
  "isConnected": self?.isConnected ?? false,
605
711
  "type": self?.connectionType ?? "unknown"
606
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
+ }
607
723
  }
608
724
  networkMonitor?.start(queue: DispatchQueue.global())
609
725
  }
@@ -618,6 +734,9 @@ struct CraftWebView: UIViewRepresentable {
618
734
  let action = body["action"] as? String else { return }
619
735
 
620
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)
621
740
 
622
741
  // The Zig runtime first, when there is one.
623
742
  //
@@ -650,16 +769,23 @@ struct CraftWebView: UIViewRepresentable {
650
769
  switch action {
651
770
  case "startListening":
652
771
  if config.enableSpeechRecognition {
772
+ // Answers that the request was taken. Authorization and the
773
+ // recognizer answer later, as craftSpeech* events; waiting
774
+ // for them here would leave the promise open on every path
775
+ // that ends without one.
653
776
  startSpeechRecognition()
777
+ resolveCallback(callbackId, result: true)
654
778
  } else {
655
779
  rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED")
656
780
  }
657
781
  case "stopListening":
658
782
  stopSpeechRecognition()
783
+ resolveCallback(callbackId, result: true)
659
784
  case "haptic":
660
785
  if config.enableHaptics {
661
786
  let style = body["style"] as? String ?? "medium"
662
787
  triggerHaptic(style: style)
788
+ resolveCallback(callbackId, result: true)
663
789
  } else {
664
790
  rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
665
791
  }
@@ -821,6 +947,8 @@ struct CraftWebView: UIViewRepresentable {
821
947
  case "setBadge":
822
948
  if let count = body["count"] as? Int {
823
949
  setBadgeCount(count, callbackId: callbackId)
950
+ } else {
951
+ rejectCallback(callbackId, error: "setBadge needs a whole number of badges", code: "INVALID_ARGUMENT")
824
952
  }
825
953
  case "clearBadge":
826
954
  setBadgeCount(0, callbackId: callbackId)
@@ -835,6 +963,8 @@ struct CraftWebView: UIViewRepresentable {
835
963
  case "setFlashlight":
836
964
  if let enabled = body["enabled"] as? Bool {
837
965
  setFlashlight(enabled: enabled, callbackId: callbackId)
966
+ } else {
967
+ rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
838
968
  }
839
969
  // Vibrate pattern
840
970
  case "vibrate":
@@ -849,6 +979,8 @@ struct CraftWebView: UIViewRepresentable {
849
979
  if let urlString = body["url"] as? String, let url = URL(string: urlString) {
850
980
  UIApplication.shared.open(url)
851
981
  resolveCallback(callbackId, result: true)
982
+ } else {
983
+ rejectCallback(callbackId, error: "openURL needs a URL it can parse", code: "INVALID_ARGUMENT")
852
984
  }
853
985
  // App state
854
986
  case "getAppState":
@@ -1256,6 +1388,8 @@ struct CraftWebView: UIViewRepresentable {
1256
1388
  case "setShortcuts":
1257
1389
  if let shortcuts = body["shortcuts"] as? [[String: Any]] {
1258
1390
  setAppShortcuts(shortcuts: shortcuts, callbackId: callbackId)
1391
+ } else {
1392
+ rejectCallback(callbackId, error: "setShortcuts needs a list of shortcuts", code: "INVALID_ARGUMENT")
1259
1393
  }
1260
1394
  case "clearShortcuts":
1261
1395
  clearAppShortcuts(callbackId: callbackId)
@@ -1266,16 +1400,22 @@ struct CraftWebView: UIViewRepresentable {
1266
1400
  let value = body["value"] as? String {
1267
1401
  let group = body["group"] as? String
1268
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")
1269
1405
  }
1270
1406
  case "getSharedItem":
1271
1407
  if let key = body["key"] as? String {
1272
1408
  let group = body["group"] as? String
1273
1409
  getSharedKeychainItem(key: key, group: group, callbackId: callbackId)
1410
+ } else {
1411
+ rejectCallback(callbackId, error: "getSharedItem needs a key", code: "INVALID_ARGUMENT")
1274
1412
  }
1275
1413
  case "removeSharedItem":
1276
1414
  if let key = body["key"] as? String {
1277
1415
  let group = body["group"] as? String
1278
1416
  removeSharedKeychainItem(key: key, group: group, callbackId: callbackId)
1417
+ } else {
1418
+ rejectCallback(callbackId, error: "removeSharedItem needs a key", code: "INVALID_ARGUMENT")
1279
1419
  }
1280
1420
 
1281
1421
  // MARK: - Local Auth Persistence
@@ -1283,6 +1423,8 @@ struct CraftWebView: UIViewRepresentable {
1283
1423
  if let enabled = body["enabled"] as? Bool {
1284
1424
  let duration = body["duration"] as? Double ?? 300 // 5 min default
1285
1425
  setBiometricPersistence(enabled: enabled, duration: duration, callbackId: callbackId)
1426
+ } else {
1427
+ rejectCallback(callbackId, error: "setBiometricPersistence needs true or false", code: "INVALID_ARGUMENT")
1286
1428
  }
1287
1429
  case "checkBiometricPersistence":
1288
1430
  checkBiometricPersistence(callbackId: callbackId)
@@ -1366,6 +1508,8 @@ struct CraftWebView: UIViewRepresentable {
1366
1508
  case "updateWidget":
1367
1509
  if let data = body["data"] as? [String: Any] {
1368
1510
  updateWidget(data: data, callbackId: callbackId)
1511
+ } else {
1512
+ rejectCallback(callbackId, error: "updateWidget needs a data object", code: "INVALID_ARGUMENT")
1369
1513
  }
1370
1514
  case "reloadWidgets":
1371
1515
  reloadAllWidgets(callbackId: callbackId)
@@ -1375,20 +1519,28 @@ struct CraftWebView: UIViewRepresentable {
1375
1519
  if let phrase = body["phrase"] as? String,
1376
1520
  let action = body["shortcutAction"] as? String {
1377
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")
1378
1524
  }
1379
1525
  case "removeSiriShortcut":
1380
1526
  if let action = body["shortcutAction"] as? String {
1381
1527
  removeSiriShortcut(action: action, callbackId: callbackId)
1528
+ } else {
1529
+ rejectCallback(callbackId, error: "removeSiriShortcut needs a shortcutAction", code: "INVALID_ARGUMENT")
1382
1530
  }
1383
1531
 
1384
1532
  // MARK: - Watch Connectivity
1385
1533
  case "sendToWatch":
1386
1534
  if let message = body["message"] as? [String: Any] {
1387
1535
  sendMessageToWatch(message: message, callbackId: callbackId)
1536
+ } else {
1537
+ rejectCallback(callbackId, error: "sendToWatch needs a message object", code: "INVALID_ARGUMENT")
1388
1538
  }
1389
1539
  case "updateWatchContext":
1390
1540
  if let context = body["context"] as? [String: Any] {
1391
1541
  updateWatchContext(context: context, callbackId: callbackId)
1542
+ } else {
1543
+ rejectCallback(callbackId, error: "updateWatchContext needs a context object", code: "INVALID_ARGUMENT")
1392
1544
  }
1393
1545
  case "isWatchReachable":
1394
1546
  isWatchReachable(callbackId: callbackId)
@@ -1400,6 +1552,7 @@ struct CraftWebView: UIViewRepresentable {
1400
1552
 
1401
1553
  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
1402
1554
  CraftEventManager.shared.setLoading()
1555
+ DeepLinkManager.shared.setLoading()
1403
1556
  }
1404
1557
 
1405
1558
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -1428,10 +1581,17 @@ struct CraftWebView: UIViewRepresentable {
1428
1581
  }
1429
1582
 
1430
1583
  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
1431
- loadBundledFallback(in: webView)
1584
+ fallBackIfUnreachable(webView, after: error)
1432
1585
  }
1433
1586
 
1434
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 }
1435
1595
  loadBundledFallback(in: webView)
1436
1596
  }
1437
1597
 
@@ -1440,16 +1600,60 @@ struct CraftWebView: UIViewRepresentable {
1440
1600
  config.devServerURL != nil,
1441
1601
  let bundledURL = URL(string: "craft://app/index.html") else { return }
1442
1602
  loadedBundledFallback = true
1603
+ // Kept for the way back: `returnFromBundledFallback` has no view of
1604
+ // its own to reload.
1605
+ self.webView = webView
1443
1606
  webView.load(URLRequest(url: bundledURL))
1444
1607
  }
1445
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
+
1446
1632
  private func isTrustedURL(_ url: URL?) -> Bool {
1447
1633
  guard let url = url else { return false }
1448
- if url.scheme == "craft" && url.host == "app" { return true }
1449
1634
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
1450
1635
  }
1451
1636
 
1637
+ /// The single answer to "may this origin reach native?".
1638
+ ///
1639
+ /// The bundled app is served from craft://app by
1640
+ /// `BundledAssetSchemeHandler`, and it is the only content a generated
1641
+ /// app loads when no dev server is configured. That origin used to be
1642
+ /// trusted for *navigation* only: `isTrustedURL` carried the clause,
1643
+ /// while the `userContentController` guard called this function
1644
+ /// directly and fell through to the https/localhost check. So every
1645
+ /// bridge call from the app's own page was answered with
1646
+ ///
1647
+ /// Blocked Craft bridge message from untrusted origin: craft://app
1648
+ ///
1649
+ /// and its promise never settled — the whole native surface was dead
1650
+ /// in the default configuration, which is the one every generated app
1651
+ /// ships with. Two guards that had to agree, and did not. There is one
1652
+ /// now, and `scripts/mobile-e2e.ts` runs a real round trip on a booted
1653
+ /// simulator so a future hardening pass cannot quietly take the bridge
1654
+ /// away again.
1452
1655
  private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1656
+ if scheme == "craft" && host == "app" { return true }
1453
1657
  if scheme == "file" { return true }
1454
1658
  guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false }
1455
1659
  let defaultPort = scheme == "https" ? 443 : 80
@@ -1466,6 +1670,16 @@ struct CraftWebView: UIViewRepresentable {
1466
1670
  return allowed.contains(origin)
1467
1671
  }
1468
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
+
1469
1683
  private func injectNativeBridge() {
1470
1684
  let laContext = LAContext()
1471
1685
  var biometricAvailable = false
@@ -1505,7 +1719,9 @@ struct CraftWebView: UIViewRepresentable {
1505
1719
  },
1506
1720
 
1507
1721
  _callbacks: {},
1508
- _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),
1509
1725
 
1510
1726
  _createCallback: function() {
1511
1727
  var id = 'cb_' + (++this._callbackId);
@@ -1909,10 +2125,10 @@ struct CraftWebView: UIViewRepresentable {
1909
2125
  return this.geolocation.getCurrentPosition({});
1910
2126
  },
1911
2127
  watchPosition: function(callback) {
1912
- return this.geolocation.watchPosition(callback);
2128
+ return this.location.watchPosition(callback);
1913
2129
  },
1914
2130
  clearWatch: function(watchId) {
1915
- return this.geolocation.clearWatch(watchId);
2131
+ return this.location.clearWatch(watchId);
1916
2132
  },
1917
2133
  getContacts: function() {
1918
2134
  return this._invoke('getContacts');
@@ -1977,20 +2193,62 @@ struct CraftWebView: UIViewRepresentable {
1977
2193
  return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
1978
2194
  },
1979
2195
 
2196
+ // Resolves true once UIKit has taken the haptic, and rejects
2197
+ // CAPABILITY_DISABLED when enableHaptics is off.
1980
2198
  haptic: function(style) {
1981
- window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium'});
2199
+ var self = window.craft;
2200
+ var id = 'cb_' + (++self._callbackId);
2201
+ window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium', callbackId: id});
2202
+ return new Promise(function(resolve, reject) {
2203
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2204
+ });
1982
2205
  },
1983
2206
 
2207
+ // Resolves true once native has taken the request, not once
2208
+ // audio is flowing. What happens next (a prompt declined, no
2209
+ // recognizer, a transcript) still arrives as craftSpeech*
2210
+ // events, because none of it is known when this answers.
1984
2211
  startListening: function() {
1985
- window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
2212
+ var self = window.craft;
2213
+ var id = 'cb_' + (++self._callbackId);
2214
+ window.webkit.messageHandlers.craft.postMessage({action: 'startListening', callbackId: id});
2215
+ return new Promise(function(resolve, reject) {
2216
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2217
+ });
1986
2218
  },
1987
2219
 
1988
2220
  stopListening: function() {
1989
- window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
2221
+ var self = window.craft;
2222
+ var id = 'cb_' + (++self._callbackId);
2223
+ window.webkit.messageHandlers.craft.postMessage({action: 'stopListening', callbackId: id});
2224
+ return new Promise(function(resolve, reject) {
2225
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2226
+ });
1990
2227
  },
1991
2228
 
1992
2229
  share: function(text) {
1993
- window.webkit.messageHandlers.craft.postMessage({action: 'share', text: text});
2230
+ return this._share({text: text});
2231
+ },
2232
+
2233
+ // Both share entry points come through here, because the
2234
+ // native side answers them the same way: `true` when the
2235
+ // person finished an activity, `false` when they dismissed
2236
+ // the sheet, a rejection when sharing is disabled or there is
2237
+ // nothing to share. The flat `share` used to post with no
2238
+ // callbackId, so every one of those answers stopped at the nil
2239
+ // guard in resolveCallback and the page got `undefined`.
2240
+ //
2241
+ // No timeout, unlike `_invoke`'s thirty seconds. The sheet
2242
+ // waits on a person, and someone slower than that to pick an
2243
+ // app would be told the share failed while it was still on
2244
+ // screen, with the real answer then dropped.
2245
+ _share: function(payload) {
2246
+ var self = this;
2247
+ var id = 'cb_' + (++this._callbackId);
2248
+ window.webkit.messageHandlers.craft.postMessage(Object.assign({}, payload, {action: 'share', callbackId: id}));
2249
+ return new Promise(function(resolve, reject) {
2250
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2251
+ });
1994
2252
  },
1995
2253
 
1996
2254
  openCamera: function() {
@@ -2112,12 +2370,25 @@ struct CraftWebView: UIViewRepresentable {
2112
2370
  }
2113
2371
  });
2114
2372
  },
2373
+ // Resolves true once updates have started, before any
2374
+ // authorization answer. A later refusal arrives as a
2375
+ // craftLocationError event.
2115
2376
  watchPosition: function(callback) {
2377
+ var self = window.craft;
2378
+ var id = 'cb_' + (++self._callbackId);
2116
2379
  window.addEventListener('craftLocationUpdate', function(e) { callback(e.detail); });
2117
- window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition'});
2380
+ window.webkit.messageHandlers.craft.postMessage({action: 'watchPosition', callbackId: id});
2381
+ return new Promise(function(resolve, reject) {
2382
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2383
+ });
2118
2384
  },
2119
2385
  clearWatch: function() {
2120
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2386
+ var self = window.craft;
2387
+ var id = 'cb_' + (++self._callbackId);
2388
+ window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch', callbackId: id});
2389
+ return new Promise(function(resolve, reject) {
2390
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2391
+ });
2121
2392
  }
2122
2393
  },
2123
2394
 
@@ -2217,7 +2488,12 @@ struct CraftWebView: UIViewRepresentable {
2217
2488
 
2218
2489
  // Vibrate
2219
2490
  vibrate: function(pattern) {
2220
- window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern});
2491
+ var self = window.craft;
2492
+ var id = 'cb_' + (++self._callbackId);
2493
+ window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern, callbackId: id});
2494
+ return new Promise(function(resolve, reject) {
2495
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2496
+ });
2221
2497
  },
2222
2498
 
2223
2499
  // Open URL
@@ -2394,7 +2670,7 @@ struct CraftWebView: UIViewRepresentable {
2394
2670
 
2395
2671
  // Deep Links
2396
2672
  onDeepLink: function(callback) {
2397
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2673
+ return window.craft._subscribeDeepLinks(callback);
2398
2674
  },
2399
2675
 
2400
2676
  // Background Tasks
@@ -2722,6 +2998,7 @@ struct CraftWebView: UIViewRepresentable {
2722
2998
  deepLinks: {
2723
2999
  getInitialURL: function() {
2724
3000
  var self = window.craft;
3001
+ self._claimInitialDeepLink();
2725
3002
  var id = 'cb_' + (++self._callbackId);
2726
3003
  window.webkit.messageHandlers.craft.postMessage({action: 'getInitialURL', callbackId: id});
2727
3004
  return new Promise(function(resolve, reject) {
@@ -2729,7 +3006,7 @@ struct CraftWebView: UIViewRepresentable {
2729
3006
  });
2730
3007
  },
2731
3008
  onLink: function(callback) {
2732
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
3009
+ return window.craft._subscribeDeepLinks(callback);
2733
3010
  }
2734
3011
  },
2735
3012
 
@@ -2792,6 +3069,115 @@ struct CraftWebView: UIViewRepresentable {
2792
3069
  // Stable, versioned mobile contract consumed by craft-native/mobile.
2793
3070
  // Legacy flat methods remain available while every public SDK method
2794
3071
  // is routed through this nested contract.
3072
+ // Links that arrive before anything is listening (#198).
3073
+ //
3074
+ // The native side dispatches a link the moment this script has
3075
+ // run, and on a cold start that is the link the app was opened
3076
+ // with. No page code can have called onLink by then: onLink is
3077
+ // defined by this very script. So every such link was dispatched
3078
+ // to nobody, and a page that subscribes, rather than asking
3079
+ // getInitialURL, never learned how it was opened.
3080
+ //
3081
+ // Held here instead, and handed to the first subscriber on the
3082
+ // next turn, so an unsubscribe returned in the same tick still
3083
+ // applies. The launch link belongs to getInitialURL once the page
3084
+ // has called it, whether native has dispatched it yet or not, so a
3085
+ // page that does both in the same tick, in either order, gets it
3086
+ // once. That includes a craftReady handler, which runs before
3087
+ // native dispatches anything. A page that asks getInitialURL only
3088
+ // later, after an await, should skip `initial` in onLink.
3089
+ //
3090
+ // The same block runs on Android (#215), where this script can run
3091
+ // twice in one document, so its state lives on window.
3092
+ (function installDeepLinkReplay(craft) {
3093
+ var replay = window.__craftDeepLinkReplay;
3094
+ if (!replay) {
3095
+ replay = window.__craftDeepLinkReplay = {undelivered: [], subscribed: false, initialClaimed: false};
3096
+ window.addEventListener('craftDeepLink', function(e) {
3097
+ if (!replay.subscribed && !claimed(e.detail)) replay.undelivered.push(e.detail);
3098
+ });
3099
+ }
3100
+ function claimed(detail) {
3101
+ return replay.initialClaimed && detail && detail.initial;
3102
+ }
3103
+ craft._subscribeDeepLinks = function(callback) {
3104
+ var active = true;
3105
+ var listener = function(e) { if (!claimed(e.detail)) callback(e.detail); };
3106
+ window.addEventListener('craftDeepLink', listener);
3107
+ if (!replay.subscribed) {
3108
+ replay.subscribed = true;
3109
+ setTimeout(function() {
3110
+ var pending = replay.undelivered;
3111
+ replay.undelivered = [];
3112
+ if (!active) return;
3113
+ pending.forEach(function(detail) { callback(detail); });
3114
+ }, 0);
3115
+ }
3116
+ return function() {
3117
+ active = false;
3118
+ window.removeEventListener('craftDeepLink', listener);
3119
+ };
3120
+ };
3121
+ craft._claimInitialDeepLink = function() {
3122
+ replay.initialClaimed = true;
3123
+ replay.undelivered = replay.undelivered.filter(function(detail) { return !(detail && detail.initial); });
3124
+ };
3125
+ })(window.craft);
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
+
2795
3181
  (function installCraftMobileContract(craft) {
2796
3182
  var legacyShare = craft.share.bind(craft);
2797
3183
  var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -2823,14 +3209,25 @@ struct CraftWebView: UIViewRepresentable {
2823
3209
  getInfo: function() { return craft.getDeviceInfo(); },
2824
3210
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
2825
3211
  };
3212
+ // Feedback, as on Android and the web, where there is no
3213
+ // motor to fire: an app that left enableHaptics off gets
3214
+ // nothing played and a settled promise, so `await
3215
+ // haptics.selection()` in the middle of a flow does not stop
3216
+ // the flow. A native failure still rejects, and
3217
+ // craft.haptic() itself reports the refusal.
3218
+ function hapticFeedback(answer) {
3219
+ return answer.then(function() {}, function(error) {
3220
+ if (error && error.code === 'CAPABILITY_DISABLED') return;
3221
+ throw error;
3222
+ });
3223
+ }
2826
3224
  craft.haptics = {
2827
- impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
3225
+ impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
2828
3226
  notification: function(type) {
2829
- craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light');
2830
- return Promise.resolve();
3227
+ return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
2831
3228
  },
2832
- selection: function() { craft.haptic('soft'); return Promise.resolve(); },
2833
- vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
3229
+ selection: function() { return hapticFeedback(craft.haptic('soft')); },
3230
+ vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
2834
3231
  };
2835
3232
  craft.permissions = {
2836
3233
  check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); },
@@ -2856,23 +3253,80 @@ struct CraftWebView: UIViewRepresentable {
2856
3253
  };
2857
3254
 
2858
3255
  var locationWatchCallbacks = new Map();
3256
+ var legacyLocationWatchCallbacks = [];
2859
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
+
2860
3301
  window.addEventListener('craftLocationUpdate', function(event) {
2861
3302
  locationWatchCallbacks.forEach(function(callback) { callback(event.detail); });
3303
+ legacyLocationWatchCallbacks.slice().forEach(function(callback) { callback(event.detail); });
2862
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
+ };
2863
3313
  craft.location = {
2864
3314
  getCurrentPosition: function(options) { return legacyGeolocation.getCurrentPosition(options || {}); },
2865
3315
  watchPosition: function(callback) {
2866
3316
  var id = ++nextLocationWatchId;
2867
3317
  locationWatchCallbacks.set(id, callback);
2868
- if (locationWatchCallbacks.size === 1) void craft._invoke('watchPosition');
3318
+ void startLocationWatch().catch(function() {});
2869
3319
  return id;
2870
3320
  },
2871
3321
  clearWatch: function(id) {
2872
- locationWatchCallbacks.delete(id);
2873
- if (locationWatchCallbacks.size === 0) {
2874
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
3322
+ if (typeof id === 'undefined') {
3323
+ locationWatchCallbacks.clear();
3324
+ legacyLocationWatchCallbacks.length = 0;
3325
+ void stopLocationWatchIfUnused().catch(function() {});
3326
+ return;
2875
3327
  }
3328
+ if (!locationWatchCallbacks.delete(id)) return;
3329
+ void stopLocationWatchIfUnused().catch(function() {});
2876
3330
  },
2877
3331
  startRecording: function() { return craft._invoke('startLocationRecording'); },
2878
3332
  pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
@@ -2901,7 +3355,7 @@ struct CraftWebView: UIViewRepresentable {
2901
3355
  }
2902
3356
  };
2903
3357
  var shareApi = function(text) { return legacyShare(text); };
2904
- shareApi.share = function(options) { return craft._invoke('share', {options: options || {}}); };
3358
+ shareApi.share = function(options) { return craft._share({options: options || {}}); };
2905
3359
  craft.share = shareApi;
2906
3360
  craft.lifecycle = {
2907
3361
  getState: function() { return document.visibilityState === 'visible' ? 'active' : 'background'; },
@@ -3219,36 +3673,68 @@ struct CraftWebView: UIViewRepresentable {
3219
3673
  }
3220
3674
 
3221
3675
  // MARK: - Push Notifications
3676
+
3677
+ /// The deadline covering the APNs round trip, once authorization is in.
3678
+ private var pendingPushDeadline: UUID?
3679
+
3222
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
+ }
3223
3690
  pendingPushCallbackId = callbackId
3224
3691
  UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { [weak self] granted, error in
3225
3692
  if granted {
3226
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
3227
3704
  UIApplication.shared.registerForRemoteNotifications()
3228
3705
  }
3229
3706
  } else {
3230
- self?.rejectCallback(callbackId, error: error?.localizedDescription ?? "Permission denied")
3231
- 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
+ }
3232
3712
  }
3233
3713
  }
3234
3714
  }
3235
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
+
3236
3725
  @objc private func receivePushToken(_ notification: Notification) {
3237
3726
  guard let token = notification.object as? String else { return }
3238
- resolveCallback(pendingPushCallbackId, result: token)
3239
- 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) }
3240
3731
  sendToWeb("craftPushToken", data: ["token": token])
3241
3732
  }
3242
3733
 
3243
3734
  @objc private func receivePushRegistrationError(_ notification: Notification) {
3244
3735
  let message = notification.object as? String ?? "Push registration failed"
3245
- rejectCallback(pendingPushCallbackId, error: message, code: "PUSH_REGISTRATION_ERROR")
3246
- pendingPushCallbackId = nil
3247
- }
3248
-
3249
- @objc private func receiveNotificationResponse(_ notification: Notification) {
3250
- let data = notification.object as? [String: Any] ?? [:]
3251
- sendToWeb("craftNotificationResponse", data: data)
3736
+ guard let claimed = claimPushRegistration() else { return }
3737
+ rejectCallback(claimed, error: message, code: "PUSH_REGISTRATION_ERROR")
3252
3738
  }
3253
3739
 
3254
3740
  // MARK: - Secure Storage (Keychain)
@@ -3339,6 +3825,13 @@ struct CraftWebView: UIViewRepresentable {
3339
3825
  let status = CBManager.authorization
3340
3826
  resolveCallback(callbackId, result: permissionStatus(status == .allowedAlways, denied: status == .denied, restricted: status == .restricted))
3341
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
+ )
3342
3835
  UNUserNotificationCenter.current().getNotificationSettings { settings in
3343
3836
  let status: String
3344
3837
  switch settings.authorizationStatus {
@@ -3347,7 +3840,10 @@ struct CraftWebView: UIViewRepresentable {
3347
3840
  case .notDetermined: status = "undetermined"
3348
3841
  @unknown default: status = "undetermined"
3349
3842
  }
3350
- 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
+ }
3351
3847
  }
3352
3848
  default:
3353
3849
  resolveCallback(callbackId, result: "undetermined")
@@ -3682,9 +4178,23 @@ struct CraftWebView: UIViewRepresentable {
3682
4178
  }
3683
4179
 
3684
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
+ }
3685
4196
  let callbackId = singleLocationCallbackId
3686
4197
  finishSingleLocationRequest()
3687
- let nativeError = error as NSError
3688
4198
  let code = nativeError.domain == kCLErrorDomain && nativeError.code == CLError.Code.denied.rawValue
3689
4199
  ? "PERMISSION_DENIED"
3690
4200
  : "POSITION_UNAVAILABLE"
@@ -3996,6 +4506,10 @@ struct CraftWebView: UIViewRepresentable {
3996
4506
  content.body = data["body"] as? String ?? ""
3997
4507
  if let subtitle = data["subtitle"] as? String { content.subtitle = subtitle }
3998
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 }
3999
4513
  content.sound = .default
4000
4514
 
4001
4515
  let id = data["id"] as? String ?? UUID().uuidString
@@ -4046,6 +4560,13 @@ struct CraftWebView: UIViewRepresentable {
4046
4560
 
4047
4561
  // MARK: - In-App Purchase
4048
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
+ )
4049
4570
  Task {
4050
4571
  do {
4051
4572
  let products = try await Product.products(for: Set(productIds))
@@ -4058,9 +4579,15 @@ struct CraftWebView: UIViewRepresentable {
4058
4579
  "displayPrice": product.displayPrice
4059
4580
  ]
4060
4581
  }
4061
- 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
+ }
4062
4586
  } catch {
4063
- 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
+ }
4064
4591
  }
4065
4592
  }
4066
4593
  }
@@ -4780,9 +5307,17 @@ struct CraftWebView: UIViewRepresentable {
4780
5307
  durationSeconds: body["durationSeconds"] as? Double ?? current.durationSeconds,
4781
5308
  progress: min(max(body["progress"] as? Double ?? current.progress, 0), 1)
4782
5309
  )
5310
+ let token = armDeadline(
5311
+ Coordinator.liveActivityDeadline,
5312
+ callbackId: callbackId,
5313
+ error: "Activity.update did not return within \(Int(Coordinator.liveActivityDeadline))s"
5314
+ )
4783
5315
  Task {
4784
5316
  await activity.update(ActivityContent(state: state, staleDate: nil))
4785
- 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
+ }
4786
5321
  }
4787
5322
  }
4788
5323
 
@@ -4823,9 +5358,17 @@ struct CraftWebView: UIViewRepresentable {
4823
5358
  } else {
4824
5359
  finalContent = nil
4825
5360
  }
5361
+ let token = armDeadline(
5362
+ Coordinator.liveActivityDeadline,
5363
+ callbackId: callbackId,
5364
+ error: "Activity.end did not return within \(Int(Coordinator.liveActivityDeadline))s"
5365
+ )
4826
5366
  Task {
4827
5367
  await activity.end(finalContent, dismissalPolicy: .default)
4828
- 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
+ }
4829
5372
  }
4830
5373
  }
4831
5374
 
@@ -5452,9 +5995,90 @@ struct CraftWebView: UIViewRepresentable {
5452
5995
  resolveCallback(callbackId, result: ["registered": true, "action": action, "phrase": phrase])
5453
5996
  }
5454
5997
 
5998
+ // Removals waiting on their completion, each under a token of its own:
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
+
6048
+ private var pendingSiriRemovals: [UUID: DispatchWorkItem] = [:]
6049
+ // The only thing that settles craft.siri.remove, which arms no timeout
6050
+ // of its own; the same as Zig's.
6051
+ private static let siriRemovalDeadline: TimeInterval = 15
6052
+
5455
6053
  private func removeSiriShortcut(action: String, callbackId: String?) {
6054
+ // The completion comes from a system daemon, and sometimes it never
6055
+ // comes (#211). The deadline settles the call instead, and never
6056
+ // with removed: true, because the deletion may still have happened.
6057
+ // Both run on the main queue and remove the same entry, so only one
6058
+ // of them answers.
6059
+ let token = UUID()
6060
+ let deadline = DispatchWorkItem { [weak self] in
6061
+ guard let self, self.pendingSiriRemovals.removeValue(forKey: token) != nil else { return }
6062
+ self.rejectCallback(
6063
+ callbackId,
6064
+ error: "NSUserActivity.deleteSavedUserActivities did not call its completion handler within \(Int(Coordinator.siriRemovalDeadline))s",
6065
+ code: "TIMEOUT"
6066
+ )
6067
+ }
6068
+ pendingSiriRemovals[token] = deadline
6069
+ DispatchQueue.main.asyncAfter(deadline: .now() + Coordinator.siriRemovalDeadline, execute: deadline)
6070
+
5456
6071
  NSUserActivity.deleteSavedUserActivities(withPersistentIdentifiers: [action]) {
5457
- self.resolveCallback(callbackId, result: ["removed": true, "action": action])
6072
+ // The queue this runs on is not documented, and a zig: hand-off
6073
+ // reply goes to evaluateJavaScript without a hop of its own.
6074
+ DispatchQueue.main.async { [weak self] in
6075
+ guard let self, let pending = self.pendingSiriRemovals.removeValue(forKey: token) else {
6076
+ print("removeSiriShortcut: the completion for \(action) arrived after its deadline; ignored")
6077
+ return
6078
+ }
6079
+ pending.cancel()
6080
+ self.resolveCallback(callbackId, result: ["removed": true, "action": action])
6081
+ }
5458
6082
  }
5459
6083
  }
5460
6084