craft-native 0.0.124 → 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.
@@ -59,6 +59,7 @@ dependencies {
59
59
  implementation("androidx.appcompat:appcompat:1.6.1")
60
60
  implementation("androidx.fragment:fragment-ktx:1.6.2")
61
61
  implementation("androidx.recyclerview:recyclerview:1.3.2")
62
+ {{REFRESH_LAYOUT_DEPENDENCY}}
62
63
  implementation("androidx.webkit:webkit:1.12.1")
63
64
  {{JAVASCRIPT_ENGINE_DEPENDENCY}}
64
65
  implementation("com.google.android.material:material:1.11.0")
@@ -818,6 +818,47 @@ export declare const tabBar: {
818
818
  export declare const splash: {
819
819
  hide(): boolean;
820
820
  };
821
+ /** Letters, digits, `.`, `_` and `-`, not starting with a dot: the names the shell accepts. */
822
+ export declare function isSnapshotName(name: string): boolean;
823
+ /**
824
+ * Data the page keeps for its native screens. A native screen reads a
825
+ * snapshot synchronously before its first frame (`craft.snapshots.get(name)`),
826
+ * so it opens on the last data the page saw instead of an empty skeleton.
827
+ * Write one whenever the page has fresh data a native screen shows.
828
+ *
829
+ * Each is `Application Support/craft-snapshots/<name>.json` in the app.
830
+ * Outside a hybrid app every call is a no-op answering false.
831
+ */
832
+ export declare const snapshots: {
833
+ isAvailable(): boolean;
834
+ /** Keep `value` (anything JSON can hold) as `name`. Answers whether it was handed to the shell. */
835
+ set(name: string, value: unknown): boolean;
836
+ remove(name: string): boolean;
837
+ /** Every snapshot, as on signing out. */
838
+ clear(): boolean;
839
+ };
840
+ /**
841
+ * Navigation between the page and the app's native screens.
842
+ *
843
+ * The shell already hands the stx router's navigations to native paths to
844
+ * the native screens, and Back at the entry a native screen opened goes back
845
+ * to it, so most pages need none of this. It is for code that navigates
846
+ * without the router.
847
+ */
848
+ export declare const hybrid: {
849
+ /** Whether this page runs inside a hybrid app. */
850
+ isActive(): boolean;
851
+ /** The native screen `path` opens, or null when it is the page's. */
852
+ nativeScreenFor(path: string): {
853
+ path: string;
854
+ screen: string;
855
+ params: Record<string, string>;
856
+ } | null;
857
+ /** Open `path` as the shell would: a tab, a native screen, or the page pushed. */
858
+ open(path: string): boolean;
859
+ /** Back to the native screen under this page, when there is one. */
860
+ back(): boolean;
861
+ };
821
862
  /** A rect in the viewport's CSS pixels. */
822
863
  export interface NativeRect {
823
864
  x: number;
@@ -1153,6 +1194,8 @@ declare const mobile: {
1153
1194
  watchConnectivity: typeof watchConnectivity;
1154
1195
  tabBar: typeof tabBar;
1155
1196
  splash: typeof splash;
1197
+ snapshots: typeof snapshots;
1198
+ hybrid: typeof hybrid;
1156
1199
  dialog: typeof dialog;
1157
1200
  contextMenu: typeof contextMenu;
1158
1201
  browser: typeof browser;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.124";
3306
+ var version = "0.0.125";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -3611,7 +3611,7 @@ cli.command("ios init <name>", "Initialize a new iOS project").option("--bundle-
3611
3611
  config: options?.renderer ? { renderer: options.renderer } : undefined
3612
3612
  });
3613
3613
  });
3614
- cli.command("ios build", "Build iOS project").option("--html-path <path>", "Path to HTML file").option("--native-bundle <path>", "JavaScript bundle compiled by stx-native").option("-d, --dev-server <url>", "Development server URL").option("-o, --output <dir>", "iOS project directory", { default: "./ios" }).option("-w, --watch", "Watch for file changes and rebuild").example("craft ios build").example("craft ios build --html-path ./dist/index.html").example("craft ios build --native-bundle ./screen.js").example("craft ios build --dev-server http://localhost:3456").example("craft ios build --watch").action(async (options) => {
3614
+ cli.command("ios build", "Build iOS project").option("--html-path <path>", "Path to HTML file").option("--native-bundle <path>", "JavaScript bundle from `stx native compile`").option("-d, --dev-server <url>", "Development server URL").option("-o, --output <dir>", "iOS project directory", { default: "./ios" }).option("-w, --watch", "Watch for file changes and rebuild").example("craft ios build").example("craft ios build --html-path ./dist/index.html").example("craft ios build --native-bundle ./screen.js").example("craft ios build --dev-server http://localhost:3456").example("craft ios build --watch").action(async (options) => {
3615
3615
  const iosModule = await import(iosModuleURL);
3616
3616
  const doBuild = async () => {
3617
3617
  await iosModule.build({
@@ -3661,7 +3661,7 @@ cli.command("android init <name>", "Initialize a new Android project").option("-
3661
3661
  config: options?.renderer ? { renderer: options.renderer } : undefined
3662
3662
  });
3663
3663
  });
3664
- cli.command("android build", "Build Android project").option("--html-path <path>", "Path to HTML file").option("--native-bundle <path>", "JavaScript bundle compiled by stx-native").option("-d, --dev-server <url>", "Development server URL").option("-o, --output <dir>", "Android project directory", { default: "./android" }).option("--release", "Build release APK").option("-w, --watch", "Watch for file changes and rebuild").example("craft android build").example("craft android build --release").example("craft android build --native-bundle ./screen.js").example("craft android build --watch").action(async (options) => {
3664
+ cli.command("android build", "Build Android project").option("--html-path <path>", "Path to HTML file").option("--native-bundle <path>", "JavaScript bundle from `stx native compile`").option("-d, --dev-server <url>", "Development server URL").option("-o, --output <dir>", "Android project directory", { default: "./android" }).option("--release", "Build release APK").option("-w, --watch", "Watch for file changes and rebuild").example("craft android build").example("craft android build --release").example("craft android build --native-bundle ./screen.js").example("craft android build --watch").action(async (options) => {
3665
3665
  const androidModule = await import(androidModuleURL);
3666
3666
  const doBuild = async () => {
3667
3667
  await androidModule.build({
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.124";
313
+ var version = "0.0.125";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.124";
61
+ var version = "0.0.125";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -144,7 +144,55 @@ export interface CraftConfig {
144
144
  requestTimeoutSeconds?: number;
145
145
  orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
146
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>;
147
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[];
148
196
  export interface CraftBackgroundRefresh {
149
197
  enabled: boolean;
150
198
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
@@ -191,7 +239,7 @@ export interface InitOptions {
191
239
  }
192
240
  export interface BuildOptions {
193
241
  htmlPath?: string;
194
- /** JavaScript bundle emitted by `stx-native compile Screen.stx --format bundle`. */
242
+ /** JavaScript bundle emitted by `stx native compile Screen.stx --format bundle`. */
195
243
  nativeBundlePath?: string;
196
244
  devServer?: string;
197
245
  output: string;
@@ -11,6 +11,122 @@ var CRAFT_VERSION = (() => {
11
11
  return "0.0.0";
12
12
  }
13
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
+ }
14
130
  var DEFAULT_CONFIG = {
15
131
  renderer: "web",
16
132
  version: "1.0.0",
@@ -643,6 +759,12 @@ async function init(options) {
643
759
  }
644
760
  if (config.enableBackgroundLocation)
645
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;
646
768
  writeFileSync(join(output, "craft.config.json"), JSON.stringify(config, null, 2));
647
769
  const swiftTemplate = readFileSync(join(TEMPLATES_DIR, "CraftApp.swift"), "utf-8");
648
770
  const swiftSource = swiftTemplate.replace(/CraftApp/g, `${name}App`).replace(/\{\{BUNDLE_ID\}\}/g, finalBundleId).replace(/\{\{CRAFT_VERSION\}\}/g, CRAFT_VERSION);
@@ -652,6 +774,7 @@ async function init(options) {
652
774
  cpSync(join(TEMPLATES_DIR, "CraftNativeActions.swift"), join(output, "Sources", "CraftNativeActions.swift"));
653
775
  cpSync(join(TEMPLATES_DIR, "CraftNativeMutation.swift"), join(output, "Sources", "CraftNativeMutation.swift"));
654
776
  cpSync(join(TEMPLATES_DIR, "CraftNativeUI.swift"), join(output, "Sources", "CraftNativeUI.swift"));
777
+ cpSync(join(TEMPLATES_DIR, "CraftHybrid.swift"), join(output, "Sources", "CraftHybrid.swift"));
655
778
  const infoPlistTemplate = readFileSync(join(TEMPLATES_DIR, "Info.plist.template"), "utf-8");
656
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>
657
780
  <true/>
@@ -744,12 +867,18 @@ ${nativeDependencies.join(`
744
867
  writeFileSync(join(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
745
868
  }
746
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
+ }
747
876
  console.log("\u2705 Project initialized");
748
877
  console.log("");
749
878
  console.log("Next steps:");
750
879
  console.log(` 1. cd ${output}`);
751
880
  if (config.renderer === "native") {
752
- 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");
753
882
  console.log(" 3. Run: craft ios build --native-bundle screen.js");
754
883
  } else {
755
884
  console.log(" 2. Add your web content to dist/index.html");
@@ -780,11 +909,15 @@ async function build(options) {
780
909
  cpSync(nativeBundlePath, destination);
781
910
  }
782
911
  if (!existsSync(join(output, "dist", "native-screen.js"))) {
783
- 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.");
784
913
  }
785
- } else if (nativeBundlePath) {
786
- 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.');
787
916
  }
917
+ const problems = hybridConfigProblems(config);
918
+ if (problems.length)
919
+ throw new Error(problems.join(`
920
+ `));
788
921
  if (devServer) {
789
922
  config.devServerURL = devServer;
790
923
  const origin = new URL(devServer).origin;
@@ -796,6 +929,15 @@ async function build(options) {
796
929
  syncWebAssets(htmlPath, output);
797
930
  console.log(` Synced: ${htmlPath} \u2192 dist/`);
798
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
+ }
799
941
  await refreshRuntime(output, options.runtimeDir);
800
942
  if (!generateProject)
801
943
  return;
@@ -944,8 +1086,12 @@ export {
944
1086
  pickSimulator,
945
1087
  orderSimulators,
946
1088
  open,
1089
+ normalizeNativePath,
1090
+ matchNativePath,
1091
+ isHybrid,
947
1092
  installRuntime,
948
1093
  init,
1094
+ hybridConfigProblems,
949
1095
  build,
950
1096
  bootSimulator,
951
1097
  backgroundRefreshIdentifier,