@craft-native/ios 0.0.155 → 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 +98 -9
- package/dist/index.d.ts +42 -8
- package/dist/index.js +41 -9
- package/package.json +1 -1
- package/templates/CraftApp.swift +114 -18
- package/templates/CraftHybrid.swift +269 -31
- package/templates/CraftNativeActions.swift +57 -3
- package/templates/CraftNativeScreen.swift +90 -21
package/dist/cli.js
CHANGED
|
@@ -39,14 +39,88 @@ function normalizeNativePath(raw) {
|
|
|
39
39
|
function routeSegments(path) {
|
|
40
40
|
return path === "/" ? [] : path.slice(1).split("/");
|
|
41
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
|
+
}
|
|
42
102
|
function isHybrid(config) {
|
|
43
103
|
return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
44
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
|
+
}
|
|
45
121
|
function hybridConfigProblems(config) {
|
|
46
122
|
const problems = [];
|
|
47
123
|
const screens = Object.entries(config.nativeScreens ?? {});
|
|
48
|
-
if (screens.length && config.renderer === "native")
|
|
49
|
-
problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
|
|
50
124
|
for (const [pattern, screen] of screens) {
|
|
51
125
|
if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
52
126
|
problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
@@ -68,6 +142,21 @@ function hybridConfigProblems(config) {
|
|
|
68
142
|
if (!key || typeof target !== "string" || !target)
|
|
69
143
|
problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
|
|
70
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
|
+
}
|
|
71
160
|
return problems;
|
|
72
161
|
}
|
|
73
162
|
function installNativeBundle(source, output) {
|
|
@@ -854,11 +943,13 @@ ${nativeDependencies.join(`
|
|
|
854
943
|
writeFileSync(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
855
944
|
}
|
|
856
945
|
writeFileSync(join2(output, "dist", "index.html"), renderOfflinePage(config));
|
|
857
|
-
if (
|
|
946
|
+
if (usesNativeBundle(config)) {
|
|
858
947
|
if (config.nativeBundle)
|
|
859
948
|
installNativeBundle(config.nativeBundle, output);
|
|
860
|
-
else
|
|
949
|
+
else if (isHybrid(config))
|
|
861
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.");
|
|
862
953
|
}
|
|
863
954
|
console.log("\u2705 Project initialized");
|
|
864
955
|
console.log("");
|
|
@@ -890,11 +981,9 @@ async function build(options) {
|
|
|
890
981
|
if (htmlPath || devServer) {
|
|
891
982
|
throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
|
|
892
983
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
cpSync(nativeBundlePath, destination);
|
|
897
|
-
}
|
|
984
|
+
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
985
|
+
if (bundle)
|
|
986
|
+
installNativeBundle(bundle, output);
|
|
898
987
|
if (!existsSync(join2(output, "dist", "native-screen.js"))) {
|
|
899
988
|
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
900
989
|
}
|
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
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
-
/**
|
|
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`. */
|
package/dist/index.js
CHANGED
|
@@ -228,11 +228,25 @@ function matchNativePath(nativeScreens, raw) {
|
|
|
228
228
|
function isHybrid(config) {
|
|
229
229
|
return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
230
230
|
}
|
|
231
|
+
function hasNativeRoutes(config) {
|
|
232
|
+
return config.renderer === "native" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
233
|
+
}
|
|
234
|
+
function usesNativeBundle(config) {
|
|
235
|
+
return config.renderer === "native" || Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
236
|
+
}
|
|
237
|
+
function nativeStartPath(config) {
|
|
238
|
+
if (!config.devServerURL)
|
|
239
|
+
return "/";
|
|
240
|
+
try {
|
|
241
|
+
const url = new URL(config.devServerURL);
|
|
242
|
+
return `${url.pathname || "/"}${url.search}`;
|
|
243
|
+
} catch {
|
|
244
|
+
return "/";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
231
247
|
function hybridConfigProblems(config) {
|
|
232
248
|
const problems = [];
|
|
233
249
|
const screens = Object.entries(config.nativeScreens ?? {});
|
|
234
|
-
if (screens.length && config.renderer === "native")
|
|
235
|
-
problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
|
|
236
250
|
for (const [pattern, screen] of screens) {
|
|
237
251
|
if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
238
252
|
problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
@@ -254,6 +268,21 @@ function hybridConfigProblems(config) {
|
|
|
254
268
|
if (!key || typeof target !== "string" || !target)
|
|
255
269
|
problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
|
|
256
270
|
}
|
|
271
|
+
if (hasNativeRoutes(config)) {
|
|
272
|
+
for (const id of ids) {
|
|
273
|
+
if (!matchNativePath(config.nativeScreens, id))
|
|
274
|
+
problems.push(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
|
|
275
|
+
}
|
|
276
|
+
const start = nativeStartPath(config);
|
|
277
|
+
if (!matchNativePath(config.nativeScreens, start))
|
|
278
|
+
problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
279
|
+
}
|
|
280
|
+
if (config.nativeApp !== undefined) {
|
|
281
|
+
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
282
|
+
problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
283
|
+
else if (!usesNativeBundle(config))
|
|
284
|
+
problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
285
|
+
}
|
|
257
286
|
return problems;
|
|
258
287
|
}
|
|
259
288
|
function installNativeBundle(source, output) {
|
|
@@ -1044,11 +1073,13 @@ ${nativeDependencies.join(`
|
|
|
1044
1073
|
writeFileSync2(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1045
1074
|
}
|
|
1046
1075
|
writeFileSync2(join2(output, "dist", "index.html"), renderOfflinePage(config));
|
|
1047
|
-
if (
|
|
1076
|
+
if (usesNativeBundle(config)) {
|
|
1048
1077
|
if (config.nativeBundle)
|
|
1049
1078
|
installNativeBundle(config.nativeBundle, output);
|
|
1050
|
-
else
|
|
1079
|
+
else if (isHybrid(config))
|
|
1051
1080
|
console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
|
|
1081
|
+
else if (hasNativeRoutes(config))
|
|
1082
|
+
console.warn(" \u26A0 nativeScreens is set without nativeBundle: the app has no screens until `craft ios build --native-bundle` supplies one.");
|
|
1052
1083
|
}
|
|
1053
1084
|
console.log("\u2705 Project initialized");
|
|
1054
1085
|
console.log("");
|
|
@@ -1080,11 +1111,9 @@ async function build(options) {
|
|
|
1080
1111
|
if (htmlPath || devServer) {
|
|
1081
1112
|
throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
|
|
1082
1113
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
cpSync(nativeBundlePath, destination);
|
|
1087
|
-
}
|
|
1114
|
+
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
1115
|
+
if (bundle)
|
|
1116
|
+
installNativeBundle(bundle, output);
|
|
1088
1117
|
if (!existsSync(join2(output, "dist", "native-screen.js"))) {
|
|
1089
1118
|
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
1090
1119
|
}
|
|
@@ -1238,6 +1267,7 @@ async function run(options) {
|
|
|
1238
1267
|
}
|
|
1239
1268
|
}
|
|
1240
1269
|
export {
|
|
1270
|
+
usesNativeBundle,
|
|
1241
1271
|
syncWebAssets,
|
|
1242
1272
|
showSimulator,
|
|
1243
1273
|
serializeSimulatorSteps,
|
|
@@ -1266,12 +1296,14 @@ export {
|
|
|
1266
1296
|
orderSimulators,
|
|
1267
1297
|
open,
|
|
1268
1298
|
normalizeNativePath,
|
|
1299
|
+
nativeStartPath,
|
|
1269
1300
|
matchNativePath,
|
|
1270
1301
|
isHybrid,
|
|
1271
1302
|
installRuntime,
|
|
1272
1303
|
initSimulatorDriver,
|
|
1273
1304
|
init,
|
|
1274
1305
|
hybridConfigProblems,
|
|
1306
|
+
hasNativeRoutes,
|
|
1275
1307
|
createNativeMutationValidator,
|
|
1276
1308
|
build,
|
|
1277
1309
|
bootSimulator,
|
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -195,8 +195,14 @@ struct CraftRecoveryBudget {
|
|
|
195
195
|
final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
|
196
196
|
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
|
197
197
|
UNUserNotificationCenter.current().delegate = self
|
|
198
|
+
let config = CraftConfig.bundled()
|
|
198
199
|
// Before launch finishes, which is the only time iOS accepts it.
|
|
199
|
-
CraftBackgroundWork.shared.registerRefresh(
|
|
200
|
+
CraftBackgroundWork.shared.registerRefresh(config)
|
|
201
|
+
// The shell, the app script and an all-native app's services now,
|
|
202
|
+
// not when SwiftUI first draws: a launch in the background (a silent
|
|
203
|
+
// push, a refresh, a route recording's location) draws nothing, and
|
|
204
|
+
// its events still need someone to hear them.
|
|
205
|
+
CraftHybrid.shared.configure(config)
|
|
200
206
|
if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
|
201
207
|
CraftEventManager.shared.handleShortcut(shortcut)
|
|
202
208
|
// Returning false tells UIKit the launch-time item was handled and
|
|
@@ -269,12 +275,32 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
// MARK: - Native Event Manager
|
|
278
|
+
|
|
279
|
+
/// The app's own events: a notification tapped or arriving, a silent push,
|
|
280
|
+
/// background refresh, a home-screen or Siri shortcut. They go to the page,
|
|
281
|
+
/// or, in an app with an app script (`nativeApp`, CraftHybrid.swift), to that
|
|
282
|
+
/// script as `CRAFT_EVENT { event, detail }` instead. Either way an event
|
|
283
|
+
/// that arrives before its listener can hear it (a tap that launched the app)
|
|
284
|
+
/// waits for it, in order.
|
|
272
285
|
class CraftEventManager {
|
|
273
286
|
static let shared = CraftEventManager()
|
|
274
287
|
|
|
288
|
+
/// Who hears the app's events. Undecided until the shell is configured
|
|
289
|
+
/// at launch, which may be after the first event (a launch shortcut).
|
|
290
|
+
enum Listener {
|
|
291
|
+
case undecided
|
|
292
|
+
case page
|
|
293
|
+
case appScript
|
|
294
|
+
/// An all-native app with no app script: nothing hears them.
|
|
295
|
+
case none
|
|
296
|
+
}
|
|
297
|
+
|
|
275
298
|
private weak var webView: WKWebView?
|
|
276
299
|
private var isReady = false
|
|
300
|
+
private var listener: Listener = .undecided
|
|
277
301
|
private var pendingEvents: [(name: String, data: [String: Any])] = []
|
|
302
|
+
/// The app script's delivery, once its bundle has run.
|
|
303
|
+
private var appScriptSink: ((String, [String: Any]) -> Void)?
|
|
278
304
|
|
|
279
305
|
private init() {}
|
|
280
306
|
|
|
@@ -282,12 +308,35 @@ class CraftEventManager {
|
|
|
282
308
|
self.webView = webView
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
/// From `CraftHybrid.configure`: whether an app script hears the app's
|
|
312
|
+
/// events, and whether there is a page to hear them otherwise.
|
|
313
|
+
func configure(appScript: Bool, page: Bool) {
|
|
314
|
+
listener = appScript ? .appScript : (page ? .page : .none)
|
|
315
|
+
guard listener == .none else { return }
|
|
316
|
+
// Nothing will ever hear what is waiting; iOS gets its answer now
|
|
317
|
+
// rather than at the background deadline.
|
|
318
|
+
let events = pendingEvents
|
|
319
|
+
pendingEvents.removeAll()
|
|
320
|
+
for event in events { drop(event.name, data: event.data) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// The app script has run its bundle: whatever waited for it, in order.
|
|
324
|
+
func appScriptReady(_ sink: @escaping (String, [String: Any]) -> Void) {
|
|
325
|
+
appScriptSink = sink
|
|
326
|
+
guard listener == .appScript else { return }
|
|
327
|
+
let events = pendingEvents
|
|
328
|
+
pendingEvents.removeAll()
|
|
329
|
+
for event in events { sink(event.name, event.data) }
|
|
330
|
+
}
|
|
331
|
+
|
|
285
332
|
func setLoading() {
|
|
286
333
|
isReady = false
|
|
287
334
|
}
|
|
288
335
|
|
|
289
336
|
func setReady() {
|
|
290
337
|
isReady = true
|
|
338
|
+
// Held for the app script, which the page's readiness says nothing about.
|
|
339
|
+
guard listener != .appScript else { return }
|
|
291
340
|
let events = pendingEvents
|
|
292
341
|
pendingEvents.removeAll()
|
|
293
342
|
for event in events {
|
|
@@ -338,13 +387,37 @@ class CraftEventManager {
|
|
|
338
387
|
return true
|
|
339
388
|
}
|
|
340
389
|
|
|
341
|
-
/// Dispatch now if the page can hear it, or hold it
|
|
390
|
+
/// Dispatch now if the page (or the app script) can hear it, or hold it
|
|
391
|
+
/// until it can. The name says "web" for the page it was written for; the
|
|
392
|
+
/// event names are spelled at its call sites, where the conformance test
|
|
393
|
+
/// reads the vocabulary from.
|
|
342
394
|
func sendToWeb(_ event: String, data: [String: Any]) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
395
|
+
switch listener {
|
|
396
|
+
case .appScript:
|
|
397
|
+
guard let appScriptSink else {
|
|
398
|
+
pendingEvents.append((event, data))
|
|
399
|
+
// Background work cannot wait for a first frame that a
|
|
400
|
+
// launch in the background never draws.
|
|
401
|
+
if event == "craftSilentPush" || event == "craftBackgroundRefresh" { CraftHybrid.shared.backgroundWorkWaiting() }
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
appScriptSink(event, data)
|
|
405
|
+
case .none:
|
|
406
|
+
drop(event, data: data)
|
|
407
|
+
case .page, .undecided:
|
|
408
|
+
guard listener == .page, isReady, webView != nil else {
|
|
409
|
+
pendingEvents.append((event, data))
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
dispatch(event, data: data)
|
|
346
413
|
}
|
|
347
|
-
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// An event no one will hear: logged, and background work it holds is
|
|
417
|
+
/// answered at once.
|
|
418
|
+
private func drop(_ event: String, data: [String: Any]) {
|
|
419
|
+
NSLog("[craft] %@: the app has no page and no app script (nativeApp) to hear it", event)
|
|
420
|
+
if let id = data["id"] as? String { CraftBackgroundWork.shared.release(id) }
|
|
348
421
|
}
|
|
349
422
|
|
|
350
423
|
private func dispatch(_ event: String, data: [String: Any]) {
|
|
@@ -363,7 +436,9 @@ class CraftEventManager {
|
|
|
363
436
|
/// The page's share of background time: a silent push (`craftSilentPush`) and
|
|
364
437
|
/// iOS's scheduled app refresh (`craftBackgroundRefresh`). Each holds iOS's
|
|
365
438
|
/// completion until the page calls `craft.background.complete(ok)` (with the
|
|
366
|
-
/// event's `id`, or with none for everything pending), or for 25 seconds.
|
|
439
|
+
/// event's `id`, or with none for everything pending), or for 25 seconds. In
|
|
440
|
+
/// an app with an app script the script hears both and answers the same way,
|
|
441
|
+
/// through the native `Background.complete` (CraftNativeActions.swift).
|
|
367
442
|
///
|
|
368
443
|
/// The page has to be alive to do the work: when iOS wakes an app it had
|
|
369
444
|
/// suspended, it is; when it launches one it had ended, the event waits for
|
|
@@ -400,6 +475,12 @@ final class CraftBackgroundWork {
|
|
|
400
475
|
}
|
|
401
476
|
}
|
|
402
477
|
|
|
478
|
+
/// Work no one will do (nothing hears its event): iOS is answered now,
|
|
479
|
+
/// as the deadline would have.
|
|
480
|
+
func release(_ id: String) {
|
|
481
|
+
end(id, ok: nil)
|
|
482
|
+
}
|
|
483
|
+
|
|
403
484
|
private func end(_ id: String, ok: Bool?) {
|
|
404
485
|
guard let finish = pending.removeValue(forKey: id) else { return }
|
|
405
486
|
finish(ok)
|
|
@@ -461,11 +542,12 @@ struct CraftApp: App {
|
|
|
461
542
|
var body: some SwiftUI.Scene {
|
|
462
543
|
WindowGroup {
|
|
463
544
|
Group {
|
|
464
|
-
if
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
545
|
+
if CraftHybrid.shared.isEnabled {
|
|
546
|
+
// Native screens by path, with tabs (CraftHybrid.swift):
|
|
547
|
+
// the native screen for the start path at once, and in a
|
|
548
|
+
// web app (hybrid) the page warm behind it. An all-native
|
|
549
|
+
// app (renderer "native" with nativeScreens) has the same
|
|
550
|
+
// shell and no web view at all.
|
|
469
551
|
ZStack(alignment: .bottom) {
|
|
470
552
|
CraftColorSchemeObserver()
|
|
471
553
|
CraftHybridRoot(config: appState.config)
|
|
@@ -477,6 +559,9 @@ struct CraftApp: App {
|
|
|
477
559
|
// stack kept out of the keyboard's way and carried the bar
|
|
478
560
|
// up above it, over the field being typed in.
|
|
479
561
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
562
|
+
} else if appState.config.renderer == "native" {
|
|
563
|
+
// One native screen and its stack: no paths, no tabs.
|
|
564
|
+
CraftNativeScreen(config: appState.config)
|
|
480
565
|
} else {
|
|
481
566
|
// The page's own chrome, drawn natively over it: the tab
|
|
482
567
|
// bar the page asks for, and the splash until it is ready.
|
|
@@ -927,10 +1012,13 @@ class DeepLinkManager {
|
|
|
927
1012
|
}
|
|
928
1013
|
|
|
929
1014
|
// A hybrid app opens a native path's screen itself, and tells the
|
|
930
|
-
// native screens that listen; every other link is the page's.
|
|
1015
|
+
// native screens that listen; every other link is the page's. An
|
|
1016
|
+
// all-native app has no page: its native screens hear every link.
|
|
931
1017
|
if CraftHybrid.shared.isEnabled {
|
|
932
1018
|
if CraftHybrid.shared.handleDeepLink(url) {
|
|
933
1019
|
dispatchNative(url, initial: false)
|
|
1020
|
+
} else if !CraftHybrid.shared.hasWeb {
|
|
1021
|
+
if nativeListeners.isEmpty { pendingURLs.append(url) } else { dispatchNative(url, initial: false) }
|
|
934
1022
|
} else if isReady && webView != nil {
|
|
935
1023
|
dispatchDeepLink(url, initial: false)
|
|
936
1024
|
} else {
|
|
@@ -958,7 +1046,8 @@ class DeepLinkManager {
|
|
|
958
1046
|
nativeListeners[token] = listener
|
|
959
1047
|
// In a hybrid app the page is the one waiting for the links that
|
|
960
1048
|
// arrived early; a native screen hears only native ones, as they come.
|
|
961
|
-
|
|
1049
|
+
// An all-native app has no page, so its first screen hears them.
|
|
1050
|
+
if CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb { return token }
|
|
962
1051
|
let firstNativeScreen = !hasBeenReady
|
|
963
1052
|
hasBeenReady = true
|
|
964
1053
|
let urls = pendingURLs
|
|
@@ -1027,7 +1116,8 @@ class AppState: ObservableObject {
|
|
|
1027
1116
|
self.config = CraftConfig()
|
|
1028
1117
|
}
|
|
1029
1118
|
// Before the first frame: the hybrid start screen, and the tab bar
|
|
1030
|
-
// from the config, are decided here
|
|
1119
|
+
// from the config, are decided here, if the app delegate has not
|
|
1120
|
+
// already (it does at launch; the first call decides).
|
|
1031
1121
|
CraftHybrid.shared.configure(config)
|
|
1032
1122
|
}
|
|
1033
1123
|
}
|
|
@@ -1149,6 +1239,9 @@ struct CraftConfig: Codable {
|
|
|
1149
1239
|
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1150
1240
|
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1151
1241
|
var shareStorage: [String: String]? = nil
|
|
1242
|
+
/// The bundle route of the app script: a screen-less script that runs for
|
|
1243
|
+
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
1244
|
+
var nativeApp: String? = nil
|
|
1152
1245
|
|
|
1153
1246
|
struct Tab: Codable {
|
|
1154
1247
|
var id: String
|
|
@@ -8500,8 +8593,10 @@ final class CraftZigRuntime: NSObject {
|
|
|
8500
8593
|
/// started on a page can be followed on a native screen.
|
|
8501
8594
|
///
|
|
8502
8595
|
/// The coordinator exists wherever the web view does: always in a hybrid app.
|
|
8503
|
-
///
|
|
8504
|
-
///
|
|
8596
|
+
/// An app with no web view (renderer "native") has one all the same, without
|
|
8597
|
+
/// a web view (`CraftHybrid.configure`), so its native screens have these
|
|
8598
|
+
/// services too. Calls go to the Swift dispatcher; a build that links the Zig
|
|
8599
|
+
/// runtime keeps the page's calls on Zig.
|
|
8505
8600
|
enum CraftWebServices {
|
|
8506
8601
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
8507
8602
|
/// Coordinator events a native screen hears too.
|
|
@@ -8558,7 +8653,8 @@ enum CraftWebServices {
|
|
|
8558
8653
|
/// how this codebase accumulated five envelopes.
|
|
8559
8654
|
@objc(CraftSwiftShim)
|
|
8560
8655
|
final class CraftSwiftShim: NSObject {
|
|
8561
|
-
/// The coordinator serving hand-offs. Set by `makeCoordinator
|
|
8656
|
+
/// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
|
|
8657
|
+
/// app with no web view by `CraftHybrid.configure`, which holds it.
|
|
8562
8658
|
static weak var coordinator: CraftWebView.Coordinator?
|
|
8563
8659
|
|
|
8564
8660
|
private typealias DeliverResultFn = @convention(c) (
|