craft-native 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.
@@ -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
  };