@craft-native/ios 0.0.476 → 0.0.481

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.476",
3
+ "version": "0.0.481",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -820,6 +820,14 @@ final class CraftChrome: ObservableObject {
820
820
  /// A compact floating icon, instead of the system full-width accessory shelf.
821
821
  var iconOnly = false
822
822
  var companions: [Tab] = []
823
+ /// A tap opens these destinations in a compact menu instead of navigating.
824
+ var menu: [TabMenuItem] = []
825
+ }
826
+
827
+ struct TabMenuItem: Equatable, Identifiable {
828
+ let id: String
829
+ let title: String
830
+ let symbol: String
823
831
  }
824
832
 
825
833
  @Published var tabs: [Tab] = []
@@ -965,7 +973,13 @@ final class CraftChrome: ObservableObject {
965
973
  companionPath.hasPrefix("/"), !companionPath.hasPrefix("//"), companionPath != path,
966
974
  let companionTitle = action["title"] as? String else { return nil }
967
975
  return Tab(id: companionPath, title: companionTitle, symbol: action["symbol"] as? String ?? "circle",
968
- accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true)
976
+ accessibilityLabel: action["accessibilityLabel"] as? String, iconOnly: true,
977
+ menu: ((action["menu"] as? [[String: Any]]) ?? []).prefix(12).compactMap { choice in
978
+ guard let destination = choice["path"] as? String,
979
+ destination.hasPrefix("/"), !destination.hasPrefix("//"),
980
+ let label = choice["title"] as? String, !label.isEmpty else { return nil }
981
+ return TabMenuItem(id: destination, title: label, symbol: choice["symbol"] as? String ?? "circle")
982
+ })
969
983
  })
970
984
  } else {
971
985
  next = nil
@@ -1273,35 +1287,46 @@ private struct CraftTabBarContent: View {
1273
1287
  @ScaledMetric(relativeTo: .caption2) private var labelSize: CGFloat = 11
1274
1288
  @ScaledMetric(relativeTo: .caption2) private var badgeSize: CGFloat = 10
1275
1289
 
1290
+ private func accessoryLabel(_ accessory: CraftChrome.Tab) -> some View {
1291
+ surface {
1292
+ Image(systemName: accessory.symbol)
1293
+ .font(.system(size: iconSize))
1294
+ .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1295
+ .frame(width: 62, height: 62)
1296
+ .background {
1297
+ if chrome.accessorySelected(accessory) {
1298
+ Circle().fill(selectedColor.opacity(0.14)).padding(4)
1299
+ }
1300
+ }
1301
+ }
1302
+ .contentShape(Rectangle())
1303
+ .overlay(alignment: .topTrailing) {
1304
+ if let badge = accessory.badge {
1305
+ Text(badge)
1306
+ .font(.system(size: badgeSize, weight: .bold))
1307
+ .foregroundStyle(.white)
1308
+ .padding(.horizontal, 4)
1309
+ .frame(minWidth: 18, minHeight: 18)
1310
+ .background(Capsule().fill(Color.red))
1311
+ }
1312
+ }
1313
+ }
1276
1314
  var body: some View {
1277
1315
  if !chrome.tabs.isEmpty {
1278
1316
  HStack(spacing: 8) {
1279
1317
  bar
1280
1318
  if let primary = chrome.tabAccessory {
1281
1319
  ForEach(primary.companions + [primary]) { accessory in
1282
- Button { CraftHybrid.shared.open(accessory.id) } label: {
1283
- surface {
1284
- Image(systemName: accessory.symbol)
1285
- .font(.system(size: iconSize))
1286
- .foregroundStyle(chrome.accessorySelected(accessory) ? selectedColor : Color.primary)
1287
- .frame(width: 62, height: 62)
1288
- .background {
1289
- if chrome.accessorySelected(accessory) {
1290
- Circle().fill(selectedColor.opacity(0.14)).padding(4)
1291
- }
1320
+ Group {
1321
+ if accessory.menu.isEmpty {
1322
+ Button { CraftHybrid.shared.open(accessory.id) } label: { accessoryLabel(accessory) }
1323
+ } else {
1324
+ Menu {
1325
+ ForEach(accessory.menu) { choice in
1326
+ Button { CraftHybrid.shared.open(choice.id) } label: { Label(choice.title, systemImage: choice.symbol) }
1292
1327
  }
1328
+ } label: { accessoryLabel(accessory) }
1293
1329
  }
1294
- .contentShape(Rectangle())
1295
- .overlay(alignment: .topTrailing) {
1296
- if let badge = accessory.badge {
1297
- Text(badge)
1298
- .font(.system(size: badgeSize, weight: .bold))
1299
- .foregroundStyle(.white)
1300
- .padding(.horizontal, 4)
1301
- .frame(minWidth: 18, minHeight: 18)
1302
- .background(Capsule().fill(Color.red))
1303
- }
1304
- }
1305
1330
  }
1306
1331
  .buttonStyle(.plain)
1307
1332
  .accessibilityShowsLargeContentViewer {
@@ -1430,7 +1430,17 @@ final class CraftHybridController: UITabBarController, UITabBarControllerDelegat
1430
1430
  button.accessibilityLabel = action.accessibilityLabel ?? action.title
1431
1431
  button.accessibilityValue = action.badge
1432
1432
  button.accessibilityTraits = chrome.accessorySelected(action) ? [.button, .selected] : [.button]
1433
- button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1433
+ if action.menu.isEmpty {
1434
+ button.addAction(UIAction { [weak self] _ in self?.hybrid.open(action.id) }, for: .touchUpInside)
1435
+ } else {
1436
+ button.menu = UIMenu(children: action.menu.map { choice in
1437
+ UIAction(title: choice.title, image: UIImage(systemName: choice.symbol)) { [weak self] _ in
1438
+ self?.hybrid.open(choice.id)
1439
+ }
1440
+ })
1441
+ button.showsMenuAsPrimaryAction = true
1442
+ if #available(iOS 16.0, *) { button.preferredMenuElementOrder = .fixed }
1443
+ }
1434
1444
  let glass = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
1435
1445
  #if compiler(>=6.2)
1436
1446
  if #available(iOS 26.0, *) { glass.effect = UIGlassEffect() }
@@ -36,6 +36,7 @@ enum CraftNativeActions {
36
36
  private static var biometricContexts = [String: LAContext]()
37
37
  #endif
38
38
  private static var fetchTasks = [String: URLSessionTask]()
39
+ private static var fetchProgress = [String: NSKeyValueObservation]()
39
40
  private static let fetchSession: URLSession = {
40
41
  let configuration = URLSessionConfiguration.default
41
42
  configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
@@ -50,6 +51,7 @@ enum CraftNativeActions {
50
51
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
51
52
  // as it arrives) need nothing the app has to declare.
52
53
  var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation", "nativePlatformControls"]
54
+ values.append("uploadProgress")
53
55
  values.append("network")
54
56
  values.append("nativePager")
55
57
  values.append("storageCompareAndSet")
@@ -180,6 +182,7 @@ enum CraftNativeActions {
180
182
  method: String,
181
183
  args: [Any],
182
184
  config: CraftConfig,
185
+ onUploadProgress: (([String: Any]) -> Void)? = nil,
183
186
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
184
187
  ) {
185
188
  cancellationLock.lock()
@@ -293,7 +296,9 @@ enum CraftNativeActions {
293
296
  CraftWebServices.perform(action: "getNetworkStatus", body: [:]) { complete(requestToken, $0, completion) }
294
297
  return
295
298
  case ("Network", "fetch"):
296
- fetch(requestToken: requestToken, args: args, completion: completion)
299
+ DispatchQueue.global(qos: .utility).async {
300
+ fetch(requestToken: requestToken, args: args, onUploadProgress: onUploadProgress, completion: completion)
301
+ }
297
302
  return
298
303
  case ("Files", "pickImage"):
299
304
  // A person picks, so the bridge never times it out.
@@ -783,6 +788,7 @@ enum CraftNativeActions {
783
788
  let context = biometricContexts.removeValue(forKey: requestToken)
784
789
  #endif
785
790
  let task = fetchTasks.removeValue(forKey: requestToken)
791
+ fetchProgress.removeValue(forKey: requestToken)
786
792
  cancellationLock.unlock()
787
793
  #if CRAFT_PERMISSION_BIOMETRIC
788
794
  context?.invalidate()
@@ -798,8 +804,13 @@ enum CraftNativeActions {
798
804
  private static func fetch(
799
805
  requestToken: String,
800
806
  args: [Any],
807
+ onUploadProgress: (([String: Any]) -> Void)?,
801
808
  completion: @escaping (Result<Any, CraftNativeActionError>) -> Void
802
809
  ) {
810
+ guard !isCancelled(requestToken) else {
811
+ complete(requestToken, failure("CANCELLED", "Upload cancelled"), completion)
812
+ return
813
+ }
803
814
  guard let options = args.first as? [String: Any],
804
815
  let address = options["url"] as? String,
805
816
  let url = URL(string: address),
@@ -855,6 +866,7 @@ enum CraftNativeActions {
855
866
  if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
856
867
  cancellationLock.lock()
857
868
  fetchTasks.removeValue(forKey: requestToken)
869
+ fetchProgress.removeValue(forKey: requestToken)
858
870
  cancellationLock.unlock()
859
871
  if let error = error {
860
872
  let code = (error as? URLError)?.code == .timedOut ? "TIMEOUT" : "NETWORK_ERROR"
@@ -891,12 +903,22 @@ enum CraftNativeActions {
891
903
  }
892
904
  let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
893
905
  ?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
906
+ let uploadID = options["uploadProgressId"] as? String ?? ""
907
+ let expectedBytes = uploadFile.flatMap { try? $0.resourceValues(forKeys: [.fileSizeKey]).fileSize }.map { Int64($0) } ?? Int64(request.httpBody?.count ?? 0)
908
+ let observation = !uploadID.isEmpty && onUploadProgress != nil ? task.observe(\.countOfBytesSent, options: [.initial, .new]) { task, _ in
909
+ let total = max(task.countOfBytesExpectedToSend, expectedBytes)
910
+ guard total > 0 else { return }
911
+ onUploadProgress?(["id": uploadID, "loaded": min(max(0, task.countOfBytesSent), total), "total": total])
912
+ } : nil
894
913
  cancellationLock.lock()
895
914
  let alreadyCancelled = cancelledRequests.contains(requestToken)
896
- if !alreadyCancelled { fetchTasks[requestToken] = task }
915
+ if !alreadyCancelled { fetchTasks[requestToken] = task; fetchProgress[requestToken] = observation }
897
916
  cancellationLock.unlock()
898
917
  if !alreadyCancelled { task.resume() }
899
- else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
918
+ else {
919
+ if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
920
+ complete(requestToken, failure("CANCELLED", "Upload cancelled"), completion)
921
+ }
900
922
  }
901
923
 
902
924
  /// `craft.files.download`: a GET straight to a file made here (for the
@@ -7359,10 +7359,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7359
7359
  /// for every screen.
7360
7360
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7361
7361
  /// `craft.secureStorage.set` wrote.
7362
- /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
7363
- /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
7364
- /// backgroundColor, navigationBarSolid, rightButtons, tabAccessory })`;
7365
- /// a right button's tap is the event `navButton`.
7362
+ /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7363
+ /// backTitle, tabBarHidden, tabItem, swipeBack, contentSwipeBack,
7364
+ /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7365
+ /// rightButtons, tabAccessory })`; a button's tap is the event
7366
+ /// `navButton`. `craft.d.ts` types no navigation surface, so this list
7367
+ /// is the public description of it — keep it in step with the keys
7368
+ /// `setOptions` reads, which is how `leftButtons`, `tabItem` and
7369
+ /// `titleColor` came to ship undocumented.
7366
7370
  ///
7367
7371
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
7368
7372
  /// these: each namespace is a property whose setter adds them back to the
@@ -8724,6 +8728,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8724
8728
  method: method,
8725
8729
  args: args,
8726
8730
  config: config,
8731
+ onUploadProgress: { [weak self] progress in
8732
+ DispatchQueue.main.async {
8733
+ guard let self, self.pendingCapabilityRequests.contains(requestToken), !self.runtimeDisposed else { return }
8734
+ self.send(type: "NETWORK_UPLOAD_PROGRESS", payload: progress)
8735
+ }
8736
+ },
8727
8737
  completion: reply
8728
8738
  )
8729
8739
  case "API_CANCEL":