craft-native 0.0.113 → 0.0.118

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.
@@ -58,6 +58,7 @@ dependencies {
58
58
  implementation("androidx.core:core-ktx:1.12.0")
59
59
  implementation("androidx.appcompat:appcompat:1.6.1")
60
60
  implementation("androidx.fragment:fragment-ktx:1.6.2")
61
+ implementation("androidx.recyclerview:recyclerview:1.3.2")
61
62
  implementation("androidx.webkit:webkit:1.12.1")
62
63
  {{JAVASCRIPT_ENGINE_DEPENDENCY}}
63
64
  implementation("com.google.android.material:material:1.11.0")
@@ -687,6 +687,34 @@ export declare const keepAwake: {
687
687
  enable(): Promise<void>;
688
688
  disable(): Promise<void>;
689
689
  };
690
+ export interface SpeechOptions {
691
+ /** Speaking rate, 0.5 to 2. 1 is the voice's normal pace. */
692
+ rate?: number;
693
+ /** BCP-47 language tag, such as 'en-US'. Defaults to the device's language. */
694
+ language?: string;
695
+ /** Stop whatever is being said first. Defaults to true; false queues behind it. */
696
+ interrupt?: boolean;
697
+ }
698
+ /**
699
+ * Short spoken cues: "Rest, 15 seconds. Up next: Dead Bug".
700
+ *
701
+ * Inside a Craft app this is native text-to-speech, which ducks the person's
702
+ * music rather than stopping it and, on iOS, plays with the silent switch on.
703
+ * In a browser it falls back to the Web Speech API, which does neither, and
704
+ * where there is no speech at all it answers `false` rather than throwing, so
705
+ * a cue never breaks the flow around it.
706
+ */
707
+ export declare const speech: {
708
+ isAvailable(): boolean;
709
+ /**
710
+ * Speak `text`. Resolves when the utterance ends: `true` when it was
711
+ * spoken, `false` when it was stopped, interrupted or could not be spoken.
712
+ * Rejects with code `INVALID_ARGUMENT` when `text` is empty.
713
+ */
714
+ speak(text: string, options?: SpeechOptions): Promise<boolean>;
715
+ /** Stop speaking at once. Every pending `speak()` resolves `false`. */
716
+ stop(): Promise<void>;
717
+ };
690
718
  export declare const deepLinks: {
691
719
  getInitialURL(): Promise<string | null>;
692
720
  /**
@@ -852,6 +880,7 @@ declare const mobile: {
852
880
  lifecycle: typeof lifecycle;
853
881
  notifications: typeof notifications;
854
882
  keepAwake: typeof keepAwake;
883
+ speech: typeof speech;
855
884
  deepLinks: typeof deepLinks;
856
885
  network: typeof network;
857
886
  appReview: typeof appReview;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.113";
3306
+ var version = "0.0.118";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.113";
313
+ var version = "0.0.118";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
@@ -3649,6 +3649,13 @@ var _watchRecursiveWarned = false;
3649
3649
  async function watch(path, callback, options = {}) {
3650
3650
  validatePath(path);
3651
3651
  const recursive = options.recursive ?? true;
3652
+ const native2 = getCraftFs();
3653
+ if (native2?.watch) {
3654
+ const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
3655
+ return () => {
3656
+ handle.unwatch();
3657
+ };
3658
+ }
3652
3659
  if (typeof window !== "undefined" && window.craft) {
3653
3660
  const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
3654
3661
  await callBridge("fs.watch", { path, watchId, recursive });
@@ -5277,6 +5284,7 @@ var notifications = {
5277
5284
  }
5278
5285
  }
5279
5286
  };
5287
+ var speakingOnTheWeb = new Set;
5280
5288
  function detectPlatform2() {
5281
5289
  if (typeof window === "undefined")
5282
5290
  return "linux";
package/dist/index.d.cts CHANGED
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
81
81
  * Quick helper to load a URL
82
82
  */
83
83
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
84
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
84
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.d.ts CHANGED
@@ -81,4 +81,4 @@ export declare function show(html: string, options?: WindowOptions): Promise<voi
81
81
  * Quick helper to load a URL
82
82
  */
83
83
  export declare function loadURL(url: string, options?: WindowOptions): Promise<void>;
84
- export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
84
+ export type { WindowOptions, CraftSidebarAPI, CraftNativeUIAPI, CraftContextMenuItem, CraftContextMenuResult, AppConfig, CraftTrayAPI, CraftWindowAPI, CraftAppAPI, CraftBridgeAPI, Permission, HapticType, CraftHapticsAPI, CameraOptions, PhotoPickerOptions, CraftMobileAPI, CraftFileSystemAPI, CraftFsWatchEvent, CraftFsWatchHandle, CraftDatabaseAPI, CraftHttpAPI, CraftCryptoAPI, CraftCapabilities, CapabilityNamespace, CapabilityNamespaceStatus, CapabilityAction, CraftPreferencesAPI, CraftPreferencesInfo, CraftSettingsAPI, PreferenceValue, CraftEventType, CraftEventMap, CraftEventHandler, CraftEventEmitter, IOSConfig, AndroidConfig, MacOSConfig, WindowsConfig, LinuxConfig, CraftAppConfig, } from './types.js';
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.113";
61
+ var version = "0.0.118";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -3409,6 +3409,13 @@ var _watchRecursiveWarned = false;
3409
3409
  async function watch(path, callback, options = {}) {
3410
3410
  validatePath(path);
3411
3411
  const recursive = options.recursive ?? true;
3412
+ const native2 = getCraftFs();
3413
+ if (native2?.watch) {
3414
+ const handle = await native2.watch(path, (event) => callback(event.type, event.path), { recursive });
3415
+ return () => {
3416
+ handle.unwatch();
3417
+ };
3418
+ }
3412
3419
  if (typeof window !== "undefined" && window.craft) {
3413
3420
  const watchId = typeof globalThis.crypto !== "undefined" && "randomUUID" in globalThis.crypto ? globalThis.crypto.randomUUID() : `${Date.now()}_${performance.now()}`;
3414
3421
  await callBridge("fs.watch", { path, watchId, recursive });
@@ -5037,6 +5044,7 @@ var notifications = {
5037
5044
  }
5038
5045
  }
5039
5046
  };
5047
+ var speakingOnTheWeb = new Set;
5040
5048
  function detectPlatform2() {
5041
5049
  if (typeof window === "undefined")
5042
5050
  return "linux";
@@ -406,7 +406,9 @@ async function init(options) {
406
406
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
407
407
  writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
408
408
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
409
+ cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
409
410
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
411
+ cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
410
412
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
411
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>
412
414
  <true/>
@@ -722,6 +722,24 @@ struct CraftWebView: UIViewRepresentable {
722
722
  // Keep awake
723
723
  private var isKeepingAwake = false
724
724
 
725
+ // Speech synthesis. One synthesizer for the life of the web view, so a
726
+ // cue can interrupt the one before it; each utterance it has not
727
+ // finished yet is held beside the call waiting on it.
728
+ private lazy var speechSynthesizer: AVSpeechSynthesizer = {
729
+ let synthesizer = AVSpeechSynthesizer()
730
+ synthesizer.delegate = self
731
+ return synthesizer
732
+ }()
733
+ private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
734
+ /// What the session was before speech claimed it; nil while speech
735
+ /// does not hold it.
736
+ private var speechAudioSessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
737
+ /// Activating and deactivating the audio session blocks until the
738
+ /// audio hardware answers, which iOS flags as a hang risk on the main
739
+ /// thread. They run here instead, in order, and the session state
740
+ /// above is only touched here.
741
+ private let speechAudioQueue = DispatchQueue(label: "craft.speech.audio")
742
+
725
743
  // Orientation lock
726
744
  private var lockedOrientation: UIInterfaceOrientationMask?
727
745
 
@@ -1058,6 +1076,16 @@ struct CraftWebView: UIViewRepresentable {
1058
1076
  } else {
1059
1077
  rejectCallback(callbackId, error: "setFlashlight needs true or false", code: "INVALID_ARGUMENT")
1060
1078
  }
1079
+ // Speech synthesis. Ungated, like the flashlight: speaking needs no
1080
+ // permission and no entitlement.
1081
+ case "speak":
1082
+ if let text = body["text"] as? String, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
1083
+ speak(text, body: body, callbackId: callbackId)
1084
+ } else {
1085
+ rejectCallback(callbackId, error: "speak needs some text to say", code: "INVALID_ARGUMENT")
1086
+ }
1087
+ case "stopSpeaking":
1088
+ stopSpeaking(callbackId: callbackId)
1061
1089
  // Vibrate pattern
1062
1090
  case "vibrate":
1063
1091
  if let pattern = body["pattern"] as? [Int] {
@@ -1850,6 +1878,7 @@ struct CraftWebView: UIViewRepresentable {
1850
1878
  orientationLock: \(config.enableOrientationLock),
1851
1879
  deepLinks: \(config.enableDeepLinks),
1852
1880
  flashlight: true,
1881
+ speech: true,
1853
1882
  network: true,
1854
1883
  deviceInfo: true,
1855
1884
  badge: true,
@@ -2788,6 +2817,37 @@ struct CraftWebView: UIViewRepresentable {
2788
2817
  });
2789
2818
  },
2790
2819
 
2820
+ // Speech synthesis. speak() settles when the utterance ends:
2821
+ // true when it was spoken, false when something stopped it.
2822
+ // No timeout, because a long sentence at a slow rate can
2823
+ // outlast any deadline that would be fair to a short one.
2824
+ speech: {
2825
+ speak: function(text, options) {
2826
+ var self = window.craft;
2827
+ var id = 'cb_' + (++self._callbackId);
2828
+ options = options || {};
2829
+ window.webkit.messageHandlers.craft.postMessage({
2830
+ action: 'speak',
2831
+ text: text == null ? '' : String(text),
2832
+ rate: typeof options.rate === 'number' ? options.rate : null,
2833
+ language: typeof options.language === 'string' ? options.language : null,
2834
+ interrupt: options.interrupt !== false,
2835
+ callbackId: id
2836
+ });
2837
+ return new Promise(function(resolve, reject) {
2838
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2839
+ });
2840
+ },
2841
+ stop: function() {
2842
+ var self = window.craft;
2843
+ var id = 'cb_' + (++self._callbackId);
2844
+ window.webkit.messageHandlers.craft.postMessage({action: 'stopSpeaking', callbackId: id});
2845
+ return new Promise(function(resolve, reject) {
2846
+ self._callbacks[id] = {resolve: resolve, reject: reject};
2847
+ });
2848
+ }
2849
+ },
2850
+
2791
2851
  // Orientation Lock
2792
2852
  lockOrientation: function(orientation) {
2793
2853
  var self = this;
@@ -4762,6 +4822,153 @@ struct CraftWebView: UIViewRepresentable {
4762
4822
  }
4763
4823
  }
4764
4824
 
4825
+ // MARK: - Speech Synthesis
4826
+ //
4827
+ // Native rather than the page's own `speechSynthesis`, which in a
4828
+ // WKWebView plays through the default soloAmbient session: it stops
4829
+ // the person's music and the silent switch mutes it. A workout cue has
4830
+ // to do neither, so the session is set up here for as long as there is
4831
+ // something to say, and handed back when there is not.
4832
+
4833
+ private func speak(_ text: String, body: [String: Any], callbackId: String?) {
4834
+ DispatchQueue.main.async {
4835
+ if body["interrupt"] as? Bool ?? true {
4836
+ self.cancelPendingSpeech()
4837
+ }
4838
+
4839
+ let utterance = AVSpeechUtterance(string: text)
4840
+ utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
4841
+ // An unknown language finds no voice, and a nil voice is the
4842
+ // device's own, which is the default anyway.
4843
+ if let language = body["language"] as? String, !language.isEmpty,
4844
+ let voice = AVSpeechSynthesisVoice(language: language) {
4845
+ utterance.voice = voice
4846
+ }
4847
+
4848
+ self.pendingUtterances.append((utterance, callbackId))
4849
+ self.claimAudioSessionForSpeech {
4850
+ // Stopped while the session was being set up: say nothing.
4851
+ guard self.pendingUtterances.contains(where: { $0.utterance === utterance }) else { return }
4852
+ self.speechSynthesizer.speak(utterance)
4853
+ }
4854
+ }
4855
+ }
4856
+
4857
+ private func stopSpeaking(callbackId: String?) {
4858
+ DispatchQueue.main.async {
4859
+ // When something was playing, its didCancel releases the
4860
+ // session once the audio has actually stopped; deactivating
4861
+ // before that fails as busy and leaves the music ducked.
4862
+ if !self.cancelPendingSpeech() { self.releaseAudioSessionIfSilent() }
4863
+ self.resolveCallback(callbackId, result: true)
4864
+ }
4865
+ }
4866
+
4867
+ /// Stop what is being said and settle every call waiting on it, false.
4868
+ /// Returns whether the synthesizer was making a sound.
4869
+ ///
4870
+ /// Settled here rather than in `didCancel`, because the delegate hears
4871
+ /// about the utterance that was playing and nothing about the ones
4872
+ /// queued behind it, whose calls would otherwise never settle.
4873
+ @discardableResult
4874
+ private func cancelPendingSpeech() -> Bool {
4875
+ let waiting = pendingUtterances
4876
+ pendingUtterances.removeAll()
4877
+ let synthesizer = speechSynthesizer
4878
+ let wasSounding = synthesizer.isSpeaking || synthesizer.isPaused
4879
+ if wasSounding { synthesizer.stopSpeaking(at: .immediate) }
4880
+ for entry in waiting {
4881
+ resolveCallback(entry.callbackId, result: false)
4882
+ }
4883
+ return wasSounding
4884
+ }
4885
+
4886
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
4887
+ ///
4888
+ /// Apple's scale is not linear in speed: the default sits at 0.5 and
4889
+ /// the maximum is far faster than twice that. So a slower rate scales
4890
+ /// the default down in proportion, and a faster one climbs toward the
4891
+ /// maximum at half the slope, which keeps 2 brisk but intelligible.
4892
+ private static func utteranceRate(_ rate: Double) -> Float {
4893
+ let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
4894
+ let normal = AVSpeechUtteranceDefaultSpeechRate
4895
+ let mapped = requested <= 1
4896
+ ? normal * requested
4897
+ : normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
4898
+ return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
4899
+ }
4900
+
4901
+ /// Duck the music rather than stop it, and play through the silent
4902
+ /// switch.
4903
+ ///
4904
+ /// `.playback` is what the switch does not mute, `.duckOthers` lowers
4905
+ /// the music under the cue, and the spoken-audio option pauses a
4906
+ /// podcast rather than talking over it. Left alone while recognition
4907
+ /// or a recording holds the session: switching it to playback would
4908
+ /// cut their microphone off mid-take.
4909
+ ///
4910
+ /// Set up on the speech queue, then `start` runs on the main thread.
4911
+ private func claimAudioSessionForSpeech(then start: @escaping () -> Void) {
4912
+ let microphoneBusy = audioEngine.isRunning || (audioRecorder?.isRecording ?? false)
4913
+ speechAudioQueue.async {
4914
+ if self.speechAudioSessionToRestore == nil && !microphoneBusy {
4915
+ let session = AVAudioSession.sharedInstance()
4916
+ let previous = (session.category, session.mode, session.categoryOptions)
4917
+ do {
4918
+ try session.setCategory(.playback, mode: .voicePrompt, options: [.duckOthers, .interruptSpokenAudioAndMixWithOthers])
4919
+ try session.setActive(true)
4920
+ self.speechAudioSessionToRestore = previous
4921
+ } catch {
4922
+ // Still speak, through whatever session the app has: a
4923
+ // cue that does not duck beats one that never plays.
4924
+ print("[Craft] Speech audio session failed: \(error.localizedDescription)")
4925
+ }
4926
+ }
4927
+ DispatchQueue.main.async(execute: start)
4928
+ }
4929
+ }
4930
+
4931
+ /// Hand the audio back once nothing is left to say, so the music
4932
+ /// returns to full volume instead of staying ducked, and put back the
4933
+ /// category the app had, so the page's own media plays as before.
4934
+ ///
4935
+ /// Checked on the main thread, where the queue of utterances lives, and
4936
+ /// done on the speech queue after any setup already asked for, so a
4937
+ /// cue that arrives meanwhile reclaims the session after this.
4938
+ private func releaseAudioSessionIfSilent(retry: Bool = true) {
4939
+ guard pendingUtterances.isEmpty else { return }
4940
+ speechAudioQueue.async {
4941
+ guard let previous = self.speechAudioSessionToRestore else { return }
4942
+ let session = AVAudioSession.sharedInstance()
4943
+ do {
4944
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
4945
+ } catch {
4946
+ // Busy: the synthesizer's audio has not quite drained, or
4947
+ // the page has its own media playing. Once more shortly
4948
+ // covers the first; for the second, staying active is
4949
+ // right, and the ducking still ends when the music app
4950
+ // hears nothing more.
4951
+ if retry {
4952
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
4953
+ self.releaseAudioSessionIfSilent(retry: false)
4954
+ }
4955
+ return
4956
+ }
4957
+ print("[Craft] Speech audio session release failed: \(error.localizedDescription)")
4958
+ }
4959
+ self.speechAudioSessionToRestore = nil
4960
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
4961
+ }
4962
+ }
4963
+
4964
+ fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
4965
+ if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
4966
+ let entry = pendingUtterances.remove(at: index)
4967
+ resolveCallback(entry.callbackId, result: spoken)
4968
+ }
4969
+ releaseAudioSessionIfSilent()
4970
+ }
4971
+
4765
4972
  // MARK: - Orientation Lock
4766
4973
  private func lockOrientation(_ orientation: String, callbackId: String?) {
4767
4974
  var mask: UIInterfaceOrientationMask = .all
@@ -6698,6 +6905,20 @@ extension URL {
6698
6905
  }
6699
6906
 
6700
6907
  // MARK: - Contact Picker Delegate
6908
+ // MARK: - AVSpeechSynthesizerDelegate
6909
+ //
6910
+ // The end of an utterance is what `craft.speech.speak()` waits for. Hopped to
6911
+ // main because every piece of speech state is touched there and nowhere else.
6912
+ extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
6913
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
6914
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
6915
+ }
6916
+
6917
+ func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
6918
+ DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
6919
+ }
6920
+ }
6921
+
6701
6922
  extension CraftWebView.Coordinator: CNContactPickerDelegate {
6702
6923
  func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
6703
6924
  let contactData = formatContact(contact)