@craft-native/ios 0.0.244 → 0.0.246

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 CHANGED
@@ -298,6 +298,8 @@ function hybridConfigProblems(config) {
298
298
  else if (!usesNativeBundle(config))
299
299
  problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
300
300
  }
301
+ if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
302
+ problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
301
303
  return problems;
302
304
  }
303
305
  function installNativeBundle(source, output) {
package/dist/index.d.ts CHANGED
@@ -240,6 +240,15 @@ export interface CraftConfig {
240
240
  * `nativeScreens` or `renderer: "native"`, and the native bundle.
241
241
  */
242
242
  nativeApp?: string;
243
+ /**
244
+ * Where native screens run their JavaScript. `"main"` (the default) runs it
245
+ * on the main thread; `"background"` gives screens a serial thread of their
246
+ * own, so a screen's script, its timers and its renders never hold the
247
+ * main thread while it scrolls or animates. UIKit still runs on main: the
248
+ * shell hands each render to it, and a pushed screen waits for its first
249
+ * render (at most 0.3 s) so it never slides in empty.
250
+ */
251
+ nativeScriptThread?: 'main' | 'background';
243
252
  /**
244
253
  * The native tab bar, shown from the first frame, before the page loads.
245
254
  * The page's own `tabBar.set` replaces it later. Each `id` is the tab's
@@ -253,6 +262,14 @@ export interface CraftConfig {
253
262
  * `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
254
263
  */
255
264
  shareStorage?: Record<string, string>;
265
+ /**
266
+ * The secure-storage key the signed-in account's token lives under
267
+ * (default `auth.token`). `craft.session` follows it: a change of it, made
268
+ * by any native screen or by the page through a `shareStorage` key, is
269
+ * `SESSION_CHANGED { key, token, previous }` on every native screen and a
270
+ * `craftsession` event on the page.
271
+ */
272
+ sessionTokenKey?: string;
256
273
  /**
257
274
  * Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
258
275
  * and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
@@ -311,13 +328,27 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
311
328
  */
312
329
  export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
313
330
  /** What is wrong with a config's hybrid and tab settings, one sentence each. */
314
- export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
331
+ export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread'>): string[];
315
332
  /**
316
333
  * The builder installs a directory of route bundles (stx's `routesDir`) as
317
334
  * well as the one-file bundle, and the app runs each screen from its own
318
335
  * route's file. Stacks asks for route bundles only when this is true.
319
336
  */
320
337
  export declare const nativeRouteBundles = true;
338
+ /**
339
+ * The app evaluates stx's shared runtime (`__runtime.js`, stx's
340
+ * `sharedRuntime`) in each screen's context before the route's own file.
341
+ * Stacks asks stx for it only when this is true: a route file built so
342
+ * refuses to run without it.
343
+ */
344
+ export declare const nativeRouteRuntime = true;
345
+ /**
346
+ * The app gives a screen its messages a language at a time (stx's
347
+ * `messages`, `native-screens/__messages/`): read when the screen first
348
+ * looks inside them (`__stxNativeReadMessages`). Stacks leaves the languages
349
+ * out of the screens' code only when this is true.
350
+ */
351
+ export declare const nativeMessages = true;
321
352
  export interface CraftBackgroundRefresh {
322
353
  enabled: boolean;
323
354
  /** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
package/dist/index.js CHANGED
@@ -482,9 +482,13 @@ function hybridConfigProblems(config) {
482
482
  else if (!usesNativeBundle(config))
483
483
  problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
484
484
  }
485
+ if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
486
+ problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
485
487
  return problems;
486
488
  }
487
489
  var nativeRouteBundles = true;
490
+ var nativeRouteRuntime = true;
491
+ var nativeMessages = true;
488
492
  function installNativeBundle(source, output) {
489
493
  if (!existsSync(source))
490
494
  throw new Error(`Native bundle not found: ${source}`);
@@ -1568,7 +1572,9 @@ export {
1568
1572
  open,
1569
1573
  normalizeNativePath,
1570
1574
  nativeStartPath,
1575
+ nativeRouteRuntime,
1571
1576
  nativeRouteBundles,
1577
+ nativeMessages,
1572
1578
  matchNativePath,
1573
1579
  isHybrid,
1574
1580
  installRuntime,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.244",
3
+ "version": "0.0.246",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1619,9 +1619,17 @@ struct CraftConfig: Codable {
1619
1619
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1620
1620
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1621
1621
  var shareStorage: [String: String]? = nil
1622
+ /// The secure-storage key the signed-in account's token lives under
1623
+ /// (`auth.token` unless set): `craft.session` follows it, and a change of
1624
+ /// it is `SESSION_CHANGED` on every screen (CraftSession).
1625
+ var sessionTokenKey: String? = nil
1622
1626
  /// The bundle route of the app script: a screen-less script that runs for
1623
1627
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
1624
1628
  var nativeApp: String? = nil
1629
+ /// Where native screens run their JavaScript: `"main"` (the default) or
1630
+ /// `"background"`, a thread of their own, so a screen's script and its
1631
+ /// renders never hold the main thread (CraftNativeScriptThread).
1632
+ var nativeScriptThread: String? = nil
1625
1633
  /// Crash and error reporting to bughq (CraftCrashReporter.swift). Off
1626
1634
  /// unless the config names a project key.
1627
1635
  var crashReporting: CrashReporting? = nil
@@ -354,6 +354,7 @@ final class CraftHybrid: NSObject {
354
354
  guard !isConfigured else { return }
355
355
  isConfigured = true
356
356
  self.config = config
357
+ CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
357
358
  hasWeb = config.renderer != "native"
358
359
  siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
359
360
  ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
@@ -488,12 +489,16 @@ final class CraftHybrid: NSObject {
488
489
  func pageScript() -> String {
489
490
  let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
490
491
  let share = Array((config.shareStorage ?? [:]).keys).sorted()
492
+ // The page's key for the sign-in token, when it shares one.
493
+ let tokenKey = CraftSession.tokenKey(config)
494
+ let session = (config.shareStorage ?? [:]).filter { $0.value == tokenKey }.map(\.key).sorted().first
491
495
  func json(_ value: Any) -> String {
492
496
  (try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
493
497
  }
494
498
  return Self.pageScriptTemplate
495
499
  .replacingOccurrences(of: "__CRAFT_HYBRID_ROUTES__", with: json(routeList))
496
500
  .replacingOccurrences(of: "__CRAFT_HYBRID_SHARE__", with: json(share))
501
+ .replacingOccurrences(of: "__CRAFT_HYBRID_SESSION__", with: session.map { json([$0]) } ?? "[]")
497
502
  }
498
503
 
499
504
  // CRAFT_HYBRID_PAGE_SCRIPT_START
@@ -691,13 +696,46 @@ final class CraftHybrid: NSObject {
691
696
 
692
697
  // Web storage the config shares with native screens (`shareStorage`).
693
698
  var shared = {};
694
- function share(key) {
699
+ // The page's key for the sign-in token (`sessionTokenKey`), if shared.
700
+ var SESSION = (__CRAFT_HYBRID_SESSION__)[0] || null;
701
+ var sessionListeners = [];
702
+ function stored(key) {
695
703
  var value = null;
696
704
  try { value = window.sessionStorage.getItem(key); } catch (e) {}
697
705
  try { if (value === null) value = window.localStorage.getItem(key); } catch (e) {}
706
+ return value;
707
+ }
708
+ function sessionToken(value) { return typeof value === 'string' && value !== '' ? value : null; }
709
+ function share(key) {
710
+ var value = stored(key);
698
711
  if (shared[key] === value) return;
712
+ var known = Object.prototype.hasOwnProperty.call(shared, key);
713
+ var before = shared[key];
699
714
  shared[key] = value;
700
715
  post({ type: 'shareStorage', key: key, value: value });
716
+ // A sign-in, sign-out or another account, made here or on a native
717
+ // screen: `craftsession { key, token, previous }` and craft.session.
718
+ if (key === SESSION && known && sessionToken(before) !== sessionToken(value)) {
719
+ var change = { key: key, token: sessionToken(value), previous: sessionToken(before) };
720
+ sessionListeners.slice().forEach(function (callback) {
721
+ try { callback(change.token, change.previous); } catch (e) { console.error(e); }
722
+ });
723
+ try { window.dispatchEvent(new CustomEvent('craftsession', { detail: change })); } catch (e) {}
724
+ }
725
+ }
726
+ if (SESSION && window.craft && !window.craft.session) {
727
+ window.craft.session = {
728
+ key: SESSION,
729
+ token: function () { return sessionToken(stored(SESSION)); },
730
+ onChange: function (callback) {
731
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
732
+ sessionListeners.push(callback);
733
+ return function () {
734
+ var index = sessionListeners.indexOf(callback);
735
+ if (index >= 0) sessionListeners.splice(index, 1);
736
+ };
737
+ }
738
+ };
701
739
  }
702
740
  if (SHARE.length && window.Storage) {
703
741
  var proto = window.Storage.prototype;
@@ -808,7 +846,9 @@ final class CraftHybrid: NSObject {
808
846
  guard let nativeKey = config.shareStorage?[key] else { return }
809
847
  // The page's own write, or its echo of one a native screen made.
810
848
  pendingShared.removeValue(forKey: key)
811
- CraftHybridKeychain.set(nativeKey, value: value)
849
+ // The page's sign-in or sign-out moves the account for every
850
+ // screen; an echo writes what is already there and moves nothing.
851
+ CraftSession.observe(key: nativeKey, config: config) { CraftHybridKeychain.set(nativeKey, value: value) }
812
852
  case .navigated(let path, let direction, let depth, let byHost):
813
853
  pagePath = path
814
854
  pageDepth = depth
@@ -1065,6 +1105,21 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1065
1105
  /// this separate from the live frame: deriving it after the drag begins
1066
1106
  /// makes the target jump when the translation crosses the midpoint.
1067
1107
  private var tabSwipeFrom: CGFloat = 0
1108
+ /// A native screen waiting for its first content before it is pushed.
1109
+ private var pendingPush: String?
1110
+ /// Where the current screen already was when this gesture took over: a
1111
+ /// swipe that caught the last one while it settled starts from there.
1112
+ private var tabSwipeBase: CGFloat = 0
1113
+ /// A screen still partly on screen when this gesture caught a settling
1114
+ /// swipe and went the other way: it keeps its distance from the current
1115
+ /// screen (`tabSwipeTrailingGap`) until it has left.
1116
+ private var tabSwipeTrailing: UINavigationController?
1117
+ private var tabSwipeTrailingGap: CGFloat = 0
1118
+ /// The end of the settle under way, run at once when a swipe catches it.
1119
+ private var settleNow: (() -> Void)?
1120
+ private var settleCompleting = false
1121
+ /// A caught settle's screens, where they were, for the gesture that caught it.
1122
+ private var caughtSettle: (other: String, otherX: CGFloat, currentX: CGFloat)?
1068
1123
 
1069
1124
  init(config: CraftConfig) {
1070
1125
  self.config = config
@@ -1121,6 +1176,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1121
1176
  }
1122
1177
 
1123
1178
  @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1179
+ // A finger that lands while the last swipe settles takes the screens
1180
+ // from where they are, as a paging scroll view does. This swipe used
1181
+ // to be ignored until the settle ended.
1182
+ if recognizer.state == .began, tabSwipeSettling { catchSettlingSwipe() }
1124
1183
  guard !tabSwipeSettling else { return }
1125
1184
  let tabs = hybrid.tabIds
1126
1185
  guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
@@ -1138,6 +1197,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1138
1197
  updateInteractiveTabSwipe(translation: translation)
1139
1198
  case .ended, .cancelled, .failed:
1140
1199
  guard let target = activeTabSwipeTarget else {
1200
+ // Caught and let go without a direction (or toward the edge):
1201
+ // the caught screens finish where they were going.
1202
+ if caughtSettle != nil { settleCaughtScreens(); return }
1141
1203
  guard recognizer.state == .ended else { return }
1142
1204
  let delta = abs(translation) >= 32 ? translation : velocity
1143
1205
  guard abs(delta) >= 32 else { return }
@@ -1152,7 +1214,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1152
1214
  // jumping straight to their destination.
1153
1215
  tabSwipeSettling = true
1154
1216
  let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
1155
- let progress = translation * direction
1217
+ let progress = (translation + tabSwipeBase) * direction
1156
1218
  let speed = velocity * direction
1157
1219
  let complete = recognizer.state == .ended && speed > -700
1158
1220
  && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
@@ -1213,7 +1275,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1213
1275
  target.view.isHidden = false
1214
1276
  let width = view.bounds.width
1215
1277
  tabSwipeFrom = -direction * width
1216
- target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
1278
+ // Caught from a settle: the current screen is still where the last
1279
+ // swipe left it, and the other screen it showed either is this
1280
+ // target (the finger went back to it) or trails behind.
1281
+ tabSwipeBase = 0
1282
+ tabSwipeTrailing = nil
1283
+ if let caught = caughtSettle {
1284
+ caughtSettle = nil
1285
+ tabSwipeBase = caught.currentX
1286
+ if caught.other != id, let other = stacks[caught.other] {
1287
+ tabSwipeTrailing = other
1288
+ tabSwipeTrailingGap = caught.otherX - caught.currentX
1289
+ }
1290
+ }
1291
+ target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom + tabSwipeBase, dy: 0)
1217
1292
  view.bringSubviewToFront(target.view)
1218
1293
  CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
1219
1294
  lastNavigation = CACurrentMediaTime()
@@ -1226,13 +1301,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1226
1301
  let target = stacks[targetId] else { return }
1227
1302
  let width = max(1, view.bounds.width)
1228
1303
  // Reversing past the start stays at zero progress, rather than
1229
- // pulling both screens away and exposing the background.
1304
+ // pulling both screens away and exposing the background; past where
1305
+ // a caught swipe left the screens, when it left them further out.
1306
+ let raw = translation + tabSwipeBase
1230
1307
  let offset = tabSwipeFrom > 0
1231
- ? min(0, max(-width, translation))
1232
- : max(0, min(width, translation))
1308
+ ? min(max(0, tabSwipeBase), max(-width, raw))
1309
+ : max(min(0, tabSwipeBase), min(width, raw))
1233
1310
  target.view.frame.origin.x = tabSwipeFrom + offset
1234
1311
  current.view.frame.origin.x = offset
1235
- CraftChrome.shared.updateTabSwipe(progress: abs(offset) / width)
1312
+ tabSwipeTrailing?.view.frame.origin.x = offset + tabSwipeTrailingGap
1313
+ CraftChrome.shared.updateTabSwipe(progress: max(0, tabSwipeFrom > 0 ? -offset : offset) / width)
1236
1314
  }
1237
1315
 
1238
1316
  private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
@@ -1247,13 +1325,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1247
1325
  CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
1248
1326
  let remaining = end - target.view.frame.minX
1249
1327
  let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
1328
+ let trailing = tabSwipeTrailing
1329
+ let gap = tabSwipeTrailingGap
1250
1330
  let move = {
1251
1331
  target.view.frame.origin.x = end
1252
1332
  current.view.frame.origin.x = completed ? -direction * width : 0
1333
+ trailing?.view.frame.origin.x = current.view.frame.origin.x + gap
1253
1334
  }
1254
- let settled: (Bool) -> Void = { _ in
1255
- guard self.tabSwipeGeneration == generation,
1256
- self.activeTabSwipeTarget == targetId else { return }
1335
+ let finish = {
1336
+ if let trailing {
1337
+ trailing.view.isHidden = true
1338
+ trailing.view.frame = self.view.bounds
1339
+ }
1340
+ self.tabSwipeTrailing = nil
1341
+ self.tabSwipeBase = 0
1257
1342
  if completed {
1258
1343
  current.view.isHidden = true
1259
1344
  current.view.frame = self.view.bounds
@@ -1270,15 +1355,81 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1270
1355
  }
1271
1356
  completion()
1272
1357
  }
1358
+ settleNow = finish
1359
+ settleCompleting = completed
1360
+ let settled: (Bool) -> Void = { _ in
1361
+ guard self.tabSwipeGeneration == generation,
1362
+ self.activeTabSwipeTarget == targetId else { return }
1363
+ self.settleNow = nil
1364
+ finish()
1365
+ }
1273
1366
  UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1274
1367
  initialSpringVelocity: initialVelocity,
1275
1368
  options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
1276
1369
  }
1277
1370
 
1371
+ /// A finger landed while the last swipe settled. Its end happens now,
1372
+ /// commit or cancel, chrome and events included, and the two screens go
1373
+ /// back exactly where they were on screen (no frame is drawn between),
1374
+ /// for this gesture to carry on from.
1375
+ private func catchSettlingSwipe() {
1376
+ guard let finish = settleNow, let targetId = activeTabSwipeTarget,
1377
+ let current = visibleStack, let target = stacks[targetId] else { return }
1378
+ let outgoing = currentTab
1379
+ let currentX = current.view.layer.presentation()?.frame.minX ?? current.view.frame.minX
1380
+ let targetX = target.view.layer.presentation()?.frame.minX ?? target.view.frame.minX
1381
+ tabSwipeGeneration &+= 1
1382
+ for nav in [current, target] + (tabSwipeTrailing.map { [$0] } ?? []) { nav.view.layer.removeAllAnimations() }
1383
+ settleNow = nil
1384
+ let completing = settleCompleting
1385
+ CraftFramePacing.shared.end("tab swipe")
1386
+ finish()
1387
+ let (stay, stayX, other, otherId, otherX) = completing
1388
+ ? (target, targetX, current, outgoing, currentX)
1389
+ : (current, currentX, target, targetId, targetX)
1390
+ guard abs(stayX) > 0.5 else { return }
1391
+ stay.view.frame = view.bounds.offsetBy(dx: stayX, dy: 0)
1392
+ other.view.isHidden = false
1393
+ other.view.frame = view.bounds.offsetBy(dx: otherX, dy: 0)
1394
+ caughtSettle = (otherId, otherX, stayX)
1395
+ }
1396
+
1397
+ /// A caught settle that no new swipe took on: its screens finish where
1398
+ /// they were going.
1399
+ private func settleCaughtScreens(animated: Bool = true) {
1400
+ guard let caught = caughtSettle else { return }
1401
+ caughtSettle = nil
1402
+ guard let current = visibleStack else { return }
1403
+ let other = stacks[caught.other]
1404
+ let gap = caught.otherX - caught.currentX
1405
+ let generation = tabSwipeGeneration
1406
+ let done = {
1407
+ guard self.tabSwipeGeneration == generation else { return }
1408
+ current.view.frame = self.view.bounds
1409
+ other?.view.frame = self.view.bounds
1410
+ if other !== current { other?.view.isHidden = true }
1411
+ }
1412
+ guard animated, CraftChrome.motion(.easeOut(duration: 0.18)) != nil else { done(); return }
1413
+ UIView.animate(withDuration: 0.18, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
1414
+ options: [.beginFromCurrentState, .allowUserInteraction], animations: {
1415
+ current.view.frame.origin.x = 0
1416
+ other?.view.frame.origin.x = gap
1417
+ }, completion: { _ in done() })
1418
+ }
1419
+
1278
1420
  /// A newer navigation action wins immediately, including a tap back on
1279
1421
  /// the outgoing tab. Invalidate the old animation before removing it:
1280
1422
  /// UIKit may deliver its completion while the replacement is starting.
1281
1423
  private func interruptTabSwipe(restoringSelection: Bool) {
1424
+ if caughtSettle != nil { settleCaughtScreens(animated: false) }
1425
+ if let trailing = tabSwipeTrailing {
1426
+ trailing.view.layer.removeAllAnimations()
1427
+ trailing.view.frame = view.bounds
1428
+ trailing.view.isHidden = trailing !== visibleStack
1429
+ tabSwipeTrailing = nil
1430
+ }
1431
+ tabSwipeBase = 0
1432
+ settleNow = nil
1282
1433
  guard let targetId = activeTabSwipeTarget else { return }
1283
1434
  tabSwipeGeneration &+= 1
1284
1435
  activeTabSwipeTarget = nil
@@ -1529,6 +1680,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1529
1680
  }
1530
1681
  (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1531
1682
  (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1683
+ // A script on its own thread draws a moment later: its first
1684
+ // content is drawn ahead too, once it is there.
1685
+ if CraftNativeScriptThread.isBackground, let screen = nav.topViewController as? CraftNativeScreenController {
1686
+ screen.whenFirstRendered(within: 1) { [weak nav] in
1687
+ guard let nav, nav.view.isHidden else { return }
1688
+ nav.view.layoutIfNeeded()
1689
+ Self.drawContents(of: nav.view.layer)
1690
+ }
1691
+ }
1532
1692
  self.prepareAdjacentNativeTabs()
1533
1693
  }
1534
1694
  preparation = timer
@@ -1640,10 +1800,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1640
1800
  return
1641
1801
  }
1642
1802
  let screen = entry(for: raw, isTabRoot: false, depth: nil)
1643
- if replace, !nav.viewControllers.isEmpty {
1644
- nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1645
- } else {
1646
- nav.pushViewController(screen, animated: true)
1803
+ let show = { [weak nav] in
1804
+ guard let nav else { return }
1805
+ if replace, !nav.viewControllers.isEmpty {
1806
+ nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
1807
+ } else {
1808
+ nav.pushViewController(screen, animated: true)
1809
+ }
1810
+ }
1811
+ guard let native = screen as? CraftNativeScreenController, CraftNativeScriptThread.isBackground else { show(); return }
1812
+ // Its script runs on its own thread: it slides in with its content,
1813
+ // and a second tap meanwhile does not push it twice.
1814
+ guard pendingPush != match.path else { return }
1815
+ pendingPush = match.path
1816
+ native.whenFirstRendered(within: 0.3) { [weak self, weak nav] in
1817
+ guard let self, self.pendingPush == match.path else { return }
1818
+ self.pendingPush = nil
1819
+ // Not into a tab the person has left meanwhile.
1820
+ guard let nav, nav === self.visibleStack else { return }
1821
+ show()
1647
1822
  }
1648
1823
  }
1649
1824
 
@@ -1906,7 +2081,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1906
2081
 
1907
2082
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1908
2083
  if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
1909
- guard activeTabSwipeTarget == nil, !tabSwipeSettling,
2084
+ // A swipe may begin while the last one settles: it catches it.
2085
+ guard activeTabSwipeTarget == nil || tabSwipeSettling,
1910
2086
  hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
1911
2087
  guard CraftChrome.shared.tabSwipeEnabled else { return false }
1912
2088
  // Only between the tabs' own first screens, as the bar shows them:
@@ -205,11 +205,21 @@ enum CraftNativeActions {
205
205
  complete(requestToken, failure("CAPABILITY_DISABLED", "Secure storage is disabled"), completion)
206
206
  return
207
207
  }
208
- sync = secureStorage(method: method, args: args)
209
- // A key the web page shares (`shareStorage`): the page gets it too,
210
- // so a sign-in on a native screen signs the page in.
211
- if case .success = sync, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
212
- CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
208
+ let write = { () -> Result<Any, CraftNativeActionError> in
209
+ let result = secureStorage(method: method, args: args)
210
+ // A key the web page shares (`shareStorage`): the page gets it too,
211
+ // so a sign-in on a native screen signs the page in.
212
+ if case .success = result, ["set", "remove", "delete"].contains(method), let key = args.first as? String {
213
+ CraftHybrid.shared.nativeWroteShared(nativeKey: key, value: method == "set" ? args.count > 1 ? args[1] as? String : nil : nil)
214
+ }
215
+ return result
216
+ }
217
+ // A write that moves the sign-in token is the account's change:
218
+ // every screen hears it (CraftSession). Reads skip the check.
219
+ if ["set", "remove", "delete", "clear"].contains(method) {
220
+ sync = CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write)
221
+ } else {
222
+ sync = write()
213
223
  }
214
224
  case ("Biometrics", "isAvailable"):
215
225
  sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
@@ -1423,6 +1433,54 @@ extension CraftNativeActions {
1423
1433
  }
1424
1434
  }
1425
1435
 
1436
+ /// The signed-in account: the token kept in secure storage under
1437
+ /// `sessionTokenKey` (default `auth.token`). Whoever moves it, a native
1438
+ /// screen's `craft.secureStorage`, the page's own sign-in through a
1439
+ /// `shareStorage` key, or a sign-out's clear, posts `changed` once with
1440
+ /// `key`, `token` and `previous` (NSNull when signed out), after the Keychain
1441
+ /// holds the new value. Each native screen hands it to its script as
1442
+ /// `SESSION_CHANGED` and to `craft.session.onChange` (CraftNativeScreen).
1443
+ enum CraftSession {
1444
+ static let changed = Notification.Name("craftSessionChanged")
1445
+ static let defaultKey = "auth.token"
1446
+ /// One write and its before and after at a time, so two quick writes
1447
+ /// from different queues are two changes, never one reported twice.
1448
+ private static let lock = NSLock()
1449
+
1450
+ static func tokenKey(_ config: CraftConfig) -> String {
1451
+ guard let key = config.sessionTokenKey, !key.isEmpty else { return defaultKey }
1452
+ return key
1453
+ }
1454
+
1455
+ /// The token now, as `craft.secureStorage.getSync` reads it; nil when
1456
+ /// signed out (an empty token is no account either).
1457
+ static func token(_ config: CraftConfig) -> String? {
1458
+ guard config.enableSecureStorage,
1459
+ let value = CraftNativeActions.webSecureValue(forKey: tokenKey(config)), !value.isEmpty else { return nil }
1460
+ return value
1461
+ }
1462
+
1463
+ /// Run `write`, and post `changed` when it moved the token. `key` is the
1464
+ /// key written; nil for a clear, which can remove every key.
1465
+ static func observe<T>(key: String?, config: CraftConfig, _ write: () -> T) -> T {
1466
+ let name = tokenKey(config)
1467
+ guard key == nil || key == name else { return write() }
1468
+ lock.lock()
1469
+ let before = token(config)
1470
+ let result = write()
1471
+ let after = token(config)
1472
+ lock.unlock()
1473
+ if before != after { post(key: name, token: after, previous: before) }
1474
+ return result
1475
+ }
1476
+
1477
+ static func post(key: String, token: String?, previous: String?) {
1478
+ let info: [String: Any] = ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()]
1479
+ let send = { NotificationCenter.default.post(name: changed, object: nil, userInfo: info) }
1480
+ if Thread.isMainThread { send() } else { DispatchQueue.main.async(execute: send) }
1481
+ }
1482
+ }
1483
+
1426
1484
  /// Last-known data for a screen's first frame: one JSON file per name in
1427
1485
  /// `Application Support/craft-snapshots/<name>.json`. A web page writes them
1428
1486
  /// (`snapshots.set` in the mobile API) and a native screen reads them
@@ -52,11 +52,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
52
52
  var onRecycle: ((String) -> Void)?
53
53
  override func prepareForReuse() {
54
54
  super.prepareForReuse()
55
- releaseHostedView()
55
+ recycle()
56
+ }
57
+
58
+ /// The row this cell showed is gone; its views stay in the cell, and
59
+ /// the next row it shows reuses them when it has the same shape.
60
+ func recycle() {
61
+ if let identity = representedIdentity { onRecycle?(identity) }
62
+ representedIdentity = nil
63
+ onRecycle = nil
56
64
  }
57
65
 
58
66
  func host(_ view: UIView, identity: String) {
59
- if representedIdentity != identity { releaseHostedView() }
67
+ if let shown = representedIdentity, shown != identity { recycle() }
60
68
  representedIdentity = identity
61
69
  guard hostedView !== view else { return }
62
70
  hostedView?.removeFromSuperview()
@@ -72,11 +80,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
72
80
  }
73
81
 
74
82
  func releaseHostedView() {
75
- if let identity = representedIdentity { onRecycle?(identity) }
83
+ recycle()
76
84
  hostedView?.removeFromSuperview()
77
85
  hostedView = nil
78
- representedIdentity = nil
79
- onRecycle = nil
80
86
  }
81
87
 
82
88
  override func preferredLayoutAttributesFitting(
@@ -354,7 +360,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
354
360
 
355
361
  @objc private func refreshTriggered() {
356
362
  refreshHandler?()
357
- DispatchQueue.main.async { [weak self] in
363
+ // Once the script has answered the pull, on its own thread or not.
364
+ CraftNativeScriptThread.afterScript { [weak self] in
358
365
  guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
359
366
  self.nativeRefreshControl.endRefreshing()
360
367
  }