@craft-native/ios 0.0.241 → 0.0.243

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
@@ -1398,7 +1402,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1398
1402
 
1399
1403
  // MARK: Stacks
1400
1404
 
1401
- private func makeStack(tab: String, rootPath: String) -> UINavigationController {
1405
+ /// `hidden`: a stack prepared off screen. It is hidden before it joins
1406
+ /// the view hierarchy, so its screen never takes the shell's chrome (the
1407
+ /// bar, the tab accessory) from the one on screen.
1408
+ private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
1402
1409
  let nav = UINavigationController(rootViewController: entry(for: rootPath, isTabRoot: true, depth: nil))
1403
1410
  nav.delegate = self
1404
1411
  nav.view.backgroundColor = config.resolvedBackgroundColor
@@ -1407,6 +1414,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1407
1414
  addChild(nav)
1408
1415
  nav.view.frame = view.bounds
1409
1416
  nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1417
+ nav.view.isHidden = hidden
1410
1418
  view.addSubview(nav.view)
1411
1419
  nav.didMove(toParent: self)
1412
1420
  // Only once the stack's view has loaded: the recognizer is made then,
@@ -1448,9 +1456,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1448
1456
  }
1449
1457
 
1450
1458
  private func show(tab id: String, rootPath: String? = nil) {
1459
+ lastNavigation = CACurrentMediaTime()
1451
1460
  interruptTabSwipe(restoringSelection: false)
1452
1461
  cancelPendingTab()
1453
- let nav = stacks[id] ?? makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id))
1462
+ let pacing = CraftFramePacing.shared
1463
+ if pacing.enabled, id != currentTab, !currentTab.isEmpty {
1464
+ pacing.begin("tab \(id)")
1465
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { pacing.end("tab \(id)") }
1466
+ }
1467
+ let nav = stacks[id] ?? pacing.measure("tab \(id) mount") { makeStack(tab: id, rootPath: rootPath ?? (id.isEmpty ? hybrid.startPath : id)) }
1454
1468
  // A stack is hidden and shown rather than moved, so UIKit tells its
1455
1469
  // screens nothing: the shell says which went away and which is back.
1456
1470
  if id != currentTab {
@@ -1469,31 +1483,68 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1469
1483
  }
1470
1484
  }
1471
1485
 
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.
1486
+ /// Prepare the other tabs' first screens after the first frame, nearest
1487
+ /// first, one per idle moment and never inside the finger's tracking
1488
+ /// run-loop mode. A swipe or a tap then lands on a laid-out native root
1489
+ /// rather than compiling, measuring and fetching while it animates. Only
1490
+ /// the neighbours used to be prepared, so arriving on a tab mounted the
1491
+ /// next one 150ms later, which was just when the next swipe began.
1492
+ ///
1493
+ /// Idle means idle: not while a push, a pop or a tab swipe animates, and
1494
+ /// not right after one (a link opened at launch must not wait behind
1495
+ /// tabs nobody asked for). The neighbours come first and soon; the tabs
1496
+ /// further away only once the screen has been still for a while.
1475
1497
  private func prepareAdjacentNativeTabs() {
1476
1498
  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 }
1485
- let nav = self.makeStack(tab: id, rootPath: id)
1486
- nav.view.isHidden = true
1487
- nav.view.layoutIfNeeded()
1488
- (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1489
- (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1499
+ guard let index = hybrid.tabIds.firstIndex(of: owner) else { return }
1500
+ let tabs = hybrid.tabIds
1501
+ let waiting = tabs.indices.filter { $0 != index && stacks[tabs[$0]] == nil && hybrid.routes.match(tabs[$0]) != nil }
1502
+ .sorted { abs($0 - index) < abs($1 - index) || (abs($0 - index) == abs($1 - index) && $0 > $1) }
1503
+ // One preparation pending at a time, for the tab on screen now.
1504
+ preparation?.invalidate()
1505
+ preparation = nil
1506
+ guard let next = waiting.first else { return }
1507
+ let neighbour = abs(next - index) == 1
1508
+ let timer = Timer(timeInterval: neighbour ? 0.15 : 0.6, repeats: false) { [weak self] _ in
1509
+ guard let self else { return }
1510
+ self.preparation = nil
1511
+ guard self.currentTab == owner, self.view.window != nil else { return }
1512
+ let quietFor = neighbour ? 0.3 : 1.0
1513
+ guard self.activeTabSwipeTarget == nil, !self.tabSwipeSettling,
1514
+ !self.stacks.values.contains(where: { $0.transitionCoordinator != nil }),
1515
+ CACurrentMediaTime() - self.lastNavigation >= quietFor else {
1490
1516
  self.prepareAdjacentNativeTabs()
1491
- break
1517
+ return
1518
+ }
1519
+ let id = tabs[next]
1520
+ guard self.stacks[id] == nil else { self.prepareAdjacentNativeTabs(); return }
1521
+ let nav = CraftFramePacing.shared.measure("prepare \(id)") { () -> UINavigationController in
1522
+ let nav = self.makeStack(tab: id, rootPath: id, hidden: true)
1523
+ nav.view.layoutIfNeeded()
1524
+ // Draw its text and pictures now. A hidden layer is not
1525
+ // drawn at commit, so the first swipe to this tab drew
1526
+ // every label of the screen in the gesture's first frame.
1527
+ Self.drawContents(of: nav.view.layer)
1528
+ return nav
1492
1529
  }
1530
+ (nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
1531
+ (self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
1532
+ self.prepareAdjacentNativeTabs()
1493
1533
  }
1534
+ preparation = timer
1494
1535
  RunLoop.main.add(timer, forMode: .default)
1495
1536
  }
1496
1537
 
1538
+ private var preparation: Timer?
1539
+
1540
+ /// When the shell last moved between screens: a tab, a push or a pop.
1541
+ private var lastNavigation: CFTimeInterval = 0
1542
+
1543
+ private static func drawContents(of layer: CALayer) {
1544
+ layer.displayIfNeeded()
1545
+ layer.sublayers?.forEach(drawContents(of:))
1546
+ }
1547
+
1497
1548
  private func cancelPendingTab() {
1498
1549
  pendingTab?.deadline.cancel()
1499
1550
  pendingTab = nil
@@ -1704,7 +1755,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1704
1755
  // Keep this screen up until the page has switched, rather than
1705
1756
  // showing it half way.
1706
1757
  cancelPendingTab()
1707
- if stacks[id] == nil { _ = makeStack(tab: id, rootPath: id); stacks[id]?.view.isHidden = true; view.bringSubviewToFront(visibleStack?.view ?? view) }
1758
+ if stacks[id] == nil { _ = makeStack(tab: id, rootPath: id, hidden: true); view.bringSubviewToFront(visibleStack?.view ?? view) }
1708
1759
  let deadline = DispatchWorkItem { [weak self] in self?.show(tab: id) }
1709
1760
  pendingTab = (id, deadline)
1710
1761
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: deadline)
@@ -1759,6 +1810,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1759
1810
  // MARK: UINavigationControllerDelegate
1760
1811
 
1761
1812
  func navigationController(_ navigationController: UINavigationController, willShow viewController: UIViewController, animated: Bool) {
1813
+ lastNavigation = CACurrentMediaTime()
1814
+ if animated { CraftFramePacing.shared.begin("navigation") }
1762
1815
  // The page draws its own header, and a tab's root screen its own
1763
1816
  // title unless it asked for the bar; a pushed native screen gets the
1764
1817
  // bar and its Back.
@@ -1773,6 +1826,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1773
1826
  }
1774
1827
 
1775
1828
  func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
1829
+ lastNavigation = CACurrentMediaTime()
1830
+ CraftFramePacing.shared.end("navigation")
1776
1831
  let key = ObjectIdentifier(navigationController)
1777
1832
  let before = shownStacks[key] ?? []
1778
1833
  let now = navigationController.viewControllers
@@ -1959,3 +2014,115 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1959
2014
  #endif
1960
2015
  }
1961
2016
  }
2017
+
2018
+ // MARK: - Frame pacing
2019
+
2020
+ /// How smoothly the app's own motion runs, measured the way Instruments'
2021
+ /// Animation Hitches template counts it: a frame that reaches the screen
2022
+ /// later than the display expected it is a hitch, and the time it was late
2023
+ /// is hitch time. Apple's bar is a hitch time ratio under 5 ms per second of
2024
+ /// motion; above 10 the motion reads as broken.
2025
+ ///
2026
+ /// Off unless the app is launched with `-CraftFramePacing YES` (or
2027
+ /// `CRAFT_FRAME_PACING=1`), so a shipped app never runs a display link for
2028
+ /// it. Each span (a scroll, a tab swipe, a push, a screen mount) logs one
2029
+ /// `[craft frames]` line, which simulator and device runs read back with
2030
+ /// `log show`. The Animation Hitches instrument does not run in the
2031
+ /// simulator; this does.
2032
+ final class CraftFramePacing: NSObject {
2033
+ static let shared = CraftFramePacing()
2034
+
2035
+ let enabled: Bool = {
2036
+ let info = ProcessInfo.processInfo
2037
+ return UserDefaults.standard.bool(forKey: "CraftFramePacing") || info.environment["CRAFT_FRAME_PACING"] == "1"
2038
+ }()
2039
+
2040
+ private struct Span {
2041
+ var started: CFTimeInterval
2042
+ var frames = 0
2043
+ var hitches = 0
2044
+ var hitchTime: CFTimeInterval = 0
2045
+ var worst: CFTimeInterval = 0
2046
+ }
2047
+
2048
+ private var spans: [String: Span] = [:]
2049
+ private var link: CADisplayLink?
2050
+ private var lastTimestamp: CFTimeInterval = 0
2051
+ private var lastTarget: CFTimeInterval = 0
2052
+
2053
+ /// Start counting frames for `name`; a span already open keeps counting.
2054
+ func begin(_ name: String) {
2055
+ guard enabled, spans[name] == nil else { return }
2056
+ spans[name] = Span(started: CACurrentMediaTime())
2057
+ guard link == nil else { return }
2058
+ let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
2059
+ // ProMotion runs scrolling at up to 120Hz; measure against what the
2060
+ // display actually asked for, not a fixed 60.
2061
+ if #available(iOS 15.0, *) { link.preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120) }
2062
+ link.add(to: .main, forMode: .common)
2063
+ self.link = link
2064
+ lastTimestamp = 0
2065
+ }
2066
+
2067
+ /// Close `name` and log its frames.
2068
+ func end(_ name: String) {
2069
+ guard enabled, let span = spans.removeValue(forKey: name) else { return }
2070
+ let duration = max(0.001, CACurrentMediaTime() - span.started)
2071
+ write(String(format: "%@ frames=%d duration=%.0fms hitches=%d hitchTime=%.1fms worst=%.1fms ratio=%.1fms/s",
2072
+ name, span.frames, duration * 1000, span.hitches, span.hitchTime * 1000, span.worst * 1000,
2073
+ span.hitchTime * 1000 / duration))
2074
+ if spans.isEmpty {
2075
+ link?.invalidate()
2076
+ link = nil
2077
+ }
2078
+ }
2079
+
2080
+ /// One main-thread block, timed: a screen mount, a bundle evaluation.
2081
+ @discardableResult
2082
+ func measure<T>(_ name: String, _ work: () -> T) -> T {
2083
+ guard enabled else { return work() }
2084
+ let started = CACurrentMediaTime()
2085
+ let result = work()
2086
+ write(String(format: "%@ main=%.1fms", name, (CACurrentMediaTime() - started) * 1000))
2087
+ return result
2088
+ }
2089
+
2090
+ /// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
2091
+ /// in the app's container, which a test run reads back whole (a
2092
+ /// simulator's unified log can drop an app's lines under load). Written
2093
+ /// off the main thread, so the measuring costs no frames itself.
2094
+ private let output = DispatchQueue(label: "craft.frame-pacing", qos: .utility)
2095
+ private lazy var file: FileHandle? = {
2096
+ guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else { return nil }
2097
+ let url = caches.appendingPathComponent("craft-frame-pacing.log")
2098
+ if !FileManager.default.fileExists(atPath: url.path) { FileManager.default.createFile(atPath: url.path, contents: nil) }
2099
+ let handle = try? FileHandle(forWritingTo: url)
2100
+ _ = try? handle?.seekToEnd()
2101
+ return handle
2102
+ }()
2103
+
2104
+ private func write(_ line: String) {
2105
+ NSLog("[craft frames] %@", line)
2106
+ let stamped = "\(Date().timeIntervalSince1970) \(line)\n"
2107
+ output.async { [weak self] in try? self?.file?.write(contentsOf: Data(stamped.utf8)) }
2108
+ }
2109
+
2110
+ @objc private func frame(_ link: CADisplayLink) {
2111
+ defer {
2112
+ lastTimestamp = link.timestamp
2113
+ lastTarget = link.targetTimestamp
2114
+ }
2115
+ guard lastTimestamp > 0 else { return }
2116
+ // The frame was due at the last target; anything later than that,
2117
+ // beyond a millisecond of scheduling noise, is hitch time.
2118
+ let late = link.timestamp - lastTarget
2119
+ for key in spans.keys {
2120
+ spans[key]!.frames += 1
2121
+ if late > 0.001 {
2122
+ spans[key]!.hitches += 1
2123
+ spans[key]!.hitchTime += late
2124
+ spans[key]!.worst = max(spans[key]!.worst, late)
2125
+ }
2126
+ }
2127
+ }
2128
+ }
@@ -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? {