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