@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
|
@@ -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
|
-
|
|
1819
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
4465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4519
|
-
//
|
|
4520
|
-
|
|
4521
|
-
|
|
4522
|
-
|
|
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
|
|
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(
|
|
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]
|
|
5623
|
-
guard let self = self
|
|
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
|
|
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
|
}
|