craft-native 0.0.154 → 0.0.156

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ export interface SimulatorDriverOptions {
2
+ output: string;
3
+ /** The installed app to drive; the test host is a separate application. */
4
+ bundleId: string;
5
+ projectName?: string;
6
+ bundleIdPrefix?: string;
7
+ deploymentTarget?: string;
8
+ controlUrl?: string;
9
+ tabs?: readonly string[];
10
+ }
11
+ /** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
12
+ export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
13
+ export type SimulatorStep = {
14
+ command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled';
15
+ } | {
16
+ command: 'background' | 'wait';
17
+ seconds: number;
18
+ } | {
19
+ command: 'delay';
20
+ milliseconds: number;
21
+ } | {
22
+ command: 'net';
23
+ state: 'online' | 'offline';
24
+ } | {
25
+ command: 'swipe';
26
+ direction: 'up' | 'down' | 'left' | 'right';
27
+ } | {
28
+ command: 'tabbar';
29
+ state: 'shown' | 'hidden';
30
+ } | {
31
+ command: 'tapxy';
32
+ x: number;
33
+ y: number;
34
+ } | {
35
+ command: 'field';
36
+ index: number;
37
+ text: string;
38
+ } | {
39
+ command: 'typeid';
40
+ id: string;
41
+ text: string;
42
+ } | {
43
+ command: 'expect' | 'expectexact' | 'expectid';
44
+ text: string;
45
+ seconds?: number;
46
+ } | {
47
+ command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log';
48
+ text: string;
49
+ } | {
50
+ command: 'shot' | 'tree' | 'audit' | 'exec';
51
+ name: string;
52
+ } | {
53
+ command: 'openurl';
54
+ url: string;
55
+ };
56
+ /** Typed journeys use the same wire protocol as existing line-based scripts. */
57
+ export declare function serializeSimulatorSteps(steps: readonly SimulatorStep[]): string;
58
+ export interface NativeValidationResult {
59
+ exitCode: number;
60
+ stdout: string;
61
+ stderr: string;
62
+ }
63
+ export interface NativeMutationValidator {
64
+ validate(messages: readonly {
65
+ type: string;
66
+ payload?: unknown;
67
+ }[]): NativeValidationResult;
68
+ dispose(): void;
69
+ }
70
+ /** Compile the actual Foundation mutation host once, then validate captured STX messages. Requires macOS and swiftc. */
71
+ export declare function createNativeMutationValidator(): NativeMutationValidator;
@@ -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) (