@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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
.
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
7363
|
-
/// tabBarHidden, swipeBack, contentSwipeBack,
|
|
7364
|
-
/// backgroundColor, navigationBarSolid,
|
|
7365
|
-
/// a
|
|
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":
|