@craft-native/ios 0.0.497 → 0.0.499

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.497",
3
+ "version": "0.0.499",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -822,6 +822,9 @@ final class CraftChrome: ObservableObject {
822
822
  /// A shared material capsule; nil keeps separate floating buttons.
823
823
  var accessoryLayout: String? = nil
824
824
  var companions: [Tab] = []
825
+ /// A floating action that is on: a mode it switched (tracking in
826
+ /// Maps), drawn in the tint whatever screen is showing.
827
+ var active = false
825
828
  /// A tap opens these destinations in a compact menu instead of navigating.
826
829
  var menu: [TabMenuItem] = []
827
830
  }
@@ -846,6 +849,8 @@ final class CraftChrome: ObservableObject {
846
849
  guard let path = visiblePath else { return false }
847
850
  return path == accessory.id || path.hasPrefix(accessory.id + "/")
848
851
  }
852
+ /// Drawn in the tint: its destination is showing, or the app says it is on.
853
+ func accessoryActive(_ accessory: Tab) -> Bool { accessory.active || accessorySelected(accessory) }
849
854
  var highlightedTab: String? { accessorySelected ? nil : selected }
850
855
  let tabSwipeProgress = CraftTabSwipeProgress()
851
856
  private(set) var tabSwipePosition: CGFloat? {
@@ -977,13 +982,15 @@ final class CraftChrome: ObservableObject {
977
982
  let companionTitle = action["title"] as? String else { return nil }
978
983
  return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
979
984
  accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
985
+ active: action["selected"] as? Bool ?? false,
980
986
  menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
981
987
  guard let destination = choice["path"] as? String,
982
988
  destination.hasPrefix("/"), !destination.hasPrefix("//"),
983
989
  let label = choice["title"] as? String, !label.isEmpty else { return nil }
984
990
  return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
985
991
  })
986
- })
992
+ },
993
+ active: item["selected"] as? Bool ?? false)
987
994
  } else {
988
995
  next = nil
989
996
  }
@@ -1483,12 +1483,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1483
1483
  let button = UIButton(type: .system)
1484
1484
  var configuration = UIButton.Configuration.plain()
1485
1485
  configuration.image = UIImage(systemName: action.symbol)
1486
- configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1487
1486
  button.configuration = configuration
1488
1487
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1489
1488
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
1490
1489
  button.accessibilityValue = action.badge
1491
- button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1492
1490
  if action.menu.isEmpty {
1493
1491
  button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1494
1492
  } else {
@@ -1512,9 +1510,6 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1512
1510
  companionButtons.append(button)
1513
1511
  }
1514
1512
  }
1515
- for (index, action) in companionModels.enumerated() {
1516
- companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1517
- }
1518
1513
  let grouped = model?.accessoryLayout != nil
1519
1514
  // A group is one control, as the map controls in Maps are: its
1520
1515
  // actions live inside the shared surface, so the glass lights the
@@ -1522,16 +1517,12 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1522
1517
  // Separate actions stay in the shell, each on its own surface.
1523
1518
  let actionHost: UIView = grouped ? accessoryGlass.contentView : view
1524
1519
  for glass in companionGlasses { glass.isHidden = grouped }
1525
- for button in companionButtons {
1520
+ for (index, button) in companionButtons.enumerated() {
1526
1521
  if button.superview !== actionHost { actionHost.addSubview(button) }
1527
- button.tintColor = grouped ? .label : nil
1528
- button.configuration?.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1522
+ styleAccessory(button, for: companionModels[index], grouped: grouped)
1529
1523
  }
1530
- accessoryButton.tintColor = grouped ? .label : nil
1531
1524
  if let model {
1532
1525
  var configuration = UIButton.Configuration.plain()
1533
- configuration.image = UIImage(systemName: model.symbol)
1534
- configuration.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1535
1526
  configuration.title = model.iconOnly ? nil : model.displayTitle
1536
1527
  configuration.subtitle = model.iconOnly ? nil : model.badge
1537
1528
  configuration.imagePadding = model.iconOnly ? 0 : 8
@@ -1539,7 +1530,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1539
1530
  button.configuration = configuration
1540
1531
  button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1541
1532
  button.accessibilityValue = model.badge
1542
- button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1533
+ styleAccessory(button, for: model, grouped: grouped)
1543
1534
  }
1544
1535
  }
1545
1536
  #if compiler(>=6.2)
@@ -1597,10 +1588,32 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1597
1588
  view.setNeedsLayout()
1598
1589
  }
1599
1590
 
1600
- /// Symbols in a group carry the weight of the system's own map controls;
1601
- /// a separate action keeps the lighter bar-button weight.
1591
+ /// Symbols in a group carry the weight of the system's own map controls,
1592
+ /// and their size: at 20 points a symbol filled its 48-point action
1593
+ /// where the ones in Maps leave half of theirs clear. The control and
1594
+ /// its touch targets keep their size; only the symbol is smaller. A
1595
+ /// separate action keeps the lighter bar-button weight.
1602
1596
  private static func accessorySymbol(grouped: Bool) -> UIImage.SymbolConfiguration {
1603
- UIImage.SymbolConfiguration(pointSize: grouped ? 20 : 22, weight: grouped ? .medium : .regular)
1597
+ UIImage.SymbolConfiguration(pointSize: grouped ? 15 : 22, weight: grouped ? .medium : .regular)
1598
+ }
1599
+
1600
+ /// One action's symbol, tint and state. An action that is on (its
1601
+ /// destination is showing, or the app says so) is drawn in the tint and,
1602
+ /// where the symbol has one, filled: Maps' location arrow while tracking.
1603
+ /// Off, a grouped action is the label colour, as the map controls are.
1604
+ private func styleAccessory(_ button: UIButton, for action: CraftChrome.Tab, grouped: Bool) {
1605
+ let active = CraftChrome.shared.accessoryActive(action)
1606
+ let filled = active && !action.symbol.hasSuffix(".fill") ? UIImage(systemName: action.symbol + ".fill") : nil
1607
+ let tint: UIColor = CraftChrome.shared.tint ?? config.tintColor.flatMap { UIColor(hex: $0) } ?? view.tintColor
1608
+ button.configuration?.image = filled ?? UIImage(systemName: action.symbol)
1609
+ button.configuration?.preferredSymbolConfigurationForImage = Self.accessorySymbol(grouped: grouped)
1610
+ // Said to the configuration as well as the view: glass draws its
1611
+ // content in the label colour unless the content names its own.
1612
+ button.configuration?.baseForegroundColor = active ? tint : grouped ? .label : nil
1613
+ // A group places each symbol itself, when it lays out.
1614
+ if !grouped { button.configuration?.contentInsets = .zero }
1615
+ button.tintColor = active ? tint : grouped ? .label : nil
1616
+ button.accessibilityTraits = active ? [.button, .selected] : [.button]
1604
1617
  }
1605
1618
 
1606
1619
  /// The surface under the floating actions. A group is a capsule of
@@ -1639,10 +1652,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1639
1652
  }
1640
1653
  }
1641
1654
 
1642
- /// A group's breadth, and each action's length along it: the system map
1643
- /// controls' proportions, every target at least 48 points.
1655
+ /// A group's breadth, and the distance from one symbol to the next. As
1656
+ /// in Maps, the first and last symbols sit at the centres of the
1657
+ /// capsule's round ends, so two actions make a 48 by 103 point control.
1644
1658
  static let accessoryGroupBreadth: CGFloat = 48
1645
- static let accessoryGroupStep: CGFloat = 52
1659
+ static let accessoryGroupStep: CGFloat = 55
1646
1660
 
1647
1661
  @objc private func openTabAccessory() {
1648
1662
  if let accessoryModel { hybrid.open(accessoryModel.id) }
@@ -1694,7 +1708,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1694
1708
  let breadth = Self.accessoryGroupBreadth
1695
1709
  let step = Self.accessoryGroupStep
1696
1710
  let buttons = companionButtons + [accessoryButton]
1697
- let length = step * CGFloat(buttons.count)
1711
+ let length = breadth + step * CGFloat(buttons.count - 1)
1698
1712
  let size = vertical ? CGSize(width: breadth, height: length) : CGSize(width: length, height: breadth)
1699
1713
  let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1700
1714
  // The tab bar's own platter sits this far in from the screen's edge.
@@ -1702,10 +1716,21 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1702
1716
  let x = rightToLeft ? view.safeAreaInsets.left + margin : view.bounds.maxX - view.safeAreaInsets.right - size.width - margin
1703
1717
  accessoryGlass.frame = CGRect(x: x, y: tabBar.frame.minY - size.height - 10, width: size.width, height: size.height)
1704
1718
  for (index, button) in buttons.enumerated() {
1705
- let place = CGFloat(rightToLeft && !vertical ? buttons.count - 1 - index : index) * step
1719
+ // Every point of the capsule belongs to the nearest action; its
1720
+ // symbol stays where Maps puts it, at the centre of a round end
1721
+ // for the first and the last.
1722
+ let place = rightToLeft && !vertical ? buttons.count - 1 - index : index
1723
+ let centre = breadth / 2 + CGFloat(place) * step
1724
+ let from = place == 0 ? 0 : centre - step / 2
1725
+ let to = place == buttons.count - 1 ? length : centre + step / 2
1706
1726
  button.frame = vertical
1707
- ? CGRect(x: 0, y: place, width: breadth, height: step)
1708
- : CGRect(x: place, y: 0, width: step, height: breadth)
1727
+ ? CGRect(x: 0, y: from, width: breadth, height: to - from)
1728
+ : CGRect(x: from, y: 0, width: to - from, height: breadth)
1729
+ let shift = 2 * (centre - from) - (to - from)
1730
+ let before = max(0, shift), after = max(0, -shift)
1731
+ button.configuration?.contentInsets = vertical
1732
+ ? NSDirectionalEdgeInsets(top: before, leading: 0, bottom: after, trailing: 0)
1733
+ : NSDirectionalEdgeInsets(top: 0, leading: rightToLeft ? after : before, bottom: 0, trailing: rightToLeft ? before : after)
1709
1734
  }
1710
1735
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1711
1736
  let primary = accessoryButton.convert(accessoryButton.bounds, to: view)
@@ -391,7 +391,7 @@ enum CraftNativeActions {
391
391
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local database is disabled"), completion)
392
392
  return
393
393
  }
394
- databaseQueue.async { complete(requestToken, database(method: method, args: args), completion) }
394
+ databaseQueue.async { complete(requestToken, database(requestToken: requestToken, method: method, args: args), completion) }
395
395
  return
396
396
  case ("Browser", "open"):
397
397
  // SFSafariViewController, or for `mode: 'auth'` the system's web
@@ -1275,7 +1275,47 @@ enum CraftNativeActions {
1275
1275
  #endif
1276
1276
  }
1277
1277
 
1278
- private static func database(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1278
+ /// A screen has gone. A transaction it began and never finished is rolled
1279
+ /// back.
1280
+ ///
1281
+ /// The database is one connection shared by every screen, and a
1282
+ /// transaction left open on it does not end with the screen that began
1283
+ /// it: every later write joins it, reads back as written, and is gone
1284
+ /// when the process exits, since nobody is left to commit; every later
1285
+ /// `beginTransaction` is refused. Rolling back loses what the closed
1286
+ /// screen had not committed, which is what closing mid-transaction means,
1287
+ /// and gives every later write its own fate back. Queued behind whatever
1288
+ /// that screen had already asked for.
1289
+ static func endScope(_ scope: String) {
1290
+ databaseQueue.async {
1291
+ guard transactionScope == scope else { return }
1292
+ transactionScope = nil
1293
+ guard let database, sqlite3_get_autocommit(database) == 0 else { return }
1294
+ _ = execute(sql: "ROLLBACK", params: [])
1295
+ }
1296
+ }
1297
+
1298
+ /// Which screen's transaction is open, read off the database around each
1299
+ /// call rather than off the method's name, so a `BEGIN` sent through
1300
+ /// `execute` is owned like one sent through `beginTransaction`: a call
1301
+ /// that leaves a transaction open where there was none began it, and one
1302
+ /// that leaves none ended it. The scope is the part of a request token
1303
+ /// before its last `/`. Only touched on `databaseQueue`.
1304
+ private static var transactionScope: String?
1305
+
1306
+ private static func database(requestToken: String, method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1307
+ let before = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
1308
+ let answer = databaseCall(method: method, args: args)
1309
+ let after = database.map { sqlite3_get_autocommit($0) == 0 } ?? false
1310
+ if after && !before {
1311
+ transactionScope = requestToken.range(of: "/", options: .backwards).map { String(requestToken[..<$0.lowerBound]) } ?? ""
1312
+ } else if !after {
1313
+ transactionScope = nil
1314
+ }
1315
+ return answer
1316
+ }
1317
+
1318
+ private static func databaseCall(method: String, args: [Any]) -> Result<Any, CraftNativeActionError> {
1279
1319
  guard openDatabase() else { return failure("DATABASE_ERROR", "Could not open the local database") }
1280
1320
  switch method {
1281
1321
  case "execute":
@@ -1497,7 +1537,11 @@ enum CraftNativeActions {
1497
1537
  "host": url.host ?? "",
1498
1538
  "path": url.path,
1499
1539
  "query": url.query ?? "",
1500
- "queryParams": Dictionary(uniqueKeysWithValues: (components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }),
1540
+ // A link is whatever its sender wrote, and may name a parameter
1541
+ // twice. Built with unique keys this trapped, and the app closed
1542
+ // on being opened by such a link. The first value is kept, which
1543
+ // is the one Android gives.
1544
+ "queryParams": Dictionary((components?.queryItems ?? []).map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first }),
1501
1545
  ]
1502
1546
  }
1503
1547
 
@@ -902,8 +902,15 @@ enum CraftNativeFiles {
902
902
  guard let text = data as? String else {
903
903
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs its contents as a string"))
904
904
  }
905
+ // Absent or null is utf8. Anything else that is not one of the two
906
+ // names is refused, a number included, rather than read as utf8.
907
+ let encoding: String?
908
+ switch options["encoding"] {
909
+ case nil, is NSNull: encoding = "utf8"
910
+ case let named: encoding = named as? String
911
+ }
905
912
  let bytes: Data
906
- switch options["encoding"] as? String ?? "utf8" {
913
+ switch encoding {
907
914
  case "utf8":
908
915
  bytes = Data(text.utf8)
909
916
  case "base64":
@@ -4798,14 +4798,23 @@ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
4798
4798
 
4799
4799
  /// Whether a scrolling view's gesture runs together with `other`. A scroll
4800
4800
  /// view is the delegate of its own pan as well as of its keyboard tap, so this
4801
- /// answers for both. Everything runs together (the keyboard tap watches
4801
+ /// answers for both. Most things run together (the keyboard tap watches
4802
4802
  /// alongside a control's own gestures; the back-swipe and the tab swipe decide
4803
- /// for themselves) except two scroll views' pans: run together, a drag on a
4804
- /// nested scroll view scrolled its parent at the same time.
4803
+ /// for themselves), with two exceptions for a scroll view's pan.
4804
+ ///
4805
+ /// Another scroll view's pan: run together, a drag on a nested scroll view
4806
+ /// scrolled its parent at the same time.
4807
+ ///
4808
+ /// A press. A row or a card that can be pressed is a view with a tap
4809
+ /// recognizer, and UIKit fails a tap the moment a drag begins on the scroll
4810
+ /// view around it. That is what keeps a flick, or the touch that stops a
4811
+ /// moving list, from also pressing whatever is under the finger. Told to run
4812
+ /// together, both happened: a short scroll pressed the thing it began on.
4805
4813
  func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
4806
- guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
4807
- let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
4808
- return false
4814
+ guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer else { return true }
4815
+ if let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer { return false }
4816
+ if other is UITapGestureRecognizer, other.delegate is CraftPressGestureDelegate { return false }
4817
+ return true
4809
4818
  }
4810
4819
 
4811
4820
  /// Scroll containers observe taps without cancelling native input focus or
@@ -6930,6 +6939,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6930
6939
  private var runningAnimation: CraftNativeRunningAnimation?
6931
6940
  private var renderedRoot: RenderedNode?
6932
6941
  private weak var lastFocusedInput: UIView?
6942
+ /// On its way off screen, or off it: from `viewWillDisappear` until the
6943
+ /// screen is next about to appear.
6944
+ ///
6945
+ /// A screen ends editing as it starts to leave (#497), and its view stays
6946
+ /// in the window for the length of the transition. Whatever put focus
6947
+ /// back in that time brought the keyboard up for a screen that was then
6948
+ /// removed, where nothing could put it away: a render, and a sign-in
6949
+ /// screen makes one as it navigates; the acknowledgement an input had
6950
+ /// scheduled a tenth of a second before; a modal closing; `autoFocus`.
6951
+ /// Each of them asks this first.
6952
+ private var isLeavingScreen = false
6933
6953
 
6934
6954
  /// The person put the keyboard away (Return, or a tap on nothing in a
6935
6955
  /// scroll view): the last input is let go. Kept, the next render or
@@ -7035,6 +7055,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7035
7055
  let timers = self.timers
7036
7056
  CraftNativeScriptThread.run { timers.pending.values.forEach { $0.cancel() } }
7037
7057
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7058
+ CraftNativeActions.endScope(capabilityScope)
7038
7059
  lifecycleObservers.forEach(NotificationCenter.default.removeObserver)
7039
7060
  if let deepLinkListener = deepLinkListener { DeepLinkManager.shared.removeNativeListener(deepLinkListener) }
7040
7061
  }
@@ -7091,6 +7112,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7091
7112
 
7092
7113
  override func viewWillAppear(_ animated: Bool) {
7093
7114
  super.viewWillAppear(animated)
7115
+ isLeavingScreen = false
7094
7116
  if let navigationBarHidden, navigationController?.isNavigationBarHidden != navigationBarHidden {
7095
7117
  navigationController?.setNavigationBarHidden(navigationBarHidden, animated: animated)
7096
7118
  } else if navigationBarHidden == nil, navigationController?.isNavigationBarHidden == true,
@@ -7133,7 +7155,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7133
7155
  //
7134
7156
  // `lastFocusedInput` is deliberately left alone. A screen merely
7135
7157
  // covered by a push comes back, and restoreFocus putting the person
7136
- // back where they were is the behaviour that was already there.
7158
+ // back where they were is the behaviour that was already there. It
7159
+ // is not consulted again until the screen is coming back, or what
7160
+ // is ended here would be started again a moment later.
7161
+ isLeavingScreen = true
7137
7162
  view.endEditing(true)
7138
7163
  screenVisibilityChanged(false)
7139
7164
  stopCelebrations(in: view)
@@ -7298,6 +7323,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7298
7323
  pendingCapabilityDeadlines.values.forEach { $0.cancel() }
7299
7324
  pendingCapabilityDeadlines.removeAll()
7300
7325
  pendingCapabilityRequests.forEach { CraftNativeActions.cancel(requestToken: $0) }
7326
+ CraftNativeActions.endScope(capabilityScope)
7301
7327
  pendingCapabilityRequests.removeAll()
7302
7328
  if let owner = voiceRecordingOwner {
7303
7329
  voiceRecordingOwner = nil
@@ -9307,7 +9333,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9307
9333
  reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
9308
9334
  return true
9309
9335
  }
9310
- guard node.view.becomeFirstResponder() else {
9336
+ // A screen that is leaving does not open a keyboard it will
9337
+ // not be there to close.
9338
+ guard !isLeavingScreen, node.view.becomeFirstResponder() else {
9311
9339
  reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
9312
9340
  return true
9313
9341
  }
@@ -9652,6 +9680,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9652
9680
  }
9653
9681
 
9654
9682
  private func restoreFocus(_ focused: UIView?) {
9683
+ guard !isLeavingScreen else { return }
9655
9684
  guard let focused, focused.window != nil, !focused.isFirstResponder,
9656
9685
  lastFocusedInput === focused, firstResponder(in: rootStack) == nil else { return }
9657
9686
  var ancestor: UIView? = focused
@@ -9862,7 +9891,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9862
9891
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: field)
9863
9892
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: field)
9864
9893
  if props["autoFocus"] as? Bool == true, !field.isFirstResponder {
9865
- DispatchQueue.main.async { _ = field.becomeFirstResponder() }
9894
+ DispatchQueue.main.async { [weak self] in
9895
+ guard self?.isLeavingScreen == false else { return }
9896
+ _ = field.becomeFirstResponder()
9897
+ }
9866
9898
  }
9867
9899
  case "SegmentedControl":
9868
9900
  let control = result as! CraftNativeSegmentedControl
@@ -9982,7 +10014,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9982
10014
  if !visible, wasVisible == true {
9983
10015
  let restore = modalFocusRestore.removeValue(forKey: modalID)
9984
10016
  DispatchQueue.main.async { [weak self, weak restore] in
9985
- guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
10017
+ guard let self, !self.isLeavingScreen, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
9986
10018
  self.lastFocusedInput = restore
9987
10019
  _ = restore.becomeFirstResponder()
9988
10020
  }
@@ -11185,7 +11217,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
11185
11217
  updateAuxiliaryHandler(events["onEndEditing"], in: &endEditingHandlers, for: textView)
11186
11218
  updateAuxiliaryHandler(events["onSubmitEditing"], in: &submitHandlers, for: textView)
11187
11219
  if props["autoFocus"] as? Bool == true, !textView.isFirstResponder {
11188
- DispatchQueue.main.async { _ = textView.becomeFirstResponder() }
11220
+ DispatchQueue.main.async { [weak self] in
11221
+ guard self?.isLeavingScreen == false else { return }
11222
+ _ = textView.becomeFirstResponder()
11223
+ }
11189
11224
  }
11190
11225
  }
11191
11226
 
@@ -13,11 +13,22 @@ enum CraftPersistentStorage {
13
13
  let digest = SHA256.hash(data: Data(key.utf8)).map { String(format: "%02x", $0) }.joined()
14
14
  return directory.appendingPathComponent(digest + ".json")
15
15
  }
16
+ /// What an entry file holds, or nil when it is there and is not an entry.
17
+ ///
18
+ /// A file that cannot be parsed is a corrupt value, as one that parses to
19
+ /// the wrong thing is, and an app starts that value again. Only a file
20
+ /// that cannot be read at all is the disk failing, which an app retries;
21
+ /// that still throws. The two used to share the disk's answer.
22
+ private static func entry(at source: URL) throws -> [String: Any]? {
23
+ let contents = try Data(contentsOf: source)
24
+ return (try? JSONSerialization.jsonObject(with: contents)) as? [String: Any]
25
+ }
26
+
16
27
  static func get(_ key: String, prefix: String, storageDirectory: URL? = nil) throws -> Data? {
17
28
  lock.lock(); defer { lock.unlock() }
18
29
  let source = path(key, directory: storageDirectory ?? directory)
19
30
  if FileManager.default.fileExists(atPath: source.path) {
20
- guard let saved = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], saved["key"] as? String == key else {
31
+ guard let saved = try entry(at: source), saved["key"] as? String == key else {
21
32
  throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
22
33
  }
23
34
  if saved["deleted"] as? Bool == true { return nil }
@@ -26,7 +37,9 @@ enum CraftPersistentStorage {
26
37
  }
27
38
  guard let legacy = UserDefaults.standard.data(forKey: prefix + key) else { return nil }
28
39
  // Only valid legacy JSON qualifies for deferred migration on I/O failure.
29
- _ = try JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)
40
+ guard (try? JSONSerialization.jsonObject(with: legacy, options: .fragmentsAllowed)) != nil else {
41
+ throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Stored value is not valid JSON")
42
+ }
30
43
  // Reading a valid legacy checkpoint must survive a full disk. Migration
31
44
  // can be retried on a later read; only a successful set promises a new
32
45
  // durable checkpoint and permits the application to acknowledge it.
@@ -84,9 +97,11 @@ enum CraftPersistentStorage {
84
97
  var result = Set(UserDefaults.standard.dictionaryRepresentation().keys.filter { $0.hasPrefix(prefix) }.map { String($0.dropFirst(prefix.count)) })
85
98
  if FileManager.default.fileExists(atPath: directory.path) {
86
99
  for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
87
- guard let entry = try JSONSerialization.jsonObject(with: Data(contentsOf: source)) as? [String: Any], let key = entry["key"] as? String else {
88
- throw CraftNativeActionError(code: "STORAGE_CORRUPT", message: "Invalid stored value")
89
- }
100
+ // A file that is not an entry names no key. It used to fail
101
+ // the whole listing, and `clear` with it, over a value
102
+ // neither call had been asked about. `get` of its key still
103
+ // answers STORAGE_CORRUPT.
104
+ guard let entry = try entry(at: source), let key = entry["key"] as? String else { continue }
90
105
  if entry["deleted"] as? Bool == true { result.remove(key) } else { result.insert(key) }
91
106
  }
92
107
  }
@@ -95,5 +110,13 @@ enum CraftPersistentStorage {
95
110
  static func clear(prefix: String) throws {
96
111
  lock.lock(); defer { lock.unlock() }
97
112
  for key in try keys(prefix: prefix) { try set(key, data: nil, prefix: prefix) }
113
+ // Clearing a key writes over its file, so what is still not an entry
114
+ // belongs to no key this store knows, and cannot be marked removed.
115
+ // It is deleted. No earlier value can come back in its place: every
116
+ // one this store mirrors was removed just above.
117
+ guard FileManager.default.fileExists(atPath: directory.path) else { return }
118
+ for source in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) where source.pathExtension == "json" {
119
+ if try entry(at: source)?["key"] as? String == nil { try FileManager.default.removeItem(at: source) }
120
+ }
98
121
  }
99
122
  }