@craft-native/ios 0.0.123 → 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,6 +64,12 @@ 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
+ `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
71
+ views when rows are materialized or recycled.
72
+
67
73
  ```bash
68
74
  bun add craft-native
69
75
  ```
@@ -106,28 +112,36 @@ In Xcode:
106
112
 
107
113
  ### Experimental WebView-free screen
108
114
 
109
- The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `Text`, `Button`, `TextInput`, `Image`, `ScrollView`, and recycling `FlatList` nodes as UIKit controls. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
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).
110
119
 
111
120
  ```bash
112
121
  craft ios init NativeDemo --renderer native --output ./ios-native
113
- stx-native compile Screen.stx --format bundle --output ./screen.js
122
+ stx native compile Screen.stx --format bundle --output ./screen.js
114
123
  craft ios build --output ./ios-native --native-bundle ./screen.js
115
124
  craft ios run --output ./ios-native --simulator
116
125
  ```
117
126
 
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.
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.
119
128
 
120
129
  Native screen updates use the versioned mutation protocol when the bundle
121
130
  supports it and fall back to whole-document rendering for older bundles.
122
131
  Updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for
123
132
  existing screens); unkeyed children reuse their position. `TextInput` objects
124
133
  therefore keep focus and selection while unrelated labels and buttons change.
134
+ Native `TextInput` also applies `placeholderTextColor`, `selectionColor`,
135
+ positive `maxLength`, `multiline`, and `numberOfLines` values while preserving
136
+ the WebView fallback for routes that do not opt into the renderer.
125
137
  The protocol wire format, atomic validation, revisions, and error codes are in
126
138
  the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
127
139
  `bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
128
140
  simulator UI tests.
129
141
 
130
- 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.
143
+
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.
131
145
 
132
146
  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.
133
147
 
@@ -203,12 +217,64 @@ development environment and Release builds use the production environment.
203
217
  The generated entitlements select the correct value from the active Xcode
204
218
  build configuration automatically.
205
219
 
220
+ ### Launch, offline and the feel of the web view
221
+
222
+ ```json
223
+ {
224
+ "splashMaxSeconds": 3,
225
+ "requestTimeoutSeconds": 10,
226
+ "allowsLinkPreview": false,
227
+ "keyboardAccessory": false,
228
+ "disableZoom": true,
229
+ "associatedDomains": ["hq.training"],
230
+ "backgroundRefresh": { "enabled": true, "minimumIntervalMinutes": 30 }
231
+ }
232
+ ```
233
+
234
+ - `splashMaxSeconds` (default 3): the splash stays until the page calls
235
+ `splash.hide()` or its first contentful paint is on screen, and never longer
236
+ than this.
237
+ - `requestTimeoutSeconds` (default 10): how long the app's own page may take
238
+ before the load counts as unreachable and the bundled page stands in.
239
+ - The bundled page `init` writes (`dist/index.html`, until your web assets
240
+ replace it) is an offline page in the app's colours, light and dark, whose
241
+ Retry loads the remote page again. An app whose page has a service worker
242
+ normally opens from it offline; this shows on a first launch without one or
243
+ after a hard failure. With `appBoundDomains` it is loaded as a page of the
244
+ remote origin, because WebKit refuses to navigate from an app-bound domain
245
+ to `craft://app`.
246
+ - `allowsLinkPreview` (default false): Safari's press-and-hold link previews.
247
+ - `keyboardAccessory` (default false): WebKit's previous/next/Done bar above
248
+ the keyboard. Change it at runtime with `craft.chrome.setKeyboardAccessory`.
249
+ - `disableZoom` (default true): no pinch or double-tap zoom, and the page's
250
+ viewport pinned at scale 1.
251
+ - `associatedDomains`: universal links. A bare host is signed as
252
+ `applinks:<host>`; a universal link reaches the page as `craftDeepLink`, like
253
+ a custom-scheme link.
254
+ - `backgroundRefresh`: a BGAppRefreshTask (`<bundleId>.refresh` unless
255
+ `identifier` is set), declared in `BGTaskSchedulerPermittedIdentifiers` with
256
+ the `fetch` background mode, scheduled no sooner than
257
+ `minimumIntervalMinutes` (default 15). It fires `craftBackgroundRefresh`.
258
+
259
+ The web view also: shows `alert`, `confirm` and `prompt` as system alerts;
260
+ opens `target=_blank` and `window.open` for the app's own origins in place and
261
+ anything else in `SFSafariViewController`; grants `getUserMedia` to the app's
262
+ own origin without WebKit's extra prompt; dismisses the keyboard with a drag;
263
+ allows picture in picture; ends its user agent with `Craft/<version>
264
+ <AppName>/<version>`; brings a page whose content process died back under the
265
+ splash, on the page it was showing, at most three times a minute before
266
+ showing the offline page; and saves its `interactionState` when the app goes to
267
+ the background, so a launch after iOS ended the app returns to the same screen
268
+ (same build only).
269
+
206
270
  ## JavaScript Bridge
207
271
 
208
272
  Once Craft is initialized, the `window.craft` object is available:
209
273
 
210
274
  ```javascript
211
- // Wait for Craft to be ready
275
+ // window.craft is installed at document start, before the page's own scripts,
276
+ // so it can be used at once. craftReady still fires, once, when the document
277
+ // has been parsed, for pages that wait for it.
212
278
  window.addEventListener('craftReady', (e) => {
213
279
  console.log('Platform:', e.detail.platform); // 'ios'
214
280
  console.log('Capabilities:', e.detail.capabilities);
@@ -600,6 +666,64 @@ window.craft.watch.onReachabilityChange((status) => {
600
666
  });
601
667
  ```
602
668
 
669
+ ### Native UI, appearance and background (iOS)
670
+
671
+ Every call below returns a promise that settles and never throws: a failure
672
+ answers with what "nothing happened" means for it. `craft-native/mobile`
673
+ exports the same calls typed (`dialog`, `contextMenu`, `browser`, `symbols`,
674
+ `statusBar`, `chrome`, `refresh`, `background`, `appearance`), resolving
675
+ outside the shell too.
676
+
677
+ ```javascript
678
+ // Haptics by kind, each on UIKit's own generator, kept warm.
679
+ await craft.haptics.impact('rigid'); // light, medium, heavy, soft, rigid
680
+ await craft.haptics.notification('success'); // success, warning, error
681
+ await craft.haptics.selection();
682
+ await craft.haptics.prepare('selection'); // before a gesture that will play one
683
+
684
+ // System dialogs. An anchor is a rect in CSS pixels or an element.
685
+ await craft.dialog.alert({ title: 'Saved' });
686
+ const ok = await craft.dialog.confirm({ title: 'Delete workout?', confirmLabel: 'Delete', destructive: true });
687
+ const choice = await craft.dialog.actionSheet({
688
+ title: 'Workout',
689
+ anchor: button,
690
+ actions: [{ id: 'edit', title: 'Edit' }, { id: 'delete', title: 'Delete', style: 'destructive' }],
691
+ }); // the id, or null when dismissed
692
+
693
+ // The system context menu (iOS 17.4+; the edit menu, then an action sheet, before).
694
+ const picked = await craft.contextMenu.show({
695
+ anchor: row,
696
+ items: [{ id: 'duplicate', title: 'Duplicate', symbol: 'plus.square.on.square' }, { id: 'delete', title: 'Delete', symbol: 'trash', destructive: true }],
697
+ });
698
+
699
+ // SFSafariViewController, or the system web sign-in sheet.
700
+ await craft.browser.open('https://example.com');
701
+ const { url, cancelled } = await craft.browser.open(authorizeURL, { mode: 'auth', callbackScheme: 'hqtraining' });
702
+
703
+ // An SF Symbol as a PNG data URL.
704
+ img.src = await craft.symbols.image('heart.fill', { pointSize: 22, weight: 'semibold', color: '#e11d48' });
705
+
706
+ // Chrome around the page.
707
+ await craft.statusBar.setStyle('light'); // default, light, dark
708
+ await craft.chrome.setUnderPageColor('#020617'); // shown past the page's edges
709
+ await craft.chrome.setKeyboardAccessory(true);
710
+
711
+ // Native pull to refresh.
712
+ await craft.refresh.enable({ tintColor: '#22c55e' });
713
+ craft.refresh.onRefresh(async () => { await reload(); craft.refresh.end(); });
714
+ ```
715
+
716
+ Events on `window`:
717
+
718
+ | Event | Detail | When |
719
+ | --- | --- | --- |
720
+ | `craftAppearance` | `{ contentSizeCategory, fontScale, reduceMotion, reduceTransparency, colorScheme }` | with `craftReady`, and whenever Dynamic Type, Reduce Motion, Reduce Transparency or Light/Dark changes. The root element also carries `--craft-font-scale`, `data-craft-reduce-motion`, `data-craft-reduce-transparency` and `data-craft-content-size` from the first byte; `craft.appearance` reads the current values. |
721
+ | `craftResume` | `{ backgroundedMs }` | the app returned to the foreground |
722
+ | `craftMemoryWarning` | `{}` | iOS is short of memory |
723
+ | `craftSilentPush` | `{ id, payload }` | a `content-available` push; answer with `craft.background.complete(ok, id)` within 25 s |
724
+ | `craftBackgroundRefresh` | `{ id }` | the scheduled refresh ran; answer the same way |
725
+ | `craftRefresh` | `{}` | the native pull-to-refresh was pulled |
726
+
603
727
  ## CLI Reference
604
728
 
605
729
  ```bash
package/dist/cli.js CHANGED
@@ -9,6 +9,69 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statS
9
9
  import { dirname, join, resolve } from "path";
10
10
  var {$ } = globalThis.Bun;
11
11
  var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
12
+ var CRAFT_VERSION = (() => {
13
+ try {
14
+ return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
15
+ } catch {
16
+ return "0.0.0";
17
+ }
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
+ }
12
75
  var DEFAULT_CONFIG = {
13
76
  renderer: "web",
14
77
  version: "1.0.0",
@@ -176,6 +239,8 @@ function plistArray(values, indent = 2) {
176
239
  }
177
240
  function renderBackgroundModes(config) {
178
241
  const modes = new Set;
242
+ if (config.backgroundRefresh?.enabled)
243
+ modes.add("fetch");
179
244
  if (config.enableBackgroundLocation)
180
245
  modes.add("location");
181
246
  if (config.enableBackgroundTasks)
@@ -189,12 +254,30 @@ function renderBackgroundModes(config) {
189
254
  ${plistArray([...modes])}
190
255
  </array>`;
191
256
  }
257
+ function backgroundRefreshIdentifier(config) {
258
+ if (!config.backgroundRefresh?.enabled)
259
+ return null;
260
+ return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
261
+ }
262
+ function renderBackgroundTaskIdentifiers(config) {
263
+ const identifier = backgroundRefreshIdentifier(config);
264
+ if (!identifier)
265
+ return "";
266
+ return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
267
+ <array>
268
+ ${plistArray([identifier])}
269
+ </array>`;
270
+ }
271
+ function associatedDomainEntries(domains) {
272
+ return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
273
+ }
192
274
  function renderEntitlements(config) {
193
275
  const entries = [];
194
- if (config.associatedDomains?.length) {
276
+ const associatedDomains = associatedDomainEntries(config.associatedDomains);
277
+ if (associatedDomains.length) {
195
278
  entries.push(` <key>com.apple.developer.associated-domains</key>
196
279
  <array>
197
- ${plistArray(config.associatedDomains)}
280
+ ${plistArray(associatedDomains)}
198
281
  </array>`);
199
282
  }
200
283
  if (config.appGroups?.length) {
@@ -419,6 +502,102 @@ function renderLaunchScreen(config) {
419
502
  return ` <key>UIColorName</key>
420
503
  <string>LaunchBackground</string>${image}`;
421
504
  }
505
+ function hexChannels(hex) {
506
+ const value = hex?.trim().replace(/^#/, "") ?? "";
507
+ const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
508
+ if (!/^[0-9a-f]{6}$/i.test(full))
509
+ return null;
510
+ return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
511
+ }
512
+ function readableTextOn(background) {
513
+ const channels = hexChannels(background);
514
+ if (!channels)
515
+ return "#0f172a";
516
+ const [r, g, b] = channels.map((channel) => {
517
+ const c = channel / 255;
518
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
519
+ });
520
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
521
+ return luminance > 0.4 ? "#0f172a" : "#f8fafc";
522
+ }
523
+ function renderOfflinePage(config) {
524
+ const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
525
+ const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
526
+ const name = xmlEscape(config.appName);
527
+ return `<!DOCTYPE html>
528
+ <html lang="en">
529
+ <head>
530
+ <meta charset="UTF-8">
531
+ <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
532
+ <meta name="color-scheme" content="light dark">
533
+ <title>${name}</title>
534
+ <style>
535
+ :root { --background: ${light}; --text: ${readableTextOn(light)}; }
536
+ @media (prefers-color-scheme: dark) {
537
+ :root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
538
+ }
539
+ * { margin: 0; padding: 0; box-sizing: border-box; }
540
+ html, body { height: 100%; }
541
+ body {
542
+ font: -apple-system-body;
543
+ font-family: -apple-system, system-ui, sans-serif;
544
+ background: var(--background);
545
+ color: var(--text);
546
+ display: flex;
547
+ align-items: center;
548
+ justify-content: center;
549
+ text-align: center;
550
+ padding: calc(env(safe-area-inset-top) + 24px) calc(env(safe-area-inset-right) + 32px) calc(env(safe-area-inset-bottom) + 24px) calc(env(safe-area-inset-left) + 32px);
551
+ -webkit-user-select: none;
552
+ user-select: none;
553
+ -webkit-tap-highlight-color: transparent;
554
+ }
555
+ main { max-width: 22rem; }
556
+ .app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
557
+ h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
558
+ p { opacity: 0.72; line-height: 1.4; }
559
+ button {
560
+ font: -apple-system-headline;
561
+ margin-top: 1.75rem;
562
+ min-width: 9rem;
563
+ min-height: 44px;
564
+ padding: 0.75rem 1.75rem;
565
+ border: 0;
566
+ border-radius: 999px;
567
+ background: var(--text);
568
+ color: var(--background);
569
+ cursor: pointer;
570
+ }
571
+ button:active { opacity: 0.7; }
572
+ button:disabled { opacity: 0.5; }
573
+ </style>
574
+ </head>
575
+ <body>
576
+ <main>
577
+ <div class="app">${name}</div>
578
+ <h1>You're offline</h1>
579
+ <p>${name} could not reach the internet. Check your connection, then try again.</p>
580
+ <button id="retry" type="button">Retry</button>
581
+ </main>
582
+ <script>
583
+ (function () {
584
+ var button = document.getElementById('retry');
585
+ function retry() {
586
+ button.disabled = true;
587
+ button.textContent = 'Retrying';
588
+ var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
589
+ if (handler) handler.postMessage({ action: 'retryRemote' });
590
+ else location.reload();
591
+ // Still here a while later means the page is still out of reach.
592
+ setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
593
+ }
594
+ button.addEventListener('click', retry);
595
+ })();
596
+ </script>
597
+ </body>
598
+ </html>
599
+ `;
600
+ }
422
601
  function syncWebAssets(source, output) {
423
602
  const sourcePath = resolve(source);
424
603
  if (!existsSync(sourcePath))
@@ -525,16 +704,24 @@ async function init(options) {
525
704
  }
526
705
  if (config.enableBackgroundLocation)
527
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;
528
713
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
529
714
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
530
- const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
715
+ const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
531
716
  writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
532
717
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
533
718
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
534
719
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
535
720
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
721
+ cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
722
+ cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
536
723
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
537
- 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(/\{\{LAUNCH_SCREEN\}\}/g, renderLaunchScreen(config)).replace(/\{\{NON_EXEMPT_ENCRYPTION\}\}/g, config.usesNonExemptEncryption ? "true" : "false").replace(/\{\{LIVE_ACTIVITY_SUPPORT\}\}/g, config.enableLiveActivities ? ` <key>NSSupportsLiveActivities</key>
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>
538
725
  <true/>
539
726
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
540
727
  <true/>` : "");
@@ -624,51 +811,19 @@ ${nativeDependencies.join(`
624
811
  writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
625
812
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
626
813
  }
627
- const placeholderHtml = `<!DOCTYPE html>
628
- <html>
629
- <head>
630
- <meta charset="UTF-8">
631
- <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
632
- <title>${xmlEscape(displayName)}</title>
633
- <style>
634
- * { margin: 0; padding: 0; box-sizing: border-box; }
635
- body {
636
- font-family: -apple-system, system-ui, sans-serif;
637
- background: ${config.backgroundColor};
638
- color: white;
639
- min-height: 100vh;
640
- display: flex;
641
- justify-content: center;
642
- align-items: center;
643
- padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
644
- }
645
- .container { text-align: center; padding: 2rem; }
646
- h1 { font-size: 2.5rem; margin-bottom: 1rem; }
647
- p { opacity: 0.7; }
648
- .ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
649
- </style>
650
- </head>
651
- <body>
652
- <div class="container">
653
- <h1>\u26A1 ${xmlEscape(displayName)}</h1>
654
- <p>Built with Craft iOS</p>
655
- <p class="ready" id="status">Waiting for Craft bridge...</p>
656
- </div>
657
- <script>
658
- window.addEventListener('craftReady', (e) => {
659
- document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
660
- console.log('Craft capabilities:', e.detail.capabilities);
661
- });
662
- </script>
663
- </body>
664
- </html>`;
665
- writeFileSync(join(output, "dist", "index.html"), placeholderHtml);
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
+ }
666
821
  console.log("\u2705 Project initialized");
667
822
  console.log("");
668
823
  console.log("Next steps:");
669
824
  console.log(` 1. cd ${output}`);
670
825
  if (config.renderer === "native") {
671
- 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");
672
827
  console.log(" 3. Run: craft ios build --native-bundle screen.js");
673
828
  } else {
674
829
  console.log(" 2. Add your web content to dist/index.html");
@@ -699,11 +854,15 @@ async function build(options) {
699
854
  cpSync(nativeBundlePath, destination);
700
855
  }
701
856
  if (!existsSync(join(output, "dist", "native-screen.js"))) {
702
- 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.");
703
858
  }
704
- } else if (nativeBundlePath) {
705
- 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.');
706
861
  }
862
+ const problems = hybridConfigProblems(config);
863
+ if (problems.length)
864
+ throw new Error(problems.join(`
865
+ `));
707
866
  if (devServer) {
708
867
  config.devServerURL = devServer;
709
868
  const origin = new URL(devServer).origin;
@@ -715,6 +874,15 @@ async function build(options) {
715
874
  syncWebAssets(htmlPath, output);
716
875
  console.log(` Synced: ${htmlPath} \u2192 dist/`);
717
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
+ }
718
886
  await refreshRuntime(output, options.runtimeDir);
719
887
  if (!generateProject)
720
888
  return;
@@ -876,7 +1044,7 @@ Options:
876
1044
  -t, --team-id <id> Apple Developer Team ID
877
1045
  --renderer <kind> iOS renderer: web (default) or native
878
1046
  --html-path <path> Path to HTML file or directory
879
- --native-bundle <path> JavaScript bundle compiled by stx-native
1047
+ --native-bundle <path> JavaScript bundle from \`stx native compile\`
880
1048
  -d, --dev-server <url> Development server URL
881
1049
  -o, --output <dir> Output directory (default: ./ios)
882
1050
  -s, --simulator Run on simulator instead of device