@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.
- package/README.md +26 -12
- 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 +681 -57
- 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 }
|
|
@@ -172,9 +243,16 @@ class DeepLinkManager {
|
|
|
172
243
|
static let shared = DeepLinkManager()
|
|
173
244
|
|
|
174
245
|
private var initialURL: URL?
|
|
175
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
2128
|
+
return this.location.watchPosition(callback);
|
|
1913
2129
|
},
|
|
1914
2130
|
clearWatch: function(watchId) {
|
|
1915
|
-
return this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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')
|
|
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')
|
|
2833
|
-
vibrate: function(pattern) { craft.vibrate(pattern || [])
|
|
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
|
-
|
|
3318
|
+
void startLocationWatch().catch(function() {});
|
|
2869
3319
|
return id;
|
|
2870
3320
|
},
|
|
2871
3321
|
clearWatch: function(id) {
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
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.
|
|
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
|
-
|
|
3231
|
-
|
|
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
|
-
|
|
3239
|
-
|
|
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
|
-
|
|
3246
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|