@craft-native/ios 0.0.92 → 0.0.95
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.
- package/dist/cli.js +751 -0
- package/dist/index.d.ts +246 -0
- package/dist/index.js +674 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +450 -27
- package/LICENSE.md +0 -21
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2128
|
+
return this.location.watchPosition(callback);
|
|
1956
2129
|
},
|
|
1957
2130
|
clearWatch: function(watchId) {
|
|
1958
|
-
return this.
|
|
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
|
-
|
|
3318
|
+
void startLocationWatch().catch(function() {});
|
|
3039
3319
|
return id;
|
|
3040
3320
|
},
|
|
3041
3321
|
clearWatch: function(id) {
|
|
3042
|
-
|
|
3043
|
-
|
|
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
|
-
|
|
3399
|
-
|
|
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
|
-
|
|
3407
|
-
|
|
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
|
-
|
|
3414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|