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.
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +46 -8
- package/dist/ios/src/index.js +289 -98
- package/dist/ios/src/testing.d.ts +71 -0
- package/dist/ios/templates/CraftApp.swift +114 -18
- package/dist/ios/templates/CraftHybrid.swift +269 -31
- package/dist/ios/templates/CraftNativeActions.swift +57 -3
- package/dist/ios/templates/CraftNativeScreen.swift +90 -21
- package/dist/ios/templates/testing/DriverTests.swift.template +305 -0
- package/dist/ios/templates/testing/HostApp.swift +6 -0
- package/dist/ios/templates/testing/MutationValidator.swift +15 -0
- package/dist/ios/templates/testing/project.yml.template +36 -0
- package/package.json +1 -1
|
@@ -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(
|
|
200
|
+
CraftBackgroundWork.shared.registerRefresh(config)
|
|
201
|
+
// The shell, the app script and an all-native app's services now,
|
|
202
|
+
// not when SwiftUI first draws: a launch in the background (a silent
|
|
203
|
+
// push, a refresh, a route recording's location) draws nothing, and
|
|
204
|
+
// its events still need someone to hear them.
|
|
205
|
+
CraftHybrid.shared.configure(config)
|
|
200
206
|
if let shortcut = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
|
|
201
207
|
CraftEventManager.shared.handleShortcut(shortcut)
|
|
202
208
|
// Returning false tells UIKit the launch-time item was handled and
|
|
@@ -269,12 +275,32 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
|
|
|
269
275
|
}
|
|
270
276
|
|
|
271
277
|
// MARK: - Native Event Manager
|
|
278
|
+
|
|
279
|
+
/// The app's own events: a notification tapped or arriving, a silent push,
|
|
280
|
+
/// background refresh, a home-screen or Siri shortcut. They go to the page,
|
|
281
|
+
/// or, in an app with an app script (`nativeApp`, CraftHybrid.swift), to that
|
|
282
|
+
/// script as `CRAFT_EVENT { event, detail }` instead. Either way an event
|
|
283
|
+
/// that arrives before its listener can hear it (a tap that launched the app)
|
|
284
|
+
/// waits for it, in order.
|
|
272
285
|
class CraftEventManager {
|
|
273
286
|
static let shared = CraftEventManager()
|
|
274
287
|
|
|
288
|
+
/// Who hears the app's events. Undecided until the shell is configured
|
|
289
|
+
/// at launch, which may be after the first event (a launch shortcut).
|
|
290
|
+
enum Listener {
|
|
291
|
+
case undecided
|
|
292
|
+
case page
|
|
293
|
+
case appScript
|
|
294
|
+
/// An all-native app with no app script: nothing hears them.
|
|
295
|
+
case none
|
|
296
|
+
}
|
|
297
|
+
|
|
275
298
|
private weak var webView: WKWebView?
|
|
276
299
|
private var isReady = false
|
|
300
|
+
private var listener: Listener = .undecided
|
|
277
301
|
private var pendingEvents: [(name: String, data: [String: Any])] = []
|
|
302
|
+
/// The app script's delivery, once its bundle has run.
|
|
303
|
+
private var appScriptSink: ((String, [String: Any]) -> Void)?
|
|
278
304
|
|
|
279
305
|
private init() {}
|
|
280
306
|
|
|
@@ -282,12 +308,35 @@ class CraftEventManager {
|
|
|
282
308
|
self.webView = webView
|
|
283
309
|
}
|
|
284
310
|
|
|
311
|
+
/// From `CraftHybrid.configure`: whether an app script hears the app's
|
|
312
|
+
/// events, and whether there is a page to hear them otherwise.
|
|
313
|
+
func configure(appScript: Bool, page: Bool) {
|
|
314
|
+
listener = appScript ? .appScript : (page ? .page : .none)
|
|
315
|
+
guard listener == .none else { return }
|
|
316
|
+
// Nothing will ever hear what is waiting; iOS gets its answer now
|
|
317
|
+
// rather than at the background deadline.
|
|
318
|
+
let events = pendingEvents
|
|
319
|
+
pendingEvents.removeAll()
|
|
320
|
+
for event in events { drop(event.name, data: event.data) }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/// The app script has run its bundle: whatever waited for it, in order.
|
|
324
|
+
func appScriptReady(_ sink: @escaping (String, [String: Any]) -> Void) {
|
|
325
|
+
appScriptSink = sink
|
|
326
|
+
guard listener == .appScript else { return }
|
|
327
|
+
let events = pendingEvents
|
|
328
|
+
pendingEvents.removeAll()
|
|
329
|
+
for event in events { sink(event.name, event.data) }
|
|
330
|
+
}
|
|
331
|
+
|
|
285
332
|
func setLoading() {
|
|
286
333
|
isReady = false
|
|
287
334
|
}
|
|
288
335
|
|
|
289
336
|
func setReady() {
|
|
290
337
|
isReady = true
|
|
338
|
+
// Held for the app script, which the page's readiness says nothing about.
|
|
339
|
+
guard listener != .appScript else { return }
|
|
291
340
|
let events = pendingEvents
|
|
292
341
|
pendingEvents.removeAll()
|
|
293
342
|
for event in events {
|
|
@@ -338,13 +387,37 @@ class CraftEventManager {
|
|
|
338
387
|
return true
|
|
339
388
|
}
|
|
340
389
|
|
|
341
|
-
/// Dispatch now if the page can hear it, or hold it
|
|
390
|
+
/// Dispatch now if the page (or the app script) can hear it, or hold it
|
|
391
|
+
/// until it can. The name says "web" for the page it was written for; the
|
|
392
|
+
/// event names are spelled at its call sites, where the conformance test
|
|
393
|
+
/// reads the vocabulary from.
|
|
342
394
|
func sendToWeb(_ event: String, data: [String: Any]) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
395
|
+
switch listener {
|
|
396
|
+
case .appScript:
|
|
397
|
+
guard let appScriptSink else {
|
|
398
|
+
pendingEvents.append((event, data))
|
|
399
|
+
// Background work cannot wait for a first frame that a
|
|
400
|
+
// launch in the background never draws.
|
|
401
|
+
if event == "craftSilentPush" || event == "craftBackgroundRefresh" { CraftHybrid.shared.backgroundWorkWaiting() }
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
appScriptSink(event, data)
|
|
405
|
+
case .none:
|
|
406
|
+
drop(event, data: data)
|
|
407
|
+
case .page, .undecided:
|
|
408
|
+
guard listener == .page, isReady, webView != nil else {
|
|
409
|
+
pendingEvents.append((event, data))
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
dispatch(event, data: data)
|
|
346
413
|
}
|
|
347
|
-
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/// An event no one will hear: logged, and background work it holds is
|
|
417
|
+
/// answered at once.
|
|
418
|
+
private func drop(_ event: String, data: [String: Any]) {
|
|
419
|
+
NSLog("[craft] %@: the app has no page and no app script (nativeApp) to hear it", event)
|
|
420
|
+
if let id = data["id"] as? String { CraftBackgroundWork.shared.release(id) }
|
|
348
421
|
}
|
|
349
422
|
|
|
350
423
|
private func dispatch(_ event: String, data: [String: Any]) {
|
|
@@ -363,7 +436,9 @@ class CraftEventManager {
|
|
|
363
436
|
/// The page's share of background time: a silent push (`craftSilentPush`) and
|
|
364
437
|
/// iOS's scheduled app refresh (`craftBackgroundRefresh`). Each holds iOS's
|
|
365
438
|
/// completion until the page calls `craft.background.complete(ok)` (with the
|
|
366
|
-
/// event's `id`, or with none for everything pending), or for 25 seconds.
|
|
439
|
+
/// event's `id`, or with none for everything pending), or for 25 seconds. In
|
|
440
|
+
/// an app with an app script the script hears both and answers the same way,
|
|
441
|
+
/// through the native `Background.complete` (CraftNativeActions.swift).
|
|
367
442
|
///
|
|
368
443
|
/// The page has to be alive to do the work: when iOS wakes an app it had
|
|
369
444
|
/// suspended, it is; when it launches one it had ended, the event waits for
|
|
@@ -400,6 +475,12 @@ final class CraftBackgroundWork {
|
|
|
400
475
|
}
|
|
401
476
|
}
|
|
402
477
|
|
|
478
|
+
/// Work no one will do (nothing hears its event): iOS is answered now,
|
|
479
|
+
/// as the deadline would have.
|
|
480
|
+
func release(_ id: String) {
|
|
481
|
+
end(id, ok: nil)
|
|
482
|
+
}
|
|
483
|
+
|
|
403
484
|
private func end(_ id: String, ok: Bool?) {
|
|
404
485
|
guard let finish = pending.removeValue(forKey: id) else { return }
|
|
405
486
|
finish(ok)
|
|
@@ -461,11 +542,12 @@ struct CraftApp: App {
|
|
|
461
542
|
var body: some SwiftUI.Scene {
|
|
462
543
|
WindowGroup {
|
|
463
544
|
Group {
|
|
464
|
-
if
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
//
|
|
468
|
-
//
|
|
545
|
+
if CraftHybrid.shared.isEnabled {
|
|
546
|
+
// Native screens by path, with tabs (CraftHybrid.swift):
|
|
547
|
+
// the native screen for the start path at once, and in a
|
|
548
|
+
// web app (hybrid) the page warm behind it. An all-native
|
|
549
|
+
// app (renderer "native" with nativeScreens) has the same
|
|
550
|
+
// shell and no web view at all.
|
|
469
551
|
ZStack(alignment: .bottom) {
|
|
470
552
|
CraftColorSchemeObserver()
|
|
471
553
|
CraftHybridRoot(config: appState.config)
|
|
@@ -477,6 +559,9 @@ struct CraftApp: App {
|
|
|
477
559
|
// stack kept out of the keyboard's way and carried the bar
|
|
478
560
|
// up above it, over the field being typed in.
|
|
479
561
|
.ignoresSafeArea(.keyboard, edges: .bottom)
|
|
562
|
+
} else if appState.config.renderer == "native" {
|
|
563
|
+
// One native screen and its stack: no paths, no tabs.
|
|
564
|
+
CraftNativeScreen(config: appState.config)
|
|
480
565
|
} else {
|
|
481
566
|
// The page's own chrome, drawn natively over it: the tab
|
|
482
567
|
// bar the page asks for, and the splash until it is ready.
|
|
@@ -927,10 +1012,13 @@ class DeepLinkManager {
|
|
|
927
1012
|
}
|
|
928
1013
|
|
|
929
1014
|
// A hybrid app opens a native path's screen itself, and tells the
|
|
930
|
-
// native screens that listen; every other link is the page's.
|
|
1015
|
+
// native screens that listen; every other link is the page's. An
|
|
1016
|
+
// all-native app has no page: its native screens hear every link.
|
|
931
1017
|
if CraftHybrid.shared.isEnabled {
|
|
932
1018
|
if CraftHybrid.shared.handleDeepLink(url) {
|
|
933
1019
|
dispatchNative(url, initial: false)
|
|
1020
|
+
} else if !CraftHybrid.shared.hasWeb {
|
|
1021
|
+
if nativeListeners.isEmpty { pendingURLs.append(url) } else { dispatchNative(url, initial: false) }
|
|
934
1022
|
} else if isReady && webView != nil {
|
|
935
1023
|
dispatchDeepLink(url, initial: false)
|
|
936
1024
|
} else {
|
|
@@ -958,7 +1046,8 @@ class DeepLinkManager {
|
|
|
958
1046
|
nativeListeners[token] = listener
|
|
959
1047
|
// In a hybrid app the page is the one waiting for the links that
|
|
960
1048
|
// arrived early; a native screen hears only native ones, as they come.
|
|
961
|
-
|
|
1049
|
+
// An all-native app has no page, so its first screen hears them.
|
|
1050
|
+
if CraftHybrid.shared.isEnabled && CraftHybrid.shared.hasWeb { return token }
|
|
962
1051
|
let firstNativeScreen = !hasBeenReady
|
|
963
1052
|
hasBeenReady = true
|
|
964
1053
|
let urls = pendingURLs
|
|
@@ -1027,7 +1116,8 @@ class AppState: ObservableObject {
|
|
|
1027
1116
|
self.config = CraftConfig()
|
|
1028
1117
|
}
|
|
1029
1118
|
// Before the first frame: the hybrid start screen, and the tab bar
|
|
1030
|
-
// from the config, are decided here
|
|
1119
|
+
// from the config, are decided here, if the app delegate has not
|
|
1120
|
+
// already (it does at launch; the first call decides).
|
|
1031
1121
|
CraftHybrid.shared.configure(config)
|
|
1032
1122
|
}
|
|
1033
1123
|
}
|
|
@@ -1149,6 +1239,9 @@ struct CraftConfig: Codable {
|
|
|
1149
1239
|
/// Hybrid: web-storage keys the page's own code writes, mirrored into the
|
|
1150
1240
|
/// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
|
|
1151
1241
|
var shareStorage: [String: String]? = nil
|
|
1242
|
+
/// The bundle route of the app script: a screen-less script that runs for
|
|
1243
|
+
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
1244
|
+
var nativeApp: String? = nil
|
|
1152
1245
|
|
|
1153
1246
|
struct Tab: Codable {
|
|
1154
1247
|
var id: String
|
|
@@ -8500,8 +8593,10 @@ final class CraftZigRuntime: NSObject {
|
|
|
8500
8593
|
/// started on a page can be followed on a native screen.
|
|
8501
8594
|
///
|
|
8502
8595
|
/// The coordinator exists wherever the web view does: always in a hybrid app.
|
|
8503
|
-
///
|
|
8504
|
-
///
|
|
8596
|
+
/// An app with no web view (renderer "native") has one all the same, without
|
|
8597
|
+
/// a web view (`CraftHybrid.configure`), so its native screens have these
|
|
8598
|
+
/// services too. Calls go to the Swift dispatcher; a build that links the Zig
|
|
8599
|
+
/// runtime keeps the page's calls on Zig.
|
|
8505
8600
|
enum CraftWebServices {
|
|
8506
8601
|
private static var waiting: [String: (Result<Any, CraftNativeActionError>) -> Void] = [:]
|
|
8507
8602
|
/// Coordinator events a native screen hears too.
|
|
@@ -8558,7 +8653,8 @@ enum CraftWebServices {
|
|
|
8558
8653
|
/// how this codebase accumulated five envelopes.
|
|
8559
8654
|
@objc(CraftSwiftShim)
|
|
8560
8655
|
final class CraftSwiftShim: NSObject {
|
|
8561
|
-
/// The coordinator serving hand-offs. Set by `makeCoordinator
|
|
8656
|
+
/// The coordinator serving hand-offs. Set by `makeCoordinator`, or in an
|
|
8657
|
+
/// app with no web view by `CraftHybrid.configure`, which holds it.
|
|
8562
8658
|
static weak var coordinator: CraftWebView.Coordinator?
|
|
8563
8659
|
|
|
8564
8660
|
private typealias DeliverResultFn = @convention(c) (
|