craft-native 0.0.163 → 0.0.166

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.
@@ -1004,6 +1004,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1004
1004
  private var openingWeb = false
1005
1005
  private var firstFrameLogged = false
1006
1006
  private var tabBarHeight: CGFloat = 0
1007
+ private var tabSwipeRecognizers: [UIGestureRecognizer] = []
1008
+ private var activeTabSwipeTarget: String?
1007
1009
 
1008
1010
  init(config: CraftConfig) {
1009
1011
  self.config = config
@@ -1033,6 +1035,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1033
1035
  let tabs = hybrid.tabIds
1034
1036
  let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
1035
1037
  show(tab: start, rootPath: hybrid.startPath)
1038
+ installTabSwipeGestures()
1036
1039
  if hybrid.hasWeb && hybrid.startsNative {
1037
1040
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
1038
1041
  }
@@ -1043,6 +1046,151 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1043
1046
 
1044
1047
  private var webAttached = false
1045
1048
 
1049
+ private func installTabSwipeGestures() {
1050
+ guard tabSwipeRecognizers.isEmpty else { return }
1051
+ let recognizer = UIPanGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
1052
+ recognizer.delegate = self
1053
+ recognizer.cancelsTouchesInView = false
1054
+ recognizer.delaysTouchesBegan = false
1055
+ view.addGestureRecognizer(recognizer)
1056
+ tabSwipeRecognizers.append(recognizer)
1057
+ }
1058
+
1059
+ @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1060
+ let tabs = hybrid.tabIds
1061
+ guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
1062
+ let translation = recognizer.translation(in: view).x
1063
+ let velocity = recognizer.velocity(in: view).x
1064
+ switch recognizer.state {
1065
+ case .changed:
1066
+ if activeTabSwipeTarget == nil {
1067
+ let direction = abs(translation) > 1 ? translation : velocity
1068
+ guard abs(direction) > 1 else { return }
1069
+ let next = direction < 0 ? index + 1 : index - 1
1070
+ guard tabs.indices.contains(next), beginInteractiveTabSwipe(to: tabs[next], direction: direction < 0 ? -1 : 1) else { return }
1071
+ activeTabSwipeTarget = tabs[next]
1072
+ }
1073
+ updateInteractiveTabSwipe(translation: translation)
1074
+ case .ended, .cancelled, .failed:
1075
+ guard let target = activeTabSwipeTarget else {
1076
+ guard recognizer.state == .ended else { return }
1077
+ let delta = abs(translation) >= 32 ? translation : velocity
1078
+ guard abs(delta) >= 32 else { return }
1079
+ let next = delta < 0 ? index + 1 : index - 1
1080
+ guard tabs.indices.contains(next) else { return }
1081
+ CraftChrome.shared.swipe(to: tabs[next])
1082
+ return
1083
+ }
1084
+ activeTabSwipeTarget = nil
1085
+ let complete = recognizer.state == .ended && (abs(translation) >= max(32, view.bounds.width * 0.28) || abs(velocity) > 700)
1086
+ finishInteractiveTabSwipe(completed: complete) { [weak self] in
1087
+ guard complete else {
1088
+ CraftChrome.shared.cancelTabSwipe()
1089
+ return
1090
+ }
1091
+ CraftChrome.shared.commitTabSwipe(to: target)
1092
+ self?.view.layoutIfNeeded()
1093
+ }
1094
+ default:
1095
+ break
1096
+ }
1097
+ }
1098
+
1099
+ /// Prepare a mounted target beside the current stack. Native roots render
1100
+ /// immediately; a web root borrows the shared WebView and stays
1101
+ /// transparent until its page route catches up. Two web roots still use
1102
+ /// the route-aware fallback because one WebView cannot render both pages.
1103
+ private func beginInteractiveTabSwipe(to id: String, direction: CGFloat) -> Bool {
1104
+ guard let current = visibleStack, let target = stacks[id],
1105
+ current.view !== target.view,
1106
+ !(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
1107
+ view.bounds.width > 0 else { return false }
1108
+ if let slot = target.topViewController as? CraftHybridWebSlot {
1109
+ // There is one shared WebView, so a web-to-web gesture cannot
1110
+ // render two pages at once. A native-to-web gesture can: move the
1111
+ // WebView into the hidden target slot and let its route catch up
1112
+ // while the slot follows the finger. It stays transparent until
1113
+ // the page reports that it reached the target path.
1114
+ slot.awaitingPage = true
1115
+ adoptWeb(into: slot, allowHiddenStack: true)
1116
+ webHost?.view.alpha = 0
1117
+ hybrid.navigatePage(slot.path) { [weak self, weak slot] depth in
1118
+ guard let self, let slot else { return }
1119
+ slot.depth = depth
1120
+ guard self.activeTabSwipeTarget == id,
1121
+ CraftHybridRoutes.normalize(self.hybrid.pagePath ?? "") == CraftHybridRoutes.normalize(slot.path) else { return }
1122
+ self.reveal(slot)
1123
+ }
1124
+ }
1125
+ target.view.isHidden = false
1126
+ let width = view.bounds.width
1127
+ target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
1128
+ view.bringSubviewToFront(target.view)
1129
+ CraftChrome.shared.beginTabSwipe(to: id)
1130
+ return true
1131
+ }
1132
+
1133
+ private func updateInteractiveTabSwipe(translation: CGFloat) {
1134
+ guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1135
+ let target = stacks[targetId] else { return }
1136
+ let width = max(1, view.bounds.width)
1137
+ let start = target.view.frame.minX >= width * 0.5 ? width : -width
1138
+ let offset = min(width, max(-width, translation))
1139
+ target.view.frame.origin.x = start + offset
1140
+ current.view.frame.origin.x = offset
1141
+ }
1142
+
1143
+ private func finishInteractiveTabSwipe(completed: Bool, completion: @escaping () -> Void) {
1144
+ guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1145
+ let target = stacks[targetId] else { completion(); return }
1146
+ let width = view.bounds.width
1147
+ let direction: CGFloat = target.view.frame.minX >= 0 ? 1 : -1
1148
+ let end = completed ? 0 : direction * width
1149
+ let duration = CraftChrome.motion(.easeOut(duration: completed ? 0.22 : 0.18)) == nil ? 0 : 0.22
1150
+ UIView.animate(withDuration: duration, animations: {
1151
+ target.view.frame.origin.x = end
1152
+ current.view.frame.origin.x = completed ? -direction * width : 0
1153
+ }, completion: { _ in
1154
+ if !completed {
1155
+ target.view.isHidden = true
1156
+ target.view.frame = self.view.bounds
1157
+ current.view.frame = self.view.bounds
1158
+ if target.topViewController is CraftHybridWebSlot,
1159
+ current.topViewController is CraftNativeScreenController {
1160
+ self.parkWeb()
1161
+ }
1162
+ }
1163
+ completion()
1164
+ })
1165
+ }
1166
+
1167
+ private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
1168
+ let local = root.convert(point, from: view)
1169
+ guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
1170
+ // WKWebView keeps its scrolling UIScrollView below the hosting view
1171
+ // that owns the native slot, so it is not necessarily on the hit
1172
+ // view's superview chain. Treat an overflowing page as its own
1173
+ // horizontal gesture surface before the shell claims the swipe.
1174
+ if let webView = CraftChrome.shared.webView,
1175
+ hit === webView || hit.isDescendant(of: webView) || webView.isDescendant(of: hit) {
1176
+ let scroll = webView.scrollView
1177
+ if scroll.isScrollEnabled,
1178
+ scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
1179
+ return true
1180
+ }
1181
+ }
1182
+ var candidate: UIView? = hit
1183
+ while let current = candidate {
1184
+ if let scroll = current as? UIScrollView,
1185
+ scroll.isScrollEnabled,
1186
+ scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
1187
+ return true
1188
+ }
1189
+ candidate = current.superview
1190
+ }
1191
+ return false
1192
+ }
1193
+
1046
1194
  /// The web view joins the screen, behind the native screens, and loads.
1047
1195
  private func attachWeb() {
1048
1196
  guard let webHost, !webAttached else { return }
@@ -1155,6 +1303,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1155
1303
  }
1156
1304
  currentTab = id
1157
1305
  for (key, other) in stacks { other.view.isHidden = key != id }
1306
+ nav.view.frame = view.bounds
1158
1307
  view.bringSubviewToFront(nav.view)
1159
1308
  if let slot = nav.topViewController as? CraftHybridWebSlot, slot.isViewLoaded {
1160
1309
  adoptWeb(into: slot)
@@ -1168,8 +1317,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1168
1317
  pendingTab = nil
1169
1318
  }
1170
1319
 
1171
- func adoptWeb(into slot: CraftHybridWebSlot) {
1172
- guard let webHost, slot.navigationController === visibleStack || visibleStack == nil else { return }
1320
+ func adoptWeb(into slot: CraftHybridWebSlot, allowHiddenStack: Bool = false) {
1321
+ guard let webHost,
1322
+ allowHiddenStack || slot.navigationController === visibleStack || visibleStack == nil else { return }
1173
1323
  attachWeb()
1174
1324
  moveWeb(to: slot, in: slot.view)
1175
1325
  webHost.view.alpha = slot.awaitingPage ? 0 : 1
@@ -1295,6 +1445,13 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1295
1445
 
1296
1446
  func pageNavigated(path: String, direction: String, depth: Int, byHost: Bool) {
1297
1447
  let owner = CraftHybridRoutes.tab(owning: path, among: hybrid.tabIds)
1448
+ if let targetId = activeTabSwipeTarget,
1449
+ let target = stacks[targetId]?.topViewController as? CraftHybridWebSlot,
1450
+ owner == targetId || CraftHybridRoutes.normalize(path) == CraftHybridRoutes.normalize(target.path) {
1451
+ target.depth = depth
1452
+ target.awaitingPage = false
1453
+ reveal(target)
1454
+ }
1298
1455
  if let pending = pendingTab, owner == pending.id || direction == "tab" {
1299
1456
  show(tab: pending.id)
1300
1457
  }
@@ -1481,10 +1638,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1481
1638
  }
1482
1639
 
1483
1640
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1641
+ if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
1642
+ guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
1643
+ guard CraftChrome.shared.tabSwipeEnabled else { return false }
1644
+ if let pan = gestureRecognizer as? UIPanGestureRecognizer {
1645
+ let velocity = pan.velocity(in: view)
1646
+ guard abs(velocity.x) > abs(velocity.y) else { return false }
1647
+ }
1648
+ if let native = top as? CraftNativeScreenController,
1649
+ !native.tabSwipeEnabled || !native.contentSwipeBack { return false }
1650
+ return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
1651
+ }
1484
1652
  guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
1485
1653
  nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
1486
1654
  if let native = nav.topViewController as? CraftNativeScreenController,
1487
1655
  gestureRecognizer !== nav.interactivePopGestureRecognizer,
1656
+ !native.contentSwipeBack {
1657
+ return false
1658
+ }
1659
+ if let native = nav.topViewController as? CraftNativeScreenController,
1488
1660
  nativeScreenContainsHorizontalScroll(native) {
1489
1661
  let moved = (gestureRecognizer as? UIPanGestureRecognizer)?.translation(in: nav.view).x ?? 0
1490
1662
  if gestureRecognizer.location(in: nav.view).x - moved > 32 { return false }
@@ -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") }
@@ -76,6 +76,9 @@ enum CraftNativeActions {
76
76
  // runs out of process and asks no permission. Taking a photo needs
77
77
  // the camera, which the app declares.
78
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")
79
82
  if config.enableCamera { values.append("camera") }
80
83
  return values
81
84
  }
@@ -190,6 +193,19 @@ enum CraftNativeActions {
190
193
  return
191
194
  case ("Files", "remove"):
192
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())
193
209
  case ("Lifecycle", "getState"):
194
210
  sync = .success(currentAppState())
195
211
  case ("DeepLinks", "getInitialURL"):
@@ -440,6 +456,19 @@ enum CraftNativeActions {
440
456
  complete(requestToken, .success(true), completion)
441
457
  }
442
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
443
472
  case ("Notifications", _):
444
473
  guard config.enableLocalNotifications else {
445
474
  complete(requestToken, failure("CAPABILITY_DISABLED", "Local notifications are disabled"), completion)
@@ -614,6 +643,63 @@ enum CraftNativeActions {
614
643
  if !alreadyCancelled { task.resume() }
615
644
  }
616
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
+
617
703
  private static func claimInitialDeepLink() -> URL? {
618
704
  cancellationLock.lock()
619
705
  defer { cancellationLock.unlock() }
@@ -920,6 +1006,30 @@ enum CraftNativeActions {
920
1006
  }
921
1007
  }
922
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
+
923
1033
  static func deepLinkData(_ url: URL) -> [String: Any] {
924
1034
  let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
925
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
+ }