@craft-native/ios 0.0.104 → 0.0.107
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 +21 -2
- package/dist/cli.js +41 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.js +32 -3
- package/package.json +1 -1
- package/templates/CraftApp.swift +20 -92
- package/templates/CraftNativeActions.swift +93 -0
- package/templates/CraftNativeScreen.swift +381 -0
package/README.md
CHANGED
|
@@ -104,6 +104,25 @@ In Xcode:
|
|
|
104
104
|
|
|
105
105
|
## Configuration
|
|
106
106
|
|
|
107
|
+
### Experimental WebView-free screen
|
|
108
|
+
|
|
109
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `Text`, `Button`, and `TextInput` nodes as UIKit controls. The default remains `web`; existing iOS apps are unchanged. This is a vertical slice, not yet a replacement for the WebView renderer: the native bridge supports device info, haptics, clipboard read/write, and multi-screen navigation. Lists and the rest of Craft's 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.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
113
|
+
stx-native compile Screen.stx --format bundle --output ./screen.js
|
|
114
|
+
craft ios build --output ./ios-native --native-bundle ./screen.js
|
|
115
|
+
craft ios run --output ./ios-native --simulator
|
|
116
|
+
```
|
|
117
|
+
|
|
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
|
+
|
|
120
|
+
Native screen updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for existing screens); unkeyed children reuse their position. `TextInput` objects therefore keep focus and selection while unrelated labels and buttons change. On macOS, `bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and simulator UI tests.
|
|
121
|
+
|
|
122
|
+
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
|
+
|
|
124
|
+
The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
|
|
125
|
+
|
|
107
126
|
Edit `craft.config.json` in your iOS project:
|
|
108
127
|
|
|
109
128
|
```json
|
|
@@ -222,12 +241,12 @@ const scannedCode = await window.craft.scanQRCode();
|
|
|
222
241
|
console.log(scannedCode); // "https://example.com"
|
|
223
242
|
|
|
224
243
|
// File Picker
|
|
225
|
-
const file = await window.craft.pickFile(['public.image', '
|
|
244
|
+
const file = await window.craft.pickFile(['public.image', 'application/pdf']);
|
|
226
245
|
console.log(file.name, file.data); // base64 encoded
|
|
227
246
|
|
|
228
247
|
// File Download
|
|
229
248
|
await window.craft.downloadFile('https://example.com/file.pdf', 'document.pdf');
|
|
230
|
-
await window.craft.saveFile(
|
|
249
|
+
await window.craft.saveFile('data:text/plain;base64,SGVsbG8=', 'hello.txt', 'text/plain');
|
|
231
250
|
|
|
232
251
|
// Social Auth (Apple Sign In)
|
|
233
252
|
const user = await window.craft.signInWithApple();
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { dirname, join, resolve } from "path";
|
|
|
10
10
|
var {$ } = globalThis.Bun;
|
|
11
11
|
var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
|
|
12
12
|
var DEFAULT_CONFIG = {
|
|
13
|
+
renderer: "web",
|
|
13
14
|
version: "1.0.0",
|
|
14
15
|
buildNumber: "1",
|
|
15
16
|
darkMode: true,
|
|
@@ -398,12 +399,17 @@ async function init(options) {
|
|
|
398
399
|
teamId: teamId || "",
|
|
399
400
|
...given
|
|
400
401
|
};
|
|
402
|
+
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
403
|
+
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
404
|
+
}
|
|
401
405
|
if (config.enableBackgroundLocation)
|
|
402
406
|
config.enableGeolocation = true;
|
|
403
407
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
404
408
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
405
409
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
406
410
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
411
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
412
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
407
413
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
408
414
|
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>
|
|
409
415
|
<true/>
|
|
@@ -538,13 +544,18 @@ ${nativeDependencies.join(`
|
|
|
538
544
|
console.log("");
|
|
539
545
|
console.log("Next steps:");
|
|
540
546
|
console.log(` 1. cd ${output}`);
|
|
541
|
-
|
|
542
|
-
|
|
547
|
+
if (config.renderer === "native") {
|
|
548
|
+
console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
|
|
549
|
+
console.log(" 3. Run: craft ios build --native-bundle screen.js");
|
|
550
|
+
} else {
|
|
551
|
+
console.log(" 2. Add your web content to dist/index.html");
|
|
552
|
+
console.log(" 3. Run: craft ios build");
|
|
553
|
+
}
|
|
543
554
|
console.log(" 4. Run: craft ios open");
|
|
544
555
|
console.log("");
|
|
545
556
|
}
|
|
546
557
|
async function build(options) {
|
|
547
|
-
const { htmlPath, devServer, output, generateProject = true } = options;
|
|
558
|
+
const { htmlPath, nativeBundlePath, devServer, output, generateProject = true } = options;
|
|
548
559
|
console.log(`
|
|
549
560
|
\uD83D\uDCE6 Building Craft iOS project...`);
|
|
550
561
|
const configPath = join(output, "craft.config.json");
|
|
@@ -552,6 +563,24 @@ async function build(options) {
|
|
|
552
563
|
throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
|
|
553
564
|
}
|
|
554
565
|
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
566
|
+
if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
|
|
567
|
+
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
568
|
+
}
|
|
569
|
+
if (config.renderer === "native") {
|
|
570
|
+
if (htmlPath || devServer) {
|
|
571
|
+
throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
|
|
572
|
+
}
|
|
573
|
+
if (nativeBundlePath) {
|
|
574
|
+
const destination = join(output, "dist", "native-screen.js");
|
|
575
|
+
if (resolve(nativeBundlePath) !== resolve(destination))
|
|
576
|
+
cpSync(nativeBundlePath, destination);
|
|
577
|
+
}
|
|
578
|
+
if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
579
|
+
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx-native first.");
|
|
580
|
+
}
|
|
581
|
+
} else if (nativeBundlePath) {
|
|
582
|
+
throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
|
|
583
|
+
}
|
|
555
584
|
if (devServer) {
|
|
556
585
|
config.devServerURL = devServer;
|
|
557
586
|
const origin = new URL(devServer).origin;
|
|
@@ -695,7 +724,9 @@ var { values, positionals } = parseArgs({
|
|
|
695
724
|
name: { type: "string", short: "n" },
|
|
696
725
|
"bundle-id": { type: "string", short: "b" },
|
|
697
726
|
"team-id": { type: "string", short: "t" },
|
|
727
|
+
renderer: { type: "string" },
|
|
698
728
|
"html-path": { type: "string" },
|
|
729
|
+
"native-bundle": { type: "string" },
|
|
699
730
|
"dev-server": { type: "string", short: "d" },
|
|
700
731
|
output: { type: "string", short: "o" },
|
|
701
732
|
simulator: { type: "boolean", short: "s" }
|
|
@@ -720,7 +751,9 @@ Options:
|
|
|
720
751
|
-n, --name <name> App name
|
|
721
752
|
-b, --bundle-id <id> Bundle identifier (e.g., com.example.app)
|
|
722
753
|
-t, --team-id <id> Apple Developer Team ID
|
|
754
|
+
--renderer <kind> iOS renderer: web (default) or native
|
|
723
755
|
--html-path <path> Path to HTML file or directory
|
|
756
|
+
--native-bundle <path> JavaScript bundle compiled by stx-native
|
|
724
757
|
-d, --dev-server <url> Development server URL
|
|
725
758
|
-o, --output <dir> Output directory (default: ./ios)
|
|
726
759
|
-s, --simulator Run on simulator instead of device
|
|
@@ -730,6 +763,8 @@ Options:
|
|
|
730
763
|
Examples:
|
|
731
764
|
craft-ios init MyApp
|
|
732
765
|
craft-ios build --html-path ./dist/index.html
|
|
766
|
+
craft-ios init MyApp --renderer native
|
|
767
|
+
craft-ios build --native-bundle ./screen.js
|
|
733
768
|
craft-ios build --dev-server http://localhost:3456
|
|
734
769
|
craft-ios run --simulator
|
|
735
770
|
`);
|
|
@@ -747,12 +782,14 @@ async function main() {
|
|
|
747
782
|
name: positionals[1] || values.name || "CraftApp",
|
|
748
783
|
bundleId: values["bundle-id"],
|
|
749
784
|
teamId: values["team-id"],
|
|
750
|
-
output: values.output || "./ios"
|
|
785
|
+
output: values.output || "./ios",
|
|
786
|
+
config: values.renderer ? { renderer: values.renderer } : undefined
|
|
751
787
|
});
|
|
752
788
|
break;
|
|
753
789
|
case "build":
|
|
754
790
|
await build({
|
|
755
791
|
htmlPath: values["html-path"],
|
|
792
|
+
nativeBundlePath: values["native-bundle"],
|
|
756
793
|
devServer: values["dev-server"],
|
|
757
794
|
output: values.output || "./ios"
|
|
758
795
|
});
|
package/dist/index.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export type CraftAppearance = 'light' | 'dark' | 'system';
|
|
|
7
7
|
export interface CraftConfig {
|
|
8
8
|
appName: string;
|
|
9
9
|
bundleId: string;
|
|
10
|
+
/** Opt in to UIKit + JavaScriptCore instead of the default WebView. */
|
|
11
|
+
renderer?: 'web' | 'native';
|
|
10
12
|
version?: string;
|
|
11
13
|
buildNumber?: string;
|
|
12
14
|
darkMode?: boolean;
|
|
@@ -108,6 +110,8 @@ export interface InitOptions {
|
|
|
108
110
|
}
|
|
109
111
|
export interface BuildOptions {
|
|
110
112
|
htmlPath?: string;
|
|
113
|
+
/** JavaScript bundle emitted by `stx-native compile Screen.stx --format bundle`. */
|
|
114
|
+
nativeBundlePath?: string;
|
|
111
115
|
devServer?: string;
|
|
112
116
|
output: string;
|
|
113
117
|
generateProject?: boolean;
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join, resolve } from "path";
|
|
|
5
5
|
var {$ } = globalThis.Bun;
|
|
6
6
|
var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
|
|
7
7
|
var DEFAULT_CONFIG = {
|
|
8
|
+
renderer: "web",
|
|
8
9
|
version: "1.0.0",
|
|
9
10
|
buildNumber: "1",
|
|
10
11
|
darkMode: true,
|
|
@@ -393,12 +394,17 @@ async function init(options) {
|
|
|
393
394
|
teamId: teamId || "",
|
|
394
395
|
...given
|
|
395
396
|
};
|
|
397
|
+
if (config.renderer !== "web" && config.renderer !== "native") {
|
|
398
|
+
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
399
|
+
}
|
|
396
400
|
if (config.enableBackgroundLocation)
|
|
397
401
|
config.enableGeolocation = true;
|
|
398
402
|
writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
|
|
399
403
|
const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
|
|
400
404
|
const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
|
|
401
405
|
writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
|
|
406
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
|
|
407
|
+
cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
|
|
402
408
|
const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
|
|
403
409
|
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>
|
|
404
410
|
<true/>
|
|
@@ -533,13 +539,18 @@ ${nativeDependencies.join(`
|
|
|
533
539
|
console.log("");
|
|
534
540
|
console.log("Next steps:");
|
|
535
541
|
console.log(` 1. cd ${output}`);
|
|
536
|
-
|
|
537
|
-
|
|
542
|
+
if (config.renderer === "native") {
|
|
543
|
+
console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
|
|
544
|
+
console.log(" 3. Run: craft ios build --native-bundle screen.js");
|
|
545
|
+
} else {
|
|
546
|
+
console.log(" 2. Add your web content to dist/index.html");
|
|
547
|
+
console.log(" 3. Run: craft ios build");
|
|
548
|
+
}
|
|
538
549
|
console.log(" 4. Run: craft ios open");
|
|
539
550
|
console.log("");
|
|
540
551
|
}
|
|
541
552
|
async function build(options) {
|
|
542
|
-
const { htmlPath, devServer, output, generateProject = true } = options;
|
|
553
|
+
const { htmlPath, nativeBundlePath, devServer, output, generateProject = true } = options;
|
|
543
554
|
console.log(`
|
|
544
555
|
\uD83D\uDCE6 Building Craft iOS project...`);
|
|
545
556
|
const configPath = join(output, "craft.config.json");
|
|
@@ -547,6 +558,24 @@ async function build(options) {
|
|
|
547
558
|
throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
|
|
548
559
|
}
|
|
549
560
|
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
561
|
+
if (config.renderer !== undefined && config.renderer !== "web" && config.renderer !== "native") {
|
|
562
|
+
throw new Error(`Unknown iOS renderer: ${config.renderer}. Expected web or native.`);
|
|
563
|
+
}
|
|
564
|
+
if (config.renderer === "native") {
|
|
565
|
+
if (htmlPath || devServer) {
|
|
566
|
+
throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
|
|
567
|
+
}
|
|
568
|
+
if (nativeBundlePath) {
|
|
569
|
+
const destination = join(output, "dist", "native-screen.js");
|
|
570
|
+
if (resolve(nativeBundlePath) !== resolve(destination))
|
|
571
|
+
cpSync(nativeBundlePath, destination);
|
|
572
|
+
}
|
|
573
|
+
if (!existsSync(join(output, "dist", "native-screen.js"))) {
|
|
574
|
+
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx-native first.");
|
|
575
|
+
}
|
|
576
|
+
} else if (nativeBundlePath) {
|
|
577
|
+
throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
|
|
578
|
+
}
|
|
550
579
|
if (devServer) {
|
|
551
580
|
config.devServerURL = devServer;
|
|
552
581
|
const origin = new URL(devServer).origin;
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -221,8 +221,14 @@ struct CraftApp: App {
|
|
|
221
221
|
|
|
222
222
|
var body: some SwiftUI.Scene {
|
|
223
223
|
WindowGroup {
|
|
224
|
-
|
|
225
|
-
.
|
|
224
|
+
Group {
|
|
225
|
+
if appState.config.renderer == "native" {
|
|
226
|
+
CraftNativeScreen(config: appState.config)
|
|
227
|
+
} else {
|
|
228
|
+
CraftWebView(config: appState.config)
|
|
229
|
+
.ignoresSafeArea()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
226
232
|
.preferredColorScheme(appState.config.colorScheme)
|
|
227
233
|
.environmentObject(appState)
|
|
228
234
|
.onOpenURL { url in
|
|
@@ -375,6 +381,8 @@ extension CraftConfig {
|
|
|
375
381
|
struct CraftConfig: Codable {
|
|
376
382
|
var appName: String = "Craft App"
|
|
377
383
|
var bundleId: String = "com.craft.app"
|
|
384
|
+
/// Opt-in UIKit + JavaScriptCore screen; existing apps keep WKWebView.
|
|
385
|
+
var renderer: String = "web"
|
|
378
386
|
var darkMode: Bool = true
|
|
379
387
|
/// "light", "dark" or "system". Absent in configs older than the field,
|
|
380
388
|
/// which keep what `darkMode` pinned.
|
|
@@ -873,6 +881,13 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
873
881
|
/// `craft_ios_deliver_result` instead of evaluating JavaScript here —
|
|
874
882
|
/// one reply path, owned by whichever side received the page's message.
|
|
875
883
|
func dispatch(action: String, body: [String: Any], callbackId: String?) {
|
|
884
|
+
if let answer = CraftNativeActions.perform(action: action, body: body, config: config) {
|
|
885
|
+
switch answer {
|
|
886
|
+
case .success(let value): resolveCallback(callbackId, result: value)
|
|
887
|
+
case .failure(let error): rejectCallback(callbackId, error: error.message, code: error.code)
|
|
888
|
+
}
|
|
889
|
+
return
|
|
890
|
+
}
|
|
876
891
|
switch action {
|
|
877
892
|
case "startListening":
|
|
878
893
|
if config.enableSpeechRecognition {
|
|
@@ -888,14 +903,6 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
888
903
|
case "stopListening":
|
|
889
904
|
stopSpeechRecognition()
|
|
890
905
|
resolveCallback(callbackId, result: true)
|
|
891
|
-
case "haptic":
|
|
892
|
-
if config.enableHaptics {
|
|
893
|
-
let style = body["style"] as? String ?? "medium"
|
|
894
|
-
triggerHaptic(style: style)
|
|
895
|
-
resolveCallback(callbackId, result: true)
|
|
896
|
-
} else {
|
|
897
|
-
rejectCallback(callbackId, error: "Haptics is disabled", code: "CAPABILITY_DISABLED")
|
|
898
|
-
}
|
|
899
906
|
case "share":
|
|
900
907
|
if config.enableShare {
|
|
901
908
|
if let options = body["options"] as? [String: Any] {
|
|
@@ -1028,28 +1035,6 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1028
1035
|
getLocationRecordingState(callbackId: callbackId)
|
|
1029
1036
|
case "readLocationRecording":
|
|
1030
1037
|
readLocationRecording(callbackId: callbackId)
|
|
1031
|
-
// Clipboard
|
|
1032
|
-
case "clipboardWrite":
|
|
1033
|
-
if config.enableClipboard {
|
|
1034
|
-
if let text = body["text"] as? String {
|
|
1035
|
-
UIPasteboard.general.string = text
|
|
1036
|
-
resolveCallback(callbackId, result: true)
|
|
1037
|
-
} else {
|
|
1038
|
-
rejectCallback(callbackId, error: "clipboardWrite was called without the values it needs", code: "INVALID_ARGUMENT")
|
|
1039
|
-
}
|
|
1040
|
-
} else {
|
|
1041
|
-
rejectCallback(callbackId, error: "Clipboard is disabled", code: "CAPABILITY_DISABLED")
|
|
1042
|
-
}
|
|
1043
|
-
case "clipboardRead":
|
|
1044
|
-
if config.enableClipboard {
|
|
1045
|
-
let text = UIPasteboard.general.string ?? ""
|
|
1046
|
-
resolveCallback(callbackId, result: text)
|
|
1047
|
-
} else {
|
|
1048
|
-
rejectCallback(callbackId, error: "Clipboard is disabled", code: "CAPABILITY_DISABLED")
|
|
1049
|
-
}
|
|
1050
|
-
// Device Info
|
|
1051
|
-
case "getDeviceInfo":
|
|
1052
|
-
getDeviceInfo(callbackId: callbackId)
|
|
1053
1038
|
// App Badge
|
|
1054
1039
|
case "setBadge":
|
|
1055
1040
|
if let count = body["count"] as? Int {
|
|
@@ -1078,7 +1063,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
1078
1063
|
if let pattern = body["pattern"] as? [Int] {
|
|
1079
1064
|
vibratePattern(pattern)
|
|
1080
1065
|
} else {
|
|
1081
|
-
triggerHaptic(style: "medium")
|
|
1066
|
+
CraftNativeActions.triggerHaptic(style: "medium")
|
|
1082
1067
|
}
|
|
1083
1068
|
resolveCallback(callbackId, result: true)
|
|
1084
1069
|
// Open URL
|
|
@@ -3693,7 +3678,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3693
3678
|
do {
|
|
3694
3679
|
try audioEngine.start()
|
|
3695
3680
|
sendToWeb("craftSpeechStart", data: [:])
|
|
3696
|
-
triggerHaptic(style: "light")
|
|
3681
|
+
CraftNativeActions.triggerHaptic(style: "light")
|
|
3697
3682
|
} catch {
|
|
3698
3683
|
sendToWeb("craftSpeechError", data: ["error": "Audio engine failed"])
|
|
3699
3684
|
}
|
|
@@ -3707,27 +3692,7 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
3707
3692
|
recognitionTask?.cancel()
|
|
3708
3693
|
recognitionTask = nil
|
|
3709
3694
|
sendToWeb("craftSpeechEnd", data: [:])
|
|
3710
|
-
triggerHaptic(style: "light")
|
|
3711
|
-
}
|
|
3712
|
-
|
|
3713
|
-
// MARK: - Haptics
|
|
3714
|
-
private func triggerHaptic(style: String) {
|
|
3715
|
-
switch style {
|
|
3716
|
-
case "light":
|
|
3717
|
-
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
3718
|
-
case "heavy":
|
|
3719
|
-
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
3720
|
-
case "success":
|
|
3721
|
-
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
3722
|
-
case "warning":
|
|
3723
|
-
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
3724
|
-
case "error":
|
|
3725
|
-
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
3726
|
-
case "selection":
|
|
3727
|
-
UISelectionFeedbackGenerator().selectionChanged()
|
|
3728
|
-
default:
|
|
3729
|
-
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
3730
|
-
}
|
|
3695
|
+
CraftNativeActions.triggerHaptic(style: "light")
|
|
3731
3696
|
}
|
|
3732
3697
|
|
|
3733
3698
|
// MARK: - Share
|
|
@@ -4412,43 +4377,6 @@ struct CraftWebView: UIViewRepresentable {
|
|
|
4412
4377
|
}
|
|
4413
4378
|
}
|
|
4414
4379
|
|
|
4415
|
-
// MARK: - Device Info
|
|
4416
|
-
private func getDeviceInfo(callbackId: String?) {
|
|
4417
|
-
let device = UIDevice.current
|
|
4418
|
-
let screen = UIScreen.main
|
|
4419
|
-
#if targetEnvironment(simulator)
|
|
4420
|
-
let isSimulator = true
|
|
4421
|
-
#else
|
|
4422
|
-
let isSimulator = false
|
|
4423
|
-
#endif
|
|
4424
|
-
let info: [String: Any] = [
|
|
4425
|
-
"platform": "ios",
|
|
4426
|
-
"model": device.model,
|
|
4427
|
-
"name": device.name,
|
|
4428
|
-
"systemName": device.systemName,
|
|
4429
|
-
"systemVersion": device.systemVersion,
|
|
4430
|
-
"identifierForVendor": device.identifierForVendor?.uuidString ?? "",
|
|
4431
|
-
"isSimulator": isSimulator,
|
|
4432
|
-
"screenWidth": screen.bounds.width,
|
|
4433
|
-
"screenHeight": screen.bounds.height,
|
|
4434
|
-
"screenScale": screen.scale,
|
|
4435
|
-
"batteryLevel": device.batteryLevel,
|
|
4436
|
-
"batteryState": getBatteryState(device.batteryState),
|
|
4437
|
-
"locale": Locale.current.identifier,
|
|
4438
|
-
"timezone": TimeZone.current.identifier
|
|
4439
|
-
]
|
|
4440
|
-
resolveCallback(callbackId, result: info)
|
|
4441
|
-
}
|
|
4442
|
-
|
|
4443
|
-
private func getBatteryState(_ state: UIDevice.BatteryState) -> String {
|
|
4444
|
-
switch state {
|
|
4445
|
-
case .charging: return "charging"
|
|
4446
|
-
case .full: return "full"
|
|
4447
|
-
case .unplugged: return "unplugged"
|
|
4448
|
-
default: return "unknown"
|
|
4449
|
-
}
|
|
4450
|
-
}
|
|
4451
|
-
|
|
4452
4380
|
// MARK: - App Badge
|
|
4453
4381
|
private func setBadgeCount(_ count: Int, callbackId: String?) {
|
|
4454
4382
|
UNUserNotificationCenter.current().requestAuthorization(options: .badge) { granted, _ in
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import UIKit
|
|
2
|
+
|
|
3
|
+
/// The result of a Craft action, independent of whether the caller is a
|
|
4
|
+
/// WebKit page or the JavaScriptCore native-screen runtime.
|
|
5
|
+
struct CraftNativeActionError: Error {
|
|
6
|
+
let code: String
|
|
7
|
+
let message: String
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
enum CraftNativeActions {
|
|
11
|
+
/// Nil means this small shared router does not own the action. Both hosts
|
|
12
|
+
/// can then continue through their existing unsupported-action path.
|
|
13
|
+
static func perform(action: String, body: [String: Any], config: CraftConfig) -> Result<Any, CraftNativeActionError>? {
|
|
14
|
+
switch action {
|
|
15
|
+
case "getDeviceInfo":
|
|
16
|
+
return .success(deviceInfo())
|
|
17
|
+
case "haptic":
|
|
18
|
+
guard config.enableHaptics else {
|
|
19
|
+
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Haptics is disabled"))
|
|
20
|
+
}
|
|
21
|
+
triggerHaptic(style: body["style"] as? String ?? "medium")
|
|
22
|
+
return .success(true)
|
|
23
|
+
case "clipboardWrite":
|
|
24
|
+
guard config.enableClipboard else {
|
|
25
|
+
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
|
|
26
|
+
}
|
|
27
|
+
guard let text = body["text"] as? String else {
|
|
28
|
+
return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "clipboardWrite was called without the values it needs"))
|
|
29
|
+
}
|
|
30
|
+
UIPasteboard.general.string = text
|
|
31
|
+
return .success(true)
|
|
32
|
+
case "clipboardRead":
|
|
33
|
+
guard config.enableClipboard else {
|
|
34
|
+
return .failure(CraftNativeActionError(code: "CAPABILITY_DISABLED", message: "Clipboard is disabled"))
|
|
35
|
+
}
|
|
36
|
+
return .success(UIPasteboard.general.string ?? "")
|
|
37
|
+
default:
|
|
38
|
+
return nil
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static func triggerHaptic(style: String) {
|
|
43
|
+
switch style {
|
|
44
|
+
case "light":
|
|
45
|
+
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
46
|
+
case "heavy":
|
|
47
|
+
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
48
|
+
case "success":
|
|
49
|
+
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
|
50
|
+
case "warning":
|
|
51
|
+
UINotificationFeedbackGenerator().notificationOccurred(.warning)
|
|
52
|
+
case "error":
|
|
53
|
+
UINotificationFeedbackGenerator().notificationOccurred(.error)
|
|
54
|
+
case "selection":
|
|
55
|
+
UISelectionFeedbackGenerator().selectionChanged()
|
|
56
|
+
default:
|
|
57
|
+
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private static func deviceInfo() -> [String: Any] {
|
|
62
|
+
let device = UIDevice.current
|
|
63
|
+
let screen = UIScreen.main
|
|
64
|
+
#if targetEnvironment(simulator)
|
|
65
|
+
let isSimulator = true
|
|
66
|
+
#else
|
|
67
|
+
let isSimulator = false
|
|
68
|
+
#endif
|
|
69
|
+
let batteryState: String
|
|
70
|
+
switch device.batteryState {
|
|
71
|
+
case .charging: batteryState = "charging"
|
|
72
|
+
case .full: batteryState = "full"
|
|
73
|
+
case .unplugged: batteryState = "unplugged"
|
|
74
|
+
default: batteryState = "unknown"
|
|
75
|
+
}
|
|
76
|
+
return [
|
|
77
|
+
"platform": "ios",
|
|
78
|
+
"model": device.model,
|
|
79
|
+
"name": device.name,
|
|
80
|
+
"systemName": device.systemName,
|
|
81
|
+
"systemVersion": device.systemVersion,
|
|
82
|
+
"identifierForVendor": device.identifierForVendor?.uuidString ?? "",
|
|
83
|
+
"isSimulator": isSimulator,
|
|
84
|
+
"screenWidth": screen.bounds.width,
|
|
85
|
+
"screenHeight": screen.bounds.height,
|
|
86
|
+
"screenScale": screen.scale,
|
|
87
|
+
"batteryLevel": device.batteryLevel,
|
|
88
|
+
"batteryState": batteryState,
|
|
89
|
+
"locale": Locale.current.identifier,
|
|
90
|
+
"timezone": TimeZone.current.identifier
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import JavaScriptCore
|
|
3
|
+
import SwiftUI
|
|
4
|
+
import UIKit
|
|
5
|
+
|
|
6
|
+
/// A WebView-free host for the first stx-native vertical slice. The bundled
|
|
7
|
+
/// JavaScript sends whole, compiled view trees to UIKit and receives control
|
|
8
|
+
/// events and Craft API replies through JavaScriptCore.
|
|
9
|
+
struct CraftNativeScreen: UIViewControllerRepresentable {
|
|
10
|
+
let config: CraftConfig
|
|
11
|
+
|
|
12
|
+
func makeUIViewController(context: Context) -> UINavigationController {
|
|
13
|
+
UINavigationController(rootViewController: CraftNativeScreenController(config: config))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func updateUIViewController(_ controller: UINavigationController, context: Context) {}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
final class CraftNativeScreenController: UIViewController {
|
|
20
|
+
private final class RenderedNode {
|
|
21
|
+
let identity: String
|
|
22
|
+
let type: String
|
|
23
|
+
let view: UIView
|
|
24
|
+
var children: [RenderedNode] = []
|
|
25
|
+
var widthConstraint: NSLayoutConstraint?
|
|
26
|
+
var heightConstraint: NSLayoutConstraint?
|
|
27
|
+
|
|
28
|
+
init(identity: String, type: String, view: UIView) {
|
|
29
|
+
self.identity = identity
|
|
30
|
+
self.type = type
|
|
31
|
+
self.view = view
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private let config: CraftConfig
|
|
36
|
+
private let routeName: String?
|
|
37
|
+
private let routeParams: [String: Any]
|
|
38
|
+
private let jsContext = JSContext()!
|
|
39
|
+
private let rootStack = UIStackView()
|
|
40
|
+
private var handlers: [ObjectIdentifier: String] = [:]
|
|
41
|
+
private var renderedRoot: RenderedNode?
|
|
42
|
+
|
|
43
|
+
init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
|
|
44
|
+
self.config = config
|
|
45
|
+
self.routeName = routeName
|
|
46
|
+
self.routeParams = routeParams
|
|
47
|
+
super.init(nibName: nil, bundle: nil)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
required init?(coder: NSCoder) {
|
|
51
|
+
fatalError("init(coder:) has not been implemented")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
override func viewDidLoad() {
|
|
55
|
+
super.viewDidLoad()
|
|
56
|
+
navigationItem.title = routeName ?? config.appName
|
|
57
|
+
view.backgroundColor = config.resolvedBackgroundColor
|
|
58
|
+
rootStack.axis = .vertical
|
|
59
|
+
rootStack.alignment = .fill
|
|
60
|
+
rootStack.distribution = .fill
|
|
61
|
+
rootStack.translatesAutoresizingMaskIntoConstraints = false
|
|
62
|
+
view.addSubview(rootStack)
|
|
63
|
+
NSLayoutConstraint.activate([
|
|
64
|
+
rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
|
65
|
+
rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
|
|
66
|
+
rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
|
|
67
|
+
rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
|
|
68
|
+
])
|
|
69
|
+
setupJavaScript()
|
|
70
|
+
loadBundle()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private func setupJavaScript() {
|
|
74
|
+
if let routeName = routeName {
|
|
75
|
+
jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
|
|
76
|
+
}
|
|
77
|
+
jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
|
|
78
|
+
jsContext.exceptionHandler = { _, exception in
|
|
79
|
+
NSLog("[craft native] JavaScript exception: %@", exception?.toString() ?? "unknown")
|
|
80
|
+
}
|
|
81
|
+
let postMessage: @convention(block) (String) -> Void = { [weak self] json in
|
|
82
|
+
self?.receive(json)
|
|
83
|
+
}
|
|
84
|
+
jsContext.setObject(postMessage, forKeyedSubscript: "craftNativePostMessage" as NSString)
|
|
85
|
+
jsContext.evaluateScript("""
|
|
86
|
+
globalThis.__stxNativeCallback = null;
|
|
87
|
+
globalThis.__stxNativeBridge = {
|
|
88
|
+
postMessage: function(message) { craftNativePostMessage(message); },
|
|
89
|
+
onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
|
|
90
|
+
};
|
|
91
|
+
globalThis.console = {
|
|
92
|
+
log: function() {}, warn: function() {}, error: function() {}
|
|
93
|
+
};
|
|
94
|
+
""")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private func loadBundle() {
|
|
98
|
+
guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
|
|
99
|
+
?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
|
|
100
|
+
let script = try? String(contentsOf: url, encoding: .utf8) else {
|
|
101
|
+
showError("Missing dist/native-screen.js. Compile a .stx screen with stx-native first.")
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
jsContext.evaluateScript(script)
|
|
105
|
+
if routeName == nil, let selected = jsContext.objectForKeyedSubscript("__stxNativeRoute")?.toString() {
|
|
106
|
+
navigationItem.title = selected
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private func showError(_ message: String) {
|
|
111
|
+
let label = UILabel()
|
|
112
|
+
label.numberOfLines = 0
|
|
113
|
+
label.textColor = .systemRed
|
|
114
|
+
label.textAlignment = .center
|
|
115
|
+
label.text = message
|
|
116
|
+
rootStack.addArrangedSubview(label)
|
|
117
|
+
NSLog("[craft native] %@", message)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private func receive(_ raw: String) {
|
|
121
|
+
guard let data = raw.data(using: .utf8),
|
|
122
|
+
let message = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
123
|
+
let type = message["type"] as? String,
|
|
124
|
+
let payload = message["payload"] as? [String: Any] else { return }
|
|
125
|
+
|
|
126
|
+
switch type {
|
|
127
|
+
case "RENDER":
|
|
128
|
+
guard let document = payload["document"] as? [String: Any] else { return }
|
|
129
|
+
render(document)
|
|
130
|
+
case "NAVIGATE", "NAVIGATE_REPLACE":
|
|
131
|
+
guard navigationController?.topViewController === self,
|
|
132
|
+
let screen = payload["screen"] as? String, !screen.isEmpty,
|
|
133
|
+
let navigation = navigationController else { return }
|
|
134
|
+
let params = payload["params"] as? [String: Any] ?? [:]
|
|
135
|
+
let next = CraftNativeScreenController(config: config, routeName: screen, routeParams: params)
|
|
136
|
+
if type == "NAVIGATE" {
|
|
137
|
+
navigation.pushViewController(next, animated: true)
|
|
138
|
+
} else {
|
|
139
|
+
navigation.setViewControllers(Array(navigation.viewControllers.dropLast()) + [next], animated: true)
|
|
140
|
+
}
|
|
141
|
+
case "NAVIGATE_BACK":
|
|
142
|
+
if navigationController?.topViewController === self {
|
|
143
|
+
navigationController?.popViewController(animated: true)
|
|
144
|
+
}
|
|
145
|
+
case "API_REQUEST":
|
|
146
|
+
guard let id = message["id"] as? String else { return }
|
|
147
|
+
let args = payload["args"] as? [Any] ?? []
|
|
148
|
+
let route: (action: String, body: [String: Any])?
|
|
149
|
+
switch (payload["module"] as? String, payload["method"] as? String) {
|
|
150
|
+
case ("Device", "getInfo"):
|
|
151
|
+
route = ("getDeviceInfo", [:])
|
|
152
|
+
case ("Haptics", "impact"):
|
|
153
|
+
route = ("haptic", ["style": args.first ?? NSNull()])
|
|
154
|
+
case ("Clipboard", "write"):
|
|
155
|
+
route = ("clipboardWrite", ["text": args.first ?? NSNull()])
|
|
156
|
+
case ("Clipboard", "read"):
|
|
157
|
+
route = ("clipboardRead", [:])
|
|
158
|
+
default:
|
|
159
|
+
route = nil
|
|
160
|
+
}
|
|
161
|
+
guard let route = route,
|
|
162
|
+
let answer = CraftNativeActions.perform(action: route.action, body: route.body, config: config) else {
|
|
163
|
+
send(type: "API_ERROR", payload: ["requestId": id, "code": "UNKNOWN_ACTION", "message": "Unsupported native API"], correlationId: id)
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
switch answer {
|
|
167
|
+
case .success(let data):
|
|
168
|
+
send(type: "API_RESPONSE", payload: ["requestId": id, "data": data], correlationId: id)
|
|
169
|
+
case .failure(let error):
|
|
170
|
+
send(type: "API_ERROR", payload: ["requestId": id, "code": error.code, "message": error.message], correlationId: id)
|
|
171
|
+
}
|
|
172
|
+
default:
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Internal so simulator-hosted XCTest can assert actual UIView identity.
|
|
178
|
+
func render(_ document: [String: Any]) {
|
|
179
|
+
let previous = renderedRoot
|
|
180
|
+
let next = reconcile(document, identity: "root", path: "root", previous: previous)
|
|
181
|
+
if previous?.view !== next.view {
|
|
182
|
+
if let old = previous { detach(old, from: rootStack) }
|
|
183
|
+
rootStack.addArrangedSubview(next.view)
|
|
184
|
+
}
|
|
185
|
+
renderedRoot = next
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
|
|
189
|
+
let type = node["type"] as? String ?? "View"
|
|
190
|
+
let props = node["props"] as? [String: Any] ?? [:]
|
|
191
|
+
let style = node["style"] as? [String: Any] ?? [:]
|
|
192
|
+
let events = node["events"] as? [String: String] ?? [:]
|
|
193
|
+
let children = node["children"] as? [Any] ?? []
|
|
194
|
+
let current: RenderedNode
|
|
195
|
+
if let previous = previous, previous.identity == identity, previous.type == type {
|
|
196
|
+
current = previous
|
|
197
|
+
} else {
|
|
198
|
+
current = RenderedNode(identity: identity, type: type, view: makeView(type))
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let result = current.view
|
|
202
|
+
result.accessibilityIdentifier = explicitKey(node, props: props) ?? path
|
|
203
|
+
if type != "View" && type != "SafeAreaView" {
|
|
204
|
+
result.setContentHuggingPriority(.required, for: .vertical)
|
|
205
|
+
}
|
|
206
|
+
result.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
207
|
+
result.layer.cornerRadius = number(style["borderRadius"]) ?? 0
|
|
208
|
+
updateDimension(number(style["width"]), constraint: ¤t.widthConstraint, anchor: result.widthAnchor)
|
|
209
|
+
updateDimension(number(style["height"]), constraint: ¤t.heightConstraint, anchor: result.heightAnchor)
|
|
210
|
+
|
|
211
|
+
switch type {
|
|
212
|
+
case "Text":
|
|
213
|
+
let label = result as! UILabel
|
|
214
|
+
label.text = children.compactMap { $0 as? String }.joined()
|
|
215
|
+
label.textColor = color(style["color"]) ?? .label
|
|
216
|
+
label.font = .systemFont(ofSize: number(style["fontSize"]) ?? UIFont.systemFontSize)
|
|
217
|
+
case "Button":
|
|
218
|
+
let button = result as! UIButton
|
|
219
|
+
button.setTitle(props["title"] as? String ?? children.compactMap { $0 as? String }.joined(), for: .normal)
|
|
220
|
+
updateHandler(events["onPress"] ?? events["onClick"], for: button)
|
|
221
|
+
case "TextInput":
|
|
222
|
+
let field = result as! UITextField
|
|
223
|
+
field.placeholder = props["placeholder"] as? String
|
|
224
|
+
updateField(field, value: props["value"] as? String)
|
|
225
|
+
updateHandler(events["onChange"] ?? events["onChangeText"], for: field)
|
|
226
|
+
default:
|
|
227
|
+
let stack = result as! UIStackView
|
|
228
|
+
stack.axis = style["flexDirection"] as? String == "row" ? .horizontal : .vertical
|
|
229
|
+
stack.spacing = number(style["gap"]) ?? 0
|
|
230
|
+
let padding = number(style["padding"]) ?? 0
|
|
231
|
+
stack.layoutMargins = UIEdgeInsets(
|
|
232
|
+
top: number(style["paddingTop"]) ?? padding,
|
|
233
|
+
left: number(style["paddingLeft"]) ?? padding,
|
|
234
|
+
bottom: number(style["paddingBottom"]) ?? padding,
|
|
235
|
+
right: number(style["paddingRight"]) ?? padding
|
|
236
|
+
)
|
|
237
|
+
reconcileChildren(children, in: stack, parent: current, path: path)
|
|
238
|
+
}
|
|
239
|
+
return current
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private func makeView(_ type: String) -> UIView {
|
|
243
|
+
switch type {
|
|
244
|
+
case "Text":
|
|
245
|
+
let label = UILabel()
|
|
246
|
+
label.numberOfLines = 0
|
|
247
|
+
return label
|
|
248
|
+
case "Button":
|
|
249
|
+
let button = UIButton(type: .system)
|
|
250
|
+
button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
|
|
251
|
+
return button
|
|
252
|
+
case "TextInput":
|
|
253
|
+
let field = UITextField()
|
|
254
|
+
field.borderStyle = .roundedRect
|
|
255
|
+
field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
|
256
|
+
return field
|
|
257
|
+
default:
|
|
258
|
+
let stack = UIStackView()
|
|
259
|
+
stack.alignment = .fill
|
|
260
|
+
stack.distribution = .fill
|
|
261
|
+
stack.isLayoutMarginsRelativeArrangement = true
|
|
262
|
+
return stack
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
267
|
+
// The current stx-native compiler emits key in props; accept the IR
|
|
268
|
+
// field too so keyed children keep working when the compiler adopts it.
|
|
269
|
+
[node["key"], props["key"], props["testID"]]
|
|
270
|
+
.compactMap { $0 as? String }
|
|
271
|
+
.first { !$0.isEmpty }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private func reconcileChildren(_ children: [Any], in stack: UIStackView, parent: RenderedNode, path: String) {
|
|
275
|
+
let nodes = children.compactMap { $0 as? [String: Any] }
|
|
276
|
+
let keys = nodes.compactMap { explicitKey($0, props: $0["props"] as? [String: Any] ?? [:]) }
|
|
277
|
+
let counts = Dictionary(keys.map { ($0, 1) }, uniquingKeysWith: +)
|
|
278
|
+
let old = Dictionary(uniqueKeysWithValues: parent.children.map { ($0.identity, $0) })
|
|
279
|
+
var next: [RenderedNode] = []
|
|
280
|
+
|
|
281
|
+
for (index, node) in nodes.enumerated() {
|
|
282
|
+
let key = explicitKey(node, props: node["props"] as? [String: Any] ?? [:])
|
|
283
|
+
// Keys are local to a parent. Ambiguous sibling keys fall back to
|
|
284
|
+
// position so a move never attaches the wrong live control.
|
|
285
|
+
let identity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
|
|
286
|
+
let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
|
|
287
|
+
next.append(child)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
for child in parent.children where !next.contains(where: { $0 === child }) {
|
|
291
|
+
detach(child, from: stack)
|
|
292
|
+
}
|
|
293
|
+
for (index, child) in next.enumerated() {
|
|
294
|
+
if index < stack.arrangedSubviews.count, stack.arrangedSubviews[index] === child.view { continue }
|
|
295
|
+
if stack.arrangedSubviews.contains(where: { $0 === child.view }) {
|
|
296
|
+
stack.removeArrangedSubview(child.view)
|
|
297
|
+
}
|
|
298
|
+
stack.insertArrangedSubview(child.view, at: index)
|
|
299
|
+
}
|
|
300
|
+
parent.children = next
|
|
301
|
+
if path == "root" && stack.axis == .vertical {
|
|
302
|
+
// Keep the initial screen pinned to the safe-area top.
|
|
303
|
+
if stack.arrangedSubviews.count == next.count { stack.addArrangedSubview(UIView()) }
|
|
304
|
+
} else if stack.arrangedSubviews.count > next.count {
|
|
305
|
+
for filler in stack.arrangedSubviews.dropFirst(next.count) {
|
|
306
|
+
stack.removeArrangedSubview(filler)
|
|
307
|
+
filler.removeFromSuperview()
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private func detach(_ node: RenderedNode, from stack: UIStackView) {
|
|
313
|
+
forgetHandlers(node)
|
|
314
|
+
stack.removeArrangedSubview(node.view)
|
|
315
|
+
node.view.removeFromSuperview()
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
private func forgetHandlers(_ node: RenderedNode) {
|
|
319
|
+
handlers.removeValue(forKey: ObjectIdentifier(node.view))
|
|
320
|
+
for child in node.children { forgetHandlers(child) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private func updateHandler(_ handler: String?, for view: UIView) {
|
|
324
|
+
let id = ObjectIdentifier(view)
|
|
325
|
+
if let handler = handler { handlers[id] = handler }
|
|
326
|
+
else { handlers.removeValue(forKey: id) }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private func updateField(_ field: UITextField, value: String?) {
|
|
330
|
+
guard let value = value, field.text != value, field.markedTextRange == nil else { return }
|
|
331
|
+
let selection = field.selectedTextRange
|
|
332
|
+
let start = selection.map { field.offset(from: field.beginningOfDocument, to: $0.start) }
|
|
333
|
+
let end = selection.map { field.offset(from: field.beginningOfDocument, to: $0.end) }
|
|
334
|
+
field.text = value
|
|
335
|
+
if let start = start, let end = end,
|
|
336
|
+
let from = field.position(from: field.beginningOfDocument, offset: min(start, value.utf16.count)),
|
|
337
|
+
let to = field.position(from: field.beginningOfDocument, offset: min(end, value.utf16.count)) {
|
|
338
|
+
field.selectedTextRange = field.textRange(from: from, to: to)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private func updateDimension(_ value: CGFloat?, constraint: inout NSLayoutConstraint?, anchor: NSLayoutDimension) {
|
|
343
|
+
if let value = value {
|
|
344
|
+
if let constraint = constraint { constraint.constant = value }
|
|
345
|
+
else {
|
|
346
|
+
constraint = anchor.constraint(equalToConstant: value)
|
|
347
|
+
constraint?.isActive = true
|
|
348
|
+
}
|
|
349
|
+
} else {
|
|
350
|
+
constraint?.isActive = false
|
|
351
|
+
constraint = nil
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
@objc private func buttonPressed(_ sender: UIButton) {
|
|
356
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
357
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
@objc private func textChanged(_ sender: UITextField) {
|
|
361
|
+
guard let handler = handlers[ObjectIdentifier(sender)] else { return }
|
|
362
|
+
send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private func send(type: String, payload: [String: Any], correlationId: String? = nil) {
|
|
366
|
+
var message: [String: Any] = ["type": type, "payload": payload]
|
|
367
|
+
if let correlationId = correlationId { message["correlationId"] = correlationId }
|
|
368
|
+
guard let data = try? JSONSerialization.data(withJSONObject: message),
|
|
369
|
+
let json = String(data: data, encoding: .utf8) else { return }
|
|
370
|
+
jsContext.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json])
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func number(_ value: Any?) -> CGFloat? {
|
|
374
|
+
(value as? NSNumber).map { CGFloat(truncating: $0) }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private func color(_ value: Any?) -> UIColor? {
|
|
378
|
+
guard let hex = value as? String else { return nil }
|
|
379
|
+
return UIColor(hex: hex)
|
|
380
|
+
}
|
|
381
|
+
}
|