craft-native 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/dist/android/templates/CraftAppLinks.kt.template +28 -0
- package/dist/android/templates/CraftBridge.kt.template +3 -29
- package/dist/android/templates/CraftNativeBackground.kt.template +21 -2
- package/dist/android/templates/CraftNativeCapabilities.kt.template +145 -28
- package/dist/android/templates/CraftNativeFlatList.kt.template +51 -3
- package/dist/android/templates/CraftNativeShell.kt.template +102 -2
- package/dist/android/templates/CraftNativeSounds.kt.template +22 -3
- package/dist/android/templates/MainActivityNative.kt.template +44 -14
- package/dist/api/process.d.ts +23 -28
- package/dist/bridge/core.d.ts +30 -6
- package/dist/browser.js +27 -5
- package/dist/cli.js +39 -19
- package/dist/components/index.d.ts +1 -1
- package/dist/index.cjs +90 -99
- package/dist/index.js +90 -99
- package/dist/ios/templates/CraftApp.swift +25 -1
- package/dist/ios/templates/CraftHybrid.swift +63 -22
- package/dist/ios/templates/CraftNativeActions.swift +47 -3
- package/dist/ios/templates/CraftNativeFiles.swift +160 -8
- package/dist/ios/templates/CraftNativeFlatList.swift +43 -6
- package/dist/ios/templates/CraftNativeScreen.swift +222 -69
- package/dist/ios/templates/CraftPersistentStorage.swift +28 -5
- package/dist/ios/templates/CraftScheduledWorkouts.swift +11 -4
- package/dist/ios/templates/CraftWebAudio.swift +20 -1
- package/dist/package.d.ts +22 -0
- package/package.json +1 -1
|
@@ -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) }
|
|
@@ -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
|
-
|
|
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:
|
|
1708
|
-
: CGRect(x:
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1058
|
+
switch encoding {
|
|
907
1059
|
case "utf8":
|
|
908
1060
|
bytes = Data(text.utf8)
|
|
909
1061
|
case "base64":
|
|
@@ -316,7 +316,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
316
316
|
rows += (pending + columns - 1) / columns
|
|
317
317
|
content = CGFloat(rows) * 44 + CGFloat(max(0, rows - 1)) * flowLayout.minimumLineSpacing
|
|
318
318
|
}
|
|
319
|
-
|
|
319
|
+
// Its rows and its own padding: not the room left for what floats over it.
|
|
320
|
+
return max(0, ceil(content + contentPadding.top + contentPadding.bottom))
|
|
320
321
|
}
|
|
321
322
|
|
|
322
323
|
/// The width a row gets: the list's own width inside its content padding,
|
|
@@ -406,6 +407,24 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
406
407
|
bottom: nonNegative(raw?["paddingBottom"]) ?? vertical,
|
|
407
408
|
right: nonNegative(raw?["paddingRight"]) ?? horizontal
|
|
408
409
|
)
|
|
410
|
+
guard contentPadding != inset else { return }
|
|
411
|
+
contentPadding = inset
|
|
412
|
+
applyContentInsets()
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/// `contentContainerStyle`'s padding, which the list draws as its insets.
|
|
416
|
+
private var contentPadding: UIEdgeInsets = .zero
|
|
417
|
+
|
|
418
|
+
/// How far the compact tab actions reach over the end of this list. The
|
|
419
|
+
/// rows get that much more room at the end, so the last one can be
|
|
420
|
+
/// brought clear of them (#523).
|
|
421
|
+
var floatingCover: CGFloat = 0 {
|
|
422
|
+
didSet { if abs(oldValue - floatingCover) > 0.5 { applyContentInsets() } }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private func applyContentInsets() {
|
|
426
|
+
var inset = contentPadding
|
|
427
|
+
if flowLayout.scrollDirection == .vertical { inset.bottom += floatingCover }
|
|
409
428
|
guard contentInset != inset else { return }
|
|
410
429
|
contentInset = inset
|
|
411
430
|
scrollIndicatorInsets = inset
|
|
@@ -507,11 +526,10 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
507
526
|
let oldItems = itemsByIdentity
|
|
508
527
|
let viewportAnchor = currentViewportAnchor()
|
|
509
528
|
var seen: [String: Int] = [:]
|
|
529
|
+
var taken = Set<String>()
|
|
510
530
|
var next = nodes.enumerated().map { index, node -> Item in
|
|
511
531
|
let base = Self.identity(for: node) ?? "index:\(index)"
|
|
512
|
-
let
|
|
513
|
-
seen[base] = occurrence + 1
|
|
514
|
-
let identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
532
|
+
let identity = Self.uniqueIdentity(base, seen: &seen, taken: &taken)
|
|
515
533
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
516
534
|
let role = props["listRole"] as? String
|
|
517
535
|
let signature = (try? JSONSerialization.data(withJSONObject: node, options: [.sortedKeys])) ?? Data()
|
|
@@ -532,7 +550,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
532
550
|
return dataItemCount - 1
|
|
533
551
|
}
|
|
534
552
|
items = next
|
|
535
|
-
itemsByIdentity = Dictionary(
|
|
553
|
+
itemsByIdentity = Dictionary(next.map { ($0.identity, $0) }, uniquingKeysWith: { first, _ in first })
|
|
536
554
|
|
|
537
555
|
flowLayout.scrollDirection = horizontal ? .horizontal : .vertical
|
|
538
556
|
alwaysBounceHorizontal = horizontal
|
|
@@ -680,7 +698,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
680
698
|
let lastDataOrdinal = dataOrdinals[lastVisible]
|
|
681
699
|
guard lastDataOrdinal >= 0 else { return }
|
|
682
700
|
let remaining = dataItemCount - lastDataOrdinal - 1
|
|
683
|
-
let thresholdItems = max(1,
|
|
701
|
+
let thresholdItems = max(1, craftNativeInt(ceil(Double(dataItemCount) * endReachedThreshold)))
|
|
684
702
|
guard remaining <= thresholdItems else { return }
|
|
685
703
|
let signature = dataContentSignature
|
|
686
704
|
guard endReachedSignature != signature else { return }
|
|
@@ -864,6 +882,25 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
864
882
|
setContentOffset(clamped(offset), animated: false)
|
|
865
883
|
}
|
|
866
884
|
|
|
885
|
+
/// A row's identity, its own in this list.
|
|
886
|
+
///
|
|
887
|
+
/// Rows that share a key are told apart by a suffix: `run`, then `run#1`.
|
|
888
|
+
/// A row whose own key was `run#1` then had the identity of the second
|
|
889
|
+
/// `run`, and the list's table and its snapshot both trap on a repeated
|
|
890
|
+
/// identity: three tags keyed `run`, `run` and `run#1` ended the process.
|
|
891
|
+
/// The suffix counts on until the identity is one no row has taken. A
|
|
892
|
+
/// list with no such key has the identities it always had.
|
|
893
|
+
static func uniqueIdentity(_ base: String, seen: inout [String: Int], taken: inout Set<String>) -> String {
|
|
894
|
+
var occurrence = seen[base, default: 0]
|
|
895
|
+
var identity = occurrence == 0 ? base : "\(base)#\(occurrence)"
|
|
896
|
+
while !taken.insert(identity).inserted {
|
|
897
|
+
occurrence += 1
|
|
898
|
+
identity = "\(base)#\(occurrence)"
|
|
899
|
+
}
|
|
900
|
+
seen[base] = occurrence + 1
|
|
901
|
+
return identity
|
|
902
|
+
}
|
|
903
|
+
|
|
867
904
|
private static func identity(for node: [String: Any]) -> String? {
|
|
868
905
|
let props = node["props"] as? [String: Any] ?? [:]
|
|
869
906
|
return [node["key"], props["key"], props["testID"], node["id"]]
|