@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 +1 -1
- package/templates/CraftApp.swift +8 -1
- package/templates/CraftHybrid.swift +47 -22
- package/templates/CraftNativeActions.swift +47 -3
- package/templates/CraftNativeFiles.swift +8 -1
- package/templates/CraftNativeScreen.swift +46 -11
- package/templates/CraftPersistentStorage.swift +28 -5
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
///
|
|
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 ?
|
|
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
|
|
1643
|
-
///
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
1708
|
-
: CGRect(x:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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)
|
|
4804
|
-
///
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
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
|
}
|