craft-native 0.0.105 → 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/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.105";
313
+ var version = "0.0.107";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.105";
61
+ var version = "0.0.107";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -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;
@@ -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;
@@ -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
+ }