craft-native 0.0.124 → 0.0.125

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.
@@ -227,13 +227,14 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
227
227
  /// `craft.background.complete(ok)`, or after 25 seconds, inside the 30 iOS
228
228
  /// allows.
229
229
  func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
230
- CraftBackgroundWork.shared.begin("craftSilentPush", detail: ["payload": CraftEventManager.pageData(userInfo)]) { ok in
230
+ let id = CraftBackgroundWork.shared.hold { ok in
231
231
  switch ok {
232
232
  case true?: completionHandler(.newData)
233
233
  case false?: completionHandler(.failed)
234
234
  case nil: completionHandler(.noData)
235
235
  }
236
236
  }
237
+ CraftEventManager.shared.sendToWeb("craftSilentPush", data: ["id": id, "payload": CraftEventManager.pageData(userInfo)])
237
238
  }
238
239
 
239
240
  func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
@@ -377,15 +378,15 @@ final class CraftBackgroundWork {
377
378
 
378
379
  private init() {}
379
380
 
380
- /// Tell the page, and hold `finish` until it answers: true or false from
381
- /// the page, nil when the deadline passed first.
382
- @discardableResult
383
- func begin(_ event: String, detail: [String: Any], finish: @escaping (Bool?) -> Void) -> String {
381
+ /// Hold `finish` until the page answers the event that carries the
382
+ /// returned id: true or false from the page, nil when the deadline passed
383
+ /// first. The caller sends that event, with the id in its detail, after
384
+ /// this returns. It names the event itself so every event the page can
385
+ /// hear is spelled at a `sendToWeb` call, where the conformance test reads
386
+ /// the vocabulary from.
387
+ func hold(_ finish: @escaping (Bool?) -> Void) -> String {
384
388
  let id = UUID().uuidString
385
389
  pending[id] = finish
386
- var detail = detail
387
- detail["id"] = id
388
- CraftEventManager.shared.sendToWeb(event, data: detail)
389
390
  DispatchQueue.main.asyncAfter(deadline: .now() + Self.deadline) { [weak self] in self?.end(id, ok: nil) }
390
391
  return id
391
392
  }
@@ -441,12 +442,13 @@ final class CraftBackgroundWork {
441
442
 
442
443
  private func run(_ task: BGTask) {
443
444
  scheduleRefresh()
444
- let id = begin("craftBackgroundRefresh", detail: [:]) { ok in
445
+ let id = hold { ok in
445
446
  task.setTaskCompleted(success: ok ?? false)
446
447
  }
447
448
  task.expirationHandler = {
448
449
  DispatchQueue.main.async { CraftBackgroundWork.shared.end(id, ok: false) }
449
450
  }
451
+ CraftEventManager.shared.sendToWeb("craftBackgroundRefresh", data: ["id": id])
450
452
  }
451
453
  }
452
454
 
@@ -461,6 +463,16 @@ struct CraftApp: App {
461
463
  Group {
462
464
  if appState.config.renderer == "native" {
463
465
  CraftNativeScreen(config: appState.config)
466
+ } else if CraftHybrid.shared.isEnabled {
467
+ // Web plus native screens (CraftHybrid.swift): the native
468
+ // screen for the start path at once, the page warm behind it.
469
+ ZStack(alignment: .bottom) {
470
+ CraftColorSchemeObserver()
471
+ CraftHybridRoot(config: appState.config)
472
+ .ignoresSafeArea()
473
+ CraftTabBarView()
474
+ CraftSplashView(background: appState.config.resolvedBackgroundColor)
475
+ }
464
476
  } else {
465
477
  // The page's own chrome, drawn natively over it: the tab
466
478
  // bar the page asks for, and the splash until it is ready.
@@ -521,6 +533,17 @@ final class CraftChrome: ObservableObject {
521
533
 
522
534
  private init() {}
523
535
 
536
+ /// The tab bar from `tabs` in craft.config.json, there before the page
537
+ /// loads; the page's own `tabBar` message replaces it later. `splash`
538
+ /// false when the first screen is native and draws itself.
539
+ func configure(tabs configured: [CraftConfig.Tab], selected: String?, splash: Bool) {
540
+ splashVisible = splash
541
+ guard !configured.isEmpty else { return }
542
+ tabs = configured.map { Tab(id: $0.id, title: $0.title, symbol: $0.symbol ?? "circle", badge: nil) }
543
+ self.selected = selected ?? configured.first?.id
544
+ tabBarVisible = true
545
+ }
546
+
524
547
  /// However the page behaves, the splash is gone after `seconds`
525
548
  /// (`splashMaxSeconds`, three by default). It used to be ten: a page that
526
549
  /// never painted held a logo on screen for ten seconds, which reads as a
@@ -541,7 +564,10 @@ final class CraftChrome: ObservableObject {
541
564
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
542
565
  return Tab(id: id, title: title, symbol: item["symbol"] as? String ?? "circle", badge: badge)
543
566
  }
544
- if let selected = body["selected"] as? String { self.selected = selected }
567
+ if let selected = body["selected"] as? String {
568
+ self.selected = selected
569
+ CraftHybrid.shared.pageSelectedTab(selected)
570
+ }
545
571
  if let hex = body["tint"] as? String {
546
572
  let light = UIColor(hex: hex)
547
573
  let dark = (body["tintDark"] as? String).flatMap { UIColor(hex: $0) }
@@ -553,6 +579,7 @@ final class CraftChrome: ObservableObject {
553
579
  if let id = body["id"] as? String, id != selected {
554
580
  withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
555
581
  }
582
+ if let id = body["id"] as? String { CraftHybrid.shared.pageSelectedTab(id) }
556
583
  case "hideTabBar":
557
584
  // Later, and only if no screen asks for the bar meanwhile: moving
558
585
  // between two screens that both have it unmounts one bar and
@@ -582,9 +609,13 @@ final class CraftChrome: ObservableObject {
582
609
  /// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
583
610
  /// one of the small things that gives a web shell away.
584
611
  func tap(_ tab: Tab) {
585
- if tab.id != selected {
612
+ let reselect = tab.id == selected
613
+ if !reselect {
586
614
  withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = tab.id }
587
615
  }
616
+ // A hybrid app shows the tab's own stack first; the page hears the
617
+ // tap unless the shell handled all of it (a native tab's reselect).
618
+ guard CraftHybrid.shared.tabTapped(tab.id, reselect: reselect) else { return }
588
619
  emit("craftTabSelect", ["id": tab.id])
589
620
  }
590
621
 
@@ -605,6 +636,9 @@ final class CraftChrome: ObservableObject {
605
636
  splashDeadline?.cancel()
606
637
  splashDeadline = nil
607
638
  guard splashVisible else { return }
639
+ #if DEBUG
640
+ NSLog("[craft] splash hidden")
641
+ #endif
608
642
  withAnimation(.easeOut(duration: 0.28)) { splashVisible = false }
609
643
  }
610
644
 
@@ -647,6 +681,7 @@ final class CraftChrome: ObservableObject {
647
681
  /// for it, and as an event for anything that measures.
648
682
  func publishLayout() {
649
683
  let height = tabBarVisible ? Int(occupied) : 0
684
+ CraftHybrid.shared.tabBarCovers(CGFloat(height))
650
685
  let script = "document.documentElement.style.setProperty('--craft-tab-bar-height','\(height)px');window.dispatchEvent(new CustomEvent('craftTabBarLayout',{detail:{height:\(height)}}));"
651
686
  DispatchQueue.main.async { [weak self] in self?.webView?.evaluateJavaScript(script, completionHandler: nil) }
652
687
  }
@@ -857,6 +892,19 @@ class DeepLinkManager {
857
892
  initialURL = url
858
893
  }
859
894
 
895
+ // A hybrid app opens a native path's screen itself, and tells the
896
+ // native screens that listen; every other link is the page's.
897
+ if CraftHybrid.shared.isEnabled {
898
+ if CraftHybrid.shared.handleDeepLink(url) {
899
+ dispatchNative(url, initial: false)
900
+ } else if isReady && webView != nil {
901
+ dispatchDeepLink(url, initial: false)
902
+ } else {
903
+ pendingURLs.append(url)
904
+ }
905
+ return
906
+ }
907
+
860
908
  if !nativeListeners.isEmpty {
861
909
  dispatchNative(url, initial: false)
862
910
  } else if isReady && webView != nil {
@@ -874,6 +922,9 @@ class DeepLinkManager {
874
922
  func addNativeListener(_ listener: @escaping (URL, Bool) -> Void) -> UUID {
875
923
  let token = UUID()
876
924
  nativeListeners[token] = listener
925
+ // In a hybrid app the page is the one waiting for the links that
926
+ // arrived early; a native screen hears only native ones, as they come.
927
+ if CraftHybrid.shared.isEnabled { return token }
877
928
  let firstNativeScreen = !hasBeenReady
878
929
  hasBeenReady = true
879
930
  let urls = pendingURLs
@@ -941,6 +992,9 @@ class AppState: ObservableObject {
941
992
  } else {
942
993
  self.config = CraftConfig()
943
994
  }
995
+ // Before the first frame: the hybrid start screen, and the tab bar
996
+ // from the config, are decided here.
997
+ CraftHybrid.shared.configure(config)
944
998
  }
945
999
  }
946
1000
 
@@ -1048,6 +1102,20 @@ struct CraftConfig: Codable {
1048
1102
  var disableZoom: Bool? = nil
1049
1103
  /// iOS's scheduled app refresh, which fires `craftBackgroundRefresh`.
1050
1104
  var backgroundRefresh: BackgroundRefresh? = nil
1105
+ /// Hybrid: app paths drawn natively, to the screen in `dist/native-screen.js`
1106
+ /// that draws each (`{"/m": "today"}`). Everything else is the web page.
1107
+ var nativeScreens: [String: String]? = nil
1108
+ /// The tab bar to show before the page describes its own.
1109
+ var tabs: [Tab]? = nil
1110
+ /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1111
+ /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1112
+ var shareStorage: [String: String]? = nil
1113
+
1114
+ struct Tab: Codable {
1115
+ var id: String
1116
+ var title: String
1117
+ var symbol: String? = nil
1118
+ }
1051
1119
 
1052
1120
  struct BackgroundRefresh: Codable {
1053
1121
  var enabled: Bool = false
@@ -1290,6 +1358,11 @@ struct CraftWebView: UIViewRepresentable {
1290
1358
  // they are the screen's, not a device API.
1291
1359
  let coordinator = context.coordinator
1292
1360
  contentController.add(CraftChromeRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftChrome")
1361
+ // Hybrid apps: navigation to native screens, snapshots and shared
1362
+ // storage, outside the bridge's action dispatch like the chrome.
1363
+ if CraftHybrid.shared.isEnabled {
1364
+ contentController.add(CraftHybridRelay(trusts: { [weak coordinator] origin in coordinator?.trusts(origin) ?? false }), name: "craftHybrid")
1365
+ }
1293
1366
  #if DEBUG
1294
1367
  // The page's errors and console, in the device log, so a debug build
1295
1368
  // can be diagnosed from `log stream` or `simctl spawn … log show`
@@ -1341,6 +1414,7 @@ struct CraftWebView: UIViewRepresentable {
1341
1414
  DeepLinkManager.shared.setWebView(webView)
1342
1415
  CraftEventManager.shared.setWebView(webView)
1343
1416
  CraftChrome.shared.webView = webView
1417
+ CraftHybrid.shared.webView = webView
1344
1418
  CraftChrome.shared.holdSplash(atMost: config.splashMaxSeconds)
1345
1419
 
1346
1420
  // Parse background color
@@ -2604,7 +2678,11 @@ struct CraftWebView: UIViewRepresentable {
2604
2678
  recoverOnForeground = false
2605
2679
  switch recoveryBudget.onCrash(at: ProcessInfo.processInfo.systemUptime) {
2606
2680
  case .reload:
2607
- CraftChrome.shared.showSplash(atMost: config.splashMaxSeconds)
2681
+ // Not over a native screen in a hybrid app: the page rebuilds
2682
+ // out of sight there.
2683
+ if CraftHybrid.shared.webIsVisible {
2684
+ CraftChrome.shared.showSplash(atMost: config.splashMaxSeconds)
2685
+ }
2608
2686
  installUserScripts(into: webView.configuration.userContentController)
2609
2687
  if webView.url == nil, let lastURL = lastPageURL {
2610
2688
  webView.load(config.request(for: lastURL))
@@ -2615,7 +2693,7 @@ struct CraftWebView: UIViewRepresentable {
2615
2693
  }
2616
2694
  case .giveUp:
2617
2695
  print("Craft: the page's content process died \(recoveryBudget.attempts) times in a minute; showing the offline page")
2618
- CraftChrome.shared.hideSplash()
2696
+ if CraftHybrid.shared.webIsVisible { CraftChrome.shared.hideSplash() }
2619
2697
  showOfflinePage(in: webView)
2620
2698
  }
2621
2699
  }
@@ -2705,6 +2783,10 @@ struct CraftWebView: UIViewRepresentable {
2705
2783
 
2706
2784
  /// True when a saved place was handed to the web view to load.
2707
2785
  func restoreInteractionState(into webView: WKWebView) -> Bool {
2786
+ // A hybrid app that opens on a native screen loads the page at
2787
+ // that screen's path, never where the page was left: the native
2788
+ // screen is the start.
2789
+ guard !CraftHybrid.shared.startsNative else { return false }
2708
2790
  guard #available(iOS 15.0, *), let file = Self.interactionStateFile,
2709
2791
  UserDefaults.standard.string(forKey: Self.interactionStateBuildKey) == Self.currentBuild,
2710
2792
  let state = try? Data(contentsOf: file) else { return false }
@@ -2792,6 +2874,19 @@ struct CraftWebView: UIViewRepresentable {
2792
2874
  webView.load(config.request(for: remote))
2793
2875
  }
2794
2876
 
2877
+ /// Load `path` on the app's own origin afresh: for a hybrid app whose
2878
+ /// page cannot navigate itself (still loading, or the offline page).
2879
+ func loadAppPath(_ path: String) {
2880
+ guard let webView else { return }
2881
+ guard let remote = config.devServerURL.flatMap(URL.init(string:)),
2882
+ let url = URL(string: path, relativeTo: remote)?.absoluteURL else {
2883
+ if let bundled = URL(string: "craft://app\(path)") { webView.load(URLRequest(url: bundled)) }
2884
+ return
2885
+ }
2886
+ loadedBundledFallback = false
2887
+ webView.load(config.request(for: url))
2888
+ }
2889
+
2795
2890
  fileprivate func isTrustedURL(_ url: URL?) -> Bool {
2796
2891
  guard let url = url else { return false }
2797
2892
  return isTrustedOrigin(scheme: url.scheme ?? "", host: url.host ?? "", port: url.port ?? 0)
@@ -2882,6 +2977,9 @@ struct CraftWebView: UIViewRepresentable {
2882
2977
  controller.addUserScript(WKUserScript(source: CraftConfig.viewportScript, injectionTime: .atDocumentStart, forMainFrameOnly: true))
2883
2978
  }
2884
2979
  controller.addUserScript(WKUserScript(source: bridgeScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
2980
+ if CraftHybrid.shared.isEnabled {
2981
+ controller.addUserScript(WKUserScript(source: CraftHybrid.shared.pageScript(), injectionTime: .atDocumentStart, forMainFrameOnly: true))
2982
+ }
2885
2983
  }
2886
2984
 
2887
2985
  private func appearanceSnapshot() -> [String: Any] {