@craft-native/ios 0.0.497 → 0.0.500

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.500",
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
  }
@@ -835,6 +838,23 @@ final class CraftChrome: ObservableObject {
835
838
  @Published var tabs: [Tab] = []
836
839
  @Published var tabAccessory: Tab?
837
840
  private weak var tabAccessoryOwner: AnyObject?
841
+
842
+ /// Where the compact tab actions float over the page, in window
843
+ /// coordinates, and the shell that drew them; nil when nothing floats.
844
+ /// They cover the trailing end of whatever scrolls behind them, so a
845
+ /// screen gives its scroll views that much room at the end (#523): the
846
+ /// last row can be brought clear without the app knowing this geometry.
847
+ private(set) var floatingActions: CGRect?
848
+ private(set) weak var floatingActionsHost: UIView?
849
+ static let floatingActionsDidMove = Notification.Name("CraftFloatingActionsDidMove")
850
+
851
+ func floatingActionsMoved(to frame: CGRect?, in host: UIView?) {
852
+ let next = frame.flatMap { $0.isNull || $0.isEmpty ? nil : $0.integral }
853
+ guard next != floatingActions || (next != nil && host !== floatingActionsHost) else { return }
854
+ floatingActions = next
855
+ floatingActionsHost = next == nil ? nil : host
856
+ NotificationCenter.default.post(name: Self.floatingActionsDidMove, object: self)
857
+ }
838
858
  @Published var selected: String?
839
859
  /// The visible destination may be the accessory, independently of its owning stack.
840
860
  @Published var visiblePath: String?
@@ -846,6 +866,8 @@ final class CraftChrome: ObservableObject {
846
866
  guard let path = visiblePath else { return false }
847
867
  return path == accessory.id || path.hasPrefix(accessory.id + "/")
848
868
  }
869
+ /// Drawn in the tint: its destination is showing, or the app says it is on.
870
+ func accessoryActive(_ accessory: Tab) -> Bool { accessory.active || accessorySelected(accessory) }
849
871
  var highlightedTab: String? { accessorySelected ? nil : selected }
850
872
  let tabSwipeProgress = CraftTabSwipeProgress()
851
873
  private(set) var tabSwipePosition: CGFloat? {
@@ -977,13 +999,15 @@ final class CraftChrome: ObservableObject {
977
999
  let companionTitle = action["title"] as? String else { return nil }
978
1000
  return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
979
1001
  accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
1002
+ active: action["selected"] as? Bool ?? false,
980
1003
  menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
981
1004
  guard let destination = choice["path"] as? String,
982
1005
  destination.hasPrefix("/"), !destination.hasPrefix("//"),
983
1006
  let label = choice["title"] as? String, !label.isEmpty else { return nil }
984
1007
  return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
985
1008
  })
986
- })
1009
+ },
1010
+ active: item["selected"] as? Bool ?? false)
987
1011
  } else {
988
1012
  next = nil
989
1013
  }
@@ -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) }
@@ -1651,6 +1665,8 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1651
1665
  override func viewDidLayoutSubviews() {
1652
1666
  super.viewDidLayoutSubviews()
1653
1667
  guard hybrid.controller === self else { return }
1668
+ // Once the actions are where this pass puts them.
1669
+ defer { CraftChrome.shared.floatingActionsMoved(to: floatingActionsFrame(), in: view) }
1654
1670
  #if compiler(>=6.2)
1655
1671
  if #available(iOS 26.0, *), accessoryModel?.iconOnly != true {
1656
1672
  // UIKit already supplies the native safe area. This measurement is
@@ -1687,6 +1703,20 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1687
1703
  CraftChrome.shared.layoutChanged(tabBar.isHidden ? 0 : max(0, view.bounds.maxY - tabBar.frame.minY) + accessoryInset)
1688
1704
  }
1689
1705
 
1706
+ /// The compact actions' frame in the window, or nil when none float over
1707
+ /// the page: a full-width shelf is reserved for through the safe area,
1708
+ /// and a hidden tab bar takes its actions with it. A group is its one
1709
+ /// capsule; separate actions are the row of them.
1710
+ private func floatingActionsFrame() -> CGRect? {
1711
+ guard let model = accessoryModel, model.iconOnly, view.window != nil,
1712
+ accessoryGlass.superview === view, !accessoryGlass.isHidden, !tabBar.isHidden else { return nil }
1713
+ var frame = accessoryGlass.frame
1714
+ if model.accessoryLayout == nil {
1715
+ for glass in companionGlasses where !glass.isHidden { frame = frame.union(glass.frame) }
1716
+ }
1717
+ return view.convert(frame, to: view.window)
1718
+ }
1719
+
1690
1720
  /// One capsule above the trailing end of the tab bar, companions first
1691
1721
  /// and the primary action last. Nothing divides the actions: the glass
1692
1722
  /// is one control, and each action is found by its place and its symbol.
@@ -1694,7 +1724,7 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1694
1724
  let breadth = Self.accessoryGroupBreadth
1695
1725
  let step = Self.accessoryGroupStep
1696
1726
  let buttons = companionButtons + [accessoryButton]
1697
- let length = step * CGFloat(buttons.count)
1727
+ let length = breadth + step * CGFloat(buttons.count - 1)
1698
1728
  let size = vertical ? CGSize(width: breadth, height: length) : CGSize(width: length, height: breadth)
1699
1729
  let rightToLeft = view.effectiveUserInterfaceLayoutDirection == .rightToLeft
1700
1730
  // The tab bar's own platter sits this far in from the screen's edge.
@@ -1702,10 +1732,21 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1702
1732
  let x = rightToLeft ? view.safeAreaInsets.left + margin : view.bounds.maxX - view.safeAreaInsets.right - size.width - margin
1703
1733
  accessoryGlass.frame = CGRect(x: x, y: tabBar.frame.minY - size.height - 10, width: size.width, height: size.height)
1704
1734
  for (index, button) in buttons.enumerated() {
1705
- let place = CGFloat(rightToLeft && !vertical ? buttons.count - 1 - index : index) * step
1735
+ // Every point of the capsule belongs to the nearest action; its
1736
+ // symbol stays where Maps puts it, at the centre of a round end
1737
+ // for the first and the last.
1738
+ let place = rightToLeft && !vertical ? buttons.count - 1 - index : index
1739
+ let centre = breadth / 2 + CGFloat(place) * step
1740
+ let from = place == 0 ? 0 : centre - step / 2
1741
+ let to = place == buttons.count - 1 ? length : centre + step / 2
1706
1742
  button.frame = vertical
1707
- ? CGRect(x: 0, y: place, width: breadth, height: step)
1708
- : CGRect(x: place, y: 0, width: step, height: breadth)
1743
+ ? CGRect(x: 0, y: from, width: breadth, height: to - from)
1744
+ : CGRect(x: from, y: 0, width: to - from, height: breadth)
1745
+ let shift = 2 * (centre - from) - (to - from)
1746
+ let before = max(0, shift), after = max(0, -shift)
1747
+ button.configuration?.contentInsets = vertical
1748
+ ? NSDirectionalEdgeInsets(top: before, leading: 0, bottom: after, trailing: 0)
1749
+ : NSDirectionalEdgeInsets(top: 0, leading: rightToLeft ? after : before, bottom: 0, trailing: rightToLeft ? before : after)
1709
1750
  }
1710
1751
  let badgeWidth = max(18, accessoryBadge.intrinsicContentSize.width + 8)
1711
1752
  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
 
@@ -85,6 +85,84 @@ final class CraftNotificationAttachments {
85
85
  // memory. `fetch` and the share sheet take the path; only files made here are
86
86
  // read back for a form, so a screen cannot post an arbitrary file of the app's.
87
87
 
88
+ /// A request waiting on a system picker, as the one picker slot sees it.
89
+ protocol CraftPickerRequest: AnyObject {
90
+ /// Where its picker is.
91
+ var presence: CraftPickerPresence { get }
92
+ /// It has neither been answered by its picker nor finished.
93
+ var isWaiting: Bool { get }
94
+ /// The picker went without a choice or a cancel reaching its delegate.
95
+ /// One that was on screen answers as a cancel; one that never appeared
96
+ /// answers that it could not be presented.
97
+ func pickerLeft(shown: Bool)
98
+ }
99
+
100
+ extension CraftPickerRequest {
101
+ /// Still waiting on a picker that can no longer answer.
102
+ var lostItsPicker: Bool { isWaiting && presence.lost }
103
+ }
104
+
105
+ /// Where a request's picker is: on its way to the screen, on it, or gone.
106
+ ///
107
+ /// UIKit tells a picker's delegate about a choice and about a cancel. It
108
+ /// says nothing when the picker goes because what presented it did (an app
109
+ /// closing the sheet the picker was on), or when it gives a presentation up
110
+ /// before it began: a system picker runs in another process and is presented
111
+ /// once that process answers, and by then the sheet it was to appear on may
112
+ /// have closed ("whose view is not in the window hierarchy", in the console
113
+ /// only). The request then never answered and kept the one picker slot:
114
+ /// every later pick was refused as BUSY until the app was restarted.
115
+ final class CraftPickerPresence {
116
+ weak var picker: UIViewController?
117
+ /// What the picker was handed to for presenting.
118
+ weak var presenter: UIViewController?
119
+ weak var watch: CraftPickerWatch?
120
+
121
+ /// The picker has been on screen.
122
+ var shown: Bool { watch?.shown ?? false }
123
+
124
+ /// The picker cannot answer any more.
125
+ var lost: Bool {
126
+ guard let picker else { return true }
127
+ if picker.presentingViewController != nil { return false }
128
+ // It was on screen, is not now, and nothing presents it.
129
+ if shown { return picker.viewIfLoaded?.window == nil }
130
+ // It has not appeared yet, and what was to present it has gone.
131
+ guard let presenter else { return true }
132
+ return presenter.viewIfLoaded?.window == nil
133
+ }
134
+ }
135
+
136
+ /// A view inside a system picker that says when the picker has been seen
137
+ /// and when it is gone: it leaves the window when the picker does and is
138
+ /// released when the picker is, whichever way that happens.
139
+ final class CraftPickerWatch: UIView {
140
+ private(set) var shown = false
141
+ private var left: ((_ shown: Bool) -> Void)?
142
+
143
+ static func attach(to picker: UIViewController, left: @escaping (_ shown: Bool) -> Void) -> CraftPickerWatch {
144
+ let watch = CraftPickerWatch(frame: .zero)
145
+ watch.isHidden = true
146
+ watch.isUserInteractionEnabled = false
147
+ watch.left = left
148
+ picker.view.addSubview(watch)
149
+ return watch
150
+ }
151
+
152
+ override func didMoveToWindow() {
153
+ super.didMoveToWindow()
154
+ if window != nil { shown = true } else if shown { report() }
155
+ }
156
+
157
+ deinit { report() }
158
+
159
+ private func report() {
160
+ guard let left else { return }
161
+ let shown = shown
162
+ DispatchQueue.main.async { left(shown) }
163
+ }
164
+ }
165
+
88
166
  enum CraftNativeFiles {
89
167
  /// The longest side of a picked photo unless the screen asks otherwise:
90
168
  /// sharp on any phone screen and in a share card, a few hundred kilobytes
@@ -104,6 +182,40 @@ enum CraftNativeFiles {
104
182
  /// The picker on screen, kept alive until it answers. One at a time.
105
183
  private static var activePicker: NSObject?
106
184
 
185
+ /// The slot is free when nothing holds it, or when what holds it is
186
+ /// waiting on a picker that can no longer answer.
187
+ private static func pickerSlotIsFree() -> Bool {
188
+ guard let request = activePicker as? CraftPickerRequest else { return activePicker == nil }
189
+ guard request.lostItsPicker else { return false }
190
+ activePicker = nil
191
+ request.pickerLeft(shown: request.presence.shown)
192
+ return true
193
+ }
194
+
195
+ /// Follow `picker` for the request that is waiting on it.
196
+ private static func watch(_ picker: UIViewController, for request: CraftPickerRequest) {
197
+ let presence = request.presence
198
+ presence.picker = picker
199
+ presence.presenter = CraftPresenter.topViewController()
200
+ presence.watch = CraftPickerWatch.attach(to: picker) { [weak request] shown in request?.pickerLeft(shown: shown) }
201
+ awaitAppearance(of: request, checks: 60)
202
+ }
203
+
204
+ /// Until the picker is on screen nothing of its own can say it never
205
+ /// will be, and UIKit may hold a presentation it cannot make for as long
206
+ /// as the picker's process takes to answer. Look twice a second, for half
207
+ /// a minute at most, and answer for a picker whose presenter has gone.
208
+ private static func awaitAppearance(of request: CraftPickerRequest, checks: Int) {
209
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak request] in
210
+ guard let request, request.isWaiting, !request.presence.shown else { return }
211
+ if request.presence.lost {
212
+ request.pickerLeft(shown: false)
213
+ } else if checks > 1 {
214
+ awaitAppearance(of: request, checks: checks - 1)
215
+ }
216
+ }
217
+ }
218
+
107
219
  // MARK: Files of our own
108
220
 
109
221
  /// A new file called `name`, in a folder of its own so two files can share
@@ -208,7 +320,7 @@ enum CraftNativeFiles {
208
320
  .map { $0.doubleValue }
209
321
  .flatMap { $0.isFinite ? $0 : nil } ?? defaultQuality))
210
322
  let source = options["source"] as? String ?? "library"
211
- guard activePicker == nil else {
323
+ guard pickerSlotIsFree() else {
212
324
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
213
325
  return
214
326
  }
@@ -245,6 +357,7 @@ enum CraftNativeFiles {
245
357
  picker.delegate = delegate
246
358
  delegate.controller = picker
247
359
  activePicker = delegate
360
+ watch(picker, for: delegate)
248
361
  if !CraftPresenter.present(picker, from: nil) {
249
362
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the photo picker")))
250
363
  }
@@ -291,7 +404,7 @@ enum CraftNativeFiles {
291
404
  return "\(base).jpg"
292
405
  }
293
406
 
294
- final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate {
407
+ final class LibraryDelegate: NSObject, PHPickerViewControllerDelegate, CraftPickerRequest {
295
408
  let maxPixels: Int
296
409
  let quality: Double
297
410
  let videoLimit: Int?
@@ -353,8 +466,18 @@ enum CraftNativeFiles {
353
466
  }
354
467
  }
355
468
 
469
+ let presence = CraftPickerPresence()
470
+ var isWaiting: Bool { !answered && !finished }
471
+
472
+ func pickerLeft(shown: Bool) {
473
+ guard lostItsPicker else { return }
474
+ answered = true
475
+ finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(videoLimit == nil ? "photo" : "video") picker")))
476
+ }
477
+
356
478
  func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
357
- guard !answered else { return }
479
+ // A picker whose request was already answered still closes.
480
+ guard !answered else { picker.dismiss(animated: true); return }
358
481
  picker.dismiss(animated: true)
359
482
  guard let provider = results.first?.itemProvider else {
360
483
  answered = true
@@ -403,12 +526,13 @@ enum CraftNativeFiles {
403
526
  picker.delegate = delegate
404
527
  delegate.controller = picker
405
528
  activePicker = delegate
529
+ watch(picker, for: delegate)
406
530
  if !CraftPresenter.present(picker, from: nil) {
407
531
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
408
532
  }
409
533
  }
410
534
 
411
- final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
535
+ final class CameraDelegate: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate, CraftPickerRequest {
412
536
  let maxPixels: Int
413
537
  let quality: Double
414
538
  let requestToken: String?
@@ -483,6 +607,15 @@ enum CraftNativeFiles {
483
607
  answered = true
484
608
  finish(.success(NSNull()))
485
609
  }
610
+
611
+ let presence = CraftPickerPresence()
612
+ var isWaiting: Bool { !answered && !finished }
613
+
614
+ func pickerLeft(shown: Bool) {
615
+ guard lostItsPicker else { return }
616
+ answered = true
617
+ finish(shown ? .success(NSNull()) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the camera")))
618
+ }
486
619
  }
487
620
 
488
621
  #endif
@@ -493,7 +626,7 @@ enum CraftNativeFiles {
493
626
  /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
494
627
  static func pickVideo(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
495
628
  guard !isCancelled() else { done(.failure(importCancelled())); return }
496
- guard activePicker == nil else {
629
+ guard pickerSlotIsFree() else {
497
630
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
498
631
  return
499
632
  }
@@ -512,6 +645,7 @@ enum CraftNativeFiles {
512
645
  picker.delegate = delegate
513
646
  delegate.controller = picker
514
647
  activePicker = delegate
648
+ watch(picker, for: delegate)
515
649
  if !CraftPresenter.present(picker, from: nil) {
516
650
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
517
651
  }
@@ -525,6 +659,7 @@ enum CraftNativeFiles {
525
659
  picker.delegate = delegate
526
660
  delegate.controller = picker
527
661
  activePicker = delegate
662
+ watch(picker, for: delegate)
528
663
  if !CraftPresenter.present(picker, from: nil) {
529
664
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
530
665
  }
@@ -538,7 +673,7 @@ enum CraftNativeFiles {
538
673
  /// UTIs, or filename extensions; an empty list means any document.
539
674
  static func pickFile(_ options: [String: Any], requestToken: String? = nil, isCancelled: @escaping () -> Bool = { false }, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
540
675
  guard !isCancelled() else { done(.failure(importCancelled())); return }
541
- guard activePicker == nil else {
676
+ guard pickerSlotIsFree() else {
542
677
  done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
543
678
  return
544
679
  }
@@ -567,6 +702,7 @@ enum CraftNativeFiles {
567
702
  picker.delegate = delegate
568
703
  delegate.controller = picker
569
704
  activePicker = delegate
705
+ watch(picker, for: delegate)
570
706
  if !CraftPresenter.present(picker, from: nil) {
571
707
  delegate.finish(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the file picker")))
572
708
  }
@@ -727,7 +863,7 @@ enum CraftNativeFiles {
727
863
  }
728
864
  }
729
865
 
730
- final class DocumentDelegate: NSObject, UIDocumentPickerDelegate {
866
+ final class DocumentDelegate: NSObject, UIDocumentPickerDelegate, CraftPickerRequest {
731
867
  let limit: Int
732
868
  let video: Bool
733
869
  let requestToken: String?
@@ -768,6 +904,15 @@ enum CraftNativeFiles {
768
904
  finish(.success(video ? NSNull() : []))
769
905
  }
770
906
 
907
+ let presence = CraftPickerPresence()
908
+ var isWaiting: Bool { !answered && !finished }
909
+
910
+ func pickerLeft(shown: Bool) {
911
+ guard lostItsPicker else { return }
912
+ answered = true
913
+ finish(shown ? .success(video ? NSNull() : []) : .failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the \(video ? "video" : "file") picker")))
914
+ }
915
+
771
916
  func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
772
917
  guard !answered else { return }
773
918
  answered = true
@@ -902,8 +1047,15 @@ enum CraftNativeFiles {
902
1047
  guard let text = data as? String else {
903
1048
  return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "files.write needs its contents as a string"))
904
1049
  }
1050
+ // Absent or null is utf8. Anything else that is not one of the two
1051
+ // names is refused, a number included, rather than read as utf8.
1052
+ let encoding: String?
1053
+ switch options["encoding"] {
1054
+ case nil, is NSNull: encoding = "utf8"
1055
+ case let named: encoding = named as? String
1056
+ }
905
1057
  let bytes: Data
906
- switch options["encoding"] as? String ?? "utf8" {
1058
+ switch encoding {
907
1059
  case "utf8":
908
1060
  bytes = Data(text.utf8)
909
1061
  case "base64":