@craft-native/ios 0.0.187 → 0.0.191

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 CHANGED
@@ -174,12 +174,24 @@ reversing each line's main-axis children, matching Android's native host.
174
174
 
175
175
  For multiple native screens, configure named `.stx` routes and an initial screen in `native.config.json` (see [`fixtures/native-navigation`](./fixtures/native-navigation)). Compile without a positional file: `stx native compile --format bundle --output ./screen.js`. A screen can call `craft.navigation.push('details', { id: 7 })`, `.replace('home')`, or `.back()`; the destination reads `craft.route.params`. Craft uses a native navigation stack, so the back button and edge-swipe reveal the existing controller and retain its JavaScript and input state. `bun packages/ios/scripts/test-native-navigation.ts` compiles the fixture and runs its simulator test; the default WebView renderer is unchanged.
176
176
 
177
+ Hybrid web-to-web pushes use the same shared WebView across stack entries. The maintained [`fixtures/hybrid-navigation`](./fixtures/hybrid-navigation) fixture samples the outgoing page during the UIKit transition and is run by CI with:
178
+
179
+ ```bash
180
+ bun packages/ios/scripts/test-hybrid-navigation.ts
181
+ ```
182
+
177
183
  Hybrid apps also get the shell's completed left/right tab swipe over the
178
184
  WebView. Disable that page-wide gesture with
179
185
  `craft.tabBar.set(tabs, { swipeEnabled: false })`; a native screen can still
180
186
  use `craft.navigation.setOptions({ tabSwipeEnabled: false })` for its own
181
187
  content.
182
188
 
189
+ Native screens can opt into an opaque navigation-bar fill that follows their
190
+ screen background (including light/dark trait changes) with
191
+ `craft.navigation.setOptions({ navigationBarSolid: true })`. The default stays
192
+ UIKit's translucent material; set it back to `false` when a screen no longer
193
+ needs a solid bar.
194
+
183
195
  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.
184
196
 
185
197
  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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.187",
3
+ "version": "0.0.191",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -614,7 +614,10 @@ final class CraftChrome: ObservableObject {
614
614
  let id: String
615
615
  let title: String
616
616
  let symbol: String
617
- let badge: String?
617
+ var badge: String?
618
+ var iconText: String? = nil
619
+ var accessibilityLabel: String? = nil
620
+ var image: String? = nil
618
621
  }
619
622
 
620
623
  @Published var tabs: [Tab] = []
@@ -668,6 +671,23 @@ final class CraftChrome: ObservableObject {
668
671
  /// the bar away while it shows (`tabBarHidden`, CraftNativeScreen.swift);
669
672
  /// the next screen shown says again. The page's own hideTabBar is apart.
670
673
  private(set) var hiddenByScreen = false
674
+
675
+ /// Change one tab's icon without replacing tabs, navigation stacks or selection.
676
+ func updateTab(_ item: [String: Any]) {
677
+ guard let id = item["id"] as? String, let index = tabs.firstIndex(where: { $0.id == id }) else { return }
678
+ if item.keys.contains("iconText") {
679
+ tabs[index].iconText = (item["iconText"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
680
+ }
681
+ if item.keys.contains("accessibilityLabel") {
682
+ tabs[index].accessibilityLabel = (item["accessibilityLabel"] as? String).map { String($0.prefix(160)) }
683
+ }
684
+ if item.keys.contains("image") {
685
+ tabs[index].image = (item["image"] as? String).flatMap { URL(string: $0)?.scheme == "https" ? $0 : nil }
686
+ }
687
+ if item.keys.contains("badge") {
688
+ tabs[index].badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : String($0.prefix(8)) }
689
+ }
690
+ }
671
691
  func setHiddenByScreen(_ hidden: Bool) {
672
692
  guard hiddenByScreen != hidden else { return }
673
693
  hiddenByScreen = hidden
@@ -941,9 +961,28 @@ struct CraftTabBarView: View {
941
961
  Button { chrome.tap(tab) } label: {
942
962
  VStack(spacing: 3) {
943
963
  ZStack(alignment: .topTrailing) {
944
- Image(systemName: symbol(tab.symbol, selected: isSelected))
945
- .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
964
+ if let image = tab.image, let url = URL(string: image) {
965
+ AsyncImage(url: url) { image in
966
+ image.resizable().scaledToFill()
967
+ } placeholder: {
968
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
969
+ }
970
+ .frame(width: iconSize + 4, height: iconSize + 4)
971
+ .clipShape(Circle())
946
972
  .frame(height: iconSize + 6)
973
+ } else if let text = tab.iconText {
974
+ Text(text)
975
+ .font(.system(size: iconSize, weight: .semibold, design: .rounded))
976
+ .monospacedDigit()
977
+ .foregroundStyle(Color.red)
978
+ .lineLimit(1)
979
+ .minimumScaleFactor(0.7)
980
+ .frame(height: iconSize + 6)
981
+ } else {
982
+ Image(systemName: symbol(tab.symbol, selected: isSelected))
983
+ .font(.system(size: iconSize, weight: isSelected ? .semibold : .regular))
984
+ .frame(height: iconSize + 6)
985
+ }
947
986
  if let badge = tab.badge {
948
987
  Text(badge)
949
988
  .font(.system(size: badgeSize, weight: .bold))
@@ -975,7 +1014,7 @@ struct CraftTabBarView: View {
975
1014
  Image(systemName: symbol(tab.symbol, selected: isSelected))
976
1015
  Text(tab.title)
977
1016
  }
978
- .accessibilityLabel(tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
1017
+ .accessibilityLabel(tab.accessibilityLabel ?? tab.badge.map { "\(tab.title), \($0)" } ?? tab.title)
979
1018
  .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton)
980
1019
  }
981
1020
  }
@@ -992,7 +992,10 @@ final class CraftHybridWebSlot: UIViewController {
992
992
 
993
993
  override func viewWillAppear(_ animated: Bool) {
994
994
  super.viewWillAppear(animated)
995
- (parent?.parent as? CraftHybridController)?.adoptWeb(into: self)
995
+ if let controller = parent?.parent as? CraftHybridController {
996
+ controller.prepareWebTransition(to: self)
997
+ controller.adoptWeb(into: self)
998
+ }
996
999
  }
997
1000
  }
998
1001
 
@@ -1011,11 +1014,18 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1011
1014
  private var shownStacks: [ObjectIdentifier: [UIViewController]] = [:]
1012
1015
  /// A tab whose page has not caught up yet; shown when it has, or soon.
1013
1016
  private var pendingTab: (id: String, deadline: DispatchWorkItem)?
1017
+ /// The live WebView moves to the destination before UIKit animates a web
1018
+ /// push. Keep the outgoing page visible until that transition completes.
1019
+ private var webTransitionSnapshots: [UIView] = []
1014
1020
  private var openingWeb = false
1015
1021
  private var firstFrameLogged = false
1016
1022
  private var tabBarHeight: CGFloat = 0
1017
1023
  private var tabSwipeRecognizers: [UIGestureRecognizer] = []
1018
1024
  private var activeTabSwipeTarget: String?
1025
+ /// The edge the incoming tab started from for the current gesture. Keep
1026
+ /// this separate from the live frame: deriving it after the drag begins
1027
+ /// makes the target jump when the translation crosses the midpoint.
1028
+ private var tabSwipeFrom: CGFloat = 0
1019
1029
 
1020
1030
  init(config: CraftConfig) {
1021
1031
  self.config = config
@@ -1134,7 +1144,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1134
1144
  }
1135
1145
  target.view.isHidden = false
1136
1146
  let width = view.bounds.width
1137
- target.view.frame = view.bounds.offsetBy(dx: direction * width, dy: 0)
1147
+ tabSwipeFrom = -direction * width
1148
+ target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom, dy: 0)
1138
1149
  view.bringSubviewToFront(target.view)
1139
1150
  CraftChrome.shared.beginTabSwipe(to: id)
1140
1151
  return true
@@ -1144,9 +1155,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1144
1155
  guard let targetId = activeTabSwipeTarget, let current = visibleStack,
1145
1156
  let target = stacks[targetId] else { return }
1146
1157
  let width = max(1, view.bounds.width)
1147
- let start = target.view.frame.minX >= width * 0.5 ? width : -width
1148
1158
  let offset = min(width, max(-width, translation))
1149
- target.view.frame.origin.x = start + offset
1159
+ target.view.frame.origin.x = tabSwipeFrom + offset
1150
1160
  current.view.frame.origin.x = offset
1151
1161
  }
1152
1162
 
@@ -1244,6 +1254,26 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1244
1254
  ])
1245
1255
  }
1246
1256
 
1257
+ /// UIKit calls the destination's viewWillAppear before it starts a push.
1258
+ /// Since the hybrid shell has one WebView shared by all web entries, moving
1259
+ /// it at that point would leave the outgoing slot blank for the whole
1260
+ /// transition. A snapshot gives that slot the page it was showing while
1261
+ /// the live WebView is adopted by the destination.
1262
+ func prepareWebTransition(to slot: CraftHybridWebSlot) {
1263
+ guard let nav = slot.navigationController,
1264
+ nav === visibleStack,
1265
+ let from = nav.transitionCoordinator?.viewController(forKey: .from),
1266
+ let source = from as? CraftHybridWebSlot,
1267
+ source !== slot,
1268
+ let webHost,
1269
+ webHost.view.superview === source.view,
1270
+ let snapshot = webHost.view.snapshotView(afterScreenUpdates: false) else { return }
1271
+ snapshot.frame = source.view.bounds
1272
+ snapshot.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1273
+ source.view.addSubview(snapshot)
1274
+ webTransitionSnapshots.append(snapshot)
1275
+ }
1276
+
1247
1277
  private var visibleStack: UINavigationController? { stacks[currentTab] }
1248
1278
 
1249
1279
  var webIsVisible: Bool {
@@ -1593,6 +1623,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1593
1623
  if let depth = removed.compactMap({ ($0 as? CraftHybridWebSlot)?.depth }).min() {
1594
1624
  hybrid.pageBack(to: depth - 1)
1595
1625
  }
1626
+ for snapshot in webTransitionSnapshots { snapshot.removeFromSuperview() }
1627
+ webTransitionSnapshots.removeAll()
1596
1628
  // Back at a tab's native root while the page, with no history left
1597
1629
  // to go back through (it reloaded on the screen just popped), still
1598
1630
  // shows that screen: it moves to the root's path. Left there, the
@@ -31,7 +31,7 @@ enum CraftNativeActions {
31
31
  private static let fetchSession: URLSession = {
32
32
  let configuration = URLSessionConfiguration.default
33
33
  configuration.timeoutIntervalForRequest = craftNativeFetchTimeoutSeconds
34
- configuration.timeoutIntervalForResource = craftNativeFetchTimeoutSeconds
34
+ configuration.timeoutIntervalForResource = 600
35
35
  return URLSession(configuration: configuration)
36
36
  }()
37
37
  private static var initialDeepLinkClaimed = false
@@ -219,6 +219,12 @@ enum CraftNativeActions {
219
219
  CraftNativeFiles.pickImage(options, cameraEnabled: config.enableCamera) { complete(requestToken, $0, completion) }
220
220
  }
221
221
  return
222
+ case ("Files", "pickVideo"):
223
+ let options = args.first as? [String: Any] ?? [:]
224
+ DispatchQueue.main.async {
225
+ CraftNativeFiles.pickVideo(options) { complete(requestToken, $0, completion) }
226
+ }
227
+ return
222
228
  case ("Files", "download"):
223
229
  download(requestToken: requestToken, args: args, completion: completion)
224
230
  return
@@ -594,6 +600,7 @@ enum CraftNativeActions {
594
600
  request.setValue(value, forHTTPHeaderField: name)
595
601
  }
596
602
  }
603
+ var uploadFile: URL?
597
604
  if let form = options["form"] as? [Any] {
598
605
  // A screen's FormData: text fields, and files craft.files made.
599
606
  guard method != "GET", method != "HEAD" else {
@@ -604,8 +611,9 @@ enum CraftNativeActions {
604
611
  case .failure(let error):
605
612
  complete(requestToken, .failure(error), completion)
606
613
  return
607
- case .success(let (data, boundary)):
608
- request.httpBody = data
614
+ case .success(let (file, boundary)):
615
+ uploadFile = file
616
+ request.timeoutInterval = 600
609
617
  request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
610
618
  }
611
619
  } else if let body = options["body"] as? String {
@@ -619,7 +627,8 @@ enum CraftNativeActions {
619
627
  return
620
628
  }
621
629
 
622
- let task = fetchSession.dataTask(with: request) { data, response, error in
630
+ let responseHandler: (Data?, URLResponse?, Error?) -> Void = { data, response, error in
631
+ if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
623
632
  cancellationLock.lock()
624
633
  fetchTasks.removeValue(forKey: requestToken)
625
634
  cancellationLock.unlock()
@@ -656,11 +665,14 @@ enum CraftNativeActions {
656
665
  "body": String(decoding: body, as: UTF8.self),
657
666
  ]), completion)
658
667
  }
668
+ let task: URLSessionTask = uploadFile.map { fetchSession.uploadTask(with: request, fromFile: $0, completionHandler: responseHandler) }
669
+ ?? fetchSession.dataTask(with: request, completionHandler: responseHandler)
659
670
  cancellationLock.lock()
660
671
  let alreadyCancelled = cancelledRequests.contains(requestToken)
661
672
  if !alreadyCancelled { fetchTasks[requestToken] = task }
662
673
  cancellationLock.unlock()
663
674
  if !alreadyCancelled { task.resume() }
675
+ else if let uploadFile { CraftNativeFiles.remove(uploadFile.path) }
664
676
  }
665
677
 
666
678
  /// `craft.files.download`: a GET straight to a file made here (for the
@@ -274,41 +274,147 @@ enum CraftNativeFiles {
274
274
  }
275
275
  }
276
276
 
277
+ // MARK: Picking a video
278
+
279
+ /// System pickers grant access to just the selected video. Copy it while
280
+ /// the provider's URL is valid; no movie bytes cross JavaScriptCore.
281
+ static func pickVideo(_ options: [String: Any], done: @escaping (Result<Any, CraftNativeActionError>) -> Void) {
282
+ guard activePicker == nil else {
283
+ done(.failure(CraftNativeActionError(code: "BUSY", message: "A picker is already open")))
284
+ return
285
+ }
286
+ let limit = min(512 * 1024 * 1024, max(1, (options["maxBytes"] as? NSNumber)?.intValue ?? 512 * 1024 * 1024))
287
+ let finish: (Result<Any, CraftNativeActionError>) -> Void = { result in
288
+ DispatchQueue.main.async { activePicker = nil; done(result) }
289
+ }
290
+ if options["source"] as? String == "files" {
291
+ let delegate = VideoDocumentDelegate(limit: limit, done: finish)
292
+ var types: [UTType] = [.movie]
293
+ if let webm = UTType(filenameExtension: "webm") { types.append(webm) }
294
+ let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
295
+ picker.allowsMultipleSelection = false
296
+ picker.delegate = delegate
297
+ activePicker = delegate
298
+ if !CraftPresenter.present(picker, from: nil) {
299
+ activePicker = nil
300
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
301
+ }
302
+ } else {
303
+ var configuration = PHPickerConfiguration()
304
+ configuration.filter = .videos
305
+ configuration.selectionLimit = 1
306
+ configuration.preferredAssetRepresentationMode = .current
307
+ let delegate = VideoLibraryDelegate(limit: limit, done: finish)
308
+ let picker = PHPickerViewController(configuration: configuration)
309
+ picker.delegate = delegate
310
+ activePicker = delegate
311
+ if !CraftPresenter.present(picker, from: nil) {
312
+ activePicker = nil
313
+ done(.failure(CraftNativeActionError(code: "PICKER_ERROR", message: "Unable to present the video picker")))
314
+ }
315
+ }
316
+ }
317
+
318
+ static func copyVideo(_ source: URL, limit: Int) -> Result<Any, CraftNativeActionError> {
319
+ let ext = source.pathExtension.lowercased()
320
+ let types = ["mp4": "video/mp4", "m4v": "video/x-m4v", "mov": "video/quicktime", "webm": "video/webm"]
321
+ guard let type = types[ext] else {
322
+ return .failure(CraftNativeActionError(code: "INVALID_VIDEO", message: "Choose an MP4, MOV or WebM video."))
323
+ }
324
+ do {
325
+ let size = ((try FileManager.default.attributesOfItem(atPath: source.path))[.size] as? NSNumber)?.intValue ?? 0
326
+ guard size > 0, size <= limit else {
327
+ return .failure(CraftNativeActionError(code: "FILE_TOO_LARGE", message: "Choose a nonempty video under \(limit / 1024 / 1024)MB."))
328
+ }
329
+ let destination = try newFile(named: source.lastPathComponent)
330
+ do { try FileManager.default.copyItem(at: source, to: destination) }
331
+ catch { try? FileManager.default.removeItem(at: destination.deletingLastPathComponent()); throw error }
332
+ return .success(describe(destination, type: type))
333
+ } catch {
334
+ return .failure(CraftNativeActionError(code: "READ_ERROR", message: error.localizedDescription))
335
+ }
336
+ }
337
+
338
+ private final class VideoLibraryDelegate: NSObject, PHPickerViewControllerDelegate {
339
+ let limit: Int
340
+ let done: (Result<Any, CraftNativeActionError>) -> Void
341
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
342
+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
343
+ picker.dismiss(animated: true)
344
+ guard let provider = results.first?.itemProvider else { done(.success(NSNull())); return }
345
+ let (limit, done) = (self.limit, self.done)
346
+ provider.loadFileRepresentation(forTypeIdentifier: UTType.movie.identifier) { url, error in
347
+ guard let url else {
348
+ done(.failure(CraftNativeActionError(code: "READ_ERROR", message: error?.localizedDescription ?? "The video could not be read")))
349
+ return
350
+ }
351
+ done(CraftNativeFiles.copyVideo(url, limit: limit))
352
+ }
353
+ }
354
+ }
355
+
356
+ private final class VideoDocumentDelegate: NSObject, UIDocumentPickerDelegate {
357
+ let limit: Int
358
+ let done: (Result<Any, CraftNativeActionError>) -> Void
359
+ init(limit: Int, done: @escaping (Result<Any, CraftNativeActionError>) -> Void) { self.limit = limit; self.done = done }
360
+ func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { done(.success(NSNull())) }
361
+ func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
362
+ guard let url = urls.first else { done(.success(NSNull())); return }
363
+ let (limit, done) = (self.limit, self.done)
364
+ DispatchQueue.global(qos: .userInitiated).async {
365
+ let granted = url.startAccessingSecurityScopedResource()
366
+ defer { if granted { url.stopAccessingSecurityScopedResource() } }
367
+ done(CraftNativeFiles.copyVideo(url, limit: limit))
368
+ }
369
+ }
370
+ }
371
+
277
372
  // MARK: Sending files
278
373
 
279
374
  /// A multipart/form-data body from a screen's `FormData`: parts are
280
375
  /// `{ name, value }` or `{ name, path, filename?, type? }`, a file made
281
376
  /// here. Answers the body and its boundary.
282
- static func multipart(_ parts: [Any]) -> Result<(Data, String), CraftNativeActionError> {
377
+ static func multipart(_ parts: [Any]) -> Result<(URL, String), CraftNativeActionError> {
283
378
  let boundary = "craft-\(UUID().uuidString)"
284
- var body = Data()
285
- func append(_ text: String) { body.append(Data(text.utf8)) }
286
- for item in parts {
287
- guard let part = item as? [String: Any], let name = part["name"] as? String else {
288
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name"))
289
- }
290
- append("--\(boundary)\r\n")
291
- if let path = part["path"] as? String {
292
- guard let url = owned(path) else {
293
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there"))
379
+ var temporary: URL?
380
+ do {
381
+ let destination = try newFile(named: "upload.multipart")
382
+ temporary = destination
383
+ FileManager.default.createFile(atPath: destination.path, contents: nil)
384
+ let output = try FileHandle(forWritingTo: destination)
385
+ defer { try? output.close() }
386
+ func append(_ text: String) throws { try output.write(contentsOf: Data(text.utf8)) }
387
+ for item in parts {
388
+ guard let part = item as? [String: Any], let name = part["name"] as? String else {
389
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form part needs a name")
294
390
  }
295
- guard let data = try? Data(contentsOf: url, options: .mappedIfSafe) else {
296
- return .failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file could not be read"))
391
+ try append("--\(boundary)\r\n")
392
+ if let path = part["path"] as? String {
393
+ guard let url = owned(path) else {
394
+ throw CraftNativeActionError(code: "INVALID_ARGUMENT", message: "A form file must be one craft.files made, and still there")
395
+ }
396
+ let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
397
+ let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
398
+ try append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
399
+ try append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
400
+ let input = try FileHandle(forReadingFrom: url)
401
+ defer { try? input.close() }
402
+ while let chunk = try input.read(upToCount: 64 * 1024), !chunk.isEmpty {
403
+ try output.write(contentsOf: chunk)
404
+ }
405
+ try append("\r\n")
406
+ } else {
407
+ try append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
408
+ try append(part["value"] as? String ?? "")
409
+ try append("\r\n")
297
410
  }
298
- let filename = (part["filename"] as? String).map(safeName) ?? url.lastPathComponent
299
- let type = (part["type"] as? String).flatMap { $0.isEmpty ? nil : $0 } ?? "application/octet-stream"
300
- append("Content-Disposition: form-data; name=\"\(quoted(name))\"; filename=\"\(quoted(filename))\"\r\n")
301
- append("Content-Type: \(type.replacingOccurrences(of: "\r", with: "").replacingOccurrences(of: "\n", with: ""))\r\n\r\n")
302
- body.append(data)
303
- append("\r\n")
304
- } else {
305
- append("Content-Disposition: form-data; name=\"\(quoted(name))\"\r\n\r\n")
306
- append(part["value"] as? String ?? "")
307
- append("\r\n")
308
411
  }
412
+ try append("--\(boundary)--\r\n")
413
+ return .success((destination, boundary))
414
+ } catch {
415
+ if let temporary { remove(temporary.path) }
416
+ return .failure((error as? CraftNativeActionError) ?? CraftNativeActionError(code: "WRITE_ERROR", message: error.localizedDescription))
309
417
  }
310
- append("--\(boundary)--\r\n")
311
- return .success((body, boundary))
312
418
  }
313
419
 
314
420
  /// A name inside a quoted header value, escaped as browsers send it.
@@ -135,22 +135,105 @@ private enum CraftNativeTreeLayoutBridge {
135
135
  var childCount: UInt32
136
136
  }
137
137
 
138
- private struct Frame {
138
+ private struct StyleInputV2 {
139
+ var direction: UInt32 = 0
140
+ var width: Float = .nan
141
+ var height: Float = .nan
142
+ var intrinsicWidth: Float = .nan
143
+ var intrinsicHeight: Float = .nan
144
+ var baseline: Float = .nan
145
+ var aspectRatio: Float = .nan
146
+ var flexBasis: Float = .nan
147
+ var flexGrow: Float = 0
148
+ var flexShrink: Float = 0
149
+ var minWidth: Float = .nan
150
+ var maxWidth: Float = .nan
151
+ var minHeight: Float = .nan
152
+ var maxHeight: Float = .nan
153
+ var gap: Float = 0
154
+ var justifyContent: UInt32 = 0
155
+ var alignItems: UInt32 = 3
156
+ var alignSelf: UInt32 = 0
157
+ var flexWrap: UInt32 = 0
158
+ var alignContent: UInt32 = 6
159
+ var marginTop: Float = 0
160
+ var marginRight: Float = 0
161
+ var marginBottom: Float = 0
162
+ var marginLeft: Float = 0
163
+ var paddingTop: Float = 0
164
+ var paddingRight: Float = 0
165
+ var paddingBottom: Float = 0
166
+ var paddingLeft: Float = 0
167
+ var grid: UInt32 = 0
168
+ var gridColumns: UInt32 = 1
169
+ var rowGap: Float = 0
170
+ var columnGap: Float = 0
171
+ var reverse: UInt32 = 0
172
+ }
173
+
174
+ private struct NodeInputV2 {
175
+ var style: StyleInputV2
176
+ var firstChild: UInt32
177
+ var childCount: UInt32
178
+ }
179
+
180
+ fileprivate struct Frame {
139
181
  var x: Float
140
182
  var y: Float
141
183
  var width: Float
142
184
  var height: Float
143
185
  }
144
186
 
187
+ fileprivate struct TreeNode {
188
+ let view: UIView?
189
+ let style: CraftNativeLayoutStyle
190
+ let intrinsic: CGSize
191
+ let baseline: CGFloat
192
+ let direction: NSLayoutConstraint.Axis
193
+ let gap: CGFloat
194
+ let rowGap: CGFloat
195
+ let columnGap: CGFloat
196
+ let wrap: Bool
197
+ let justifyContent: String
198
+ let alignItems: String
199
+ let alignContent: String
200
+ let padding: UIEdgeInsets
201
+ let grid: Bool
202
+ let gridColumns: Int
203
+ let reverse: Bool
204
+ let wrapReverse: Bool
205
+ let children: [TreeNode]
206
+ }
207
+
208
+ fileprivate struct TreeResult {
209
+ let nodes: [TreeNode]
210
+ let parents: [Int]
211
+ let frames: [Frame]
212
+ }
213
+
145
214
  private typealias Function = @convention(c) (
146
215
  Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
147
216
  ) -> Int32
148
217
 
218
+ private typealias FunctionV2 = @convention(c) (
219
+ Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
220
+ ) -> Int32
221
+
149
222
  private static let function: Function? = {
150
223
  guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
151
224
  return unsafeBitCast(symbol, to: Function.self)
152
225
  }()
153
226
 
227
+ private static let functionV2: FunctionV2? = {
228
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v2") else { return nil }
229
+ return unsafeBitCast(symbol, to: FunctionV2.self)
230
+ }()
231
+
232
+ private static let functionV3: FunctionV2? = {
233
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v3") else { return nil }
234
+ return unsafeBitCast(symbol, to: FunctionV2.self)
235
+ }()
236
+
154
237
  private static func finite(_ value: CGFloat?) -> Float {
155
238
  guard let value, value.isFinite else { return .nan }
156
239
  return Float(value)
@@ -260,6 +343,199 @@ private enum CraftNativeTreeLayoutBridge {
260
343
  (child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
261
344
  }
262
345
  }
346
+
347
+ fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
348
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
349
+ if containsWrapReverse(root) {
350
+ return treeFramesV2(root: root, width: width, height: height, function: functionV3)
351
+ }
352
+ if containsReverse(root) {
353
+ return treeFramesV2(root: root, width: width, height: height, function: functionV2)
354
+ }
355
+ var nodes: [TreeNode] = []
356
+ var parents: [Int] = []
357
+ func append(_ node: TreeNode, parent: Int) {
358
+ let index = nodes.count
359
+ nodes.append(node)
360
+ parents.append(parent)
361
+ node.children.forEach { append($0, parent: index) }
362
+ }
363
+ append(root, parent: -1)
364
+
365
+ func put(_ input: inout StyleInput, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
366
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
367
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
368
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
369
+ input.direction = node.direction == .horizontal ? 0 : 1
370
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
371
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
372
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
373
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
374
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
375
+ input.aspectRatio = finite(node.style.aspectRatio)
376
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
377
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
378
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
379
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
380
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
381
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
382
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
383
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
384
+ input.gap = Float(max(0, node.gap))
385
+ input.justifyContent = justify(node.justifyContent)
386
+ input.alignItems = alignment(node.alignItems)
387
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
388
+ input.flexWrap = node.wrap ? 1 : 0
389
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
390
+ input.marginTop = Float(max(0, node.style.marginTop))
391
+ input.marginRight = Float(max(0, node.style.marginRight))
392
+ input.marginBottom = Float(max(0, node.style.marginBottom))
393
+ input.marginLeft = Float(max(0, node.style.marginLeft))
394
+ input.paddingTop = Float(max(0, node.padding.top))
395
+ input.paddingRight = Float(max(0, node.padding.right))
396
+ input.paddingBottom = Float(max(0, node.padding.bottom))
397
+ input.paddingLeft = Float(max(0, node.padding.left))
398
+ input.grid = node.grid ? 1 : 0
399
+ input.gridColumns = UInt32(max(1, node.gridColumns))
400
+ input.rowGap = Float(max(0, node.rowGap))
401
+ input.columnGap = Float(max(0, node.columnGap))
402
+ }
403
+
404
+ var styles = [StyleInput](repeating: StyleInput(), count: nodes.count)
405
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
406
+ var cursor = 0
407
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
408
+ let index = cursor
409
+ cursor += 1
410
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
411
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
412
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
413
+ let firstChild = cursor
414
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
415
+ metadata[index * 2] = Float(firstChild)
416
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
417
+ }
418
+ write(root, parentWidth: width, parentHeight: height, rootNode: true)
419
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
420
+ NodeInput(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
421
+ }
422
+
423
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
424
+ let frameCount = output.count
425
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
426
+ metadata.withUnsafeBufferPointer { metadataBuffer in
427
+ output.withUnsafeMutableBufferPointer { outputBuffer in
428
+ function(
429
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
430
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
431
+ )
432
+ }
433
+ }
434
+ }
435
+ guard status == 0, output.allSatisfy({ frame in
436
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
437
+ frame.width >= 0 && frame.height >= 0
438
+ }) else { return nil }
439
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
440
+ }
441
+
442
+ private static func containsReverse(_ node: TreeNode) -> Bool {
443
+ node.reverse || node.children.contains(where: containsReverse)
444
+ }
445
+
446
+ private static func containsWrapReverse(_ node: TreeNode) -> Bool {
447
+ node.wrapReverse || node.children.contains(where: containsWrapReverse)
448
+ }
449
+
450
+ private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
451
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
452
+ var nodes: [TreeNode] = []
453
+ var parents: [Int] = []
454
+ func append(_ node: TreeNode, parent: Int) {
455
+ let index = nodes.count
456
+ nodes.append(node)
457
+ parents.append(parent)
458
+ node.children.forEach { append($0, parent: index) }
459
+ }
460
+ append(root, parent: -1)
461
+
462
+ func put(_ input: inout StyleInputV2, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
463
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
464
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
465
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
466
+ input.direction = node.direction == .horizontal ? 0 : 1
467
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
468
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
469
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
470
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
471
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
472
+ input.aspectRatio = finite(node.style.aspectRatio)
473
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
474
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
475
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
476
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
477
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
478
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
479
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
480
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
481
+ input.gap = Float(max(0, node.gap))
482
+ input.justifyContent = justify(node.justifyContent)
483
+ input.alignItems = alignment(node.alignItems)
484
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
485
+ input.flexWrap = node.wrap ? 1 : 0
486
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
487
+ input.marginTop = Float(max(0, node.style.marginTop))
488
+ input.marginRight = Float(max(0, node.style.marginRight))
489
+ input.marginBottom = Float(max(0, node.style.marginBottom))
490
+ input.marginLeft = Float(max(0, node.style.marginLeft))
491
+ input.paddingTop = Float(max(0, node.padding.top))
492
+ input.paddingRight = Float(max(0, node.padding.right))
493
+ input.paddingBottom = Float(max(0, node.padding.bottom))
494
+ input.paddingLeft = Float(max(0, node.padding.left))
495
+ input.grid = node.grid ? 1 : 0
496
+ input.gridColumns = UInt32(max(1, node.gridColumns))
497
+ input.rowGap = Float(max(0, node.rowGap))
498
+ input.columnGap = Float(max(0, node.columnGap))
499
+ input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
500
+ }
501
+
502
+ var styles = [StyleInputV2](repeating: StyleInputV2(), count: nodes.count)
503
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
504
+ var cursor = 0
505
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
506
+ let index = cursor
507
+ cursor += 1
508
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
509
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
510
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
511
+ let firstChild = cursor
512
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
513
+ metadata[index * 2] = Float(firstChild)
514
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
515
+ }
516
+ write(root, parentWidth: width, parentHeight: height, rootNode: true)
517
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
518
+ NodeInputV2(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
519
+ }
520
+
521
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
522
+ let frameCount = output.count
523
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
524
+ metadata.withUnsafeBufferPointer { metadataBuffer in
525
+ output.withUnsafeMutableBufferPointer { outputBuffer in
526
+ function(
527
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
528
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
529
+ )
530
+ }
531
+ }
532
+ }
533
+ guard status == 0, output.allSatisfy({ frame in
534
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
535
+ frame.width >= 0 && frame.height >= 0
536
+ }) else { return nil }
537
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
538
+ }
263
539
  }
264
540
 
265
541
  private enum CraftNativeFlexLineBridge {
@@ -1548,6 +1824,137 @@ private class CraftNativeFlowView: UIStackView {
1548
1824
  flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
1549
1825
  }
1550
1826
 
1827
+ /// Builds the eligible portion of this container for the host-neutral
1828
+ /// tree ABI. The local algorithm remains authoritative for
1829
+ /// grid-auto-rows, absolute children, and any subtree whose measurement
1830
+ /// cannot be represented by the shared contract.
1831
+ fileprivate func sharedTreeNode(parentWidth: CGFloat, parentHeight: CGFloat) -> CraftNativeTreeLayoutBridge.TreeNode? {
1832
+ guard gridAutoRows == nil else { return nil }
1833
+ let children = visibleChildren
1834
+ let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
1835
+ guard flow.count == children.count else { return nil }
1836
+ let style = craftLayoutStyle
1837
+ let width = style.width?.resolve(parentWidth)
1838
+ let height = style.height?.resolve(parentHeight)
1839
+ let intrinsic = CraftNativeFlexLayout.measure(
1840
+ self, width: width, height: height,
1841
+ maxWidth: max(0, parentWidth), maxHeight: max(0, parentHeight)
1842
+ )
1843
+ let measuredWidth = width ?? intrinsic.width
1844
+ let measuredHeight = height ?? intrinsic.height
1845
+ let contentWidth = max(0, measuredWidth - effectivePadding.left - effectivePadding.right)
1846
+ let contentHeight = max(0, measuredHeight - effectivePadding.top - effectivePadding.bottom)
1847
+ let nodes = flow.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
1848
+ let childStyle = child.craftLayoutStyle
1849
+ let childWidth = childStyle.width?.resolve(contentWidth)
1850
+ let childHeight = childStyle.height?.resolve(contentHeight)
1851
+ let childIntrinsic = CraftNativeFlexLayout.measure(
1852
+ child, width: childWidth, height: childHeight,
1853
+ maxWidth: contentWidth, maxHeight: contentHeight
1854
+ )
1855
+ let nested = child as? CraftNativeFlowView
1856
+ let nestedNode = nested?.sharedTreeNode(
1857
+ parentWidth: contentWidth, parentHeight: contentHeight
1858
+ )
1859
+ if nested != nil && nestedNode == nil { return nil }
1860
+ return CraftNativeTreeLayoutBridge.TreeNode(
1861
+ view: child,
1862
+ style: childStyle,
1863
+ intrinsic: childIntrinsic,
1864
+ baseline: Self.firstBaseline(child, cross: childIntrinsic.height),
1865
+ direction: nested?.axis ?? .horizontal,
1866
+ gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
1867
+ rowGap: nested?.rowGap ?? 0,
1868
+ columnGap: nested?.columnGap ?? 0,
1869
+ wrap: nested?.wrap ?? false,
1870
+ justifyContent: nested?.justifyContent ?? "flex-start",
1871
+ alignItems: nested?.alignItems ?? "stretch",
1872
+ alignContent: nested?.alignContent ?? "flex-start",
1873
+ padding: nested?.effectivePadding ?? .zero,
1874
+ grid: nested?.grid ?? false,
1875
+ gridColumns: nested?.gridColumns ?? 1,
1876
+ reverse: nested?.reverse ?? false,
1877
+ wrapReverse: nested?.wrapReverse ?? false,
1878
+ children: nestedNode?.children ?? []
1879
+ )
1880
+ }
1881
+ guard nodes.allSatisfy({ $0 != nil }) else { return nil }
1882
+ return CraftNativeTreeLayoutBridge.TreeNode(
1883
+ view: self,
1884
+ style: style,
1885
+ intrinsic: intrinsic,
1886
+ baseline: 0,
1887
+ direction: axis,
1888
+ gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
1889
+ rowGap: rowGap ?? 0,
1890
+ columnGap: columnGap ?? 0,
1891
+ wrap: wrap,
1892
+ justifyContent: justifyContent,
1893
+ alignItems: alignItems,
1894
+ alignContent: alignContent,
1895
+ padding: effectivePadding,
1896
+ grid: grid,
1897
+ gridColumns: gridColumns,
1898
+ reverse: reverse,
1899
+ wrapReverse: wrapReverse,
1900
+ children: nodes.compactMap { $0 }
1901
+ )
1902
+ }
1903
+
1904
+ private func sharedTreeFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> CraftNativeTreeLayoutBridge.TreeResult? {
1905
+ guard let width, let height, gridAutoRows == nil,
1906
+ width.isFinite, height.isFinite, width >= 0, height >= 0 else { return nil }
1907
+ let nodes = children.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
1908
+ let style = child.craftLayoutStyle
1909
+ let intrinsic = CraftNativeFlexLayout.measure(child, width: style.width?.resolve(width), height: style.height?.resolve(height), maxWidth: width, maxHeight: height)
1910
+ let nested = child as? CraftNativeFlowView
1911
+ let nestedNode = nested?.sharedTreeNode(parentWidth: width, parentHeight: height)
1912
+ if nested != nil && nestedNode == nil { return nil }
1913
+ return CraftNativeTreeLayoutBridge.TreeNode(
1914
+ view: child,
1915
+ style: style,
1916
+ intrinsic: intrinsic,
1917
+ baseline: Self.firstBaseline(child, cross: intrinsic.height),
1918
+ direction: nested?.axis ?? .horizontal,
1919
+ gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
1920
+ rowGap: nested?.rowGap ?? 0,
1921
+ columnGap: nested?.columnGap ?? 0,
1922
+ wrap: nested?.wrap ?? false,
1923
+ justifyContent: nested?.justifyContent ?? "flex-start",
1924
+ alignItems: nested?.alignItems ?? "stretch",
1925
+ alignContent: nested?.alignContent ?? "flex-start",
1926
+ padding: nested?.effectivePadding ?? .zero,
1927
+ grid: nested?.grid ?? false,
1928
+ gridColumns: nested?.gridColumns ?? 1,
1929
+ reverse: nested?.reverse ?? false,
1930
+ wrapReverse: nested?.wrapReverse ?? false,
1931
+ children: nestedNode?.children ?? []
1932
+ )
1933
+ }
1934
+ guard nodes.allSatisfy({ $0 != nil }) else { return nil }
1935
+ let root = CraftNativeTreeLayoutBridge.TreeNode(
1936
+ view: nil,
1937
+ style: .empty,
1938
+ intrinsic: .zero,
1939
+ baseline: 0,
1940
+ direction: axis,
1941
+ gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
1942
+ rowGap: rowGap ?? 0,
1943
+ columnGap: columnGap ?? 0,
1944
+ wrap: wrap,
1945
+ justifyContent: justifyContent,
1946
+ alignItems: alignItems,
1947
+ alignContent: alignContent,
1948
+ padding: effectivePadding,
1949
+ grid: grid,
1950
+ gridColumns: gridColumns,
1951
+ reverse: reverse,
1952
+ wrapReverse: wrapReverse,
1953
+ children: nodes.compactMap { $0 }
1954
+ )
1955
+ return CraftNativeTreeLayoutBridge.treeFrames(root: root, width: width, height: height)
1956
+ }
1957
+
1551
1958
  /// The flexbox algorithm, single pass. `width`/`height` are this view's
1552
1959
  /// outer size when its parent fixes it, nil when it sizes to its content
1553
1960
  /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
@@ -1609,7 +2016,19 @@ private class CraftNativeFlowView: UIStackView {
1609
2016
  frames: &frames
1610
2017
  )
1611
2018
  }
1612
- if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
2019
+ if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height), (sharedTree.nodes.count > flow.count + 1 || grid) {
2020
+ frames = []
2021
+ for index in 1..<sharedTree.nodes.count {
2022
+ guard let view = sharedTree.nodes[index].view else { continue }
2023
+ let offset = sharedTree.frames[index]
2024
+ let parent = sharedTree.parents[index]
2025
+ let parentFrame = sharedTree.frames[parent]
2026
+ frames.append((view, CGRect(
2027
+ x: CGFloat(offset.x - parentFrame.x), y: CGFloat(offset.y - parentFrame.y),
2028
+ width: CGFloat(offset.width), height: CGFloat(offset.height)
2029
+ )))
2030
+ }
2031
+ } else if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
1613
2032
  frames = shared
1614
2033
  }
1615
2034
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
@@ -3321,6 +3740,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3321
3740
  // a screen that hid its own.
3322
3741
  navigationController?.setNavigationBarHidden(false, animated: animated)
3323
3742
  }
3743
+ applyNavigationBarAppearance()
3324
3744
  // Each screen says whether it wants the tab bar, so one that hid it
3325
3745
  // never leaves it hidden for the next.
3326
3746
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
@@ -3406,6 +3826,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3406
3826
 
3407
3827
  private func refreshTraitDefaults() {
3408
3828
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3829
+ applyNavigationBarAppearance()
3409
3830
  if let document = mutationDocument.node("root") {
3410
3831
  renderCommitted(document)
3411
3832
  } else {
@@ -3637,7 +4058,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3637
4058
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
3638
4059
  /// `craft.secureStorage.set` wrote.
3639
4060
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
3640
- /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled, backgroundColor, rightButtons })`;
4061
+ /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
4062
+ /// backgroundColor, navigationBarSolid, rightButtons })`;
3641
4063
  /// a right button's tap is the event `navButton`.
3642
4064
  ///
3643
4065
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
@@ -3905,6 +4327,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3905
4327
  /// own horizontal pager or chart gesture.
3906
4328
  private(set) var tabSwipeEnabled = true
3907
4329
  private var prefersLargeTitle: Bool?
4330
+ /// Whether this screen opts into an opaque navigation bar filled with its
4331
+ /// own background colour. The default keeps UIKit's material appearance.
4332
+ private var navigationBarSolid = false
3908
4333
  /// The screen's own colour (`backgroundColor`), under the status bar and
3909
4334
  /// the home indicator too, where its content never reaches: a black
3910
4335
  /// full-screen timer is black edge to edge. nil is the app's.
@@ -3930,6 +4355,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3930
4355
  /// `craft.navigation.setOptions`: this screen's bar. Every key is
3931
4356
  /// optional and only the keys given change.
3932
4357
  private func applyNavigationOptions(_ options: [String: Any]) {
4358
+ if let tab = options["tabItem"] as? [String: Any] {
4359
+ CraftChrome.shared.updateTab(tab)
4360
+ }
3933
4361
  // The app script has no screen, so no bar and no tab bar to change.
3934
4362
  guard !isAppScript else { return }
3935
4363
  if options.keys.contains("title") {
@@ -3964,6 +4392,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3964
4392
  screenBackgroundColor = color(options["backgroundColor"])
3965
4393
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3966
4394
  }
4395
+ if let solid = options["navigationBarSolid"] as? Bool {
4396
+ navigationBarSolid = solid
4397
+ }
3967
4398
  if let hidden = options["hidden"] as? Bool {
3968
4399
  navigationBarHidden = hidden
3969
4400
  if navigationController?.topViewController === self {
@@ -3974,6 +4405,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3974
4405
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
3975
4406
  navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
3976
4407
  }
4408
+ if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
4409
+ applyNavigationBarAppearance()
4410
+ }
3977
4411
  // Asked for after the bar was hidden for a bare root screen.
3978
4412
  if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
3979
4413
  navigationController?.isNavigationBarHidden == true {
@@ -3981,6 +4415,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3981
4415
  }
3982
4416
  }
3983
4417
 
4418
+ /// Applies the screen's optional opaque bar without changing the default
4419
+ /// UIKit material when the screen did not opt in.
4420
+ private func applyNavigationBarAppearance() {
4421
+ guard navigationBarSolid else {
4422
+ navigationItem.standardAppearance = nil
4423
+ navigationItem.scrollEdgeAppearance = nil
4424
+ navigationItem.compactAppearance = nil
4425
+ navigationItem.compactScrollEdgeAppearance = nil
4426
+ return
4427
+ }
4428
+ let appearance = UINavigationBarAppearance()
4429
+ appearance.configureWithOpaqueBackground()
4430
+ appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
4431
+ appearance.shadowColor = .clear
4432
+ navigationItem.standardAppearance = appearance
4433
+ navigationItem.scrollEdgeAppearance = appearance
4434
+ navigationItem.compactAppearance = appearance
4435
+ navigationItem.compactScrollEdgeAppearance = appearance
4436
+ }
4437
+
3984
4438
  private func applyStatusBar(_ props: [String: Any]) {
3985
4439
  let style: UIStatusBarStyle?
3986
4440
  switch (props["barStyle"] as? String)?.lowercased() {