craft-native 0.0.424 → 0.0.436

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.
@@ -42,7 +42,7 @@ import Darwin
42
42
  /// tabbar shown|hidden the native tab bar (Today, Calendar, …) can be tapped, or not
43
43
  /// selected <tab> the tab bar's selected button is this one
44
44
  /// tab <name> tap a tab bar button
45
- /// scrollto <text> swipes up (at most 8 times) until text is hittable
45
+ /// scrollto <text> at most 12 drags until the target is hittable and visible
46
46
  /// swipe up|down|left|right
47
47
  /// drag x1 y1 x2 y2 slow app-relative drag, held 0.3s before release
48
48
  /// audit <name> accessibility audit (element descriptions, Dynamic Type, contrast); logs each issue
@@ -143,13 +143,29 @@ final class DriverTests: XCTestCase {
143
143
  return any.exists ? any : nil
144
144
  }
145
145
 
146
+ /// Where a label is looked for, in order: the topmost presented alert,
147
+ /// then the topmost sheet, then the whole app. A sheet's "Sign out" and
148
+ /// the row that opened it share a label, and the row behind the sheet
149
+ /// is still reported hittable, so the presented one has to win.
150
+ func searchScopes<Scope>(alerts: [Scope], sheets: [Scope], app: Scope) -> [Scope] {
151
+ [alerts.last, sheets.last].compactMap { $0 } + [app]
152
+ }
153
+
154
+ func presentedScopes() -> [XCUIElement] {
155
+ let alerts = app.alerts.allElementsBoundByIndex.filter { $0.exists }
156
+ let sheets = app.sheets.allElementsBoundByIndex.filter { $0.exists }
157
+ return searchScopes(alerts: alerts, sheets: sheets, app: app as XCUIElement)
158
+ }
159
+
146
160
  func exact(_ label: String) -> XCUIElement? {
147
161
  let predicate = NSPredicate(format: "label == %@", label)
148
- for query in [app.buttons, app.links, app.staticTexts] {
149
- let matches = query.matching(predicate)
150
- for index in 0..<min(matches.count, 25) {
151
- let candidate = matches.element(boundBy: index)
152
- if candidate.exists && candidate.isHittable { return candidate }
162
+ for scope in presentedScopes() {
163
+ for query in [scope.buttons, scope.links, scope.staticTexts] {
164
+ let matches = query.matching(predicate)
165
+ for index in 0..<min(matches.count, 25) {
166
+ let candidate = matches.element(boundBy: index)
167
+ if candidate.exists && candidate.isHittable { return candidate }
168
+ }
153
169
  }
154
170
  }
155
171
  return nil
@@ -164,6 +180,27 @@ final class DriverTests: XCTestCase {
164
180
  return nil
165
181
  }
166
182
 
183
+ /// A short target fits entirely; an oversized target fills the viewport.
184
+ func visibleScrollFrame(_ target: CGRect, in viewport: CGRect) -> Bool {
185
+ let visible = target.intersection(viewport)
186
+ return !target.isEmpty && !viewport.isEmpty && !visible.isNull
187
+ && visible.width >= min(target.width, viewport.width)
188
+ && visible.height >= min(target.height, viewport.height)
189
+ }
190
+
191
+ func scrollTargetVisible(_ target: XCUIElement?) -> Bool {
192
+ guard let target, target.exists, target.isHittable,
193
+ visibleScrollFrame(target.frame, in: app.frame) else { return false }
194
+ // Hittable alone accepts controls clipped by the scroll view's edge.
195
+ // Only an ancestor scroll view clips this target, not its siblings.
196
+ let identity = NSPredicate(format: "identifier == %@ AND label == %@", target.identifier, target.label)
197
+ for scroll in app.scrollViews.allElementsBoundByIndex {
198
+ if scroll.descendants(matching: .any).matching(identity).firstMatch.exists,
199
+ !visibleScrollFrame(target.frame, in: scroll.frame.intersection(app.frame)) { return false }
200
+ }
201
+ return true
202
+ }
203
+
167
204
  /// About a third of a screen of scrolling, as a held drag.
168
205
  ///
169
206
  /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
@@ -248,8 +285,15 @@ final class DriverTests: XCTestCase {
248
285
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
249
286
  }
250
287
 
251
- /// A tap that never fails the run: an element covered a moment (the big
252
- /// timer coming up) is tapped where it is, and the check after says.
288
+ /// A switch reports "0" or "1". Any other value is not a switch state to
289
+ /// wait on, so the tap stands as it would for any other control.
290
+ func switchFlipped(from before: String?, to after: String?) -> Bool {
291
+ let states: Set<String> = ["0", "1"]
292
+ guard let before, let after, states.contains(before), states.contains(after) else { return true }
293
+ return before != after
294
+ }
295
+
296
+ /// Deliver one gesture; the command's observation determines success.
253
297
  func press(_ element: XCUIElement) {
254
298
  if element.isHittable { element.tap() }
255
299
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
@@ -401,8 +445,21 @@ final class DriverTests: XCTestCase {
401
445
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
402
446
  case "tapid":
403
447
  let target = identified(arg)
404
- if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
405
- else { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
448
+ if !target.waitForExistence(timeout: 10) { XCTFail("Element not found"); note("FAIL tapid \(arg): NOT FOUND") }
449
+ else if target.elementType == .switch {
450
+ // Observe one physical gesture. Retapping could conceal a
451
+ // renderer or hit-testing failure (HQ agreement job 7570).
452
+ let before = target.value as? String
453
+ press(target)
454
+ let settle = Date().addingTimeInterval(1.5)
455
+ var flipped = false
456
+ repeat {
457
+ flipped = switchFlipped(from: before, to: target.value as? String)
458
+ if !flipped { Thread.sleep(forTimeInterval: 0.25) }
459
+ } while !flipped && Date() < settle
460
+ check(flipped, "tapid \(arg) flips the switch")
461
+ }
462
+ else { press(target); note("tapid \(arg): ok") }
406
463
  case "longpressid":
407
464
  let target = identified(arg)
408
465
  guard target.waitForExistence(timeout: 10), target.isHittable else {
@@ -590,13 +647,13 @@ final class DriverTests: XCTestCase {
590
647
  case "scrollto":
591
648
  var found = element(containing: arg)
592
649
  var drags = 0
593
- while (found == nil || !(found!.isHittable)) && drags < 12 {
650
+ while !scrollTargetVisible(found) && drags < 12 {
594
651
  let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
595
652
  scrollPage(towardTop: towardTop)
596
653
  drags += 1
597
654
  found = element(containing: arg)
598
655
  }
599
- check(found != nil && found!.isHittable, "scrollto \(arg)")
656
+ check(scrollTargetVisible(found), "scrollto \(arg)")
600
657
  case "swipe":
601
658
  switch arg {
602
659
  case "up": app.swipeUp()
@@ -644,7 +701,9 @@ final class DriverTests: XCTestCase {
644
701
  // A system prompt over the app ("Save Password?" → Not Now), wherever iOS shows it.
645
702
  let label = arg.isEmpty ? "Not Now" : arg
646
703
  let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
647
- let candidates = [app.buttons[label], springboard.buttons[label]]
704
+ // The app's own alert or sheet first (its button can share a
705
+ // label with the control behind it), then the app, then iOS.
706
+ let candidates = { self.presentedScopes().map { $0.buttons[label] } + [springboard.buttons[label]] }
648
707
  let end = Date().addingTimeInterval(6)
649
708
  // A tap is not a dismissal. iOS 26 draws "Save Password?" as a
650
709
  // sheet that is in the tree, and hittable, while it still slides
@@ -655,7 +714,7 @@ final class DriverTests: XCTestCase {
655
714
  var tapped = false
656
715
  var gone = false
657
716
  while !gone && Date() < end {
658
- guard let button = candidates.first(where: { $0.exists && $0.isHittable }) else {
717
+ guard let button = candidates().first(where: { $0.exists && $0.isHittable }) else {
659
718
  if tapped { gone = true } else { Thread.sleep(forTimeInterval: 0.5) }
660
719
  continue
661
720
  }
@@ -43,9 +43,9 @@ import process from "process";
43
43
  var macosToolchain = {
44
44
  zig: "0.18.0-dev.252+86dc5ce25",
45
45
  sources: {
46
- "zig-js": { repository: "zig-utils/zig-js", revision: "a6739721273ae0f13d36fb3422052a588c5079a8", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
46
+ "zig-js": { repository: "zig-utils/zig-js", revision: "44a0a2ba814a911ac701c44073bd8d7d8ff6a22d", paths: ["src", "include", "tests", "tools", "docs", "bench", "conformance"] },
47
47
  "zig-regex": { repository: "zig-utils/zig-regex", revision: "fdd9c2e333ea6ac2fd06d080d939ef7f11d61b1b", paths: ["src"] },
48
- "zig-gc": { repository: "zig-utils/zig-gc", revision: "0285ef801dd221a4ae8e88b31a09ae31627657a3", paths: ["src"] }
48
+ "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
49
49
  }
50
50
  };
51
51
  var defaultRun = async (args, cwd) => {
@@ -9,7 +9,7 @@ export declare const macosToolchain: {
9
9
  readonly sources: {
10
10
  readonly 'zig-js': {
11
11
  readonly repository: 'zig-utils/zig-js';
12
- readonly revision: 'a6739721273ae0f13d36fb3422052a588c5079a8';
12
+ readonly revision: '44a0a2ba814a911ac701c44073bd8d7d8ff6a22d';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -19,7 +19,7 @@ export declare const macosToolchain: {
19
19
  };
20
20
  readonly 'zig-gc': {
21
21
  readonly repository: 'zig-utils/zig-gc';
22
- readonly revision: '0285ef801dd221a4ae8e88b31a09ae31627657a3';
22
+ readonly revision: '73c802e05768a729614e9c4296abb118aa06faf9';
23
23
  readonly paths: readonly ['src'];
24
24
  };
25
25
  };
@@ -21,6 +21,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
21
21
  let trafficLights: CGPoint?
22
22
  /// The screen has work to finish before the app quits (`craft.desktop.onQuit`).
23
23
  var holdsQuit = false
24
+ /// The content width the screen last heard, so a resize that changes nothing sends nothing.
25
+ var reportedViewportWidth: CGFloat = -1
24
26
  init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
25
27
  self.name = name
26
28
  runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
@@ -49,9 +51,22 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
49
51
  if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
50
52
  window.makeKeyAndOrderFront(nil)
51
53
  placeTrafficLights()
54
+ // The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
55
+ let size = window.contentLayoutRect.size
56
+ reportedViewportWidth = size.width
57
+ runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
52
58
  runtime.load(application.bundle)
53
59
  }
54
60
 
61
+ /// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
62
+ /// since a live resize lays out every frame.
63
+ func reportViewport() {
64
+ let size = window.contentLayoutRect.size
65
+ guard size.width > 0, abs(size.width - reportedViewportWidth) >= 0.5 else { return }
66
+ reportedViewportWidth = size.width
67
+ runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
68
+ }
69
+
55
70
  /// AppKit lays the title bar out again on resize, full screen and appearance changes, so this runs after each.
56
71
  func placeTrafficLights() {
57
72
  guard let inset = trafficLights, let close = window.standardWindowButton(.closeButton), let titlebar = close.superview?.superview else { return }
@@ -64,8 +79,8 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
64
79
  window.standardWindowButton(type)?.setFrameOrigin(CGPoint(x: inset.x + CGFloat(index) * (size.width + 6), y: (height - size.height) / 2))
65
80
  }
66
81
  }
67
- func windowDidResize(_ notification: Notification) { placeTrafficLights() }
68
- func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights() }
82
+ func windowDidResize(_ notification: Notification) { placeTrafficLights(); reportViewport() }
83
+ func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights(); reportViewport() }
69
84
  func windowShouldClose(_ sender: NSWindow) -> Bool {
70
85
  if name == "main" { sender.orderOut(nil); return false }
71
86
  return true
@@ -213,6 +228,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
213
228
  defer { CATransaction.commit() }
214
229
  let scheme = CraftMacColor.isDark ? "dark" : "light"
215
230
  for scene in scenes.values {
231
+ // AppKit lays the title bar out again for the new appearance, after this returns.
232
+ scene.placeTrafficLights()
233
+ DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
216
234
  scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
217
235
  // The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
218
236
  scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
@@ -455,7 +473,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
455
473
  let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == target }) else { return ["ok": false, "error": "No control \(action["target"] ?? "")"] }
456
474
  let text = action["text"] as? String ?? ""
457
475
  switch kind {
458
- case "press": if let button = box.control as? NSButton { button.performClick(nil) } else { box.fire("onPress") }
476
+ case "press": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.fire("onPress") }
459
477
  case "type":
460
478
  if let field = box.control as? NSTextField { field.stringValue = text }
461
479
  else if let view = (box.control as? NSScrollView)?.documentView as? NSTextView { view.string = text }
@@ -500,7 +518,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
500
518
  let editor = (target as? NSTextField)?.currentEditor() as? NSTextView ?? target as? NSTextView
501
519
  guard let editor else { return ["ok": false, "error": "Not a text input"] }
502
520
  editor.doCommand(by: command)
503
- case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
521
+ case "click": if let button = box.control as? NSButton { button.performClick(nil) } else if let toggle = box.control as? NSSwitch { toggle.performClick(nil) } else { box.pressed() }
504
522
  case "fill":
505
523
  let value = action["value"] as? String ?? ""
506
524
  if let field = box.control as? NSTextField { field.stringValue = value; box.input(value) }
@@ -536,7 +554,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
536
554
  return node
537
555
  }
538
556
  func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
539
- return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "errors": scene.runtime.errors]
557
+ return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: type(of: scene.runtime.context)), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
540
558
  }
541
559
  let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
542
560
  return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
@@ -288,6 +288,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
288
288
  static var measuredCount = 0
289
289
  private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
290
290
  private var measured: [MeasureKey: CGSize] = [:]
291
+ /// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
292
+ func forgetMeasurements() { measured.removeAll(keepingCapacity: true) }
291
293
  private var measuredGeneration = -1
292
294
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
293
295
  var layoutStyle: [String: Any] {
@@ -318,7 +320,10 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
318
320
  case "Picker":
319
321
  let picker = NSPopUpButton(frame: .zero, pullsDown: false)
320
322
  picker.target = self; picker.action = #selector(changed); control = picker
321
- case "Switch", "Checkbox":
323
+ case "Switch":
324
+ // A toggle, as System Settings draws one; a checkbox is the Checkbox primitive's.
325
+ let toggle = NSSwitch(); toggle.target = self; toggle.action = #selector(changed); control = toggle
326
+ case "Checkbox":
322
327
  let button = NSButton(checkboxWithTitle: "", target: self, action: #selector(changed)); control = button
323
328
  case "Icon", "Image":
324
329
  let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; control = image
@@ -407,6 +412,11 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
407
412
  text.maximumNumberOfLines = (props["numberOfLines"] as? NSNumber)?.intValue ?? 0
408
413
  text.lineBreakMode = text.maximumNumberOfLines == 1 ? .byTruncatingTail : .byWordWrapping
409
414
  }
415
+ if let toggle = control as? NSSwitch {
416
+ toggle.state = (props["value"] as? Bool ?? props["checked"] as? Bool ?? false) ? .on : .off
417
+ toggle.isEnabled = props["disabled"] as? Bool != true
418
+ if let label { toggle.setAccessibilityLabel(label) }
419
+ }
410
420
  if let button = control as? NSButton {
411
421
  let title = props["title"] as? String ?? nativeText(node)
412
422
  button.title = props["iconOnly"] as? Bool == true ? "" : title
@@ -811,7 +821,8 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
811
821
  func fire(_ name: String, _ payload: [String: Any] = [:]) { renderer?.runtime.event(events[name], payload: payload) }
812
822
  @objc func pressed() { fire("onPress") }
813
823
  @objc func changed() {
814
- let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? ((control as? NSButton)?.state == .on)
824
+ let state: NSControl.StateValue? = (control as? NSSwitch)?.state ?? (control as? NSButton)?.state
825
+ let value: Any = (control as? NSPopUpButton)?.selectedItem?.representedObject ?? (state == .on)
815
826
  fire("onValueChange", ["value": value, "nativeEvent": ["value": value]])
816
827
  }
817
828
  func controlTextDidChange(_ notification: Notification) { if let field = notification.object as? NSTextField { input(field.stringValue) } }
@@ -957,6 +968,77 @@ final class CraftMacRenderer {
957
968
  weak var window: NSWindow?
958
969
  var sheets: [String: NSPanel] = [:]
959
970
  init(runtime: CraftMacRuntime) { self.runtime = runtime }
971
+ /// The box for `node`, made or reconfigured, and its children's in order; every id drawn goes into `retained`.
972
+ func reconcile(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
973
+ let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
974
+ retained.insert(id)
975
+ let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
976
+ boxes[id] = box; box.configure(node)
977
+ if let flow = box.flow, kind != "Button" {
978
+ let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map { reconcile($0, retained: &retained) }
979
+ for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
980
+ flow.boxes = children
981
+ for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
982
+ // `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
983
+ let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
984
+ .sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
985
+ if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
986
+ for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
987
+ }
988
+ }
989
+ return box
990
+ }
991
+
992
+ /// A batch drawn where it happened rather than across the window: each changed node's subtree
993
+ /// (or, for a node the batch created, its nearest drawn ancestor's) is reconciled again, and only
994
+ /// those boxes and the boxes above them measure and lay out again. A hover on one row used to
995
+ /// reconfigure every view in the window and forget every measurement. False when it cannot be
996
+ /// done in place (nothing drawn yet, a sheet, a node changing kind): the caller renders the tree.
997
+ func update(_ ids: [String], parentOf: (String) -> String?, node nodeFor: (String) -> [String: Any]?) -> Bool {
998
+ guard !boxes.isEmpty else { return false }
999
+ var targets: [String] = []
1000
+ for id in ids {
1001
+ var current: String? = id
1002
+ while let candidate = current, boxes[candidate] == nil { current = parentOf(candidate) }
1003
+ guard let target = current else { return false }
1004
+ if !targets.contains(target) { targets.append(target) }
1005
+ }
1006
+ // A target inside another is drawn with it.
1007
+ let chosen = targets.filter { target in
1008
+ guard let box = boxes[target] else { return false }
1009
+ return !targets.contains { other in other != target && boxes[other].map { box.isDescendant(of: $0) } == true }
1010
+ }
1011
+ CATransaction.begin(); CATransaction.setDisableActions(true)
1012
+ defer { CATransaction.commit() }
1013
+ for target in chosen {
1014
+ guard let box = boxes[target], box.window === window, sheets[target] == nil, box.kind != "Modal",
1015
+ let node = nodeFor(target), (node["type"] as? String ?? "View") == box.kind else { return false }
1016
+ var before = Set<String>()
1017
+ collectIds(box, into: &before)
1018
+ var retained = Set<String>()
1019
+ _ = reconcile(node, retained: &retained)
1020
+ for id in before where !retained.contains(id) {
1021
+ guard let gone = boxes.removeValue(forKey: id) else { continue }
1022
+ gone.imageTask?.cancel(); gone.removeFromSuperview()
1023
+ }
1024
+ for id in retained { if let drawn = boxes[id] { drawn.forgetMeasurements(); drawn.needsLayout = true } }
1025
+ var above: NSView? = box.superview
1026
+ while let view = above {
1027
+ (view as? CraftMacBox)?.forgetMeasurements()
1028
+ view.needsLayout = true
1029
+ above = view.superview
1030
+ }
1031
+ }
1032
+ root.needsLayout = true
1033
+ root.layoutSubtreeIfNeeded()
1034
+ return true
1035
+ }
1036
+
1037
+ private func collectIds(_ box: CraftMacBox, into ids: inout Set<String>) {
1038
+ ids.insert(box.id)
1039
+ for child in box.flow?.boxes ?? [] { collectIds(child, into: &ids) }
1040
+ }
1041
+
960
1042
  func render(_ document: [String: Any]) {
961
1043
  CATransaction.begin(); CATransaction.setDisableActions(true)
962
1044
  defer { CATransaction.commit() }
@@ -967,26 +1049,7 @@ final class CraftMacRenderer {
967
1049
  let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
968
1050
  scrolls.append((box, bounds.origin, bottom))
969
1051
  } }
970
- func reconcile(_ node: [String: Any]) -> CraftMacBox {
971
- let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
972
- retained.insert(id)
973
- let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
974
- boxes[id] = box; box.configure(node)
975
- if let flow = box.flow, kind != "Button" {
976
- let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map(reconcile)
977
- for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
978
- flow.boxes = children
979
- for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
980
- // `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
981
- let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
982
- .sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
983
- if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
984
- for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
985
- }
986
- }
987
- return box
988
- }
989
- let box = reconcile(document)
1052
+ let box = reconcile(document, retained: &retained)
990
1053
  // Boxes were reconfigured and children replaced: nothing measured before now holds.
991
1054
  CraftMacBox.measureGeneration &+= 1
992
1055
  for (id, box) in boxes where !retained.contains(id) {
@@ -173,7 +173,37 @@ final class CraftMacRuntime {
173
173
  """)
174
174
  }
175
175
 
176
- func load(_ bundle: String) { context.evaluateScript(bundle); ZJSContextRunMicrotasks(context) }
176
+ func load(_ bundle: String) {
177
+ startProfileIfAsked()
178
+ context.evaluateScript(bundle); ZJSContextRunMicrotasks(context)
179
+ }
180
+
181
+ /// `CRAFT_JS_PROFILE=<file>`: sample this screen's JavaScript from the moment its bundle loads,
182
+ /// for `CRAFT_JS_PROFILE_MS` milliseconds (5000, every `CRAFT_JS_PROFILE_INTERVAL_US`, 1000),
183
+ /// and write it as Chrome's `.cpuprofile` (DevTools and speedscope open it). A window other
184
+ /// than Main writes beside it, its route in the name.
185
+ private func startProfileIfAsked() {
186
+ let env = ProcessInfo.processInfo.environment
187
+ guard let path = env["CRAFT_JS_PROFILE"], !path.isEmpty, let ref = context.jsGlobalContextRef else { return }
188
+ guard ZJSContextStartCPUProfile(ref, UInt32(env["CRAFT_JS_PROFILE_INTERVAL_US"] ?? "") ?? 0) else { return }
189
+ let seconds = (Double(env["CRAFT_JS_PROFILE_MS"] ?? "") ?? 5000) / 1000
190
+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds) { [weak self] in self?.writeProfile(to: path) }
191
+ }
192
+
193
+ private func writeProfile(to path: String) {
194
+ guard !disposed, let ref = context.jsGlobalContextRef, let json = ZJSContextStopCPUProfile(ref) else { return }
195
+ defer { JSStringRelease(json) }
196
+ let size = JSStringGetMaximumUTF8CStringSize(json)
197
+ var bytes = [CChar](repeating: 0, count: size)
198
+ JSStringGetUTF8CString(json, &bytes, size)
199
+ let url = URL(fileURLWithPath: path)
200
+ let target = route == "Main" ? url : url.deletingPathExtension().appendingPathExtension("\(route).cpuprofile")
201
+ do {
202
+ try String(cString: bytes).write(to: target, atomically: true, encoding: .utf8)
203
+ fputs("[craft] JavaScript profile written to \(target.path)\n", stderr)
204
+ }
205
+ catch { fputs("[craft] could not write the JavaScript profile to \(target.path): \(error)\n", stderr) }
206
+ }
177
207
 
178
208
  func dispose() {
179
209
  guard !disposed else { return }
@@ -215,7 +245,9 @@ final class CraftMacRuntime {
215
245
  case "MUTATE":
216
246
  do {
217
247
  let result = try document.apply(payload)
218
- if let tree = result.document { scene?.renderer.render(tree) }
248
+ // Drawn where it changed; the whole tree only when the batch reshaped it.
249
+ if result.requiresFullRender || scene?.renderer.update(result.affectedNodeIds, parentOf: document.parentId(of:), node: document.node) != true,
250
+ let tree = result.document { scene?.renderer.render(tree) }
219
251
  send("MUTATION_ACK", ["version": 1, "batchId": result.batchId, "revision": result.revision])
220
252
  } catch let error as CraftNativeMutationFailure {
221
253
  send("MUTATION_ERROR", ["version": 1, "batchId": payload["batchId"] ?? "", "code": error.code, "message": error.message])
@@ -18,10 +18,22 @@ struct CraftNativeMutationFailure: Error, Equatable {
18
18
  struct CraftNativeMutationResult {
19
19
  let batchId: String
20
20
  let revision: Int
21
- let document: [String: Any]?
22
21
  let updatedNodeIds: [String]
23
22
  let affectedNodeIds: [String]
24
23
  let requiresFullRender: Bool
24
+ private let materialize: () -> [String: Any]?
25
+ /// The committed tree, built when asked for: a host that redraws only the
26
+ /// nodes a batch touched never needs the whole tree.
27
+ var document: [String: Any]? { materialize() }
28
+
29
+ init(batchId: String, revision: Int, document: @escaping () -> [String: Any]?, updatedNodeIds: [String], affectedNodeIds: [String], requiresFullRender: Bool) {
30
+ self.batchId = batchId
31
+ self.revision = revision
32
+ self.materialize = document
33
+ self.updatedNodeIds = updatedNodeIds
34
+ self.affectedNodeIds = affectedNodeIds
35
+ self.requiresFullRender = requiresFullRender
36
+ }
25
37
  }
26
38
 
27
39
  /// Validates and applies versioned native-tree mutations without touching UIKit.
@@ -91,6 +103,8 @@ final class CraftNativeMutationDocument {
91
103
  var updatedNodeIds: [String] = []
92
104
  var affectedNodeIds: [String] = []
93
105
  var requiresFullRender = false
106
+ // Only an operation that adds, moves or removes nodes can leave one unreachable.
107
+ var reshaped = false
94
108
  for (index, value) in operations.enumerated() {
95
109
  guard let operation = value as? [String: Any], let name = operation["op"] as? String else {
96
110
  throw CraftNativeMutationFailure(
@@ -99,6 +113,7 @@ final class CraftNativeMutationDocument {
99
113
  operationIndex: index
100
114
  )
101
115
  }
116
+ if name != "updateNode" { reshaped = true }
102
117
  do {
103
118
  switch name {
104
119
  case "createNode":
@@ -147,7 +162,7 @@ final class CraftNativeMutationDocument {
147
162
  guard let root = candidateNodes[candidateRoot], root.parent == nil else {
148
163
  throw CraftNativeMutationFailure("INVALID_TREE", "root node must not have a parent")
149
164
  }
150
- guard descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
165
+ guard !reshaped || descendants(of: candidateRoot, nodes: candidateNodes).count + 1 == candidateNodes.count else {
151
166
  throw CraftNativeMutationFailure("INVALID_TREE", "all nodes must be reachable from the root")
152
167
  }
153
168
  }
@@ -158,7 +173,7 @@ final class CraftNativeMutationDocument {
158
173
  return CraftNativeMutationResult(
159
174
  batchId: batchId,
160
175
  revision: nextRevision,
161
- document: candidateRoot.flatMap { materialize($0, nodes: candidateNodes) },
176
+ document: { [candidateRoot, candidateNodes, weak self] in candidateRoot.flatMap { self?.materialize($0, nodes: candidateNodes) } },
162
177
  updatedNodeIds: updatedNodeIds,
163
178
  affectedNodeIds: affectedNodeIds,
164
179
  requiresFullRender: requiresFullRender
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.424",
3
+ "version": "0.0.436",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",