@craft-native/ios 0.0.123 → 0.0.124

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,9 @@ 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
68
+ views when rows are materialized or recycled.
69
+
67
70
  ```bash
68
71
  bun add craft-native
69
72
  ```
@@ -106,7 +109,7 @@ In Xcode:
106
109
 
107
110
  ### Experimental WebView-free screen
108
111
 
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).
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).
110
113
 
111
114
  ```bash
112
115
  craft ios init NativeDemo --renderer native --output ./ios-native
@@ -122,6 +125,9 @@ supports it and fall back to whole-document rendering for older bundles.
122
125
  Updates reconcile the UIKit tree by sibling-scoped `key` (or `testID` for
123
126
  existing screens); unkeyed children reuse their position. `TextInput` objects
124
127
  therefore keep focus and selection while unrelated labels and buttons change.
128
+ Native `TextInput` also applies `placeholderTextColor`, `selectionColor`,
129
+ positive `maxLength`, `multiline`, and `numberOfLines` values while preserving
130
+ the WebView fallback for routes that do not opt into the renderer.
125
131
  The protocol wire format, atomic validation, revisions, and error codes are in
126
132
  the [Native STX guide](../../docs/guides/native-stx.md). On macOS,
127
133
  `bun packages/ios/scripts/test-native-render.ts` runs the renderer's unit and
@@ -129,6 +135,8 @@ simulator UI tests.
129
135
 
130
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.
131
137
 
138
+ 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
+
132
140
  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
141
 
134
142
  Edit `craft.config.json` in your iOS project:
@@ -203,12 +211,64 @@ development environment and Release builds use the production environment.
203
211
  The generated entitlements select the correct value from the active Xcode
204
212
  build configuration automatically.
205
213
 
214
+ ### Launch, offline and the feel of the web view
215
+
216
+ ```json
217
+ {
218
+ "splashMaxSeconds": 3,
219
+ "requestTimeoutSeconds": 10,
220
+ "allowsLinkPreview": false,
221
+ "keyboardAccessory": false,
222
+ "disableZoom": true,
223
+ "associatedDomains": ["hq.training"],
224
+ "backgroundRefresh": { "enabled": true, "minimumIntervalMinutes": 30 }
225
+ }
226
+ ```
227
+
228
+ - `splashMaxSeconds` (default 3): the splash stays until the page calls
229
+ `splash.hide()` or its first contentful paint is on screen, and never longer
230
+ than this.
231
+ - `requestTimeoutSeconds` (default 10): how long the app's own page may take
232
+ before the load counts as unreachable and the bundled page stands in.
233
+ - The bundled page `init` writes (`dist/index.html`, until your web assets
234
+ replace it) is an offline page in the app's colours, light and dark, whose
235
+ Retry loads the remote page again. An app whose page has a service worker
236
+ normally opens from it offline; this shows on a first launch without one or
237
+ after a hard failure. With `appBoundDomains` it is loaded as a page of the
238
+ remote origin, because WebKit refuses to navigate from an app-bound domain
239
+ to `craft://app`.
240
+ - `allowsLinkPreview` (default false): Safari's press-and-hold link previews.
241
+ - `keyboardAccessory` (default false): WebKit's previous/next/Done bar above
242
+ the keyboard. Change it at runtime with `craft.chrome.setKeyboardAccessory`.
243
+ - `disableZoom` (default true): no pinch or double-tap zoom, and the page's
244
+ viewport pinned at scale 1.
245
+ - `associatedDomains`: universal links. A bare host is signed as
246
+ `applinks:<host>`; a universal link reaches the page as `craftDeepLink`, like
247
+ a custom-scheme link.
248
+ - `backgroundRefresh`: a BGAppRefreshTask (`<bundleId>.refresh` unless
249
+ `identifier` is set), declared in `BGTaskSchedulerPermittedIdentifiers` with
250
+ the `fetch` background mode, scheduled no sooner than
251
+ `minimumIntervalMinutes` (default 15). It fires `craftBackgroundRefresh`.
252
+
253
+ The web view also: shows `alert`, `confirm` and `prompt` as system alerts;
254
+ opens `target=_blank` and `window.open` for the app's own origins in place and
255
+ anything else in `SFSafariViewController`; grants `getUserMedia` to the app's
256
+ own origin without WebKit's extra prompt; dismisses the keyboard with a drag;
257
+ allows picture in picture; ends its user agent with `Craft/<version>
258
+ <AppName>/<version>`; brings a page whose content process died back under the
259
+ splash, on the page it was showing, at most three times a minute before
260
+ showing the offline page; and saves its `interactionState` when the app goes to
261
+ the background, so a launch after iOS ended the app returns to the same screen
262
+ (same build only).
263
+
206
264
  ## JavaScript Bridge
207
265
 
208
266
  Once Craft is initialized, the `window.craft` object is available:
209
267
 
210
268
  ```javascript
211
- // Wait for Craft to be ready
269
+ // window.craft is installed at document start, before the page's own scripts,
270
+ // so it can be used at once. craftReady still fires, once, when the document
271
+ // has been parsed, for pages that wait for it.
212
272
  window.addEventListener('craftReady', (e) => {
213
273
  console.log('Platform:', e.detail.platform); // 'ios'
214
274
  console.log('Capabilities:', e.detail.capabilities);
@@ -600,6 +660,64 @@ window.craft.watch.onReachabilityChange((status) => {
600
660
  });
601
661
  ```
602
662
 
663
+ ### Native UI, appearance and background (iOS)
664
+
665
+ Every call below returns a promise that settles and never throws: a failure
666
+ answers with what "nothing happened" means for it. `craft-native/mobile`
667
+ exports the same calls typed (`dialog`, `contextMenu`, `browser`, `symbols`,
668
+ `statusBar`, `chrome`, `refresh`, `background`, `appearance`), resolving
669
+ outside the shell too.
670
+
671
+ ```javascript
672
+ // Haptics by kind, each on UIKit's own generator, kept warm.
673
+ await craft.haptics.impact('rigid'); // light, medium, heavy, soft, rigid
674
+ await craft.haptics.notification('success'); // success, warning, error
675
+ await craft.haptics.selection();
676
+ await craft.haptics.prepare('selection'); // before a gesture that will play one
677
+
678
+ // System dialogs. An anchor is a rect in CSS pixels or an element.
679
+ await craft.dialog.alert({ title: 'Saved' });
680
+ const ok = await craft.dialog.confirm({ title: 'Delete workout?', confirmLabel: 'Delete', destructive: true });
681
+ const choice = await craft.dialog.actionSheet({
682
+ title: 'Workout',
683
+ anchor: button,
684
+ actions: [{ id: 'edit', title: 'Edit' }, { id: 'delete', title: 'Delete', style: 'destructive' }],
685
+ }); // the id, or null when dismissed
686
+
687
+ // The system context menu (iOS 17.4+; the edit menu, then an action sheet, before).
688
+ const picked = await craft.contextMenu.show({
689
+ anchor: row,
690
+ items: [{ id: 'duplicate', title: 'Duplicate', symbol: 'plus.square.on.square' }, { id: 'delete', title: 'Delete', symbol: 'trash', destructive: true }],
691
+ });
692
+
693
+ // SFSafariViewController, or the system web sign-in sheet.
694
+ await craft.browser.open('https://example.com');
695
+ const { url, cancelled } = await craft.browser.open(authorizeURL, { mode: 'auth', callbackScheme: 'hqtraining' });
696
+
697
+ // An SF Symbol as a PNG data URL.
698
+ img.src = await craft.symbols.image('heart.fill', { pointSize: 22, weight: 'semibold', color: '#e11d48' });
699
+
700
+ // Chrome around the page.
701
+ await craft.statusBar.setStyle('light'); // default, light, dark
702
+ await craft.chrome.setUnderPageColor('#020617'); // shown past the page's edges
703
+ await craft.chrome.setKeyboardAccessory(true);
704
+
705
+ // Native pull to refresh.
706
+ await craft.refresh.enable({ tintColor: '#22c55e' });
707
+ craft.refresh.onRefresh(async () => { await reload(); craft.refresh.end(); });
708
+ ```
709
+
710
+ Events on `window`:
711
+
712
+ | Event | Detail | When |
713
+ | --- | --- | --- |
714
+ | `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. |
715
+ | `craftResume` | `{ backgroundedMs }` | the app returned to the foreground |
716
+ | `craftMemoryWarning` | `{}` | iOS is short of memory |
717
+ | `craftSilentPush` | `{ id, payload }` | a `content-available` push; answer with `craft.background.complete(ok, id)` within 25 s |
718
+ | `craftBackgroundRefresh` | `{ id }` | the scheduled refresh ran; answer the same way |
719
+ | `craftRefresh` | `{}` | the native pull-to-refresh was pulled |
720
+
603
721
  ## CLI Reference
604
722
 
605
723
  ```bash
package/dist/cli.js CHANGED
@@ -9,6 +9,13 @@ 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
+ })();
12
19
  var DEFAULT_CONFIG = {
13
20
  renderer: "web",
14
21
  version: "1.0.0",
@@ -176,6 +183,8 @@ function plistArray(values, indent = 2) {
176
183
  }
177
184
  function renderBackgroundModes(config) {
178
185
  const modes = new Set;
186
+ if (config.backgroundRefresh?.enabled)
187
+ modes.add("fetch");
179
188
  if (config.enableBackgroundLocation)
180
189
  modes.add("location");
181
190
  if (config.enableBackgroundTasks)
@@ -189,12 +198,30 @@ function renderBackgroundModes(config) {
189
198
  ${plistArray([...modes])}
190
199
  </array>`;
191
200
  }
201
+ function backgroundRefreshIdentifier(config) {
202
+ if (!config.backgroundRefresh?.enabled)
203
+ return null;
204
+ return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
205
+ }
206
+ function renderBackgroundTaskIdentifiers(config) {
207
+ const identifier = backgroundRefreshIdentifier(config);
208
+ if (!identifier)
209
+ return "";
210
+ return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
211
+ <array>
212
+ ${plistArray([identifier])}
213
+ </array>`;
214
+ }
215
+ function associatedDomainEntries(domains) {
216
+ return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
217
+ }
192
218
  function renderEntitlements(config) {
193
219
  const entries = [];
194
- if (config.associatedDomains?.length) {
220
+ const associatedDomains = associatedDomainEntries(config.associatedDomains);
221
+ if (associatedDomains.length) {
195
222
  entries.push(` <key>com.apple.developer.associated-domains</key>
196
223
  <array>
197
- ${plistArray(config.associatedDomains)}
224
+ ${plistArray(associatedDomains)}
198
225
  </array>`);
199
226
  }
200
227
  if (config.appGroups?.length) {
@@ -419,6 +446,102 @@ function renderLaunchScreen(config) {
419
446
  return ` <key>UIColorName</key>
420
447
  <string>LaunchBackground</string>${image}`;
421
448
  }
449
+ function hexChannels(hex) {
450
+ const value = hex?.trim().replace(/^#/, "") ?? "";
451
+ const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
452
+ if (!/^[0-9a-f]{6}$/i.test(full))
453
+ return null;
454
+ return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
455
+ }
456
+ function readableTextOn(background) {
457
+ const channels = hexChannels(background);
458
+ if (!channels)
459
+ return "#0f172a";
460
+ const [r, g, b] = channels.map((channel) => {
461
+ const c = channel / 255;
462
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
463
+ });
464
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
465
+ return luminance > 0.4 ? "#0f172a" : "#f8fafc";
466
+ }
467
+ function renderOfflinePage(config) {
468
+ const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
469
+ const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
470
+ const name = xmlEscape(config.appName);
471
+ return `<!DOCTYPE html>
472
+ <html lang="en">
473
+ <head>
474
+ <meta charset="UTF-8">
475
+ <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
476
+ <meta name="color-scheme" content="light dark">
477
+ <title>${name}</title>
478
+ <style>
479
+ :root { --background: ${light}; --text: ${readableTextOn(light)}; }
480
+ @media (prefers-color-scheme: dark) {
481
+ :root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
482
+ }
483
+ * { margin: 0; padding: 0; box-sizing: border-box; }
484
+ html, body { height: 100%; }
485
+ body {
486
+ font: -apple-system-body;
487
+ font-family: -apple-system, system-ui, sans-serif;
488
+ background: var(--background);
489
+ color: var(--text);
490
+ display: flex;
491
+ align-items: center;
492
+ justify-content: center;
493
+ text-align: center;
494
+ 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);
495
+ -webkit-user-select: none;
496
+ user-select: none;
497
+ -webkit-tap-highlight-color: transparent;
498
+ }
499
+ main { max-width: 22rem; }
500
+ .app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
501
+ h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
502
+ p { opacity: 0.72; line-height: 1.4; }
503
+ button {
504
+ font: -apple-system-headline;
505
+ margin-top: 1.75rem;
506
+ min-width: 9rem;
507
+ min-height: 44px;
508
+ padding: 0.75rem 1.75rem;
509
+ border: 0;
510
+ border-radius: 999px;
511
+ background: var(--text);
512
+ color: var(--background);
513
+ cursor: pointer;
514
+ }
515
+ button:active { opacity: 0.7; }
516
+ button:disabled { opacity: 0.5; }
517
+ </style>
518
+ </head>
519
+ <body>
520
+ <main>
521
+ <div class="app">${name}</div>
522
+ <h1>You're offline</h1>
523
+ <p>${name} could not reach the internet. Check your connection, then try again.</p>
524
+ <button id="retry" type="button">Retry</button>
525
+ </main>
526
+ <script>
527
+ (function () {
528
+ var button = document.getElementById('retry');
529
+ function retry() {
530
+ button.disabled = true;
531
+ button.textContent = 'Retrying';
532
+ var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
533
+ if (handler) handler.postMessage({ action: 'retryRemote' });
534
+ else location.reload();
535
+ // Still here a while later means the page is still out of reach.
536
+ setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
537
+ }
538
+ button.addEventListener('click', retry);
539
+ })();
540
+ </script>
541
+ </body>
542
+ </html>
543
+ `;
544
+ }
422
545
  function syncWebAssets(source, output) {
423
546
  const sourcePath = resolve(source);
424
547
  if (!existsSync(sourcePath))
@@ -527,14 +650,15 @@ async function init(options) {
527
650
  config.enableGeolocation = true;
528
651
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
529
652
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
530
- const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
653
+ const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
531
654
  writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
532
655
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
533
656
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
534
657
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
535
658
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
659
+ cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
536
660
  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>
661
+ 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
662
  <true/>
539
663
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
540
664
  <true/>` : "");
@@ -624,45 +748,7 @@ ${nativeDependencies.join(`
624
748
  writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
625
749
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
626
750
  }
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);
751
+ writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
666
752
  console.log("\u2705 Project initialized");
667
753
  console.log("");
668
754
  console.log("Next steps:");
package/dist/index.d.ts CHANGED
@@ -78,11 +78,30 @@ export interface CraftConfig {
78
78
  /**
79
79
  * Also refuse to load anything but the app-bound domains in the web view
80
80
  * (WKWebViewConfiguration.limitsNavigationsToAppBoundDomains). Off by
81
- * default: links elsewhere already open in Safari, and embedded players and
82
- * maps in iframes keep loading.
81
+ * default: links elsewhere already open in Safari. WebKit applies the limit
82
+ * to the main frame only, so embedded players and maps in cross-origin
83
+ * iframes (YouTube, Vimeo) keep loading with it on (checked in the iOS 26
84
+ * simulator); WebKit only declines to inject user scripts into them, which
85
+ * the bridge never does.
83
86
  */
84
87
  limitNavigationsToAppBoundDomains?: boolean;
88
+ /**
89
+ * Universal links and shared web credentials
90
+ * (`com.apple.developer.associated-domains`). A bare host such as
91
+ * `hq.training` is written as `applinks:hq.training`; an entry with its own
92
+ * service (`webcredentials:`, `applinks:…?mode=developer`) is kept as given.
93
+ * A universal link reaches the page the way a custom-scheme link does, as
94
+ * `craftDeepLink`.
95
+ */
85
96
  associatedDomains?: string[];
97
+ /**
98
+ * iOS's scheduled background refresh (BGAppRefreshTask). When enabled the
99
+ * page gets `craftBackgroundRefresh` and answers with
100
+ * `craft.background.complete(ok)`. The identifier defaults to
101
+ * `<bundleId>.refresh` and is declared in BGTaskSchedulerPermittedIdentifiers,
102
+ * with the `fetch` background mode.
103
+ */
104
+ backgroundRefresh?: CraftBackgroundRefresh;
86
105
  appGroups?: string[];
87
106
  appIconPath?: string;
88
107
  /**
@@ -103,9 +122,36 @@ export interface CraftConfig {
103
122
  */
104
123
  usesNonExemptEncryption?: boolean;
105
124
  privacy?: CraftPrivacyManifest;
125
+ /** Press-and-hold link previews, as in Safari. Default false. */
126
+ allowsLinkPreview?: boolean;
127
+ /**
128
+ * WebKit's bar above the keyboard for a form field (previous, next, Done),
129
+ * which no native app's keyboard has. Default false: hidden. A page can
130
+ * change it with `craft.chrome.setKeyboardAccessory()`.
131
+ */
132
+ keyboardAccessory?: boolean;
133
+ /** Pinch and double-tap zoom off, and the page's viewport pinned at scale 1. Default true. */
134
+ disableZoom?: boolean;
135
+ /**
136
+ * The longest the launch splash stays up, in seconds, when the page neither
137
+ * hides it (`splash.hide()`) nor paints. Default 3.
138
+ */
139
+ splashMaxSeconds?: number;
140
+ /**
141
+ * How long the app's own page may take to answer, in seconds, before the
142
+ * load counts as unreachable and the bundled page stands in. Default 10.
143
+ */
144
+ requestTimeoutSeconds?: number;
106
145
  orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
107
146
  deviceFamilies?: Array<'iphone' | 'ipad'>;
108
147
  }
148
+ export interface CraftBackgroundRefresh {
149
+ enabled: boolean;
150
+ /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
151
+ identifier?: string;
152
+ /** The soonest iOS may run it again, in minutes. Default 15. */
153
+ minimumIntervalMinutes?: number;
154
+ }
109
155
  export interface CraftPrivacyDataType {
110
156
  type: string;
111
157
  linked?: boolean;
@@ -185,6 +231,16 @@ export declare function renderOrientations(config: CraftConfig): string;
185
231
  export declare function renderAppBoundDomains(config: CraftConfig): string;
186
232
  export declare function renderUrlTypes(config: CraftConfig): string;
187
233
  export declare function renderBackgroundModes(config: CraftConfig): string;
234
+ /** The background refresh task's identifier, or null when it is off. */
235
+ export declare function backgroundRefreshIdentifier(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string | null;
236
+ /** BGTaskSchedulerPermittedIdentifiers: iOS runs no task it does not list. */
237
+ export declare function renderBackgroundTaskIdentifiers(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string;
238
+ /**
239
+ * The associated-domains entitlement's entries. Each needs a service prefix,
240
+ * and a bare host (the easy thing to write) used to be signed as it was and
241
+ * then matched nothing, so universal links silently never opened the app.
242
+ */
243
+ export declare function associatedDomainEntries(domains: string[] | undefined): string[];
188
244
  export declare function renderEntitlements(config: CraftConfig): string;
189
245
  export declare function renderWatchEntitlements(config: CraftConfig): string;
190
246
  /**
@@ -219,6 +275,24 @@ export declare function pngHasAlpha(bytes: Uint8Array): boolean;
219
275
  export declare const LAUNCH_LOGO = "LaunchLogo";
220
276
  /** The launch screen: the background colour, and the splash logo on it when there is one. */
221
277
  export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
278
+ /** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
279
+ export declare function readableTextOn(background: string | undefined): string;
280
+ /**
281
+ * The page the app shows when its own page cannot be reached: the generated
282
+ * `dist/index.html` until the app's web assets replace it.
283
+ *
284
+ * An app whose page has a service worker normally opens from it offline, so
285
+ * this only shows on a first launch without one, or when the remote fails
286
+ * hard. It used to be a placeholder that said "Built with Craft iOS" in white
287
+ * on whatever the background was (invisible on a light one) with a bridge
288
+ * status line under it: debug output, in front of the person using the app.
289
+ *
290
+ * Now it says what happened in the app's own colours, light and dark, sized
291
+ * with Dynamic Type, and Retry asks the shell to load the app's page again.
292
+ * The shell also goes back to the app's page by itself when the connection
293
+ * returns, so the page does not retry on `online` as well.
294
+ */
295
+ export declare function renderOfflinePage(config: Pick<CraftConfig, 'appName' | 'backgroundColor' | 'backgroundColorDark'>): string;
222
296
  /** Replace the bundled web application atomically so removed assets cannot linger. */
223
297
  export declare function syncWebAssets(source: string, output: string): void;
224
298
  /**