@craft-native/ios 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.
- package/README.md +24 -2
- package/dist/cli.js +2 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +221 -0
- package/templates/CraftNativeFlatList.swift +323 -0
- package/templates/CraftNativeMutation.swift +365 -0
- package/templates/CraftNativeScreen.swift +460 -31
package/README.md
CHANGED
|
@@ -106,7 +106,7 @@ In Xcode:
|
|
|
106
106
|
|
|
107
107
|
### Experimental WebView-free screen
|
|
108
108
|
|
|
109
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `Text`, `Button`, and `
|
|
109
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `TextInput`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. The default remains `web`; existing iOS apps are unchanged. Most Craft device APIs still need native adapters. Enable `enableHaptics` and `enableClipboard` in `craft.config.json` before using those two capabilities; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code. The portable component, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
|
|
110
110
|
|
|
111
111
|
```bash
|
|
112
112
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
@@ -117,7 +117,15 @@ craft ios run --output ./ios-native --simulator
|
|
|
117
117
|
|
|
118
118
|
`stx-native` is currently a private package in the sibling [stx repository](https://github.com/stacksjs/stx). Run its source CLI with Bun until it is published or linked locally. A runnable screen is in [`fixtures/native-screen/Screen.stx`](./fixtures/native-screen/Screen.stx). In native mode, the app never instantiates `WKWebView`; a missing `dist/native-screen.js` fails the build rather than silently opening the web placeholder.
|
|
119
119
|
|
|
120
|
-
Native screen updates
|
|
120
|
+
Native screen updates use the versioned mutation protocol when the bundle
|
|
121
|
+
supports it and fall back to whole-document rendering for older bundles.
|
|
122
|
+
Updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for
|
|
123
|
+
existing screens); unkeyed children reuse their position. `TextInput` objects
|
|
124
|
+
therefore keep focus and selection while unrelated labels and buttons change.
|
|
125
|
+
The protocol wire format, atomic validation, revisions, and error codes are in
|
|
126
|
+
the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
|
|
127
|
+
`bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
|
|
128
|
+
simulator UI tests.
|
|
121
129
|
|
|
122
130
|
For multiple native screens, configure named `.stx` routes and an initial screen in `stx-native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx-native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
|
|
123
131
|
|
|
@@ -431,6 +439,20 @@ await window.craft.restorePurchases();
|
|
|
431
439
|
window.craft.setKeepAwake(true); // Prevent screen dimming
|
|
432
440
|
window.craft.setKeepAwake(false); // Allow screen dimming
|
|
433
441
|
|
|
442
|
+
// Speech: short spoken cues that duck the person's music instead of stopping
|
|
443
|
+
// it, and play with the silent switch on. Always available (no config flag).
|
|
444
|
+
// Resolves when the utterance ends: true if spoken, false if stopped or
|
|
445
|
+
// interrupted. Rejects with INVALID_ARGUMENT for empty text.
|
|
446
|
+
const spoken = await window.craft.speech.speak('Rest, 15 seconds. Up next: Dead Bug', {
|
|
447
|
+
rate: 1, // 0.5 to 2, 1 = normal
|
|
448
|
+
language: 'en-US', // BCP-47; defaults to the device's language
|
|
449
|
+
interrupt: true // default: stop what is being said first; false queues
|
|
450
|
+
});
|
|
451
|
+
await window.craft.speech.stop(); // pending speak() calls resolve false
|
|
452
|
+
// Or `import { speech } from 'craft-native/mobile'`: the same calls, falling
|
|
453
|
+
// back to the Web Speech API in a browser and resolving false where neither
|
|
454
|
+
// exists. speech.isAvailable() says which.
|
|
455
|
+
|
|
434
456
|
// Orientation Lock
|
|
435
457
|
window.craft.lockOrientation('portrait'); // Lock to portrait
|
|
436
458
|
window.craft.lockOrientation('landscape'); // Lock to landscape
|
package/dist/cli.js
CHANGED
|
@@ -411,7 +411,9 @@ async function init(options) {
|
|
|
411
411
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
412
412
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
413
413
|
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
414
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
|
|
414
415
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
416
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
415
417
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
416
418
|
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>
|
|
417
419
|
<true/>
|
package/dist/index.js
CHANGED
|
@@ -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/>
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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)
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
private final class CraftNativeFlatListLayout: UICollectionViewFlowLayout {
|
|
4
|
+
override func shouldInvalidateLayout(forBoundsChange newBounds: CGRect) -> Bool {
|
|
5
|
+
guard let current = collectionView?.bounds else { return true }
|
|
6
|
+
return scrollDirection == .vertical
|
|
7
|
+
? abs(current.width - newBounds.width) > .ulpOfOne
|
|
8
|
+
: abs(current.height - newBounds.height) > .ulpOfOne
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/// A keyed, recycling native list used by the stx-native FlatList primitive.
|
|
13
|
+
/// The screen controller owns rendered row state; this view owns collection
|
|
14
|
+
/// diffs, viewport state, and UICollectionView cell reuse.
|
|
15
|
+
final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowLayout {
|
|
16
|
+
typealias RenderItem = (_ node: [String: Any], _ identity: String, _ previous: UIView?) -> UIView
|
|
17
|
+
|
|
18
|
+
private final class Cell: UICollectionViewCell {
|
|
19
|
+
static let reuseIdentifier = "CraftNativeFlatListCell"
|
|
20
|
+
|
|
21
|
+
private(set) var representedIdentity: String?
|
|
22
|
+
private(set) var hostedView: UIView?
|
|
23
|
+
var onRecycle: ((String) -> Void)?
|
|
24
|
+
|
|
25
|
+
override func prepareForReuse() {
|
|
26
|
+
super.prepareForReuse()
|
|
27
|
+
releaseHostedView()
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func host(_ view: UIView, identity: String) {
|
|
31
|
+
if representedIdentity != identity { releaseHostedView() }
|
|
32
|
+
representedIdentity = identity
|
|
33
|
+
guard hostedView !== view else { return }
|
|
34
|
+
hostedView?.removeFromSuperview()
|
|
35
|
+
hostedView = view
|
|
36
|
+
view.translatesAutoresizingMaskIntoConstraints = false
|
|
37
|
+
contentView.addSubview(view)
|
|
38
|
+
NSLayoutConstraint.activate([
|
|
39
|
+
view.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
40
|
+
view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
|
|
41
|
+
view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
42
|
+
view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
|
|
43
|
+
])
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func releaseHostedView() {
|
|
47
|
+
if let identity = representedIdentity { onRecycle?(identity) }
|
|
48
|
+
hostedView?.removeFromSuperview()
|
|
49
|
+
hostedView = nil
|
|
50
|
+
representedIdentity = nil
|
|
51
|
+
onRecycle = nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override func preferredLayoutAttributesFitting(
|
|
55
|
+
_ layoutAttributes: UICollectionViewLayoutAttributes
|
|
56
|
+
) -> UICollectionViewLayoutAttributes {
|
|
57
|
+
guard let hostedView = hostedView else { return layoutAttributes }
|
|
58
|
+
let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
|
|
59
|
+
let target = CGSize(width: layoutAttributes.size.width, height: UIView.layoutFittingCompressedSize.height)
|
|
60
|
+
let measured = hostedView.systemLayoutSizeFitting(
|
|
61
|
+
target,
|
|
62
|
+
withHorizontalFittingPriority: .required,
|
|
63
|
+
verticalFittingPriority: .fittingSizeLevel
|
|
64
|
+
)
|
|
65
|
+
attributes.size.height = max(1, measured.height)
|
|
66
|
+
return attributes
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private struct Item {
|
|
71
|
+
let identity: String
|
|
72
|
+
let node: [String: Any]
|
|
73
|
+
let signature: Data
|
|
74
|
+
let isChrome: Bool
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private struct ViewportAnchor {
|
|
78
|
+
let identity: String
|
|
79
|
+
let distanceFromOrigin: CGFloat
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private let flowLayout: UICollectionViewFlowLayout
|
|
83
|
+
private var diffableDataSource: UICollectionViewDiffableDataSource<Int, String>!
|
|
84
|
+
private var items: [Item] = []
|
|
85
|
+
private var itemsByIdentity: [String: Item] = [:]
|
|
86
|
+
private var renderItem: RenderItem?
|
|
87
|
+
private var recycleItem: ((String) -> Void)?
|
|
88
|
+
private var endReached: (() -> Void)?
|
|
89
|
+
private var endReachedThreshold = 0.1
|
|
90
|
+
private var endReachedSignature: String?
|
|
91
|
+
private var columns = 1
|
|
92
|
+
private var isApplyingSnapshot = false
|
|
93
|
+
private var pendingApply: (() -> Void)?
|
|
94
|
+
|
|
95
|
+
init() {
|
|
96
|
+
let layout = CraftNativeFlatListLayout()
|
|
97
|
+
layout.estimatedItemSize = CGSize(width: 320, height: 44)
|
|
98
|
+
layout.minimumLineSpacing = 0
|
|
99
|
+
layout.minimumInteritemSpacing = 0
|
|
100
|
+
flowLayout = layout
|
|
101
|
+
super.init(frame: .zero, collectionViewLayout: layout)
|
|
102
|
+
backgroundColor = .clear
|
|
103
|
+
alwaysBounceVertical = true
|
|
104
|
+
delegate = self
|
|
105
|
+
register(Cell.self, forCellWithReuseIdentifier: Cell.reuseIdentifier)
|
|
106
|
+
diffableDataSource = UICollectionViewDiffableDataSource<Int, String>(collectionView: self) {
|
|
107
|
+
[weak self] collectionView, indexPath, identity in
|
|
108
|
+
guard let self = self,
|
|
109
|
+
let item = self.itemsByIdentity[identity],
|
|
110
|
+
let cell = collectionView.dequeueReusableCell(
|
|
111
|
+
withReuseIdentifier: Cell.reuseIdentifier,
|
|
112
|
+
for: indexPath
|
|
113
|
+
) as? Cell,
|
|
114
|
+
let renderItem = self.renderItem else { return UICollectionViewCell() }
|
|
115
|
+
let view = renderItem(item.node, identity, cell.hostedView)
|
|
116
|
+
cell.onRecycle = self.recycleItem
|
|
117
|
+
cell.host(view, identity: identity)
|
|
118
|
+
return cell
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
required init?(coder: NSCoder) { nil }
|
|
123
|
+
|
|
124
|
+
var visibleItemIdentities: [String] {
|
|
125
|
+
indexPathsForVisibleItems
|
|
126
|
+
.sorted { $0.item < $1.item }
|
|
127
|
+
.compactMap { diffableDataSource.itemIdentifier(for: $0) }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func apply(
|
|
131
|
+
nodes: [[String: Any]],
|
|
132
|
+
horizontal: Bool,
|
|
133
|
+
columns: Int,
|
|
134
|
+
inverted: Bool,
|
|
135
|
+
endReachedThreshold: Double,
|
|
136
|
+
renderItem: @escaping RenderItem,
|
|
137
|
+
recycleItem: @escaping (String) -> Void,
|
|
138
|
+
endReached: (() -> Void)?
|
|
139
|
+
) {
|
|
140
|
+
if isApplyingSnapshot {
|
|
141
|
+
pendingApply = { [weak self] in
|
|
142
|
+
self?.apply(
|
|
143
|
+
nodes: nodes,
|
|
144
|
+
horizontal: horizontal,
|
|
145
|
+
columns: columns,
|
|
146
|
+
inverted: inverted,
|
|
147
|
+
endReachedThreshold: endReachedThreshold,
|
|
148
|
+
renderItem: renderItem,
|
|
149
|
+
recycleItem: recycleItem,
|
|
150
|
+
endReached: endReached
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
isApplyingSnapshot = true
|
|
156
|
+
|
|
157
|
+
self.renderItem = renderItem
|
|
158
|
+
self.recycleItem = recycleItem
|
|
159
|
+
self.endReached = endReached
|
|
160
|
+
self.endReachedThreshold = max(0, endReachedThreshold)
|
|
161
|
+
self.columns = max(1, columns)
|
|
162
|
+
|
|
163
|
+
layoutIfNeeded()
|
|
164
|
+
let oldItems = itemsByIdentity
|
|
165
|
+
let viewportAnchor = currentViewportAnchor()
|
|
166
|
+
var seen: [String: Int] = [:]
|
|
167
|
+
var next = nodes.enumerated().map { index, node -> Item in
|
|
168
|
+
let base = Self.identity(for: node) ?? "index:\(index)"
|
|
169
|
+
let occurrence = seen[base, default: 0]
|
|
170
|
+
seen[base] = occurrence + 1
|
|
171
|
+
let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
172
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
173
|
+
let role = props["listRole"] as? String
|
|
174
|
+
let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
|
|
175
|
+
return Item(identity: identity, node: node, signature: signature, isChrome: role != nil && role != "item")
|
|
176
|
+
}
|
|
177
|
+
if inverted { next.reverse() }
|
|
178
|
+
items = next
|
|
179
|
+
itemsByIdentity = Dictionary(uniqueKeysWithValues: next.map { ($0.identity, $0) })
|
|
180
|
+
|
|
181
|
+
flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
|
|
182
|
+
alwaysBounceHorizontal = horizontal
|
|
183
|
+
alwaysBounceVertical = !horizontal
|
|
184
|
+
showsHorizontalScrollIndicator = horizontal
|
|
185
|
+
showsVerticalScrollIndicator = !horizontal
|
|
186
|
+
flowLayout.invalidateLayout()
|
|
187
|
+
|
|
188
|
+
let nextIds = Set(itemsByIdentity.keys)
|
|
189
|
+
for identity in oldItems.keys where !nextIds.contains(identity) { recycleItem(identity) }
|
|
190
|
+
let changed = next.compactMap { item -> String? in
|
|
191
|
+
guard let old = oldItems[item.identity], old.signature != item.signature else { return nil }
|
|
192
|
+
return item.identity
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
var snapshot = NSDiffableDataSourceSnapshot<Int, String>()
|
|
196
|
+
snapshot.appendSections([0])
|
|
197
|
+
snapshot.appendItems(next.map(\.identity), toSection: 0)
|
|
198
|
+
let retainedOffset = contentOffset
|
|
199
|
+
diffableDataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
|
|
200
|
+
guard let self = self else { return }
|
|
201
|
+
let live = Set(self.diffableDataSource.snapshot().itemIdentifiers)
|
|
202
|
+
let changedLive = changed.filter { live.contains($0) }
|
|
203
|
+
let restoreViewport = { [weak self] in
|
|
204
|
+
guard let self = self else { return }
|
|
205
|
+
self.layoutIfNeeded()
|
|
206
|
+
if let viewportAnchor = viewportAnchor {
|
|
207
|
+
self.restore(viewportAnchor)
|
|
208
|
+
} else {
|
|
209
|
+
self.setContentOffset(self.clamped(retainedOffset), animated: false)
|
|
210
|
+
}
|
|
211
|
+
self.finishApplyingSnapshot()
|
|
212
|
+
}
|
|
213
|
+
if !changedLive.isEmpty {
|
|
214
|
+
var update = self.diffableDataSource.snapshot()
|
|
215
|
+
update.reconfigureItems(changedLive)
|
|
216
|
+
self.diffableDataSource.apply(update, animatingDifferences: false, completion: restoreViewport)
|
|
217
|
+
} else {
|
|
218
|
+
restoreViewport()
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let dataItems = next.filter { !$0.isChrome }
|
|
223
|
+
let contentSignature = "\(dataItems.count):\(dataItems.last?.identity ?? "empty")"
|
|
224
|
+
if contentSignature != endReachedSignature { endReachedSignature = nil }
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
func discardAll() {
|
|
228
|
+
visibleCells.compactMap { $0 as? Cell }.forEach { $0.releaseHostedView() }
|
|
229
|
+
itemsByIdentity.keys.forEach { recycleItem?($0) }
|
|
230
|
+
items = []
|
|
231
|
+
itemsByIdentity = [:]
|
|
232
|
+
renderItem = nil
|
|
233
|
+
recycleItem = nil
|
|
234
|
+
endReached = nil
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
func scrollViewDidScroll(_ scrollView: UIScrollView) { evaluateEndReached() }
|
|
238
|
+
|
|
239
|
+
func collectionView(
|
|
240
|
+
_ collectionView: UICollectionView,
|
|
241
|
+
layout collectionViewLayout: UICollectionViewLayout,
|
|
242
|
+
sizeForItemAt indexPath: IndexPath
|
|
243
|
+
) -> CGSize {
|
|
244
|
+
guard items.indices.contains(indexPath.item) else { return CGSize(width: bounds.width, height: 44) }
|
|
245
|
+
let item = items[indexPath.item]
|
|
246
|
+
if flowLayout.scrollDirection == .horizontal {
|
|
247
|
+
return CGSize(width: max(1, bounds.width * 0.8), height: max(1, bounds.height))
|
|
248
|
+
}
|
|
249
|
+
let count = item.isChrome ? 1 : columns
|
|
250
|
+
let spacing = flowLayout.minimumInteritemSpacing * CGFloat(count - 1)
|
|
251
|
+
return CGSize(width: max(1, (bounds.width - spacing) / CGFloat(count)), height: 44)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private func evaluateEndReached() {
|
|
255
|
+
guard !isApplyingSnapshot, pendingApply == nil,
|
|
256
|
+
let endReached = endReached,
|
|
257
|
+
let lastVisible = indexPathsForVisibleItems.map(\.item).max() else { return }
|
|
258
|
+
let dataIndices = items.indices.filter { !items[$0].isChrome }
|
|
259
|
+
guard !dataIndices.isEmpty,
|
|
260
|
+
let lastDataOrdinal = dataIndices.lastIndex(where: { $0 <= lastVisible }) else { return }
|
|
261
|
+
let remaining = dataIndices.count - lastDataOrdinal - 1
|
|
262
|
+
let thresholdItems = max(1, Int(ceil(Double(dataIndices.count) * endReachedThreshold)))
|
|
263
|
+
guard remaining <= thresholdItems else { return }
|
|
264
|
+
let lastIdentity = items[dataIndices[dataIndices.count - 1]].identity
|
|
265
|
+
let signature = "\(dataIndices.count):\(lastIdentity)"
|
|
266
|
+
guard endReachedSignature != signature else { return }
|
|
267
|
+
endReachedSignature = signature
|
|
268
|
+
endReached()
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private func finishApplyingSnapshot() {
|
|
272
|
+
DispatchQueue.main.async { [weak self] in
|
|
273
|
+
guard let self = self else { return }
|
|
274
|
+
self.isApplyingSnapshot = false
|
|
275
|
+
if let pendingApply = self.pendingApply {
|
|
276
|
+
self.pendingApply = nil
|
|
277
|
+
pendingApply()
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
self.evaluateEndReached()
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private func clamped(_ offset: CGPoint) -> CGPoint {
|
|
285
|
+
CGPoint(
|
|
286
|
+
x: min(max(-adjustedContentInset.left, offset.x), max(-adjustedContentInset.left, contentSize.width - bounds.width + adjustedContentInset.right)),
|
|
287
|
+
y: min(max(-adjustedContentInset.top, offset.y), max(-adjustedContentInset.top, contentSize.height - bounds.height + adjustedContentInset.bottom))
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
private func currentViewportAnchor() -> ViewportAnchor? {
|
|
292
|
+
let horizontal = flowLayout.scrollDirection == .horizontal
|
|
293
|
+
let ordered = indexPathsForVisibleItems.sorted { lhs, rhs in
|
|
294
|
+
let left = layoutAttributesForItem(at: lhs)?.frame
|
|
295
|
+
let right = layoutAttributesForItem(at: rhs)?.frame
|
|
296
|
+
return horizontal ? (left?.minX ?? 0) < (right?.minX ?? 0) : (left?.minY ?? 0) < (right?.minY ?? 0)
|
|
297
|
+
}
|
|
298
|
+
guard let indexPath = ordered.first,
|
|
299
|
+
let identity = diffableDataSource.itemIdentifier(for: indexPath),
|
|
300
|
+
let frame = layoutAttributesForItem(at: indexPath)?.frame else { return nil }
|
|
301
|
+
let distance = horizontal ? frame.minX - contentOffset.x : frame.minY - contentOffset.y
|
|
302
|
+
return ViewportAnchor(identity: identity, distanceFromOrigin: distance)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private func restore(_ anchor: ViewportAnchor) {
|
|
306
|
+
guard let indexPath = diffableDataSource.indexPath(for: anchor.identity),
|
|
307
|
+
let frame = layoutAttributesForItem(at: indexPath)?.frame else { return }
|
|
308
|
+
var offset = contentOffset
|
|
309
|
+
if flowLayout.scrollDirection == .horizontal {
|
|
310
|
+
offset.x = frame.minX - anchor.distanceFromOrigin
|
|
311
|
+
} else {
|
|
312
|
+
offset.y = frame.minY - anchor.distanceFromOrigin
|
|
313
|
+
}
|
|
314
|
+
setContentOffset(clamped(offset), animated: false)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private static func identity(for node: [String: Any]) -> String? {
|
|
318
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
319
|
+
return [node["id"], node["key"], props["key"], props["testID"]]
|
|
320
|
+
.compactMap { $0 as? String }
|
|
321
|
+
.first { !$0.isEmpty }
|
|
322
|
+
}
|
|
323
|
+
}
|