craft-native 0.0.115 → 0.0.119

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.
@@ -111,6 +111,27 @@ function renderOrientations(config) {
111
111
  return values.map((value) => ` <string>${names[value]}</string>`).join(`
112
112
  `);
113
113
  }
114
+ function renderAppBoundDomains(config) {
115
+ const hostOf = (value) => {
116
+ try {
117
+ const url = new URL(value);
118
+ return url.protocol === "https:" || url.protocol === "http:" ? url.hostname.toLowerCase() : null;
119
+ } catch {
120
+ return null;
121
+ }
122
+ };
123
+ const domains = config.appBoundDomains !== undefined ? config.appBoundDomains.map((value) => value.trim().toLowerCase()).filter(Boolean) : (config.trustedOrigins ?? []).map(hostOf).filter((host) => !!host);
124
+ if (!domains.length)
125
+ return "";
126
+ const devHost = config.devServerURL ? hostOf(config.devServerURL) : null;
127
+ if (devHost)
128
+ domains.push(devHost);
129
+ const unique = [...new Set(domains)].slice(0, 10);
130
+ return ` <key>WKAppBoundDomains</key>
131
+ <array>
132
+ ${plistArray(unique)}
133
+ </array>`;
134
+ }
114
135
  function renderUrlTypes(config) {
115
136
  const schemes = [...new Set(config.urlSchemes?.map((value) => value.trim()).filter(Boolean) ?? [])];
116
137
  if (!schemes.length)
@@ -168,6 +189,12 @@ ${plistArray(config.appGroups)}
168
189
  entries.push(` <key>aps-environment</key>
169
190
  <string>$(CRAFT_APNS_ENVIRONMENT)</string>`);
170
191
  }
192
+ if (config.enableSecureStorage) {
193
+ entries.push(` <key>keychain-access-groups</key>
194
+ <array>
195
+ <string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
196
+ </array>`);
197
+ }
171
198
  return `<?xml version="1.0" encoding="UTF-8"?>
172
199
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
173
200
  <plist version="1.0">
@@ -410,7 +437,7 @@ async function init(options) {
410
437
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
411
438
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
412
439
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
413
- const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
440
+ const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
414
441
  <true/>
415
442
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
416
443
  <true/>` : "");
@@ -729,6 +756,7 @@ export {
729
756
  renderDeviceFamilies,
730
757
  renderBackgroundModes,
731
758
  renderAppearance,
759
+ renderAppBoundDomains,
732
760
  productName,
733
761
  pickSimulator,
734
762
  orderSimulators,
@@ -259,6 +259,7 @@ class DeepLinkManager {
259
259
  // the one the app was opened with; a link that arrives later is not, and
260
260
  // is no answer to getInitialURL.
261
261
  private var hasBeenReady = false
262
+ private var nativeListeners: [UUID: (URL, Bool) -> Void] = [:]
262
263
 
263
264
  private init() {}
264
265
 
@@ -289,7 +290,9 @@ class DeepLinkManager {
289
290
  initialURL = url
290
291
  }
291
292
 
292
- if isReady && webView != nil {
293
+ if !nativeListeners.isEmpty {
294
+ dispatchNative(url, initial: false)
295
+ } else if isReady && webView != nil {
293
296
  dispatchDeepLink(url, initial: false)
294
297
  } else {
295
298
  pendingURLs.append(url)
@@ -300,6 +303,28 @@ class DeepLinkManager {
300
303
  return initialURL
301
304
  }
302
305
 
306
+ @discardableResult
307
+ func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
308
+ let token = UUID()
309
+ nativeListeners[token] = listener
310
+ let firstNativeScreen = !hasBeenReady
311
+ hasBeenReady = true
312
+ let urls = pendingURLs
313
+ pendingURLs.removeAll()
314
+ for url in urls {
315
+ listener(url, firstNativeScreen && url == initialURL)
316
+ }
317
+ return token
318
+ }
319
+
320
+ func removeNativeListener(_ token: UUID) {
321
+ nativeListeners.removeValue(forKey: token)
322
+ }
323
+
324
+ private func dispatchNative(_ url: URL, initial: Bool) {
325
+ for listener in nativeListeners.values { listener(url, initial) }
326
+ }
327
+
303
328
  private func dispatchDeepLink(_ url: URL, initial: Bool) {
304
329
  guard let webView = webView else { return }
305
330
 
@@ -392,6 +417,8 @@ struct CraftConfig: Codable {
392
417
  var backgroundColorDark: String? = nil
393
418
  /// Edge-swipe back and forward through the page's history.
394
419
  var swipeNavigation: Bool? = nil
420
+ /// Refuse to load anything but the Info.plist's WKAppBoundDomains.
421
+ var limitNavigationsToAppBoundDomains: Bool? = nil
395
422
  var enableSpeechRecognition: Bool = false
396
423
  var enableHaptics: Bool = false
397
424
  var enableShare: Bool = false
@@ -596,6 +623,13 @@ struct CraftWebView: UIViewRepresentable {
596
623
  webConfig.allowsInlineMediaPlayback = true
597
624
  webConfig.mediaTypesRequiringUserActionForPlayback = []
598
625
  webConfig.setURLSchemeHandler(BundledAssetSchemeHandler(), forURLScheme: "craft")
626
+ // The app's own domains (WKAppBoundDomains in Info.plist) get what an
627
+ // iOS web view reserves for them, service workers among them, so a
628
+ // site that works offline in Safari works offline here. This only
629
+ // narrows loading further when the app asks for it.
630
+ if config.limitNavigationsToAppBoundDomains == true {
631
+ webConfig.limitsNavigationsToAppBoundDomains = true
632
+ }
599
633
 
600
634
  // Add native bridge
601
635
  let contentController = WKUserContentController()
@@ -722,6 +756,24 @@ struct CraftWebView: UIViewRepresentable {
722
756
  // Keep awake
723
757
  private var isKeepingAwake = false
724
758
 
759
+ // Speech synthesis. One synthesizer for the life of the web view, so a
760
+ // cue can interrupt the one before it; each utterance it has not
761
+ // finished yet is held beside the call waiting on it.
762
+ private lazy var speechSynthesizer: AVSpeechSynthesizer = {
763
+ let synthesizer = AVSpeechSynthesizer()
764
+ synthesizer.delegate = self
765
+ return synthesizer
766
+ }()
767
+ private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
768
+ /// What the session was before speech claimed it; nil while speech
769
+ /// does not hold it.
770
+ private var speechAudioSessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
771
+ /// Activating and deactivating the audio session blocks until the
772
+ /// audio hardware answers, which iOS flags as a hang risk on the main
773
+ /// thread. They run here instead, in order, and the session state
774
+ /// above is only touched here.
775
+ private let speechAudioQueue = DispatchQueue(label: "craft.speech.audio")
776
+
725
777
  // Orientation lock
726
778
  private var lockedOrientation: UIInterfaceOrientationMask?
727
779
 
@@ -1058,6 +1110,16 @@ struct CraftWebView: UIViewRepresentable {
1058
1110
  } else {
1059
1111
  rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
1060
1112
  }
1113
+ // Speech synthesis. Ungated, like the flashlight: speaking needs no
1114
+ // permission and no entitlement.
1115
+ case "speak":
1116
+ if let text = body["text"] as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1117
+ speak(text, body: body, callbackId: callbackId)
1118
+ } else {
1119
+ rejectCallback(callbackId, error: "speak needs some text to say", code: "INVALID_ARGUMENT")
1120
+ }
1121
+ case "stopSpeaking":
1122
+ stopSpeaking(callbackId: callbackId)
1061
1123
  // Vibrate pattern
1062
1124
  case "vibrate":
1063
1125
  if let pattern = body["pattern"] as? [Int] {
@@ -1850,6 +1912,7 @@ struct CraftWebView: UIViewRepresentable {
1850
1912
  orientationLock: \(config.enableOrientationLock),
1851
1913
  deepLinks: \(config.enableDeepLinks),
1852
1914
  flashlight: true,
1915
+ speech: true,
1853
1916
  network: true,
1854
1917
  deviceInfo: true,
1855
1918
  badge: true,
@@ -2788,6 +2851,37 @@ struct CraftWebView: UIViewRepresentable {
2788
2851
  });
2789
2852
  },
2790
2853
 
2854
+ // Speech synthesis. speak() settles when the utterance ends:
2855
+ // true when it was spoken, false when something stopped it.
2856
+ // No timeout, because a long sentence at a slow rate can
2857
+ // outlast any deadline that would be fair to a short one.
2858
+ speech: {
2859
+ speak: function(text, options) {
2860
+ var self = window.craft;
2861
+ var id = 'cb_' + (++self._callbackId);
2862
+ options = options || {};
2863
+ window.webkit.messageHandlers.craft.postMessage({
2864
+ action: 'speak',
2865
+ text: text == null ? '' : String(text),
2866
+ rate: typeof options.rate === 'number' ? options.rate : null,
2867
+ language: typeof options.language === 'string' ? options.language : null,
2868
+ interrupt: options.interrupt !== false,
2869
+ callbackId: id
2870
+ });
2871
+ return new Promise(function(resolve, reject) {
2872
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2873
+ });
2874
+ },
2875
+ stop: function() {
2876
+ var self = window.craft;
2877
+ var id = 'cb_' + (++self._callbackId);
2878
+ window.webkit.messageHandlers.craft.postMessage({action: 'stopSpeaking', callbackId: id});
2879
+ return new Promise(function(resolve, reject) {
2880
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2881
+ });
2882
+ }
2883
+ },
2884
+
2791
2885
  // Orientation Lock
2792
2886
  lockOrientation: function(orientation) {
2793
2887
  var self = this;
@@ -3400,6 +3494,10 @@ struct CraftWebView: UIViewRepresentable {
3400
3494
  craft.secureStorage = {
3401
3495
  set: function(key, value) { return legacySecureStore.set(key, value).then(function() {}); },
3402
3496
  get: function(key) { return legacySecureStore.get(key); },
3497
+ // `remove` is the legacy name retained by the public Craft
3498
+ // type declaration; keep it as an alias of `delete` so
3499
+ // native STX callers can migrate without a feature check.
3500
+ remove: function(key) { return legacySecureStore.remove(key).then(function() {}); },
3403
3501
  delete: function(key) { return legacySecureStore.remove(key).then(function() {}); },
3404
3502
  clear: function() { return craft._invoke('secureClear').then(function() {}); }
3405
3503
  };
@@ -4762,6 +4860,153 @@ struct CraftWebView: UIViewRepresentable {
4762
4860
  }
4763
4861
  }
4764
4862
 
4863
+ // MARK: - Speech Synthesis
4864
+ //
4865
+ // Native rather than the page's own `speechSynthesis`, which in a
4866
+ // WKWebView plays through the default soloAmbient session: it stops
4867
+ // the person's music and the silent switch mutes it. A workout cue has
4868
+ // to do neither, so the session is set up here for as long as there is
4869
+ // something to say, and handed back when there is not.
4870
+
4871
+ private func speak(_ text: String, body: [String: Any], callbackId: String?) {
4872
+ DispatchQueue.main.async {
4873
+ if body["interrupt"] as? Bool ?? true {
4874
+ self.cancelPendingSpeech()
4875
+ }
4876
+
4877
+ let utterance = AVSpeechUtterance(string: text)
4878
+ utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
4879
+ // An unknown language finds no voice, and a nil voice is the
4880
+ // device's own, which is the default anyway.
4881
+ if let language = body["language"] as? String, !language.isEmpty,
4882
+ let voice = AVSpeechSynthesisVoice(language: language) {
4883
+ utterance.voice = voice
4884
+ }
4885
+
4886
+ self.pendingUtterances.append((utterance, callbackId))
4887
+ self.claimAudioSessionForSpeech {
4888
+ // Stopped while the session was being set up: say nothing.
4889
+ guard self.pendingUtterances.contains(where: { $0.utterance === utterance }) else { return }
4890
+ self.speechSynthesizer.speak(utterance)
4891
+ }
4892
+ }
4893
+ }
4894
+
4895
+ private func stopSpeaking(callbackId: String?) {
4896
+ DispatchQueue.main.async {
4897
+ // When something was playing, its didCancel releases the
4898
+ // session once the audio has actually stopped; deactivating
4899
+ // before that fails as busy and leaves the music ducked.
4900
+ if !self.cancelPendingSpeech() { self.releaseAudioSessionIfSilent() }
4901
+ self.resolveCallback(callbackId, result: true)
4902
+ }
4903
+ }
4904
+
4905
+ /// Stop what is being said and settle every call waiting on it, false.
4906
+ /// Returns whether the synthesizer was making a sound.
4907
+ ///
4908
+ /// Settled here rather than in `didCancel`, because the delegate hears
4909
+ /// about the utterance that was playing and nothing about the ones
4910
+ /// queued behind it, whose calls would otherwise never settle.
4911
+ @discardableResult
4912
+ private func cancelPendingSpeech() -> Bool {
4913
+ let waiting = pendingUtterances
4914
+ pendingUtterances.removeAll()
4915
+ let synthesizer = speechSynthesizer
4916
+ let wasSounding = synthesizer.isSpeaking || synthesizer.isPaused
4917
+ if wasSounding { synthesizer.stopSpeaking(at: .immediate) }
4918
+ for entry in waiting {
4919
+ resolveCallback(entry.callbackId, result: false)
4920
+ }
4921
+ return wasSounding
4922
+ }
4923
+
4924
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
4925
+ ///
4926
+ /// Apple's scale is not linear in speed: the default sits at 0.5 and
4927
+ /// the maximum is far faster than twice that. So a slower rate scales
4928
+ /// the default down in proportion, and a faster one climbs toward the
4929
+ /// maximum at half the slope, which keeps 2 brisk but intelligible.
4930
+ private static func utteranceRate(_ rate: Double) -> Float {
4931
+ let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
4932
+ let normal = AVSpeechUtteranceDefaultSpeechRate
4933
+ let mapped = requested <= 1
4934
+ ? normal * requested
4935
+ : normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
4936
+ return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
4937
+ }
4938
+
4939
+ /// Duck the music rather than stop it, and play through the silent
4940
+ /// switch.
4941
+ ///
4942
+ /// `.playback` is what the switch does not mute, `.duckOthers` lowers
4943
+ /// the music under the cue, and the spoken-audio option pauses a
4944
+ /// podcast rather than talking over it. Left alone while recognition
4945
+ /// or a recording holds the session: switching it to playback would
4946
+ /// cut their microphone off mid-take.
4947
+ ///
4948
+ /// Set up on the speech queue, then `start` runs on the main thread.
4949
+ private func claimAudioSessionForSpeech(then start: @escaping () -> Void) {
4950
+ let microphoneBusy = audioEngine.isRunning || (audioRecorder?.isRecording ?? false)
4951
+ speechAudioQueue.async {
4952
+ if self.speechAudioSessionToRestore == nil && !microphoneBusy {
4953
+ let session = AVAudioSession.sharedInstance()
4954
+ let previous = (session.category, session.mode, session.categoryOptions)
4955
+ do {
4956
+ try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
4957
+ try session.setActive(true)
4958
+ self.speechAudioSessionToRestore = previous
4959
+ } catch {
4960
+ // Still speak, through whatever session the app has: a
4961
+ // cue that does not duck beats one that never plays.
4962
+ print("[Craft] Speech audio session failed: \(error.localizedDescription)")
4963
+ }
4964
+ }
4965
+ DispatchQueue.main.async(execute: start)
4966
+ }
4967
+ }
4968
+
4969
+ /// Hand the audio back once nothing is left to say, so the music
4970
+ /// returns to full volume instead of staying ducked, and put back the
4971
+ /// category the app had, so the page's own media plays as before.
4972
+ ///
4973
+ /// Checked on the main thread, where the queue of utterances lives, and
4974
+ /// done on the speech queue after any setup already asked for, so a
4975
+ /// cue that arrives meanwhile reclaims the session after this.
4976
+ private func releaseAudioSessionIfSilent(retry: Bool = true) {
4977
+ guard pendingUtterances.isEmpty else { return }
4978
+ speechAudioQueue.async {
4979
+ guard let previous = self.speechAudioSessionToRestore else { return }
4980
+ let session = AVAudioSession.sharedInstance()
4981
+ do {
4982
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
4983
+ } catch {
4984
+ // Busy: the synthesizer's audio has not quite drained, or
4985
+ // the page has its own media playing. Once more shortly
4986
+ // covers the first; for the second, staying active is
4987
+ // right, and the ducking still ends when the music app
4988
+ // hears nothing more.
4989
+ if retry {
4990
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
4991
+ self.releaseAudioSessionIfSilent(retry: false)
4992
+ }
4993
+ return
4994
+ }
4995
+ print("[Craft] Speech audio session release failed: \(error.localizedDescription)")
4996
+ }
4997
+ self.speechAudioSessionToRestore = nil
4998
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
4999
+ }
5000
+ }
5001
+
5002
+ fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
5003
+ if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
5004
+ let entry = pendingUtterances.remove(at: index)
5005
+ resolveCallback(entry.callbackId, result: spoken)
5006
+ }
5007
+ releaseAudioSessionIfSilent()
5008
+ }
5009
+
4765
5010
  // MARK: - Orientation Lock
4766
5011
  private func lockOrientation(_ orientation: String, callbackId: String?) {
4767
5012
  var mask: UIInterfaceOrientationMask = .all
@@ -6698,6 +6943,20 @@ extension URL {
6698
6943
  }
6699
6944
 
6700
6945
  // MARK: - Contact Picker Delegate
6946
+ // MARK: - AVSpeechSynthesizerDelegate
6947
+ //
6948
+ // The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
6949
+ // main because every piece of speech state is touched there and nowhere else.
6950
+ extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
6951
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
6952
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
6953
+ }
6954
+
6955
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
6956
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
6957
+ }
6958
+ }
6959
+
6701
6960
  extension CraftWebView.Coordinator: CNContactPickerDelegate {
6702
6961
  func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
6703
6962
  let contactData = formatContact(contact)