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.
@@ -18,6 +18,18 @@ import WebKit
18
18
  // reports where it went, and leaves the edge swipe to UIKit while it is at the
19
19
  // entry a native screen opened. Nothing in the page has to change for that;
20
20
  // craft's typescript mobile API adds `snapshots` and `hybrid` on top.
21
+ //
22
+ // `renderer: "native"` with `nativeScreens` is the same shell with no web
23
+ // view at all (`hasWeb` false): the same tabs, stacks and path routing, every
24
+ // tab and the start path a native screen, and a path no screen draws opened
25
+ // in the in-app browser on the app's site (`devServerURL`). No WKWebView is
26
+ // ever made; the services the web view's coordinator holds (GPS, the route
27
+ // recorder, Apple Music) come from a coordinator with no web view instead.
28
+ //
29
+ // Either way an app may run an app script (`nativeApp`): a bundle route with
30
+ // no screen, loaded once after the first frame and kept for the app's life.
31
+ // It hears the app's events (CraftEventManager) instead of the page, and
32
+ // opens paths through the shell.
21
33
 
22
34
  /// The `nativeScreens` table: which paths are native, and with what params.
23
35
  ///
@@ -128,6 +140,34 @@ struct CraftHybridRoutes {
128
140
  return path
129
141
  }
130
142
 
143
+ /// Whether a web address is on the app's site: the host of `site`
144
+ /// (`devServerURL`), over http or https.
145
+ static func isSite(_ url: URL, site: URL?) -> Bool {
146
+ let scheme = (url.scheme ?? "").lowercased()
147
+ guard scheme == "http" || scheme == "https",
148
+ let host = url.host?.lowercased(), !host.isEmpty,
149
+ let siteHost = site?.host?.lowercased() else { return false }
150
+ return host == siteHost
151
+ }
152
+
153
+ /// Where a path no native screen draws opens in an app with no web view:
154
+ /// a web address as it is, an app path on the origin of `site`. Nil for
155
+ /// anything else, and for a path when there is no site.
156
+ static func siteURL(for raw: String, site: URL?) -> URL? {
157
+ if let url = URL(string: raw), let scheme = url.scheme?.lowercased() {
158
+ return scheme == "http" || scheme == "https" ? url : nil
159
+ }
160
+ // `//host/x` is another host, not a path.
161
+ guard raw.hasPrefix("/"), !raw.hasPrefix("//"), let site,
162
+ var origin = URLComponents(url: site, resolvingAgainstBaseURL: false),
163
+ let scheme = origin.scheme?.lowercased(), scheme == "http" || scheme == "https" else { return nil }
164
+ origin.percentEncodedPath = ""
165
+ origin.percentEncodedQuery = nil
166
+ origin.percentEncodedFragment = nil
167
+ guard let base = origin.url else { return nil }
168
+ return URL(string: raw, relativeTo: base)?.absoluteURL
169
+ }
170
+
131
171
  /// The tab a path belongs to: the tab whose id is the path, or else the
132
172
  /// longest tab id that is a whole-segment prefix of it.
133
173
  static func tab(owning raw: String, among tabs: [String]) -> String? {
@@ -233,8 +273,15 @@ final class CraftHybrid: NSObject {
233
273
 
234
274
  private(set) var config = CraftConfig()
235
275
  private(set) var routes = CraftHybridRoutes([:])
236
- /// Whether this app mixes native screens into its web page.
276
+ /// Whether this app routes paths to native screens through the shell:
277
+ /// web plus native screens (hybrid), or all native with paths and tabs.
237
278
  private(set) var isEnabled = false
279
+ /// Whether the app has a web page at all (renderer "web"). False for an
280
+ /// all-native app, whose shell never makes a web view.
281
+ private(set) var hasWeb = true
282
+ /// The app's site (`devServerURL`, http or https): where an all-native
283
+ /// app opens a path no screen draws, in the in-app browser.
284
+ private(set) var siteURL: URL?
238
285
  /// The path the app opens on: its page's path.
239
286
  private(set) var startPath = "/"
240
287
  /// Whether that path is a native screen, drawn before the page loads.
@@ -242,6 +289,22 @@ final class CraftHybrid: NSObject {
242
289
  weak var controller: CraftHybridController?
243
290
  weak var webView: WKWebView?
244
291
 
292
+ /// The app script's bundle route (`nativeApp`), when it runs.
293
+ private(set) var appScriptRoute: String?
294
+ /// The app script itself, held for the app's life and never shown.
295
+ private var appScript: CraftNativeScreenController?
296
+ /// An all-native app's services: the web view's coordinator, with no web
297
+ /// view, so GPS, the route recorder (and its relaunch restore) and Apple
298
+ /// Music serve native screens (CraftWebServices). Held here, because
299
+ /// `CraftSwiftShim.coordinator` is weak.
300
+ private var services: CraftWebView.Coordinator?
301
+ private var isConfigured = false
302
+ /// A single-stack native app's first frame, which starts the app script.
303
+ private var firstFrameSeen = false
304
+ /// What waits for the shell: links and opens that arrived before it
305
+ /// existed (a launch from a notification or a link).
306
+ private var untilShell: [(CraftHybridController) -> Void] = []
307
+
245
308
  /// Where the page is, as it last said.
246
309
  fileprivate(set) var pagePath: String?
247
310
  fileprivate(set) var pageDepth = 0
@@ -261,6 +324,7 @@ final class CraftHybrid: NSObject {
261
324
  func nativeWroteShared(nativeKey: String, value: String?) {
262
325
  guard let webKey = config.shareStorage?.first(where: { $0.value == nativeKey })?.key else { return }
263
326
  CraftHybridKeychain.set(nativeKey, value: value)
327
+ guard hasWeb else { return }
264
328
  DispatchQueue.main.async {
265
329
  self.pendingShared[webKey] = value ?? NSNull()
266
330
  self.flushShared()
@@ -279,20 +343,113 @@ final class CraftHybrid: NSObject {
279
343
  ?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
280
344
  }
281
345
 
282
- /// From the bundled config, before the first frame: whether the app is
283
- /// hybrid, where it starts, and the tab bar it starts with.
346
+ /// From the bundled config, at launch: whether the shell routes paths,
347
+ /// whether there is a web view, where it starts, the tab bar it starts
348
+ /// with, the app script, and an all-native app's services. Once: the app
349
+ /// delegate calls it at launch and AppState again; the first call decides.
284
350
  func configure(_ config: CraftConfig) {
351
+ guard !isConfigured else { return }
352
+ isConfigured = true
285
353
  self.config = config
354
+ hasWeb = config.renderer != "native"
355
+ siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
356
+ ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
357
+ }
286
358
  routes = CraftHybridRoutes(config.nativeScreens ?? [:])
287
359
  startPath = config.devServerURL.flatMap(URL.init(string:)).map(CraftHybridRoutes.appPath(of:)) ?? "/"
288
360
  if startPath.isEmpty { startPath = "/" }
289
- let wanted = config.renderer == "web" && !routes.isEmpty
361
+ let wanted = !routes.isEmpty
290
362
  if wanted && Self.bundleURL == nil {
291
- NSLog("[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view")
363
+ NSLog(hasWeb
364
+ ? "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view"
365
+ : "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
292
366
  }
293
367
  isEnabled = wanted && Self.bundleURL != nil
368
+ if isEnabled, !hasWeb, routes.match(startPath) == nil {
369
+ // No web view to show it in: the first tab a screen draws.
370
+ let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
371
+ NSLog("[craft hybrid] no native screen draws the start path %@ and there is no web view; starting on %@", startPath, fallback ?? "nothing")
372
+ if let fallback { startPath = fallback }
373
+ }
294
374
  startsNative = isEnabled && routes.match(startPath) != nil
295
- CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: !startsNative)
375
+ // The splash waits for a page; an all-native app's first screen draws itself.
376
+ CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
377
+
378
+ let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
379
+ appScriptRoute = script != nil && Self.bundleURL != nil && (isEnabled || !hasWeb) ? script : nil
380
+ if let script, appScriptRoute == nil {
381
+ NSLog("[craft hybrid] nativeApp %@ does not run: it needs dist/native-screen.js, and nativeScreens or renderer \"native\"", script)
382
+ }
383
+ CraftEventManager.shared.configure(appScript: appScriptRoute != nil, page: hasWeb)
384
+
385
+ if !hasWeb && services == nil {
386
+ let coordinator = CraftWebView.Coordinator(config: config)
387
+ services = coordinator
388
+ CraftSwiftShim.coordinator = coordinator
389
+ }
390
+
391
+ guard appScriptRoute != nil else { return }
392
+ // It starts after the first frame (`nativeScreenDidRender`), or as
393
+ // soon as background work waits for it (`backgroundWorkWaiting`).
394
+ // This is for the rest: a first screen that is the page's, one that
395
+ // never draws, a launch in the background with nothing to draw.
396
+ // (`applicationState` cannot tell those apart here: a scene-based app
397
+ // is `.background` during launch however it was opened.)
398
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
399
+ }
400
+
401
+ /// A silent push or a background refresh is waiting for the app script,
402
+ /// with iOS's clock running: it starts now rather than after a frame,
403
+ /// which a launch in the background never draws.
404
+ func backgroundWorkWaiting() {
405
+ guard appScriptRoute != nil, appScript == nil else { return }
406
+ DispatchQueue.main.async { self.startAppScript() }
407
+ }
408
+
409
+ /// The app script: loaded once, off screen, and kept. Started after the
410
+ /// first frame is committed, so it never delays it, or at once when
411
+ /// background work is waiting for it. Its `craft.navigation.open` comes here
412
+ /// (`nativeScreen(_:open:)`), and so to the shell.
413
+ func startAppScript() {
414
+ guard appScript == nil, let route = appScriptRoute else { return }
415
+ let script = CraftNativeScreenController(config: config, routeName: route, isAppScript: true)
416
+ script.hybridEvents = self
417
+ appScript = script
418
+ script.loadViewIfNeeded()
419
+ #if DEBUG
420
+ NSLog("[craft hybrid] app script %@ is running", route)
421
+ #endif
422
+ }
423
+
424
+ /// The shell is up: what waited for it.
425
+ func shellReady() {
426
+ guard let controller else { return }
427
+ let waiting = untilShell
428
+ untilShell.removeAll()
429
+ for work in waiting { work(controller) }
430
+ }
431
+
432
+ /// A path or a web address opened by the app script or a single-stack
433
+ /// screen: the shell opens it (holding it until the shell exists); an app
434
+ /// with no shell opens a web address in the in-app browser.
435
+ func open(_ path: String) {
436
+ if let controller {
437
+ controller.open(path)
438
+ } else if isEnabled {
439
+ untilShell.append { $0.open(path) }
440
+ } else {
441
+ openOutside(path)
442
+ }
443
+ }
444
+
445
+ /// A path no native screen draws, in an app with no web view, or an
446
+ /// address on another site: the in-app browser.
447
+ func openOutside(_ raw: String) {
448
+ guard let url = CraftHybridRoutes.siteURL(for: raw, site: siteURL) else {
449
+ NSLog("[craft hybrid] %@ has no native screen, and the app has no site (devServerURL) to open it on", raw)
450
+ return
451
+ }
452
+ CraftBrowser.shared.open(url, mode: "safari", callbackScheme: nil, from: nil) { _ in }
296
453
  }
297
454
 
298
455
  private func tabIds(from tabs: [CraftConfig.Tab]) -> [String] { tabs.map(\.id) }
@@ -303,8 +460,10 @@ final class CraftHybrid: NSObject {
303
460
  return shown.isEmpty ? (config.tabs ?? []).map(\.id) : shown
304
461
  }
305
462
 
306
- /// Whether the web view is what the person sees: always, outside hybrid.
463
+ /// Whether the web view is what the person sees: always in a web app
464
+ /// outside hybrid, never in an app with no web view.
307
465
  var webIsVisible: Bool {
466
+ guard hasWeb else { return false }
308
467
  guard isEnabled else { return true }
309
468
  return controller?.webIsVisible ?? !startsNative
310
469
  }
@@ -631,11 +790,7 @@ final class CraftHybrid: NSObject {
631
790
  case .snapshotRemove(let name):
632
791
  CraftSnapshots.write(name, json: nil)
633
792
  case .snapshotClear:
634
- let names = CraftSnapshots.directory
635
- .flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
636
- .filter { $0.hasSuffix(".json") }
637
- .map { String($0.dropLast(5)) } ?? []
638
- for name in names { CraftSnapshots.write(name, json: nil) }
793
+ CraftSnapshots.clear()
639
794
  case .shareStorage(let key, let value):
640
795
  guard let nativeKey = config.shareStorage?[key] else { return }
641
796
  // The page's own write, or its echo of one a native screen made.
@@ -675,12 +830,26 @@ final class CraftHybrid: NSObject {
675
830
  controller?.pageSelectedTab(id)
676
831
  }
677
832
 
678
- /// A deep link to a native path opens the native screen; true when it did.
833
+ /// A deep link to a native path opens the native screen; in an app with
834
+ /// no web view, a link to its own site that no screen draws opens in the
835
+ /// in-app browser. True when the shell took it.
679
836
  func handleDeepLink(_ url: URL) -> Bool {
680
- guard isEnabled, let controller else { return false }
837
+ guard isEnabled else { return false }
681
838
  let path = CraftHybridRoutes.appPath(of: url)
682
- guard routes.match(path) != nil else { return false }
683
- controller.showNative(path: path, replace: false, fromLink: true)
839
+ let native = routes.match(path) != nil
840
+ let browse = !native && !hasWeb && CraftHybridRoutes.isSite(url, site: siteURL)
841
+ guard native || browse else { return false }
842
+ let show: (CraftHybridController) -> Void = { [weak self] controller in
843
+ if native { controller.showNative(path: path, replace: false, fromLink: true) } else { self?.openOutside(url.absoluteString) }
844
+ }
845
+ guard let controller else {
846
+ // Before the shell: a hybrid app's page has it; an all-native app
847
+ // opens it once the shell is up (a link that launched the app).
848
+ guard !hasWeb else { return false }
849
+ untilShell.append(show)
850
+ return true
851
+ }
852
+ show(controller)
684
853
  return true
685
854
  }
686
855
 
@@ -702,6 +871,7 @@ final class CraftHybrid: NSObject {
702
871
  answered = true
703
872
  completion(depth)
704
873
  }
874
+ guard hasWeb else { answer(nil); return }
705
875
  guard let webView, pageReady, let scheme = webView.url?.scheme, scheme == "http" || scheme == "https" else {
706
876
  CraftSwiftShim.coordinator?.loadAppPath(path)
707
877
  answer(nil)
@@ -719,20 +889,43 @@ final class CraftHybrid: NSObject {
719
889
  }
720
890
 
721
891
  func setPageBase(_ depth: Int?) {
892
+ guard hasWeb else { return }
722
893
  let value = depth.map(String.init) ?? "null"
723
894
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.setBase(\(value))", completionHandler: nil)
724
895
  }
725
896
 
726
897
  func pageReset(to path: String) {
727
- guard let data = try? JSONSerialization.data(withJSONObject: [path]), let json = String(data: data, encoding: .utf8) else { return }
898
+ guard hasWeb, let data = try? JSONSerialization.data(withJSONObject: [path]), let json = String(data: data, encoding: .utf8) else { return }
728
899
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.reset && window.__craftHybrid.reset(\(json)[0])", completionHandler: nil)
729
900
  }
730
901
 
731
902
  func pageBack(to depth: Int) {
903
+ guard hasWeb else { return }
732
904
  webView?.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.backTo(\(max(0, depth)))", completionHandler: nil)
733
905
  }
734
906
  }
735
907
 
908
+ /// What the app script and a single-stack native app's screens tell the shell.
909
+ extension CraftHybrid: CraftHybridScreenEvents {
910
+ /// A single-stack app's first frame: the app script starts once it is
911
+ /// committed. (The routed shell's controller does the same for its own.)
912
+ func nativeScreenDidRender(_ screen: CraftNativeScreenController) {
913
+ guard !firstFrameSeen else { return }
914
+ firstFrameSeen = true
915
+ CATransaction.setCompletionBlock { [weak self] in self?.startAppScript() }
916
+ }
917
+
918
+ /// The screen keeps its own error; the app script still runs.
919
+ func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
920
+ NSLog("[craft hybrid] the native screen could not draw its first frame: %@", message)
921
+ DispatchQueue.main.async { self.startAppScript() }
922
+ }
923
+
924
+ func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
925
+ open(path)
926
+ }
927
+ }
928
+
736
929
  /// The page's `craftHybrid` channel, only from a trusted origin's main frame.
737
930
  final class CraftHybridRelay: NSObject, WKScriptMessageHandler {
738
931
  private let trusts: (WKSecurityOrigin) -> Bool
@@ -796,7 +989,8 @@ final class CraftHybridWebSlot: UIViewController {
796
989
  final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
797
990
  private let config: CraftConfig
798
991
  private let hybrid = CraftHybrid.shared
799
- private let webHost: UIHostingController<AnyView>
992
+ /// The web view's host; nil in an all-native app, which never makes one.
993
+ private let webHost: UIHostingController<AnyView>?
800
994
  /// Where the web view waits, behind the screens, while none of them is web.
801
995
  private let parking = UIView()
802
996
  private var stacks: [String: UINavigationController] = [:]
@@ -816,7 +1010,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
816
1010
  // Edge to edge, but not under the keyboard: the web view ends at its
817
1011
  // top. Under it, WebKit scrolled the page's layout viewport to show a
818
1012
  // focused field and carried every sticky header off the screen.
819
- webHost = UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea(.container)))
1013
+ webHost = CraftHybrid.shared.hasWeb
1014
+ ? UIHostingController(rootView: AnyView(CraftWebView(config: config).ignoresSafeArea(.container)))
1015
+ : nil
820
1016
  super.init(nibName: nil, bundle: nil)
821
1017
  hybrid.controller = self
822
1018
  }
@@ -831,21 +1027,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
831
1027
  view.addSubview(parking)
832
1028
 
833
1029
  // A native start screen draws first; the web view, whose creation
834
- // costs a frame or more, starts loading straight after it.
835
- if !hybrid.startsNative { attachWeb() }
1030
+ // costs a frame or more, starts loading straight after it. An
1031
+ // all-native app has none.
1032
+ if hybrid.hasWeb && !hybrid.startsNative { attachWeb() }
836
1033
  let tabs = hybrid.tabIds
837
1034
  let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
838
1035
  show(tab: start, rootPath: hybrid.startPath)
839
- if hybrid.startsNative {
1036
+ if hybrid.hasWeb && hybrid.startsNative {
840
1037
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
841
1038
  }
1039
+ // Links and opens that arrived before the shell, once the start
1040
+ // screen is up.
1041
+ DispatchQueue.main.async { [weak self] in self?.hybrid.shellReady() }
842
1042
  }
843
1043
 
844
1044
  private var webAttached = false
845
1045
 
846
1046
  /// The web view joins the screen, behind the native screens, and loads.
847
1047
  private func attachWeb() {
848
- guard !webAttached else { return }
1048
+ guard let webHost, !webAttached else { return }
849
1049
  webAttached = true
850
1050
  webHost.view.backgroundColor = config.resolvedBackgroundColor
851
1051
  if #available(iOS 16.4, *) { webHost.safeAreaRegions = [.keyboard] }
@@ -859,6 +1059,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
859
1059
  /// The web view's controller is the child of whichever controller shows
860
1060
  /// it: UIKit refuses a child's view in another controller's hierarchy.
861
1061
  private func moveWeb(to owner: UIViewController, in container: UIView) {
1062
+ guard let webHost else { return }
862
1063
  if webHost.parent !== owner {
863
1064
  if webHost.parent != nil {
864
1065
  webHost.willMove(toParent: nil)
@@ -888,7 +1089,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
888
1089
  private var visibleStack: UINavigationController? { stacks[currentTab] }
889
1090
 
890
1091
  var webIsVisible: Bool {
891
- guard let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
1092
+ guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
892
1093
  return webHost.view.superview === slot.view
893
1094
  }
894
1095
 
@@ -931,6 +1132,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
931
1132
  nativePaths[ObjectIdentifier(screen)] = match.path
932
1133
  return screen
933
1134
  }
1135
+ guard hybrid.hasWeb else {
1136
+ // The config checks rule this out (every tab and the start path
1137
+ // are native screens); an empty screen rather than a web entry
1138
+ // nothing would ever fill.
1139
+ NSLog("[craft hybrid] no native screen draws %@, and the app has no web view", path)
1140
+ let empty = UIViewController()
1141
+ empty.view.backgroundColor = config.resolvedBackgroundColor
1142
+ return empty
1143
+ }
934
1144
  return CraftHybridWebSlot(path: path, depth: depth, isTabRoot: isTabRoot)
935
1145
  }
936
1146
 
@@ -959,7 +1169,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
959
1169
  }
960
1170
 
961
1171
  func adoptWeb(into slot: CraftHybridWebSlot) {
962
- guard slot.navigationController === visibleStack || visibleStack == nil else { return }
1172
+ guard let webHost, slot.navigationController === visibleStack || visibleStack == nil else { return }
963
1173
  attachWeb()
964
1174
  moveWeb(to: slot, in: slot.view)
965
1175
  webHost.view.alpha = slot.awaitingPage ? 0 : 1
@@ -967,7 +1177,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
967
1177
  }
968
1178
 
969
1179
  private func parkWeb() {
970
- guard webAttached else { return }
1180
+ guard webAttached, let webHost else { return }
971
1181
  webHost.view.alpha = 1
972
1182
  moveWeb(to: self, in: parking)
973
1183
  }
@@ -975,8 +1185,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
975
1185
  // MARK: Opening paths
976
1186
 
977
1187
  /// `craft.navigation.open(path)` or `craft.hybrid.open(path)`: a tab, a
978
- /// native screen, or the web view pushed over the screen that asked.
1188
+ /// native screen, or the web view pushed over the screen that asked. In
1189
+ /// an app with no web view, a path no screen draws opens in the in-app
1190
+ /// browser on the app's site. A web address on the app's site is its
1191
+ /// path; one anywhere else opens in the in-app browser.
979
1192
  func open(_ raw: String) {
1193
+ if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
1194
+ guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
1195
+ hybrid.openOutside(raw)
1196
+ return
1197
+ }
1198
+ open(CraftHybridRoutes.appPath(of: url))
1199
+ return
1200
+ }
980
1201
  guard let path = CraftHybridRoutes.normalize(raw) else { return }
981
1202
  if let tab = hybrid.tabIds.first(where: { CraftHybridRoutes.normalize($0) == path }) {
982
1203
  selectTabAsIfTapped(tab)
@@ -986,7 +1207,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
986
1207
  showNative(path: raw, replace: false)
987
1208
  return
988
1209
  }
989
- pushWeb(raw)
1210
+ if hybrid.hasWeb {
1211
+ pushWeb(raw)
1212
+ } else {
1213
+ hybrid.openOutside(raw)
1214
+ }
990
1215
  }
991
1216
 
992
1217
  private func selectTabAsIfTapped(_ id: String) {
@@ -1035,7 +1260,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1035
1260
  /// The web view, pushed: the page goes to `path` first, without its own
1036
1261
  /// slide, then the entry slides in over the screen that opened it.
1037
1262
  private func pushWeb(_ path: String) {
1038
- guard !openingWeb, let nav = visibleStack else { return }
1263
+ guard hybrid.hasWeb, !openingWeb, let nav = visibleStack else { return }
1039
1264
  openingWeb = true
1040
1265
  hybrid.navigatePage(path) { [weak self, weak nav] depth in
1041
1266
  guard let self else { return }
@@ -1056,8 +1281,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1056
1281
 
1057
1282
  private func reveal(_ slot: CraftHybridWebSlot) {
1058
1283
  slot.awaitingPage = false
1059
- guard webHost.view.superview === slot.view else { return }
1060
- UIView.animate(withDuration: 0.15) { self.webHost.view.alpha = 1 }
1284
+ guard let webHost, webHost.view.superview === slot.view else { return }
1285
+ UIView.animate(withDuration: 0.15) { webHost.view.alpha = 1 }
1061
1286
  }
1062
1287
 
1063
1288
  /// Back from the web entry on top, as the page's Back at that entry asks.
@@ -1113,6 +1338,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1113
1338
  // then its own first screen).
1114
1339
  return nav.topViewController is CraftHybridWebSlot
1115
1340
  }
1341
+ // No page to wait for: the tab's own stack, now.
1342
+ guard hybrid.hasWeb else {
1343
+ show(tab: id)
1344
+ return true
1345
+ }
1116
1346
  let target = stacks[id]
1117
1347
  let rootIsWeb = target.map { $0.topViewController is CraftHybridWebSlot } ?? (hybrid.routes.match(id) == nil)
1118
1348
  let pageThere = hybrid.pagePath.flatMap { CraftHybridRoutes.tab(owning: $0, among: hybrid.tabIds) } == id
@@ -1265,12 +1495,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1265
1495
  CATransaction.setCompletionBlock { [weak self] in
1266
1496
  NSLog("[craft hybrid] native first frame %@", path)
1267
1497
  self?.attachWeb()
1498
+ // The app script after the first frame, never before it.
1499
+ self?.hybrid.startAppScript()
1268
1500
  }
1269
1501
  CraftChrome.shared.hideSplash()
1270
1502
  }
1271
1503
 
1272
1504
  func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String) {
1273
1505
  let path = nativePaths[ObjectIdentifier(screen)] ?? hybrid.startPath
1506
+ guard hybrid.hasWeb else {
1507
+ // No web view to open it in: the screen keeps its own error.
1508
+ NSLog("[craft hybrid] native screen for %@ failed (%@); the app has no web view to open it in", path, message)
1509
+ if !firstFrameLogged { DispatchQueue.main.async { [weak self] in self?.hybrid.startAppScript() } }
1510
+ return
1511
+ }
1274
1512
  NSLog("[craft hybrid] native screen for %@ failed (%@); opening it in the web view", path, message)
1275
1513
  DispatchQueue.main.async { [weak self, weak screen] in
1276
1514
  guard let self, let screen, let nav = screen.navigationController else { return }
@@ -57,10 +57,14 @@ enum CraftNativeActions {
57
57
  values.append(contentsOf: ["brightness", "statusBar"])
58
58
  if config.enableKeepAwake { values.append("keepAwake") }
59
59
  // GPS and the route recording are the web view's coordinator's
60
- // (CraftWebServices), so a native-only app, which has none, has none.
61
- if config.enableGeolocation && config.renderer != "native" { values.append("geolocation") }
60
+ // (CraftWebServices); an app with no web view has the coordinator
61
+ // all the same (CraftHybrid.configure).
62
+ if config.enableGeolocation { values.append("geolocation") }
62
63
  // The Music app's song and transport are the coordinator's too.
63
- if config.enableAppleMusic && config.renderer != "native" { values.append("music") }
64
+ if config.enableAppleMusic { values.append("music") }
65
+ // `craft.background.complete(ok, id)`: the answer to a silent push or
66
+ // a background refresh, which the app script (`nativeApp`) hears.
67
+ if config.backgroundRefresh?.enabled == true || config.enablePushNotifications { values.append("background") }
64
68
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
65
69
  // so it needs no web view.
66
70
  if config.enableHeartRate { values.append("heartRate") }
@@ -407,6 +411,21 @@ enum CraftNativeActions {
407
411
  complete(requestToken, failure("UNKNOWN_ACTION", "Unsupported Push method"), completion)
408
412
  }
409
413
  return
414
+ case ("Background", "complete"):
415
+ // The app script's answer to `craftSilentPush` or
416
+ // `craftBackgroundRefresh`: args `[ok, id?]`, with no id for
417
+ // everything still pending, as a page's `craft.background.complete`.
418
+ guard config.backgroundRefresh?.enabled == true || config.enablePushNotifications else {
419
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Background work is disabled"), completion)
420
+ return
421
+ }
422
+ let ok = args.first as? Bool ?? true
423
+ let id = args.count > 1 ? args[1] as? String : nil
424
+ DispatchQueue.main.async {
425
+ CraftBackgroundWork.shared.complete(id: id, ok: ok)
426
+ complete(requestToken, .success(true), completion)
427
+ }
428
+ return
410
429
  case ("Notifications", _):
411
430
  guard config.enableLocalNotifications else {
412
431
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
@@ -1081,6 +1100,41 @@ enum CraftSnapshots {
1081
1100
  }
1082
1101
  }
1083
1102
  }
1103
+
1104
+ /// Every snapshot there is: on disk, and written but not on disk yet.
1105
+ static func names() -> [String] {
1106
+ var names = Set(directory
1107
+ .flatMap { try? FileManager.default.contentsOfDirectory(atPath: $0.path) }?
1108
+ .filter { $0.hasSuffix(".json") }
1109
+ .map { String($0.dropLast(5)) } ?? [])
1110
+ lock.lock()
1111
+ for (name, json) in pending {
1112
+ if json == nil { names.remove(name) } else { names.insert(name) }
1113
+ }
1114
+ lock.unlock()
1115
+ return names.filter(isValidName).sorted()
1116
+ }
1117
+
1118
+ /// Deletes every snapshot but the ones named in `keep` (a sign-out), each
1119
+ /// as `write(name, json: nil)` does, so each posts `changed`.
1120
+ /// `completion` runs on the main queue: true when every one went.
1121
+ static func clear(keeping keep: Set<String> = [], completion: ((Bool) -> Void)? = nil) {
1122
+ let doomed = names().filter { !keep.contains($0) }
1123
+ guard !doomed.isEmpty else {
1124
+ DispatchQueue.main.async { completion?(true) }
1125
+ return
1126
+ }
1127
+ let group = DispatchGroup()
1128
+ var allRemoved = true
1129
+ for name in doomed {
1130
+ group.enter()
1131
+ write(name, json: nil) { ok in
1132
+ if !ok { allRemoved = false }
1133
+ group.leave()
1134
+ }
1135
+ }
1136
+ group.notify(queue: .main) { completion?(allRemoved) }
1137
+ }
1084
1138
  }
1085
1139
 
1086
1140
  /// The Taptic Engine's generators, held for the life of the process.