@craft-native/ios 0.0.105 → 0.0.109

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 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', 'public.pdf']);
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(base64Data, 'image.png', 'image/png');
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
- console.log(" 2. Add your web content to dist/index.html");
542
- console.log(" 3. Run: craft ios build");
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
- console.log(" 2. Add your web content to dist/index.html");
537
- console.log(" 3. Run: craft ios build");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.105",
3
+ "version": "0.0.109",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -221,8 +221,14 @@ struct CraftApp: App {
221
221
 
222
222
  var body: some SwiftUI.Scene {
223
223
  WindowGroup {
224
- CraftWebView(config: appState.config)
225
- .ignoresSafeArea()
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: &current.widthConstraint, anchor: result.widthAnchor)
209
+ updateDimension(number(style["height"]), constraint: &current.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
+ }