craft-native 0.0.155 → 0.0.158
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +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 +48 -13
- package/dist/ios/src/testing.d.ts +7 -1
- 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 +98 -22
- package/dist/ios/templates/testing/DriverTests.swift.template +161 -145
- package/package.json +1 -1
|
@@ -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.
|