@craft-native/ios 0.0.163 → 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.
package/README.md CHANGED
@@ -148,6 +148,8 @@ The iOS host uses the shared layout ABI when it is available and retains a
148
148
  local fallback for older runtimes. Both paths implement wrapped-line and grid
149
149
  `alignContent`, including `space-evenly`, so a missing optional shared symbol
150
150
  does not change placement semantics.
151
+ Reverse flex directions also retain wrapped line order on the cross axis while
152
+ reversing each line's main-axis children, matching Android's native host.
151
153
 
152
154
  For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
153
155
 
@@ -405,6 +407,10 @@ const workouts = await health.getWorkouts({ startDate: Date.now() - 30 * 8640000
405
407
  const hrv = await health.getDailyStatistics('heartRateVariability', { startDate: Date.now() - 30 * 86400000 })
406
408
  // What a food log wrote (MyFitnessPal, Lose It!...): kcal, then grams of protein, carbohydrate and fat, summed per day.
407
409
  const eaten = await health.getDailyStatistics('dietaryEnergy', { startDate: Date.now() - 7 * 86400000 })
410
+ // Food logs can write a quantity too; request write access first. The result
411
+ // contains HealthKit's sample id, and the date range defaults to this instant.
412
+ await health.requestAuthorization(['dietaryEnergy', 'dietaryProtein'], { write: true })
413
+ const sample = await health.saveData('dietaryProtein', 28, { startDate: mealStart, endDate: mealEnd })
408
414
  // Every heart-rate reading in a range (bpm, start/end, source and device), oldest first.
409
415
  const readings = await health.getHeartRateSamples({ startDate: Date.now() - 86400000 })
410
416
  // The night as Apple Health recorded it: in bed, awake, and each asleep stage.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.163",
3
+ "version": "0.0.164",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -738,6 +738,18 @@ final class CraftChrome: ObservableObject {
738
738
  emit("craftTabSelect", ["id": tab.id])
739
739
  }
740
740
 
741
+ /// A completed horizontal swipe selects the adjacent tab. The shell owns
742
+ /// this path so it works over both native screens and the shared WebView.
743
+ /// The gesture recognizer yields before calling this for a screen-owned
744
+ /// horizontal scroll or custom pager.
745
+ func swipe(to id: String) {
746
+ guard id != selected, tabs.contains(where: { $0.id == id }) else { return }
747
+ withAnimation(Self.motion(.spring(response: 0.32, dampingFraction: 0.86))) { selected = id }
748
+ UISelectionFeedbackGenerator().selectionChanged()
749
+ guard CraftHybrid.shared.tabTapped(id, reselect: false) else { return }
750
+ emit("craftTabSelect", ["id": id])
751
+ }
752
+
741
753
  /// The animation, or none with Reduce Motion on: the selection jumps to
742
754
  /// its tab rather than springing there, as UIKit's own controls do.
743
755
  static func motion(_ animation: Animation) -> Animation? {
@@ -2514,6 +2526,21 @@ struct CraftWebView: UIViewRepresentable {
2514
2526
  } else {
2515
2527
  rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2516
2528
  }
2529
+ case "saveHealthData":
2530
+ if config.enableHealthKit {
2531
+ guard let dataType = body["type"] as? String,
2532
+ let value = (body["value"] as? NSNumber)?.doubleValue else {
2533
+ rejectCallback(callbackId, error: "saveHealthData was called without a type and numeric value", code: "INVALID_ARGUMENT")
2534
+ break
2535
+ }
2536
+ CraftHealth.shared.saveData(
2537
+ type: dataType,
2538
+ value: value,
2539
+ options: body
2540
+ ) { [weak self] in self?.answer(callbackId, $0) }
2541
+ } else {
2542
+ rejectCallback(callbackId, error: "HealthKit is disabled", code: "CAPABILITY_DISABLED")
2543
+ }
2517
2544
  case "saveHealthWorkout":
2518
2545
  if config.enableHealthKit {
2519
2546
  saveHealthWorkout(body: body, callbackId: callbackId)
@@ -5077,6 +5104,10 @@ struct CraftWebView: UIViewRepresentable {
5077
5104
  options = options || {};
5078
5105
  return craft._invoke('getHealthData', {type: type, startDate: options.startDate, endDate: options.endDate});
5079
5106
  },
5107
+ saveData: function(type, value, options) {
5108
+ options = options || {};
5109
+ return craft._invoke('saveHealthData', {type: type, value: value, startDate: options.startDate, endDate: options.endDate});
5110
+ },
5080
5111
  saveWorkout: function(workout) { return craft._invoke('saveHealthWorkout', workout || {}); },
5081
5112
  getWorkouts: function(options) {
5082
5113
  options = options || {};
@@ -32,6 +32,13 @@ final class CraftHealth {
32
32
  case "getData":
33
33
  guard let type = args.first as? String else { return completion(.failure(CraftHealthError(message: "getData needs a type", code: "INVALID_ARGUMENT"))) }
34
34
  getData(type: type, startDate: options(1)["startDate"] as? Double, endDate: options(1)["endDate"] as? Double, completion: completion)
35
+ case "saveData":
36
+ guard let type = args.first as? String,
37
+ args.count > 1,
38
+ let value = (args[1] as? NSNumber)?.doubleValue else {
39
+ return completion(.failure(CraftHealthError(message: "saveData needs a type and numeric value", code: "INVALID_ARGUMENT")))
40
+ }
41
+ saveData(type: type, value: value, options: options(2), completion: completion)
35
42
  case "saveWorkout":
36
43
  saveWorkout(body: options(0), completion: completion)
37
44
  case "getWorkouts":
@@ -120,12 +127,15 @@ final class CraftHealth {
120
127
  }
121
128
  case "dietaryEnergy", "dietaryProtein", "dietaryCarbohydrates", "dietaryFat":
122
129
  // What a food log (MyFitnessPal, Lose It!, Cronometer...)
123
- // writes to Apple Health: read only, never written back.
130
+ // writes to Apple Health. Request the write grant too unless
131
+ // the caller explicitly asked for a read-only authorization.
124
132
  if let dietaryType = Self.healthQuantity(type)?.type {
125
133
  readTypes.insert(dietaryType)
134
+ if !readOnly { shareTypes.insert(dietaryType) }
126
135
  }
127
136
  default:
128
- break
137
+ completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
138
+ return
129
139
  }
130
140
  }
131
141
 
@@ -179,6 +189,42 @@ final class CraftHealth {
179
189
  healthStore.execute(query)
180
190
  }
181
191
 
192
+ /// Save one quantity sample, primarily a food-log entry. Dates are epoch
193
+ /// milliseconds; an omitted range records an instantaneous sample now.
194
+ func saveData(type: String, value: Double, options: [String: Any], completion: @escaping Completion) {
195
+ guard let healthStore = healthStore else {
196
+ completion(.failure(CraftHealthError(message: "HealthKit not available")))
197
+ return
198
+ }
199
+ guard value.isFinite, value >= 0 else {
200
+ completion(.failure(CraftHealthError(message: "saveData needs a finite, non-negative value", code: "INVALID_ARGUMENT")))
201
+ return
202
+ }
203
+ guard let spec = Self.healthQuantity(type), let qType = spec.type else {
204
+ completion(.failure(CraftHealthError(message: "Unknown health data type: \(type)", code: "INVALID_ARGUMENT")))
205
+ return
206
+ }
207
+ let startValue = options["startDate"] as? Double ?? Date().timeIntervalSince1970 * 1000
208
+ let endValue = options["endDate"] as? Double ?? startValue
209
+ guard startValue.isFinite, endValue.isFinite, endValue >= startValue else {
210
+ completion(.failure(CraftHealthError(message: "saveData needs a valid date range", code: "INVALID_ARGUMENT")))
211
+ return
212
+ }
213
+ let sample = HKQuantitySample(
214
+ type: qType,
215
+ quantity: HKQuantity(unit: spec.unit, doubleValue: value),
216
+ start: Date(timeIntervalSince1970: startValue / 1000),
217
+ end: Date(timeIntervalSince1970: endValue / 1000)
218
+ )
219
+ healthStore.save(sample) { success, error in
220
+ if success {
221
+ completion(.success(["id": sample.uuid.uuidString]))
222
+ } else {
223
+ completion(.failure(CraftHealthError(message: error?.localizedDescription ?? "Health data could not be saved")))
224
+ }
225
+ }
226
+ }
227
+
182
228
  func saveWorkout(body: [String: Any], completion: @escaping Completion) {
183
229
  guard let healthStore = healthStore else {
184
230
  completion(.failure(CraftHealthError(message: "HealthKit not available")))
@@ -1004,6 +1004,7 @@ 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: [UISwipeGestureRecognizer] = []
1007
1008
 
1008
1009
  init(config: CraftConfig) {
1009
1010
  self.config = config
@@ -1033,6 +1034,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1033
1034
  let tabs = hybrid.tabIds
1034
1035
  let start = CraftHybridRoutes.tab(owning: hybrid.startPath, among: tabs) ?? tabs.first ?? ""
1035
1036
  show(tab: start, rootPath: hybrid.startPath)
1037
+ installTabSwipeGestures()
1036
1038
  if hybrid.hasWeb && hybrid.startsNative {
1037
1039
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in self?.attachWeb() }
1038
1040
  }
@@ -1043,6 +1045,42 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1043
1045
 
1044
1046
  private var webAttached = false
1045
1047
 
1048
+ private func installTabSwipeGestures() {
1049
+ guard tabSwipeRecognizers.isEmpty else { return }
1050
+ for direction in [UISwipeGestureRecognizer.Direction.left, .right] {
1051
+ let recognizer = UISwipeGestureRecognizer(target: self, action: #selector(tabSwipeRecognized(_:)))
1052
+ recognizer.direction = direction
1053
+ recognizer.delegate = self
1054
+ recognizer.cancelsTouchesInView = false
1055
+ recognizer.delaysTouchesBegan = false
1056
+ view.addGestureRecognizer(recognizer)
1057
+ tabSwipeRecognizers.append(recognizer)
1058
+ }
1059
+ }
1060
+
1061
+ @objc private func tabSwipeRecognized(_ recognizer: UISwipeGestureRecognizer) {
1062
+ let tabs = hybrid.tabIds
1063
+ guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
1064
+ let next = recognizer.direction == .left ? index + 1 : index - 1
1065
+ guard tabs.indices.contains(next) else { return }
1066
+ CraftChrome.shared.swipe(to: tabs[next])
1067
+ }
1068
+
1069
+ private func horizontalScrollAt(_ point: CGPoint, in root: UIView) -> Bool {
1070
+ let local = root.convert(point, from: view)
1071
+ guard root.bounds.contains(local), let hit = root.hitTest(local, with: nil) else { return false }
1072
+ var candidate: UIView? = hit
1073
+ while let current = candidate {
1074
+ if let scroll = current as? UIScrollView,
1075
+ scroll.isScrollEnabled,
1076
+ scroll.alwaysBounceHorizontal || scroll.contentSize.width > scroll.bounds.width + 1 {
1077
+ return true
1078
+ }
1079
+ candidate = current.superview
1080
+ }
1081
+ return false
1082
+ }
1083
+
1046
1084
  /// The web view joins the screen, behind the native screens, and loads.
1047
1085
  private func attachWeb() {
1048
1086
  guard let webHost, !webAttached else { return }
@@ -1481,8 +1519,19 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1481
1519
  }
1482
1520
 
1483
1521
  func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
1522
+ if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
1523
+ guard hybrid.tabIds.count > 1, let top = visibleStack?.topViewController else { return false }
1524
+ if let native = top as? CraftNativeScreenController,
1525
+ !native.tabSwipeEnabled || !native.contentSwipeBack { return false }
1526
+ return !horizontalScrollAt(gestureRecognizer.location(in: view), in: top.view)
1527
+ }
1484
1528
  guard let nav = visibleStack, isPopRecognizer(gestureRecognizer),
1485
1529
  nav.viewControllers.count > 1, nav.transitionCoordinator == nil else { return false }
1530
+ if let native = nav.topViewController as? CraftNativeScreenController,
1531
+ gestureRecognizer !== nav.interactivePopGestureRecognizer,
1532
+ !native.contentSwipeBack {
1533
+ return false
1534
+ }
1486
1535
  if let native = nav.topViewController as? CraftNativeScreenController,
1487
1536
  gestureRecognizer !== nav.interactivePopGestureRecognizer,
1488
1537
  nativeScreenContainsHorizontalScroll(native) {
@@ -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
+ }
@@ -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] = []
@@ -1922,7 +1937,9 @@ private final class CraftNativeSvgView: UIView {
1922
1937
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
1923
1938
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
1924
1939
  if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
1925
- 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) }
1926
1943
  if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
1927
1944
  if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
1928
1945
  if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
@@ -2709,7 +2726,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2709
2726
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
2710
2727
  /// `craft.secureStorage.set` wrote.
2711
2728
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
2712
- /// 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`.
2713
2731
  ///
2714
2732
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
2715
2733
  /// these: each namespace is a property whose setter adds them back to the
@@ -2953,6 +2971,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2953
2971
  private var navigationBarHidden: Bool?
2954
2972
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
2955
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
2956
2981
  private var prefersLargeTitle: Bool?
2957
2982
 
2958
2983
  /// Whether this screen asked for a bar of its own through `setOptions`: a
@@ -2988,6 +3013,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2988
3013
  CraftChrome.shared.setHiddenByScreen(hidden)
2989
3014
  }
2990
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
+ }
2991
3022
  if let hidden = options["hidden"] as? Bool {
2992
3023
  navigationBarHidden = hidden
2993
3024
  if navigationController?.topViewController === self {