craft-native 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 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.156";
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.156";
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.156";
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`. */
@@ -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 (isHybrid(config)) {
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
- if (nativeBundlePath) {
1084
- const destination = join2(output, "dist", "native-screen.js");
1085
- if (resolve(nativeBundlePath) !== resolve(destination))
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,
@@ -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) (
@@ -18,6 +18,18 @@ import WebKit
18
18
  // reports where it went, and leaves the edge swipe to UIKit while it is at the
19
19
  // entry a native screen opened. Nothing in the page has to change for that;
20
20
  // craft's typescript mobile API adds `snapshots` and `hybrid` on top.
21
+ //
22
+ // `renderer: "native"` with `nativeScreens` is the same shell with no web
23
+ // view at all (`hasWeb` false): the same tabs, stacks and path routing, every
24
+ // tab and the start path a native screen, and a path no screen draws opened
25
+ // in the in-app browser on the app's site (`devServerURL`). No WKWebView is
26
+ // ever made; the services the web view's coordinator holds (GPS, the route
27
+ // recorder, Apple Music) come from a coordinator with no web view instead.
28
+ //
29
+ // Either way an app may run an app script (`nativeApp`): a bundle route with
30
+ // no screen, loaded once after the first frame and kept for the app's life.
31
+ // It hears the app's events (CraftEventManager) instead of the page, and
32
+ // opens paths through the shell.
21
33
 
22
34
  /// The `nativeScreens` table: which paths are native, and with what params.
23
35
  ///
@@ -128,6 +140,34 @@ struct CraftHybridRoutes {
128
140
  return path
129
141
  }
130
142
 
143
+ /// Whether a web address is on the app's site: the host of `site`
144
+ /// (`devServerURL`), over http or https.
145
+ static func isSite(_ url: URL, site: URL?) -> Bool {
146
+ let scheme = (url.scheme ?? "").lowercased()
147
+ guard scheme == "http" || scheme == "https",
148
+ let host = url.host?.lowercased(), !host.isEmpty,
149
+ let siteHost = site?.host?.lowercased() else { return false }
150
+ return host == siteHost
151
+ }
152
+
153
+ /// Where a path no native screen draws opens in an app with no web view:
154
+ /// a web address as it is, an app path on the origin of `site`. Nil for
155
+ /// anything else, and for a path when there is no site.
156
+ static func siteURL(for raw: String, site: URL?) -> URL? {
157
+ if let url = URL(string: raw), let scheme = url.scheme?.lowercased() {
158
+ return scheme == "http" || scheme == "https" ? url : nil
159
+ }
160
+ // `//host/x` is another host, not a path.
161
+ guard raw.hasPrefix("/"), !raw.hasPrefix("//"), let site,
162
+ var origin = URLComponents(url: site, resolvingAgainstBaseURL: false),
163
+ let scheme = origin.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
164
+ origin.percentEncodedPath = ""
165
+ origin.percentEncodedQuery = nil
166
+ origin.percentEncodedFragment = nil
167
+ guard let base = origin.url else { return nil }
168
+ return URL(string: raw, relativeTo: base)?.absoluteURL
169
+ }
170
+
131
171
  /// The tab a path belongs to: the tab whose id is the path, or else the
132
172
  /// longest tab id that is a whole-segment prefix of it.
133
173
  static func tab(owning raw: String, among tabs: [String]) -> String? {
@@ -233,8 +273,15 @@ final class CraftHybrid: NSObject {
233
273
 
234
274
  private(set) var config = CraftConfig()
235
275
  private(set) var routes = CraftHybridRoutes([:])
236
- /// Whether this app mixes native screens into its web page.
276
+ /// Whether this app routes paths to native screens through the shell:
277
+ /// web plus native screens (hybrid), or all native with paths and tabs.
237
278
  private(set) var isEnabled = false
279
+ /// Whether the app has a web page at all (renderer "web"). False for an
280
+ /// all-native app, whose shell never makes a web view.
281
+ private(set) var hasWeb = true
282
+ /// The app's site (`devServerURL`, http or https): where an all-native
283
+ /// app opens a path no screen draws, in the in-app browser.
284
+ private(set) var siteURL: URL?
238
285
  /// The path the app opens on: its page's path.
239
286
  private(set) var startPath = "/"
240
287
  /// Whether that path is a native screen, drawn before the page loads.
@@ -242,6 +289,22 @@ final class CraftHybrid: NSObject {
242
289
  weak var controller: CraftHybridController?
243
290
  weak var webView: WKWebView?
244
291
 
292
+ /// The app script's bundle route (`nativeApp`), when it runs.
293
+ private(set) var appScriptRoute: String?
294
+ /// The app script itself, held for the app's life and never shown.
295
+ private var appScript: CraftNativeScreenController?
296
+ /// An all-native app's services: the web view's coordinator, with no web
297
+ /// view, so GPS, the route recorder (and its relaunch restore) and Apple
298
+ /// Music serve native screens (CraftWebServices). Held here, because
299
+ /// `CraftSwiftShim.coordinator` is weak.
300
+ private var services: CraftWebView.Coordinator?
301
+ private var isConfigured = false
302
+ /// A single-stack native app's first frame, which starts the app script.
303
+ private var firstFrameSeen = false
304
+ /// What waits for the shell: links and opens that arrived before it
305
+ /// existed (a launch from a notification or a link).
306
+ private var untilShell: [(CraftHybridController) -> Void] = []
307
+
245
308
  /// Where the page is, as it last said.
246
309
  fileprivate(set) var pagePath: String?
247
310
  fileprivate(set) var pageDepth = 0
@@ -261,6 +324,7 @@ final class CraftHybrid: NSObject {
261
324
  func nativeWroteShared(nativeKey: String, value: String?) {
262
325
  guard let webKey = config.shareStorage?.first(where: { $0.value == nativeKey })?.key else { return }
263
326
  CraftHybridKeychain.set(nativeKey, value: value)
327
+ guard hasWeb else { return }
264
328
  DispatchQueue.main.async {
265
329
  self.pendingShared[webKey] = value ?? NSNull()
266
330
  self.flushShared()
@@ -279,20 +343,113 @@ final class CraftHybrid: NSObject {
279
343
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
280
344
  }
281
345
 
282
- /// From the bundled config, before the first frame: whether the app is
283
- /// hybrid, where it starts, and the tab bar it starts with.
346
+ /// From the bundled config, at launch: whether the shell routes paths,
347
+ /// whether there is a web view, where it starts, the tab bar it starts
348
+ /// with, the app script, and an all-native app's services. Once: the app
349
+ /// delegate calls it at launch and AppState again; the first call decides.
284
350
  func configure(_ config: CraftConfig) {
351
+ guard !isConfigured else { return }
352
+ isConfigured = true
285
353
  self.config = config
354
+ hasWeb = config.renderer != "native"
355
+ siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
356
+ ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
357
+ }
286
358
  routes = CraftHybridRoutes(config.nativeScreens ?? [:])
287
359
  startPath = config.devServerURL.flatMap(URL.init(string:)).map(CraftHybridRoutes.appPath(of:)) ?? "/"
288
360
  if startPath.isEmpty { startPath = "/" }
289
- let wanted = config.renderer == "web" && !routes.isEmpty
361
+ let wanted = !routes.isEmpty
290
362
  if wanted && Self.bundleURL == nil {
291
- NSLog("[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view")
363
+ NSLog(hasWeb
364
+ ? "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view"
365
+ : "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
292
366
  }
293
367
  isEnabled = wanted && Self.bundleURL != nil
368
+ if isEnabled, !hasWeb, routes.match(startPath) == nil {
369
+ // No web view to show it in: the first tab a screen draws.
370
+ let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
371
+ NSLog("[craft hybrid] no native screen draws the start path %@ and there is no web view; starting on %@", startPath, fallback ?? "nothing")
372
+ if let fallback { startPath = fallback }
373
+ }
294
374
  startsNative = isEnabled && routes.match(startPath) != nil
295
- CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: !startsNative)
375
+ // The splash waits for a page; an all-native app's first screen draws itself.
376
+ CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
377
+
378
+ let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
379
+ appScriptRoute = script != nil && Self.bundleURL != nil && (isEnabled || !hasWeb) ? script : nil
380
+ if let script, appScriptRoute == nil {
381
+ NSLog("[craft hybrid] nativeApp %@ does not run: it needs dist/native-screen.js, and nativeScreens or renderer \"native\"", script)
382
+ }
383
+ CraftEventManager.shared.configure(appScript: appScriptRoute != nil, page: hasWeb)
384
+
385
+ if !hasWeb && services == nil {
386
+ let coordinator = CraftWebView.Coordinator(config: config)
387
+ services = coordinator
388
+ CraftSwiftShim.coordinator = coordinator
389
+ }
390
+
391
+ guard appScriptRoute != nil else { return }
392
+ // It starts after the first frame (`nativeScreenDidRender`), or as
393
+ // soon as background work waits for it (`backgroundWorkWaiting`).
394
+ // This is for the rest: a first screen that is the page's, one that
395
+ // never draws, a launch in the background with nothing to draw.
396
+ // (`applicationState` cannot tell those apart here: a scene-based app
397
+ // is `.background` during launch however it was opened.)
398
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
399
+ }
400
+
401
+ /// A silent push or a background refresh is waiting for the app script,
402
+ /// with iOS's clock running: it starts now rather than after a frame,
403
+ /// which a launch in the background never draws.
404
+ func backgroundWorkWaiting() {
405
+ guard appScriptRoute != nil, appScript == nil else { return }
406
+ DispatchQueue.main.async { self.startAppScript() }
407
+ }
408
+
409
+ /// The app script: loaded once, off screen, and kept. Started after the
410
+ /// first frame is committed, so it never delays it, or at once when
411
+ /// background work is waiting for it. Its `craft.navigation.open` comes here
412
+ /// (`nativeScreen(_:open:)`), and so to the shell.
413
+ func startAppScript() {
414
+ guard appScript == nil, let route = appScriptRoute else { return }
415
+ let script = CraftNativeScreenController(config: config, routeName: route, isAppScript: true)
416
+ script.hybridEvents = self
417
+ appScript = script
418
+ script.loadViewIfNeeded()
419
+ #if DEBUG
420
+ NSLog("[craft hybrid] app script %@ is running", route)
421
+ #endif
422
+ }
423
+
424
+ /// The shell is up: what waited for it.
425
+ func shellReady() {
426
+ guard let controller else { return }
427
+ let waiting = untilShell
428
+ untilShell.removeAll()
429
+ for work in waiting { work(controller) }
430
+ }
431
+
432
+ /// A path or a web address opened by the app script or a single-stack
433
+ /// screen: the shell opens it (holding it until the shell exists); an app
434
+ /// with no shell opens a web address in the in-app browser.
435
+ func open(_ path: String) {
436
+ if let controller {
437
+ controller.open(path)
438
+ } else if isEnabled {
439
+ untilShell.append { $0.open(path) }
440
+ } else {
441
+ openOutside(path)
442
+ }
443
+ }
444
+
445
+ /// A path no native screen draws, in an app with no web view, or an
446
+ /// address on another site: the in-app browser.
447
+ func openOutside(_ raw: String) {
448
+ guard let url = CraftHybridRoutes.siteURL(for: raw, site: siteURL) else {
449
+ NSLog("[craft hybrid] %@ has no native screen, and the app has no site (devServerURL) to open it on", raw)
450
+ return
451
+ }
452
+ CraftBrowser.shared.open(url, mode: "safari", callbackScheme: nil, from: nil) { _ in }
296
453
  }
297
454
 
298
455
  private func tabIds(from tabs: [CraftConfig.Tab]) -> [String] { tabs.map(\.id) }
@@ -303,8 +460,10 @@ final class CraftHybrid: NSObject {
303
460
  return shown.isEmpty ? (config.tabs ?? []).map(\.id) : shown
304
461
  }
305
462
 
306
- /// Whether the web view is what the person sees: always, outside hybrid.
463
+ /// Whether the web view is what the person sees: always in a web app
464
+ /// outside hybrid, never in an app with no web view.
307
465
  var webIsVisible: Bool {
466
+ guard hasWeb else { return false }
308
467
  guard isEnabled else { return true }
309
468
  return controller?.webIsVisible ?? !startsNative
310
469
  }
@@ -631,11 +790,7 @@ final class CraftHybrid: NSObject {
631
790
  case .snapshotRemove(let name):
632
791
  CraftSnapshots.write(name, json: nil)
633
792
  case .snapshotClear:
634
- let names = CraftSnapshots.directory
635
- .flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
636
- .filter { $0.hasSuffix(".json") }
637
- .map { String($0.dropLast(5)) } ?? []
638
- for name in names { CraftSnapshots.write(name, json: nil) }
793
+ CraftSnapshots.clear()
639
794
  case .shareStorage(let key, let value):
640
795
  guard let nativeKey = config.shareStorage?[key] else { return }
641
796
  // The page's own write, or its echo of one a native screen made.
@@ -675,12 +830,26 @@ final class CraftHybrid: NSObject {
675
830
  controller?.pageSelectedTab(id)
676
831
  }
677
832
 
678
- /// A deep link to a native path opens the native screen; true when it did.
833
+ /// A deep link to a native path opens the native screen; in an app with
834
+ /// no web view, a link to its own site that no screen draws opens in the
835
+ /// in-app browser. True when the shell took it.
679
836
  func handleDeepLink(_ url: URL) -> Bool {
680
- guard isEnabled, let controller else { return false }
837
+ guard isEnabled else { return false }
681
838
  let path = CraftHybridRoutes.appPath(of: url)
682
- guard routes.match(path) != nil else { return false }
683
- controller.showNative(path: path, replace: false, fromLink: true)
839
+ let native = routes.match(path) != nil
840
+ let browse = !native && !hasWeb && CraftHybridRoutes.isSite(url, site: siteURL)
841
+ guard native || browse else { return false }
842
+ let show: (CraftHybridController) -> Void = { [weak self] controller in
843
+ if native { controller.showNative(path: path, replace: false, fromLink: true) } else { self?.openOutside(url.absoluteString) }
844
+ }
845
+ guard let controller else {
846
+ // Before the shell: a hybrid app's page has it; an all-native app
847
+ // opens it once the shell is up (a link that launched the app).
848
+ guard !hasWeb else { return false }
849
+ untilShell.append(show)
850
+ return true
851
+ }
852
+ show(controller)
684
853
  return true
685
854
  }
686
855
 
@@ -702,6 +871,7 @@ final class CraftHybrid: NSObject {
702
871
  answered = true
703
872
  completion(depth)
704
873
  }
874
+ guard hasWeb else { answer(nil); return }
705
875
  guard let webView, pageReady, let scheme = webView.url?.scheme, scheme == "http" || scheme == "https" else {
706
876
  CraftSwiftShim.coordinator?.loadAppPath(path)
707
877
  answer(nil)
@@ -719,20 +889,43 @@ final class CraftHybrid: NSObject {
719
889
  }
720
890
 
721
891
  func setPageBase(_ depth: Int?) {
892
+ guard hasWeb else { return }
722
893
  let value = depth.map(String.init) ?? "null"
723
894
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.setBase(\(value))", completionHandler: nil)
724
895
  }
725
896
 
726
897
  func pageReset(to path: String) {
727
- guard let data = try? JSONSerialization.data(withJSONObject: [path]), let json = String(data: data, encoding: .utf8) else { return }
898
+ guard hasWeb, let data = try? JSONSerialization.data(withJSONObject: [path]), let json = String(data: data, encoding: .utf8) else { return }
728
899
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.reset && window.__craftHybrid.reset(\(json)[0])", completionHandler: nil)
729
900
  }
730
901
 
731
902
  func pageBack(to depth: Int) {
903
+ guard hasWeb else { return }
732
904
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.backTo(\(max(0, depth)))", completionHandler: nil)
733
905
  }
734
906
  }
735
907
 
908
+ /// What the app script and a single-stack native app's screens tell the shell.
909
+ extension CraftHybrid: CraftHybridScreenEvents {
910
+ /// A single-stack app's first frame: the app script starts once it is
911
+ /// committed. (The routed shell's controller does the same for its own.)
912
+ func nativeScreenDidRender(_ screen: CraftNativeScreenController) {
913
+ guard !firstFrameSeen else { return }
914
+ firstFrameSeen = true
915
+ CATransaction.setCompletionBlock { [weak self] in self?.startAppScript() }
916
+ }
917
+
918
+ /// The screen keeps its own error; the app script still runs.
919
+ func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
920
+ NSLog("[craft hybrid] the native screen could not draw its first frame: %@", message)
921
+ DispatchQueue.main.async { self.startAppScript() }
922
+ }
923
+
924
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
925
+ open(path)
926
+ }
927
+ }
928
+
736
929
  /// The page's `craftHybrid` channel, only from a trusted origin's main frame.
737
930
  final class CraftHybridRelay: NSObject, WKScriptMessageHandler {
738
931
  private let trusts: (WKSecurityOrigin) -> Bool
@@ -796,7 +989,8 @@ final class CraftHybridWebSlot: UIViewController {
796
989
  final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
797
990
  private let config: CraftConfig
798
991
  private let hybrid = CraftHybrid.shared
799
- private let webHost: UIHostingController<AnyView>
992
+ /// The web view's host; nil in an all-native app, which never makes one.
993
+ private let webHost: UIHostingController<AnyView>?
800
994
  /// Where the web view waits, behind the screens, while none of them is web.
801
995
  private let parking = UIView()
802
996
  private var stacks: [String: UINavigationController] = [:]
@@ -816,7 +1010,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
816
1010
  // Edge to edge, but not under the keyboard: the web view ends at its
817
1011
  // top. Under it, WebKit scrolled the page's layout viewport to show a
818
1012
  // focused field and carried every sticky header off the screen.
819
- webHost = UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea(.container)))
1013
+ webHost = CraftHybrid.shared.hasWeb
1014
+ ? UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea(.container)))
1015
+ : nil
820
1016
  super.init(nibName: nil, bundle: nil)
821
1017
  hybrid.controller = self
822
1018
  }
@@ -831,21 +1027,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
831
1027
  view.addSubview(parking)
832
1028
 
833
1029
  // A native start screen draws first; the web view, whose creation
834
- // costs a frame or more, starts loading straight after it.
835
- if !hybrid.startsNative { attachWeb() }
1030
+ // costs a frame or more, starts loading straight after it. An
1031
+ // all-native app has none.
1032
+ if hybrid.hasWeb && !hybrid.startsNative { attachWeb() }
836
1033
  let tabs = hybrid.tabIds
837
1034
  let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
838
1035
  show(tab: start, rootPath: hybrid.startPath)
839
- if hybrid.startsNative {
1036
+ if hybrid.hasWeb && hybrid.startsNative {
840
1037
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
841
1038
  }
1039
+ // Links and opens that arrived before the shell, once the start
1040
+ // screen is up.
1041
+ DispatchQueue.main.async { [weak self] in self?.hybrid.shellReady() }
842
1042
  }
843
1043
 
844
1044
  private var webAttached = false
845
1045
 
846
1046
  /// The web view joins the screen, behind the native screens, and loads.
847
1047
  private func attachWeb() {
848
- guard !webAttached else { return }
1048
+ guard let webHost, !webAttached else { return }
849
1049
  webAttached = true
850
1050
  webHost.view.backgroundColor = config.resolvedBackgroundColor
851
1051
  if #available(iOS 16.4, *) { webHost.safeAreaRegions = [.keyboard] }
@@ -859,6 +1059,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
859
1059
  /// The web view's controller is the child of whichever controller shows
860
1060
  /// it: UIKit refuses a child's view in another controller's hierarchy.
861
1061
  private func moveWeb(to owner: UIViewController, in container: UIView) {
1062
+ guard let webHost else { return }
862
1063
  if webHost.parent !== owner {
863
1064
  if webHost.parent != nil {
864
1065
  webHost.willMove(toParent: nil)
@@ -888,7 +1089,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
888
1089
  private var visibleStack: UINavigationController? { stacks[currentTab] }
889
1090
 
890
1091
  var webIsVisible: Bool {
891
- guard let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
1092
+ guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
892
1093
  return webHost.view.superview === slot.view
893
1094
  }
894
1095
 
@@ -931,6 +1132,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
931
1132
  nativePaths[ObjectIdentifier(screen)] = match.path
932
1133
  return screen
933
1134
  }
1135
+ guard hybrid.hasWeb else {
1136
+ // The config checks rule this out (every tab and the start path
1137
+ // are native screens); an empty screen rather than a web entry
1138
+ // nothing would ever fill.
1139
+ NSLog("[craft hybrid] no native screen draws %@, and the app has no web view", path)
1140
+ let empty = UIViewController()
1141
+ empty.view.backgroundColor = config.resolvedBackgroundColor
1142
+ return empty
1143
+ }
934
1144
  return CraftHybridWebSlot(path: path, depth: depth, isTabRoot: isTabRoot)
935
1145
  }
936
1146
 
@@ -959,7 +1169,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
959
1169
  }
960
1170
 
961
1171
  func adoptWeb(into slot: CraftHybridWebSlot) {
962
- guard slot.navigationController === visibleStack || visibleStack == nil else { return }
1172
+ guard let webHost, slot.navigationController === visibleStack || visibleStack == nil else { return }
963
1173
  attachWeb()
964
1174
  moveWeb(to: slot, in: slot.view)
965
1175
  webHost.view.alpha = slot.awaitingPage ? 0 : 1
@@ -967,7 +1177,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
967
1177
  }
968
1178
 
969
1179
  private func parkWeb() {
970
- guard webAttached else { return }
1180
+ guard webAttached, let webHost else { return }
971
1181
  webHost.view.alpha = 1
972
1182
  moveWeb(to: self, in: parking)
973
1183
  }
@@ -975,8 +1185,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
975
1185
  // MARK: Opening paths
976
1186
 
977
1187
  /// `craft.navigation.open(path)` or `craft.hybrid.open(path)`: a tab, a
978
- /// native screen, or the web view pushed over the screen that asked.
1188
+ /// native screen, or the web view pushed over the screen that asked. In
1189
+ /// an app with no web view, a path no screen draws opens in the in-app
1190
+ /// browser on the app's site. A web address on the app's site is its
1191
+ /// path; one anywhere else opens in the in-app browser.
979
1192
  func open(_ raw: String) {
1193
+ if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
1194
+ guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
1195
+ hybrid.openOutside(raw)
1196
+ return
1197
+ }
1198
+ open(CraftHybridRoutes.appPath(of: url))
1199
+ return
1200
+ }
980
1201
  guard let path = CraftHybridRoutes.normalize(raw) else { return }
981
1202
  if let tab = hybrid.tabIds.first(where: { CraftHybridRoutes.normalize($0) == path }) {
982
1203
  selectTabAsIfTapped(tab)
@@ -986,7 +1207,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
986
1207
  showNative(path: raw, replace: false)
987
1208
  return
988
1209
  }
989
- pushWeb(raw)
1210
+ if hybrid.hasWeb {
1211
+ pushWeb(raw)
1212
+ } else {
1213
+ hybrid.openOutside(raw)
1214
+ }
990
1215
  }
991
1216
 
992
1217
  private func selectTabAsIfTapped(_ id: String) {
@@ -1035,7 +1260,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1035
1260
  /// The web view, pushed: the page goes to `path` first, without its own
1036
1261
  /// slide, then the entry slides in over the screen that opened it.
1037
1262
  private func pushWeb(_ path: String) {
1038
- guard !openingWeb, let nav = visibleStack else { return }
1263
+ guard hybrid.hasWeb, !openingWeb, let nav = visibleStack else { return }
1039
1264
  openingWeb = true
1040
1265
  hybrid.navigatePage(path) { [weak self, weak nav] depth in
1041
1266
  guard let self else { return }
@@ -1056,8 +1281,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1056
1281
 
1057
1282
  private func reveal(_ slot: CraftHybridWebSlot) {
1058
1283
  slot.awaitingPage = false
1059
- guard webHost.view.superview === slot.view else { return }
1060
- UIView.animate(withDuration: 0.15) { self.webHost.view.alpha = 1 }
1284
+ guard let webHost, webHost.view.superview === slot.view else { return }
1285
+ UIView.animate(withDuration: 0.15) { webHost.view.alpha = 1 }
1061
1286
  }
1062
1287
 
1063
1288
  /// Back from the web entry on top, as the page's Back at that entry asks.
@@ -1113,6 +1338,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1113
1338
  // then its own first screen).
1114
1339
  return nav.topViewController is CraftHybridWebSlot
1115
1340
  }
1341
+ // No page to wait for: the tab's own stack, now.
1342
+ guard hybrid.hasWeb else {
1343
+ show(tab: id)
1344
+ return true
1345
+ }
1116
1346
  let target = stacks[id]
1117
1347
  let rootIsWeb = target.map { $0.topViewController is CraftHybridWebSlot } ?? (hybrid.routes.match(id) == nil)
1118
1348
  let pageThere = hybrid.pagePath.flatMap { CraftHybridRoutes.tab(owning: $0, among: hybrid.tabIds) } == id
@@ -1265,12 +1495,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1265
1495
  CATransaction.setCompletionBlock { [weak self] in
1266
1496
  NSLog("[craft hybrid] native first frame %@", path)
1267
1497
  self?.attachWeb()
1498
+ // The app script after the first frame, never before it.
1499
+ self?.hybrid.startAppScript()
1268
1500
  }
1269
1501
  CraftChrome.shared.hideSplash()
1270
1502
  }
1271
1503
 
1272
1504
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
1273
1505
  let path = nativePaths[ObjectIdentifier(screen)] ?? hybrid.startPath
1506
+ guard hybrid.hasWeb else {
1507
+ // No web view to open it in: the screen keeps its own error.
1508
+ NSLog("[craft hybrid] native screen for %@ failed (%@); the app has no web view to open it in", path, message)
1509
+ if !firstFrameLogged { DispatchQueue.main.async { [weak self] in self?.hybrid.startAppScript() } }
1510
+ return
1511
+ }
1274
1512
  NSLog("[craft hybrid] native screen for %@ failed (%@); opening it in the web view", path, message)
1275
1513
  DispatchQueue.main.async { [weak self, weak screen] in
1276
1514
  guard let self, let screen, let nav = screen.navigationController else { return }
@@ -57,10 +57,14 @@ enum CraftNativeActions {
57
57
  values.append(contentsOf: ["brightness", "statusBar"])
58
58
  if config.enableKeepAwake { values.append("keepAwake") }
59
59
  // GPS and the route recording are the web view's coordinator's
60
- // (CraftWebServices), so a native-only app, which has none, has none.
61
- if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
60
+ // (CraftWebServices); an app with no web view has the coordinator
61
+ // all the same (CraftHybrid.configure).
62
+ if config.enableGeolocation { values.append("geolocation") }
62
63
  // The Music app's song and transport are the coordinator's too.
63
- if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
64
+ if config.enableAppleMusic { values.append("music") }
65
+ // `craft.background.complete(ok, id)`: the answer to a silent push or
66
+ // a background refresh, which the app script (`nativeApp`) hears.
67
+ if config.backgroundRefresh?.enabled == true || config.enablePushNotifications { values.append("background") }
64
68
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
65
69
  // so it needs no web view.
66
70
  if config.enableHeartRate { values.append("heartRate") }
@@ -407,6 +411,21 @@ enum CraftNativeActions {
407
411
  complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Push method"), completion)
408
412
  }
409
413
  return
414
+ case ("Background", "complete"):
415
+ // The app script's answer to `craftSilentPush` or
416
+ // `craftBackgroundRefresh`: args `[ok, id?]`, with no id for
417
+ // everything still pending, as a page's `craft.background.complete`.
418
+ guard config.backgroundRefresh?.enabled == true || config.enablePushNotifications else {
419
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Background work is disabled"), completion)
420
+ return
421
+ }
422
+ let ok = args.first as? Bool ?? true
423
+ let id = args.count > 1 ? args[1] as? String : nil
424
+ DispatchQueue.main.async {
425
+ CraftBackgroundWork.shared.complete(id: id, ok: ok)
426
+ complete(requestToken, .success(true), completion)
427
+ }
428
+ return
410
429
  case ("Notifications", _):
411
430
  guard config.enableLocalNotifications else {
412
431
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
@@ -1081,6 +1100,41 @@ enum CraftSnapshots {
1081
1100
  }
1082
1101
  }
1083
1102
  }
1103
+
1104
+ /// Every snapshot there is: on disk, and written but not on disk yet.
1105
+ static func names() -> [String] {
1106
+ var names = Set(directory
1107
+ .flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
1108
+ .filter { $0.hasSuffix(".json") }
1109
+ .map { String($0.dropLast(5)) } ?? [])
1110
+ lock.lock()
1111
+ for (name, json) in pending {
1112
+ if json == nil { names.remove(name) } else { names.insert(name) }
1113
+ }
1114
+ lock.unlock()
1115
+ return names.filter(isValidName).sorted()
1116
+ }
1117
+
1118
+ /// Deletes every snapshot but the ones named in `keep` (a sign-out), each
1119
+ /// as `write(name, json: nil)` does, so each posts `changed`.
1120
+ /// `completion` runs on the main queue: true when every one went.
1121
+ static func clear(keeping keep: Set<String> = [], completion: ((Bool) -> Void)? = nil) {
1122
+ let doomed = names().filter { !keep.contains($0) }
1123
+ guard !doomed.isEmpty else {
1124
+ DispatchQueue.main.async { completion?(true) }
1125
+ return
1126
+ }
1127
+ let group = DispatchGroup()
1128
+ var allRemoved = true
1129
+ for name in doomed {
1130
+ group.enter()
1131
+ write(name, json: nil) { ok in
1132
+ if !ok { allRemoved = false }
1133
+ group.leave()
1134
+ }
1135
+ }
1136
+ group.notify(queue: .main) { completion?(allRemoved) }
1137
+ }
1084
1138
  }
1085
1139
 
1086
1140
  /// The Taptic Engine's generators, held for the life of the process.
@@ -367,7 +367,12 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
367
367
  let config: CraftConfig
368
368
 
369
369
  func makeUIViewController(context: Context) -> UINavigationController {
370
- UINavigationController(rootViewController: CraftNativeScreenController(config: config))
370
+ let screen = CraftNativeScreenController(config: config)
371
+ // The shell hears the first frame (the app script starts after it)
372
+ // and `craft.navigation.open`, which has no paths to open here but
373
+ // web addresses, in the in-app browser.
374
+ screen.hybridEvents = CraftHybrid.shared
375
+ return UINavigationController(rootViewController: screen)
371
376
  }
372
377
 
373
378
  func updateUIViewController(_ controller: UINavigationController, context: Context) {}
@@ -2086,11 +2091,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2086
2091
  weak var hybridEvents: CraftHybridScreenEvents?
2087
2092
  private var hybridRendered = false
2088
2093
  private var runtimeDisposed = false
2094
+ /// The app script (`nativeApp`, CraftHybrid.swift): a bundle route with
2095
+ /// no screen, loaded once and never shown, that runs for the app's life.
2096
+ /// It hears every `APP_STATE` and the app's events (`CRAFT_EVENT`), no
2097
+ /// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
2098
+ let isAppScript: Bool
2089
2099
 
2090
- init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:]) {
2100
+ init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
2091
2101
  self.config = config
2092
2102
  self.routeName = routeName
2093
2103
  self.routeParams = routeParams
2104
+ self.isAppScript = isAppScript
2094
2105
  super.init(nibName: nil, bundle: nil)
2095
2106
  }
2096
2107
 
@@ -2127,6 +2138,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2127
2138
  setupJavaScript()
2128
2139
  loadBundle()
2129
2140
  observeNativeEvents()
2141
+ if isAppScript {
2142
+ // Its bundle has run, so its listeners are in place: the app's
2143
+ // events that waited for it arrive now, in order.
2144
+ CraftEventManager.shared.appScriptReady { [weak self] event, detail in
2145
+ self?.send(type: "CRAFT_EVENT", payload: ["event": event, "detail": detail])
2146
+ }
2147
+ }
2130
2148
  }
2131
2149
 
2132
2150
  override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -2280,6 +2298,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2280
2298
  let capabilities = CraftNativeActions.capabilities(config: config)
2281
2299
  let capabilityData = try? JSONSerialization.data(withJSONObject: capabilities)
2282
2300
  let capabilityJSON = capabilityData.flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
2301
+ // The app's site and build, as JSON strings: the site an all-native
2302
+ // app's paths and links belong to (`devServerURL`), and the version a
2303
+ // request or a crash report names.
2304
+ let info = Bundle.main.infoDictionary
2305
+ let appURL = Self.jsonString(config.devServerURL ?? "")
2306
+ let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
2307
+ let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
2283
2308
  jsContext.evaluateScript("""
2284
2309
  globalThis.__stxNativeCallback = null;
2285
2310
  globalThis.__stxNativeBridge = {
@@ -2290,6 +2315,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2290
2315
  capabilities: \(capabilityJSON),
2291
2316
  initialAppState: "\(CraftNativeActions.currentAppState())",
2292
2317
  colorScheme: "\(colorScheme)",
2318
+ appURL: \(appURL),
2319
+ appVersion: \(appVersion),
2320
+ appBuild: \(appBuild),
2293
2321
  postMessage: function(message) { craftNativePostMessage(message); },
2294
2322
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
2295
2323
  };
@@ -2350,6 +2378,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2350
2378
  """)
2351
2379
  }
2352
2380
 
2381
+ /// `value` as a JavaScript string literal (JSON), quotes and all.
2382
+ static func jsonString(_ value: String) -> String {
2383
+ guard let data = try? JSONSerialization.data(withJSONObject: [value]),
2384
+ let array = String(data: data, encoding: .utf8), array.count >= 2 else { return "\"\"" }
2385
+ return String(array.dropFirst().dropLast())
2386
+ }
2387
+
2353
2388
  /// `"dark"` or `"light"` for the bundle's `dark:` classes (contract A4):
2354
2389
  /// the window's appearance once there is one, else the app's setting.
2355
2390
  private var colorScheme: String {
@@ -2379,6 +2414,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2379
2414
  /// asynchronous `craft.storage` uses. `getSync` answers what `get` would.
2380
2415
  /// - `craft.snapshots.get(name)` (sync) and `set(name, value)` (a
2381
2416
  /// promise): `Application Support/craft-snapshots/<name>.json`.
2417
+ /// `clear(keep?)` (a promise) deletes every snapshot but the names in
2418
+ /// `keep`, as a sign-out does; each removal is a `SNAPSHOT_CHANGED`
2419
+ /// for every screen.
2382
2420
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2383
2421
  /// `craft.secureStorage.set` wrote.
2384
2422
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
@@ -2405,6 +2443,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2405
2443
  done.call(withArguments: [ok])
2406
2444
  }
2407
2445
  }
2446
+ let snapshotClear: @convention(block) (JSValue, JSValue) -> Void = { keep, done in
2447
+ let names = (keep.isUndefined || keep.isNull ? [] : (keep.toArray() ?? [])).compactMap { $0 as? String }
2448
+ CraftSnapshots.clear(keeping: Set(names)) { ok in
2449
+ done.call(withArguments: [ok])
2450
+ }
2451
+ }
2408
2452
  let config = self.config
2409
2453
  let secureGet: @convention(block) (String) -> String? = { key in
2410
2454
  config.enableSecureStorage ? CraftNativeActions.webSecureValue(forKey: key) : nil
@@ -2419,6 +2463,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2419
2463
  jsContext.setObject(storageSet, forKeyedSubscript: "craftNativeStorageSet" as NSString)
2420
2464
  jsContext.setObject(snapshotGet, forKeyedSubscript: "craftNativeSnapshotGet" as NSString)
2421
2465
  jsContext.setObject(snapshotSet, forKeyedSubscript: "craftNativeSnapshotSet" as NSString)
2466
+ jsContext.setObject(snapshotClear, forKeyedSubscript: "craftNativeSnapshotClear" as NSString)
2422
2467
  jsContext.setObject(secureGet, forKeyedSubscript: "craftNativeSecureGet" as NSString)
2423
2468
  jsContext.setObject(setOptions, forKeyedSubscript: "craftNativeSetOptions" as NSString)
2424
2469
  // `AudioContext` and the rest of Web Audio, rendered natively.
@@ -2568,6 +2613,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2568
2613
  else reject(new Error('craft.snapshots.set could not write ' + name));
2569
2614
  });
2570
2615
  });
2616
+ },
2617
+ // Every snapshot but the names in `keep` (a sign-out).
2618
+ clear: function(keep) {
2619
+ var names = Array.isArray(keep) ? keep.map(String) : [];
2620
+ return new Promise(function(resolve, reject) {
2621
+ craftNativeSnapshotClear(names, function(ok) {
2622
+ if (ok) resolve();
2623
+ else reject(new Error('craft.snapshots.clear could not remove every snapshot'));
2624
+ });
2625
+ });
2571
2626
  }
2572
2627
  });
2573
2628
  keep('secureStorage', {
@@ -2623,6 +2678,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2623
2678
  /// `craft.navigation.setOptions`: this screen's bar. Every key is
2624
2679
  /// optional and only the keys given change.
2625
2680
  private func applyNavigationOptions(_ options: [String: Any]) {
2681
+ // The app script has no screen, so no bar and no tab bar to change.
2682
+ guard !isAppScript else { return }
2626
2683
  if options.keys.contains("title") {
2627
2684
  navigationItem.title = options["title"] as? String
2628
2685
  titleFromOptions = true
@@ -2826,20 +2883,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2826
2883
  observe(UIApplication.didBecomeActiveNotification, state: "active")
2827
2884
  observe(UIApplication.willResignActiveNotification, state: "inactive")
2828
2885
  observe(UIApplication.didEnterBackgroundNotification, state: "background")
2829
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2830
- forName: UIResponder.keyboardWillChangeFrameNotification,
2831
- object: nil,
2832
- queue: .main
2833
- ) { [weak self] notification in
2834
- self?.applyKeyboardSafeArea(notification)
2835
- })
2836
- lifecycleObservers.append(NotificationCenter.default.addObserver(
2837
- forName: UIResponder.keyboardWillHideNotification,
2838
- object: nil,
2839
- queue: .main
2840
- ) { [weak self] notification in
2841
- self?.applyKeyboardSafeArea(notification, hidden: true)
2842
- })
2886
+ // The app script has no screen for the keyboard to cover.
2887
+ if !isAppScript {
2888
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
2889
+ forName: UIResponder.keyboardWillChangeFrameNotification,
2890
+ object: nil,
2891
+ queue: .main
2892
+ ) { [weak self] notification in
2893
+ self?.applyKeyboardSafeArea(notification)
2894
+ })
2895
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
2896
+ forName: UIResponder.keyboardWillHideNotification,
2897
+ object: nil,
2898
+ queue: .main
2899
+ ) { [weak self] notification in
2900
+ self?.applyKeyboardSafeArea(notification, hidden: true)
2901
+ })
2902
+ }
2843
2903
  lifecycleObservers.append(NotificationCenter.default.addObserver(
2844
2904
  forName: CraftSnapshots.changed,
2845
2905
  object: nil,
@@ -2873,6 +2933,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2873
2933
  sounds.emit = { [weak self] payload in self?.send(type: "SOUND_EVENT", payload: payload) }
2874
2934
  streams.emit = { [weak self] payload in self?.send(type: "NETWORK_STREAM", payload: payload) }
2875
2935
  sendAppState(CraftNativeActions.currentAppState())
2936
+ // Links are the screens' to show; the app script hears none.
2937
+ guard !isAppScript else { return }
2876
2938
  deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
2877
2939
  guard let self = self, self.navigationController?.topViewController === self else { return }
2878
2940
  var payload = CraftNativeActions.deepLinkData(url)
@@ -2952,7 +3014,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2952
3014
  }
2953
3015
 
2954
3016
  private func sendAppState(_ state: String) {
2955
- guard navigationController?.topViewController === self else { return }
3017
+ // A screen hears it while it is on top of its stack; the app script,
3018
+ // which is on no stack, always.
3019
+ guard isAppScript || navigationController?.topViewController === self else { return }
2956
3020
  send(type: "APP_STATE", payload: ["state": state])
2957
3021
  }
2958
3022
 
@@ -2972,6 +3036,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2972
3036
  /// is up, and are logged.
2973
3037
  private func hybridFailed(_ message: String) {
2974
3038
  guard !hybridRendered else { return }
3039
+ // The app script draws nothing for anything to stand in for: logged
3040
+ // (by the caller) and left to run on.
3041
+ guard !isAppScript else { return }
2975
3042
  hybridEvents?.nativeScreen(self, didFail: message)
2976
3043
  }
2977
3044
 
@@ -3014,7 +3081,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3014
3081
  ])
3015
3082
  }
3016
3083
  case "NAVIGATE", "NAVIGATE_REPLACE":
3017
- guard navigationController?.topViewController === self,
3084
+ // A screen's own stack; the app script has none (it opens paths).
3085
+ guard !isAppScript, navigationController?.topViewController === self,
3018
3086
  let screen = payload["screen"] as? String, !screen.isEmpty,
3019
3087
  let navigation = navigationController else { return }
3020
3088
  let params = payload["params"] as? [String: Any] ?? [:]
@@ -3034,13 +3102,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3034
3102
  guard let path = payload["path"] as? String, !path.isEmpty else { return }
3035
3103
  hybridEvents?.nativeScreen(self, open: path)
3036
3104
  case "NAVIGATE_BACK":
3037
- if navigationController?.topViewController === self {
3105
+ if !isAppScript, navigationController?.topViewController === self {
3038
3106
  navigationController?.popViewController(animated: true)
3039
3107
  }
3040
3108
  case "NAVIGATE_POP_TO_ROOT":
3041
3109
  // Back to the first screen of this screen's stack in one move: a
3042
3110
  // finished task (a saved workout) closes the way it was reached.
3043
- if navigationController?.topViewController === self {
3111
+ if !isAppScript, navigationController?.topViewController === self {
3044
3112
  navigationController?.popToRootViewController(animated: true)
3045
3113
  }
3046
3114
  case "API_REQUEST":
@@ -3181,7 +3249,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3181
3249
  renderCommitted(document)
3182
3250
  if !hybridRendered {
3183
3251
  hybridRendered = true
3184
- hybridEvents?.nativeScreenDidRender(self)
3252
+ // The app script's drawing is never on screen: not a first frame.
3253
+ if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
3185
3254
  }
3186
3255
  }
3187
3256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.155",
3
+ "version": "0.0.156",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",