@craft-native/ios 0.0.243 → 0.0.245

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/index.d.ts CHANGED
@@ -253,6 +253,14 @@ export interface CraftConfig {
253
253
  * `craft.secureStorage.getSync('auth.token')`. Turns on secure storage.
254
254
  */
255
255
  shareStorage?: Record<string, string>;
256
+ /**
257
+ * The secure-storage key the signed-in account's token lives under
258
+ * (default `auth.token`). `craft.session` follows it: a change of it, made
259
+ * by any native screen or by the page through a `shareStorage` key, is
260
+ * `SESSION_CHANGED { key, token, previous }` on every native screen and a
261
+ * `craftsession` event on the page.
262
+ */
263
+ sessionTokenKey?: string;
256
264
  /**
257
265
  * Crash and error reporting to bughq (CraftCrashReporter.swift): crashes
258
266
  * and hangs (signal handlers, NSException, MetricKit), uncaught JavaScript
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.243",
3
+ "version": "0.0.245",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -670,6 +670,7 @@ final class CraftChrome: ObservableObject {
670
670
 
671
671
  @Published var tabs: [Tab] = []
672
672
  @Published var tabAccessory: Tab?
673
+ private weak var tabAccessoryOwner: AnyObject?
673
674
  @Published var selected: String?
674
675
  let tabSwipeProgress = CraftTabSwipeProgress()
675
676
  private(set) var tabSwipePosition: CGFloat? {
@@ -771,7 +772,7 @@ final class CraftChrome: ObservableObject {
771
772
  tabs[index] = next
772
773
  }
773
774
  /// A destination beside the tab pill, outside the tab stacks and swipe order.
774
- func updateTabAccessory(_ item: [String: Any]?) {
775
+ func updateTabAccessory(_ item: [String: Any]?, owner: AnyObject? = nil) {
775
776
  let next: Tab?
776
777
  if let item, let path = item["path"] as? String,
777
778
  path.hasPrefix("/"), !path.hasPrefix("//"),
@@ -782,10 +783,17 @@ final class CraftChrome: ObservableObject {
782
783
  } else {
783
784
  next = nil
784
785
  }
786
+ tabAccessoryOwner = owner
785
787
  guard tabAccessory != next else { return }
786
788
  tabAccessory = next
787
789
  }
788
790
 
791
+ /// A retained or prepared screen cannot clear another screen's chrome.
792
+ func clearTabAccessory(ownedBy owner: AnyObject) {
793
+ guard tabAccessoryOwner === owner else { return }
794
+ updateTabAccessory(nil)
795
+ }
796
+
789
797
  func setHiddenByScreen(_ hidden: Bool) {
790
798
  guard hiddenByScreen != hidden else { return }
791
799
  hiddenByScreen = hidden
@@ -1611,6 +1619,10 @@ struct CraftConfig: Codable {
1611
1619
  /// Hybrid: web-storage keys the page's own code writes, mirrored into the
1612
1620
  /// Keychain under another name for native screens (`{"auth_token": "auth.token"}`).
1613
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
1614
1626
  /// The bundle route of the app script: a screen-less script that runs for
1615
1627
  /// the app's whole life and hears the app's events (CraftHybrid.swift).
1616
1628
  var nativeApp: String? = nil
@@ -488,12 +488,16 @@ final class CraftHybrid: NSObject {
488
488
  func pageScript() -> String {
489
489
  let routeList = (config.nativeScreens ?? [:]).map { ["pattern": $0.key, "screen": $0.value] }
490
490
  let share = Array((config.shareStorage ?? [:]).keys).sorted()
491
+ // The page's key for the sign-in token, when it shares one.
492
+ let tokenKey = CraftSession.tokenKey(config)
493
+ let session = (config.shareStorage ?? [:]).filter { $0.value == tokenKey }.map(\.key).sorted().first
491
494
  func json(_ value: Any) -> String {
492
495
  (try? JSONSerialization.data(withJSONObject: value)).flatMap { String(data: $0, encoding: .utf8) } ?? "[]"
493
496
  }
494
497
  return Self.pageScriptTemplate
495
498
  .replacingOccurrences(of: "__CRAFT_HYBRID_ROUTES__", with: json(routeList))
496
499
  .replacingOccurrences(of: "__CRAFT_HYBRID_SHARE__", with: json(share))
500
+ .replacingOccurrences(of: "__CRAFT_HYBRID_SESSION__", with: session.map { json([$0]) } ?? "[]")
497
501
  }
498
502
 
499
503
  // CRAFT_HYBRID_PAGE_SCRIPT_START
@@ -691,13 +695,46 @@ final class CraftHybrid: NSObject {
691
695
 
692
696
  // Web storage the config shares with native screens (`shareStorage`).
693
697
  var shared = {};
694
- function share(key) {
698
+ // The page's key for the sign-in token (`sessionTokenKey`), if shared.
699
+ var SESSION = (__CRAFT_HYBRID_SESSION__)[0] || null;
700
+ var sessionListeners = [];
701
+ function stored(key) {
695
702
  var value = null;
696
703
  try { value = window.sessionStorage.getItem(key); } catch (e) {}
697
704
  try { if (value === null) value = window.localStorage.getItem(key); } catch (e) {}
705
+ return value;
706
+ }
707
+ function sessionToken(value) { return typeof value === 'string' && value !== '' ? value : null; }
708
+ function share(key) {
709
+ var value = stored(key);
698
710
  if (shared[key] === value) return;
711
+ var known = Object.prototype.hasOwnProperty.call(shared, key);
712
+ var before = shared[key];
699
713
  shared[key] = value;
700
714
  post({ type: 'shareStorage', key: key, value: value });
715
+ // A sign-in, sign-out or another account, made here or on a native
716
+ // screen: `craftsession { key, token, previous }` and craft.session.
717
+ if (key === SESSION && known && sessionToken(before) !== sessionToken(value)) {
718
+ var change = { key: key, token: sessionToken(value), previous: sessionToken(before) };
719
+ sessionListeners.slice().forEach(function (callback) {
720
+ try { callback(change.token, change.previous); } catch (e) { console.error(e); }
721
+ });
722
+ try { window.dispatchEvent(new CustomEvent('craftsession', { detail: change })); } catch (e) {}
723
+ }
724
+ }
725
+ if (SESSION && window.craft && !window.craft.session) {
726
+ window.craft.session = {
727
+ key: SESSION,
728
+ token: function () { return sessionToken(stored(SESSION)); },
729
+ onChange: function (callback) {
730
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
731
+ sessionListeners.push(callback);
732
+ return function () {
733
+ var index = sessionListeners.indexOf(callback);
734
+ if (index >= 0) sessionListeners.splice(index, 1);
735
+ };
736
+ }
737
+ };
701
738
  }
702
739
  if (SHARE.length && window.Storage) {
703
740
  var proto = window.Storage.prototype;
@@ -808,7 +845,9 @@ final class CraftHybrid: NSObject {
808
845
  guard let nativeKey = config.shareStorage?[key] else { return }
809
846
  // The page's own write, or its echo of one a native screen made.
810
847
  pendingShared.removeValue(forKey: key)
811
- CraftHybridKeychain.set(nativeKey, value: value)
848
+ // The page's sign-in or sign-out moves the account for every
849
+ // screen; an echo writes what is already there and moves nothing.
850
+ CraftSession.observe(key: nativeKey, config: config) { CraftHybridKeychain.set(nativeKey, value: value) }
812
851
  case .navigated(let path, let direction, let depth, let byHost):
813
852
  pagePath = path
814
853
  pageDepth = depth
@@ -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
@@ -29,6 +29,7 @@ struct CraftNativeMutationResult {
29
29
  final class CraftNativeMutationDocument {
30
30
  private struct Node {
31
31
  let id: String
32
+ let key: String?
32
33
  var type: String
33
34
  var props: [String: Any]
34
35
  var style: [String: Any]
@@ -177,6 +178,7 @@ final class CraftNativeMutationDocument {
177
178
  }
178
179
  nodes[id] = Node(
179
180
  id: id,
181
+ key: value["key"] as? String,
180
182
  type: type,
181
183
  props: dictionary(value["props"]),
182
184
  style: dictionary(value["style"]),
@@ -330,6 +332,7 @@ final class CraftNativeMutationDocument {
330
332
  "style": node.style,
331
333
  "events": node.events,
332
334
  ]
335
+ if let key = node.key { value["key"] = key }
333
336
  let text: [Any] = node.text
334
337
  let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
335
338
  value["children"] = text + children
@@ -350,6 +353,7 @@ final class CraftNativeMutationDocument {
350
353
  let children = value["children"] as? [Any] ?? []
351
354
  nodes[id] = Node(
352
355
  id: id,
356
+ key: value["key"] as? String,
353
357
  type: value["type"] as? String ?? "View",
354
358
  props: props,
355
359
  style: dictionary(value["style"]),
@@ -1435,21 +1435,47 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1435
1435
 
1436
1436
  private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1437
1437
  if let bearing = markerBearings[id] {
1438
- let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
1439
- .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1440
- // MapKit otherwise tints this as a monochrome template and loses
1441
- // the contrasting route arrow when displayed over map content.
1442
- view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
1443
- .withRenderingMode(.alwaysOriginal)
1438
+ let color = (markerPinColors[id] ?? .systemGreen).resolvedColor(with: traitCollection)
1439
+ view.tintColor = color
1440
+ view.image = Self.markerImage(color: color, direction: true)
1444
1441
  view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1445
1442
  } else {
1446
- view.tintColor = markerPinColors[id] ?? .systemRed
1447
- view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1448
- .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
1443
+ let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
1444
+ view.tintColor = color
1445
+ view.image = Self.markerImage(color: color, direction: false)
1449
1446
  view.transform = .identity
1450
1447
  }
1451
1448
  }
1452
1449
 
1450
+ private static let markerImages: NSCache<NSString, UIImage> = {
1451
+ let cache = NSCache<NSString, UIImage>()
1452
+ cache.countLimit = 64
1453
+ return cache
1454
+ }()
1455
+
1456
+ private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
1457
+ let key = "\(direction ? "direction" : "pin"):\(color.description)" as NSString
1458
+ if let cached = markerImages.object(forKey: key) { return cached }
1459
+ let size = direction ? CGSize(width: 20, height: 24) : CGSize(width: 28, height: 28)
1460
+ // Explicit native drawing preserves the light backing independently
1461
+ // of MapKit's tint and SF Symbols' multicolor rendering preferences.
1462
+ let image = UIGraphicsImageRenderer(size: size).image { _ in
1463
+ let border = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1),
1464
+ cornerRadius: (size.width - 2) / 2)
1465
+ UIColor.white.setFill()
1466
+ border.fill()
1467
+ color.setStroke()
1468
+ border.lineWidth = 1.5
1469
+ border.stroke()
1470
+ let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
1471
+ withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
1472
+ .withTintColor(color, renderingMode: .alwaysOriginal)
1473
+ symbol?.draw(in: direction ? CGRect(x: 5, y: 6, width: 10, height: 12) : CGRect(x: 7, y: 5, width: 14, height: 18))
1474
+ }.withRenderingMode(.alwaysOriginal)
1475
+ markerImages.setObject(image, forKey: key)
1476
+ return image
1477
+ }
1478
+
1453
1479
  override func layoutSubviews() {
1454
1480
  super.layoutSubviews()
1455
1481
  if tileTemplate != nil {
@@ -4621,10 +4647,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4621
4647
  super.viewWillDisappear(animated)
4622
4648
  screenVisibilityChanged(false)
4623
4649
  stopCelebrations(in: view)
4624
- // The accessory belongs to the visible native screen. Clear the
4625
- // shared shell state before a hybrid/web route appears, otherwise a
4626
- // destination from the outgoing screen remains tappable on top of it.
4627
- CraftChrome.shared.updateTabAccessory(nil)
4650
+ // Release only this screen's accessory. A retained or prepared screen
4651
+ // can disappear after the destination has already published its own.
4652
+ CraftChrome.shared.clearTabAccessory(ownedBy: self)
4628
4653
  // Popped (or its stack dismissed) while the bar was away: it comes
4629
4654
  // back for whatever shows next, a web page included.
4630
4655
  if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -4669,7 +4694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4669
4694
  private func restoreShellChrome() {
4670
4695
  guard !isAppScript else { return }
4671
4696
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
4672
- CraftChrome.shared.updateTabAccessory(tabAccessory)
4697
+ CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
4673
4698
  }
4674
4699
 
4675
4700
  /// The screen came on screen or went off it: a screen pushed over it was
@@ -4830,6 +4855,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4830
4855
  let appURL = Self.jsonString(config.devServerURL ?? "")
4831
4856
  let appVersion = Self.jsonString(info?["CFBundleShortVersionString"] as? String ?? "")
4832
4857
  let appBuild = Self.jsonString(info?["CFBundleVersion"] as? String ?? "")
4858
+ let sessionTokenKey = Self.jsonString(CraftSession.tokenKey(config))
4833
4859
  jsContext.evaluateScript("""
4834
4860
  // JavaScriptCore has no DOM window, but browser-safe SDK bridges
4835
4861
  // address the host through `window.craft`. Keep native screens
@@ -4848,6 +4874,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4848
4874
  appURL: \(appURL),
4849
4875
  appVersion: \(appVersion),
4850
4876
  appBuild: \(appBuild),
4877
+ sessionTokenKey: \(sessionTokenKey),
4851
4878
  postMessage: function(message) { craftNativePostMessage(message); },
4852
4879
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
4853
4880
  };
@@ -5249,6 +5276,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5249
5276
  return value === undefined ? null : value;
5250
5277
  }
5251
5278
  });
5279
+ // The signed-in account (CraftSession): the token under
5280
+ // `sessionTokenKey`, and each change of it, which the host
5281
+ // reports through `__craftSessionChanged` after the Keychain
5282
+ // holds it, beside the `SESSION_CHANGED` bridge message.
5283
+ var sessionListeners = [];
5284
+ var sessionKey = globalThis.__stxNativeBridge && __stxNativeBridge.sessionTokenKey || 'auth.token';
5285
+ function sessionToken() {
5286
+ var value = craftNativeSecureGet(String(sessionKey));
5287
+ return typeof value === 'string' && value !== '' ? value : null;
5288
+ }
5289
+ globalThis.__craftSessionChanged = function(token, previous) {
5290
+ sessionListeners.slice().forEach(function(callback) {
5291
+ try { callback(token, previous); } catch (error) { console.error('[session] ' + (error && error.stack || error)); }
5292
+ });
5293
+ };
5294
+ keep('session', {
5295
+ key: sessionKey,
5296
+ token: sessionToken,
5297
+ onChange: function(callback) {
5298
+ if (typeof callback !== 'function') throw new TypeError('craft.session.onChange needs a function');
5299
+ sessionListeners.push(callback);
5300
+ return function() {
5301
+ var index = sessionListeners.indexOf(callback);
5302
+ if (index >= 0) sessionListeners.splice(index, 1);
5303
+ };
5304
+ }
5305
+ });
5252
5306
  keep('navigation', {
5253
5307
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
5254
5308
  });
@@ -5340,7 +5394,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5340
5394
  if options.keys.contains("tabAccessory") {
5341
5395
  tabAccessory = options["tabAccessory"] as? [String: Any]
5342
5396
  if navigationController?.topViewController === self, view.window != nil {
5343
- CraftChrome.shared.updateTabAccessory(tabAccessory)
5397
+ CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
5344
5398
  }
5345
5399
  }
5346
5400
  if options.keys.contains("title") {
@@ -5714,6 +5768,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5714
5768
  guard let name = notification.userInfo?["name"] as? String else { return }
5715
5769
  self?.send(type: "SNAPSHOT_CHANGED", payload: ["name": name])
5716
5770
  })
5771
+ if config.enableSecureStorage {
5772
+ // A sign-in, sign-out or another account, made on any screen or
5773
+ // the page (CraftSession): `SESSION_CHANGED { key, token,
5774
+ // previous }` for the runtime, then craft.session's listeners.
5775
+ lifecycleObservers.append(NotificationCenter.default.addObserver(
5776
+ forName: CraftSession.changed,
5777
+ object: nil,
5778
+ queue: .main
5779
+ ) { [weak self] notification in
5780
+ guard let self, let info = notification.userInfo, let key = info["key"] as? String else { return }
5781
+ let token = info["token"] as? String
5782
+ let previous = info["previous"] as? String
5783
+ self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
5784
+ self.jsContext.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()])
5785
+ })
5786
+ }
5717
5787
  if config.enablePushNotifications {
5718
5788
  // A new device token (iOS hands one out after a reinstall or a
5719
5789
  // restore): every screen's `craft.push.onToken` hears it.
@@ -6865,7 +6935,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6865
6935
 
6866
6936
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
6867
6937
  if let authored = authoredKey(node, props: props) { return authored }
6868
- return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
6938
+ // Repeated test selectors are valid for list rows. Retain each row by
6939
+ // its unique protocol ID before considering legacy selector identity.
6940
+ return [node["id"], props["testID"]]
6941
+ .compactMap { $0 as? String }
6942
+ .first { !$0.isEmpty }
6869
6943
  }
6870
6944
 
6871
6945
  /// The key a node's author gave it. A wrapper of one child (a
@@ -6877,7 +6951,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6877
6951
  private func authoredKey(_ node: [String: Any], props: [String: Any]) -> String? {
6878
6952
  // The current @stacksjs/stx native compiler emits key in props; accept the IR
6879
6953
  // field too so keyed children keep working when the compiler adopts it.
6880
- let direct = [node["key"], props["key"], props["testID"]]
6954
+ let direct = [node["key"], props["key"]]
6881
6955
  .compactMap { $0 as? String }
6882
6956
  .first { !$0.isEmpty }
6883
6957
  if let direct { return direct }
@@ -448,7 +448,11 @@ final class DriverTests: XCTestCase {
448
448
  var found = element(containing: arg)
449
449
  var swipes = 0
450
450
  while (found == nil || !(found!.isHittable)) && swipes < 8 {
451
- app.swipeUp()
451
+ if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
452
+ app.swipeDown()
453
+ } else {
454
+ app.swipeUp()
455
+ }
452
456
  swipes += 1
453
457
  found = element(containing: arg)
454
458
  }