@craft-native/ios 0.0.124 → 0.0.125

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
@@ -64,7 +64,10 @@ Build native iOS apps with web technologies using Craft.
64
64
 
65
65
  The iOS support is built into the main `craft` CLI:
66
66
 
67
- `FlatList` item roots receive the same `onLayout` callback as ordinary native
67
+ `Pressable`, `TouchableOpacity`, and `TouchableHighlight` render as native
68
+ pressable flow containers. `Modal` renders a parent-scoped blocking layer in native mode, with `visible`,
69
+ `transparent`, `animationType`, `onShow`, `onDismiss`, and `onRequestClose`
70
+ support. `FlatList` item roots receive the same `onLayout` callback as ordinary native
68
71
  views when rows are materialized or recycled.
69
72
 
70
73
  ```bash
@@ -109,16 +112,19 @@ In Xcode:
109
112
 
110
113
  ### Experimental WebView-free screen
111
114
 
112
- The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `TextInput`, `ActivityIndicator`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
115
+ Keyboard frame changes update the native controller's additional safe area, so
116
+ focused inputs and responsive `onLayout` measurements move with the keyboard.
117
+
118
+ The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `Link`, `TextInput`, `Picker`, `Switch`, `Slider`, `ActivityIndicator`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
113
119
 
114
120
  ```bash
115
121
  craft ios init NativeDemo --renderer native --output ./ios-native
116
- stx-native compile Screen.stx --format bundle --output ./screen.js
122
+ stx native compile Screen.stx --format bundle --output ./screen.js
117
123
  craft ios build --output ./ios-native --native-bundle ./screen.js
118
124
  craft ios run --output ./ios-native --simulator
119
125
  ```
120
126
 
121
- `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.
127
+ `stx native compile` is part of the [stx](https://github.com/stacksjs/stx) CLI (`@stacksjs/stx`); build tools can call `compileNativeBundle` from `@stacksjs/stx/native` instead. 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.
122
128
 
123
129
  Native screen updates use the versioned mutation protocol when the bundle
124
130
  supports it and fall back to whole-document rendering for older bundles.
@@ -133,7 +139,7 @@ the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
133
139
  `bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
134
140
  simulator UI tests.
135
141
 
136
- 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.
142
+ For multiple native screens, configure named `.stx` routes and an initial screen in `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.
137
143
 
138
144
  The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
139
145
 
package/dist/cli.js CHANGED
@@ -16,6 +16,62 @@ var CRAFT_VERSION = (() => {
16
16
  return "0.0.0";
17
17
  }
18
18
  })();
19
+ function normalizeNativePath(raw) {
20
+ if (typeof raw !== "string")
21
+ return null;
22
+ let path = raw;
23
+ const cut = path.search(/[?#]/);
24
+ if (cut >= 0)
25
+ path = path.slice(0, cut);
26
+ if (!path.startsWith("/"))
27
+ return null;
28
+ path = path.replace(/\/{2,}/g, "/");
29
+ if (path.length > 1 && path.endsWith("/"))
30
+ path = path.slice(0, -1);
31
+ return path;
32
+ }
33
+ function routeSegments(path) {
34
+ return path === "/" ? [] : path.slice(1).split("/");
35
+ }
36
+ function isHybrid(config) {
37
+ return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
38
+ }
39
+ function hybridConfigProblems(config) {
40
+ const problems = [];
41
+ const screens = Object.entries(config.nativeScreens ?? {});
42
+ if (screens.length && config.renderer === "native")
43
+ problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
44
+ for (const [pattern, screen] of screens) {
45
+ if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
46
+ problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
47
+ if (routeSegments(normalizeNativePath(pattern) ?? "/").slice(0, -1).includes("*"))
48
+ problems.push(`nativeScreens path "${pattern}" may only end in *.`);
49
+ if (typeof screen !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(screen))
50
+ problems.push(`nativeScreens screen "${String(screen)}" for "${pattern}" must be a screen name from the native bundle.`);
51
+ }
52
+ const ids = new Set;
53
+ for (const tab of config.tabs ?? []) {
54
+ if (!tab || typeof tab.id !== "string" || !tab.id || typeof tab.title !== "string" || !tab.title)
55
+ problems.push("Each tab needs an id (its root path) and a title.");
56
+ else if (ids.has(tab.id))
57
+ problems.push(`Tab "${tab.id}" is listed twice.`);
58
+ else
59
+ ids.add(tab.id);
60
+ }
61
+ for (const [key, target] of Object.entries(config.shareStorage ?? {})) {
62
+ if (!key || typeof target !== "string" || !target)
63
+ problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
64
+ }
65
+ return problems;
66
+ }
67
+ function installNativeBundle(source, output) {
68
+ if (!existsSync(source))
69
+ throw new Error(`Native bundle not found: ${source}`);
70
+ const destination = join(output, "dist", "native-screen.js");
71
+ mkdirSync(dirname(destination), { recursive: true });
72
+ if (resolve(source) !== resolve(destination))
73
+ cpSync(source, destination);
74
+ }
19
75
  var DEFAULT_CONFIG = {
20
76
  renderer: "web",
21
77
  version: "1.0.0",
@@ -648,6 +704,12 @@ async function init(options) {
648
704
  }
649
705
  if (config.enableBackgroundLocation)
650
706
  config.enableGeolocation = true;
707
+ const problems = hybridConfigProblems(config);
708
+ if (problems.length)
709
+ throw new Error(problems.join(`
710
+ `));
711
+ if (Object.keys(config.shareStorage ?? {}).length)
712
+ config.enableSecureStorage = true;
651
713
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
652
714
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
653
715
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
@@ -657,6 +719,7 @@ async function init(options) {
657
719
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
658
720
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
659
721
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
722
+ cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
660
723
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
661
724
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
662
725
  <true/>
@@ -749,12 +812,18 @@ ${nativeDependencies.join(`
749
812
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
750
813
  }
751
814
  writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
815
+ if (isHybrid(config)) {
816
+ if (config.nativeBundle)
817
+ installNativeBundle(config.nativeBundle, output);
818
+ else
819
+ console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
820
+ }
752
821
  console.log("\u2705 Project initialized");
753
822
  console.log("");
754
823
  console.log("Next steps:");
755
824
  console.log(` 1. cd ${output}`);
756
825
  if (config.renderer === "native") {
757
- console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
826
+ console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
758
827
  console.log(" 3. Run: craft ios build --native-bundle screen.js");
759
828
  } else {
760
829
  console.log(" 2. Add your web content to dist/index.html");
@@ -785,11 +854,15 @@ async function build(options) {
785
854
  cpSync(nativeBundlePath, destination);
786
855
  }
787
856
  if (!existsSync(join(output, "dist", "native-screen.js"))) {
788
- throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx-native first.");
857
+ throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
789
858
  }
790
- } else if (nativeBundlePath) {
791
- throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
859
+ } else if (nativeBundlePath && !isHybrid(config)) {
860
+ throw new Error('--native-bundle requires renderer: "native", or nativeScreens, in craft.config.json.');
792
861
  }
862
+ const problems = hybridConfigProblems(config);
863
+ if (problems.length)
864
+ throw new Error(problems.join(`
865
+ `));
793
866
  if (devServer) {
794
867
  config.devServerURL = devServer;
795
868
  const origin = new URL(devServer).origin;
@@ -801,6 +874,15 @@ async function build(options) {
801
874
  syncWebAssets(htmlPath, output);
802
875
  console.log(` Synced: ${htmlPath} \u2192 dist/`);
803
876
  }
877
+ if (isHybrid(config)) {
878
+ const bundle = nativeBundlePath ?? config.nativeBundle;
879
+ if (bundle) {
880
+ installNativeBundle(bundle, output);
881
+ console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
882
+ } else if (!existsSync(join(output, "dist", "native-screen.js"))) {
883
+ console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
884
+ }
885
+ }
804
886
  await refreshRuntime(output, options.runtimeDir);
805
887
  if (!generateProject)
806
888
  return;
@@ -962,7 +1044,7 @@ Options:
962
1044
  -t, --team-id <id> Apple Developer Team ID
963
1045
  --renderer <kind> iOS renderer: web (default) or native
964
1046
  --html-path <path> Path to HTML file or directory
965
- --native-bundle <path> JavaScript bundle compiled by stx-native
1047
+ --native-bundle <path> JavaScript bundle from \`stx native compile\`
966
1048
  -d, --dev-server <url> Development server URL
967
1049
  -o, --output <dir> Output directory (default: ./ios)
968
1050
  -s, --simulator Run on simulator instead of device
package/dist/index.d.ts CHANGED
@@ -144,7 +144,55 @@ export interface CraftConfig {
144
144
  requestTimeoutSeconds?: number;
145
145
  orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
146
146
  deviceFamilies?: Array<'iphone' | 'ipad'>;
147
+ /**
148
+ * Hybrid: app paths drawn as native stx screens instead of in the
149
+ * web view, each to the screen in the native bundle that draws it, e.g.
150
+ * `{ "/m": "Today", "/m/calendar": "Calendar" }`. A segment may be a
151
+ * parameter (`/m/workout/:id`) and the last one `*`. Needs `renderer: "web"`
152
+ * and `nativeBundle`; every other path stays in the web view, and a path
153
+ * whose screen is missing or throws falls back to it.
154
+ */
155
+ nativeScreens?: Record<string, string>;
156
+ /** Hybrid: the bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
157
+ nativeBundle?: string;
158
+ /**
159
+ * The native tab bar, shown from the first frame, before the page loads.
160
+ * The page's own `tabBar.set` replaces it later. Each `id` is the tab's
161
+ * root path, as the page's tab links name it.
162
+ */
163
+ tabs?: CraftTab[];
164
+ /**
165
+ * Hybrid: web-storage keys the page writes (localStorage or
166
+ * sessionStorage) that native screens need, mirrored into the Keychain
167
+ * under the name given, e.g. `{ "auth_token": "auth.token" }` for
168
+ * `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
169
+ */
170
+ shareStorage?: Record<string, string>;
171
+ }
172
+ export interface CraftTab {
173
+ /** The tab's root path; comes back to the page in `craftTabSelect`. */
174
+ id: string;
175
+ title: string;
176
+ /** An SF Symbol name. */
177
+ symbol?: string;
178
+ }
179
+ /** `/a//b/?q#h` → `/a/b`, the way the shell and the page compare paths. Null for a relative path. */
180
+ export declare function normalizeNativePath(raw: string): string | null;
181
+ export interface NativePathMatch {
182
+ path: string;
183
+ screen: string;
184
+ params: Record<string, string>;
147
185
  }
186
+ /**
187
+ * The native screen for a path, by the rules the shell (`CraftHybridRoutes`)
188
+ * and the page script apply: static segments win over `:params`, which win
189
+ * over a trailing `*`; query items become params under the path's own.
190
+ */
191
+ export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string): NativePathMatch | null;
192
+ /** Whether a config mixes native screens into its web page. */
193
+ export declare function isHybrid(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
194
+ /** What is wrong with a config's hybrid and tab settings, one sentence each. */
195
+ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage'>): string[];
148
196
  export interface CraftBackgroundRefresh {
149
197
  enabled: boolean;
150
198
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
@@ -191,7 +239,7 @@ export interface InitOptions {
191
239
  }
192
240
  export interface BuildOptions {
193
241
  htmlPath?: string;
194
- /** JavaScript bundle emitted by `stx-native compile Screen.stx --format bundle`. */
242
+ /** JavaScript bundle emitted by `stx native compile Screen.stx --format bundle`. */
195
243
  nativeBundlePath?: string;
196
244
  devServer?: string;
197
245
  output: string;
package/dist/index.js CHANGED
@@ -11,6 +11,122 @@ var CRAFT_VERSION = (() => {
11
11
  return "0.0.0";
12
12
  }
13
13
  })();
14
+ function normalizeNativePath(raw) {
15
+ if (typeof raw !== "string")
16
+ return null;
17
+ let path = raw;
18
+ const cut = path.search(/[?#]/);
19
+ if (cut >= 0)
20
+ path = path.slice(0, cut);
21
+ if (!path.startsWith("/"))
22
+ return null;
23
+ path = path.replace(/\/{2,}/g, "/");
24
+ if (path.length > 1 && path.endsWith("/"))
25
+ path = path.slice(0, -1);
26
+ return path;
27
+ }
28
+ function routeSegments(path) {
29
+ return path === "/" ? [] : path.slice(1).split("/");
30
+ }
31
+ function matchNativePath(nativeScreens, raw) {
32
+ const path = normalizeNativePath(raw);
33
+ if (path === null)
34
+ return null;
35
+ const routes = Object.entries(nativeScreens ?? {}).map(([pattern, screen]) => ({ pattern: normalizeNativePath(pattern), screen })).filter((route) => route.pattern !== null && !!route.screen).map((route) => ({ ...route, segments: routeSegments(route.pattern) }));
36
+ const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
37
+ const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
38
+ routes.sort((a, b) => wild(a.segments) - wild(b.segments) || fixed(b.segments) - fixed(a.segments) || b.segments.length - a.segments.length || (a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0));
39
+ const decode = (value) => {
40
+ try {
41
+ return decodeURIComponent(value);
42
+ } catch {
43
+ return value;
44
+ }
45
+ };
46
+ const parts = routeSegments(path);
47
+ for (const route of routes) {
48
+ const params = {};
49
+ let matched = true;
50
+ for (let i = 0;i < route.segments.length; i++) {
51
+ const segment = route.segments[i];
52
+ if (segment === "*") {
53
+ params["*"] = parts.slice(i).join("/");
54
+ break;
55
+ }
56
+ if (i >= parts.length) {
57
+ matched = false;
58
+ break;
59
+ }
60
+ if (segment.startsWith(":")) {
61
+ if (!parts[i]) {
62
+ matched = false;
63
+ break;
64
+ }
65
+ params[segment.slice(1)] = decode(parts[i]);
66
+ } else if (segment !== parts[i]) {
67
+ matched = false;
68
+ break;
69
+ }
70
+ }
71
+ if (!matched)
72
+ continue;
73
+ if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
74
+ continue;
75
+ const query = raw.indexOf("?");
76
+ if (query >= 0) {
77
+ for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
78
+ if (!item)
79
+ continue;
80
+ const [rawKey = "", ...rest] = item.split("=");
81
+ const key = decode(rawKey);
82
+ if (!key || key in params)
83
+ continue;
84
+ params[key] = decode(rest.join("=").replace(/\+/g, " "));
85
+ }
86
+ }
87
+ return { path, screen: route.screen, params };
88
+ }
89
+ return null;
90
+ }
91
+ function isHybrid(config) {
92
+ return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
93
+ }
94
+ function hybridConfigProblems(config) {
95
+ const problems = [];
96
+ const screens = Object.entries(config.nativeScreens ?? {});
97
+ if (screens.length && config.renderer === "native")
98
+ problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
99
+ for (const [pattern, screen] of screens) {
100
+ if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
101
+ problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
102
+ if (routeSegments(normalizeNativePath(pattern) ?? "/").slice(0, -1).includes("*"))
103
+ problems.push(`nativeScreens path "${pattern}" may only end in *.`);
104
+ if (typeof screen !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(screen))
105
+ problems.push(`nativeScreens screen "${String(screen)}" for "${pattern}" must be a screen name from the native bundle.`);
106
+ }
107
+ const ids = new Set;
108
+ for (const tab of config.tabs ?? []) {
109
+ if (!tab || typeof tab.id !== "string" || !tab.id || typeof tab.title !== "string" || !tab.title)
110
+ problems.push("Each tab needs an id (its root path) and a title.");
111
+ else if (ids.has(tab.id))
112
+ problems.push(`Tab "${tab.id}" is listed twice.`);
113
+ else
114
+ ids.add(tab.id);
115
+ }
116
+ for (const [key, target] of Object.entries(config.shareStorage ?? {})) {
117
+ if (!key || typeof target !== "string" || !target)
118
+ problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
119
+ }
120
+ return problems;
121
+ }
122
+ function installNativeBundle(source, output) {
123
+ if (!existsSync(source))
124
+ throw new Error(`Native bundle not found: ${source}`);
125
+ const destination = join(output, "dist", "native-screen.js");
126
+ mkdirSync(dirname(destination), { recursive: true });
127
+ if (resolve(source) !== resolve(destination))
128
+ cpSync(source, destination);
129
+ }
14
130
  var DEFAULT_CONFIG = {
15
131
  renderer: "web",
16
132
  version: "1.0.0",
@@ -643,6 +759,12 @@ async function init(options) {
643
759
  }
644
760
  if (config.enableBackgroundLocation)
645
761
  config.enableGeolocation = true;
762
+ const problems = hybridConfigProblems(config);
763
+ if (problems.length)
764
+ throw new Error(problems.join(`
765
+ `));
766
+ if (Object.keys(config.shareStorage ?? {}).length)
767
+ config.enableSecureStorage = true;
646
768
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
647
769
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
648
770
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
@@ -652,6 +774,7 @@ async function init(options) {
652
774
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
653
775
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
654
776
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
777
+ cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
655
778
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
656
779
  const infoPlist = infoPlistTemplate.replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{UI_STYLE\}\}/g, renderAppearance(config).interfaceStyle).replace(/\{\{STATUS_BAR_STYLE\}\}/g, renderAppearance(config).statusBarStyle).replace(/\{\{ORIENTATIONS\}\}/g, renderOrientations(config)).replace(/\{\{USAGE_DESCRIPTIONS\}\}/g, renderUsageDescriptions(config)).replace(/\{\{URL_TYPES\}\}/g, renderUrlTypes(config)).replace(/\{\{APP_BOUND_DOMAINS\}\}/g, renderAppBoundDomains(config)).replace(/\{\{BACKGROUND_MODES\}\}/g, renderBackgroundModes(config)).replace(/\{\{BACKGROUND_TASK_IDENTIFIERS\}\}/g, renderBackgroundTaskIdentifiers(config)).replace(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
657
780
  <true/>
@@ -744,12 +867,18 @@ ${nativeDependencies.join(`
744
867
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
745
868
  }
746
869
  writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
870
+ if (isHybrid(config)) {
871
+ if (config.nativeBundle)
872
+ installNativeBundle(config.nativeBundle, output);
873
+ else
874
+ console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
875
+ }
747
876
  console.log("\u2705 Project initialized");
748
877
  console.log("");
749
878
  console.log("Next steps:");
750
879
  console.log(` 1. cd ${output}`);
751
880
  if (config.renderer === "native") {
752
- console.log(" 2. Compile your screen: stx-native compile Screen.stx --format bundle --output screen.js");
881
+ console.log(" 2. Compile your screen: stx native compile Screen.stx --format bundle --output screen.js");
753
882
  console.log(" 3. Run: craft ios build --native-bundle screen.js");
754
883
  } else {
755
884
  console.log(" 2. Add your web content to dist/index.html");
@@ -780,11 +909,15 @@ async function build(options) {
780
909
  cpSync(nativeBundlePath, destination);
781
910
  }
782
911
  if (!existsSync(join(output, "dist", "native-screen.js"))) {
783
- throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with stx-native first.");
912
+ throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
784
913
  }
785
- } else if (nativeBundlePath) {
786
- throw new Error('--native-bundle requires renderer: "native" in craft.config.json.');
914
+ } else if (nativeBundlePath && !isHybrid(config)) {
915
+ throw new Error('--native-bundle requires renderer: "native", or nativeScreens, in craft.config.json.');
787
916
  }
917
+ const problems = hybridConfigProblems(config);
918
+ if (problems.length)
919
+ throw new Error(problems.join(`
920
+ `));
788
921
  if (devServer) {
789
922
  config.devServerURL = devServer;
790
923
  const origin = new URL(devServer).origin;
@@ -796,6 +929,15 @@ async function build(options) {
796
929
  syncWebAssets(htmlPath, output);
797
930
  console.log(` Synced: ${htmlPath} \u2192 dist/`);
798
931
  }
932
+ if (isHybrid(config)) {
933
+ const bundle = nativeBundlePath ?? config.nativeBundle;
934
+ if (bundle) {
935
+ installNativeBundle(bundle, output);
936
+ console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
937
+ } else if (!existsSync(join(output, "dist", "native-screen.js"))) {
938
+ console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
939
+ }
940
+ }
799
941
  await refreshRuntime(output, options.runtimeDir);
800
942
  if (!generateProject)
801
943
  return;
@@ -944,8 +1086,12 @@ export {
944
1086
  pickSimulator,
945
1087
  orderSimulators,
946
1088
  open,
1089
+ normalizeNativePath,
1090
+ matchNativePath,
1091
+ isHybrid,
947
1092
  installRuntime,
948
1093
  init,
1094
+ hybridConfigProblems,
949
1095
  build,
950
1096
  bootSimulator,
951
1097
  backgroundRefreshIdentifier,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.124",
3
+ "version": "0.0.125",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",