@craft-native/ios 0.0.496 → 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.496",
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
  }
@@ -1248,7 +1248,9 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1248
1248
  private var companionButtons: [UIButton] = []
1249
1249
  private var companionModels: [CraftChrome.Tab] = []
1250
1250
  private var companionGlasses: [UIVisualEffectView] = []
1251
- private var accessorySeparators: [UIView] = []
1251
+ /// Whether the shared surface was last configured for a group; its
1252
+ /// material is replaced only when that changes, never on a badge update.
1253
+ private var accessorySurfaceGrouped: Bool?
1252
1254
  private let accessoryBadge = UILabel()
1253
1255
  private let accessoryGlass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1254
1256
  private static let tabPictures = NSCache<NSURL, UIImage>()
@@ -1481,12 +1483,10 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1481
1483
  let button = UIButton(type: .system)
1482
1484
  var configuration = UIButton.Configuration.plain()
1483
1485
  configuration.image = UIImage(systemName: action.symbol)
1484
- configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1485
1486
  button.configuration = configuration
1486
1487
  button.accessibilityIdentifier = "craft-tab-companion:" + action.id
1487
1488
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
1488
1489
  button.accessibilityValue = action.badge
1489
- button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1490
1490
  if action.menu.isEmpty {
1491
1491
  button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1492
1492
  } else {
@@ -1506,31 +1506,23 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1506
1506
  glass.clipsToBounds = true
1507
1507
  glass.isUserInteractionEnabled = false
1508
1508
  view.addSubview(glass)
1509
- view.addSubview(button)
1510
1509
  companionGlasses.append(glass)
1511
1510
  companionButtons.append(button)
1512
1511
  }
1513
1512
  }
1514
- for (index, action) in companionModels.enumerated() {
1515
- companionButtons[index].accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1516
- }
1517
1513
  let grouped = model?.accessoryLayout != nil
1514
+ // A group is one control, as the map controls in Maps are: its
1515
+ // actions live inside the shared surface, so the glass lights the
1516
+ // one being pressed and keeps every symbol legible over the page.
1517
+ // Separate actions stay in the shell, each on its own surface.
1518
+ let actionHost: UIView = grouped ? accessoryGlass.contentView : view
1518
1519
  for glass in companionGlasses { glass.isHidden = grouped }
1519
- for button in companionButtons { button.tintColor = grouped ? .label : nil }
1520
- accessoryButton.tintColor = grouped ? .label : nil
1521
- for separator in accessorySeparators { separator.removeFromSuperview() }
1522
- accessorySeparators = grouped ? companionButtons.map { _ in
1523
- let separator = UIView()
1524
- separator.backgroundColor = .separator
1525
- separator.isUserInteractionEnabled = false
1526
- separator.isAccessibilityElement = false
1527
- view.addSubview(separator)
1528
- return separator
1529
- } : []
1520
+ for (index, button) in companionButtons.enumerated() {
1521
+ if button.superview !== actionHost { actionHost.addSubview(button) }
1522
+ styleAccessory(button, for: companionModels[index], grouped: grouped)
1523
+ }
1530
1524
  if let model {
1531
1525
  var configuration = UIButton.Configuration.plain()
1532
- configuration.image = UIImage(systemName: model.symbol)
1533
- configuration.preferredSymbolConfigurationForImage = UIImage.SymbolConfiguration(pointSize: 22, weight: .regular)
1534
1526
  configuration.title = model.iconOnly ? nil : model.displayTitle
1535
1527
  configuration.subtitle = model.iconOnly ? nil : model.badge
1536
1528
  configuration.imagePadding = model.iconOnly ? 0 : 8
@@ -1538,7 +1530,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1538
1530
  button.configuration = configuration
1539
1531
  button.accessibilityLabel = model.accessibilityLabel ?? model.displayTitle
1540
1532
  button.accessibilityValue = model.badge
1541
- button.accessibilityTraits = chrome.accessorySelected(model) ? [.button, .selected] : [.button]
1533
+ styleAccessory(button, for: model, grouped: grouped)
1542
1534
  }
1543
1535
  }
1544
1536
  #if compiler(>=6.2)
@@ -1559,22 +1551,17 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1559
1551
  // Older systems have no bottom-accessory container. Keep the action
1560
1552
  // available above the system bar, without recreating the tab control.
1561
1553
  #if compiler(>=6.2)
1562
- if #available(iOS 26.0, *) {
1563
- bottomAccessory = nil
1564
- accessoryGlass.effect = UIGlassEffect()
1565
- }
1554
+ if #available(iOS 26.0, *) { bottomAccessory = nil }
1566
1555
  #endif
1567
- if model != nil, accessoryButton.superview !== view {
1556
+ if model != nil, accessoryGlass.superview !== view || accessoryButton.superview !== actionHost {
1568
1557
  accessoryButton.removeFromSuperview()
1569
- view.addSubview(accessoryGlass)
1570
- view.addSubview(accessoryButton)
1558
+ if accessoryGlass.superview !== view { view.addSubview(accessoryGlass) }
1559
+ actionHost.addSubview(accessoryButton)
1571
1560
  view.addSubview(accessoryBadge)
1572
1561
  }
1573
1562
  accessoryButton.isHidden = model == nil
1574
1563
  accessoryGlass.isHidden = model == nil
1575
- accessoryGlass.isUserInteractionEnabled = false
1576
- accessoryGlass.layer.cornerRadius = grouped ? 26 : 24
1577
- accessoryGlass.clipsToBounds = true
1564
+ configureAccessorySurface(grouped: grouped)
1578
1565
  accessoryButton.backgroundColor = .clear
1579
1566
  accessoryButton.layer.cornerRadius = 24
1580
1567
  accessoryBadge.text = model?.badge
@@ -1591,15 +1578,86 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1591
1578
  // companion-only update creates new controls or a shelf changes mode.
1592
1579
  if accessoryGlass.superview === view {
1593
1580
  view.bringSubviewToFront(accessoryGlass)
1594
- for button in companionButtons { view.bringSubviewToFront(button) }
1595
- view.bringSubviewToFront(accessoryButton)
1596
- for separator in accessorySeparators { view.bringSubviewToFront(separator) }
1581
+ if !grouped {
1582
+ for button in companionButtons { view.bringSubviewToFront(button) }
1583
+ view.bringSubviewToFront(accessoryButton)
1584
+ }
1597
1585
  view.bringSubviewToFront(accessoryBadge)
1598
1586
  }
1599
1587
  for nav in stacks.values { nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar() }
1600
1588
  view.setNeedsLayout()
1601
1589
  }
1602
1590
 
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.
1596
+ private static func accessorySymbol(grouped: Bool) -> UIImage.SymbolConfiguration {
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]
1617
+ }
1618
+
1619
+ /// The surface under the floating actions. A group is a capsule of
1620
+ /// interactive Liquid Glass on iOS 26, shaped by the system so its rim,
1621
+ /// lensing and shadow are not clipped away; before iOS 26 it is the tab
1622
+ /// bar's material with a hairline. A single action stays a plain circle.
1623
+ private func configureAccessorySurface(grouped: Bool) {
1624
+ accessoryGlass.isUserInteractionEnabled = grouped
1625
+ if accessorySurfaceGrouped != grouped {
1626
+ accessorySurfaceGrouped = grouped
1627
+ var system = false
1628
+ #if compiler(>=6.2)
1629
+ if #available(iOS 26.0, *) {
1630
+ let glass = UIGlassEffect()
1631
+ glass.isInteractive = grouped
1632
+ accessoryGlass.effect = glass
1633
+ accessoryGlass.cornerConfiguration = grouped ? .capsule() : .corners(radius: 24)
1634
+ system = true
1635
+ }
1636
+ #endif
1637
+ if system {
1638
+ accessoryGlass.layer.cornerRadius = 0
1639
+ accessoryGlass.layer.borderWidth = 0
1640
+ accessoryGlass.clipsToBounds = false
1641
+ } else {
1642
+ accessoryGlass.effect = UIBlurEffect(style: grouped ? .systemUltraThinMaterial : .systemMaterial)
1643
+ accessoryGlass.layer.cornerCurve = .continuous
1644
+ accessoryGlass.layer.borderWidth = grouped ? 0.5 : 0
1645
+ accessoryGlass.layer.borderColor = UIColor.white.withAlphaComponent(0.35).cgColor
1646
+ accessoryGlass.clipsToBounds = true
1647
+ }
1648
+ }
1649
+ // Before iOS 26 the capsule follows the group's shorter side.
1650
+ if accessoryGlass.clipsToBounds {
1651
+ accessoryGlass.layer.cornerRadius = grouped ? Self.accessoryGroupBreadth / 2 : 24
1652
+ }
1653
+ }
1654
+
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.
1658
+ static let accessoryGroupBreadth: CGFloat = 48
1659
+ static let accessoryGroupStep: CGFloat = 55
1660
+
1603
1661
  @objc private func openTabAccessory() {
1604
1662
  if let accessoryModel { hybrid.open(accessoryModel.id) }
1605
1663
  }
@@ -1617,6 +1675,11 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1617
1675
  }
1618
1676
  #endif
1619
1677
  let iconOnly = accessoryModel?.iconOnly == true
1678
+ if let axis = accessoryModel?.accessoryLayout {
1679
+ layoutAccessoryGroup(vertical: axis == "vertical")
1680
+ CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY))
1681
+ return
1682
+ }
1620
1683
  let height: CGFloat = iconOnly ? 48 : 44
1621
1684
  let width: CGFloat = iconOnly ? 48 : max(0, view.bounds.width - 32)
1622
1685
  let trailingX = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
@@ -1624,25 +1687,12 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1624
1687
  let x = iconOnly ? trailingX : 16
1625
1688
  accessoryButton.frame = CGRect(x: x, y: tabBar.frame.minY - height - (iconOnly ? 8 : 0), width: width, height: height)
1626
1689
  accessoryGlass.frame = accessoryButton.frame
1627
- let layout = accessoryModel?.accessoryLayout
1628
1690
  for (index, button) in companionButtons.enumerated() {
1629
1691
  let offset = CGFloat(companionButtons.count - index)
1630
- if layout == "vertical" {
1631
- button.frame = accessoryButton.frame.offsetBy(dx: 0, dy: -offset * height)
1632
- } else {
1633
- let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
1634
- button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + (layout == nil ? 12 : 0)), dy: 0)
1635
- }
1692
+ let direction: CGFloat = view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? 1 : -1
1693
+ button.frame = accessoryButton.frame.offsetBy(dx: direction * offset * (width + 12), dy: 0)
1636
1694
  companionGlasses[index].frame = button.frame
1637
- if layout != nil {
1638
- accessoryGlass.frame = accessoryGlass.frame.union(button.frame)
1639
- let line = 1 / (view.window?.screen.scale ?? 2)
1640
- accessorySeparators[index].frame = layout == "vertical"
1641
- ? CGRect(x: button.frame.minX + 12, y: button.frame.maxY - line / 2, width: width - 24, height: line)
1642
- : CGRect(x: view.effectiveUserInterfaceLayoutDirection == .rightToLeft ? button.frame.minX : button.frame.maxX - line / 2, y: button.frame.minY + 12, width: line, height: height - 24)
1643
- }
1644
1695
  }
1645
- if layout != nil { accessoryGlass.frame = accessoryGlass.frame.insetBy(dx: -2, dy: -2) }
1646
1696
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1647
1697
  accessoryBadge.frame = CGRect(x: accessoryButton.frame.maxX - badgeWidth + 4, y: accessoryButton.frame.minY - 3, width: badgeWidth, height: 18)
1648
1698
  // A compact action floats over the page, not across its whole width.
@@ -1651,6 +1701,42 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1651
1701
  CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
1652
1702
  }
1653
1703
 
1704
+ /// One capsule above the trailing end of the tab bar, companions first
1705
+ /// and the primary action last. Nothing divides the actions: the glass
1706
+ /// is one control, and each action is found by its place and its symbol.
1707
+ private func layoutAccessoryGroup(vertical: Bool) {
1708
+ let breadth = Self.accessoryGroupBreadth
1709
+ let step = Self.accessoryGroupStep
1710
+ let buttons = companionButtons + [accessoryButton]
1711
+ let length = breadth + step * CGFloat(buttons.count - 1)
1712
+ let size = vertical ? CGSize(width: breadth, height: length) : CGSize(width: length, height: breadth)
1713
+ let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1714
+ // The tab bar's own platter sits this far in from the screen's edge.
1715
+ let margin: CGFloat = 22
1716
+ let x = rightToLeft ? view.safeAreaInsets.left + margin : view.bounds.maxX - view.safeAreaInsets.right - size.width - margin
1717
+ accessoryGlass.frame = CGRect(x: x, y: tabBar.frame.minY - size.height - 10, width: size.width, height: size.height)
1718
+ for (index, button) in buttons.enumerated() {
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
1726
+ button.frame = vertical
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)
1734
+ }
1735
+ let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1736
+ let primary = accessoryButton.convert(accessoryButton.bounds, to: view)
1737
+ accessoryBadge.frame = CGRect(x: primary.maxX - badgeWidth + 4, y: primary.minY - 3, width: badgeWidth, height: 18)
1738
+ }
1739
+
1654
1740
  private var webAttached = false
1655
1741
 
1656
1742
  private func installTabSwipeGestures() {
@@ -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
  }