craft-native 0.0.162 → 0.0.164

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.
@@ -40,7 +40,7 @@ enum CraftNativeActions {
40
40
  static func capabilities(config: CraftConfig) -> [String] {
41
41
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
42
42
  // as it arrives) need nothing the app has to declare.
43
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking"]
43
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge"]
44
44
  if config.enableBiometric { values.append("biometric") }
45
45
  if config.enableSecureStorage { values.append("secureStorage") }
46
46
  if config.enableLocalDatabase { values.append("database") }
@@ -68,11 +68,17 @@ enum CraftNativeActions {
68
68
  // A heart-rate strap is the app's own service (CraftHeartRate.swift),
69
69
  // so it needs no web view.
70
70
  if config.enableHeartRate { values.append("heartRate") }
71
+ // Workouts on the paired Apple Watch (CraftScheduledWorkouts.swift),
72
+ // also the app's own service.
73
+ if config.enableScheduledWorkouts { values.append("scheduledWorkouts") }
71
74
  // Photos picked from the library, files downloaded for the share
72
75
  // sheet and sent in a form (CraftNativeFiles.swift): the photo picker
73
76
  // runs out of process and asks no permission. Taking a photo needs
74
77
  // the camera, which the app declares.
75
78
  values.append("files")
79
+ // Video and audio kept on the phone for offline (CraftNativeMedia),
80
+ // which the native Video element plays in place of the address.
81
+ values.append("media")
76
82
  if config.enableCamera { values.append("camera") }
77
83
  return values
78
84
  }
@@ -187,6 +193,19 @@ enum CraftNativeActions {
187
193
  return
188
194
  case ("Files", "remove"):
189
195
  sync = .success(CraftNativeFiles.remove(args.first as? String ?? ""))
196
+ case ("Media", "keep"):
197
+ keepMedia(requestToken: requestToken, args: args, completion: completion)
198
+ return
199
+ case ("Media", "path"):
200
+ sync = .success(CraftNativeMedia.local(args.first as? String ?? "")?.path ?? NSNull())
201
+ case ("Media", "prune"):
202
+ let keep = (args.first as? [Any] ?? []).compactMap { $0 as? String }
203
+ DispatchQueue.global(qos: .utility).async {
204
+ complete(requestToken, .success(CraftNativeMedia.prune(keep: keep)), completion)
205
+ }
206
+ return
207
+ case ("Media", "usage"):
208
+ sync = .success(CraftNativeMedia.usage())
190
209
  case ("Lifecycle", "getState"):
191
210
  sync = .success(currentAppState())
192
211
  case ("DeepLinks", "getInitialURL"):
@@ -338,6 +357,17 @@ enum CraftNativeActions {
338
357
  complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
339
358
  }
340
359
  return
360
+ case ("ScheduledWorkouts", _):
361
+ // Workouts on the paired Apple Watch, the same service as a
362
+ // page's `craft.scheduledWorkouts` (WorkoutKit's scheduler).
363
+ guard config.enableScheduledWorkouts else {
364
+ complete(requestToken, failure("CAPABILITY_DISABLED", "Scheduled workouts are disabled"), completion)
365
+ return
366
+ }
367
+ CraftScheduledWorkouts.shared.perform(method: method, args: args) { result in
368
+ complete(requestToken, result.mapError { CraftNativeActionError(code: $0.code, message: $0.message) }, completion)
369
+ }
370
+ return
341
371
  case ("Linking", "openURL"), ("Linking", "canOpenURL"):
342
372
  // Another app or the system: `webcal:` subscriptions, `mailto:`,
343
373
  // `tel:`, the App Store, this app's page in Settings
@@ -426,6 +456,19 @@ enum CraftNativeActions {
426
456
  complete(requestToken, .success(true), completion)
427
457
  }
428
458
  return
459
+ case ("Badge", "set"):
460
+ guard let value = args.first as? NSNumber,
461
+ value.doubleValue.isFinite,
462
+ value.doubleValue >= 0,
463
+ value.doubleValue.rounded() == value.doubleValue else {
464
+ complete(requestToken, failure("INVALID_ARGUMENT", "Badge.set needs a non-negative whole number"), completion)
465
+ return
466
+ }
467
+ setBadgeCount(value.intValue) { complete(requestToken, $0, completion) }
468
+ return
469
+ case ("Badge", "clear"):
470
+ setBadgeCount(0) { complete(requestToken, $0, completion) }
471
+ return
429
472
  case ("Notifications", _):
430
473
  guard config.enableLocalNotifications else {
431
474
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
@@ -600,6 +643,63 @@ enum CraftNativeActions {
600
643
  if !alreadyCancelled { task.resume() }
601
644
  }
602
645
 
646
+ /// `craft.media.keep`: the file at an address kept on the phone for
647
+ /// offline, fetched once (a copy already there answers at once, and two
648
+ /// screens asking at the same time share one download). A refused answer
649
+ /// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
650
+ private static func keepMedia(
651
+ requestToken: String,
652
+ args: [Any],
653
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
654
+ ) {
655
+ guard let address = args.first as? String,
656
+ let url = URL(string: address),
657
+ let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http",
658
+ url.host?.isEmpty == false else {
659
+ complete(requestToken, failure("INVALID_ARGUMENT", "media.keep needs an absolute http or https URL"), completion)
660
+ return
661
+ }
662
+ if let kept = CraftNativeMedia.local(address) {
663
+ complete(requestToken, .success(CraftNativeMedia.describe(address, kept)), completion)
664
+ return
665
+ }
666
+ let options = args.count > 1 ? args[1] as? [String: Any] ?? [:] : [:]
667
+ var request = URLRequest(url: url, timeoutInterval: craftNativeFetchTimeoutSeconds)
668
+ request.httpMethod = "GET"
669
+ if let headers = options["headers"] as? [String: Any] {
670
+ for (name, value) in headers {
671
+ guard let value = value as? String else {
672
+ complete(requestToken, failure("INVALID_ARGUMENT", "media.keep header values must be strings"), completion)
673
+ return
674
+ }
675
+ request.setValue(value, forHTTPHeaderField: name)
676
+ }
677
+ }
678
+ if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
679
+ let task = fetchSession.downloadTask(with: request) { location, response, error in
680
+ cancellationLock.lock()
681
+ fetchTasks.removeValue(forKey: requestToken)
682
+ cancellationLock.unlock()
683
+ if let error = error {
684
+ let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
685
+ CraftNativeMedia.finish(address, failure(code, error.localizedDescription))
686
+ return
687
+ }
688
+ guard let http = response as? HTTPURLResponse, let location else {
689
+ CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
690
+ return
691
+ }
692
+ // The system deletes `location` once this returns: move it now.
693
+ CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
694
+ }
695
+ cancellationLock.lock()
696
+ let alreadyCancelled = cancelledRequests.contains(requestToken)
697
+ if !alreadyCancelled { fetchTasks[requestToken] = task }
698
+ cancellationLock.unlock()
699
+ if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
700
+ else { task.resume() }
701
+ }
702
+
603
703
  private static func claimInitialDeepLink() -> URL? {
604
704
  cancellationLock.lock()
605
705
  defer { cancellationLock.unlock() }
@@ -906,6 +1006,30 @@ enum CraftNativeActions {
906
1006
  }
907
1007
  }
908
1008
 
1009
+ /// Set the app icon badge without requesting notification authorization.
1010
+ /// Native screens use this same permission-free path as the page bridge.
1011
+ private static func setBadgeCount(
1012
+ _ count: Int,
1013
+ completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
1014
+ ) {
1015
+ DispatchQueue.main.async {
1016
+ if #available(iOS 16.0, *) {
1017
+ UNUserNotificationCenter.current().setBadgeCount(count) { error in
1018
+ DispatchQueue.main.async {
1019
+ if let error {
1020
+ completion(.failure(CraftNativeActionError(code: "NATIVE_CALL_FAILED", message: error.localizedDescription)))
1021
+ } else {
1022
+ completion(.success(true))
1023
+ }
1024
+ }
1025
+ }
1026
+ } else {
1027
+ UIApplication.shared.applicationIconBadgeNumber = count
1028
+ completion(.success(true))
1029
+ }
1030
+ }
1031
+ }
1032
+
909
1033
  static func deepLinkData(_ url: URL) -> [String: Any] {
910
1034
  let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
911
1035
  return [
@@ -1,3 +1,4 @@
1
+ import CryptoKit
1
2
  import Foundation
2
3
  import ImageIO
3
4
  import PhotosUI
@@ -375,3 +376,109 @@ enum CraftNativeFiles {
375
376
  return ["ok": false, "status": response.statusCode, "body": text]
376
377
  }
377
378
  }
379
+
380
+ // `craft.media` on a native screen: video and audio kept on the phone for
381
+ // when there is no signal (a gym's basement, a plane). Not tmp/craft-files,
382
+ // which iOS and the sweep above both empty: Application Support/craft-media,
383
+ // left out of iCloud backups because every file can be fetched again. A file
384
+ // is named by its address's SHA-256, so the native `Video` element finds the
385
+ // copy of the URL it was given and plays it instead, with no lookup in
386
+ // JavaScript. The screen decides what to keep (`keep`) and what to drop
387
+ // (`prune`); nothing is evicted behind its back.
388
+ enum CraftNativeMedia {
389
+ static var root: URL {
390
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
391
+ ?? FileManager.default.temporaryDirectory
392
+ return base.appendingPathComponent("craft-media", isDirectory: true)
393
+ }
394
+
395
+ private static let lock = NSLock()
396
+ /// Addresses being fetched now, so a second `keep` waits for the first.
397
+ private static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
398
+
399
+ /// Where `address`'s copy lives (whether or not it is there yet). The
400
+ /// extension is the address's, because AVPlayer reads the type from it.
401
+ static func file(for address: String) -> URL {
402
+ let digest = SHA256.hash(data: Data(address.utf8)).map { String(format: "%02x", $0) }.joined()
403
+ let ext = URL(string: address)?.pathExtension.lowercased() ?? ""
404
+ let safe = ext.range(of: "^[a-z0-9]{1,5}$", options: .regularExpression) != nil ? ext : "bin"
405
+ return root.appendingPathComponent("\(digest).\(safe)")
406
+ }
407
+
408
+ /// The copy of `address` on this phone, or nil.
409
+ static func local(_ address: String) -> URL? {
410
+ guard !address.isEmpty else { return nil }
411
+ let url = file(for: address)
412
+ return FileManager.default.fileExists(atPath: url.path) ? url : nil
413
+ }
414
+
415
+ static func describe(_ address: String, _ url: URL) -> [String: Any] {
416
+ let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
417
+ return ["ok": true, "url": address, "path": url.path, "size": size]
418
+ }
419
+
420
+ /// Joins a fetch of `address` already under way; true when one was.
421
+ static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
422
+ lock.lock()
423
+ defer { lock.unlock() }
424
+ if inFlight[address] != nil {
425
+ inFlight[address]!.append(done)
426
+ return true
427
+ }
428
+ inFlight[address] = [done]
429
+ return false
430
+ }
431
+
432
+ static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
433
+ lock.lock()
434
+ let waiting = inFlight.removeValue(forKey: address) ?? []
435
+ lock.unlock()
436
+ for done in waiting { done(result) }
437
+ }
438
+
439
+ /// A finished download moved into place (atomically, so a half-written
440
+ /// file is never played), or a refusal with the server's status.
441
+ static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
442
+ guard (200..<300).contains(response.statusCode) else {
443
+ return .success(["ok": false, "url": address, "status": response.statusCode])
444
+ }
445
+ let fm = FileManager.default
446
+ do {
447
+ var folder = root
448
+ if !fm.fileExists(atPath: folder.path) {
449
+ try fm.createDirectory(at: folder, withIntermediateDirectories: true)
450
+ var values = URLResourceValues()
451
+ values.isExcludedFromBackup = true
452
+ try? folder.setResourceValues(values)
453
+ }
454
+ let target = file(for: address)
455
+ let staging = root.appendingPathComponent(UUID().uuidString + ".part")
456
+ try fm.moveItem(at: location, to: staging)
457
+ _ = try? fm.removeItem(at: target)
458
+ try fm.moveItem(at: staging, to: target)
459
+ return .success(describe(address, target))
460
+ }
461
+ catch {
462
+ return .failure(CraftNativeActionError(code: "IO_ERROR", message: "media.keep could not store the file: \(error.localizedDescription)"))
463
+ }
464
+ }
465
+
466
+ /// Removes every copy whose address is not in `keep`; answers how many.
467
+ static func prune(keep: [String]) -> Int {
468
+ let wanted = Set(keep.map { file(for: $0).lastPathComponent })
469
+ let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
470
+ var removed = 0
471
+ for url in files where !wanted.contains(url.lastPathComponent) {
472
+ if (try? FileManager.default.removeItem(at: url)) != nil { removed += 1 }
473
+ }
474
+ return removed
475
+ }
476
+
477
+ /// What the kept copies come to, in bytes, and how many there are.
478
+ static func usage() -> [String: Any] {
479
+ let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey])) ?? []
480
+ let kept = files.filter { $0.pathExtension != "part" }
481
+ let bytes = kept.reduce(0) { $0 + ((try? $1.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0) }
482
+ return ["files": kept.count, "bytes": bytes]
483
+ }
484
+ }
@@ -341,8 +341,19 @@ private final class CraftNativeVideoView: UIView {
341
341
  detachPlayer()
342
342
  sourceURI = uri
343
343
  loops = false
344
- guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
344
+ // The copy `craft.media.keep` put on the phone plays in place of
345
+ // the address, so a kept video plays with no signal.
346
+ let kept = CraftNativeMedia.local(uri)
347
+ guard let url = kept ?? URL(string: uri), !uri.isEmpty, kept != nil || url.scheme != nil else {
345
348
  playerController.player = nil
349
+ // A path with no scheme ("/videos/a.mp4") has nowhere to load
350
+ // from on a native screen. Say so rather than stay black.
351
+ if !uri.isEmpty {
352
+ DispatchQueue.main.async { [weak self] in
353
+ guard let self, self.sourceURI == uri else { return }
354
+ self.fire("onError", ["message": "The video address is not a full URL: \(uri)"])
355
+ }
356
+ }
346
357
  return
347
358
  }
348
359
  let next = AVPlayer(url: url)
@@ -1110,6 +1121,10 @@ private class CraftNativeFlowView: UIStackView {
1110
1121
  }
1111
1122
  if start < items.count { lines.append(start..<items.count) }
1112
1123
  }
1124
+ // Reconciliation reverses the child order for the requested main
1125
+ // axis. A wrapped flex container must keep its lines in cross-axis
1126
+ // order, so restore the groups without changing each line's order.
1127
+ if reverse && wrap && lines.count > 1 { lines.reverse() }
1113
1128
 
1114
1129
  var lineMains: [CGFloat] = []
1115
1130
  var lineCrosses: [CGFloat] = []
@@ -1178,6 +1193,7 @@ private class CraftNativeFlowView: UIStackView {
1178
1193
  case "flex-end": 2
1179
1194
  case "space-between": 3
1180
1195
  case "space-around": 4
1196
+ case "space-evenly": 6
1181
1197
  case "stretch": 5
1182
1198
  default: 0
1183
1199
  }
@@ -1374,6 +1390,9 @@ private class CraftNativeFlowView: UIStackView {
1374
1390
  case "space-around":
1375
1391
  rowLeading = gridFree / CGFloat(rowHeights.count * 2)
1376
1392
  rowBetween += gridFree / CGFloat(rowHeights.count)
1393
+ case "space-evenly":
1394
+ rowLeading = gridFree / CGFloat(rowHeights.count + 1)
1395
+ rowBetween += gridFree / CGFloat(rowHeights.count + 1)
1377
1396
  case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
1378
1397
  default: break
1379
1398
  }
@@ -1918,7 +1937,9 @@ private final class CraftNativeSvgView: UIView {
1918
1937
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
1919
1938
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
1920
1939
  if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
1921
- if let opacity = props["opacity"] as? NSNumber ?? style["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
1940
+ // Computed style wins over the component prop, matching Android
1941
+ // and the browser's cascade when both provide opacity.
1942
+ if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
1922
1943
  if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
1923
1944
  if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
1924
1945
  if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
@@ -2705,7 +2726,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2705
2726
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2706
2727
  /// `craft.secureStorage.set` wrote.
2707
2728
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
2708
- /// rightButtons })`; a right button's tap is the event `navButton`.
2729
+ /// tabBarHidden, contentSwipeBack, tabSwipeEnabled, rightButtons })`; a right button's
2730
+ /// tap is the event `navButton`.
2709
2731
  ///
2710
2732
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
2711
2733
  /// these: each namespace is a property whose setter adds them back to the
@@ -2949,6 +2971,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2949
2971
  private var navigationBarHidden: Bool?
2950
2972
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
2951
2973
  private var tabBarHidden = false
2974
+ /// Whether iOS 26's content-wide back-swipe may begin on this screen.
2975
+ /// Setting `contentSwipeBack: false` leaves the 32-point edge gesture.
2976
+ private(set) var contentSwipeBack = true
2977
+ /// Whether the shell's horizontal tab switch gesture may begin here.
2978
+ /// This is separate so a screen can keep navigation back while owning its
2979
+ /// own horizontal pager or chart gesture.
2980
+ private(set) var tabSwipeEnabled = true
2952
2981
  private var prefersLargeTitle: Bool?
2953
2982
 
2954
2983
  /// Whether this screen asked for a bar of its own through `setOptions`: a
@@ -2984,6 +3013,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2984
3013
  CraftChrome.shared.setHiddenByScreen(hidden)
2985
3014
  }
2986
3015
  }
3016
+ if let contentSwipeBack = options["contentSwipeBack"] as? Bool {
3017
+ self.contentSwipeBack = contentSwipeBack
3018
+ }
3019
+ if let tabSwipeEnabled = options["tabSwipeEnabled"] as? Bool {
3020
+ self.tabSwipeEnabled = tabSwipeEnabled
3021
+ }
2987
3022
  if let hidden = options["hidden"] as? Bool {
2988
3023
  navigationBarHidden = hidden
2989
3024
  if navigationController?.topViewController === self {