@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.
@@ -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 as? NSNumber)?.doubleValue, level.isFinite else {
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"] as? NSNumber)?.doubleValue,
521
- pitch: (options["pitch"] as? NSNumber)?.doubleValue,
522
- preDelay: (options["preDelay"] as? NSNumber)?.doubleValue,
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 as? NSNumber,
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"] as? NSNumber { content.badge = 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"] as? NSNumber)?.doubleValue
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 = min(largestMaxPixels, max(64, (options["maxPixels"] as? NSNumber)?.intValue ?? defaultMaxPixels))
112
- let quality = min(1, max(0.1, (options["quality"] as? NSNumber)?.doubleValue ?? defaultQuality))
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 = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
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) { recycleItem(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],