@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.
- package/dist/cli.js +22 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +24 -7
- package/package.json +1 -1
- package/templates/CraftApp.swift +67 -22
- package/templates/CraftHybrid.swift +200 -33
- package/templates/CraftNativeActions.swift +26 -27
- package/templates/CraftNativeFiles.swift +205 -1
- package/templates/CraftNativeScreen.swift +184 -27
|
@@ -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
|
|
@@ -1398,7 +1402,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1398
1402
|
|
|
1399
1403
|
// MARK: Stacks
|
|
1400
1404
|
|
|
1401
|
-
|
|
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
|
|
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
|
|
1473
|
-
///
|
|
1474
|
-
///
|
|
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
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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? {
|