@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.
@@ -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
+ }
@@ -1755,6 +1755,25 @@ private var craftNativeCornerRadiiKey: UInt8 = 0
1755
1755
  private var craftNativeBorderLayerKey: UInt8 = 0
1756
1756
  private var craftNativeSideBorderLayersKey: UInt8 = 0
1757
1757
 
1758
+ private let craftAllCorners: CACornerMask = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
1759
+
1760
+ /// One radius for every rounded corner of `radii` (top-left, top-right,
1761
+ /// bottom-right, bottom-left), and which corners those are; nil when the
1762
+ /// rounded corners differ.
1763
+ func craftSharedCornerRadius(_ radii: [CGFloat]) -> (radius: CGFloat, corners: CACornerMask)? {
1764
+ let order: [CACornerMask] = [.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner]
1765
+ var radius: CGFloat?
1766
+ var corners: CACornerMask = []
1767
+ for (index, corner) in order.enumerated() {
1768
+ let value = max(0, radii.count > index ? radii[index] : 0)
1769
+ guard value > 0 else { continue }
1770
+ if let radius, abs(radius - value) > 0.01 { return nil }
1771
+ radius = value
1772
+ corners.insert(corner)
1773
+ }
1774
+ return radius.map { ($0, corners) } ?? (0, craftAllCorners)
1775
+ }
1776
+
1758
1777
  private func craftRoundedPath(_ rect: CGRect, _ rawRadii: [CGFloat]) -> CGPath {
1759
1778
  let limit = min(rect.width, rect.height) / 2
1760
1779
  let radii = (0..<4).map { min(max(0, rawRadii.count > $0 ? rawRadii[$0] : 0), limit) }
@@ -1805,25 +1824,62 @@ extension UIView {
1805
1824
  let requested = craftRequestedCornerRadius ?? 0
1806
1825
  let limit = min(bounds.width, bounds.height) / 2
1807
1826
  let radius = limit > 0 ? min(requested, limit) : min(requested, 9_998)
1808
- if let radii = craftRequestedCornerRadii {
1827
+ if let radii = craftRequestedCornerRadii, let shared = craftSharedCornerRadius(radii) {
1828
+ // The rounded corners share one radius (`rounded-t-md`): the
1829
+ // layer rounds just those corners itself. A mask layer is an
1830
+ // offscreen render pass for every frame the view is on screen,
1831
+ // which Apple names first among render-phase hitches. Clipped
1832
+ // like the mask was.
1833
+ let clamped = limit > 0 ? min(shared.radius, limit) : min(shared.radius, 9_998)
1834
+ layer.mask = nil
1835
+ if layer.cornerRadius != clamped { layer.cornerRadius = clamped }
1836
+ if layer.maskedCorners != shared.corners { layer.maskedCorners = shared.corners }
1837
+ if !layer.masksToBounds { layer.masksToBounds = true }
1838
+ } else if let radii = craftRequestedCornerRadii {
1839
+ // Corners of different radii: only a path can draw them.
1809
1840
  let mask = (layer.mask as? CAShapeLayer) ?? CAShapeLayer()
1810
- mask.frame = bounds
1841
+ if mask.frame != bounds { mask.frame = bounds }
1811
1842
  mask.path = craftRoundedPath(bounds, radii)
1812
1843
  layer.mask = mask
1813
1844
  layer.cornerRadius = 0
1814
1845
  } else {
1815
- layer.mask = nil
1846
+ if layer.mask != nil { layer.mask = nil }
1847
+ if layer.maskedCorners != craftAllCorners { layer.maskedCorners = craftAllCorners }
1816
1848
  if layer.cornerRadius != radius { layer.cornerRadius = radius }
1817
1849
  }
1818
- if let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer {
1819
- border.frame = bounds
1850
+ // A box shadow, as CSS draws it: the shape of the rounded box. Given
1851
+ // no path, Core Animation renders the layer offscreen every frame to
1852
+ // find the shadow's shape from its pixels.
1853
+ if layer.shadowOpacity > 0, bounds.width > 0, bounds.height > 0 {
1854
+ let cornerRadius = layer.mask == nil ? layer.cornerRadius : 0
1855
+ let shape: CGPath = layer.mask == nil
1856
+ ? (layer.maskedCorners == craftAllCorners || cornerRadius == 0
1857
+ ? CGPath(roundedRect: bounds, cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
1858
+ : craftRoundedPath(bounds, [CACornerMask.layerMinXMinYCorner, .layerMaxXMinYCorner, .layerMaxXMaxYCorner, .layerMinXMaxYCorner].map { layer.maskedCorners.contains($0) ? cornerRadius : 0 }))
1859
+ : craftRoundedPath(bounds, craftRequestedCornerRadii ?? [])
1860
+ if layer.shadowPath != shape { layer.shadowPath = shape }
1861
+ } else if layer.shadowPath != nil {
1862
+ layer.shadowPath = nil
1863
+ }
1864
+ let border = objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer
1865
+ let sides = (objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer]).flatMap { $0.count == 4 ? $0 : nil }
1866
+ guard border != nil || sides != nil else { return }
1867
+ // Border layers are the view's own sublayers, so a new frame or path
1868
+ // would animate implicitly; and a path set again, even an equal one,
1869
+ // draws the shape again. Layout runs for every child on every pass.
1870
+ CATransaction.begin()
1871
+ CATransaction.setDisableActions(true)
1872
+ defer { CATransaction.commit() }
1873
+ if let border {
1874
+ if border.frame != bounds { border.frame = bounds }
1820
1875
  let inset = border.lineWidth / 2
1821
- border.path = UIBezierPath(
1876
+ let path = UIBezierPath(
1822
1877
  roundedRect: bounds.insetBy(dx: inset, dy: inset),
1823
1878
  cornerRadius: max(0, radius - inset)
1824
1879
  ).cgPath
1880
+ if border.path != path { border.path = path }
1825
1881
  }
1826
- if let sides = objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer], sides.count == 4 {
1882
+ if let sides {
1827
1883
  let top = sides[0].frame.height
1828
1884
  let right = sides[1].frame.width
1829
1885
  let bottom = sides[2].frame.height
@@ -1835,9 +1891,10 @@ extension UIView {
1835
1891
  CGRect(x: 0, y: 0, width: left, height: bounds.height),
1836
1892
  ]
1837
1893
  for (index, side) in sides.enumerated() {
1838
- side.frame = frames[index]
1894
+ if side.frame != frames[index] { side.frame = frames[index] }
1839
1895
  if let shape = side as? CAShapeLayer {
1840
- shape.path = craftSideBorderPath(shape.bounds, index: index)
1896
+ let path = craftSideBorderPath(shape.bounds, index: index)
1897
+ if shape.path != path { shape.path = path }
1841
1898
  }
1842
1899
  }
1843
1900
  }
@@ -4292,6 +4349,52 @@ private final class CraftNativeTextView: UITextView {
4292
4349
  }
4293
4350
  }
4294
4351
 
4352
+ /// The native screens' JavaScript. A screen is a JavaScriptCore context of
4353
+ /// its own and evaluates the whole file it is given, so each route has a file
4354
+ /// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
4355
+ /// `routesDir`): a screen then parses its own code rather than every
4356
+ /// screen's. The one-file `dist/native-screen.js` remains the fallback.
4357
+ enum CraftNativeBundle {
4358
+ /// Every screen's context lives in this one virtual machine. They all run
4359
+ /// on the main thread, the machine's only one, and sharing it shares its
4360
+ /// code cache: a screen opened again (a second workout, a tab rebuilt)
4361
+ /// is not parsed again. Each screen keeps a global object of its own.
4362
+ static let virtualMachine = JSVirtualMachine()!
4363
+
4364
+ private static let sources: NSCache<NSString, NSString> = {
4365
+ let cache = NSCache<NSString, NSString>()
4366
+ // Room for the one-file bundle, or a dozen route files.
4367
+ cache.totalCostLimit = 24 << 20
4368
+ return cache
4369
+ }()
4370
+
4371
+ /// The file a route runs from: its own, else the one-file bundle.
4372
+ static func url(for route: String?) -> URL? {
4373
+ if let route, !route.isEmpty, !route.contains("/"), !route.hasPrefix("."),
4374
+ let own = Bundle.main.url(forResource: route, withExtension: "js", subdirectory: "dist/native-screens") {
4375
+ return own
4376
+ }
4377
+ return Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
4378
+ ?? Bundle.main.url(forResource: "native-screen", withExtension: "js")
4379
+ }
4380
+
4381
+ /// Whether the app has native screens to run at all.
4382
+ static var isAvailable: Bool {
4383
+ url(for: nil) != nil || Bundle.main.url(forResource: "native-screens", withExtension: nil, subdirectory: "dist") != nil
4384
+ }
4385
+
4386
+ /// The route's source, read from disk once and kept while memory allows:
4387
+ /// a push read and decoded the file on the main thread every time.
4388
+ static func source(for route: String?) -> (source: String, url: URL)? {
4389
+ guard let url = url(for: route) else { return nil }
4390
+ let key = url.path as NSString
4391
+ if let cached = sources.object(forKey: key) { return (cached as String, url) }
4392
+ guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }
4393
+ sources.setObject(text as NSString, forKey: key, cost: text.utf8.count)
4394
+ return (text, url)
4395
+ }
4396
+ }
4397
+
4295
4398
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4296
4399
  private final class RenderedNode {
4297
4400
  let identity: String
@@ -4313,7 +4416,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4313
4416
  private let config: CraftConfig
4314
4417
  private let routeName: String?
4315
4418
  private(set) var routeParams: [String: Any]
4316
- private let jsContext = JSContext()!
4419
+ private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
4317
4420
  private let rootStack = CraftNativeFlowView()
4318
4421
  private var handlers: [ObjectIdentifier: String] = [:]
4319
4422
  private var pressInHandlers: [ObjectIdentifier: String] = [:]
@@ -4461,8 +4564,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4461
4564
  rootLeadingToSafeArea!,
4462
4565
  rootTrailingToSafeArea!
4463
4566
  ])
4464
- setupJavaScript()
4465
- loadBundle()
4567
+ let pacing = CraftFramePacing.shared
4568
+ pacing.measure("mount \(routeName ?? "screen") setup") { setupJavaScript() }
4569
+ pacing.measure("mount \(routeName ?? "screen") bundle") { loadBundle() }
4466
4570
  observeNativeEvents()
4467
4571
  if isAppScript {
4468
4572
  // Its bundle has run, so its listeners are in place: the app's
@@ -4494,13 +4598,28 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4494
4598
  }
4495
4599
  applyNavigationBarAppearance()
4496
4600
  // Each screen says whether it wants the tab bar, so one that hid it
4497
- // never leaves it hidden for the next.
4498
- restoreShellChrome()
4601
+ // never leaves it hidden for the next. Only the screen on screen:
4602
+ // UIKit tells a tab's screen prepared in a hidden stack that it
4603
+ // appears too, and that one took the accessory of the screen showing.
4604
+ if isShownOnScreen { restoreShellChrome() }
4499
4605
  refreshTraitDefaults()
4500
4606
  }
4501
4607
 
4608
+ /// In the window with nothing above it hidden: the screen the person
4609
+ /// sees, not a tab prepared behind it.
4610
+ private var isShownOnScreen: Bool {
4611
+ guard let window = view.window else { return false }
4612
+ var candidate: UIView? = view
4613
+ while let current = candidate, current !== window {
4614
+ if current.isHidden { return false }
4615
+ candidate = current.superview
4616
+ }
4617
+ return true
4618
+ }
4619
+
4502
4620
  override func viewWillDisappear(_ animated: Bool) {
4503
4621
  super.viewWillDisappear(animated)
4622
+ screenVisibilityChanged(false)
4504
4623
  stopCelebrations(in: view)
4505
4624
  // The accessory belongs to the visible native screen. Clear the
4506
4625
  // shared shell state before a hybrid/web route appears, otherwise a
@@ -4515,11 +4634,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4515
4634
 
4516
4635
  override func viewDidAppear(_ animated: Bool) {
4517
4636
  super.viewDidAppear(animated)
4518
- screenVisibilityChanged(true)
4519
- // A navigation breadcrumb for the crash reporter: the trail a crash
4520
- // report carries is the screens that led to it.
4521
- if !isAppScript, let routeName, !routeName.isEmpty {
4522
- CraftCrashReporter.shared.screenAppeared(routeName)
4637
+ // A tab prepared in a hidden stack "appears" to UIKit as well; the
4638
+ // shell says when it really shows (screenVisibilityChanged).
4639
+ if isShownOnScreen {
4640
+ screenVisibilityChanged(true)
4641
+ // A navigation breadcrumb for the crash reporter: the trail a
4642
+ // crash report carries is the screens that led to it.
4643
+ if !isAppScript, let routeName, !routeName.isEmpty {
4644
+ CraftCrashReporter.shared.screenAppeared(routeName)
4645
+ }
4523
4646
  }
4524
4647
  // onLayout reports views that are on screen: a pushed screen lays
4525
4648
  // out before it has a window, and nothing moved again once it had
@@ -4563,6 +4686,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4563
4686
  if visible { restoreShellChrome() }
4564
4687
  guard visible != reportedVisible else { return }
4565
4688
  reportedVisible = visible
4689
+ if visible { DeepLinkManager.shared.replayNativeLinks() }
4566
4690
  if visible, !hasAppeared {
4567
4691
  hasAppeared = true
4568
4692
  return
@@ -5133,13 +5257,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5133
5257
  }
5134
5258
 
5135
5259
  private func loadBundle() {
5136
- guard let url = Bundle.main.url(forResource: "native-screen", withExtension: "js", subdirectory: "dist")
5137
- ?? Bundle.main.url(forResource: "native-screen", withExtension: "js"),
5138
- let script = try? String(contentsOf: url, encoding: .utf8) else {
5260
+ guard let bundle = CraftNativeBundle.source(for: routeName) else {
5139
5261
  showError("Missing dist/native-screen.js. Compile a .stx screen with `stx native compile` first.")
5140
5262
  return
5141
5263
  }
5142
- jsContext.evaluateScript(script)
5264
+ jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
5143
5265
  // A bundle that names no route leaves `__stxNativeRoute` undefined;
5144
5266
  // its title is the app's, not the word "undefined". A title the
5145
5267
  // script set with `navigation.setOptions` wins over both.
@@ -5485,7 +5607,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5485
5607
  private func loadBarImage(_ url: URL, _ done: @escaping (UIImage?) -> Void) {
5486
5608
  if let cached = Self.barImages.object(forKey: url as NSURL) { done(cached); return }
5487
5609
  URLSession.shared.dataTask(with: url) { data, _, _ in
5488
- let image = data.flatMap(UIImage.init(data:))
5610
+ let image = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
5489
5611
  if let image { Self.barImages.setObject(image, forKey: url as NSURL) }
5490
5612
  DispatchQueue.main.async { done(image) }
5491
5613
  }.resume()
@@ -5619,8 +5741,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5619
5741
  sendAppState(CraftNativeActions.currentAppState())
5620
5742
  // Links are the screens' to show; the app script hears none.
5621
5743
  guard !isAppScript else { return }
5622
- deepLinkListener = DeepLinkManager.shared.addNativeListener { [weak self] url, initial in
5623
- guard let self = self, self.navigationController?.topViewController === self else { return }
5744
+ deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
5745
+ guard let self = self else { return false }
5746
+ return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
5747
+ }) { [weak self] url, initial in
5748
+ guard let self = self else { return }
5624
5749
  var payload = CraftNativeActions.deepLinkData(url)
5625
5750
  payload["initial"] = initial
5626
5751
  self.send(type: "DEEP_LINK", payload: payload)
@@ -7425,6 +7550,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7425
7550
  || (view is UIScrollView && overflow != "visible")
7426
7551
  || (view is UILabel && (view.craftRequestedCornerRadius ?? 0) > 0)
7427
7552
  || (view is UIImageView && (borderRadius > 0 || hasCornerRadii))
7553
+ // Per-corner rounding clips its content, as its mask did.
7554
+ || hasCornerRadii
7428
7555
  view.layer.masksToBounds = view.clipsToBounds
7429
7556
  view.craftLayoutStyle = CraftNativeLayoutStyle(style)
7430
7557
  (view as? CraftNativeFlowView)?.invalidateMeasurements()
@@ -7687,6 +7814,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7687
7814
  }
7688
7815
  }
7689
7816
 
7817
+ /// Downloaded images, decoded, for every screen: a screen opened again or
7818
+ /// a row scrolled back draws its picture in its first frame rather than
7819
+ /// fetching and decoding it again. Bounded by decoded bytes.
7820
+ private static let decodedImages: NSCache<NSString, UIImage> = {
7821
+ let cache = NSCache<NSString, UIImage>()
7822
+ cache.totalCostLimit = 48 << 20
7823
+ return cache
7824
+ }()
7825
+
7690
7826
  /// An image the screen downloads: https anywhere, and http only from this
7691
7827
  /// machine (localhost, 127.0.0.1, ::1, *.localhost), the development
7692
7828
  /// server the app's ATS exception already allows. Internal for XCTest.
@@ -7738,14 +7874,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7738
7874
  return
7739
7875
  }
7740
7876
  if let url = URL(string: uri), Self.loadsImage(from: url) {
7877
+ // Shown before: on screen in this same frame, already decoded.
7878
+ if let decoded = Self.decodedImages.object(forKey: requestKey as NSString) {
7879
+ view.image = imageForDisplay(decoded, view: view)
7880
+ imageDidChange(view)
7881
+ emitImageEvent(view, handler: imageLoadHandlers[id])
7882
+ emitImageEvent(view, handler: imageLoadEndHandlers[id])
7883
+ return
7884
+ }
7741
7885
  var request = URLRequest(url: url)
7742
7886
  for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
7743
7887
  let task = URLSession.shared.dataTask(with: request) { [weak self, weak view] data, _, _ in
7888
+ // Decoded here, off the main thread. A UIImage made from data
7889
+ // is only decoded when it first draws, on the main thread, in
7890
+ // the frame it scrolls into view: Apple's "prepare" hitch.
7891
+ let decoded = data.flatMap(UIImage.init(data:)).map { $0.preparingForDisplay() ?? $0 }
7892
+ if let decoded {
7893
+ let pixels = decoded.size.width * decoded.scale * decoded.size.height * decoded.scale
7894
+ Self.decodedImages.setObject(decoded, forKey: requestKey as NSString, cost: Int(pixels * 4))
7895
+ }
7744
7896
  guard let self, let view else { return }
7745
7897
  DispatchQueue.main.async {
7746
7898
  guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
7747
7899
  self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
7748
- guard let data, let image = UIImage(data: data) else {
7900
+ guard let image = decoded else {
7749
7901
  self.imageFailure(view, uri: uri, message: "Image download failed")
7750
7902
  return
7751
7903
  }
@@ -8259,12 +8411,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8259
8411
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8260
8412
  }
8261
8413
 
8414
+ private var pacingName: String { "scroll \(routeName ?? "screen")" }
8415
+
8262
8416
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
8417
+ CraftFramePacing.shared.begin(pacingName)
8263
8418
  guard let handler = scrollBeginHandlers[ObjectIdentifier(scrollView)] else { return }
8264
8419
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8265
8420
  }
8266
8421
 
8267
8422
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
8423
+ if !decelerate { CraftFramePacing.shared.end(pacingName) }
8268
8424
  guard let handler = scrollEndHandlers[ObjectIdentifier(scrollView)] else { return }
8269
8425
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8270
8426
  }
@@ -8275,6 +8431,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8275
8431
  }
8276
8432
 
8277
8433
  func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
8434
+ CraftFramePacing.shared.end(pacingName)
8278
8435
  guard let handler = scrollMomentumEndHandlers[ObjectIdentifier(scrollView)] else { return }
8279
8436
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": scrollEvent(scrollView)])
8280
8437
  }