@craft-native/ios 0.0.154 → 0.0.156

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/cli.js CHANGED
@@ -6,12 +6,18 @@ import { parseArgs } from "util";
6
6
 
7
7
  // src/index.ts
8
8
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs";
9
- import { dirname, join, resolve } from "path";
9
+ import { dirname as dirname2, join as join2, resolve } from "path";
10
10
  var {$ } = globalThis.Bun;
11
- var TEMPLATES_DIR = join(dirname(import.meta.dir), "templates");
11
+
12
+ // src/testing.ts
13
+ import { dirname, join } from "path";
14
+ var templates = join(dirname(import.meta.dir), "templates/testing");
15
+
16
+ // src/index.ts
17
+ var TEMPLATES_DIR = join2(dirname2(import.meta.dir), "templates");
12
18
  var CRAFT_VERSION = (() => {
13
19
  try {
14
- return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
20
+ return JSON.parse(readFileSync(join2(dirname2(import.meta.dir), "package.json"), "utf8")).version ?? "0.0.0";
15
21
  } catch {
16
22
  return "0.0.0";
17
23
  }
@@ -33,14 +39,88 @@ function normalizeNativePath(raw) {
33
39
  function routeSegments(path) {
34
40
  return path === "/" ? [] : path.slice(1).split("/");
35
41
  }
42
+ function matchNativePath(nativeScreens, raw) {
43
+ const path = normalizeNativePath(raw);
44
+ if (path === null)
45
+ return null;
46
+ 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) }));
47
+ const wild = (segments) => segments.at(-1) === "*" ? 1 : 0;
48
+ const fixed = (segments) => segments.filter((s) => s !== "*" && !s.startsWith(":")).length;
49
+ 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));
50
+ const decode = (value) => {
51
+ try {
52
+ return decodeURIComponent(value);
53
+ } catch {
54
+ return value;
55
+ }
56
+ };
57
+ const parts = routeSegments(path);
58
+ for (const route of routes) {
59
+ const params = {};
60
+ let matched = true;
61
+ for (let i = 0;i < route.segments.length; i++) {
62
+ const segment = route.segments[i];
63
+ if (segment === "*") {
64
+ params["*"] = parts.slice(i).join("/");
65
+ break;
66
+ }
67
+ if (i >= parts.length) {
68
+ matched = false;
69
+ break;
70
+ }
71
+ if (segment.startsWith(":")) {
72
+ if (!parts[i]) {
73
+ matched = false;
74
+ break;
75
+ }
76
+ params[segment.slice(1)] = decode(parts[i]);
77
+ } else if (segment !== parts[i]) {
78
+ matched = false;
79
+ break;
80
+ }
81
+ }
82
+ if (!matched)
83
+ continue;
84
+ if (route.segments.at(-1) !== "*" && route.segments.length !== parts.length)
85
+ continue;
86
+ const query = raw.indexOf("?");
87
+ if (query >= 0) {
88
+ for (const item of raw.slice(query + 1).split("#")[0].split("&")) {
89
+ if (!item)
90
+ continue;
91
+ const [rawKey = "", ...rest] = item.split("=");
92
+ const key = decode(rawKey);
93
+ if (!key || key in params)
94
+ continue;
95
+ params[key] = decode(rest.join("=").replace(/\+/g, " "));
96
+ }
97
+ }
98
+ return { path, screen: route.screen, params };
99
+ }
100
+ return null;
101
+ }
36
102
  function isHybrid(config) {
37
103
  return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
38
104
  }
105
+ function hasNativeRoutes(config) {
106
+ return config.renderer === "native" && Object.keys(config.nativeScreens ?? {}).length > 0;
107
+ }
108
+ function usesNativeBundle(config) {
109
+ return config.renderer === "native" || Object.keys(config.nativeScreens ?? {}).length > 0;
110
+ }
111
+ function nativeStartPath(config) {
112
+ if (!config.devServerURL)
113
+ return "/";
114
+ try {
115
+ const url = new URL(config.devServerURL);
116
+ return `${url.pathname || "/"}${url.search}`;
117
+ } catch {
118
+ return "/";
119
+ }
120
+ }
39
121
  function hybridConfigProblems(config) {
40
122
  const problems = [];
41
123
  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
124
  for (const [pattern, screen] of screens) {
45
125
  if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
46
126
  problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
@@ -62,13 +142,28 @@ function hybridConfigProblems(config) {
62
142
  if (!key || typeof target !== "string" || !target)
63
143
  problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
64
144
  }
145
+ if (hasNativeRoutes(config)) {
146
+ for (const id of ids) {
147
+ if (!matchNativePath(config.nativeScreens, id))
148
+ problems.push(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
149
+ }
150
+ const start = nativeStartPath(config);
151
+ if (!matchNativePath(config.nativeScreens, start))
152
+ problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
153
+ }
154
+ if (config.nativeApp !== undefined) {
155
+ if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
156
+ problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
157
+ else if (!usesNativeBundle(config))
158
+ problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
159
+ }
65
160
  return problems;
66
161
  }
67
162
  function installNativeBundle(source, output) {
68
163
  if (!existsSync(source))
69
164
  throw new Error(`Native bundle not found: ${source}`);
70
- const destination = join(output, "dist", "native-screen.js");
71
- mkdirSync(dirname(destination), { recursive: true });
165
+ const destination = join2(output, "dist", "native-screen.js");
166
+ mkdirSync(dirname2(destination), { recursive: true });
72
167
  if (resolve(source) !== resolve(destination))
73
168
  cpSync(source, destination);
74
169
  }
@@ -432,20 +527,20 @@ function writeOpaqueIcon(source, target) {
432
527
  }
433
528
  }
434
529
  function renderAssetCatalog(output, config) {
435
- const catalog = join(output, "Assets.xcassets");
436
- const appIcon = join(catalog, "AppIcon.appiconset");
437
- const launchBackground = join(catalog, "LaunchBackground.colorset");
530
+ const catalog = join2(output, "Assets.xcassets");
531
+ const appIcon = join2(catalog, "AppIcon.appiconset");
532
+ const launchBackground = join2(catalog, "LaunchBackground.colorset");
438
533
  mkdirSync(appIcon, { recursive: true });
439
534
  mkdirSync(launchBackground, { recursive: true });
440
- writeFileSync(join(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
535
+ writeFileSync(join2(catalog, "Contents.json"), `${JSON.stringify({ info: { author: "xcode", version: 1 } }, null, 2)}
441
536
  `);
442
537
  const iconFilename = config.appIconPath ? "AppIcon-1024.png" : undefined;
443
538
  if (config.appIconPath) {
444
539
  if (!existsSync(config.appIconPath))
445
540
  throw new Error(`App icon not found: ${config.appIconPath}`);
446
- writeOpaqueIcon(config.appIconPath, join(appIcon, iconFilename));
541
+ writeOpaqueIcon(config.appIconPath, join2(appIcon, iconFilename));
447
542
  }
448
- writeFileSync(join(appIcon, "Contents.json"), `${JSON.stringify({
543
+ writeFileSync(join2(appIcon, "Contents.json"), `${JSON.stringify({
449
544
  images: iconFilename ? [{
450
545
  filename: iconFilename,
451
546
  idiom: "universal",
@@ -472,7 +567,7 @@ function renderAssetCatalog(output, config) {
472
567
  idiom: "universal"
473
568
  });
474
569
  }
475
- writeFileSync(join(launchBackground, "Contents.json"), `${JSON.stringify({
570
+ writeFileSync(join2(launchBackground, "Contents.json"), `${JSON.stringify({
476
571
  colors,
477
572
  info: { author: "xcode", version: 1 }
478
573
  }, null, 2)}
@@ -481,7 +576,7 @@ function renderAssetCatalog(output, config) {
481
576
  }
482
577
  var LAUNCH_LOGO = "LaunchLogo";
483
578
  function renderLaunchLogo(catalog, config) {
484
- const imageset = join(catalog, `${LAUNCH_LOGO}.imageset`);
579
+ const imageset = join2(catalog, `${LAUNCH_LOGO}.imageset`);
485
580
  rmSync(imageset, { recursive: true, force: true });
486
581
  if (!config.splashImagePath)
487
582
  return;
@@ -497,14 +592,14 @@ function renderLaunchLogo(catalog, config) {
497
592
  if (extension === "svg" || extension === "pdf")
498
593
  vector = true;
499
594
  const filename = `${LAUNCH_LOGO}${dark ? "-dark" : ""}.${extension}`;
500
- cpSync(source, join(imageset, filename));
595
+ cpSync(source, join2(imageset, filename));
501
596
  images.push({
502
597
  ...dark ? { appearances: [{ appearance: "luminosity", value: "dark" }] } : {},
503
598
  filename,
504
599
  idiom: "universal"
505
600
  });
506
601
  }
507
- writeFileSync(join(imageset, "Contents.json"), `${JSON.stringify({
602
+ writeFileSync(join2(imageset, "Contents.json"), `${JSON.stringify({
508
603
  images,
509
604
  info: { author: "xcode", version: 1 },
510
605
  ...vector ? { properties: { "preserves-vector-representation": true } } : {}
@@ -618,15 +713,15 @@ function syncWebAssets(source, output) {
618
713
  const sourcePath = resolve(source);
619
714
  if (!existsSync(sourcePath))
620
715
  throw new Error(`Web asset path not found: ${source}`);
621
- const distDir = join(output, "dist");
716
+ const distDir = join2(output, "dist");
622
717
  rmSync(distDir, { recursive: true, force: true });
623
718
  mkdirSync(distDir, { recursive: true });
624
719
  if (statSync(sourcePath).isDirectory()) {
625
720
  cpSync(sourcePath, distDir, { recursive: true });
626
721
  } else {
627
- cpSync(sourcePath, join(distDir, "index.html"));
722
+ cpSync(sourcePath, join2(distDir, "index.html"));
628
723
  }
629
- if (!existsSync(join(distDir, "index.html"))) {
724
+ if (!existsSync(join2(distDir, "index.html"))) {
630
725
  throw new Error(`Web asset directory must contain index.html: ${source}`);
631
726
  }
632
727
  }
@@ -648,32 +743,32 @@ var RUNTIME_ARCHIVES = {
648
743
  };
649
744
  async function installRuntime(output, runtimeDir) {
650
745
  const resolved = Object.entries(RUNTIME_ARCHIVES).map(([sdk, archives]) => {
651
- const present = archives.filter((a) => existsSync(join(runtimeDir, a)));
746
+ const present = archives.filter((a) => existsSync(join2(runtimeDir, a)));
652
747
  if (present.length === 0) {
653
748
  throw new Error(`${runtimeDir} has none of ${archives.join(", ")}. ` + `Run \`zig build build-ios-all\` in packages/zig and point at its zig-out/lib.`);
654
749
  }
655
750
  return { sdk, archives, present };
656
751
  });
657
- const dest = join(output, "Runtime");
752
+ const dest = join2(output, "Runtime");
658
753
  rmSync(dest, { recursive: true, force: true });
659
754
  for (const { sdk, archives, present } of resolved) {
660
- const sdkDir = join(dest, sdk);
755
+ const sdkDir = join2(dest, sdk);
661
756
  mkdirSync(sdkDir, { recursive: true });
662
- const target = join(sdkDir, "libcraft-ios.a");
757
+ const target = join2(sdkDir, "libcraft-ios.a");
663
758
  if (present.length === 1) {
664
759
  const missing = archives.filter((a) => !present.includes(a));
665
760
  if (missing.length > 0) {
666
761
  console.warn(` \u26A0 ${sdk}: only ${present[0]} was found; ${missing.join(", ")} is missing. ` + `The generated project will not link on the other architecture.`);
667
762
  }
668
- cpSync(join(runtimeDir, present[0]), target);
763
+ cpSync(join2(runtimeDir, present[0]), target);
669
764
  } else {
670
- await $`lipo -create ${present.map((a) => join(runtimeDir, a))} -output ${target}`.quiet();
765
+ await $`lipo -create ${present.map((a) => join2(runtimeDir, a))} -output ${target}`.quiet();
671
766
  }
672
767
  }
673
768
  return true;
674
769
  }
675
770
  async function refreshRuntime(output, override) {
676
- if (!existsSync(join(output, "Runtime")))
771
+ if (!existsSync(join2(output, "Runtime")))
677
772
  return;
678
773
  const dir = resolveRuntimeDir(override);
679
774
  if (!dir) {
@@ -690,6 +785,39 @@ function productName(name) {
690
785
  return "Craft";
691
786
  return /^\d/.test(joined) ? `App${joined}` : joined;
692
787
  }
788
+ function writeShell(output, config) {
789
+ if (!config.appName || !/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/.test(config.bundleId))
790
+ throw new Error("The iOS shell needs an appName and a valid bundleId in craft.config.json.");
791
+ const name = productName(config.appName);
792
+ mkdirSync(join2(output, "Sources"), { recursive: true });
793
+ mkdirSync(join2(output, "Shared"), { recursive: true });
794
+ const swiftTemplate = readFileSync(join2(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
795
+ const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, config.bundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
796
+ writeFileSync(join2(output, "Sources", `${name}App.swift`), swiftSource);
797
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeScreen.swift"), join2(output, "Sources", "CraftNativeScreen.swift"));
798
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join2(output, "Sources", "CraftNativeFlatList.swift"));
799
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeActions.swift"), join2(output, "Sources", "CraftNativeActions.swift"));
800
+ cpSync(join2(TEMPLATES_DIR, "CraftHealth.swift"), join2(output, "Sources", "CraftHealth.swift"));
801
+ cpSync(join2(TEMPLATES_DIR, "CraftMusic.swift"), join2(output, "Sources", "CraftMusic.swift"));
802
+ cpSync(join2(TEMPLATES_DIR, "CraftHeartRate.swift"), join2(output, "Sources", "CraftHeartRate.swift"));
803
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeMutation.swift"), join2(output, "Sources", "CraftNativeMutation.swift"));
804
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeUI.swift"), join2(output, "Sources", "CraftNativeUI.swift"));
805
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeServices.swift"), join2(output, "Sources", "CraftNativeServices.swift"));
806
+ cpSync(join2(TEMPLATES_DIR, "CraftNativeFiles.swift"), join2(output, "Sources", "CraftNativeFiles.swift"));
807
+ cpSync(join2(TEMPLATES_DIR, "CraftWebAudio.swift"), join2(output, "Sources", "CraftWebAudio.swift"));
808
+ cpSync(join2(TEMPLATES_DIR, "CraftHybrid.swift"), join2(output, "Sources", "CraftHybrid.swift"));
809
+ cpSync(join2(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join2(output, "Shared", "CraftActivityAttributes.swift"));
810
+ for (const [enabled, folder, template, filename] of [
811
+ [config.enableLiveActivities, "WidgetExtension", "CraftLiveActivityWidget.swift.template", `${name}LiveActivity.swift`],
812
+ [config.enableWatchApp, "WatchExtension", "CraftWatchApp.swift.template", `${name}WatchApp.swift`]
813
+ ]) {
814
+ if (!enabled)
815
+ continue;
816
+ mkdirSync(join2(output, folder), { recursive: true });
817
+ const source = readFileSync(join2(TEMPLATES_DIR, template), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
818
+ writeFileSync(join2(output, folder, filename), source);
819
+ }
820
+ }
693
821
  async function init(options) {
694
822
  const { name: displayName, bundleId, teamId, output } = options;
695
823
  const name = productName(displayName);
@@ -697,9 +825,9 @@ async function init(options) {
697
825
  \u26A1 Initializing Craft iOS project: ${displayName}`);
698
826
  console.log(` Output: ${output}
699
827
  `);
700
- const dirs = [output, join(output, "Sources"), join(output, "Shared"), join(output, "dist")];
828
+ const dirs = [output, join2(output, "Sources"), join2(output, "Shared"), join2(output, "dist")];
701
829
  if (options.config?.enableWatchApp)
702
- dirs.push(join(output, "WatchApp"), join(output, "WatchExtension"));
830
+ dirs.push(join2(output, "WatchApp"), join2(output, "WatchExtension"));
703
831
  for (const dir of dirs) {
704
832
  if (!existsSync(dir)) {
705
833
  mkdirSync(dir, { recursive: true });
@@ -726,29 +854,15 @@ async function init(options) {
726
854
  `));
727
855
  if (Object.keys(config.shareStorage ?? {}).length)
728
856
  config.enableSecureStorage = true;
729
- writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
730
- const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
731
- const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
732
- writeFileSync(join(output, "Sources", `${name}App.swift`), swiftSource);
733
- cpSync(join(TEMPLATES_DIR, "CraftNativeScreen.swift"), join(output, "Sources", "CraftNativeScreen.swift"));
734
- cpSync(join(TEMPLATES_DIR, "CraftNativeFlatList.swift"), join(output, "Sources", "CraftNativeFlatList.swift"));
735
- cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
736
- cpSync(join(TEMPLATES_DIR, "CraftHealth.swift"), join(output, "Sources", "CraftHealth.swift"));
737
- cpSync(join(TEMPLATES_DIR, "CraftMusic.swift"), join(output, "Sources", "CraftMusic.swift"));
738
- cpSync(join(TEMPLATES_DIR, "CraftHeartRate.swift"), join(output, "Sources", "CraftHeartRate.swift"));
739
- cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
740
- cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
741
- cpSync(join(TEMPLATES_DIR, "CraftNativeServices.swift"), join(output, "Sources", "CraftNativeServices.swift"));
742
- cpSync(join(TEMPLATES_DIR, "CraftNativeFiles.swift"), join(output, "Sources", "CraftNativeFiles.swift"));
743
- cpSync(join(TEMPLATES_DIR, "CraftWebAudio.swift"), join(output, "Sources", "CraftWebAudio.swift"));
744
- cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
745
- const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
857
+ writeFileSync(join2(output, "craft.config.json"), JSON.stringify(config, null, 2));
858
+ writeShell(output, config);
859
+ const infoPlistTemplate = readFileSync(join2(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
746
860
  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>
747
861
  <true/>
748
862
  <key>NSSupportsLiveActivitiesFrequentUpdates</key>
749
863
  <true/>` : "");
750
- writeFileSync(join(output, "Info.plist"), infoPlist);
751
- const projectYmlTemplate = readFileSync(join(TEMPLATES_DIR, "project.yml.template"), "utf-8");
864
+ writeFileSync(join2(output, "Info.plist"), infoPlist);
865
+ const projectYmlTemplate = readFileSync(join2(TEMPLATES_DIR, "project.yml.template"), "utf-8");
752
866
  const nativeDependencies = [];
753
867
  if (config.enableLiveActivities)
754
868
  nativeDependencies.push(` - target: ${name}LiveActivity`);
@@ -805,40 +919,37 @@ async function init(options) {
805
919
  if (hasRuntime) {
806
920
  console.log(" Linked the Zig runtime from", runtimeDir);
807
921
  } else {
808
- rmSync(join(output, "Runtime"), { recursive: true, force: true });
922
+ rmSync(join2(output, "Runtime"), { recursive: true, force: true });
809
923
  }
810
924
  const projectYml = projectYmlTemplate.replace(/\{\{CRAFT_RUNTIME_SETTINGS\}\}/g, hasRuntime ? renderRuntimeSettings() : "").replace(/\{\{APP_NAME\}\}/g, name).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{BUNDLE_ID_PREFIX\}\}/g, bundleIdPrefix).replace(/\{\{VERSION\}\}/g, config.version || "1.0.0").replace(/\{\{BUILD_NUMBER\}\}/g, config.buildNumber || "1").replace(/\{\{IOS_VERSION\}\}/g, config.iosVersion || "15.0").replace(/\{\{DEVICE_FAMILIES\}\}/g, renderDeviceFamilies(config)).replace(/\{\{TEAM_ID\}\}/g, teamId || "").replace(/\{\{NATIVE_DEPENDENCIES\}\}/g, nativeDependencies.length ? ` dependencies:
811
925
  ${nativeDependencies.join(`
812
926
  `)}` : "").replace(/\{\{NATIVE_TARGETS\}\}/g, nativeTargets.join(`
813
927
  `));
814
- writeFileSync(join(output, "project.yml"), projectYml);
815
- writeFileSync(join(output, "Craft.entitlements"), renderEntitlements(config));
816
- writeFileSync(join(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
928
+ writeFileSync(join2(output, "project.yml"), projectYml);
929
+ writeFileSync(join2(output, "Craft.entitlements"), renderEntitlements(config));
930
+ writeFileSync(join2(output, "PrivacyInfo.xcprivacy"), renderPrivacyManifest(config));
817
931
  renderAssetCatalog(output, config);
818
- cpSync(join(TEMPLATES_DIR, "CraftActivityAttributes.swift"), join(output, "Shared", "CraftActivityAttributes.swift"));
819
932
  if (config.enableLiveActivities) {
820
- mkdirSync(join(output, "WidgetExtension"), { recursive: true });
821
- const widgetSource = readFileSync(join(TEMPLATES_DIR, "CraftLiveActivityWidget.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
822
- writeFileSync(join(output, "WidgetExtension", `${name}LiveActivity.swift`), widgetSource);
823
- const widgetInfo = readFileSync(join(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
824
- writeFileSync(join(output, "WidgetExtension", "Info.plist"), widgetInfo);
933
+ mkdirSync(join2(output, "WidgetExtension"), { recursive: true });
934
+ const widgetInfo = readFileSync(join2(TEMPLATES_DIR, "WidgetExtension.Info.plist"), "utf8");
935
+ writeFileSync(join2(output, "WidgetExtension", "Info.plist"), widgetInfo);
825
936
  }
826
937
  if (config.enableWatchApp) {
827
- const watchSource = readFileSync(join(TEMPLATES_DIR, "CraftWatchApp.swift.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, name);
828
- writeFileSync(join(output, "WatchExtension", `${name}WatchApp.swift`), watchSource);
829
- const watchInfo = readFileSync(join(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
830
- writeFileSync(join(output, "WatchApp", "Info.plist"), watchInfo);
831
- writeFileSync(join(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
832
- const watchExtensionInfo = readFileSync(join(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
833
- writeFileSync(join(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
834
- writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
938
+ const watchInfo = readFileSync(join2(TEMPLATES_DIR, "WatchApp.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
939
+ writeFileSync(join2(output, "WatchApp", "Info.plist"), watchInfo);
940
+ writeFileSync(join2(output, "WatchApp", "Watch.entitlements"), renderWatchEntitlements(config));
941
+ const watchExtensionInfo = readFileSync(join2(TEMPLATES_DIR, "WatchExtension.Info.plist.template"), "utf8").replace(/\{\{APP_NAME\}\}/g, xmlEscape(displayName)).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId);
942
+ writeFileSync(join2(output, "WatchExtension", "Info.plist"), watchExtensionInfo);
943
+ writeFileSync(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
835
944
  }
836
- writeFileSync(join(output, "dist", "index.html"), renderOfflinePage(config));
837
- if (isHybrid(config)) {
945
+ writeFileSync(join2(output, "dist", "index.html"), renderOfflinePage(config));
946
+ if (usesNativeBundle(config)) {
838
947
  if (config.nativeBundle)
839
948
  installNativeBundle(config.nativeBundle, output);
840
- else
949
+ else if (isHybrid(config))
841
950
  console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
951
+ else if (hasNativeRoutes(config))
952
+ console.warn(" \u26A0 nativeScreens is set without nativeBundle: the app has no screens until `craft ios build --native-bundle` supplies one.");
842
953
  }
843
954
  console.log("\u2705 Project initialized");
844
955
  console.log("");
@@ -858,7 +969,7 @@ async function build(options) {
858
969
  const { htmlPath, nativeBundlePath, devServer, output, generateProject = true } = options;
859
970
  console.log(`
860
971
  \uD83D\uDCE6 Building Craft iOS project...`);
861
- const configPath = join(output, "craft.config.json");
972
+ const configPath = join2(output, "craft.config.json");
862
973
  if (!existsSync(configPath)) {
863
974
  throw new Error(`No craft.config.json found in ${output}. Run 'craft ios init' first.`);
864
975
  }
@@ -870,12 +981,10 @@ async function build(options) {
870
981
  if (htmlPath || devServer) {
871
982
  throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
872
983
  }
873
- if (nativeBundlePath) {
874
- const destination = join(output, "dist", "native-screen.js");
875
- if (resolve(nativeBundlePath) !== resolve(destination))
876
- cpSync(nativeBundlePath, destination);
877
- }
878
- if (!existsSync(join(output, "dist", "native-screen.js"))) {
984
+ const bundle = nativeBundlePath ?? config.nativeBundle;
985
+ if (bundle)
986
+ installNativeBundle(bundle, output);
987
+ if (!existsSync(join2(output, "dist", "native-screen.js"))) {
879
988
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
880
989
  }
881
990
  } else if (nativeBundlePath && !isHybrid(config)) {
@@ -901,7 +1010,7 @@ async function build(options) {
901
1010
  if (bundle) {
902
1011
  installNativeBundle(bundle, output);
903
1012
  console.log(` Native screens: ${bundle} \u2192 dist/native-screen.js`);
904
- } else if (!existsSync(join(output, "dist", "native-screen.js"))) {
1013
+ } else if (!existsSync(join2(output, "dist", "native-screen.js"))) {
905
1014
  console.warn(" \u26A0 nativeScreens is set but there is no native bundle: every path opens in the web view.");
906
1015
  }
907
1016
  }
@@ -931,7 +1040,7 @@ async function open(options) {
931
1040
  if (!xcodeproj) {
932
1041
  throw new Error(`No Xcode project found in ${output}. Run 'craft ios build' first.`);
933
1042
  }
934
- const projectPath = join(output, xcodeproj);
1043
+ const projectPath = join2(output, xcodeproj);
935
1044
  console.log(`\uD83D\uDE80 Opening ${xcodeproj}...`);
936
1045
  await $`open ${projectPath}`;
937
1046
  }
@@ -972,7 +1081,7 @@ async function showSimulator() {
972
1081
  const selected = await $`xcode-select -p`.quiet().nothrow();
973
1082
  const developer = selected.exitCode === 0 ? selected.stdout.toString().trim() : "";
974
1083
  if (developer) {
975
- const app = join(developer, "Applications", "Simulator.app");
1084
+ const app = join2(developer, "Applications", "Simulator.app");
976
1085
  if (existsSync(app)) {
977
1086
  await $`open ${app}`.quiet().nothrow();
978
1087
  return;
@@ -988,9 +1097,9 @@ async function run(options) {
988
1097
  if (!xcodeproj) {
989
1098
  throw new Error(`No Xcode project found in ${output}`);
990
1099
  }
991
- const projectPath = join(output, xcodeproj);
1100
+ const projectPath = join2(output, xcodeproj);
992
1101
  const appName = xcodeproj.replace(".xcodeproj", "");
993
- const configPath = join(output, "craft.config.json");
1102
+ const configPath = join2(output, "craft.config.json");
994
1103
  if (!existsSync(configPath))
995
1104
  throw new Error(`No craft.config.json found in ${output}. Run 'craft-ios init' first.`);
996
1105
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
@@ -1003,8 +1112,8 @@ async function run(options) {
1003
1112
  console.log(` Device: ${device.name} (${device.runtime})`);
1004
1113
  try {
1005
1114
  await bootSimulator(device);
1006
- const derivedData = join(output, "build");
1007
- const product = join(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1115
+ const derivedData = join2(output, "build");
1116
+ const product = join2(derivedData, "Build", "Products", "Debug-iphonesimulator", `${appName}.app`);
1008
1117
  await $`xcodebuild -project ${projectPath} -scheme ${appName} -configuration Debug -destination ${`id=${device.udid}`} -derivedDataPath ${derivedData} build`;
1009
1118
  if (!existsSync(product)) {
1010
1119
  throw new Error(`xcodebuild reported success but ${product} does not exist. ` + "The scheme may build a different product name than the project.");
package/dist/index.d.ts CHANGED
@@ -83,6 +83,14 @@ export interface CraftConfig {
83
83
  * Each replaces the generic sentence Craft writes for that key.
84
84
  */
85
85
  usageDescriptions?: Record<string, string>;
86
+ /**
87
+ * The app's site. With `renderer: "web"` it is the page the web view loads.
88
+ * With `renderer: "native"` and `nativeScreens` there is no web view: its
89
+ * path is the path the app starts on (`/` when unset), and a path no native
90
+ * screen draws (`craft.navigation.open('/terms')`, or a link to this host)
91
+ * opens in the in-app browser against its origin. Native screens read it as
92
+ * `__stxNativeBridge.appURL`.
93
+ */
86
94
  devServerURL?: string;
87
95
  iosVersion?: string;
88
96
  watchosVersion?: string;
@@ -168,16 +176,32 @@ export interface CraftConfig {
168
176
  orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
169
177
  deviceFamilies?: Array<'iphone' | 'ipad'>;
170
178
  /**
171
- * Hybrid: app paths drawn as native stx screens instead of in the
172
- * web view, each to the screen in the native bundle that draws it, e.g.
173
- * `{ "/m": "Today", "/m/calendar": "Calendar" }`. A segment may be a
174
- * parameter (`/m/workout/:id`) and the last one `*`. Needs `renderer: "web"`
175
- * and `nativeBundle`; every other path stays in the web view, and a path
176
- * whose screen is missing or throws falls back to it.
179
+ * App paths drawn as native stx screens, each to the screen in the native
180
+ * bundle that draws it, e.g. `{ "/m": "Today", "/m/calendar": "Calendar" }`.
181
+ * A segment may be a parameter (`/m/workout/:id`) and the last one `*`.
182
+ * Needs `nativeBundle`.
183
+ *
184
+ * With `renderer: "web"` (hybrid) every other path stays in the web view,
185
+ * and a path whose screen is missing or throws falls back to it.
186
+ *
187
+ * With `renderer: "native"` the app is all native, with the same tabs and
188
+ * path routing and no web view at all: every tab id and the start path (the
189
+ * path of `devServerURL`) must be a native screen's, and any other path
190
+ * opens in the in-app browser on the app's site.
177
191
  */
178
192
  nativeScreens?: Record<string, string>;
179
- /** Hybrid: the bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
193
+ /** The bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
180
194
  nativeBundle?: string;
195
+ /**
196
+ * The bundle route of a screen-less script that runs once for the app's
197
+ * whole life, beside the screens (Stacks names it `__app`). It hears the
198
+ * app's events (`craftNotificationResponse`, `craftNotificationReceived`,
199
+ * `craftSilentPush`, `craftBackgroundRefresh`, `craftShortcut`,
200
+ * `craftSiriShortcut`) as `CRAFT_EVENT` messages instead of the page, every
201
+ * `APP_STATE`, and can open paths with `craft.navigation.open`. Needs
202
+ * `nativeScreens` or `renderer: "native"`, and the native bundle.
203
+ */
204
+ nativeApp?: string;
181
205
  /**
182
206
  * The native tab bar, shown from the first frame, before the page loads.
183
207
  * The page's own `tabBar.set` replaces it later. Each `id` is the tab's
@@ -214,8 +238,18 @@ export interface NativePathMatch {
214
238
  export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string): NativePathMatch | null;
215
239
  /** Whether a config mixes native screens into its web page. */
216
240
  export declare function isHybrid(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
241
+ /** Whether a config is an all-native app with routes: `renderer: "native"` and `nativeScreens`, tabs and paths with no web view. */
242
+ export declare function hasNativeRoutes(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
243
+ /** Whether a config draws any native screen, and so needs `dist/native-screen.js`. */
244
+ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
245
+ /**
246
+ * The path the app starts on: the path of `devServerURL` (with its query),
247
+ * `/` when there is none. The shell reads it the same way
248
+ * (`CraftHybridRoutes.appPath`).
249
+ */
250
+ export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
217
251
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
218
- export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage'>): string[];
252
+ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
219
253
  export interface CraftBackgroundRefresh {
220
254
  enabled: boolean;
221
255
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
@@ -412,6 +446,8 @@ export declare function installRuntime(output: string, runtimeDir: string): Prom
412
446
  * valid Swift type.
413
447
  */
414
448
  export declare function productName(name: string): string;
449
+ /** Restore package-owned Swift into an existing project, without changing its Xcode metadata or bundled screens. */
450
+ export declare function restoreShell(output: string): void;
415
451
  export declare function init(options: InitOptions): Promise<void>;
416
452
  /**
417
453
  * Build web assets and generate Xcode project
@@ -475,3 +511,5 @@ export declare function bootSimulator(device: SimulatorDevice): Promise<void>;
475
511
  */
476
512
  export declare function showSimulator(): Promise<void>;
477
513
  export declare function run(options: RunOptions): Promise<void>;
514
+ export { createNativeMutationValidator, initSimulatorDriver, serializeSimulatorSteps } from './testing.js';
515
+ export type { NativeMutationValidator, NativeValidationResult, SimulatorDriverOptions, SimulatorStep } from './testing.js';