@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
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
|
|
@@ -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,
|
|
7363
|
-
/// tabBarHidden, swipeBack, contentSwipeBack,
|
|
7364
|
-
/// backgroundColor, navigationBarSolid,
|
|
7365
|
-
/// a
|
|
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":
|