craft-native 0.0.91 → 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.
@@ -172,9 +172,16 @@ class DeepLinkManager {
172
172
  static let shared = DeepLinkManager()
173
173
 
174
174
  private var initialURL: URL?
175
- private var pendingURL: URL?
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] = []
176
179
  private weak var webView: WKWebView?
177
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
178
185
 
179
186
  private init() {}
180
187
 
@@ -182,26 +189,33 @@ class DeepLinkManager {
182
189
  self.webView = webView
183
190
  }
184
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
+
185
199
  func setReady() {
186
200
  isReady = true
187
- // If there's a pending URL, dispatch it now
188
- if let url = pendingURL {
189
- dispatchDeepLink(url)
190
- pendingURL = nil
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)
191
207
  }
192
208
  }
193
209
 
194
210
  func handleURL(_ url: URL) {
195
- // Store as initial URL if this is the first one
196
- if initialURL == nil {
211
+ if initialURL == nil && !hasBeenReady {
197
212
  initialURL = url
198
213
  }
199
214
 
200
215
  if isReady && webView != nil {
201
- dispatchDeepLink(url)
216
+ dispatchDeepLink(url, initial: false)
202
217
  } else {
203
- // Store for later when web view is ready
204
- pendingURL = url
218
+ pendingURLs.append(url)
205
219
  }
206
220
  }
207
221
 
@@ -209,7 +223,7 @@ class DeepLinkManager {
209
223
  return initialURL
210
224
  }
211
225
 
212
- private func dispatchDeepLink(_ url: URL) {
226
+ private func dispatchDeepLink(_ url: URL, initial: Bool) {
213
227
  guard let webView = webView else { return }
214
228
 
215
229
  // Parse URL components
@@ -218,7 +232,10 @@ class DeepLinkManager {
218
232
  "scheme": url.scheme ?? "",
219
233
  "host": url.host ?? "",
220
234
  "path": url.path,
221
- "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
222
239
  ]
223
240
 
224
241
  // Parse query parameters
@@ -650,16 +667,23 @@ struct CraftWebView: UIViewRepresentable {
650
667
  switch action {
651
668
  case "startListening":
652
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.
653
674
  startSpeechRecognition()
675
+ resolveCallback(callbackId, result: true)
654
676
  } else {
655
677
  rejectCallback(callbackId, error: "Speech recognition is disabled", code: "CAPABILITY_DISABLED")
656
678
  }
657
679
  case "stopListening":
658
680
  stopSpeechRecognition()
681
+ resolveCallback(callbackId, result: true)
659
682
  case "haptic":
660
683
  if config.enableHaptics {
661
684
  let style = body["style"] as? String ?? "medium"
662
685
  triggerHaptic(style: style)
686
+ resolveCallback(callbackId, result: true)
663
687
  } else {
664
688
  rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
665
689
  }
@@ -1400,6 +1424,7 @@ struct CraftWebView: UIViewRepresentable {
1400
1424
 
1401
1425
  func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
1402
1426
  CraftEventManager.shared.setLoading()
1427
+ DeepLinkManager.shared.setLoading()
1403
1428
  }
1404
1429
 
1405
1430
  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
@@ -1445,11 +1470,29 @@ struct CraftWebView: UIViewRepresentable {
1445
1470
 
1446
1471
  private func isTrustedURL(_ url: URL?) -> Bool {
1447
1472
  guard let url = url else { return false }
1448
- if url.scheme == "craft" && url.host == "app" { return true }
1449
1473
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
1450
1474
  }
1451
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.
1452
1494
  private func isTrustedOrigin(scheme: String, host: String, port: Int) -> Bool {
1495
+ if scheme == "craft" && host == "app" { return true }
1453
1496
  if scheme == "file" { return true }
1454
1497
  guard scheme == "https" || (scheme == "http" && ["localhost", "127.0.0.1", "::1"].contains(host)) else { return false }
1455
1498
  let defaultPort = scheme == "https" ? 443 : 80
@@ -1977,20 +2020,62 @@ struct CraftWebView: UIViewRepresentable {
1977
2020
  return Promise.reject(new Error('Android fitness APIs are unavailable on iOS'));
1978
2021
  },
1979
2022
 
2023
+ // Resolves true once UIKit has taken the haptic, and rejects
2024
+ // CAPABILITY_DISABLED when enableHaptics is off.
1980
2025
  haptic: function(style) {
1981
- window.webkit.messageHandlers.craft.postMessage({action: 'haptic', style: style || 'medium'});
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
+ });
1982
2032
  },
1983
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.
1984
2038
  startListening: function() {
1985
- window.webkit.messageHandlers.craft.postMessage({action: 'startListening'});
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
+ });
1986
2045
  },
1987
2046
 
1988
2047
  stopListening: function() {
1989
- window.webkit.messageHandlers.craft.postMessage({action: 'stopListening'});
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
+ });
1990
2054
  },
1991
2055
 
1992
2056
  share: function(text) {
1993
- window.webkit.messageHandlers.craft.postMessage({action: 'share', text: text});
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
+ });
1994
2079
  },
1995
2080
 
1996
2081
  openCamera: function() {
@@ -2112,12 +2197,25 @@ struct CraftWebView: UIViewRepresentable {
2112
2197
  }
2113
2198
  });
2114
2199
  },
2200
+ // Resolves true once updates have started, before any
2201
+ // authorization answer. A later refusal arrives as a
2202
+ // craftLocationError event.
2115
2203
  watchPosition: function(callback) {
2204
+ var self = window.craft;
2205
+ var id = 'cb_' + (++self._callbackId);
2116
2206
  window.addEventListener('craftLocationUpdate', function(e) { callback(e.detail); });
2117
- 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
+ });
2118
2211
  },
2119
2212
  clearWatch: function() {
2120
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
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
+ });
2121
2219
  }
2122
2220
  },
2123
2221
 
@@ -2217,7 +2315,12 @@ struct CraftWebView: UIViewRepresentable {
2217
2315
 
2218
2316
  // Vibrate
2219
2317
  vibrate: function(pattern) {
2220
- window.webkit.messageHandlers.craft.postMessage({action: 'vibrate', pattern: pattern});
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
+ });
2221
2324
  },
2222
2325
 
2223
2326
  // Open URL
@@ -2394,7 +2497,7 @@ struct CraftWebView: UIViewRepresentable {
2394
2497
 
2395
2498
  // Deep Links
2396
2499
  onDeepLink: function(callback) {
2397
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2500
+ return window.craft._subscribeDeepLinks(callback);
2398
2501
  },
2399
2502
 
2400
2503
  // Background Tasks
@@ -2722,6 +2825,7 @@ struct CraftWebView: UIViewRepresentable {
2722
2825
  deepLinks: {
2723
2826
  getInitialURL: function() {
2724
2827
  var self = window.craft;
2828
+ self._claimInitialDeepLink();
2725
2829
  var id = 'cb_' + (++self._callbackId);
2726
2830
  window.webkit.messageHandlers.craft.postMessage({action: 'getInitialURL', callbackId: id});
2727
2831
  return new Promise(function(resolve, reject) {
@@ -2729,7 +2833,7 @@ struct CraftWebView: UIViewRepresentable {
2729
2833
  });
2730
2834
  },
2731
2835
  onLink: function(callback) {
2732
- window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
2836
+ return window.craft._subscribeDeepLinks(callback);
2733
2837
  }
2734
2838
  },
2735
2839
 
@@ -2792,6 +2896,61 @@ struct CraftWebView: UIViewRepresentable {
2792
2896
  // Stable, versioned mobile contract consumed by craft-native/mobile.
2793
2897
  // Legacy flat methods remain available while every public SDK method
2794
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
+
2795
2954
  (function installCraftMobileContract(craft) {
2796
2955
  var legacyShare = craft.share.bind(craft);
2797
2956
  var legacyOpenCamera = craft.openCamera.bind(craft);
@@ -2823,14 +2982,25 @@ struct CraftWebView: UIViewRepresentable {
2823
2982
  getInfo: function() { return craft.getDeviceInfo(); },
2824
2983
  getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
2825
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
+ }
2826
2997
  craft.haptics = {
2827
- impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
2998
+ impact: function(style) { return hapticFeedback(craft.haptic(style || 'medium')); },
2828
2999
  notification: function(type) {
2829
- craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light');
2830
- return Promise.resolve();
3000
+ return hapticFeedback(craft.haptic(type === 'error' ? 'heavy' : type === 'warning' ? 'medium' : 'light'));
2831
3001
  },
2832
- selection: function() { craft.haptic('soft'); return Promise.resolve(); },
2833
- vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
3002
+ selection: function() { return hapticFeedback(craft.haptic('soft')); },
3003
+ vibrate: function(pattern) { return hapticFeedback(craft.vibrate(pattern || [])); }
2834
3004
  };
2835
3005
  craft.permissions = {
2836
3006
  check: function(permission) { return craft._invoke('checkPermission', {permission: permission}); },
@@ -2870,9 +3040,7 @@ struct CraftWebView: UIViewRepresentable {
2870
3040
  },
2871
3041
  clearWatch: function(id) {
2872
3042
  locationWatchCallbacks.delete(id);
2873
- if (locationWatchCallbacks.size === 0) {
2874
- window.webkit.messageHandlers.craft.postMessage({action: 'clearWatch'});
2875
- }
3043
+ if (locationWatchCallbacks.size === 0) void craft._invoke('clearWatch');
2876
3044
  },
2877
3045
  startRecording: function() { return craft._invoke('startLocationRecording'); },
2878
3046
  pauseRecording: function() { return craft._invoke('pauseLocationRecording'); },
@@ -2901,7 +3069,7 @@ struct CraftWebView: UIViewRepresentable {
2901
3069
  }
2902
3070
  };
2903
3071
  var shareApi = function(text) { return legacyShare(text); };
2904
- shareApi.share = function(options) { return craft._invoke('share', {options: options || {}}); };
3072
+ shareApi.share = function(options) { return craft._share({options: options || {}}); };
2905
3073
  craft.share = shareApi;
2906
3074
  craft.lifecycle = {
2907
3075
  getState: function() { return document.visibilityState === 'visible' ? 'active' : 'background'; },
@@ -5452,9 +5620,42 @@ struct CraftWebView: UIViewRepresentable {
5452
5620
  resolveCallback(callbackId, result: ["registered": true, "action": action, "phrase": phrase])
5453
5621
  }
5454
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
+
5455
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
+
5456
5648
  NSUserActivity.deleteSavedUserActivities(withPersistentIdentifiers: [action]) {
5457
- self.resolveCallback(callbackId, result: ["removed": true, "action": action])
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
+ }
5458
5659
  }
5459
5660
  }
5460
5661
 
package/dist/mobile.js CHANGED
@@ -457,11 +457,18 @@ var share = {
457
457
  return craft.share.share(options);
458
458
  }
459
459
  if (navigator.share) {
460
- await navigator.share({
461
- title: options.title,
462
- text: options.text,
463
- url: options.url
464
- });
460
+ try {
461
+ await navigator.share({
462
+ title: options.title,
463
+ text: options.text,
464
+ url: options.url
465
+ });
466
+ return true;
467
+ } catch (error) {
468
+ if (error instanceof DOMException && error.name === "AbortError")
469
+ return false;
470
+ throw error;
471
+ }
465
472
  } else {
466
473
  throw new Error("Share API not available");
467
474
  }
@@ -555,15 +562,16 @@ var deepLinks = {
555
562
  return craft.deepLinks.onLink((value) => {
556
563
  const url = normalizeDeepLinkURL(value);
557
564
  if (url)
558
- callback(url);
565
+ callback(url, { initial: value?.initial === true });
559
566
  }) ?? (() => {});
560
567
  }
561
568
  if (typeof globalThis === "undefined")
562
569
  return () => {};
563
570
  const listener = (event) => {
564
- const url = normalizeDeepLinkURL(event.detail);
571
+ const detail = event.detail;
572
+ const url = normalizeDeepLinkURL(detail);
565
573
  if (url)
566
- callback(url);
574
+ callback(url, { initial: detail?.initial === true });
567
575
  };
568
576
  globalThis.addEventListener("craftDeepLink", listener);
569
577
  return () => globalThis.removeEventListener("craftDeepLink", listener);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.91",
3
+ "version": "0.0.92",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",