@craft-native/ios 0.0.247 → 0.0.249
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/README.md +1 -0
- package/dist/cli.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +54 -53
- package/package.json +1 -1
- package/templates/CraftApp.swift +44 -3
- package/templates/CraftHealth.swift +217 -0
- package/templates/CraftHeartRate.swift +6 -3
- package/templates/CraftHybrid.swift +30 -22
- package/templates/CraftLiveActivities.swift +1 -1
- package/templates/CraftNativeActions.swift +87 -8
- package/templates/CraftNativeFiles.swift +15 -3
- package/templates/CraftNativeFlatList.swift +18 -1
- package/templates/CraftNativeMutation.swift +36 -0
- package/templates/CraftNativeScreen.swift +826 -69
- package/templates/CraftNativeServices.swift +4 -2
- package/templates/CraftNativeUI.swift +10 -2
- package/templates/testing/DriverTests.swift.template +1 -0
- package/templates/testing/HostApp.swift +9 -2
|
@@ -264,8 +264,8 @@ protocol CraftHybridScreenEvents: AnyObject {
|
|
|
264
264
|
func nativeScreenDidRender(_ screen: CraftNativeScreenController)
|
|
265
265
|
/// The screen could not draw its first frame: no bundle, or its script threw.
|
|
266
266
|
func nativeScreen(_ screen: CraftNativeScreenController, didFail message: String)
|
|
267
|
-
/// `craft.navigation.open(path)`.
|
|
268
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String)
|
|
267
|
+
/// `craft.navigation.open(path, props)`.
|
|
268
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any])
|
|
269
269
|
/// `craft.navigation.replace(path)`: finish the current flow atomically.
|
|
270
270
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String)
|
|
271
271
|
}
|
|
@@ -446,11 +446,11 @@ final class CraftHybrid: NSObject {
|
|
|
446
446
|
/// A path or a web address opened by the app script or a single-stack
|
|
447
447
|
/// screen: the shell opens it (holding it until the shell exists); an app
|
|
448
448
|
/// with no shell opens a web address in the in-app browser.
|
|
449
|
-
func open(_ path: String) {
|
|
449
|
+
func open(_ path: String, props: [String: Any] = [:]) {
|
|
450
450
|
if let controller {
|
|
451
|
-
controller.open(path)
|
|
451
|
+
controller.open(path, props: props)
|
|
452
452
|
} else if isEnabled {
|
|
453
|
-
untilShell.append { $0.open(path) }
|
|
453
|
+
untilShell.append { $0.open(path, props: props) }
|
|
454
454
|
} else {
|
|
455
455
|
openOutside(path)
|
|
456
456
|
}
|
|
@@ -976,8 +976,8 @@ extension CraftHybrid: CraftHybridScreenEvents {
|
|
|
976
976
|
DispatchQueue.main.async { self.startAppScript() }
|
|
977
977
|
}
|
|
978
978
|
|
|
979
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
980
|
-
open(path)
|
|
979
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
|
|
980
|
+
open(path, props: props)
|
|
981
981
|
}
|
|
982
982
|
|
|
983
983
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
@@ -1249,8 +1249,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1249
1249
|
// create a second web root here: the shared WebView cannot draw
|
|
1250
1250
|
// both pages during the same gesture.
|
|
1251
1251
|
guard hybrid.routes.match(id) != nil else { return false }
|
|
1252
|
-
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
|
|
1253
|
-
(target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1252
|
+
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id, hidden: true) }
|
|
1254
1253
|
}
|
|
1255
1254
|
guard current.view !== target.view,
|
|
1256
1255
|
!(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
|
|
@@ -1546,6 +1545,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1546
1545
|
|
|
1547
1546
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1548
1547
|
|
|
1548
|
+
/// A swipe preview can be visible without owning navigation or focus.
|
|
1549
|
+
/// UIKit appearance callbacks must follow the committed native stack.
|
|
1550
|
+
func isFocusedScreen(_ screen: CraftNativeScreenController) -> Bool {
|
|
1551
|
+
visibleStack?.topViewController === screen
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1549
1554
|
var webIsVisible: Bool {
|
|
1550
1555
|
guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
|
|
1551
1556
|
return webHost.view.superview === slot.view
|
|
@@ -1557,7 +1562,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1557
1562
|
/// the view hierarchy, so its screen never takes the shell's chrome (the
|
|
1558
1563
|
/// bar, the tab accessory) from the one on screen.
|
|
1559
1564
|
private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
|
|
1560
|
-
let
|
|
1565
|
+
let root = entry(for: rootPath, isTabRoot: true, depth: nil)
|
|
1566
|
+
// Constructing the navigation controller can load the root's view and
|
|
1567
|
+
// mount its script immediately. Set its initial focus before that.
|
|
1568
|
+
if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
|
|
1569
|
+
let nav = UINavigationController(rootViewController: root)
|
|
1561
1570
|
nav.delegate = self
|
|
1562
1571
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1563
1572
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -1585,11 +1594,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1585
1594
|
}
|
|
1586
1595
|
|
|
1587
1596
|
/// The entry for a path: its native screen, or a web entry.
|
|
1588
|
-
private func entry(for path: String, isTabRoot: Bool, depth: Int?,
|
|
1597
|
+
private func entry(for path: String, isTabRoot: Bool, depth: Int?, props: [String: Any] = [:]) -> UIViewController {
|
|
1589
1598
|
if hybrid.isEnabled, let match = hybrid.routes.match(path) {
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
|
|
1599
|
+
let params = self.params(for: path, match)
|
|
1600
|
+
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params, routeProps: props)
|
|
1593
1601
|
screen.hybridEvents = self
|
|
1594
1602
|
nativePaths[ObjectIdentifier(screen)] = match.path
|
|
1595
1603
|
return screen
|
|
@@ -1734,7 +1742,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1734
1742
|
/// an app with no web view, a path no screen draws opens in the in-app
|
|
1735
1743
|
/// browser on the app's site. A web address on the app's site is its
|
|
1736
1744
|
/// path; one anywhere else opens in the in-app browser.
|
|
1737
|
-
func open(_ raw: String) {
|
|
1745
|
+
func open(_ raw: String, props: [String: Any] = [:]) {
|
|
1738
1746
|
if let url = URL(string: raw), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" {
|
|
1739
1747
|
guard CraftHybridRoutes.isSite(url, site: hybrid.siteURL) else {
|
|
1740
1748
|
hybrid.openOutside(raw)
|
|
@@ -1749,7 +1757,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1749
1757
|
return
|
|
1750
1758
|
}
|
|
1751
1759
|
if hybrid.routes.match(raw) != nil {
|
|
1752
|
-
showNative(path: raw, replace: false)
|
|
1760
|
+
showNative(path: raw, replace: false, props: props)
|
|
1753
1761
|
return
|
|
1754
1762
|
}
|
|
1755
1763
|
if hybrid.hasWeb {
|
|
@@ -1769,7 +1777,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1769
1777
|
|
|
1770
1778
|
/// The native screen for `raw`: the one already in the stack (popping to
|
|
1771
1779
|
/// it), a tab's root, or a new one pushed.
|
|
1772
|
-
func showNative(path raw: String, replace: Bool, fromLink: Bool = false) {
|
|
1780
|
+
func showNative(path raw: String, replace: Bool, fromLink: Bool = false, props: [String: Any] = [:]) {
|
|
1773
1781
|
guard let match = hybrid.routes.match(raw) else { return }
|
|
1774
1782
|
interruptTabSwipe(restoringSelection: true)
|
|
1775
1783
|
let tabs = hybrid.tabIds
|
|
@@ -1785,7 +1793,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1785
1793
|
nav.popToRootViewController(animated: !fromLink)
|
|
1786
1794
|
}
|
|
1787
1795
|
// `/m/store?coach=5`: the tab's own screen, with the query.
|
|
1788
|
-
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1796
|
+
(stacks[tab]?.viewControllers.first as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1789
1797
|
return
|
|
1790
1798
|
}
|
|
1791
1799
|
if fromLink, let owner = CraftHybridRoutes.tab(owning: match.path, among: tabs), owner != currentTab {
|
|
@@ -1796,10 +1804,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1796
1804
|
if existing !== nav.topViewController { nav.popToViewController(existing, animated: true) }
|
|
1797
1805
|
// Opened again, perhaps with another query (`?person=8`): the
|
|
1798
1806
|
// screen hears the params it was opened with this time.
|
|
1799
|
-
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match))
|
|
1807
|
+
(existing as? CraftNativeScreenController)?.receiveRouteParams(params(for: raw, match), props: props)
|
|
1800
1808
|
return
|
|
1801
1809
|
}
|
|
1802
|
-
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1810
|
+
let screen = entry(for: raw, isTabRoot: false, depth: nil, props: props)
|
|
1803
1811
|
let show = { [weak nav] in
|
|
1804
1812
|
guard let nav else { return }
|
|
1805
1813
|
if replace, !nav.viewControllers.isEmpty {
|
|
@@ -2175,8 +2183,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
2175
2183
|
}
|
|
2176
2184
|
}
|
|
2177
2185
|
|
|
2178
|
-
func nativeScreen(_ screen: CraftNativeScreenController, open path: String) {
|
|
2179
|
-
open(path)
|
|
2186
|
+
func nativeScreen(_ screen: CraftNativeScreenController, open path: String, props: [String: Any]) {
|
|
2187
|
+
open(path, props: props)
|
|
2180
2188
|
}
|
|
2181
2189
|
|
|
2182
2190
|
func nativeScreen(_ screen: CraftNativeScreenController, replace path: String) {
|
|
@@ -70,7 +70,7 @@ enum CraftLiveActivities {
|
|
|
70
70
|
|
|
71
71
|
private static func finite(_ body: [String: Any], _ key: String, _ fallback: Double? = nil) -> Double? {
|
|
72
72
|
guard body.keys.contains(key) else { return fallback }
|
|
73
|
-
guard let number = body[key]
|
|
73
|
+
guard let number = craftNativeNumber(body[key]), number.doubleValue.isFinite else { return nil }
|
|
74
74
|
return max(0, number.doubleValue)
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import Foundation
|
|
2
2
|
import AuthenticationServices
|
|
3
3
|
import LocalAuthentication
|
|
4
|
+
import PassKit
|
|
4
5
|
import SQLite3
|
|
5
6
|
import Security
|
|
6
7
|
import UIKit
|
|
@@ -41,9 +42,10 @@ enum CraftNativeActions {
|
|
|
41
42
|
static func capabilities(config: CraftConfig) -> [String] {
|
|
42
43
|
// `speech`, `audio` (background sound) and `eventStream` (a fetch read
|
|
43
44
|
// as it arrives) need nothing the app has to declare.
|
|
44
|
-
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation"]
|
|
45
|
+
var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
|
|
45
46
|
values.append("storageCompareAndSet")
|
|
46
47
|
values.append("storageSetIfAbsent")
|
|
48
|
+
values.append("viewScroll")
|
|
47
49
|
values.append("crypto")
|
|
48
50
|
if config.enableBiometric { values.append("biometric") }
|
|
49
51
|
if config.enableSecureStorage { values.append("secureStorage") }
|
|
@@ -51,6 +53,7 @@ enum CraftNativeActions {
|
|
|
51
53
|
if config.enableDeepLinks { values.append("deepLinks") }
|
|
52
54
|
if config.enableLocalNotifications { values.append("notifications") }
|
|
53
55
|
if config.enableHealthKit { values.append("health") }
|
|
56
|
+
if config.enableWallet { values.append("wallet") }
|
|
54
57
|
if config.enableLiveActivities { values.append("liveActivities") }
|
|
55
58
|
// The in-app browser and the system's web sign-in sheet (CraftBrowser).
|
|
56
59
|
values.append("browser")
|
|
@@ -375,6 +378,44 @@ enum CraftNativeActions {
|
|
|
375
378
|
complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
|
|
376
379
|
}
|
|
377
380
|
return
|
|
381
|
+
case ("Wallet", "isAvailable"):
|
|
382
|
+
sync = config.enableWallet ? .success(PKPassLibrary.isPassLibraryAvailable()) : failure("CAPABILITY_DISABLED", "Wallet is disabled")
|
|
383
|
+
case ("Wallet", "has"):
|
|
384
|
+
guard config.enableWallet else {
|
|
385
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Wallet is disabled"), completion)
|
|
386
|
+
return
|
|
387
|
+
}
|
|
388
|
+
guard let serialNumber = args.first as? String, !serialNumber.isEmpty else {
|
|
389
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.has needs a serial number"), completion)
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
sync = .success(PKPassLibrary().passes().contains { $0.serialNumber == serialNumber })
|
|
393
|
+
case ("Wallet", "add"):
|
|
394
|
+
guard config.enableWallet else {
|
|
395
|
+
complete(requestToken, failure("CAPABILITY_DISABLED", "Wallet is disabled"), completion)
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
let options = args.first as? [String: Any] ?? [:]
|
|
399
|
+
if let encoded = options["data"] as? String {
|
|
400
|
+
guard let data = Data(base64Encoded: encoded), let pass = try? PKPass(data: data) else {
|
|
401
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.add needs a valid base64 pkpass"), completion)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
presentWalletPass(pass, requestToken: requestToken, completion: completion)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
guard let address = options["url"] as? String, let url = URL(string: address), url.scheme == "https" else {
|
|
408
|
+
complete(requestToken, failure("INVALID_ARGUMENT", "Wallet.add needs base64 data or an https URL"), completion)
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
URLSession.shared.dataTask(with: url) { data, _, error in
|
|
412
|
+
guard let data, error == nil, let pass = try? PKPass(data: data) else {
|
|
413
|
+
complete(requestToken, failure("WALLET_PASS_INVALID", "The Wallet pass could not be downloaded or parsed"), completion)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
DispatchQueue.main.async { self.presentWalletPass(pass, requestToken: requestToken, completion: completion) }
|
|
417
|
+
}.resume()
|
|
418
|
+
return
|
|
378
419
|
case ("KeepAwake", "set"):
|
|
379
420
|
guard config.enableKeepAwake else {
|
|
380
421
|
complete(requestToken, failure("CAPABILITY_DISABLED", "Keep awake is disabled"), completion)
|
|
@@ -392,7 +433,7 @@ enum CraftNativeActions {
|
|
|
392
433
|
case ("Brightness", "set"):
|
|
393
434
|
// The device's backlight, system-wide: a screen that dims it reads
|
|
394
435
|
// it first and puts it back, as @stacksjs/mobile's brightness does.
|
|
395
|
-
guard let level = (args.first
|
|
436
|
+
guard let level = craftNativeNumber(args.first)?.doubleValue, level.isFinite else {
|
|
396
437
|
complete(requestToken, failure("INVALID_ARGUMENT", "Brightness.set needs a level from 0 to 1"), completion)
|
|
397
438
|
return
|
|
398
439
|
}
|
|
@@ -517,9 +558,9 @@ enum CraftNativeActions {
|
|
|
517
558
|
CraftNativeSpeech.shared.speak(
|
|
518
559
|
text,
|
|
519
560
|
language: options["language"] as? String,
|
|
520
|
-
rate: (options["rate"]
|
|
521
|
-
pitch: (options["pitch"]
|
|
522
|
-
preDelay: (options["preDelay"]
|
|
561
|
+
rate: craftNativeNumber(options["rate"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
|
|
562
|
+
pitch: craftNativeNumber(options["pitch"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
|
|
563
|
+
preDelay: craftNativeNumber(options["preDelay"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil },
|
|
523
564
|
interrupt: options["interrupt"] as? Bool ?? true
|
|
524
565
|
) { spoken in complete(requestToken, .success(spoken), completion) }
|
|
525
566
|
}
|
|
@@ -570,7 +611,7 @@ enum CraftNativeActions {
|
|
|
570
611
|
}
|
|
571
612
|
return
|
|
572
613
|
case ("Badge", "set"):
|
|
573
|
-
guard let value = args.first
|
|
614
|
+
guard let value = craftNativeNumber(args.first),
|
|
574
615
|
value.doubleValue.isFinite,
|
|
575
616
|
value.doubleValue >= 0,
|
|
576
617
|
value.doubleValue.rounded() == value.doubleValue else {
|
|
@@ -595,6 +636,27 @@ enum CraftNativeActions {
|
|
|
595
636
|
complete(requestToken, sync ?? failure("UNKNOWN_ACTION", "Unsupported native API"), completion)
|
|
596
637
|
}
|
|
597
638
|
|
|
639
|
+
private static var walletPresenters: [String: CraftWalletPresenter] = [:]
|
|
640
|
+
|
|
641
|
+
private static func presentWalletPass(
|
|
642
|
+
_ pass: PKPass,
|
|
643
|
+
requestToken: String,
|
|
644
|
+
completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
|
|
645
|
+
) {
|
|
646
|
+
DispatchQueue.main.async {
|
|
647
|
+
let presenter = CraftWalletPresenter { result in
|
|
648
|
+
walletPresenters.removeValue(forKey: requestToken)
|
|
649
|
+
completion(result)
|
|
650
|
+
}
|
|
651
|
+
guard let controller = PKAddPassesViewController(pass: pass), CraftPresenter.present(controller, from: nil) else {
|
|
652
|
+
completion(failure("PRESENTATION_FAILED", "Wallet could not present its add-pass sheet"))
|
|
653
|
+
return
|
|
654
|
+
}
|
|
655
|
+
controller.delegate = presenter
|
|
656
|
+
walletPresenters[requestToken] = presenter
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
598
660
|
static func cancel(requestToken: String) {
|
|
599
661
|
cancellationLock.lock()
|
|
600
662
|
cancelledRequests.insert(requestToken)
|
|
@@ -1117,9 +1179,14 @@ enum CraftNativeActions {
|
|
|
1117
1179
|
content.body = value["body"] as? String ?? ""
|
|
1118
1180
|
content.sound = .default
|
|
1119
1181
|
if let data = value["data"] as? [String: Any] { content.userInfo = data }
|
|
1120
|
-
if let badge = value["badge"]
|
|
1182
|
+
if let badge = craftNativeNumber(value["badge"]),
|
|
1183
|
+
badge.doubleValue.isFinite,
|
|
1184
|
+
badge.doubleValue >= 0,
|
|
1185
|
+
badge.doubleValue.rounded() == badge.doubleValue {
|
|
1186
|
+
content.badge = badge
|
|
1187
|
+
}
|
|
1121
1188
|
let id = (value["id"] as? String).flatMap { validKey($0) } ?? UUID().uuidString
|
|
1122
|
-
let delay = (value["delay"]
|
|
1189
|
+
let delay = craftNativeNumber(value["delay"]).map { $0.doubleValue }.flatMap { $0.isFinite ? $0 : nil }
|
|
1123
1190
|
let trigger = delay.map { UNTimeIntervalNotificationTrigger(timeInterval: max($0 / 1_000, 1), repeats: false) }
|
|
1124
1191
|
center.add(UNNotificationRequest(identifier: id, content: content, trigger: trigger)) { addError in
|
|
1125
1192
|
if let addError = addError {
|
|
@@ -1236,6 +1303,18 @@ enum CraftNativeActions {
|
|
|
1236
1303
|
}
|
|
1237
1304
|
}
|
|
1238
1305
|
|
|
1306
|
+
private final class CraftWalletPresenter: NSObject, PKAddPassesViewControllerDelegate {
|
|
1307
|
+
private let completion: (Result<Any, CraftNativeActionError>) -> Void
|
|
1308
|
+
|
|
1309
|
+
init(completion: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
1310
|
+
self.completion = completion
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
func addPassesViewControllerDidFinish(_ controller: PKAddPassesViewController) {
|
|
1314
|
+
controller.dismiss(animated: true) { [completion] in completion(.success(true)) }
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1239
1318
|
/// The native WebAuthn half for screens without a browser context. The
|
|
1240
1319
|
/// server still owns challenges and verifies the returned client data; this
|
|
1241
1320
|
/// class only translates WebAuthn options into Apple's passkey sheet and
|
|
@@ -108,8 +108,15 @@ enum CraftNativeFiles {
|
|
|
108
108
|
/// (no location) left in it. Answers the file, or null when the person
|
|
109
109
|
/// closed the picker. Main thread.
|
|
110
110
|
static func pickImage(_ options: [String: Any], cameraEnabled: Bool, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
|
|
111
|
-
let maxPixels =
|
|
112
|
-
|
|
111
|
+
let maxPixels = craftNativeNumber(options["maxPixels"])
|
|
112
|
+
.map { $0.doubleValue }
|
|
113
|
+
.flatMap { value in
|
|
114
|
+
guard value.isFinite else { return nil }
|
|
115
|
+
return Int(max(64, min(Double(largestMaxPixels), value)))
|
|
116
|
+
} ?? defaultMaxPixels
|
|
117
|
+
let quality = min(1, max(0.1, craftNativeNumber(options["quality"])
|
|
118
|
+
.map { $0.doubleValue }
|
|
119
|
+
.flatMap { $0.isFinite ? $0 : nil } ?? defaultQuality))
|
|
113
120
|
let source = options["source"] as? String ?? "library"
|
|
114
121
|
guard activePicker == nil else {
|
|
115
122
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
@@ -284,7 +291,12 @@ enum CraftNativeFiles {
|
|
|
284
291
|
done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
|
|
285
292
|
return
|
|
286
293
|
}
|
|
287
|
-
let limit =
|
|
294
|
+
let limit = craftNativeNumber(options["maxBytes"])
|
|
295
|
+
.map { $0.doubleValue }
|
|
296
|
+
.flatMap { value in
|
|
297
|
+
guard value.isFinite else { return nil }
|
|
298
|
+
return Int(max(1, min(Double(512 * 1024 * 1024), value)))
|
|
299
|
+
} ?? 512 * 1024 * 1024
|
|
288
300
|
let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
|
|
289
301
|
DispatchQueue.main.async { activePicker = nil; done(result) }
|
|
290
302
|
}
|
|
@@ -145,6 +145,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
145
145
|
private var itemsByIdentity: [String: Item] = [:]
|
|
146
146
|
private var renderItem: RenderItem?
|
|
147
147
|
private var recycleItem: ((String) -> Void)?
|
|
148
|
+
private var removeItem: ((String) -> Void)?
|
|
148
149
|
private var endReached: (() -> Void)?
|
|
149
150
|
var onScrollEvent: ((UIScrollView) -> Void)?
|
|
150
151
|
/// Called as the visible rows move, for a screen that lays out around them.
|
|
@@ -408,6 +409,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
408
409
|
endReachedThreshold: Double,
|
|
409
410
|
renderItem: @escaping RenderItem,
|
|
410
411
|
recycleItem: @escaping (String) -> Void,
|
|
412
|
+
removeItem: @escaping (String) -> Void,
|
|
411
413
|
endReached: (() -> Void)?
|
|
412
414
|
) {
|
|
413
415
|
if isApplyingSnapshot {
|
|
@@ -420,6 +422,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
420
422
|
endReachedThreshold: endReachedThreshold,
|
|
421
423
|
renderItem: renderItem,
|
|
422
424
|
recycleItem: recycleItem,
|
|
425
|
+
removeItem: removeItem,
|
|
423
426
|
endReached: endReached
|
|
424
427
|
)
|
|
425
428
|
}
|
|
@@ -429,6 +432,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
429
432
|
|
|
430
433
|
self.renderItem = renderItem
|
|
431
434
|
self.recycleItem = recycleItem
|
|
435
|
+
self.removeItem = removeItem
|
|
432
436
|
self.endReached = endReached
|
|
433
437
|
self.endReachedThreshold = max(0, endReachedThreshold)
|
|
434
438
|
self.columns = max(1, columns)
|
|
@@ -459,7 +463,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
459
463
|
flowLayout.invalidateLayout()
|
|
460
464
|
|
|
461
465
|
let nextIds = Set(itemsByIdentity.keys)
|
|
462
|
-
for identity in oldItems.keys where !nextIds.contains(identity) {
|
|
466
|
+
for identity in oldItems.keys where !nextIds.contains(identity) { removeItem(identity) }
|
|
463
467
|
let changed = next.compactMap { item -> String? in
|
|
464
468
|
guard let old = oldItems[item.identity], old.signature != item.signature else { return nil }
|
|
465
469
|
return item.identity
|
|
@@ -508,6 +512,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
508
512
|
itemsByIdentity = [:]
|
|
509
513
|
renderItem = nil
|
|
510
514
|
recycleItem = nil
|
|
515
|
+
removeItem = nil
|
|
511
516
|
endReached = nil
|
|
512
517
|
dataContentSignature = ""
|
|
513
518
|
onScrollEvent = nil
|
|
@@ -521,6 +526,18 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
521
526
|
pendingScrollTarget = nil
|
|
522
527
|
}
|
|
523
528
|
|
|
529
|
+
/// Releases callbacks and UIKit ownership that belong to a permanently
|
|
530
|
+
/// removed list node. Recycled rows intentionally use `discardAll()` only:
|
|
531
|
+
/// the same list can be rebound during the next reconciliation.
|
|
532
|
+
func resetForTeardown() {
|
|
533
|
+
setRefreshHandler(nil, refreshing: false)
|
|
534
|
+
discardAll()
|
|
535
|
+
onFittingHeightChanged = nil
|
|
536
|
+
delegate = nil
|
|
537
|
+
nativeRefreshControl.removeTarget(self, action: nil, for: .allEvents)
|
|
538
|
+
removeGestureRecognizer(keyboardTapGesture)
|
|
539
|
+
}
|
|
540
|
+
|
|
524
541
|
func refreshThemeDefaults() {
|
|
525
542
|
if isApplyingSnapshot {
|
|
526
543
|
pendingThemeRefresh = true
|
|
@@ -108,6 +108,13 @@ final class CraftNativeMutationDocument {
|
|
|
108
108
|
case "moveChild":
|
|
109
109
|
try moveChild(operation, nodes: &candidateNodes)
|
|
110
110
|
if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
|
|
111
|
+
case "reparentChild":
|
|
112
|
+
let previousParent = try reparentChild(operation, nodes: &candidateNodes)
|
|
113
|
+
if let id = operation["parentId"] as? String { appendUnique(id, to: &affectedNodeIds) }
|
|
114
|
+
appendUnique(previousParent, to: &affectedNodeIds)
|
|
115
|
+
// A child crossing parents needs the committed tree to be
|
|
116
|
+
// reconciled so the host can move its existing native view.
|
|
117
|
+
requiresFullRender = true
|
|
111
118
|
case "removeNode":
|
|
112
119
|
if let id = operation["id"] as? String, let parent = candidateNodes[id]?.parent {
|
|
113
120
|
appendUnique(parent, to: &affectedNodeIds)
|
|
@@ -251,6 +258,35 @@ final class CraftNativeMutationDocument {
|
|
|
251
258
|
nodes[parentId] = parent
|
|
252
259
|
}
|
|
253
260
|
|
|
261
|
+
private func reparentChild(_ operation: [String: Any], nodes: inout [String: Node]) throws -> String {
|
|
262
|
+
let parentId = try requiredId(operation, field: "parentId", name: "reparentChild")
|
|
263
|
+
let childId = try requiredId(operation, field: "childId", name: "reparentChild")
|
|
264
|
+
guard var parent = nodes[parentId] else {
|
|
265
|
+
throw CraftNativeMutationFailure("UNKNOWN_PARENT", "node \(parentId) does not exist")
|
|
266
|
+
}
|
|
267
|
+
guard var child = nodes[childId], let previousParentId = child.parent,
|
|
268
|
+
var previousParent = nodes[previousParentId] else {
|
|
269
|
+
throw CraftNativeMutationFailure("NOT_ATTACHED", "node \(childId) is not attached to a parent")
|
|
270
|
+
}
|
|
271
|
+
guard acceptsChildren(parent.type) else {
|
|
272
|
+
throw CraftNativeMutationFailure("INVALID_PARENT", "node \(parentId) cannot contain child nodes")
|
|
273
|
+
}
|
|
274
|
+
guard parentId != childId, !descendants(of: childId, nodes: nodes).contains(parentId) else {
|
|
275
|
+
throw CraftNativeMutationFailure("CYCLE", "reparenting node \(childId) would create a cycle")
|
|
276
|
+
}
|
|
277
|
+
guard parentId != previousParentId else {
|
|
278
|
+
throw CraftNativeMutationFailure("SAME_PARENT", "node \(childId) is already a child of \(parentId)")
|
|
279
|
+
}
|
|
280
|
+
let index = try insertionIndex(operation, count: parent.children.count)
|
|
281
|
+
previousParent.children.removeAll { $0 == childId }
|
|
282
|
+
parent.children.insert(childId, at: index)
|
|
283
|
+
child.parent = parentId
|
|
284
|
+
nodes[previousParentId] = previousParent
|
|
285
|
+
nodes[parentId] = parent
|
|
286
|
+
nodes[childId] = child
|
|
287
|
+
return previousParentId
|
|
288
|
+
}
|
|
289
|
+
|
|
254
290
|
private func removeNode(
|
|
255
291
|
_ operation: [String: Any],
|
|
256
292
|
nodes: inout [String: Node],
|