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 +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +42 -8
- package/dist/ios/src/index.js +41 -9
- 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/package.json +1 -1
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.
|
|
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
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -83,6 +83,14 @@ export interface CraftConfig {
|
|
|
83
83
|
* Each replaces the generic sentence Craft writes for that key.
|
|
84
84
|
*/
|
|
85
85
|
usageDescriptions?: Record<string, string>;
|
|
86
|
+
/**
|
|
87
|
+
* The app's site. With `renderer: "web"` it is the page the web view loads.
|
|
88
|
+
* With `renderer: "native"` and `nativeScreens` there is no web view: its
|
|
89
|
+
* path is the path the app starts on (`/` when unset), and a path no native
|
|
90
|
+
* screen draws (`craft.navigation.open('/terms')`, or a link to this host)
|
|
91
|
+
* opens in the in-app browser against its origin. Native screens read it as
|
|
92
|
+
* `__stxNativeBridge.appURL`.
|
|
93
|
+
*/
|
|
86
94
|
devServerURL?: string;
|
|
87
95
|
iosVersion?: string;
|
|
88
96
|
watchosVersion?: string;
|
|
@@ -168,16 +176,32 @@ export interface CraftConfig {
|
|
|
168
176
|
orientations?: Array<'portrait' | 'landscape-left' | 'landscape-right' | 'portrait-upside-down'>;
|
|
169
177
|
deviceFamilies?: Array<'iphone' | 'ipad'>;
|
|
170
178
|
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
179
|
+
* App paths drawn as native stx screens, each to the screen in the native
|
|
180
|
+
* bundle that draws it, e.g. `{ "/m": "Today", "/m/calendar": "Calendar" }`.
|
|
181
|
+
* A segment may be a parameter (`/m/workout/:id`) and the last one `*`.
|
|
182
|
+
* Needs `nativeBundle`.
|
|
183
|
+
*
|
|
184
|
+
* With `renderer: "web"` (hybrid) every other path stays in the web view,
|
|
185
|
+
* and a path whose screen is missing or throws falls back to it.
|
|
186
|
+
*
|
|
187
|
+
* With `renderer: "native"` the app is all native, with the same tabs and
|
|
188
|
+
* path routing and no web view at all: every tab id and the start path (the
|
|
189
|
+
* path of `devServerURL`) must be a native screen's, and any other path
|
|
190
|
+
* opens in the in-app browser on the app's site.
|
|
177
191
|
*/
|
|
178
192
|
nativeScreens?: Record<string, string>;
|
|
179
|
-
/**
|
|
193
|
+
/** The bundle `stx native compile --format bundle` wrote, copied to `dist/native-screen.js`. */
|
|
180
194
|
nativeBundle?: string;
|
|
195
|
+
/**
|
|
196
|
+
* The bundle route of a screen-less script that runs once for the app's
|
|
197
|
+
* whole life, beside the screens (Stacks names it `__app`). It hears the
|
|
198
|
+
* app's events (`craftNotificationResponse`, `craftNotificationReceived`,
|
|
199
|
+
* `craftSilentPush`, `craftBackgroundRefresh`, `craftShortcut`,
|
|
200
|
+
* `craftSiriShortcut`) as `CRAFT_EVENT` messages instead of the page, every
|
|
201
|
+
* `APP_STATE`, and can open paths with `craft.navigation.open`. Needs
|
|
202
|
+
* `nativeScreens` or `renderer: "native"`, and the native bundle.
|
|
203
|
+
*/
|
|
204
|
+
nativeApp?: string;
|
|
181
205
|
/**
|
|
182
206
|
* The native tab bar, shown from the first frame, before the page loads.
|
|
183
207
|
* The page's own `tabBar.set` replaces it later. Each `id` is the tab's
|
|
@@ -214,8 +238,18 @@ export interface NativePathMatch {
|
|
|
214
238
|
export declare function matchNativePath(nativeScreens: Record<string, string> | undefined, raw: string): NativePathMatch | null;
|
|
215
239
|
/** Whether a config mixes native screens into its web page. */
|
|
216
240
|
export declare function isHybrid(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
|
|
241
|
+
/** Whether a config is an all-native app with routes: `renderer: "native"` and `nativeScreens`, tabs and paths with no web view. */
|
|
242
|
+
export declare function hasNativeRoutes(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
|
|
243
|
+
/** Whether a config draws any native screen, and so needs `dist/native-screen.js`. */
|
|
244
|
+
export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' | 'nativeScreens'>): boolean;
|
|
245
|
+
/**
|
|
246
|
+
* The path the app starts on: the path of `devServerURL` (with its query),
|
|
247
|
+
* `/` when there is none. The shell reads it the same way
|
|
248
|
+
* (`CraftHybridRoutes.appPath`).
|
|
249
|
+
*/
|
|
250
|
+
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
217
251
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
218
|
-
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage'>): string[];
|
|
252
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
|
|
219
253
|
export interface CraftBackgroundRefresh {
|
|
220
254
|
enabled: boolean;
|
|
221
255
|
/** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
|
package/dist/ios/src/index.js
CHANGED
|
@@ -228,11 +228,25 @@ function matchNativePath(nativeScreens, raw) {
|
|
|
228
228
|
function isHybrid(config) {
|
|
229
229
|
return (config.renderer ?? "web") === "web" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
230
230
|
}
|
|
231
|
+
function hasNativeRoutes(config) {
|
|
232
|
+
return config.renderer === "native" && Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
233
|
+
}
|
|
234
|
+
function usesNativeBundle(config) {
|
|
235
|
+
return config.renderer === "native" || Object.keys(config.nativeScreens ?? {}).length > 0;
|
|
236
|
+
}
|
|
237
|
+
function nativeStartPath(config) {
|
|
238
|
+
if (!config.devServerURL)
|
|
239
|
+
return "/";
|
|
240
|
+
try {
|
|
241
|
+
const url = new URL(config.devServerURL);
|
|
242
|
+
return `${url.pathname || "/"}${url.search}`;
|
|
243
|
+
} catch {
|
|
244
|
+
return "/";
|
|
245
|
+
}
|
|
246
|
+
}
|
|
231
247
|
function hybridConfigProblems(config) {
|
|
232
248
|
const problems = [];
|
|
233
249
|
const screens = Object.entries(config.nativeScreens ?? {});
|
|
234
|
-
if (screens.length && config.renderer === "native")
|
|
235
|
-
problems.push('nativeScreens mixes native screens into a web app; it needs renderer: "web" (renderer: "native" is all native already).');
|
|
236
250
|
for (const [pattern, screen] of screens) {
|
|
237
251
|
if (normalizeNativePath(pattern) !== pattern || /[?#]/.test(pattern))
|
|
238
252
|
problems.push(`nativeScreens path "${pattern}" must be an absolute path with no query, hash or trailing slash.`);
|
|
@@ -254,6 +268,21 @@ function hybridConfigProblems(config) {
|
|
|
254
268
|
if (!key || typeof target !== "string" || !target)
|
|
255
269
|
problems.push(`shareStorage "${key}" needs the Keychain key native screens read it under.`);
|
|
256
270
|
}
|
|
271
|
+
if (hasNativeRoutes(config)) {
|
|
272
|
+
for (const id of ids) {
|
|
273
|
+
if (!matchNativePath(config.nativeScreens, id))
|
|
274
|
+
problems.push(`Tab "${id}" is not a nativeScreens path; with renderer: "native" every tab must be a native screen.`);
|
|
275
|
+
}
|
|
276
|
+
const start = nativeStartPath(config);
|
|
277
|
+
if (!matchNativePath(config.nativeScreens, start))
|
|
278
|
+
problems.push(`The start path "${start}" (from devServerURL) is not a nativeScreens path; with renderer: "native" the app must start on a native screen.`);
|
|
279
|
+
}
|
|
280
|
+
if (config.nativeApp !== undefined) {
|
|
281
|
+
if (typeof config.nativeApp !== "string" || !/^[A-Za-z_$][\w$-]*$/.test(config.nativeApp))
|
|
282
|
+
problems.push(`nativeApp "${String(config.nativeApp)}" must be a route name from the native bundle.`);
|
|
283
|
+
else if (!usesNativeBundle(config))
|
|
284
|
+
problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
285
|
+
}
|
|
257
286
|
return problems;
|
|
258
287
|
}
|
|
259
288
|
function installNativeBundle(source, output) {
|
|
@@ -1044,11 +1073,13 @@ ${nativeDependencies.join(`
|
|
|
1044
1073
|
writeFileSync2(join2(output, "WatchExtension", "Watch.entitlements"), renderWatchEntitlements(config));
|
|
1045
1074
|
}
|
|
1046
1075
|
writeFileSync2(join2(output, "dist", "index.html"), renderOfflinePage(config));
|
|
1047
|
-
if (
|
|
1076
|
+
if (usesNativeBundle(config)) {
|
|
1048
1077
|
if (config.nativeBundle)
|
|
1049
1078
|
installNativeBundle(config.nativeBundle, output);
|
|
1050
|
-
else
|
|
1079
|
+
else if (isHybrid(config))
|
|
1051
1080
|
console.warn(" \u26A0 nativeScreens is set without nativeBundle: every path opens in the web view until `craft ios build --native-bundle` supplies one.");
|
|
1081
|
+
else if (hasNativeRoutes(config))
|
|
1082
|
+
console.warn(" \u26A0 nativeScreens is set without nativeBundle: the app has no screens until `craft ios build --native-bundle` supplies one.");
|
|
1052
1083
|
}
|
|
1053
1084
|
console.log("\u2705 Project initialized");
|
|
1054
1085
|
console.log("");
|
|
@@ -1080,11 +1111,9 @@ async function build(options) {
|
|
|
1080
1111
|
if (htmlPath || devServer) {
|
|
1081
1112
|
throw new Error("A native iOS screen cannot use --html-path or --dev-server.");
|
|
1082
1113
|
}
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
cpSync(nativeBundlePath, destination);
|
|
1087
|
-
}
|
|
1114
|
+
const bundle = nativeBundlePath ?? config.nativeBundle;
|
|
1115
|
+
if (bundle)
|
|
1116
|
+
installNativeBundle(bundle, output);
|
|
1088
1117
|
if (!existsSync(join2(output, "dist", "native-screen.js"))) {
|
|
1089
1118
|
throw new Error("Native iOS mode needs dist/native-screen.js. Compile a .stx screen with `stx native compile` first.");
|
|
1090
1119
|
}
|
|
@@ -1238,6 +1267,7 @@ async function run(options) {
|
|
|
1238
1267
|
}
|
|
1239
1268
|
}
|
|
1240
1269
|
export {
|
|
1270
|
+
usesNativeBundle,
|
|
1241
1271
|
syncWebAssets,
|
|
1242
1272
|
showSimulator,
|
|
1243
1273
|
serializeSimulatorSteps,
|
|
@@ -1266,12 +1296,14 @@ export {
|
|
|
1266
1296
|
orderSimulators,
|
|
1267
1297
|
open,
|
|
1268
1298
|
normalizeNativePath,
|
|
1299
|
+
nativeStartPath,
|
|
1269
1300
|
matchNativePath,
|
|
1270
1301
|
isHybrid,
|
|
1271
1302
|
installRuntime,
|
|
1272
1303
|
initSimulatorDriver,
|
|
1273
1304
|
init,
|
|
1274
1305
|
hybridConfigProblems,
|
|
1306
|
+
hasNativeRoutes,
|
|
1275
1307
|
createNativeMutationValidator,
|
|
1276
1308
|
build,
|
|
1277
1309
|
bootSimulator,
|
|
@@ -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) (
|
|
@@ -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
|
|
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,
|
|
283
|
-
///
|
|
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 =
|
|
361
|
+
let wanted = !routes.isEmpty
|
|
290
362
|
if wanted && Self.bundleURL == nil {
|
|
291
|
-
NSLog(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
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
|
|
837
|
+
guard isEnabled else { return false }
|
|
681
838
|
let path = CraftHybridRoutes.appPath(of: url)
|
|
682
|
-
|
|
683
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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)
|
|
61
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|