craft-native 0.0.155 → 0.0.158

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
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.155";
3306
+ var version = "0.0.158";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
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.155";
313
+ var version = "0.0.158";
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.155";
61
+ var version = "0.0.158";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -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`. */
@@ -66,9 +66,11 @@ function serializeSimulatorSteps(steps) {
66
66
  throw new Error("Simulator identifiers must be nonempty single tokens");
67
67
  return value;
68
68
  };
69
- return steps.map((step) => {
69
+ const line = (step) => {
70
70
  let arg = "";
71
- if ("seconds" in step && !("text" in step))
71
+ if ("step" in step)
72
+ arg = `${token(step.id)} ${line(step.step)}`;
73
+ else if ("seconds" in step && !("text" in step))
72
74
  arg = String(number(step.seconds));
73
75
  else if ("milliseconds" in step)
74
76
  arg = String(number(step.milliseconds));
@@ -100,8 +102,9 @@ function serializeSimulatorSteps(steps) {
100
102
  if (/[\r\n]/.test(arg))
101
103
  throw new Error("Simulator arguments cannot contain newlines");
102
104
  return `${step.command}${arg ? ` ${arg}` : ""}`;
103
- }).join(`
104
- `) + `
105
+ };
106
+ return `${steps.map(line).join(`
107
+ `)}
105
108
  `;
106
109
  }
107
110
  function createNativeMutationValidator() {
@@ -228,11 +231,25 @@ function matchNativePath(nativeScreens, raw) {
228
231
  function isHybrid(config) {
229
232
  return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
230
233
  }
234
+ function hasNativeRoutes(config) {
235
+ return config.renderer === "native" && Object.keys(config.nativeScreens ?? {}).length > 0;
236
+ }
237
+ function usesNativeBundle(config) {
238
+ return config.renderer === "native" || Object.keys(config.nativeScreens ?? {}).length > 0;
239
+ }
240
+ function nativeStartPath(config) {
241
+ if (!config.devServerURL)
242
+ return "/";
243
+ try {
244
+ const url = new URL(config.devServerURL);
245
+ return `${url.pathname || "/"}${url.search}`;
246
+ } catch {
247
+ return "/";
248
+ }
249
+ }
231
250
  function hybridConfigProblems(config) {
232
251
  const problems = [];
233
252
  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
253
  for (const [pattern, screen] of screens) {
237
254
  if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
238
255
  problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
@@ -254,6 +271,21 @@ function hybridConfigProblems(config) {
254
271
  if (!key || typeof target !== "string" || !target)
255
272
  problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
256
273
  }
274
+ if (hasNativeRoutes(config)) {
275
+ for (const id of ids) {
276
+ if (!matchNativePath(config.nativeScreens, id))
277
+ problems.push(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
278
+ }
279
+ const start = nativeStartPath(config);
280
+ if (!matchNativePath(config.nativeScreens, start))
281
+ problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
282
+ }
283
+ if (config.nativeApp !== undefined) {
284
+ if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
285
+ problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
286
+ else if (!usesNativeBundle(config))
287
+ problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
288
+ }
257
289
  return problems;
258
290
  }
259
291
  function installNativeBundle(source, output) {
@@ -1044,11 +1076,13 @@ ${nativeDependencies.join(`
1044
1076
  writeFileSync2(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
1045
1077
  }
1046
1078
  writeFileSync2(join2(output, "dist", "index.html"), renderOfflinePage(config));
1047
- if (isHybrid(config)) {
1079
+ if (usesNativeBundle(config)) {
1048
1080
  if (config.nativeBundle)
1049
1081
  installNativeBundle(config.nativeBundle, output);
1050
- else
1082
+ else if (isHybrid(config))
1051
1083
  console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
1084
+ else if (hasNativeRoutes(config))
1085
+ console.warn(" \u26A0 nativeScreens is set without nativeBundle: the app has no screens until `craft ios build --native-bundle` supplies one.");
1052
1086
  }
1053
1087
  console.log("\u2705 Project initialized");
1054
1088
  console.log("");
@@ -1080,11 +1114,9 @@ async function build(options) {
1080
1114
  if (htmlPath || devServer) {
1081
1115
  throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
1082
1116
  }
1083
- if (nativeBundlePath) {
1084
- const destination = join2(output, "dist", "native-screen.js");
1085
- if (resolve(nativeBundlePath) !== resolve(destination))
1086
- cpSync(nativeBundlePath, destination);
1087
- }
1117
+ const bundle = nativeBundlePath ?? config.nativeBundle;
1118
+ if (bundle)
1119
+ installNativeBundle(bundle, output);
1088
1120
  if (!existsSync(join2(output, "dist", "native-screen.js"))) {
1089
1121
  throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
1090
1122
  }
@@ -1238,6 +1270,7 @@ async function run(options) {
1238
1270
  }
1239
1271
  }
1240
1272
  export {
1273
+ usesNativeBundle,
1241
1274
  syncWebAssets,
1242
1275
  showSimulator,
1243
1276
  serializeSimulatorSteps,
@@ -1266,12 +1299,14 @@ export {
1266
1299
  orderSimulators,
1267
1300
  open,
1268
1301
  normalizeNativePath,
1302
+ nativeStartPath,
1269
1303
  matchNativePath,
1270
1304
  isHybrid,
1271
1305
  installRuntime,
1272
1306
  initSimulatorDriver,
1273
1307
  init,
1274
1308
  hybridConfigProblems,
1309
+ hasNativeRoutes,
1275
1310
  createNativeMutationValidator,
1276
1311
  build,
1277
1312
  bootSimulator,
@@ -36,7 +36,7 @@ export type SimulatorStep = {
36
36
  index: number;
37
37
  text: string;
38
38
  } | {
39
- command: 'typeid';
39
+ command: 'typeid' | 'labelid';
40
40
  id: string;
41
41
  text: string;
42
42
  } | {
@@ -52,6 +52,12 @@ export type SimulatorStep = {
52
52
  } | {
53
53
  command: 'openurl';
54
54
  url: string;
55
+ }
56
+ /** Runs `step` only when the element with this identifier is there (a screen shown the first time). */
57
+ | {
58
+ command: 'ifid';
59
+ id: string;
60
+ step: SimulatorStep;
55
61
  };
56
62
  /** Typed journeys use the same wire protocol as existing line-based scripts. */
57
63
  export declare function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string;
@@ -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(CraftConfig.bundled())
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 until it can.
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
- guard isReady, webView != nil else {
344
- pendingEvents.append((event, data))
345
- return
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
- dispatch(event, data: data)
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 appState.config.renderer == "native" {
465
- CraftNativeScreen(config: appState.config)
466
- } else if CraftHybrid.shared.isEnabled {
467
- // Web plus native screens (CraftHybrid.swift): the native
468
- // screen for the start path at once, the page warm behind it.
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
- if CraftHybrid.shared.isEnabled { return token }
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
- /// Calls go to the Swift dispatcher; a build that links the Zig runtime keeps
8504
- /// the page's calls on Zig.
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) (