@craft-native/ios 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.
- package/README.md +15 -1
- package/dist/cli.js +28 -1
- package/dist/index.d.ts +18 -0
- package/dist/index.js +29 -1
- package/package.json +1 -1
- package/templates/CraftApp.swift +260 -1
- package/templates/CraftNativeActions.swift +472 -31
- package/templates/CraftNativeScreen.swift +93 -22
- package/templates/Info.plist.template +1 -0
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`, `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.
|
|
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. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. The portable component, capability, 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
|
|
@@ -439,6 +439,20 @@ await window.craft.restorePurchases();
|
|
|
439
439
|
window.craft.setKeepAwake(true); // Prevent screen dimming
|
|
440
440
|
window.craft.setKeepAwake(false); // Allow screen dimming
|
|
441
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
|
+
|
|
442
456
|
// Orientation Lock
|
|
443
457
|
window.craft.lockOrientation('portrait'); // Lock to portrait
|
|
444
458
|
window.craft.lockOrientation('landscape'); // Lock to landscape
|
package/dist/cli.js
CHANGED
|
@@ -116,6 +116,27 @@ function renderOrientations(config) {
|
|
|
116
116
|
return values.map((value) => ` <string>${names[value]}</string>`).join(`
|
|
117
117
|
`);
|
|
118
118
|
}
|
|
119
|
+
function renderAppBoundDomains(config) {
|
|
120
|
+
const hostOf = (value) => {
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(value);
|
|
123
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.hostname.toLowerCase() : null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
const domains = config.appBoundDomains !== undefined ? config.appBoundDomains.map((value) => value.trim().toLowerCase()).filter(Boolean) : (config.trustedOrigins ?? []).map(hostOf).filter((host) => !!host);
|
|
129
|
+
if (!domains.length)
|
|
130
|
+
return "";
|
|
131
|
+
const devHost = config.devServerURL ? hostOf(config.devServerURL) : null;
|
|
132
|
+
if (devHost)
|
|
133
|
+
domains.push(devHost);
|
|
134
|
+
const unique = [...new Set(domains)].slice(0, 10);
|
|
135
|
+
return ` <key>WKAppBoundDomains</key>
|
|
136
|
+
<array>
|
|
137
|
+
${plistArray(unique)}
|
|
138
|
+
</array>`;
|
|
139
|
+
}
|
|
119
140
|
function renderUrlTypes(config) {
|
|
120
141
|
const schemes = [...new Set(config.urlSchemes?.map((value) => value.trim()).filter(Boolean) ?? [])];
|
|
121
142
|
if (!schemes.length)
|
|
@@ -173,6 +194,12 @@ ${plistArray(config.appGroups)}
|
|
|
173
194
|
entries.push(` <key>aps-environment</key>
|
|
174
195
|
<string>$(CRAFT_APNS_ENVIRONMENT)</string>`);
|
|
175
196
|
}
|
|
197
|
+
if (config.enableSecureStorage) {
|
|
198
|
+
entries.push(` <key>keychain-access-groups</key>
|
|
199
|
+
<array>
|
|
200
|
+
<string>$(AppIdentifierPrefix)$(CFBundleIdentifier)</string>
|
|
201
|
+
</array>`);
|
|
202
|
+
}
|
|
176
203
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
177
204
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
178
205
|
<plist version="1.0">
|
|
@@ -415,7 +442,7 @@ async function init(options) {
|
|
|
415
442
|
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
416
443
|
cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
|
|
417
444
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
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>
|
|
445
|
+
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>
|
|
419
446
|
<true/>
|
|
420
447
|
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
|
421
448
|
<true/>` : "");
|
package/dist/index.d.ts
CHANGED
|
@@ -66,6 +66,22 @@ export interface CraftConfig {
|
|
|
66
66
|
teamId?: string;
|
|
67
67
|
urlSchemes?: string[];
|
|
68
68
|
trustedOrigins?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* The domains the app's web view treats as its own (WKAppBoundDomains, at
|
|
71
|
+
* most 10). Service workers only run in an iOS web view on these, so a site
|
|
72
|
+
* that works offline in Safari works offline in the app. Unset, they are the
|
|
73
|
+
* hosts of `trustedOrigins` and the dev server: the origins the bridge is
|
|
74
|
+
* already injected into, and once any are declared iOS keeps script
|
|
75
|
+
* injection to them. `[]` declares none.
|
|
76
|
+
*/
|
|
77
|
+
appBoundDomains?: string[];
|
|
78
|
+
/**
|
|
79
|
+
* Also refuse to load anything but the app-bound domains in the web view
|
|
80
|
+
* (WKWebViewConfiguration.limitsNavigationsToAppBoundDomains). Off by
|
|
81
|
+
* default: links elsewhere already open in Safari, and embedded players and
|
|
82
|
+
* maps in iframes keep loading.
|
|
83
|
+
*/
|
|
84
|
+
limitNavigationsToAppBoundDomains?: boolean;
|
|
69
85
|
associatedDomains?: string[];
|
|
70
86
|
appGroups?: string[];
|
|
71
87
|
appIconPath?: string;
|
|
@@ -148,6 +164,8 @@ export declare function renderAppearance(config: Pick<CraftConfig, 'appearance'
|
|
|
148
164
|
};
|
|
149
165
|
export declare function renderUsageDescriptions(config: CraftConfig): string;
|
|
150
166
|
export declare function renderOrientations(config: CraftConfig): string;
|
|
167
|
+
/** WKAppBoundDomains: the configured domains plus the dev server's host, at most 10. */
|
|
168
|
+
export declare function renderAppBoundDomains(config: CraftConfig): string;
|
|
151
169
|
export declare function renderUrlTypes(config: CraftConfig): string;
|
|
152
170
|
export declare function renderBackgroundModes(config: CraftConfig): string;
|
|
153
171
|
export declare function renderEntitlements(config: CraftConfig): string;
|
package/dist/index.js
CHANGED
|
@@ -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,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
|
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)
|
|
@@ -1,63 +1,498 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import LocalAuthentication
|
|
3
|
+
import SQLite3
|
|
4
|
+
import Security
|
|
1
5
|
import UIKit
|
|
6
|
+
import UserNotifications
|
|
7
|
+
|
|
8
|
+
let craftNativeCapabilityProtocolVersion = 1
|
|
2
9
|
|
|
3
|
-
/// The result of a Craft action, independent of whether the caller is a
|
|
4
|
-
/// WebKit page or the JavaScriptCore native-screen runtime.
|
|
5
10
|
struct CraftNativeActionError: Error {
|
|
6
11
|
let code: String
|
|
7
12
|
let message: String
|
|
8
13
|
}
|
|
9
14
|
|
|
15
|
+
/// Process-wide capability services used by every retained native route.
|
|
16
|
+
/// Database work is serialized so transactions cannot be interleaved by two
|
|
17
|
+
/// JavaScript contexts, while completions return to JavaScriptCore on main.
|
|
10
18
|
enum CraftNativeActions {
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
private static let storagePrefix = "craft.native.storage."
|
|
20
|
+
private static let databaseQueue = DispatchQueue(label: "dev.craft.native.database")
|
|
21
|
+
private static let cancellationLock = NSLock()
|
|
22
|
+
private static var cancelledRequests = Set<String>()
|
|
23
|
+
private static var biometricContexts = [String: LAContext]()
|
|
24
|
+
private static var initialDeepLinkClaimed = false
|
|
25
|
+
private static var database: OpaquePointer?
|
|
26
|
+
private static let sqliteTransient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
|
|
27
|
+
|
|
28
|
+
static func capabilities(config: CraftConfig) -> [String] {
|
|
29
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle"]
|
|
30
|
+
if config.enableBiometric { values.append("biometric") }
|
|
31
|
+
if config.enableSecureStorage { values.append("secureStorage") }
|
|
32
|
+
if config.enableLocalDatabase { values.append("database") }
|
|
33
|
+
if config.enableDeepLinks { values.append("deepLinks") }
|
|
34
|
+
if config.enableLocalNotifications { values.append("notifications") }
|
|
35
|
+
return values
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/// Nil means this small shared router does not own the action.
|
|
13
39
|
static func perform(action: String, body: [String: Any], config: CraftConfig) -> Result<Any, CraftNativeActionError>? {
|
|
14
40
|
switch action {
|
|
15
41
|
case "getDeviceInfo":
|
|
16
42
|
return .success(deviceInfo())
|
|
17
43
|
case "haptic":
|
|
18
|
-
guard config.enableHaptics else {
|
|
19
|
-
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Haptics is disabled"))
|
|
20
|
-
}
|
|
44
|
+
guard config.enableHaptics else { return failure("CAPABILITY_DISABLED", "Haptics is disabled") }
|
|
21
45
|
triggerHaptic(style: body["style"] as? String ?? "medium")
|
|
22
46
|
return .success(true)
|
|
23
47
|
case "clipboardWrite":
|
|
24
|
-
guard config.enableClipboard else {
|
|
25
|
-
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
|
|
26
|
-
}
|
|
48
|
+
guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
|
|
27
49
|
guard let text = body["text"] as? String else {
|
|
28
|
-
return
|
|
50
|
+
return failure("INVALID_ARGUMENT", "clipboardWrite was called without the values it needs")
|
|
29
51
|
}
|
|
30
52
|
UIPasteboard.general.string = text
|
|
31
53
|
return .success(true)
|
|
32
54
|
case "clipboardRead":
|
|
33
|
-
guard config.enableClipboard else {
|
|
34
|
-
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
|
|
35
|
-
}
|
|
55
|
+
guard config.enableClipboard else { return failure("CAPABILITY_DISABLED", "Clipboard is disabled") }
|
|
36
56
|
return .success(UIPasteboard.general.string ?? "")
|
|
37
57
|
default:
|
|
38
58
|
return nil
|
|
39
59
|
}
|
|
40
60
|
}
|
|
41
61
|
|
|
62
|
+
static func perform(
|
|
63
|
+
requestToken: String,
|
|
64
|
+
version: Int,
|
|
65
|
+
module: String,
|
|
66
|
+
method: String,
|
|
67
|
+
args: [Any],
|
|
68
|
+
config: CraftConfig,
|
|
69
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
70
|
+
) {
|
|
71
|
+
cancellationLock.lock()
|
|
72
|
+
cancelledRequests.remove(requestToken)
|
|
73
|
+
cancellationLock.unlock()
|
|
74
|
+
guard version == craftNativeCapabilityProtocolVersion else {
|
|
75
|
+
complete(requestToken, failure("UNSUPPORTED_VERSION", "Unsupported native capability protocol version"), completion)
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
let sync: Result<Any, CraftNativeActionError>?
|
|
80
|
+
switch (module, method) {
|
|
81
|
+
case ("Device", "getInfo"):
|
|
82
|
+
sync = perform(action: "getDeviceInfo", body: [:], config: config)
|
|
83
|
+
case ("Haptics", "impact"):
|
|
84
|
+
sync = perform(action: "haptic", body: ["style": args.first ?? NSNull()], config: config)
|
|
85
|
+
case ("Clipboard", "write"):
|
|
86
|
+
sync = perform(action: "clipboardWrite", body: ["text": args.first ?? NSNull()], config: config)
|
|
87
|
+
case ("Clipboard", "read"):
|
|
88
|
+
sync = perform(action: "clipboardRead", body: [:], config: config)
|
|
89
|
+
case ("Storage", _):
|
|
90
|
+
sync = storage(method: method, args: args)
|
|
91
|
+
case ("SecureStorage", _):
|
|
92
|
+
guard config.enableSecureStorage else {
|
|
93
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
sync = secureStorage(method: method, args: args)
|
|
97
|
+
case ("Biometrics", "isAvailable"):
|
|
98
|
+
sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
|
|
99
|
+
case ("Biometrics", "getBiometricType"):
|
|
100
|
+
sync = config.enableBiometric ? .success(biometricType()) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
|
|
101
|
+
case ("Biometrics", "authenticate"):
|
|
102
|
+
guard config.enableBiometric else {
|
|
103
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Biometric authentication is disabled"), completion)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
authenticate(requestToken: requestToken, args: args, completion: completion)
|
|
107
|
+
return
|
|
108
|
+
case ("Lifecycle", "getState"):
|
|
109
|
+
sync = .success(currentAppState())
|
|
110
|
+
case ("DeepLinks", "getInitialURL"):
|
|
111
|
+
if config.enableDeepLinks {
|
|
112
|
+
sync = .success(claimInitialDeepLink().map(deepLinkData) ?? NSNull())
|
|
113
|
+
} else {
|
|
114
|
+
sync = failure("CAPABILITY_DISABLED", "Deep links are disabled")
|
|
115
|
+
}
|
|
116
|
+
case ("Database", _):
|
|
117
|
+
guard config.enableLocalDatabase else {
|
|
118
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
|
|
122
|
+
return
|
|
123
|
+
case ("Notifications", _):
|
|
124
|
+
guard config.enableLocalNotifications else {
|
|
125
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
notifications(requestToken: requestToken, method: method, args: args, completion: completion)
|
|
129
|
+
return
|
|
130
|
+
default:
|
|
131
|
+
sync = failure("UNKNOWN_ACTION", "Unsupported native API")
|
|
132
|
+
}
|
|
133
|
+
complete(requestToken, sync ?? failure("UNKNOWN_ACTION", "Unsupported native API"), completion)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
static func cancel(requestToken: String) {
|
|
137
|
+
cancellationLock.lock()
|
|
138
|
+
cancelledRequests.insert(requestToken)
|
|
139
|
+
let context = biometricContexts.removeValue(forKey: requestToken)
|
|
140
|
+
cancellationLock.unlock()
|
|
141
|
+
context?.invalidate()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private static func claimInitialDeepLink() -> URL? {
|
|
145
|
+
cancellationLock.lock()
|
|
146
|
+
defer { cancellationLock.unlock() }
|
|
147
|
+
guard !initialDeepLinkClaimed else { return nil }
|
|
148
|
+
initialDeepLinkClaimed = true
|
|
149
|
+
return DeepLinkManager.shared.getInitialURL()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static func complete(
|
|
153
|
+
_ requestToken: String,
|
|
154
|
+
_ result: Result<Any, CraftNativeActionError>,
|
|
155
|
+
_ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
156
|
+
) {
|
|
157
|
+
DispatchQueue.main.async {
|
|
158
|
+
cancellationLock.lock()
|
|
159
|
+
let cancelled = cancelledRequests.remove(requestToken) != nil
|
|
160
|
+
cancellationLock.unlock()
|
|
161
|
+
if !cancelled { completion(result) }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private static func storage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
166
|
+
if method == "clear" {
|
|
167
|
+
for key in UserDefaults.standard.dictionaryRepresentation().keys where key.hasPrefix(storagePrefix) {
|
|
168
|
+
UserDefaults.standard.removeObject(forKey: key)
|
|
169
|
+
}
|
|
170
|
+
return .success(true)
|
|
171
|
+
}
|
|
172
|
+
if method == "keys" {
|
|
173
|
+
let keys = UserDefaults.standard.dictionaryRepresentation().keys
|
|
174
|
+
.filter { $0.hasPrefix(storagePrefix) }
|
|
175
|
+
.map { String($0.dropFirst(storagePrefix.count)) }
|
|
176
|
+
.sorted()
|
|
177
|
+
return .success(keys)
|
|
178
|
+
}
|
|
179
|
+
guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Storage needs a non-empty key") }
|
|
180
|
+
let defaultsKey = storagePrefix + key
|
|
181
|
+
switch method {
|
|
182
|
+
case "get":
|
|
183
|
+
guard let data = UserDefaults.standard.data(forKey: defaultsKey) else { return .success(NSNull()) }
|
|
184
|
+
do {
|
|
185
|
+
return .success(try JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]))
|
|
186
|
+
} catch {
|
|
187
|
+
return failure("STORAGE_CORRUPT", "Stored value is not valid JSON")
|
|
188
|
+
}
|
|
189
|
+
case "set":
|
|
190
|
+
guard args.count == 2 else { return failure("INVALID_ARGUMENT", "Storage.set needs a key and value") }
|
|
191
|
+
do {
|
|
192
|
+
let data = try JSONSerialization.data(withJSONObject: args[1], options: [.fragmentsAllowed])
|
|
193
|
+
UserDefaults.standard.set(data, forKey: defaultsKey)
|
|
194
|
+
return .success(true)
|
|
195
|
+
} catch {
|
|
196
|
+
return failure("INVALID_ARGUMENT", "Storage values must be JSON serializable")
|
|
197
|
+
}
|
|
198
|
+
case "remove":
|
|
199
|
+
UserDefaults.standard.removeObject(forKey: defaultsKey)
|
|
200
|
+
return .success(true)
|
|
201
|
+
default:
|
|
202
|
+
return failure("UNKNOWN_ACTION", "Unsupported Storage method")
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private static func secureStorage(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
207
|
+
let service = "dev.craft.native.secure"
|
|
208
|
+
if method == "clear" {
|
|
209
|
+
let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]
|
|
210
|
+
let status = SecItemDelete(query as CFDictionary)
|
|
211
|
+
guard status == errSecSuccess || status == errSecItemNotFound else {
|
|
212
|
+
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
|
|
213
|
+
}
|
|
214
|
+
return .success(true)
|
|
215
|
+
}
|
|
216
|
+
guard let key = validKey(args.first) else { return failure("INVALID_ARGUMENT", "Secure storage needs a non-empty key") }
|
|
217
|
+
let base: [String: Any] = [
|
|
218
|
+
kSecClass as String: kSecClassGenericPassword,
|
|
219
|
+
kSecAttrService as String: service,
|
|
220
|
+
kSecAttrAccount as String: key,
|
|
221
|
+
]
|
|
222
|
+
switch method {
|
|
223
|
+
case "set":
|
|
224
|
+
guard args.count == 2, let value = args[1] as? String else {
|
|
225
|
+
return failure("INVALID_ARGUMENT", "SecureStorage.set needs a key and string value")
|
|
226
|
+
}
|
|
227
|
+
var query = base
|
|
228
|
+
query[kSecValueData as String] = Data(value.utf8)
|
|
229
|
+
query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
|
|
230
|
+
let removed = SecItemDelete(base as CFDictionary)
|
|
231
|
+
guard removed == errSecSuccess || removed == errSecItemNotFound else {
|
|
232
|
+
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be replaced (\(removed))")
|
|
233
|
+
}
|
|
234
|
+
let status = SecItemAdd(query as CFDictionary, nil)
|
|
235
|
+
return status == errSecSuccess ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be written (\(status))")
|
|
236
|
+
case "get":
|
|
237
|
+
var query = base
|
|
238
|
+
query[kSecReturnData as String] = true
|
|
239
|
+
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
|
240
|
+
var result: AnyObject?
|
|
241
|
+
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
|
242
|
+
if status == errSecItemNotFound { return .success(NSNull()) }
|
|
243
|
+
guard status == errSecSuccess, let data = result as? Data else {
|
|
244
|
+
return failure("SECURE_STORAGE_ERROR", "Secure storage could not be read")
|
|
245
|
+
}
|
|
246
|
+
return .success(String(decoding: data, as: UTF8.self))
|
|
247
|
+
case "remove":
|
|
248
|
+
let status = SecItemDelete(base as CFDictionary)
|
|
249
|
+
return status == errSecSuccess || status == errSecItemNotFound ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
|
|
250
|
+
default:
|
|
251
|
+
return failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private static func biometricContext() -> LAContext {
|
|
256
|
+
let context = LAContext()
|
|
257
|
+
context.localizedCancelTitle = "Cancel"
|
|
258
|
+
return context
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private static func biometricType() -> String {
|
|
262
|
+
let context = biometricContext()
|
|
263
|
+
_ = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)
|
|
264
|
+
switch context.biometryType {
|
|
265
|
+
case .faceID: return "faceId"
|
|
266
|
+
case .touchID: return "touchId"
|
|
267
|
+
default: return "none"
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private static func authenticate(
|
|
272
|
+
requestToken: String,
|
|
273
|
+
args: [Any],
|
|
274
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
275
|
+
) {
|
|
276
|
+
let reason = (args.first as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "Authenticate to continue"
|
|
277
|
+
let context = biometricContext()
|
|
278
|
+
cancellationLock.lock()
|
|
279
|
+
biometricContexts[requestToken] = context
|
|
280
|
+
cancellationLock.unlock()
|
|
281
|
+
context.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: reason) { result, error in
|
|
282
|
+
cancellationLock.lock()
|
|
283
|
+
biometricContexts.removeValue(forKey: requestToken)
|
|
284
|
+
cancellationLock.unlock()
|
|
285
|
+
if result { complete(requestToken, .success(true), completion) }
|
|
286
|
+
else { complete(requestToken, failure("AUTHENTICATION_FAILED", error?.localizedDescription ?? "Biometric authentication failed"), completion) }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private static func database(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
291
|
+
guard openDatabase() else { return failure("DATABASE_ERROR", "Could not open the local database") }
|
|
292
|
+
switch method {
|
|
293
|
+
case "execute":
|
|
294
|
+
guard let sql = args.first as? String, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
295
|
+
return failure("INVALID_ARGUMENT", "Database.execute needs a SQL statement")
|
|
296
|
+
}
|
|
297
|
+
return execute(sql: sql, params: args.count > 1 ? args[1] as? [Any] ?? [] : [])
|
|
298
|
+
case "query":
|
|
299
|
+
guard let sql = args.first as? String, !sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
300
|
+
return failure("INVALID_ARGUMENT", "Database.query needs a SQL statement")
|
|
301
|
+
}
|
|
302
|
+
return query(sql: sql, params: args.count > 1 ? args[1] as? [Any] ?? [] : [])
|
|
303
|
+
case "beginTransaction": return transaction(sql: "BEGIN TRANSACTION")
|
|
304
|
+
case "commit": return transaction(sql: "COMMIT")
|
|
305
|
+
case "rollback": return transaction(sql: "ROLLBACK")
|
|
306
|
+
default: return failure("UNKNOWN_ACTION", "Unsupported Database method")
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private static func transaction(sql: String) -> Result<Any, CraftNativeActionError> {
|
|
311
|
+
switch execute(sql: sql, params: []) {
|
|
312
|
+
case .success: return .success(true)
|
|
313
|
+
case .failure(let error): return .failure(error)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private static func openDatabase() -> Bool {
|
|
318
|
+
if database != nil { return true }
|
|
319
|
+
guard let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { return false }
|
|
320
|
+
do { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) } catch { return false }
|
|
321
|
+
let path = directory.appendingPathComponent("craft-native.sqlite").path
|
|
322
|
+
if sqlite3_open(path, &database) != SQLITE_OK {
|
|
323
|
+
if database != nil { sqlite3_close(database) }
|
|
324
|
+
database = nil
|
|
325
|
+
return false
|
|
326
|
+
}
|
|
327
|
+
sqlite3_busy_timeout(database, 5_000)
|
|
328
|
+
return true
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private static func bind(_ params: [Any], to statement: OpaquePointer?) -> Bool {
|
|
332
|
+
for (offset, value) in params.enumerated() {
|
|
333
|
+
let index = Int32(offset + 1)
|
|
334
|
+
let code: Int32
|
|
335
|
+
switch value {
|
|
336
|
+
case is NSNull: code = sqlite3_bind_null(statement, index)
|
|
337
|
+
case let string as String: code = sqlite3_bind_text(statement, index, string, -1, sqliteTransient)
|
|
338
|
+
case let number as NSNumber:
|
|
339
|
+
if CFGetTypeID(number) == CFBooleanGetTypeID() {
|
|
340
|
+
code = sqlite3_bind_int64(statement, index, number.boolValue ? 1 : 0)
|
|
341
|
+
} else if number.doubleValue.rounded() == number.doubleValue {
|
|
342
|
+
code = sqlite3_bind_int64(statement, index, number.int64Value)
|
|
343
|
+
} else {
|
|
344
|
+
code = sqlite3_bind_double(statement, index, number.doubleValue)
|
|
345
|
+
}
|
|
346
|
+
default: return false
|
|
347
|
+
}
|
|
348
|
+
if code != SQLITE_OK { return false }
|
|
349
|
+
}
|
|
350
|
+
return true
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private static func execute(sql: String, params: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
354
|
+
var statement: OpaquePointer?
|
|
355
|
+
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { return databaseFailure() }
|
|
356
|
+
defer { sqlite3_finalize(statement) }
|
|
357
|
+
guard bind(params, to: statement) else { return failure("INVALID_ARGUMENT", "Database parameters must be strings, numbers, booleans, or null") }
|
|
358
|
+
guard sqlite3_step(statement) == SQLITE_DONE else { return databaseFailure() }
|
|
359
|
+
return .success(["rowsAffected": Int(sqlite3_changes(database)), "lastInsertId": Int(sqlite3_last_insert_rowid(database))])
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
private static func query(sql: String, params: [Any]) -> Result<Any, CraftNativeActionError> {
|
|
363
|
+
var statement: OpaquePointer?
|
|
364
|
+
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { return databaseFailure() }
|
|
365
|
+
defer { sqlite3_finalize(statement) }
|
|
366
|
+
guard bind(params, to: statement) else { return failure("INVALID_ARGUMENT", "Database parameters must be strings, numbers, booleans, or null") }
|
|
367
|
+
var rows: [[String: Any]] = []
|
|
368
|
+
while true {
|
|
369
|
+
let step = sqlite3_step(statement)
|
|
370
|
+
if step == SQLITE_DONE { return .success(rows) }
|
|
371
|
+
guard step == SQLITE_ROW else { return databaseFailure() }
|
|
372
|
+
var row: [String: Any] = [:]
|
|
373
|
+
for column in 0..<sqlite3_column_count(statement) {
|
|
374
|
+
let name = String(cString: sqlite3_column_name(statement, column))
|
|
375
|
+
switch sqlite3_column_type(statement, column) {
|
|
376
|
+
case SQLITE_INTEGER: row[name] = Int(sqlite3_column_int64(statement, column))
|
|
377
|
+
case SQLITE_FLOAT: row[name] = sqlite3_column_double(statement, column)
|
|
378
|
+
case SQLITE_TEXT: row[name] = sqlite3_column_text(statement, column).map { String(cString: $0) } ?? ""
|
|
379
|
+
case SQLITE_BLOB:
|
|
380
|
+
let length = Int(sqlite3_column_bytes(statement, column))
|
|
381
|
+
row[name] = sqlite3_column_blob(statement, column).map { Data(bytes: $0, count: length).base64EncodedString() } ?? ""
|
|
382
|
+
default: row[name] = NSNull()
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
rows.append(row)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private static func databaseFailure() -> Result<Any, CraftNativeActionError> {
|
|
390
|
+
let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "Database unavailable"
|
|
391
|
+
return failure("DATABASE_ERROR", message)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private static func notifications(
|
|
395
|
+
requestToken: String,
|
|
396
|
+
method: String,
|
|
397
|
+
args: [Any],
|
|
398
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
399
|
+
) {
|
|
400
|
+
let center = UNUserNotificationCenter.current()
|
|
401
|
+
switch method {
|
|
402
|
+
case "cancel":
|
|
403
|
+
guard let id = validKey(args.first) else {
|
|
404
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Notifications.cancel needs an id"), completion)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
center.removePendingNotificationRequests(withIdentifiers: [id])
|
|
408
|
+
complete(requestToken, .success(true), completion)
|
|
409
|
+
case "cancelAll":
|
|
410
|
+
center.removeAllPendingNotificationRequests()
|
|
411
|
+
complete(requestToken, .success(true), completion)
|
|
412
|
+
case "pending":
|
|
413
|
+
center.getPendingNotificationRequests { requests in
|
|
414
|
+
complete(requestToken, .success(requests.map { request in
|
|
415
|
+
["id": request.identifier, "title": request.content.title, "body": request.content.body]
|
|
416
|
+
}), completion)
|
|
417
|
+
}
|
|
418
|
+
case "schedule":
|
|
419
|
+
guard let value = args.first as? [String: Any], let title = value["title"] as? String, !title.isEmpty else {
|
|
420
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Notifications.schedule needs a title"), completion)
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
center.requestAuthorization(options: [.alert, .sound, .badge]) { granted, authorizationError in
|
|
424
|
+
guard authorizationError == nil, granted else {
|
|
425
|
+
complete(requestToken, failure("PERMISSION_DENIED", authorizationError?.localizedDescription ?? "Notification permission denied"), completion)
|
|
426
|
+
return
|
|
427
|
+
}
|
|
428
|
+
let content = UNMutableNotificationContent()
|
|
429
|
+
content.title = title
|
|
430
|
+
content.body = value["body"] as? String ?? ""
|
|
431
|
+
content.sound = .default
|
|
432
|
+
if let data = value["data"] as? [String: Any] { content.userInfo = data }
|
|
433
|
+
if let badge = value["badge"] as? NSNumber { content.badge = badge }
|
|
434
|
+
let id = (value["id"] as? String).flatMap { validKey($0) } ?? UUID().uuidString
|
|
435
|
+
let delay = (value["delay"] as? NSNumber)?.doubleValue
|
|
436
|
+
let trigger = delay.map { UNTimeIntervalNotificationTrigger(timeInterval: max($0 / 1_000, 1), repeats: false) }
|
|
437
|
+
center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) { addError in
|
|
438
|
+
if let addError = addError {
|
|
439
|
+
complete(requestToken, failure("NOTIFICATION_ERROR", addError.localizedDescription), completion)
|
|
440
|
+
} else {
|
|
441
|
+
complete(requestToken, .success(id), completion)
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
default:
|
|
446
|
+
complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Notifications method"), completion)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
static func deepLinkData(_ url: URL) -> [String: Any] {
|
|
451
|
+
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
|
452
|
+
return [
|
|
453
|
+
"url": url.absoluteString,
|
|
454
|
+
"scheme": url.scheme ?? "",
|
|
455
|
+
"host": url.host ?? "",
|
|
456
|
+
"path": url.path,
|
|
457
|
+
"query": url.query ?? "",
|
|
458
|
+
"queryParams": Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }),
|
|
459
|
+
]
|
|
460
|
+
}
|
|
461
|
+
|
|
42
462
|
static func triggerHaptic(style: String) {
|
|
43
463
|
switch style {
|
|
44
|
-
case "light":
|
|
45
|
-
|
|
46
|
-
case "
|
|
47
|
-
|
|
48
|
-
case "
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
52
|
-
case "error":
|
|
53
|
-
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
54
|
-
case "selection":
|
|
55
|
-
UISelectionFeedbackGenerator().selectionChanged()
|
|
56
|
-
default:
|
|
57
|
-
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
464
|
+
case "light": UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
465
|
+
case "heavy": UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
466
|
+
case "success": UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
467
|
+
case "warning": UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
468
|
+
case "error": UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
469
|
+
case "selection": UISelectionFeedbackGenerator().selectionChanged()
|
|
470
|
+
default: UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
58
471
|
}
|
|
59
472
|
}
|
|
60
473
|
|
|
474
|
+
static func currentAppState() -> String {
|
|
475
|
+
switch UIApplication.shared.applicationState {
|
|
476
|
+
case .active: return "active"
|
|
477
|
+
case .inactive: return "inactive"
|
|
478
|
+
case .background: return "background"
|
|
479
|
+
@unknown default: return "inactive"
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
private static func validKey(_ value: Any?) -> String? {
|
|
484
|
+
guard let key = value as? String, !key.isEmpty, key.count <= 512, !key.contains("\0") else { return nil }
|
|
485
|
+
return key
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private static func error(_ code: String, _ message: String) -> CraftNativeActionError {
|
|
489
|
+
CraftNativeActionError(code: code, message: message)
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private static func failure(_ code: String, _ message: String) -> Result<Any, CraftNativeActionError> {
|
|
493
|
+
.failure(error(code, message))
|
|
494
|
+
}
|
|
495
|
+
|
|
61
496
|
private static func deviceInfo() -> [String: Any] {
|
|
62
497
|
let device = UIDevice.current
|
|
63
498
|
let screen = UIScreen.main
|
|
@@ -75,19 +510,25 @@ enum CraftNativeActions {
|
|
|
75
510
|
}
|
|
76
511
|
return [
|
|
77
512
|
"platform": "ios",
|
|
513
|
+
"osVersion": device.systemVersion,
|
|
78
514
|
"model": device.model,
|
|
515
|
+
"manufacturer": "Apple",
|
|
516
|
+
"deviceId": device.identifierForVendor?.uuidString ?? "",
|
|
517
|
+
"isTablet": device.userInterfaceIdiom == .pad,
|
|
518
|
+
"screen": ["width": screen.bounds.width, "height": screen.bounds.height, "scale": screen.scale],
|
|
519
|
+
"battery": ["level": device.batteryLevel, "isCharging": device.batteryState == .charging || device.batteryState == .full],
|
|
520
|
+
"locale": Locale.current.identifier,
|
|
521
|
+
"timezone": TimeZone.current.identifier,
|
|
522
|
+
"isSimulator": isSimulator,
|
|
79
523
|
"name": device.name,
|
|
80
524
|
"systemName": device.systemName,
|
|
81
525
|
"systemVersion": device.systemVersion,
|
|
82
526
|
"identifierForVendor": device.identifierForVendor?.uuidString ?? "",
|
|
83
|
-
"isSimulator": isSimulator,
|
|
84
527
|
"screenWidth": screen.bounds.width,
|
|
85
528
|
"screenHeight": screen.bounds.height,
|
|
86
529
|
"screenScale": screen.scale,
|
|
87
530
|
"batteryLevel": device.batteryLevel,
|
|
88
531
|
"batteryState": batteryState,
|
|
89
|
-
"locale": Locale.current.identifier,
|
|
90
|
-
"timezone": TimeZone.current.identifier
|
|
91
532
|
]
|
|
92
533
|
}
|
|
93
534
|
}
|
|
@@ -3,6 +3,8 @@ import JavaScriptCore
|
|
|
3
3
|
import SwiftUI
|
|
4
4
|
import UIKit
|
|
5
5
|
|
|
6
|
+
private let craftNativeCapabilityTimeoutMilliseconds = 30_000
|
|
7
|
+
|
|
6
8
|
/// A WebView-free host for the first stx-native vertical slice. The bundled
|
|
7
9
|
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
8
10
|
/// events and Craft API replies through JavaScriptCore.
|
|
@@ -83,6 +85,11 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
83
85
|
private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
|
|
84
86
|
private var flatListOwners: [String: String] = [:]
|
|
85
87
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
88
|
+
private let capabilityScope = UUID().uuidString
|
|
89
|
+
private var pendingCapabilityRequests = Set<String>()
|
|
90
|
+
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
91
|
+
private var lifecycleObservers: [NSObjectProtocol] = []
|
|
92
|
+
private var deepLinkListener: UUID?
|
|
86
93
|
|
|
87
94
|
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
88
95
|
self.config = config
|
|
@@ -97,6 +104,10 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
97
104
|
|
|
98
105
|
deinit {
|
|
99
106
|
imageTasks.values.forEach { $0.cancel() }
|
|
107
|
+
pendingCapabilityDeadlines.values.forEach { $0.cancel() }
|
|
108
|
+
pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
|
|
109
|
+
lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
|
|
110
|
+
if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
override func viewDidLoad() {
|
|
@@ -116,6 +127,7 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
116
127
|
])
|
|
117
128
|
setupJavaScript()
|
|
118
129
|
loadBundle()
|
|
130
|
+
observeNativeEvents()
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
private func setupJavaScript() {
|
|
@@ -130,10 +142,18 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
130
142
|
self?.receive(json)
|
|
131
143
|
}
|
|
132
144
|
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
145
|
+
let capabilities = CraftNativeActions.capabilities(config: config)
|
|
146
|
+
let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
|
|
147
|
+
let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
|
|
133
148
|
jsContext.evaluateScript("""
|
|
134
149
|
globalThis.__stxNativeCallback = null;
|
|
135
150
|
globalThis.__stxNativeBridge = {
|
|
151
|
+
platform: "ios",
|
|
136
152
|
mutationProtocolVersion: 1,
|
|
153
|
+
capabilityProtocolVersion: \(craftNativeCapabilityProtocolVersion),
|
|
154
|
+
capabilityTimeoutMs: \(craftNativeCapabilityTimeoutMilliseconds),
|
|
155
|
+
capabilities: \(capabilityJSON),
|
|
156
|
+
initialAppState: "\(CraftNativeActions.currentAppState())",
|
|
137
157
|
postMessage: function(message) { craftNativePostMessage(message); },
|
|
138
158
|
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
139
159
|
};
|
|
@@ -156,6 +176,31 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
156
176
|
}
|
|
157
177
|
}
|
|
158
178
|
|
|
179
|
+
private func observeNativeEvents() {
|
|
180
|
+
func observe(_ name: Notification.Name, state: String) {
|
|
181
|
+
lifecycleObservers.append(NotificationCenter.default.addObserver(
|
|
182
|
+
forName: name,
|
|
183
|
+
object: nil,
|
|
184
|
+
queue: .main
|
|
185
|
+
) { [weak self] _ in self?.sendAppState(state) })
|
|
186
|
+
}
|
|
187
|
+
observe(UIApplication.didBecomeActiveNotification, state: "active")
|
|
188
|
+
observe(UIApplication.willResignActiveNotification, state: "inactive")
|
|
189
|
+
observe(UIApplication.didEnterBackgroundNotification, state: "background")
|
|
190
|
+
sendAppState(CraftNativeActions.currentAppState())
|
|
191
|
+
deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
|
|
192
|
+
guard let self = self, self.navigationController?.topViewController === self else { return }
|
|
193
|
+
var payload = CraftNativeActions.deepLinkData(url)
|
|
194
|
+
payload["initial"] = initial
|
|
195
|
+
self.send(type: "DEEP_LINK", payload: payload)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private func sendAppState(_ state: String) {
|
|
200
|
+
guard navigationController?.topViewController === self else { return }
|
|
201
|
+
send(type: "APP_STATE", payload: ["state": state])
|
|
202
|
+
}
|
|
203
|
+
|
|
159
204
|
private func showError(_ message: String) {
|
|
160
205
|
let label = UILabel()
|
|
161
206
|
label.numberOfLines = 0
|
|
@@ -219,30 +264,56 @@ final class CraftNativeScreenController: UIViewController {
|
|
|
219
264
|
case "API_REQUEST":
|
|
220
265
|
guard let id = message["id"] as? String else { return }
|
|
221
266
|
let args = payload["args"] as? [Any] ?? []
|
|
222
|
-
let
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
}
|
|
235
|
-
guard let route = route,
|
|
236
|
-
let answer = CraftNativeActions.perform(action: route.action, body: route.body, config: config) else {
|
|
237
|
-
send(type: "API_ERROR", payload: ["requestId": id, "code": "UNKNOWN_ACTION", "message": "Unsupported native API"], correlationId: id)
|
|
238
|
-
return
|
|
267
|
+
let requestToken = "\(capabilityScope)/\(id)"
|
|
268
|
+
pendingCapabilityRequests.insert(requestToken)
|
|
269
|
+
let deadline = DispatchWorkItem { [weak self] in
|
|
270
|
+
guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
271
|
+
self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)
|
|
272
|
+
CraftNativeActions.cancel(requestToken: requestToken)
|
|
273
|
+
self.send(type: "API_ERROR", payload: [
|
|
274
|
+
"version": craftNativeCapabilityProtocolVersion,
|
|
275
|
+
"requestId": id,
|
|
276
|
+
"code": "TIMEOUT",
|
|
277
|
+
"message": "Native API request timed out",
|
|
278
|
+
], correlationId: id)
|
|
239
279
|
}
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
280
|
+
pendingCapabilityDeadlines[requestToken] = deadline
|
|
281
|
+
DispatchQueue.main.asyncAfter(
|
|
282
|
+
deadline: .now() + .milliseconds(craftNativeCapabilityTimeoutMilliseconds),
|
|
283
|
+
execute: deadline
|
|
284
|
+
)
|
|
285
|
+
CraftNativeActions.perform(
|
|
286
|
+
requestToken: requestToken,
|
|
287
|
+
version: payload["version"] as? Int ?? craftNativeCapabilityProtocolVersion,
|
|
288
|
+
module: payload["module"] as? String ?? "",
|
|
289
|
+
method: payload["method"] as? String ?? "",
|
|
290
|
+
args: args,
|
|
291
|
+
config: config
|
|
292
|
+
) { [weak self] answer in
|
|
293
|
+
guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
|
|
294
|
+
self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
295
|
+
switch answer {
|
|
296
|
+
case .success(let data):
|
|
297
|
+
self.send(type: "API_RESPONSE", payload: [
|
|
298
|
+
"version": craftNativeCapabilityProtocolVersion,
|
|
299
|
+
"requestId": id,
|
|
300
|
+
"data": data,
|
|
301
|
+
], correlationId: id)
|
|
302
|
+
case .failure(let error):
|
|
303
|
+
self.send(type: "API_ERROR", payload: [
|
|
304
|
+
"version": craftNativeCapabilityProtocolVersion,
|
|
305
|
+
"requestId": id,
|
|
306
|
+
"code": error.code,
|
|
307
|
+
"message": error.message,
|
|
308
|
+
], correlationId: id)
|
|
309
|
+
}
|
|
245
310
|
}
|
|
311
|
+
case "API_CANCEL":
|
|
312
|
+
guard let id = payload["requestId"] as? String else { return }
|
|
313
|
+
let requestToken = "\(capabilityScope)/\(id)"
|
|
314
|
+
pendingCapabilityRequests.remove(requestToken)
|
|
315
|
+
pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
316
|
+
CraftNativeActions.cancel(requestToken: requestToken)
|
|
246
317
|
default:
|
|
247
318
|
break
|
|
248
319
|
}
|