craft-native 0.0.166 → 0.0.168

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/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.166";
3306
+ var version = "0.0.168";
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.166";
313
+ var version = "0.0.168";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
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.166";
61
+ var version = "0.0.168";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -1712,14 +1712,22 @@ struct CraftWebView: UIViewRepresentable {
1712
1712
  // Keep awake
1713
1713
  private var isKeepingAwake = false
1714
1714
 
1715
- // Speech synthesis. One synthesizer for the life of the web view, so a
1716
- // cue can interrupt the one before it; each utterance it has not
1717
- // finished yet is held beside the call waiting on it.
1715
+ // Speech synthesis. Each utterance not finished yet is held beside the
1716
+ // call waiting on it. A cue that interrupts one still sounding speaks
1717
+ // on a fresh synthesizer: AVSpeechSynthesizer can drop an utterance
1718
+ // handed to it straight after `stopSpeaking(at: .immediate)`, which
1719
+ // lost the "2" of a "3, 2, 1" countdown. The stopped one is kept in
1720
+ // `retiredSpeechSynthesizers` until it reports the cancel.
1718
1721
  private lazy var speechSynthesizer: AVSpeechSynthesizer = {
1719
1722
  let synthesizer = AVSpeechSynthesizer()
1720
1723
  synthesizer.delegate = self
1721
1724
  return synthesizer
1722
1725
  }()
1726
+ private var retiredSpeechSynthesizers: [AVSpeechSynthesizer] = []
1727
+ /// The session is handed back this long after the last word, so a
1728
+ /// countdown ducks the music once rather than between every number.
1729
+ private static let speechReleaseAfterSilence: TimeInterval = 1.5
1730
+ private var pendingSpeechRelease: DispatchWorkItem?
1723
1731
  private var pendingUtterances: [(utterance: AVSpeechUtterance, callbackId: String?)] = []
1724
1732
  /// What the session was before speech claimed it; nil while speech
1725
1733
  /// does not hold it.
@@ -6715,8 +6723,13 @@ struct CraftWebView: UIViewRepresentable {
6715
6723
 
6716
6724
  private func speak(_ text: String, body: [String: Any], callbackId: String?) {
6717
6725
  DispatchQueue.main.async {
6718
- if body["interrupt"] as? Bool ?? true {
6719
- self.cancelPendingSpeech()
6726
+ self.pendingSpeechRelease?.cancel()
6727
+ self.pendingSpeechRelease = nil
6728
+ if body["interrupt"] as? Bool ?? true, self.cancelPendingSpeech() {
6729
+ self.retiredSpeechSynthesizers.append(self.speechSynthesizer)
6730
+ let fresh = AVSpeechSynthesizer()
6731
+ fresh.delegate = self
6732
+ self.speechSynthesizer = fresh
6720
6733
  }
6721
6734
 
6722
6735
  let utterance = AVSpeechUtterance(string: text)
@@ -6818,8 +6831,20 @@ struct CraftWebView: UIViewRepresentable {
6818
6831
  /// Checked on the main thread, where the queue of utterances lives, and
6819
6832
  /// done on the speech queue after any setup already asked for, so a
6820
6833
  /// cue that arrives meanwhile reclaims the session after this.
6821
- private func releaseAudioSessionIfSilent(retry: Bool = true) {
6834
+ private func releaseAudioSessionIfSilent(retry: Bool = true, after delay: TimeInterval = Coordinator.speechReleaseAfterSilence) {
6822
6835
  guard pendingUtterances.isEmpty else { return }
6836
+ pendingSpeechRelease?.cancel()
6837
+ let work = DispatchWorkItem { [weak self] in
6838
+ guard let self else { return }
6839
+ self.pendingSpeechRelease = nil
6840
+ guard self.pendingUtterances.isEmpty, !self.speechSynthesizer.isSpeaking else { return }
6841
+ self.deactivateSpeechAudioSession(retry: retry)
6842
+ }
6843
+ pendingSpeechRelease = work
6844
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
6845
+ }
6846
+
6847
+ private func deactivateSpeechAudioSession(retry: Bool) {
6823
6848
  speechAudioQueue.async {
6824
6849
  guard let previous = self.speechAudioSessionToRestore else { return }
6825
6850
  let session = AVAudioSession.sharedInstance()
@@ -6832,8 +6857,8 @@ struct CraftWebView: UIViewRepresentable {
6832
6857
  // right, and the ducking still ends when the music app
6833
6858
  // hears nothing more.
6834
6859
  if retry {
6835
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
6836
- self.releaseAudioSessionIfSilent(retry: false)
6860
+ DispatchQueue.main.async {
6861
+ self.releaseAudioSessionIfSilent(retry: false, after: 0.5)
6837
6862
  }
6838
6863
  return
6839
6864
  }
@@ -6844,11 +6869,14 @@ struct CraftWebView: UIViewRepresentable {
6844
6869
  }
6845
6870
  }
6846
6871
 
6847
- fileprivate func speechDidEnd(_ utterance: AVSpeechUtterance, spoken: Bool) {
6872
+ fileprivate func speechDidEnd(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
6848
6873
  if let index = pendingUtterances.firstIndex(where: { $0.utterance === utterance }) {
6849
6874
  let entry = pendingUtterances.remove(at: index)
6850
6875
  resolveCallback(entry.callbackId, result: spoken)
6851
6876
  }
6877
+ if synthesizer !== speechSynthesizer, !synthesizer.isSpeaking {
6878
+ retiredSpeechSynthesizers.removeAll { $0 === synthesizer }
6879
+ }
6852
6880
  releaseAudioSessionIfSilent()
6853
6881
  }
6854
6882
 
@@ -8480,11 +8508,11 @@ extension CraftWebView.Coordinator: WKUIDelegate {
8480
8508
 
8481
8509
  extension CraftWebView.Coordinator: AVSpeechSynthesizerDelegate {
8482
8510
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
8483
- DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: true) }
8511
+ DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: true) }
8484
8512
  }
8485
8513
 
8486
8514
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
8487
- DispatchQueue.main.async { self.speechDidEnd(utterance, spoken: false) }
8515
+ DispatchQueue.main.async { self.speechDidEnd(synthesizer, utterance, spoken: false) }
8488
8516
  }
8489
8517
  }
8490
8518
 
@@ -2694,7 +2694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2694
2694
  }
2695
2695
 
2696
2696
  private func refreshTraitDefaults() {
2697
- view.backgroundColor = config.resolvedBackgroundColor
2697
+ view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
2698
2698
  if let document = mutationDocument.node("root") {
2699
2699
  renderCommitted(document)
2700
2700
  } else {
@@ -2895,8 +2895,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2895
2895
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2896
2896
  /// `craft.secureStorage.set` wrote.
2897
2897
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
2898
- /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
2899
- /// tap is the event `navButton`.
2898
+ /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, backgroundColor, rightButtons })`;
2899
+ /// a right button's tap is the event `navButton`.
2900
2900
  ///
2901
2901
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
2902
2902
  /// these: each namespace is a property whose setter adds them back to the
@@ -3148,6 +3148,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3148
3148
  /// own horizontal pager or chart gesture.
3149
3149
  private(set) var tabSwipeEnabled = true
3150
3150
  private var prefersLargeTitle: Bool?
3151
+ /// The screen's own colour (`backgroundColor`), under the status bar and
3152
+ /// the home indicator too, where its content never reaches: a black
3153
+ /// full-screen timer is black edge to edge. nil is the app's.
3154
+ private var screenBackgroundColor: UIColor?
3151
3155
 
3152
3156
  /// Whether this screen asked for a bar of its own through `setOptions`: a
3153
3157
  /// title, a large title or buttons, and not `hidden`. A tab's root screen
@@ -3188,6 +3192,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3188
3192
  if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
3189
3193
  self.tabSwipeEnabled = tabSwipeEnabled
3190
3194
  }
3195
+ if options.keys.contains("backgroundColor") {
3196
+ screenBackgroundColor = color(options["backgroundColor"])
3197
+ view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3198
+ }
3191
3199
  if let hidden = options["hidden"] as? Bool {
3192
3200
  navigationBarHidden = hidden
3193
3201
  if navigationController?.topViewController === self {
@@ -3345,12 +3353,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3345
3353
  }
3346
3354
  return nil
3347
3355
  }
3356
+ /// The scroll view the screen starts with, when nothing sits above it:
3357
+ /// the root's first child, its first, and so on, none padded on top.
3358
+ func leading(_ node: RenderedNode) -> UIScrollView? {
3359
+ if let scroll = node.view as? CraftNativeScrollView { return scroll.isVertical && scroll.isScrollEnabled ? scroll : nil }
3360
+ if let list = node.view as? CraftNativeFlatList {
3361
+ return list.isScrollEnabled && (list.collectionViewLayout as? UICollectionViewFlowLayout)?.scrollDirection != .horizontal ? list : nil
3362
+ }
3363
+ guard paddingInsets(node.style).top == 0,
3364
+ let first = node.children.first(where: { $0.type != "Modal" && !$0.view.isHidden }) else { return nil }
3365
+ return leading(first)
3366
+ }
3348
3367
  let scroll = find(renderedRoot)
3349
3368
  // Under a large title the screen runs edge to edge, as UIKit's own
3350
3369
  // screens do: the scroll view starts under the bar and insets its
3351
3370
  // content by it, so the title collapses and pull to refresh draws
3352
- // in the bar's space. Other screens stay inside the safe area.
3353
- let edgeToEdge = scroll != nil && prefersLargeTitle == true
3371
+ // in the bar's space. So does a screen that starts with its scroll
3372
+ // view: its content scrolls up under the bar behind iOS's scroll edge
3373
+ // effect, rather than over the bar's back button. Other screens stay
3374
+ // inside the safe area.
3375
+ let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
3354
3376
  if rootTopToView?.isActive != edgeToEdge {
3355
3377
  rootTopToSafeArea?.isActive = !edgeToEdge
3356
3378
  rootTopToView?.isActive = edgeToEdge
@@ -4924,6 +4946,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4924
4946
  )
4925
4947
  // A label paints its background inside its own bounds, so a rounded
4926
4948
  // chip of text clips to its corners.
4949
+ // A scroll view does not clip either: its content runs on under the
4950
+ // floating tab bar and the home indicator, as iOS 26's own screens'
4951
+ // does. At the top, a screen that starts with one runs edge to edge
4952
+ // (trackContentScrollView), so nothing scrolls over the bar.
4927
4953
  view.clipsToBounds = style["overflow"] as? String == "hidden"
4928
4954
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
4929
4955
  view.layer.masksToBounds = view.clipsToBounds
@@ -13,12 +13,32 @@ import UserNotifications
13
13
  /// `craft.speech` on a native screen: AVSpeechSynthesizer, set up the way the
14
14
  /// page's own speech is (CraftApp.swift). The music ducks rather than stops,
15
15
  /// the silent switch does not mute a cue, and the session is handed back once
16
- /// nothing is left to say.
16
+ /// nothing has been said for a moment.
17
+ ///
18
+ /// Three things a workout's countdown ("3", "2", "1", a second apart) needs:
19
+ /// - An interrupting cue is never lost. AVSpeechSynthesizer can drop an
20
+ /// utterance handed to it straight after `stopSpeaking(at: .immediate)`,
21
+ /// so a cue that cuts another off speaks on a fresh synthesizer while the
22
+ /// stopped one winds down.
23
+ /// - The session stays claimed for `releaseAfterSilence` after the last word,
24
+ /// so the music ducks once for a countdown instead of swelling back up
25
+ /// between every number, and the next number does not wait on the audio
26
+ /// hardware again.
27
+ /// - Activating and deactivating the session blocks until the hardware
28
+ /// answers, so it happens on a queue of its own, never the main thread the
29
+ /// screens' JavaScript and their timers run on.
17
30
  final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
18
31
  static let shared = CraftNativeSpeech()
32
+ /// How long the session is kept after the last word.
33
+ static let releaseAfterSilence: TimeInterval = 1.5
19
34
 
20
- private let synthesizer = AVSpeechSynthesizer()
35
+ private var synthesizer = AVSpeechSynthesizer()
36
+ /// Synthesizers stopped by an interruption, kept until they report it.
37
+ private var retired: [AVSpeechSynthesizer] = []
21
38
  private var pending: [(utterance: AVSpeechUtterance, done: (Bool) -> Void)] = []
39
+ private var pendingRelease: DispatchWorkItem?
40
+ /// The audio session's work, in order. `sessionToRestore` is only touched here.
41
+ private let sessionQueue = DispatchQueue(label: "dev.craft.native.speech-session")
22
42
  private var sessionToRestore: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?
23
43
 
24
44
  private override init() {
@@ -27,9 +47,12 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
27
47
  }
28
48
 
29
49
  /// Says `text`; `done` hears true once it was spoken, false when it was
30
- /// stopped or interrupted. Main thread.
50
+ /// stopped or interrupted. With `interrupt` false it waits its turn behind
51
+ /// what is being said. Main thread.
31
52
  func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
32
- if interrupt { cancelPending() }
53
+ pendingRelease?.cancel()
54
+ pendingRelease = nil
55
+ if interrupt, cancelPending() { retireSynthesizer() }
33
56
  let utterance = AVSpeechUtterance(string: text)
34
57
  utterance.rate = Self.utteranceRate(rate ?? 1)
35
58
  // An unknown language finds no voice, and a nil voice is the device's own.
@@ -37,8 +60,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
37
60
  utterance.voice = voice
38
61
  }
39
62
  pending.append((utterance, done))
40
- claimSession()
41
- synthesizer.speak(utterance)
63
+ sessionQueue.async {
64
+ self.claimSession()
65
+ DispatchQueue.main.async {
66
+ // Stopped while the session was being set up: say nothing.
67
+ guard self.pending.contains(where: { $0.utterance === utterance }) else { return }
68
+ self.synthesizer.speak(utterance)
69
+ }
70
+ }
42
71
  }
43
72
 
44
73
  func stop() {
@@ -58,6 +87,14 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
58
87
  return sounding
59
88
  }
60
89
 
90
+ /// The stopped synthesizer winds down on its own; the next cue gets a new one.
91
+ private func retireSynthesizer() {
92
+ retired.append(synthesizer)
93
+ let fresh = AVSpeechSynthesizer()
94
+ fresh.delegate = self
95
+ synthesizer = fresh
96
+ }
97
+
61
98
  /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
62
99
  /// same mapping as the page's speech, so a cue sounds alike in both.
63
100
  static func utteranceRate(_ rate: Double) -> Float {
@@ -69,6 +106,7 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
69
106
  return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
70
107
  }
71
108
 
109
+ /// Session queue.
72
110
  private func claimSession() {
73
111
  guard sessionToRestore == nil else { return }
74
112
  let session = AVAudioSession.sharedInstance()
@@ -83,37 +121,52 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
83
121
  }
84
122
  }
85
123
 
86
- private func releaseSessionIfSilent(retry: Bool = true) {
87
- guard pending.isEmpty, let previous = sessionToRestore else { return }
88
- let session = AVAudioSession.sharedInstance()
89
- do {
90
- try session.setActive(false, options: .notifyOthersOnDeactivation)
91
- } catch {
92
- // Busy: the last of the audio has not drained, or the app plays
93
- // something of its own. Once more shortly covers the first.
94
- if retry {
95
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self.releaseSessionIfSilent(retry: false) }
96
- return
124
+ /// Hand the session back once nothing has been said for a moment. Main thread.
125
+ private func releaseSessionIfSilent(retry: Bool = true, after delay: TimeInterval = CraftNativeSpeech.releaseAfterSilence) {
126
+ guard pending.isEmpty else { return }
127
+ pendingRelease?.cancel()
128
+ let work = DispatchWorkItem { [weak self] in
129
+ guard let self else { return }
130
+ self.pendingRelease = nil
131
+ guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
132
+ self.sessionQueue.async {
133
+ guard let previous = self.sessionToRestore else { return }
134
+ let session = AVAudioSession.sharedInstance()
135
+ do {
136
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
137
+ } catch {
138
+ // Busy: the last of the audio has not drained, or the app
139
+ // plays something of its own. Once more shortly covers the first.
140
+ if retry {
141
+ DispatchQueue.main.async { self.releaseSessionIfSilent(retry: false, after: 0.5) }
142
+ return
143
+ }
144
+ }
145
+ self.sessionToRestore = nil
146
+ try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
147
+ DispatchQueue.main.async { CraftNativeSounds.reclaimSessionIfPlaying() }
97
148
  }
98
149
  }
99
- sessionToRestore = nil
100
- try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
101
- CraftNativeSounds.reclaimSessionIfPlaying()
150
+ pendingRelease = work
151
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work)
102
152
  }
103
153
 
104
- private func finished(_ utterance: AVSpeechUtterance, spoken: Bool) {
154
+ private func finished(_ synthesizer: AVSpeechSynthesizer, _ utterance: AVSpeechUtterance, spoken: Bool) {
105
155
  if let index = pending.firstIndex(where: { $0.utterance === utterance }) {
106
156
  pending.remove(at: index).done(spoken)
107
157
  }
158
+ if synthesizer !== self.synthesizer, !synthesizer.isSpeaking {
159
+ retired.removeAll { $0 === synthesizer }
160
+ }
108
161
  releaseSessionIfSilent()
109
162
  }
110
163
 
111
164
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
112
- DispatchQueue.main.async { self.finished(utterance, spoken: true) }
165
+ DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: true) }
113
166
  }
114
167
 
115
168
  func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
116
- DispatchQueue.main.async { self.finished(utterance, spoken: false) }
169
+ DispatchQueue.main.async { self.finished(synthesizer, utterance, spoken: false) }
117
170
  }
118
171
  }
119
172
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.166",
3
+ "version": "0.0.168",
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>",