@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/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,8 +122,83 @@ 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'>;
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>;
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[];
196
+ export interface CraftBackgroundRefresh {
197
+ enabled: boolean;
198
+ /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
199
+ identifier?: string;
200
+ /** The soonest iOS may run it again, in minutes. Default 15. */
201
+ minimumIntervalMinutes?: number;
108
202
  }
109
203
  export interface CraftPrivacyDataType {
110
204
  type: string;
@@ -145,7 +239,7 @@ export interface InitOptions {
145
239
  }
146
240
  export interface BuildOptions {
147
241
  htmlPath?: string;
148
- /** JavaScript bundle emitted by `stx-native compile Screen.stx --format bundle`. */
242
+ /** JavaScript bundle emitted by `stx native compile Screen.stx --format bundle`. */
149
243
  nativeBundlePath?: string;
150
244
  devServer?: string;
151
245
  output: string;
@@ -185,6 +279,16 @@ export declare function renderOrientations(config: CraftConfig): string;
185
279
  export declare function renderAppBoundDomains(config: CraftConfig): string;
186
280
  export declare function renderUrlTypes(config: CraftConfig): string;
187
281
  export declare function renderBackgroundModes(config: CraftConfig): string;
282
+ /** The background refresh task's identifier, or null when it is off. */
283
+ export declare function backgroundRefreshIdentifier(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string | null;
284
+ /** BGTaskSchedulerPermittedIdentifiers: iOS runs no task it does not list. */
285
+ export declare function renderBackgroundTaskIdentifiers(config: Pick<CraftConfig, 'backgroundRefresh' | 'bundleId'>): string;
286
+ /**
287
+ * The associated-domains entitlement's entries. Each needs a service prefix,
288
+ * and a bare host (the easy thing to write) used to be signed as it was and
289
+ * then matched nothing, so universal links silently never opened the app.
290
+ */
291
+ export declare function associatedDomainEntries(domains: string[] | undefined): string[];
188
292
  export declare function renderEntitlements(config: CraftConfig): string;
189
293
  export declare function renderWatchEntitlements(config: CraftConfig): string;
190
294
  /**
@@ -219,6 +323,24 @@ export declare function pngHasAlpha(bytes: Uint8Array): boolean;
219
323
  export declare const LAUNCH_LOGO = "LaunchLogo";
220
324
  /** The launch screen: the background colour, and the splash logo on it when there is one. */
221
325
  export declare function renderLaunchScreen(config: Pick<CraftConfig, 'splashImagePath'>): string;
326
+ /** Near-black or near-white, whichever reads on `background` (WCAG relative luminance). */
327
+ export declare function readableTextOn(background: string | undefined): string;
328
+ /**
329
+ * The page the app shows when its own page cannot be reached: the generated
330
+ * `dist/index.html` until the app's web assets replace it.
331
+ *
332
+ * An app whose page has a service worker normally opens from it offline, so
333
+ * this only shows on a first launch without one, or when the remote fails
334
+ * hard. It used to be a placeholder that said "Built with Craft iOS" in white
335
+ * on whatever the background was (invisible on a light one) with a bridge
336
+ * status line under it: debug output, in front of the person using the app.
337
+ *
338
+ * Now it says what happened in the app's own colours, light and dark, sized
339
+ * with Dynamic Type, and Retry asks the shell to load the app's page again.
340
+ * The shell also goes back to the app's page by itself when the connection
341
+ * returns, so the page does not retry on `online` as well.
342
+ */
343
+ export declare function renderOfflinePage(config: Pick<CraftConfig, 'appName' | 'backgroundColor' | 'backgroundColorDark'>): string;
222
344
  /** Replace the bundled web application atomically so removed assets cannot linger. */
223
345
  export declare function syncWebAssets(source: string, output: string): void;
224
346
  /**
package/dist/index.js CHANGED
@@ -4,6 +4,129 @@ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statS
4
4
  import { dirname, join, resolve } from "path";
5
5
  var {$ } = globalThis.Bun;
6
6
  var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
7
+ var CRAFT_VERSION = (() => {
8
+ try {
9
+ return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
10
+ } catch {
11
+ return "0.0.0";
12
+ }
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
+ }
7
130
  var DEFAULT_CONFIG = {
8
131
  renderer: "web",
9
132
  version: "1.0.0",
@@ -171,6 +294,8 @@ function plistArray(values, indent = 2) {
171
294
  }
172
295
  function renderBackgroundModes(config) {
173
296
  const modes = new Set;
297
+ if (config.backgroundRefresh?.enabled)
298
+ modes.add("fetch");
174
299
  if (config.enableBackgroundLocation)
175
300
  modes.add("location");
176
301
  if (config.enableBackgroundTasks)
@@ -184,12 +309,30 @@ function renderBackgroundModes(config) {
184
309
  ${plistArray([...modes])}
185
310
  </array>`;
186
311
  }
312
+ function backgroundRefreshIdentifier(config) {
313
+ if (!config.backgroundRefresh?.enabled)
314
+ return null;
315
+ return config.backgroundRefresh.identifier?.trim() || `${config.bundleId}.refresh`;
316
+ }
317
+ function renderBackgroundTaskIdentifiers(config) {
318
+ const identifier = backgroundRefreshIdentifier(config);
319
+ if (!identifier)
320
+ return "";
321
+ return ` <key>BGTaskSchedulerPermittedIdentifiers</key>
322
+ <array>
323
+ ${plistArray([identifier])}
324
+ </array>`;
325
+ }
326
+ function associatedDomainEntries(domains) {
327
+ return [...new Set((domains ?? []).map((value) => value.trim()).filter(Boolean).map((value) => /^[a-z]+:/i.test(value) ? value : `applinks:${value}`))];
328
+ }
187
329
  function renderEntitlements(config) {
188
330
  const entries = [];
189
- if (config.associatedDomains?.length) {
331
+ const associatedDomains = associatedDomainEntries(config.associatedDomains);
332
+ if (associatedDomains.length) {
190
333
  entries.push(` <key>com.apple.developer.associated-domains</key>
191
334
  <array>
192
- ${plistArray(config.associatedDomains)}
335
+ ${plistArray(associatedDomains)}
193
336
  </array>`);
194
337
  }
195
338
  if (config.appGroups?.length) {
@@ -414,6 +557,102 @@ function renderLaunchScreen(config) {
414
557
  return ` <key>UIColorName</key>
415
558
  <string>LaunchBackground</string>${image}`;
416
559
  }
560
+ function hexChannels(hex) {
561
+ const value = hex?.trim().replace(/^#/, "") ?? "";
562
+ const full = value.length === 3 ? [...value].map((digit) => digit + digit).join("") : value.slice(0, 6);
563
+ if (!/^[0-9a-f]{6}$/i.test(full))
564
+ return null;
565
+ return [0, 2, 4].map((index) => Number.parseInt(full.slice(index, index + 2), 16));
566
+ }
567
+ function readableTextOn(background) {
568
+ const channels = hexChannels(background);
569
+ if (!channels)
570
+ return "#0f172a";
571
+ const [r, g, b] = channels.map((channel) => {
572
+ const c = channel / 255;
573
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
574
+ });
575
+ const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
576
+ return luminance > 0.4 ? "#0f172a" : "#f8fafc";
577
+ }
578
+ function renderOfflinePage(config) {
579
+ const light = hexChannels(config.backgroundColor) ? config.backgroundColor : "#ffffff";
580
+ const dark = hexChannels(config.backgroundColorDark) ? config.backgroundColorDark : light;
581
+ const name = xmlEscape(config.appName);
582
+ return `<!DOCTYPE html>
583
+ <html lang="en">
584
+ <head>
585
+ <meta charset="UTF-8">
586
+ <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
587
+ <meta name="color-scheme" content="light dark">
588
+ <title>${name}</title>
589
+ <style>
590
+ :root { --background: ${light}; --text: ${readableTextOn(light)}; }
591
+ @media (prefers-color-scheme: dark) {
592
+ :root { --background: ${dark}; --text: ${readableTextOn(dark)}; }
593
+ }
594
+ * { margin: 0; padding: 0; box-sizing: border-box; }
595
+ html, body { height: 100%; }
596
+ body {
597
+ font: -apple-system-body;
598
+ font-family: -apple-system, system-ui, sans-serif;
599
+ background: var(--background);
600
+ color: var(--text);
601
+ display: flex;
602
+ align-items: center;
603
+ justify-content: center;
604
+ text-align: center;
605
+ 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);
606
+ -webkit-user-select: none;
607
+ user-select: none;
608
+ -webkit-tap-highlight-color: transparent;
609
+ }
610
+ main { max-width: 22rem; }
611
+ .app { font: -apple-system-subheadline; font-weight: 600; opacity: 0.6; margin-bottom: 0.5rem; }
612
+ h1 { font: -apple-system-title1; font-weight: 700; margin-bottom: 0.75rem; }
613
+ p { opacity: 0.72; line-height: 1.4; }
614
+ button {
615
+ font: -apple-system-headline;
616
+ margin-top: 1.75rem;
617
+ min-width: 9rem;
618
+ min-height: 44px;
619
+ padding: 0.75rem 1.75rem;
620
+ border: 0;
621
+ border-radius: 999px;
622
+ background: var(--text);
623
+ color: var(--background);
624
+ cursor: pointer;
625
+ }
626
+ button:active { opacity: 0.7; }
627
+ button:disabled { opacity: 0.5; }
628
+ </style>
629
+ </head>
630
+ <body>
631
+ <main>
632
+ <div class="app">${name}</div>
633
+ <h1>You're offline</h1>
634
+ <p>${name} could not reach the internet. Check your connection, then try again.</p>
635
+ <button id="retry" type="button">Retry</button>
636
+ </main>
637
+ <script>
638
+ (function () {
639
+ var button = document.getElementById('retry');
640
+ function retry() {
641
+ button.disabled = true;
642
+ button.textContent = 'Retrying';
643
+ var handler = window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.craft;
644
+ if (handler) handler.postMessage({ action: 'retryRemote' });
645
+ else location.reload();
646
+ // Still here a while later means the page is still out of reach.
647
+ setTimeout(function () { button.disabled = false; button.textContent = 'Retry'; }, 12000);
648
+ }
649
+ button.addEventListener('click', retry);
650
+ })();
651
+ </script>
652
+ </body>
653
+ </html>
654
+ `;
655
+ }
417
656
  function syncWebAssets(source, output) {
418
657
  const sourcePath = resolve(source);
419
658
  if (!existsSync(sourcePath))
@@ -520,16 +759,24 @@ async function init(options) {
520
759
  }
521
760
  if (config.enableBackgroundLocation)
522
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;
523
768
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
524
769
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
525
- const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
770
+ const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
526
771
  writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
527
772
  cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
528
773
  cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
529
774
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
530
775
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
776
+ cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
777
+ cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
531
778
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
532
- 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>
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>
533
780
  <true/>
534
781
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
535
782
  <true/>` : "");
@@ -619,51 +866,19 @@ ${nativeDependencies.join(`
619
866
  writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
620
867
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
621
868
  }
622
- const placeholderHtml = `<!DOCTYPE html>
623
- <html>
624
- <head>
625
- <meta charset="UTF-8">
626
- <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
627
- <title>${xmlEscape(displayName)}</title>
628
- <style>
629
- * { margin: 0; padding: 0; box-sizing: border-box; }
630
- body {
631
- font-family: -apple-system, system-ui, sans-serif;
632
- background: ${config.backgroundColor};
633
- color: white;
634
- min-height: 100vh;
635
- display: flex;
636
- justify-content: center;
637
- align-items: center;
638
- padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
639
- }
640
- .container { text-align: center; padding: 2rem; }
641
- h1 { font-size: 2.5rem; margin-bottom: 1rem; }
642
- p { opacity: 0.7; }
643
- .ready { color: #4ade80; font-size: 0.9rem; margin-top: 2rem; }
644
- </style>
645
- </head>
646
- <body>
647
- <div class="container">
648
- <h1>\u26A1 ${xmlEscape(displayName)}</h1>
649
- <p>Built with Craft iOS</p>
650
- <p class="ready" id="status">Waiting for Craft bridge...</p>
651
- </div>
652
- <script>
653
- window.addEventListener('craftReady', (e) => {
654
- document.getElementById('status').textContent = \`\u2713 Craft bridge ready (platform: \${e.detail.platform})\`;
655
- console.log('Craft capabilities:', e.detail.capabilities);
656
- });
657
- </script>
658
- </body>
659
- </html>`;
660
- writeFileSync(join(output, "dist", "index.html"), placeholderHtml);
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
+ }
661
876
  console.log("\u2705 Project initialized");
662
877
  console.log("");
663
878
  console.log("Next steps:");
664
879
  console.log(` 1. cd ${output}`);
665
880
  if (config.renderer === "native") {
666
- 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");
667
882
  console.log(" 3. Run: craft ios build --native-bundle screen.js");
668
883
  } else {
669
884
  console.log(" 2. Add your web content to dist/index.html");
@@ -694,11 +909,15 @@ async function build(options) {
694
909
  cpSync(nativeBundlePath, destination);
695
910
  }
696
911
  if (!existsSync(join(output, "dist", "native-screen.js"))) {
697
- 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.");
698
913
  }
699
- } else if (nativeBundlePath) {
700
- 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.');
701
916
  }
917
+ const problems = hybridConfigProblems(config);
918
+ if (problems.length)
919
+ throw new Error(problems.join(`
920
+ `));
702
921
  if (devServer) {
703
922
  config.devServerURL = devServer;
704
923
  const origin = new URL(devServer).origin;
@@ -710,6 +929,15 @@ async function build(options) {
710
929
  syncWebAssets(htmlPath, output);
711
930
  console.log(` Synced: ${htmlPath} \u2192 dist/`);
712
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
+ }
713
941
  await refreshRuntime(output, options.runtimeDir);
714
942
  if (!generateProject)
715
943
  return;
@@ -844,20 +1072,29 @@ export {
844
1072
  renderRuntimeSettings,
845
1073
  renderPrivacyManifest,
846
1074
  renderOrientations,
1075
+ renderOfflinePage,
847
1076
  renderLaunchScreen,
848
1077
  renderEntitlements,
849
1078
  renderDeviceFamilies,
1079
+ renderBackgroundTaskIdentifiers,
850
1080
  renderBackgroundModes,
851
1081
  renderAppearance,
852
1082
  renderAppBoundDomains,
1083
+ readableTextOn,
853
1084
  productName,
854
1085
  pngHasAlpha,
855
1086
  pickSimulator,
856
1087
  orderSimulators,
857
1088
  open,
1089
+ normalizeNativePath,
1090
+ matchNativePath,
1091
+ isHybrid,
858
1092
  installRuntime,
859
1093
  init,
1094
+ hybridConfigProblems,
860
1095
  build,
861
1096
  bootSimulator,
1097
+ backgroundRefreshIdentifier,
1098
+ associatedDomainEntries,
862
1099
  LAUNCH_LOGO
863
1100
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.123",
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",