craft-native 0.0.486 → 0.0.490

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.
@@ -2046,6 +2046,9 @@ struct CraftConfig: Codable {
2046
2046
  /// (`auth.token` unless set): `craft.session` follows it, and a change of
2047
2047
  /// it is `SESSION_CHANGED` on every screen (CraftSession).
2048
2048
  var sessionTokenKey: String? = nil
2049
+ /// Which screens a signed-out launch may start on, resolved before the
2050
+ /// first frame is built (CraftHybrid.configure).
2051
+ var nativeGate: NativeGate? = nil
2049
2052
  /// The bundle route of the app script: a screen-less script that runs for
2050
2053
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
2051
2054
  var nativeApp: String? = nil
@@ -2057,6 +2060,33 @@ struct CraftConfig: Codable {
2057
2060
  /// unless the config names a project key.
2058
2061
  var crashReporting: CrashReporting? = nil
2059
2062
 
2063
+ /// An auth gate the shell applies while resolving the start path.
2064
+ ///
2065
+ /// An app script cannot do this. It is started from the first frame's
2066
+ /// `CATransaction` completion block, so by the time it can read a token
2067
+ /// the signed-in screen it would have prevented is already on screen —
2068
+ /// with the previous session's kept snapshot in it when the launch
2069
+ /// follows an expired token rather than a sign-out (#499). The check has
2070
+ /// to belong to whoever decides the start path, and that is the shell.
2071
+ struct NativeGate: Codable {
2072
+ /// Paths that may be shown without a sign-in. A path is public when
2073
+ /// it is listed, or when it sits under a listed one: `/m/store`
2074
+ /// admits `/m/store/42` and not `/m/storefront`.
2075
+ ///
2076
+ /// Spelled `publicPaths` rather than `public` in the stored config
2077
+ /// too. `public` is a Swift keyword, and remapping it through
2078
+ /// `CodingKeys` would make the property name and the stored key
2079
+ /// differ — which is exactly what the Zig conformance mirror checks,
2080
+ /// and a divergence worth not having for the sake of one word.
2081
+ var publicPaths: [String]? = nil
2082
+ /// Where a signed-out launch starts instead.
2083
+ var redirect: String? = nil
2084
+ /// The secure-storage key holding the sign-in. Defaults to
2085
+ /// `sessionTokenKey`, which is the same item `craft.session` follows,
2086
+ /// so an app that already declares one need not repeat it.
2087
+ var token: String? = nil
2088
+ }
2089
+
2060
2090
  struct Tab: Codable {
2061
2091
  var id: String
2062
2092
  var title: String
@@ -425,6 +425,7 @@ final class CraftHybrid: NSObject {
425
425
  : "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
426
426
  }
427
427
  isEnabled = wanted && Self.hasBundle
428
+ applyGate(config)
428
429
  if isEnabled, !hasWeb, routes.match(startPath) == nil {
429
430
  // No web view to show it in: the first tab a screen draws.
430
431
  let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
@@ -458,6 +459,54 @@ final class CraftHybrid: NSObject {
458
459
  DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { self.startAppScript() }
459
460
  }
460
461
 
462
+ /// Whether `path` may be shown to someone who is not signed in.
463
+ ///
464
+ /// Listed, or under something listed: `/m/store` admits `/m/store/42` and
465
+ /// not `/m/storefront`. Prefixes are compared with the separator attached
466
+ /// so a listed path cannot admit a sibling that merely starts with its
467
+ /// letters.
468
+ static func gateAdmits(_ path: String, public allowed: [String]) -> Bool {
469
+ allowed.contains { entry in
470
+ var listed = entry
471
+ while listed.count > 1, listed.hasSuffix("/") { listed.removeLast() }
472
+ guard !listed.isEmpty else { return false }
473
+ if path == listed { return true }
474
+ // "/" is the root path, not a prefix of everything. Reading it as
475
+ // one would admit every path, which is a gate that is not a gate.
476
+ guard listed != "/" else { return false }
477
+ return path.hasPrefix(listed + "/")
478
+ }
479
+ }
480
+
481
+ /// Send a signed-out launch to the gate's redirect before anything is built.
482
+ ///
483
+ /// This runs while `startPath` is still being resolved, which is the
484
+ /// whole point: the app script that would otherwise do it does not start
485
+ /// until the first frame has been committed, so it can only push sign-in
486
+ /// on top of a screen that has already been drawn with the previous
487
+ /// session's data in it (#499).
488
+ ///
489
+ /// The Keychain read is synchronous and can cost 100-900ms cold. It is
490
+ /// not an extra read: every signed-in screen reads this same item when it
491
+ /// opens, and `CraftKeychain` caches it, so the gate moves the app's first
492
+ /// read earlier rather than adding one. Only apps that declare a gate pay
493
+ /// it at all.
494
+ private func applyGate(_ config: CraftConfig) {
495
+ guard let gate = config.nativeGate, let redirect = gate.redirect, !redirect.isEmpty else { return }
496
+ guard !Self.gateAdmits(startPath, public: gate.publicPaths ?? []) else { return }
497
+ let key = gate.token ?? config.sessionTokenKey ?? "auth.token"
498
+ guard (CraftNativeActions.webSecureValue(forKey: key) ?? "").isEmpty else { return }
499
+ // A redirect nothing can draw would trade a private screen for a
500
+ // blank one. Say so and leave the start path alone; the fallback
501
+ // below then treats it as any other unroutable start.
502
+ if isEnabled, !hasWeb, routes.match(redirect) == nil {
503
+ NSLog("[craft hybrid] nativeGate redirect %@ has no native screen; the gate did not run", redirect)
504
+ return
505
+ }
506
+ NSLog("[craft hybrid] no sign-in under %@: starting on %@ instead of %@", key, redirect, startPath)
507
+ startPath = redirect
508
+ }
509
+
461
510
  /// A silent push or a background refresh is waiting for the app script,
462
511
  /// with iOS's clock running: it starts now rather than after a frame,
463
512
  /// which a launch in the background never draws.
@@ -2001,7 +2001,15 @@ enum CraftSnapshots {
2001
2001
  }
2002
2002
  lock.unlock()
2003
2003
  DispatchQueue.main.async {
2004
- if ok { NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name]) }
2004
+ // Posted whether or not the write landed. A screen displays its
2005
+ // checkpoint optimistically, and this notification carries only
2006
+ // the name, so a reader answers it by calling `read` — which
2007
+ // after a failure finds the previous value, because `pending`
2008
+ // was cleared above and the cache was only refilled on success.
2009
+ // Posting only on success left a failed checkpoint on screen as
2010
+ // though it had been kept; Android already restores it
2011
+ // (`changed("snapshots", name, if (ok) json else previous, null)`).
2012
+ NotificationCenter.default.post(name: changed, object: nil, userInfo: ["name": name])
2005
2013
  completion?(ok)
2006
2014
  }
2007
2015
  }