@craft-native/ios 0.0.476 → 0.0.482

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.482",
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
@@ -3155,6 +3155,44 @@ private class CraftNativeFlowView: UIStackView {
3155
3155
 
3156
3156
  // MARK: Touches
3157
3157
 
3158
+ /// UIKit skips empty component wrappers, including their visible floating
3159
+ /// controls. Supply the real accessible descendants only when such wrappers
3160
+ /// occur; ordinary native subtrees keep UIKit's automatic traversal.
3161
+ private var authoredAccessibilityElements: [Any]?
3162
+ private weak var explicitAccessibilityOwner: CraftNativeFlowView?
3163
+ private var needsExplicitAccessibility: Bool {
3164
+ if let owner = explicitAccessibilityOwner, isDescendant(of: owner), owner.needsExplicitAccessibility { return true }
3165
+ return subviews.contains(where: { $0 is CraftNativeFlowView && $0.bounds.isEmpty })
3166
+ }
3167
+ override var accessibilityElements: [Any]? {
3168
+ get {
3169
+ if let authored = authoredAccessibilityElements { return authored }
3170
+ guard !isAccessibilityElement, needsExplicitAccessibility else { return nil }
3171
+ return subviews.flatMap { Self.accessibleDescendants($0, owner: self) }
3172
+ }
3173
+ set {
3174
+ authoredAccessibilityElements = newValue
3175
+ super.accessibilityElements = newValue
3176
+ }
3177
+ }
3178
+
3179
+ private static func accessibleDescendants(_ view: UIView, owner: CraftNativeFlowView) -> [Any] {
3180
+ guard !view.isHidden, view.alpha > 0.01, !view.accessibilityElementsHidden else { return [] }
3181
+ guard let flow = view as? CraftNativeFlowView, !flow.isAccessibilityElement,
3182
+ flow.accessibilityContainerType == .none else { return [view] }
3183
+ guard !flow.bounds.isEmpty || !flow.clipsToBounds else { return [] }
3184
+ if let authored = flow.authoredAccessibilityElements { return authored }
3185
+ if !flow.bounds.isEmpty {
3186
+ // Keep the real container's identity and frame, and let UIKit
3187
+ // traverse its real children through the same explicit contract.
3188
+ // Flattening every nonempty View discarded ordinary containers
3189
+ // such as the native navigation fixture's max-content bubble.
3190
+ flow.explicitAccessibilityOwner = owner
3191
+ return [flow]
3192
+ }
3193
+ return flow.subviews.flatMap { accessibleDescendants($0, owner: owner) }
3194
+ }
3195
+
3158
3196
  /// A child drawn outside this view's bounds still takes its touches, as an
3159
3197
  /// overflowing element is clickable on the web, unless the view clips
3160
3198
  /// (`overflow: hidden`). UIKit only asks a view's children when the point
@@ -4414,6 +4452,9 @@ private final class CraftNativeSegmentedControl: UISegmentedControl {
4414
4452
  if segments.count != items.count { segments = items.indices.map { CraftNativeSegmentElement(control: self, index: $0) } }
4415
4453
  for (index, item) in items.enumerated() {
4416
4454
  segments[index].accessibilityLabel = item["spoken"] as? String ?? item["label"] as? String ?? values[index]
4455
+ // Match the shared component's <group testID>-<value> identity.
4456
+ // UIKit's private segment views do not inherit identifiers.
4457
+ segments[index].accessibilityIdentifier = accessibilityIdentifier.map { "\($0)-\(values[index])" }
4417
4458
  }
4418
4459
  refreshAccessibilityState()
4419
4460
  setNeedsLayout()
@@ -7359,10 +7400,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7359
7400
  /// for every screen.
7360
7401
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
7361
7402
  /// `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`.
7403
+ /// - `craft.navigation.setOptions({ title, titleColor, largeTitle, hidden,
7404
+ /// backTitle, tabBarHidden, tabItem, swipeBack, contentSwipeBack,
7405
+ /// tabSwipeEnabled, backgroundColor, navigationBarSolid, leftButtons,
7406
+ /// rightButtons, tabAccessory })`; a button's tap is the event
7407
+ /// `navButton`. `craft.d.ts` types no navigation surface, so this list
7408
+ /// is the public description of it — keep it in step with the keys
7409
+ /// `setOptions` reads, which is how `leftButtons`, `tabItem` and
7410
+ /// `titleColor` came to ship undocumented.
7366
7411
  ///
7367
7412
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
7368
7413
  /// these: each namespace is a property whose setter adds them back to the
@@ -8724,6 +8769,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8724
8769
  method: method,
8725
8770
  args: args,
8726
8771
  config: config,
8772
+ onUploadProgress: { [weak self] progress in
8773
+ DispatchQueue.main.async {
8774
+ guard let self, self.pendingCapabilityRequests.contains(requestToken), !self.runtimeDisposed else { return }
8775
+ self.send(type: "NETWORK_UPLOAD_PROGRESS", payload: progress)
8776
+ }
8777
+ },
8727
8778
  completion: reply
8728
8779
  )
8729
8780
  case "API_CANCEL":