@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.225",
3
+ "version": "0.0.227",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 tabs, navigation stacks or selection.
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
- Image(systemName: symbol(tab.symbol, selected: isSelected))
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.title)
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.title)
1079
+ Text(tab.displayTitle)
1067
1080
  }
1068
- .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
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
- if CraftNativeMedia.join(address, { complete(requestToken, $0, completion) }) { return }
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 static var inFlight: [String: [(Result<Any, CraftNativeActionError>) -> Void]] = [:]
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
- /// Joins a fetch of `address` already under way; true when one was.
541
- static func join(_ address: String, _ done: @escaping (Result<Any, CraftNativeActionError>) -> Void) -> Bool {
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 true
549
+ inFlight[address]!.waiting.append(done)
550
+ return nil
547
551
  }
548
- inFlight[address] = [done]
549
- return false
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)