@craft-native/ios 0.0.225 → 0.0.227
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/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -616,6 +616,10 @@ final class CraftChrome: ObservableObject {
|
|
|
616
616
|
let symbol: String
|
|
617
617
|
var badge: String?
|
|
618
618
|
var iconText: String? = nil
|
|
619
|
+
var titleOverride: String? = nil
|
|
620
|
+
var iconTint: String? = nil
|
|
621
|
+
var iconPulse = false
|
|
622
|
+
var displayTitle: String { titleOverride ?? title }
|
|
619
623
|
var accessibilityLabel: String? = nil
|
|
620
624
|
var image: String? = nil
|
|
621
625
|
}
|
|
@@ -673,12 +677,21 @@ final class CraftChrome: ObservableObject {
|
|
|
673
677
|
/// the next screen shown says again. The page's own hideTabBar is apart.
|
|
674
678
|
private(set) var hiddenByScreen = false
|
|
675
679
|
|
|
676
|
-
/// Change one tab's icon without replacing
|
|
680
|
+
/// Change one tab's caption or icon without replacing stacks or selection.
|
|
677
681
|
func updateTab(_ item: [String: Any]) {
|
|
678
682
|
guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
|
|
679
683
|
if item.keys.contains("iconText") {
|
|
680
684
|
tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
|
|
681
685
|
}
|
|
686
|
+
if item.keys.contains("title") {
|
|
687
|
+
tabs[index].titleOverride = (item["title"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(40)) }
|
|
688
|
+
}
|
|
689
|
+
if item.keys.contains("iconTint") {
|
|
690
|
+
tabs[index].iconTint = (item["iconTint"] as? String).flatMap { UIColor(hex: $0) == nil ? nil : $0 }
|
|
691
|
+
}
|
|
692
|
+
if item.keys.contains("iconPulse") {
|
|
693
|
+
tabs[index].iconPulse = item["iconPulse"] as? Bool ?? false
|
|
694
|
+
}
|
|
682
695
|
if item.keys.contains("accessibilityLabel") {
|
|
683
696
|
tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
|
|
684
697
|
}
|
|
@@ -923,6 +936,7 @@ struct CraftTabBarView: View {
|
|
|
923
936
|
@ObservedObject private var chrome = CraftChrome.shared
|
|
924
937
|
@Namespace private var selection
|
|
925
938
|
@Environment(\.accessibilityReduceMotion) private var reduceMotion
|
|
939
|
+
@Environment(\.scenePhase) private var scenePhase
|
|
926
940
|
// Dynamic Type, relative to the styles UIKit's own bar uses. The bar is
|
|
927
941
|
// capped at the largest standard size (below), as UITabBar is; past that
|
|
928
942
|
// a long press shows the large content viewer instead.
|
|
@@ -1030,9 +1044,7 @@ struct CraftTabBarView: View {
|
|
|
1030
1044
|
.minimumScaleFactor(0.7)
|
|
1031
1045
|
.frame(height: iconSize + 6)
|
|
1032
1046
|
} else {
|
|
1033
|
-
|
|
1034
|
-
.font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
|
|
1035
|
-
.frame(height: iconSize + 6)
|
|
1047
|
+
tabSymbol(tab, selected: isSelected)
|
|
1036
1048
|
}
|
|
1037
1049
|
if let badge = tab.badge {
|
|
1038
1050
|
Text(badge)
|
|
@@ -1044,8 +1056,9 @@ struct CraftTabBarView: View {
|
|
|
1044
1056
|
.offset(x: 10, y: -4)
|
|
1045
1057
|
}
|
|
1046
1058
|
}
|
|
1047
|
-
Text(tab.
|
|
1059
|
+
Text(tab.displayTitle)
|
|
1048
1060
|
.font(.system(size: labelSize, weight: isSelected ? .semibold : .medium))
|
|
1061
|
+
.monospacedDigit()
|
|
1049
1062
|
.lineLimit(1)
|
|
1050
1063
|
.minimumScaleFactor(0.8)
|
|
1051
1064
|
}
|
|
@@ -1063,9 +1076,10 @@ struct CraftTabBarView: View {
|
|
|
1063
1076
|
.buttonStyle(.plain)
|
|
1064
1077
|
.accessibilityShowsLargeContentViewer {
|
|
1065
1078
|
Image(systemName: symbol(tab.symbol, selected: isSelected))
|
|
1066
|
-
Text(tab.
|
|
1079
|
+
Text(tab.displayTitle)
|
|
1067
1080
|
}
|
|
1068
|
-
.
|
|
1081
|
+
.accessibilityIdentifier("craft-tab-\(tab.id)")
|
|
1082
|
+
.accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.displayTitle), \($0)" } ?? tab.displayTitle)
|
|
1069
1083
|
.accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
|
|
1070
1084
|
}
|
|
1071
1085
|
}
|
|
@@ -1075,6 +1089,20 @@ struct CraftTabBarView: View {
|
|
|
1075
1089
|
chrome.tint.map { Color($0) } ?? Color.primary
|
|
1076
1090
|
}
|
|
1077
1091
|
|
|
1092
|
+
/// Live styling belongs to the icon, leaving selection and the caption clear.
|
|
1093
|
+
@ViewBuilder private func tabSymbol(_ tab: CraftChrome.Tab, selected: Bool) -> some View {
|
|
1094
|
+
let color = tab.iconTint.flatMap { UIColor(hex: $0) }.map { Color($0) } ?? (selected ? selectedColor : Color.primary)
|
|
1095
|
+
let icon = Image(systemName: symbol(tab.symbol, selected: selected))
|
|
1096
|
+
.font(.system(size: iconSize, weight: selected ? .semibold : .regular))
|
|
1097
|
+
.foregroundStyle(color)
|
|
1098
|
+
.frame(height: iconSize + 6)
|
|
1099
|
+
if #available(iOS 17.0, *) {
|
|
1100
|
+
icon.symbolEffect(.pulse, options: .repeating, isActive: tab.iconPulse && !reduceMotion && scenePhase == .active)
|
|
1101
|
+
} else {
|
|
1102
|
+
icon
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1078
1106
|
/// The filled variant for the current tab, when the symbol has one.
|
|
1079
1107
|
private func symbol(_ name: String, selected: Bool) -> String {
|
|
1080
1108
|
guard selected, !name.hasSuffix(".fill"), UIImage(systemName: "\(name).fill") != nil else { return name }
|
|
@@ -254,6 +254,12 @@ enum CraftNativeActions {
|
|
|
254
254
|
return
|
|
255
255
|
case ("Media", "usage"):
|
|
256
256
|
sync = .success(CraftNativeMedia.usage())
|
|
257
|
+
case ("Media", "clear"):
|
|
258
|
+
DispatchQueue.global(qos: .utility).async {
|
|
259
|
+
do { complete(requestToken, .success(try CraftNativeMedia.clear()), completion) }
|
|
260
|
+
catch { complete(requestToken, failure("IO_ERROR", "The video cache could not be cleared"), completion) }
|
|
261
|
+
}
|
|
262
|
+
return
|
|
257
263
|
case ("Lifecycle", "getState"):
|
|
258
264
|
sync = .success(currentAppState())
|
|
259
265
|
case ("DeepLinks", "getInitialURL"):
|
|
@@ -782,7 +788,7 @@ enum CraftNativeActions {
|
|
|
782
788
|
request.allowsExpensiveNetworkAccess = false
|
|
783
789
|
request.allowsConstrainedNetworkAccess = false
|
|
784
790
|
}
|
|
785
|
-
|
|
791
|
+
guard let flight = CraftNativeMedia.begin(address, { complete(requestToken, $0, completion) }) else { return }
|
|
786
792
|
let task = fetchSession.downloadTask(with: request) { location, response, error in
|
|
787
793
|
cancellationLock.lock()
|
|
788
794
|
fetchTasks.removeValue(forKey: requestToken)
|
|
@@ -790,25 +796,25 @@ enum CraftNativeActions {
|
|
|
790
796
|
if let error = error {
|
|
791
797
|
// Refused only because the connection is metered: not a failure, a wait.
|
|
792
798
|
if let reason = (error as? URLError)?.networkUnavailableReason, reason == .expensive || reason == .constrained {
|
|
793
|
-
CraftNativeMedia.finish(address, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
799
|
+
CraftNativeMedia.finish(address, flight: flight, .success(["ok": false, "url": address, "reason": "cellular"]))
|
|
794
800
|
return
|
|
795
801
|
}
|
|
796
802
|
let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
|
|
797
|
-
CraftNativeMedia.finish(address, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
803
|
+
CraftNativeMedia.finish(address, flight: flight, failure(CraftNativeMedia.errorCode(error, fallback: code), error.localizedDescription))
|
|
798
804
|
return
|
|
799
805
|
}
|
|
800
806
|
guard let http = response as? HTTPURLResponse, let location else {
|
|
801
|
-
CraftNativeMedia.finish(address, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
807
|
+
CraftNativeMedia.finish(address, flight: flight, failure("NETWORK_ERROR", "media.keep received a response that is not HTTP"))
|
|
802
808
|
return
|
|
803
809
|
}
|
|
804
810
|
// The system deletes `location` once this returns: move it now.
|
|
805
|
-
CraftNativeMedia.finish(address, CraftNativeMedia.store(address, at: location, response: http))
|
|
811
|
+
CraftNativeMedia.finish(address, flight: flight, CraftNativeMedia.store(address, at: location, response: http, flight: flight))
|
|
806
812
|
}
|
|
807
813
|
cancellationLock.lock()
|
|
808
814
|
let alreadyCancelled = cancelledRequests.contains(requestToken)
|
|
809
815
|
if !alreadyCancelled { fetchTasks[requestToken] = task }
|
|
810
816
|
cancellationLock.unlock()
|
|
811
|
-
if alreadyCancelled { CraftNativeMedia.finish(address, failure("CANCELLED", "media.keep was cancelled")) }
|
|
817
|
+
if alreadyCancelled { CraftNativeMedia.finish(address, flight: flight, failure("CANCELLED", "media.keep was cancelled")) }
|
|
812
818
|
else { task.resume() }
|
|
813
819
|
}
|
|
814
820
|
|
|
@@ -514,7 +514,11 @@ enum CraftNativeMedia {
|
|
|
514
514
|
|
|
515
515
|
private static let lock = NSLock()
|
|
516
516
|
/// Addresses being fetched now, so a second `keep` waits for the first.
|
|
517
|
-
private
|
|
517
|
+
private struct Flight {
|
|
518
|
+
let id: UUID
|
|
519
|
+
var waiting: [(Result<Any, CraftNativeActionError>) -> Void]
|
|
520
|
+
}
|
|
521
|
+
private static var inFlight: [String: Flight] = [:]
|
|
518
522
|
|
|
519
523
|
/// Where `address`'s copy lives (whether or not it is there yet). The
|
|
520
524
|
/// extension is the address's, because AVPlayer reads the type from it.
|
|
@@ -537,28 +541,29 @@ enum CraftNativeMedia {
|
|
|
537
541
|
return ["ok": true, "url": address, "path": url.path, "size": size]
|
|
538
542
|
}
|
|
539
543
|
|
|
540
|
-
///
|
|
541
|
-
static func
|
|
544
|
+
/// A new download's identity, or nil after joining an existing download.
|
|
545
|
+
static func begin(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> UUID? {
|
|
542
546
|
lock.lock()
|
|
543
547
|
defer { lock.unlock() }
|
|
544
548
|
if inFlight[address] != nil {
|
|
545
|
-
inFlight[address]!.append(done)
|
|
546
|
-
return
|
|
549
|
+
inFlight[address]!.waiting.append(done)
|
|
550
|
+
return nil
|
|
547
551
|
}
|
|
548
|
-
|
|
549
|
-
|
|
552
|
+
let id = UUID()
|
|
553
|
+
inFlight[address] = Flight(id: id, waiting: [done])
|
|
554
|
+
return id
|
|
550
555
|
}
|
|
551
556
|
|
|
552
|
-
static func finish(_ address: String, _ result: Result<Any, CraftNativeActionError>) {
|
|
557
|
+
static func finish(_ address: String, flight: UUID, _ result: Result<Any, CraftNativeActionError>) {
|
|
553
558
|
lock.lock()
|
|
554
|
-
let waiting = inFlight.removeValue(forKey: address) ?? []
|
|
559
|
+
let waiting = inFlight[address]?.id == flight ? inFlight.removeValue(forKey: address)?.waiting ?? [] : []
|
|
555
560
|
lock.unlock()
|
|
556
561
|
for done in waiting { done(result) }
|
|
557
562
|
}
|
|
558
563
|
|
|
559
564
|
/// A finished download moved into place (atomically, so a half-written
|
|
560
565
|
/// file is never played), or a refusal with the server's status.
|
|
561
|
-
static func store(_ address: String, at location: URL, response: HTTPURLResponse) -> Result<Any, CraftNativeActionError> {
|
|
566
|
+
static func store(_ address: String, at location: URL, response: HTTPURLResponse, flight: UUID? = nil) -> Result<Any, CraftNativeActionError> {
|
|
562
567
|
guard response.statusCode == 200 else {
|
|
563
568
|
return .success(["ok": false, "url": address, "status": response.statusCode])
|
|
564
569
|
}
|
|
@@ -566,6 +571,9 @@ enum CraftNativeMedia {
|
|
|
566
571
|
// Commit and prune share a lock; neither can remove a file being committed.
|
|
567
572
|
lock.lock()
|
|
568
573
|
defer { lock.unlock() }
|
|
574
|
+
if let flight, inFlight[address]?.id != flight {
|
|
575
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))
|
|
576
|
+
}
|
|
569
577
|
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
570
578
|
defer { try? fm.removeItem(at: staging) }
|
|
571
579
|
do {
|
|
@@ -596,6 +604,25 @@ enum CraftNativeMedia {
|
|
|
596
604
|
}
|
|
597
605
|
}
|
|
598
606
|
|
|
607
|
+
/// End an account's cache lifetime.
|
|
608
|
+
static func clear() throws -> Int {
|
|
609
|
+
lock.lock()
|
|
610
|
+
let waiting = inFlight.values.flatMap { $0.waiting }
|
|
611
|
+
inFlight.removeAll()
|
|
612
|
+
let fm = FileManager.default
|
|
613
|
+
let files = (try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
|
|
614
|
+
var removed = 0
|
|
615
|
+
var failure: Error?
|
|
616
|
+
for url in files {
|
|
617
|
+
do { try fm.removeItem(at: url); removed += 1 }
|
|
618
|
+
catch { failure = error }
|
|
619
|
+
}
|
|
620
|
+
lock.unlock()
|
|
621
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The video cache was cleared"))) }
|
|
622
|
+
if let failure { throw failure }
|
|
623
|
+
return removed
|
|
624
|
+
}
|
|
625
|
+
|
|
599
626
|
/// Removes every copy whose address is not in `keep`; answers how many.
|
|
600
627
|
static func prune(keep: [String]) -> Int {
|
|
601
628
|
lock.lock()
|
|
@@ -6071,7 +6071,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6071
6071
|
if let current = image.image { image.image = imageForDisplay(current, view: image) }
|
|
6072
6072
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6073
6073
|
image.contentMode = imageContentMode(requestedResizeMode)
|
|
6074
|
-
image.clipsToBounds = image.contentMode == .scaleAspectFill
|
|
6074
|
+
image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
|
|
6075
6075
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
|
|
6076
6076
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
|
|
6077
6077
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
@@ -6090,7 +6090,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6090
6090
|
if let current = background.image { background.image = imageForDisplay(current, view: background) }
|
|
6091
6091
|
let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
6092
6092
|
background.contentMode = imageContentMode(requestedResizeMode)
|
|
6093
|
-
background.clipsToBounds = background.contentMode == .scaleAspectFill
|
|
6093
|
+
background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
|
|
6094
6094
|
updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
|
|
6095
6095
|
updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
|
|
6096
6096
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
|