@craft-native/ios 0.0.247 → 0.0.248
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 +37 -2
- package/templates/CraftHealth.swift +217 -0
- package/templates/CraftHeartRate.swift +6 -3
- package/templates/CraftHybrid.swift +18 -19
- 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 +813 -65
- 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
|
@@ -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],
|