@craft-native/ios 0.0.238 → 0.0.242

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.
@@ -342,10 +342,9 @@ final class CraftHybrid: NSObject {
342
342
  webView.evaluateJavaScript("window.__craftHybrid && window.__craftHybrid.adopt && window.__craftHybrid.adopt(\(json))")
343
343
  }
344
344
 
345
- static var bundleURL: URL? {
346
- Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
347
- ?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
348
- }
345
+ /// Whether the app carries native screens: the one-file bundle, or a
346
+ /// file per route (`CraftNativeBundle`).
347
+ static var hasBundle: Bool { CraftNativeBundle.isAvailable }
349
348
 
350
349
  /// From the bundled config, at launch: whether the shell routes paths,
351
350
  /// whether there is a web view, where it starts, the tab bar it starts
@@ -363,12 +362,12 @@ final class CraftHybrid: NSObject {
363
362
  startPath = config.devServerURL.flatMap(URL.init(string:)).map(CraftHybridRoutes.appPath(of:)) ?? "/"
364
363
  if startPath.isEmpty { startPath = "/" }
365
364
  let wanted = !routes.isEmpty
366
- if wanted && Self.bundleURL == nil {
365
+ if wanted && !Self.hasBundle {
367
366
  NSLog(hasWeb
368
367
  ? "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; every path opens in the web view"
369
368
  : "[craft hybrid] nativeScreens are set but dist/native-screen.js is missing; the app has no screens to show")
370
369
  }
371
- isEnabled = wanted && Self.bundleURL != nil
370
+ isEnabled = wanted && Self.hasBundle
372
371
  if isEnabled, !hasWeb, routes.match(startPath) == nil {
373
372
  // No web view to show it in: the first tab a screen draws.
374
373
  let fallback = (config.tabs ?? []).map(\.id).first { routes.match($0) != nil }
@@ -380,7 +379,7 @@ final class CraftHybrid: NSObject {
380
379
  CraftChrome.shared.configure(tabs: config.tabs ?? [], selected: tabIds(from: config.tabs ?? []).isEmpty ? nil : CraftHybridRoutes.tab(owning: startPath, among: tabIds(from: config.tabs ?? [])), splash: hasWeb && !startsNative)
381
380
 
382
381
  let script = config.nativeApp.flatMap { $0.isEmpty ? nil : $0 }
383
- appScriptRoute = script != nil && Self.bundleURL != nil && (isEnabled || !hasWeb) ? script : nil
382
+ appScriptRoute = script != nil && Self.hasBundle && (isEnabled || !hasWeb) ? script : nil
384
383
  if let script, appScriptRoute == nil {
385
384
  NSLog("[craft hybrid] nativeApp %@ does not run: it needs dist/native-screen.js, and nativeScreens or renderer \"native\"", script)
386
385
  }
@@ -1158,6 +1157,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1158
1157
  let complete = recognizer.state == .ended && speed > -700
1159
1158
  && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1160
1159
  finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1160
+ CraftFramePacing.shared.end("tab swipe")
1161
1161
  self?.activeTabSwipeTarget = nil
1162
1162
  self?.tabSwipeSettling = false
1163
1163
  self?.prepareAdjacentNativeTabs()
@@ -1187,7 +1187,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1187
1187
  // create a second web root here: the shared WebView cannot draw
1188
1188
  // both pages during the same gesture.
1189
1189
  guard hybrid.routes.match(id) != nil else { return false }
1190
- target = makeStack(tab: id, rootPath: id)
1190
+ target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
1191
1191
  (target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1192
1192
  }
1193
1193
  guard current.view !== target.view,
@@ -1216,6 +1216,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1216
1216
  target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
1217
1217
  view.bringSubviewToFront(target.view)
1218
1218
  CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
1219
+ lastNavigation = CACurrentMediaTime()
1220
+ CraftFramePacing.shared.begin("tab swipe")
1219
1221
  return true
1220
1222
  }
1221
1223
 
@@ -1245,12 +1247,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1245
1247
  CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
1246
1248
  let remaining = end - target.view.frame.minX
1247
1249
  let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
1248
- UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1249
- initialSpringVelocity: initialVelocity,
1250
- options: [.beginFromCurrentState, .allowUserInteraction], animations: {
1250
+ let move = {
1251
1251
  target.view.frame.origin.x = end
1252
1252
  current.view.frame.origin.x = completed ? -direction * width : 0
1253
- }, completion: { _ in
1253
+ }
1254
+ let settled: (Bool) -> Void = { _ in
1254
1255
  guard self.tabSwipeGeneration == generation,
1255
1256
  self.activeTabSwipeTarget == targetId else { return }
1256
1257
  if completed {
@@ -1268,7 +1269,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1268
1269
  }
1269
1270
  }
1270
1271
  completion()
1271
- })
1272
+ }
1273
+ UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
1274
+ initialSpringVelocity: initialVelocity,
1275
+ options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
1272
1276
  }
1273
1277
 
1274
1278
  /// A newer navigation action wins immediately, including a tap back on
@@ -1448,9 +1452,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1448
1452
  }
1449
1453
 
1450
1454
  private func show(tab id: String, rootPath: String? = nil) {
1455
+ lastNavigation = CACurrentMediaTime()
1451
1456
  interruptTabSwipe(restoringSelection: false)
1452
1457
  cancelPendingTab()
1453
- let nav = stacks[id] ?? makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id))
1458
+ let pacing = CraftFramePacing.shared
1459
+ if pacing.enabled, id != currentTab, !currentTab.isEmpty {
1460
+ pacing.begin("tab \(id)")
1461
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { pacing.end("tab \(id)") }
1462
+ }
1463
+ let nav = stacks[id] ?? pacing.measure("tab \(id) mount") { makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id)) }
1454
1464
  // A stack is hidden and shown rather than moved, so UIKit tells its
1455
1465
  // screens nothing: the shell says which went away and which is back.
1456
1466
  if id != currentTab {
@@ -1469,31 +1479,69 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1469
1479
  }
1470
1480
  }
1471
1481
 
1472
- /// Prepare one neighbour after the first frame, never inside the finger's
1473
- /// tracking run-loop mode. A first swipe then uses a laid-out native root
1474
- /// rather than compiling, measuring and fetching at gesture start.
1482
+ /// Prepare the other tabs' first screens after the first frame, nearest
1483
+ /// first, one per idle moment and never inside the finger's tracking
1484
+ /// run-loop mode. A swipe or a tap then lands on a laid-out native root
1485
+ /// rather than compiling, measuring and fetching while it animates. Only
1486
+ /// the neighbours used to be prepared, so arriving on a tab mounted the
1487
+ /// next one 150ms later, which was just when the next swipe began.
1488
+ ///
1489
+ /// Idle means idle: not while a push, a pop or a tab swipe animates, and
1490
+ /// not right after one (a link opened at launch must not wait behind
1491
+ /// tabs nobody asked for). The neighbours come first and soon; the tabs
1492
+ /// further away only once the screen has been still for a while.
1475
1493
  private func prepareAdjacentNativeTabs() {
1476
1494
  let owner = currentTab
1477
- let timer = Timer(timeInterval: 0.15, repeats: false) { [weak self] _ in
1478
- guard let self, self.currentTab == owner, self.activeTabSwipeTarget == nil,
1479
- self.view.window != nil,
1480
- let index = self.hybrid.tabIds.firstIndex(of: owner) else { return }
1481
- let tabs = self.hybrid.tabIds
1482
- for neighbour in [index + 1, index - 1] where tabs.indices.contains(neighbour) {
1483
- let id = tabs[neighbour]
1484
- guard self.stacks[id] == nil, self.hybrid.routes.match(id) != nil else { continue }
1495
+ guard let index = hybrid.tabIds.firstIndex(of: owner) else { return }
1496
+ let tabs = hybrid.tabIds
1497
+ let waiting = tabs.indices.filter { $0 != index && stacks[tabs[$0]] == nil && hybrid.routes.match(tabs[$0]) != nil }
1498
+ .sorted { abs($0 - index) < abs($1 - index) || (abs($0 - index) == abs($1 - index) && $0 > $1) }
1499
+ // One preparation pending at a time, for the tab on screen now.
1500
+ preparation?.invalidate()
1501
+ preparation = nil
1502
+ guard let next = waiting.first else { return }
1503
+ let neighbour = abs(next - index) == 1
1504
+ let timer = Timer(timeInterval: neighbour ? 0.15 : 0.6, repeats: false) { [weak self] _ in
1505
+ guard let self else { return }
1506
+ self.preparation = nil
1507
+ guard self.currentTab == owner, self.view.window != nil else { return }
1508
+ let quietFor = neighbour ? 0.3 : 1.0
1509
+ guard self.activeTabSwipeTarget == nil, !self.tabSwipeSettling,
1510
+ !self.stacks.values.contains(where: { $0.transitionCoordinator != nil }),
1511
+ CACurrentMediaTime() - self.lastNavigation >= quietFor else {
1512
+ self.prepareAdjacentNativeTabs()
1513
+ return
1514
+ }
1515
+ let id = tabs[next]
1516
+ guard self.stacks[id] == nil else { self.prepareAdjacentNativeTabs(); return }
1517
+ let nav = CraftFramePacing.shared.measure("prepare \(id)") { () -> UINavigationController in
1485
1518
  let nav = self.makeStack(tab: id, rootPath: id)
1486
1519
  nav.view.isHidden = true
1487
1520
  nav.view.layoutIfNeeded()
1488
- (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1489
- (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1490
- self.prepareAdjacentNativeTabs()
1491
- break
1521
+ // Draw its text and pictures now. A hidden layer is not
1522
+ // drawn at commit, so the first swipe to this tab drew
1523
+ // every label of the screen in the gesture's first frame.
1524
+ Self.drawContents(of: nav.view.layer)
1525
+ return nav
1492
1526
  }
1527
+ (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1528
+ (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1529
+ self.prepareAdjacentNativeTabs()
1493
1530
  }
1531
+ preparation = timer
1494
1532
  RunLoop.main.add(timer, forMode: .default)
1495
1533
  }
1496
1534
 
1535
+ private var preparation: Timer?
1536
+
1537
+ /// When the shell last moved between screens: a tab, a push or a pop.
1538
+ private var lastNavigation: CFTimeInterval = 0
1539
+
1540
+ private static func drawContents(of layer: CALayer) {
1541
+ layer.displayIfNeeded()
1542
+ layer.sublayers?.forEach(drawContents(of:))
1543
+ }
1544
+
1497
1545
  private func cancelPendingTab() {
1498
1546
  pendingTab?.deadline.cancel()
1499
1547
  pendingTab = nil
@@ -1759,6 +1807,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1759
1807
  // MARK: UINavigationControllerDelegate
1760
1808
 
1761
1809
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
1810
+ lastNavigation = CACurrentMediaTime()
1811
+ if animated { CraftFramePacing.shared.begin("navigation") }
1762
1812
  // The page draws its own header, and a tab's root screen its own
1763
1813
  // title unless it asked for the bar; a pushed native screen gets the
1764
1814
  // bar and its Back.
@@ -1773,6 +1823,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1773
1823
  }
1774
1824
 
1775
1825
  func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
1826
+ lastNavigation = CACurrentMediaTime()
1827
+ CraftFramePacing.shared.end("navigation")
1776
1828
  let key = ObjectIdentifier(navigationController)
1777
1829
  let before = shownStacks[key] ?? []
1778
1830
  let now = navigationController.viewControllers
@@ -1959,3 +2011,115 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1959
2011
  #endif
1960
2012
  }
1961
2013
  }
2014
+
2015
+ // MARK: - Frame pacing
2016
+
2017
+ /// How smoothly the app's own motion runs, measured the way Instruments'
2018
+ /// Animation Hitches template counts it: a frame that reaches the screen
2019
+ /// later than the display expected it is a hitch, and the time it was late
2020
+ /// is hitch time. Apple's bar is a hitch time ratio under 5 ms per second of
2021
+ /// motion; above 10 the motion reads as broken.
2022
+ ///
2023
+ /// Off unless the app is launched with `-CraftFramePacing YES` (or
2024
+ /// `CRAFT_FRAME_PACING=1`), so a shipped app never runs a display link for
2025
+ /// it. Each span (a scroll, a tab swipe, a push, a screen mount) logs one
2026
+ /// `[craft frames]` line, which simulator and device runs read back with
2027
+ /// `log show`. The Animation Hitches instrument does not run in the
2028
+ /// simulator; this does.
2029
+ final class CraftFramePacing: NSObject {
2030
+ static let shared = CraftFramePacing()
2031
+
2032
+ let enabled: Bool = {
2033
+ let info = ProcessInfo.processInfo
2034
+ return UserDefaults.standard.bool(forKey: "CraftFramePacing") || info.environment["CRAFT_FRAME_PACING"] == "1"
2035
+ }()
2036
+
2037
+ private struct Span {
2038
+ var started: CFTimeInterval
2039
+ var frames = 0
2040
+ var hitches = 0
2041
+ var hitchTime: CFTimeInterval = 0
2042
+ var worst: CFTimeInterval = 0
2043
+ }
2044
+
2045
+ private var spans: [String: Span] = [:]
2046
+ private var link: CADisplayLink?
2047
+ private var lastTimestamp: CFTimeInterval = 0
2048
+ private var lastTarget: CFTimeInterval = 0
2049
+
2050
+ /// Start counting frames for `name`; a span already open keeps counting.
2051
+ func begin(_ name: String) {
2052
+ guard enabled, spans[name] == nil else { return }
2053
+ spans[name] = Span(started: CACurrentMediaTime())
2054
+ guard link == nil else { return }
2055
+ let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
2056
+ // ProMotion runs scrolling at up to 120Hz; measure against what the
2057
+ // display actually asked for, not a fixed 60.
2058
+ if #available(iOS 15.0, *) { link.preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120) }
2059
+ link.add(to: .main, forMode: .common)
2060
+ self.link = link
2061
+ lastTimestamp = 0
2062
+ }
2063
+
2064
+ /// Close `name` and log its frames.
2065
+ func end(_ name: String) {
2066
+ guard enabled, let span = spans.removeValue(forKey: name) else { return }
2067
+ let duration = max(0.001, CACurrentMediaTime() - span.started)
2068
+ write(String(format: "%@ frames=%d duration=%.0fms hitches=%d hitchTime=%.1fms worst=%.1fms ratio=%.1fms/s",
2069
+ name, span.frames, duration * 1000, span.hitches, span.hitchTime * 1000, span.worst * 1000,
2070
+ span.hitchTime * 1000 / duration))
2071
+ if spans.isEmpty {
2072
+ link?.invalidate()
2073
+ link = nil
2074
+ }
2075
+ }
2076
+
2077
+ /// One main-thread block, timed: a screen mount, a bundle evaluation.
2078
+ @discardableResult
2079
+ func measure<T>(_ name: String, _ work: () -> T) -> T {
2080
+ guard enabled else { return work() }
2081
+ let started = CACurrentMediaTime()
2082
+ let result = work()
2083
+ write(String(format: "%@ main=%.1fms", name, (CACurrentMediaTime() - started) * 1000))
2084
+ return result
2085
+ }
2086
+
2087
+ /// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
2088
+ /// in the app's container, which a test run reads back whole (a
2089
+ /// simulator's unified log can drop an app's lines under load). Written
2090
+ /// off the main thread, so the measuring costs no frames itself.
2091
+ private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
2092
+ private lazy var file: FileHandle? = {
2093
+ guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
2094
+ let url = caches.appendingPathComponent("craft-frame-pacing.log")
2095
+ if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
2096
+ let handle = try? FileHandle(forWritingTo: url)
2097
+ _ = try? handle?.seekToEnd()
2098
+ return handle
2099
+ }()
2100
+
2101
+ private func write(_ line: String) {
2102
+ NSLog("[craft frames] %@", line)
2103
+ let stamped = "\(Date().timeIntervalSince1970) \(line)\n"
2104
+ output.async { [weak self] in try? self?.file?.write(contentsOf: Data(stamped.utf8)) }
2105
+ }
2106
+
2107
+ @objc private func frame(_ link: CADisplayLink) {
2108
+ defer {
2109
+ lastTimestamp = link.timestamp
2110
+ lastTarget = link.targetTimestamp
2111
+ }
2112
+ guard lastTimestamp > 0 else { return }
2113
+ // The frame was due at the last target; anything later than that,
2114
+ // beyond a millisecond of scheduling noise, is hitch time.
2115
+ let late = link.timestamp - lastTarget
2116
+ for key in spans.keys {
2117
+ spans[key]!.frames += 1
2118
+ if late > 0.001 {
2119
+ spans[key]!.hitches += 1
2120
+ spans[key]!.hitchTime += late
2121
+ spans[key]!.worst = max(spans[key]!.worst, late)
2122
+ }
2123
+ }
2124
+ }
2125
+ }
@@ -764,11 +764,15 @@ enum CraftNativeActions {
764
764
  /// screens asking at the same time share one download). A refused answer
765
765
  /// (not 2xx) keeps nothing and says its status. Cancelled like fetch.
766
766
  ///
767
- /// `cellular: false` keeps it off a metered connection: iOS refuses the
768
- /// download on cellular, a personal hotspot and Low Data Mode, and the
769
- /// answer is `{ ok: false, reason: "cellular" }` straight away, so the
770
- /// screen can say it is waiting for Wi-Fi instead of failing. A copy
771
- /// already kept still answers, whatever the connection.
767
+ /// `cellular: false` keeps it off a metered connection (cellular, a
768
+ /// personal hotspot, Low Data Mode). The download runs on a background
769
+ /// session (CraftNativeMediaTransfers), so it carries on while the app is
770
+ /// suspended or closed. When the phone cannot fetch it now (no
771
+ /// connection, or only a metered one for a Wi-Fi-only keep) the answer is
772
+ /// `{ ok: false, reason: "offline" | "cellular", queued: true }` straight
773
+ /// away, so the screen can say it is waiting rather than failing, and the
774
+ /// transfer finishes with iOS when it can. A copy already kept still
775
+ /// answers, whatever the connection.
772
776
  private static func keepMedia(
773
777
  requestToken: String,
774
778
  args: [Any],
@@ -797,38 +801,33 @@ enum CraftNativeActions {
797
801
  request.setValue(value, forHTTPHeaderField: name)
798
802
  }
799
803
  }
800
- if options["cellular"] as? Bool == false {
804
+ let wifiOnly = options["cellular"] as? Bool == false
805
+ if wifiOnly {
801
806
  request.allowsExpensiveNetworkAccess = false
802
807
  request.allowsConstrainedNetworkAccess = false
803
808
  }
804
- guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
805
- let task = fetchSession.downloadTask(with: request) { location, response, error in
809
+ let answer: (Result<Any, CraftNativeActionError>) -> Void = { result in
806
810
  cancellationLock.lock()
807
811
  fetchTasks.removeValue(forKey: requestToken)
808
812
  cancellationLock.unlock()
809
- if let error = error {
810
- // Refused only because the connection is metered: not a failure, a wait.
811
- if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
812
- CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
813
- return
814
- }
815
- let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
816
- CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
817
- return
818
- }
819
- guard let http = response as? HTTPURLResponse, let location else {
820
- CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
821
- return
822
- }
823
- // The system deletes `location` once this returns: move it now.
824
- CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
813
+ complete(requestToken, result, completion)
825
814
  }
815
+ guard let flight = CraftNativeMedia.begin(address, answer) else { return }
826
816
  cancellationLock.lock()
827
817
  let alreadyCancelled = cancelledRequests.contains(requestToken)
828
- if !alreadyCancelled { fetchTasks[requestToken] = task }
829
818
  cancellationLock.unlock()
830
- if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
831
- else { task.resume() }
819
+ if alreadyCancelled {
820
+ CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled"))
821
+ return
822
+ }
823
+ // On the app's background session (CraftNativeMediaTransfers): it
824
+ // carries on while the app is suspended, and a keep the phone cannot
825
+ // fetch now is answered at once while its transfer waits with iOS.
826
+ let task = CraftNativeMediaTransfers.shared.start(request, address: address, flight: flight, wifiOnly: wifiOnly)
827
+ cancellationLock.lock()
828
+ // Unless it answered already (a queued transfer, or an instant failure).
829
+ if CraftNativeMedia.isWaiting(address, flight: flight) { fetchTasks[requestToken] = task }
830
+ cancellationLock.unlock()
832
831
  }
833
832
 
834
833
  private static func claimInitialDeepLink(_ url: URL?) -> URL? {
@@ -1,6 +1,7 @@
1
1
  import CryptoKit
2
2
  import Foundation
3
3
  import ImageIO
4
+ import Network
4
5
  import PhotosUI
5
6
  import UIKit
6
7
  import UniformTypeIdentifiers
@@ -554,6 +555,13 @@ enum CraftNativeMedia {
554
555
  return id
555
556
  }
556
557
 
558
+ /// Whether `flight` is still the download `address`'s keeps wait for.
559
+ static func isWaiting(_ address: String, flight: UUID) -> Bool {
560
+ lock.lock()
561
+ defer { lock.unlock() }
562
+ return inFlight[address]?.id == flight
563
+ }
564
+
557
565
  static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
558
566
  lock.lock()
559
567
  let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
@@ -563,7 +571,7 @@ enum CraftNativeMedia {
563
571
 
564
572
  /// A finished download moved into place (atomically, so a half-written
565
573
  /// file is never played), or a refusal with the server's status.
566
- static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
574
+ static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil, stillWanted: (() -> Bool)? = nil) -> Result<Any, CraftNativeActionError> {
567
575
  guard response.statusCode == 200 else {
568
576
  return .success(["ok": false, "url": address, "status": response.statusCode])
569
577
  }
@@ -574,6 +582,10 @@ enum CraftNativeMedia {
574
582
  if let flight, inFlight[address]?.id != flight {
575
583
  return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
576
584
  }
585
+ // A background transfer from before the cache was cleared (sign-out).
586
+ if let stillWanted, !stillWanted() {
587
+ return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
588
+ }
577
589
  let staging = root.appendingPathComponent(UUID().uuidString + ".part")
578
590
  defer { try? fm.removeItem(at: staging) }
579
591
  do {
@@ -609,6 +621,9 @@ enum CraftNativeMedia {
609
621
  lock.lock()
610
622
  let waiting = inFlight.values.flatMap { $0.waiting }
611
623
  inFlight.removeAll()
624
+ // Under the same lock a background transfer commits under: none
625
+ // started before this lands after it.
626
+ CraftNativeMediaTransfers.shared.cancelAll()
612
627
  let fm = FileManager.default
613
628
  let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
614
629
  var removed = 0
@@ -625,6 +640,7 @@ enum CraftNativeMedia {
625
640
 
626
641
  /// Removes every copy whose address is not in `keep`; answers how many.
627
642
  static func prune(keep: [String]) -> Int {
643
+ CraftNativeMediaTransfers.shared.cancel(except: keep + Array(inFlightAddresses()))
628
644
  lock.lock()
629
645
  defer { lock.unlock() }
630
646
  let wanted = Set(keep.map { file(for: $0).lastPathComponent })
@@ -637,6 +653,12 @@ enum CraftNativeMedia {
637
653
  return removed
638
654
  }
639
655
 
656
+ private static func inFlightAddresses() -> Set<String> {
657
+ lock.lock()
658
+ defer { lock.unlock() }
659
+ return Set(inFlight.keys)
660
+ }
661
+
640
662
  /// What the kept copies come to, in bytes, and how many there are.
641
663
  static func usage() -> [String: Any] {
642
664
  let files = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: [.fileSizeKey])) ?? []
@@ -645,3 +667,185 @@ enum CraftNativeMedia {
645
667
  return ["files": kept.count, "bytes": bytes]
646
668
  }
647
669
  }
670
+
671
+ /// The downloads `media.keep` starts, on a background URLSession: a transfer
672
+ /// carries on while the app is suspended or closed, waits for Wi-Fi when it
673
+ /// was asked to, and lands in CraftNativeMedia when it finishes, whether or
674
+ /// not anyone is still waiting for it (the address travels with the task as
675
+ /// its description, so a transfer outlives the launch that started it).
676
+ ///
677
+ /// A request answers as soon as its file is kept, or at once with
678
+ /// `{ ok: false, reason: "cellular" | "offline", queued: true }` when the
679
+ /// phone cannot fetch it now: the transfer stays with iOS and finishes on
680
+ /// its own, and the next `keep` of that address answers from the copy.
681
+ final class CraftNativeMediaTransfers: NSObject, URLSessionDownloadDelegate {
682
+ static let shared = CraftNativeMediaTransfers()
683
+ static var identifier: String { "\(Bundle.main.bundleIdentifier ?? "dev.craft.app").craft-media" }
684
+
685
+ /// iOS's handler from relaunching the app for this session's events.
686
+ var backgroundCompletion: (() -> Void)?
687
+
688
+ private struct Transfer {
689
+ let address: String
690
+ /// The keep still waiting for this transfer; nil once answered early.
691
+ var flight: UUID?
692
+ let wifiOnly: Bool
693
+ /// Transfers from before the last `clear` must not land.
694
+ let generation: Int
695
+ }
696
+
697
+ private let lock = NSLock()
698
+ private var transfers: [Int: Transfer] = [:]
699
+ /// What each finished download came to, until its task completes.
700
+ private var finished: [Int: Result<Any, CraftNativeActionError>] = [:]
701
+ /// Transfers left running by an earlier launch, by address.
702
+ private var restored: [String: URLSessionDownloadTask] = [:]
703
+ private var generation = 0
704
+ private let monitor = NWPathMonitor()
705
+
706
+ /// Made once, at first use: iOS hands this identifier's finished
707
+ /// transfers to whichever session object the app makes with it.
708
+ private(set) var session: URLSession!
709
+
710
+ override init() {
711
+ super.init()
712
+ let configuration = URLSessionConfiguration.background(withIdentifier: Self.identifier)
713
+ configuration.sessionSendsLaunchEvents = true
714
+ configuration.isDiscretionary = false
715
+ session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
716
+ session.getAllTasks { [weak self] tasks in
717
+ guard let self else { return }
718
+ self.lock.lock()
719
+ for case let task as URLSessionDownloadTask in tasks where task.state == .running || task.state == .suspended {
720
+ if let address = task.taskDescription, self.transfers[task.taskIdentifier] == nil { self.restored[address] = task }
721
+ }
722
+ self.lock.unlock()
723
+ }
724
+ // A connection lost (or only a metered one left for a Wi-Fi-only
725
+ // keep) answers the waiting keeps; their transfers carry on later.
726
+ monitor.pathUpdateHandler = { [weak self] path in self?.release(on: path) }
727
+ monitor.start(queue: DispatchQueue(label: "dev.craft.native.media.path"))
728
+ }
729
+
730
+ /// Fetches `request` into the copy for `address`, finishing `flight`.
731
+ func start(_ request: URLRequest, address: String, flight: UUID, wifiOnly: Bool) -> URLSessionTask {
732
+ lock.lock()
733
+ let task: URLSessionDownloadTask
734
+ if let earlier = restored.removeValue(forKey: address), earlier.state == .running || earlier.state == .suspended {
735
+ task = earlier
736
+ }
737
+ else {
738
+ task = session.downloadTask(with: request)
739
+ task.taskDescription = address
740
+ }
741
+ transfers[task.taskIdentifier] = Transfer(address: address, flight: flight, wifiOnly: wifiOnly, generation: generation)
742
+ lock.unlock()
743
+ task.resume()
744
+ release(on: monitor.currentPath)
745
+ return task
746
+ }
747
+
748
+ /// Answers the keeps that cannot be fetched on `path`; their transfers stay queued with iOS.
749
+ private func release(on path: NWPath) {
750
+ let offline = path.status != .satisfied
751
+ let metered = path.isExpensive || path.isConstrained
752
+ lock.lock()
753
+ var answered: [(String, UUID, String)] = []
754
+ for (id, transfer) in transfers {
755
+ guard let flight = transfer.flight, offline || (transfer.wifiOnly && metered) else { continue }
756
+ transfers[id]?.flight = nil
757
+ answered.append((transfer.address, flight, offline ? "offline" : "cellular"))
758
+ }
759
+ lock.unlock()
760
+ for (address, flight, reason) in answered {
761
+ CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": reason, "queued": true]))
762
+ }
763
+ }
764
+
765
+ /// Stops every transfer: an account's copies are being cleared, and none of its videos may land after.
766
+ func cancelAll() {
767
+ lock.lock()
768
+ generation += 1
769
+ restored.removeAll()
770
+ lock.unlock()
771
+ session.getAllTasks { tasks in tasks.forEach { $0.cancel() } }
772
+ }
773
+
774
+ /// Stops the transfers for addresses no longer wanted.
775
+ func cancel(except keep: [String]) {
776
+ let wanted = Set(keep)
777
+ lock.lock()
778
+ restored = restored.filter { wanted.contains($0.key) }
779
+ lock.unlock()
780
+ session.getAllTasks { tasks in
781
+ for task in tasks where !wanted.contains(task.taskDescription ?? "") { task.cancel() }
782
+ }
783
+ }
784
+
785
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
786
+ lock.lock()
787
+ let transfer = transfers[downloadTask.taskIdentifier]
788
+ lock.unlock()
789
+ // One left by an earlier launch belongs to the generation this launch began with.
790
+ let started = transfer?.generation ?? 0
791
+ guard let address = transfer?.address ?? downloadTask.taskDescription else { return }
792
+ guard let http = downloadTask.response as? HTTPURLResponse else {
793
+ record(downloadTask, .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep received a response that is not HTTP")))
794
+ return
795
+ }
796
+ // The system deletes `location` once this returns: move it now. It
797
+ // lands whether its keep is still waiting or was answered early; only
798
+ // a clear since it started (sign-out) keeps it out.
799
+ record(downloadTask, CraftNativeMedia.store(address, at: location, response: http, stillWanted: { [weak self] in self?.isCurrent(started) ?? false }))
800
+ }
801
+
802
+ /// How many times the cache was cleared in this launch; a transfer started before the last clear does not land.
803
+ var currentGeneration: Int {
804
+ lock.lock()
805
+ defer { lock.unlock() }
806
+ return generation
807
+ }
808
+
809
+ func isCurrent(_ started: Int) -> Bool {
810
+ currentGeneration == started
811
+ }
812
+
813
+ private func record(_ task: URLSessionTask, _ result: Result<Any, CraftNativeActionError>) {
814
+ lock.lock()
815
+ finished[task.taskIdentifier] = result
816
+ lock.unlock()
817
+ }
818
+
819
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
820
+ lock.lock()
821
+ let transfer = transfers.removeValue(forKey: task.taskIdentifier)
822
+ let result = finished.removeValue(forKey: task.taskIdentifier)
823
+ if let address = task.taskDescription, restored[address] === task { restored.removeValue(forKey: address) }
824
+ lock.unlock()
825
+ guard let transfer, let flight = transfer.flight else { return }
826
+ if let error {
827
+ let urlError = error as? URLError
828
+ if urlError?.code == .cancelled {
829
+ CraftNativeMedia.finish(transfer.address, flight: flight, .failure(CraftNativeActionError(code: "CANCELLED", message: "media.keep was cancelled")))
830
+ return
831
+ }
832
+ // Refused only because the connection is metered: not a failure, a wait.
833
+ if let reason = urlError?.networkUnavailableReason, reason == .expensive || reason == .constrained {
834
+ CraftNativeMedia.finish(transfer.address, flight: flight, .success(["ok": false, "url": transfer.address, "reason": "cellular"]))
835
+ return
836
+ }
837
+ let code = urlError?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
838
+ CraftNativeMedia.finish(transfer.address, flight: flight, .failure(CraftNativeActionError(code: CraftNativeMedia.errorCode(error, fallback: code), message: error.localizedDescription)))
839
+ return
840
+ }
841
+ CraftNativeMedia.finish(transfer.address, flight: flight, result ?? .failure(CraftNativeActionError(code: "NETWORK_ERROR", message: "media.keep finished without a file")))
842
+ }
843
+
844
+ func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
845
+ DispatchQueue.main.async { [weak self] in
846
+ let done = self?.backgroundCompletion
847
+ self?.backgroundCompletion = nil
848
+ done?()
849
+ }
850
+ }
851
+ }