@craft-native/ios 0.0.475 → 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/README.md +5 -0
- package/package.json +1 -1
- package/templates/CraftApp.swift +47 -22
- package/templates/CraftHybrid.swift +11 -1
- package/templates/CraftNativeActions.swift +25 -3
- package/templates/CraftNativeScreen.swift +51 -17
package/README.md
CHANGED
|
@@ -342,6 +342,11 @@ needs a solid bar.
|
|
|
342
342
|
The `backgroundColor` navigation option is shared with the Android native host;
|
|
343
343
|
omitting it or passing `null` returns to the app's dynamic light/dark colour.
|
|
344
344
|
|
|
345
|
+
Set `titleColor` to a screen's text colour when its branded palette needs a
|
|
346
|
+
different navigation title contrast. Both native hosts apply it to inline and
|
|
347
|
+
large titles. Passing `null` restores the host's normal title colour; it does
|
|
348
|
+
not change the bar material, button colours, or another screen's appearance.
|
|
349
|
+
|
|
345
350
|
The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
|
|
346
351
|
|
|
347
352
|
The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
|
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
|
|
@@ -7856,6 +7860,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7856
7860
|
/// the home indicator too, where its content never reaches: a black
|
|
7857
7861
|
/// full-screen timer is black edge to edge. nil is the app's.
|
|
7858
7862
|
private var screenBackgroundColor: UIColor?
|
|
7863
|
+
/// Title contrast belongs to this screen, independently of the bar material.
|
|
7864
|
+
private var navigationTitleColor: UIColor?
|
|
7859
7865
|
|
|
7860
7866
|
override var preferredStatusBarStyle: UIStatusBarStyle {
|
|
7861
7867
|
statusBarStyleOverride ?? .default
|
|
@@ -7926,6 +7932,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7926
7932
|
if let solid = options["navigationBarSolid"] as? Bool {
|
|
7927
7933
|
navigationBarSolid = solid
|
|
7928
7934
|
}
|
|
7935
|
+
if options.keys.contains("titleColor") {
|
|
7936
|
+
navigationTitleColor = color(options["titleColor"])
|
|
7937
|
+
}
|
|
7929
7938
|
if let hidden = options["hidden"] as? Bool {
|
|
7930
7939
|
navigationBarHidden = hidden
|
|
7931
7940
|
if navigationController?.topViewController === self {
|
|
@@ -7941,7 +7950,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7941
7950
|
let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
|
|
7942
7951
|
navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
|
|
7943
7952
|
}
|
|
7944
|
-
if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
|
|
7953
|
+
if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") || options.keys.contains("titleColor") {
|
|
7945
7954
|
applyNavigationBarAppearance()
|
|
7946
7955
|
trackContentScrollView()
|
|
7947
7956
|
}
|
|
@@ -7952,25 +7961,44 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7952
7961
|
}
|
|
7953
7962
|
}
|
|
7954
7963
|
|
|
7955
|
-
///
|
|
7956
|
-
///
|
|
7964
|
+
/// Per-item appearances preserve each UIKit bar state and never recolour a
|
|
7965
|
+
/// sibling controller's title or opt a material bar into an opaque fill.
|
|
7957
7966
|
private func applyNavigationBarAppearance() {
|
|
7958
|
-
guard navigationBarSolid else {
|
|
7967
|
+
guard navigationBarSolid || navigationTitleColor != nil else {
|
|
7959
7968
|
navigationItem.standardAppearance = nil
|
|
7960
7969
|
navigationItem.scrollEdgeAppearance = nil
|
|
7961
7970
|
navigationItem.compactAppearance = nil
|
|
7962
7971
|
navigationItem.compactScrollEdgeAppearance = nil
|
|
7963
7972
|
return
|
|
7964
7973
|
}
|
|
7965
|
-
let
|
|
7966
|
-
|
|
7967
|
-
appearance
|
|
7968
|
-
|
|
7969
|
-
|
|
7970
|
-
|
|
7971
|
-
|
|
7972
|
-
|
|
7973
|
-
|
|
7974
|
+
let bar = navigationController?.navigationBar
|
|
7975
|
+
let standard = bar?.standardAppearance ?? UINavigationBarAppearance()
|
|
7976
|
+
// UIKit's unspecified scroll-edge appearance uses a transparent
|
|
7977
|
+
// background. Copy its standard text/button attributes before applying
|
|
7978
|
+
// that background so adding title contrast preserves the same states.
|
|
7979
|
+
let scrollEdge = bar?.scrollEdgeAppearance ?? {
|
|
7980
|
+
let appearance = UINavigationBarAppearance(barAppearance: standard)
|
|
7981
|
+
appearance.configureWithTransparentBackground()
|
|
7982
|
+
return appearance
|
|
7983
|
+
}()
|
|
7984
|
+
func styled(_ base: UINavigationBarAppearance) -> UINavigationBarAppearance {
|
|
7985
|
+
let appearance = UINavigationBarAppearance(barAppearance: base)
|
|
7986
|
+
if navigationBarSolid {
|
|
7987
|
+
appearance.configureWithOpaqueBackground()
|
|
7988
|
+
appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
|
|
7989
|
+
appearance.shadowColor = .clear
|
|
7990
|
+
}
|
|
7991
|
+
CraftChromeFonts.apply(to: appearance)
|
|
7992
|
+
if let navigationTitleColor {
|
|
7993
|
+
appearance.titleTextAttributes[.foregroundColor] = navigationTitleColor
|
|
7994
|
+
appearance.largeTitleTextAttributes[.foregroundColor] = navigationTitleColor
|
|
7995
|
+
}
|
|
7996
|
+
return appearance
|
|
7997
|
+
}
|
|
7998
|
+
navigationItem.standardAppearance = styled(standard)
|
|
7999
|
+
navigationItem.scrollEdgeAppearance = styled(scrollEdge)
|
|
8000
|
+
navigationItem.compactAppearance = styled(bar?.compactAppearance ?? standard)
|
|
8001
|
+
navigationItem.compactScrollEdgeAppearance = styled(bar?.compactScrollEdgeAppearance ?? scrollEdge)
|
|
7974
8002
|
}
|
|
7975
8003
|
|
|
7976
8004
|
private func applyStatusBar(_ props: [String: Any]) {
|
|
@@ -8700,6 +8728,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
8700
8728
|
method: method,
|
|
8701
8729
|
args: args,
|
|
8702
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
|
+
},
|
|
8703
8737
|
completion: reply
|
|
8704
8738
|
)
|
|
8705
8739
|
case "API_CANCEL":
|