@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.
- package/dist/cli.js +22 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +32 -7
- package/dist/testing.d.ts +14 -2
- package/package.json +1 -1
- package/templates/CraftApp.swift +117 -29
- package/templates/CraftHybrid.swift +193 -29
- package/templates/CraftNativeActions.swift +26 -27
- package/templates/CraftNativeFiles.swift +205 -1
- package/templates/CraftNativeScreen.swift +230 -33
- package/templates/testing/DriverTests.swift.template +37 -0
|
@@ -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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
1473
|
-
///
|
|
1474
|
-
///
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
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
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
|
768
|
-
///
|
|
769
|
-
///
|
|
770
|
-
///
|
|
771
|
-
///
|
|
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
|
-
|
|
804
|
+
let wifiOnly = options["cellular"] as? Bool == false
|
|
805
|
+
if wifiOnly {
|
|
801
806
|
request.allowsExpensiveNetworkAccess = false
|
|
802
807
|
request.allowsConstrainedNetworkAccess = false
|
|
803
808
|
}
|
|
804
|
-
|
|
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
|
-
|
|
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 {
|
|
831
|
-
|
|
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
|
+
}
|