craft-native 0.0.435 → 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.
package/dist/cli.js CHANGED
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3047
3047
  `);
3048
3048
  }
3049
3049
  // package.json
3050
- var version = "0.0.435";
3050
+ var version = "0.0.436";
3051
3051
 
3052
3052
  // bin/cli.ts
3053
3053
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -375,7 +375,7 @@ function craftBinaryNotFoundMessage(triedPath) {
375
375
  `);
376
376
  }
377
377
  // package.json
378
- var version = "0.0.435";
378
+ var version = "0.0.436";
379
379
 
380
380
  // src/sdk-version.ts
381
381
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -117,7 +117,7 @@ function craftBinaryNotFoundMessage(triedPath) {
117
117
  `);
118
118
  }
119
119
  // package.json
120
- var version = "0.0.435";
120
+ var version = "0.0.436";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -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
@@ -285,8 +285,15 @@ final class DriverTests: XCTestCase {
285
285
  return (parts[0], parts.count > 1 ? Double(parts[1]) ?? 10 : 10)
286
286
  }
287
287
 
288
- /// A tap that never fails the run: an element covered a moment (the big
289
- /// 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.
290
297
  func press(_ element: XCUIElement) {
291
298
  if element.isHittable { element.tap() }
292
299
  else { element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() }
@@ -438,8 +445,21 @@ final class DriverTests: XCTestCase {
438
445
  else { XCTFail("Element not found"); note("FAIL tapexact \(arg): NOT FOUND") }
439
446
  case "tapid":
440
447
  let target = identified(arg)
441
- if target.waitForExistence(timeout: 10) { press(target); note("tapid \(arg): ok") }
442
- 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") }
443
463
  case "longpressid":
444
464
  let target = identified(arg)
445
465
  guard target.waitForExistence(timeout: 10), target.isHittable else {
@@ -43,7 +43,7 @@ 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: "59497a090677aeb317e52ac55329e6a680457727", 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
48
  "zig-gc": { repository: "zig-utils/zig-gc", revision: "73c802e05768a729614e9c4296abb118aa06faf9", paths: ["src"] }
49
49
  }
@@ -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: '59497a090677aeb317e52ac55329e6a680457727';
12
+ readonly revision: '44a0a2ba814a911ac701c44073bd8d7d8ff6a22d';
13
13
  readonly paths: readonly ['src', 'include', 'tests', 'tools', 'docs', 'bench', 'conformance'];
14
14
  };
15
15
  readonly 'zig-regex': {
@@ -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] {
@@ -966,6 +968,77 @@ final class CraftMacRenderer {
966
968
  weak var window: NSWindow?
967
969
  var sheets: [String: NSPanel] = [:]
968
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
+
969
1042
  func render(_ document: [String: Any]) {
970
1043
  CATransaction.begin(); CATransaction.setDisableActions(true)
971
1044
  defer { CATransaction.commit() }
@@ -976,26 +1049,7 @@ final class CraftMacRenderer {
976
1049
  let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
977
1050
  scrolls.append((box, bounds.origin, bottom))
978
1051
  } }
979
- func reconcile(_ node: [String: Any]) -> CraftMacBox {
980
- let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
981
- retained.insert(id)
982
- let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
983
- boxes[id] = box; box.configure(node)
984
- if let flow = box.flow, kind != "Button" {
985
- let children = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }.map(reconcile)
986
- for child in flow.boxes where !children.contains(where: { $0 === child }) { child.removeFromSuperview() }
987
- flow.boxes = children
988
- for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
989
- // `zIndex` draws a child above its siblings (an overlay over a web page), highest last.
990
- let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
991
- .sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
992
- if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
993
- for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
994
- }
995
- }
996
- return box
997
- }
998
- let box = reconcile(document)
1052
+ let box = reconcile(document, retained: &retained)
999
1053
  // Boxes were reconfigured and children replaced: nothing measured before now holds.
1000
1054
  CraftMacBox.measureGeneration &+= 1
1001
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.435",
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>",