craft-native 0.0.436 → 0.0.443

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.
Files changed (38) hide show
  1. package/LICENSE.md +30 -0
  2. package/README.md +35 -0
  3. package/dist/android/src/index.d.ts +44 -0
  4. package/dist/android/src/index.js +82 -3
  5. package/dist/android/templates/AndroidManifest.xml.template +2 -1
  6. package/dist/android/templates/CraftHealthServices.kt.template +2 -0
  7. package/dist/android/templates/CraftNativeCapabilities.kt.template +42 -1
  8. package/dist/android/templates/CraftNativeMotion.kt.template +269 -0
  9. package/dist/android/templates/CraftNativeServices.kt.template +6 -1
  10. package/dist/android/templates/CraftPassiveSteps.kt.template +159 -0
  11. package/dist/android/templates/CraftRecordingPressure.kt.template +156 -0
  12. package/dist/android/templates/LocationRecordingService.kt.template +77 -9
  13. package/dist/android/templates/MainActivity.kt.template +3 -0
  14. package/dist/android/templates/MainActivityNative.kt.template +132 -1
  15. package/dist/cli.js +289 -108
  16. package/dist/components/index.d.ts +25 -0
  17. package/dist/index.cjs +207 -27
  18. package/dist/index.d.cts +2 -2
  19. package/dist/index.d.ts +2 -2
  20. package/dist/index.js +321 -140
  21. package/dist/installer-background.d.ts +2 -0
  22. package/dist/installer-layout.d.ts +1 -0
  23. package/dist/ios/src/index.d.ts +23 -1
  24. package/dist/ios/src/index.js +49 -9
  25. package/dist/ios/templates/CraftApp.swift +14 -0
  26. package/dist/ios/templates/CraftHealth.swift +2 -1
  27. package/dist/ios/templates/CraftHealthBackground.swift +308 -0
  28. package/dist/ios/templates/CraftNativeActions.swift +153 -0
  29. package/dist/ios/templates/CraftNativePedometer.swift +181 -0
  30. package/dist/ios/templates/CraftNativeScreen.swift +76 -0
  31. package/dist/macos/src/index.js +1 -1
  32. package/dist/macos/src/toolchain.d.ts +1 -1
  33. package/dist/macos/templates/CraftMacApplication.swift +124 -39
  34. package/dist/macos/templates/CraftMacRenderer.swift +111 -33
  35. package/dist/macos/templates/CraftMacRuntime.swift +270 -81
  36. package/dist/package.d.ts +40 -0
  37. package/dist/types.d.ts +26 -0
  38. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import AppKit
2
2
  import AVKit
3
+ import ZigJS
3
4
  import CoreImage
4
5
  import UserNotifications
5
6
  import UniformTypeIdentifiers
@@ -23,19 +24,31 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
23
24
  var holdsQuit = false
24
25
  /// The content width the screen last heard, so a resize that changes nothing sends nothing.
25
26
  var reportedViewportWidth: CGFloat = -1
26
- init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
27
+ /// The content size the screen started with, which the window corrects once it shows.
28
+ let startedViewport: CGSize
29
+ private var shown = false
30
+ /// How long a window waits for its screen's first tree before it opens without it.
31
+ static let firstTreeGrace: TimeInterval = 0.5
32
+ /// `runtime` is the screen already started for this window (the main one starts before AppKit has
33
+ /// launched); without one the screen starts here, and evaluates its bundle while the window is made.
34
+ init(name: String, route: String, options: [String: Any], application: CraftMacApplication, runtime prepared: CraftMacRuntime? = nil) {
35
+ CraftTrace.mark("scene \(name)")
27
36
  self.name = name
28
- runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
29
- renderer = CraftMacRenderer(runtime: runtime)
30
37
  let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
31
38
  let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
39
+ let viewport = Self.contentSize(options: options, application: application, name: name)
40
+ runtime = prepared ?? application.startScreen(route: route, options: options, viewport: viewport, colorScheme: CraftMacColor.isDark ? "dark" : "light")
41
+ reportedViewportWidth = viewport.width
42
+ startedViewport = viewport
43
+ renderer = CraftMacRenderer(runtime: runtime)
32
44
  let lights = options["trafficLights"] as? [Any] ?? (name == "main" ? application.trafficLights : nil)
33
45
  trafficLights = lights.flatMap { values in values.count == 2 ? number(values[0]).flatMap { x in number(values[1]).map { CGPoint(x: x, y: $0) } } : nil }
34
- let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false)
46
+ let panel = CraftMacWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: transparent ? [.titled, .closable, .resizable, .miniaturizable, .fullSizeContentView] : [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: true)
35
47
  panel.runtime = runtime
36
48
  window = panel
49
+ CraftTrace.mark("window created")
37
50
  super.init()
38
- runtime.scene = self; renderer.window = window
51
+ renderer.window = window
39
52
  window.title = options["title"] as? String ?? application.appName
40
53
  if (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent" {
41
54
  window.titlebarAppearsTransparent = true
@@ -48,14 +61,54 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
48
61
  renderer.root.autoresizingMask = [.width, .height]
49
62
  window.contentView = renderer.root
50
63
  window.delegate = self; window.center()
51
- if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
52
- window.makeKeyAndOrderFront(nil)
64
+ if let autosave = Self.autosaveName(name: name, application: application) { window.setFrameAutosaveName(autosave) }
65
+ // Whatever the screen drew while the window was made is in it before it shows. A screen that has not
66
+ // drawn yet gets a moment to, so the window opens with its content rather than blank.
67
+ runtime.attach(self)
68
+ if runtime.hasDrawn { show() } else {
69
+ runtime.whenDrawn { [weak self] in self?.show() }
70
+ DispatchQueue.main.asyncAfter(deadline: .now() + Self.firstTreeGrace) { [weak self] in self?.show() }
71
+ }
72
+ }
73
+
74
+ /// Orders the window front the first time. The window is made deferred, so the window server's side
75
+ /// of it is made here, once, rather than updated for each property set while it was configured; and
76
+ /// it opens without the zoom AppKit plays for a new window, which runs a display link and draws it in
77
+ /// over several frames. Later showings animate as usual.
78
+ func show() {
79
+ guard !shown else { return }
80
+ shown = true
81
+ let behavior = window.animationBehavior
82
+ window.animationBehavior = .none
83
+ CraftTrace.span("window ordered front") { window.makeKeyAndOrderFront(nil) }
84
+ window.animationBehavior = behavior
53
85
  placeTrafficLights()
54
- // The screen starts knowing its width, so `md:`/`lg:` classes and `matchMedia` are right on the first frame.
86
+ // The screen started with the size this window was expected to open at; a saved frame the
87
+ // screen cannot fit, or a smaller display, makes it another, which the screen now hears.
55
88
  let size = window.contentLayoutRect.size
56
- reportedViewportWidth = size.width
57
- runtime.context.evaluateScript("__stxNativeBridge.viewportWidth = \(Double(size.width)); __stxNativeBridge.viewportHeight = \(Double(size.height));")
58
- runtime.load(application.bundle)
89
+ if abs(size.width - startedViewport.width) >= 0.5 || abs(size.height - startedViewport.height) >= 0.5 {
90
+ reportedViewportWidth = size.width
91
+ runtime.send("VIEWPORT", ["width": Double(size.width), "height": Double(size.height)])
92
+ }
93
+ }
94
+
95
+ static func autosaveName(name: String, application: CraftMacApplication) -> String? {
96
+ name == "main" && application.testDirectory == nil ? "\(application.appName)NativeMain" : nil
97
+ }
98
+
99
+ /// The content size (below the title bar) the window will open at, known before it exists: the size
100
+ /// asked for, or the frame AppKit saved for it last time. Its screen starts with it.
101
+ static func contentSize(options: [String: Any], application: CraftMacApplication, name: String) -> CGSize {
102
+ let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
103
+ let titlebar = NSWindow.frameRect(forContentRect: CGRect(x: 0, y: 0, width: 100, height: 100), styleMask: [.titled]).height - 100
104
+ let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
105
+ // `x y width height` of the frame, then the screen's.
106
+ if let autosave = autosaveName(name: name, application: application),
107
+ let saved = UserDefaults.standard.string(forKey: "NSWindow Frame \(autosave)")?.split(separator: " ").compactMap({ Double($0) }), saved.count >= 4 {
108
+ return CGSize(width: saved[2], height: max(0, saved[3] - titlebar))
109
+ }
110
+ // A full-size content view runs under the title bar, which its layout rect leaves out.
111
+ return CGSize(width: width, height: transparent ? max(0, height - titlebar) : height)
59
112
  }
60
113
 
61
114
  /// Tells the screen its new width (VIEWPORT, as a phone does on rotation): only when the width changed,
@@ -109,8 +162,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
109
162
  var titlebarStyle = "standard"
110
163
  var trafficLights: [Any]?
111
164
  /// Apps that ship their own agent (Attic) hand out a session cookie at
112
- /// `/native/session`. An app that talks to a hosted API has no such
113
- /// endpoint and starts with `--no-session`.
165
+ /// `/native/session` (`authorize`). An app that talks to a hosted API has
166
+ /// no such endpoint and starts with `--no-session`.
114
167
  var requiresSession = true
115
168
  var mainWindowOptions: [String: Any] = [:]
116
169
  /// The Dock icon's menu (`craft-app.json` `dockMenu`): each item sends its action as a desktop command.
@@ -128,34 +181,46 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
128
181
  var testMenu: [[String: Any]] = []
129
182
  var testMenuChoice: String?
130
183
  var appearanceObservation: NSKeyValueObservation?
184
+ /// The main window's screen, started before AppKit launched, waiting for its window.
185
+ var preparedMain: CraftMacRuntime?
186
+ /// The appearance it started with, which AppKit could not be asked yet.
187
+ var preparedColorScheme = ""
188
+ var mainOptions: [String: Any] { mainWindowOptions.merging(["title": appName]) { _, new in new } }
189
+ /// Launch finished, and why the agent issued no session, if it did not (shown once both are known).
190
+ var launched = false
191
+ var sessionFailure: String?
131
192
  /// A quit waiting for screens to finish (`craft.desktop.onQuit`), for at most `quitGrace` seconds.
132
193
  private var pendingQuit: (token: String, waiting: Set<String>, timeout: Timer)?
133
194
  static let quitGrace: TimeInterval = 3
134
195
 
196
+ func applicationWillFinishLaunching(_ notification: Notification) { CraftTrace.mark("will finish launching") }
135
197
  func applicationDidFinishLaunching(_ notification: Notification) {
136
- installMenus()
198
+ CraftTrace.mark("did finish launching")
199
+ CraftTrace.span("menus installed") { installMenus() }
137
200
  appearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
138
201
  DispatchQueue.main.async { self?.appearanceChanged() }
139
202
  }
140
203
  NSApp.activate(ignoringOtherApps: true)
141
- if !requiresSession {
142
- open(name: "main", route: initialRoute, options: mainWindowOptions.merging(["title": appName]) { _, new in new })
143
- startTestDriver()
144
- return
145
- }
146
- // The agent issues an HttpOnly cookie. It stays in URLSession, shared
147
- // by native windows, and is never handed to the JavaScript context.
148
- let request = URLRequest(url: network.base.appendingPathComponent("native/session"))
149
- network.session.dataTask(with: request) { [weak self] _, response, error in
204
+ open(name: "main", route: initialRoute, options: mainOptions)
205
+ if preparedColorScheme != (CraftMacColor.isDark ? "dark" : "light") { appearanceChanged() }
206
+ startTestDriver()
207
+ launched = true
208
+ if let failure = sessionFailure { sessionFailed(failure) }
209
+ }
210
+ /// Asks the agent for the session cookie (`CraftMacNetwork.authorize`) as the app starts, before
211
+ /// AppKit has launched; the screen's requests wait for it. Without one the app says so and quits.
212
+ func authorize() {
213
+ network.authorize { failure in
150
214
  DispatchQueue.main.async {
151
- guard let self else { return }
152
- guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200 else {
153
- let alert = NSAlert(); alert.messageText = "Could not connect to the app"; alert.informativeText = error?.localizedDescription ?? "The local agent did not issue a native session."; alert.runModal(); NSApp.terminate(nil); return
154
- }
155
- self.open(name: "main", route: self.initialRoute, options: self.mainWindowOptions.merging(["title": self.appName]) { _, new in new })
156
- self.startTestDriver()
215
+ CraftTrace.mark("native session answered")
216
+ guard let failure else { return }
217
+ self.sessionFailure = failure
218
+ if self.launched { self.sessionFailed(failure) }
157
219
  }
158
- }.resume()
220
+ }
221
+ }
222
+ private func sessionFailed(_ message: String) {
223
+ let alert = NSAlert(); alert.messageText = "Could not connect to the app"; alert.informativeText = message; alert.runModal(); NSApp.terminate(nil)
159
224
  }
160
225
  func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { scenes["main"]?.window.makeKeyAndOrderFront(nil); return true }
161
226
  /// Links from other apps (this app as the default browser) and files dropped on the Dock icon.
@@ -212,7 +277,24 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
212
277
  }
213
278
  func open(name: String, route: String, options: [String: Any]) {
214
279
  if let scene = scenes[name] { scene.window.makeKeyAndOrderFront(nil); return }
215
- scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self)
280
+ let prepared = name == "main" ? preparedMain : nil
281
+ if prepared != nil { preparedMain = nil }
282
+ scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self, runtime: prepared)
283
+ }
284
+ /// A screen's thread and context, its bundle evaluated as soon as the thread can.
285
+ func startScreen(route: String, options: [String: Any], viewport: CGSize, colorScheme: String) -> CraftMacRuntime {
286
+ let runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: network, colorScheme: colorScheme)
287
+ runtime.load(bundle, viewport: viewport)
288
+ return runtime
289
+ }
290
+ /// Starts the main window's screen before AppKit launches: connecting to the window server and
291
+ /// launching take a few hundred milliseconds, most of them waiting, and the screen evaluates its
292
+ /// bundle, mounts and sends its first requests meanwhile. Its tree is drawn when the window opens.
293
+ func prepareMainScreen() {
294
+ // NSApp does not exist yet, so the system's setting stands in for AppKit's appearance;
295
+ // launching checks it against AppKit's.
296
+ preparedColorScheme = UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" ? "dark" : "light"
297
+ preparedMain = startScreen(route: initialRoute, options: mainOptions, viewport: CraftMacScene.contentSize(options: mainOptions, application: self, name: "main"), colorScheme: preparedColorScheme)
216
298
  }
217
299
  /// A menu command goes to the window it was chosen in (the key window), else the main one: with
218
300
  /// two windows of the same screen open, cmd-T opens one tab, not one in each.
@@ -231,7 +313,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
231
313
  // AppKit lays the title bar out again for the new appearance, after this returns.
232
314
  scene.placeTrafficLights()
233
315
  DispatchQueue.main.async { [weak scene] in scene?.placeTrafficLights() }
234
- scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
316
+ scene.runtime.evaluate("__stxNativeBridge.colorScheme = '\(scheme)';")
235
317
  // The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
236
318
  scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
237
319
  scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
@@ -293,7 +375,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
293
375
  if parts.contains("alt") { item.keyEquivalentModifierMask.insert(.option) }
294
376
  item.isEnabled = raw["disabled"] as? Bool != true
295
377
  item.state = raw["checked"] as? Bool == true ? .on : .off
296
- if let symbol = raw["icon"] as? String { item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) }
378
+ if let symbol = raw["icon"] as? String { item.image = CraftMacSymbols.image(symbol) }
297
379
  return item
298
380
  }
299
381
  func perform(_ method: String, args: [Any], scene: CraftMacScene?, reply: @escaping (Any) -> Void) {
@@ -373,10 +455,10 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
373
455
  guard let path = file["path"], chosenFiles.contains(path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), data.count <= 256 * 1024 * 1024,
374
456
  var request = network.request(["url": endpoint, "method": "POST", "headers": ["x-filename": file["name"] ?? "Attachment", "content-type": file["type"] ?? "application/octet-stream"]]) else { reply(["error": "Could not read a picked attachment"]); return }
375
457
  request.httpBody = data
376
- network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
458
+ network.resume(network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
377
459
  guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let data, let value = try? JSONSerialization.jsonObject(with: data) else { reply(["error": error?.localizedDescription ?? "Attachment upload failed"]); return }
378
460
  results.append(value); next(index + 1)
379
- } }.resume()
461
+ } })
380
462
  }; next(0)
381
463
  }
382
464
  private func preview(_ options: [String: Any], scene: CraftMacScene?) {
@@ -390,18 +472,18 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
390
472
  player.player?.play()
391
473
  } else {
392
474
  let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; panel.contentView = image
393
- network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } }.resume()
475
+ network.resume(network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } })
394
476
  }
395
477
  panel.center(); panel.makeKeyAndOrderFront(nil)
396
478
  }
397
479
  private func export(_ options: [String: Any], reply: @escaping (Any) -> Void) {
398
480
  let panel = NSSavePanel(); panel.nameFieldStringValue = options["name"] as? String ?? "Attachment"
399
481
  guard panel.runModal() == .OK, let destination = panel.url, let request = network.request(["url": options["url"] ?? ""]) else { reply(false); return }
400
- network.session.downloadTask(with: request) { location, response, error in
482
+ network.resume(network.session.downloadTask(with: request) { location, response, error in
401
483
  var saved = false
402
484
  if error == nil, (response as? HTTPURLResponse)?.statusCode == 200, let location { do { if FileManager.default.fileExists(atPath: destination.path) { try FileManager.default.removeItem(at: destination) }; try FileManager.default.moveItem(at: location, to: destination); saved = true } catch {} }
403
485
  DispatchQueue.main.async { reply(saved) }
404
- }.resume()
486
+ })
405
487
  }
406
488
  func notification(_ method: String, options: [String: Any], reply: @escaping (Any) -> Void) {
407
489
  guard method == "schedule" else { reply(false); return }
@@ -554,7 +636,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
554
636
  return node
555
637
  }
556
638
  func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
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]
639
+ return ["name": scene.name, "title": scene.window.title, "route": scene.runtime.route, "contextClass": String(describing: ZigJSContext.self), "nodes": nodes, "viewClasses": classes(scene.renderer.root), "viewportWidth": Double(scene.reportedViewportWidth), "errors": scene.runtime.errors]
558
640
  }
559
641
  let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
560
642
  return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews, "menu": testMenu]
@@ -563,6 +645,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
563
645
 
564
646
  @main struct CraftMacMain {
565
647
  static func main() throws {
648
+ CraftTrace.mark("main")
566
649
  // A bundled app is launched by Finder with no arguments: its settings are
567
650
  // `Contents/Resources/craft-app.json`, written by the build. A flag on the
568
651
  // command line still wins, so a development run can point elsewhere.
@@ -595,6 +678,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
595
678
  application.network = CraftMacNetwork(base: api)
596
679
  let testDirectory = argument("--test-dir")
597
680
  if !testDirectory.isEmpty { application.testDirectory = URL(fileURLWithPath: testDirectory, isDirectory: true) }
681
+ if application.requiresSession { application.authorize() }
682
+ application.prepareMainScreen()
598
683
  let app = NSApplication.shared
599
684
  app.setActivationPolicy(.regular); app.delegate = application; app.run()
600
685
  }
@@ -21,6 +21,22 @@ enum CraftMacColor {
21
21
  }
22
22
  }
23
23
 
24
+ /// SF Symbols, one image per name and description. An image keeps what it rasterized, so views that
25
+ /// share one draw it once rather than each looking the glyph up in the catalog and rendering it again.
26
+ enum CraftMacSymbols {
27
+ private struct Key: Hashable { let name: String, description: String? }
28
+ private static var images: [Key: NSImage?] = [:]
29
+ static func image(_ name: String, description: String? = nil) -> NSImage? {
30
+ let key = Key(name: name, description: description)
31
+ if let image = images[key] { return image }
32
+ // Descriptions can carry changing text (a button's title), so the cache starts over rather than grow without end.
33
+ if images.count >= 512 { images.removeAll(keepingCapacity: true) }
34
+ let image = NSImage(systemSymbolName: name, accessibilityDescription: description)
35
+ images[key] = image
36
+ return image
37
+ }
38
+ }
39
+
24
40
  /// The same C ABI used by Craft's mobile hosts. AppKit supplies text metrics;
25
41
  /// Zig supplies flex sizing and justification, without a second layout rule.
26
42
  enum CraftMacFlex {
@@ -56,7 +72,6 @@ final class CraftMacFlow: NSView {
56
72
  // scopes must preserve the root's allocation on that later pass too.
57
73
  if componentScope, boxes.count == 1, boxes[0].kind != "Modal", boxes[0].superview === self {
58
74
  boxes[0].frame = CGRect(origin: .zero, size: size)
59
- boxes[0].layoutSubtreeIfNeeded()
60
75
  return
61
76
  }
62
77
  let horizontal = style["flexDirection"] as? String == "row"
@@ -102,9 +117,10 @@ final class CraftMacFlow: NSView {
102
117
  let measured = horizontal ? box.measure(width: frames[index].0, stretch: true) : sizes[index]
103
118
  let cross = min(crossAvailable, crossExplicit ?? (alignment == "stretch" ? crossAvailable : (horizontal ? measured.height : measured.width)))
104
119
  let offset = margins.start + (alignment == "center" ? (crossAvailable - cross) / 2 : alignment == "flex-end" ? crossAvailable - cross : 0)
120
+ // No layout pass of its own: a child whose size changed is marked for layout, and AppKit lays it out
121
+ // after this one returns, in the same pass. Asking here walked its subtree again for every ancestor.
105
122
  box.frame = horizontal ? CGRect(x: left + frames[index].1, y: top + offset, width: frames[index].0, height: cross)
106
123
  : CGRect(x: left + offset, y: top + frames[index].1, width: cross, height: frames[index].0)
107
- box.layoutSubtreeIfNeeded()
108
124
  }
109
125
  for box in boxes where !box.isHidden && box.layoutStyle["position"] as? String == "absolute" {
110
126
  let measured = box.measure(width: size.width)
@@ -113,7 +129,6 @@ final class CraftMacFlow: NSView {
113
129
  let width = dimension(box.layoutStyle["width"], base: size.width) ?? (leading != nil && trailing != nil ? max(0, size.width - leading! - trailing!) : measured.width)
114
130
  let height = dimension(box.layoutStyle["height"], base: size.height) ?? (above != nil && below != nil ? max(0, size.height - above! - below!) : measured.height)
115
131
  box.frame = CGRect(x: (leading ?? (trailing.map { size.width - $0 - width } ?? 0)), y: (above ?? (below.map { size.height - $0 - height } ?? 0)), width: width, height: height)
116
- box.layoutSubtreeIfNeeded()
117
132
  }
118
133
  }
119
134
  func fitting(width: CGFloat, stretchChildren: Bool = true) -> CGSize {
@@ -289,8 +304,11 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
289
304
  private struct MeasureKey: Hashable { let width: CGFloat, stretch: Bool }
290
305
  private var measured: [MeasureKey: CGSize] = [:]
291
306
  /// What this box measured is stale: it or something inside it changed (CraftMacRenderer.update).
292
- func forgetMeasurements() { measured.removeAll(keepingCapacity: true) }
307
+ func forgetMeasurements() { measured.removeAll(keepingCapacity: true); controlSize = nil }
293
308
  private var measuredGeneration = -1
309
+ /// A control's own size (a button, a switch, a picker), asked once rather than at every width the box is
310
+ /// measured at: a button answers through SwiftUI's layout on current macOS. Forgotten with the measurements.
311
+ private var controlSize: CGSize?
294
312
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
295
313
  var layoutStyle: [String: Any] {
296
314
  guard let child = componentRoot else { return style }
@@ -313,8 +331,13 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
313
331
  text.isSelectable = true; text.maximumNumberOfLines = 0
314
332
  control = text
315
333
  case "Button", "Link":
316
- let button = NSButton(title: "", target: self, action: #selector(pressed))
317
- button.bezelStyle = .rounded; control = button
334
+ // What `NSButton(title:target:action:)` sets up, without the `sizeToFit` it ends with: a button's
335
+ // size goes through SwiftUI's layout on current macOS, and the box sets its frame anyway.
336
+ let button = NSButton(frame: .zero)
337
+ button.setButtonType(.momentaryPushIn); button.bezelStyle = .rounded
338
+ button.lineBreakMode = .byTruncatingTail; button.imageScaling = .scaleProportionallyDown
339
+ button.title = ""; button.target = self; button.action = #selector(pressed)
340
+ control = button
318
341
  case "TextInput":
319
342
  let input = NSTextField(); input.delegate = self; control = input
320
343
  case "Picker":
@@ -348,6 +371,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
348
371
  required init?(coder: NSCoder) { fatalError("Native controls are created from STX") }
349
372
 
350
373
  func configure(_ node: [String: Any]) {
374
+ controlSize = nil
351
375
  props = node["props"] as? [String: Any] ?? [:]
352
376
  flow?.componentScope = props["data-stx-component-wrapper"] != nil
353
377
  style = node["style"] as? [String: Any] ?? [:]
@@ -378,10 +402,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
378
402
  } else { layer?.shadowOpacity = 0; layer?.shadowPath = nil; control?.layer?.cornerRadius = 0 }
379
403
  layer?.cornerCurve = .continuous
380
404
  for edge in ["Top", "Right", "Bottom", "Left"] {
405
+ let hidden = (number(style["border\(edge)Width"]) ?? 0) <= 0
406
+ // An edge's layer is made the first time it shows: most boxes never draw one, and four hidden
407
+ // layers on every box still cost Core Animation a commit each. An input makes its four at once,
408
+ // as it always did, since swapping its control later puts the new one above layers made before.
409
+ if edgeBorders[edge] == nil, hidden, kind != "TextInput" { continue }
381
410
  let border = edgeBorders[edge] ?? CALayer()
382
411
  if edgeBorders[edge] == nil { edgeBorders[edge] = border; layer?.addSublayer(border) }
383
412
  border.backgroundColor = CraftMacColor.parse(style["borderColor"], fallback: .separatorColor).cgColor
384
- border.isHidden = (number(style["border\(edge)Width"]) ?? 0) <= 0
413
+ border.isHidden = hidden
385
414
  }
386
415
  if let name = props["material"] as? String {
387
416
  if material == nil { let effect = NSVisualEffectView(); effect.state = .followsWindowActiveState; addSubview(effect, positioned: .below, relativeTo: control); material = effect }
@@ -428,7 +457,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
428
457
  button.bezelStyle = props["variant"] as? String == "toolbar" ? .texturedRounded : .rounded
429
458
  button.isBordered = !["toolbar", "ghost"].contains(props["variant"] as? String ?? "")
430
459
  button.contentTintColor = CraftMacColor.parse(style["color"], fallback: .controlAccentColor)
431
- if let symbol = props["symbol"] as? String { button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: button.title); button.imagePosition = button.title.isEmpty ? .imageOnly : .imageLeading }
460
+ if let symbol = props["symbol"] as? String { button.image = CraftMacSymbols.image(symbol, description: button.title); button.imagePosition = button.title.isEmpty ? .imageOnly : .imageLeading }
432
461
  if button.image != nil { button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: number(style["fontSize"]) ?? 16, weight: .regular) }
433
462
  if props["variant"] as? String == "primary" { button.bezelColor = .controlAccentColor }
434
463
  if props["variant"] as? String == "danger" { button.contentTintColor = .systemRed }
@@ -457,7 +486,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
457
486
  picker.isEnabled = props["disabled"] as? Bool != true
458
487
  }
459
488
  if let image = control as? NSImageView {
460
- if kind == "Icon" { image.image = NSImage(systemSymbolName: props["symbol"] as? String ?? props["name"] as? String ?? "questionmark", accessibilityDescription: label); image.contentTintColor = CraftMacColor.parse(style["color"]) }
489
+ if kind == "Icon" { image.image = CraftMacSymbols.image(props["symbol"] as? String ?? props["name"] as? String ?? "questionmark", description: label); image.contentTintColor = CraftMacColor.parse(style["color"]) }
461
490
  else { configureImage(image) }
462
491
  }
463
492
  if let video = control as? AVPlayerView, let source = (props["source"] as? String ?? props["src"] as? String), source != imageSource {
@@ -556,7 +585,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
556
585
  image?.image = data.flatMap(NSImage.init(data:))
557
586
  self.fire(image?.image == nil ? "onError" : "onLoad")
558
587
  }
559
- }; imageTask?.resume()
588
+ }; imageTask.map { renderer?.runtime.network.resume($0) }
560
589
  }
561
590
 
562
591
  /// `style` with the pointer's layers on top: `hoverStyle` while hovered, `pressStyle` while pressed.
@@ -727,8 +756,9 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
727
756
  return NSEdgeInsets(top: number(style["paddingTop"]) ?? vertical, left: number(style["paddingLeft"]) ?? horizontal, bottom: number(style["paddingBottom"]) ?? vertical, right: number(style["paddingRight"]) ?? horizontal)
728
757
  }
729
758
  func measure(width: CGFloat, stretch: Bool = false) -> CGSize {
730
- if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 { measured.removeAll(keepingCapacity: true); measuredGeneration = CraftMacBox.measureGeneration }
731
- let key = MeasureKey(width: width, stretch: stretch)
759
+ if measuredGeneration != CraftMacBox.measureGeneration || measured.count > 64 { forgetMeasurements(); measuredGeneration = CraftMacBox.measureGeneration }
760
+ // Only a box with children can stretch to the width offered; a leaf measures the same either way.
761
+ let key = MeasureKey(width: width, stretch: stretch && flow != nil)
732
762
  if let size = measured[key] { return size }
733
763
  let size = measureSubtree(width: width, stretch: stretch)
734
764
  measured[key] = size
@@ -756,7 +786,15 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
756
786
  else if kind == "Icon" { size = CGSize(width: 18, height: 18) }
757
787
  else if kind == "Image" || kind == "Video" { size = CGSize(width: limit, height: 160) }
758
788
  else if kind == "TextInput" { size = CGSize(width: limit, height: props["multiline"] as? Bool == true ? 48 : 28) }
759
- else if let control = control as? NSControl { size = control.intrinsicContentSize; size.width = max(24, size.width); size.height = max(26, size.height) }
789
+ else if let control = control as? NSControl {
790
+ // A control its style sizes in full is not asked at all: the width and height below replace its answer.
791
+ if specifiedWidth != nil, dimension(style["height"], base: 1000) != nil { size = CGSize(width: 24, height: 26) }
792
+ else {
793
+ let natural = controlSize ?? control.intrinsicContentSize
794
+ controlSize = natural
795
+ size = natural; size.width = max(24, size.width); size.height = max(26, size.height)
796
+ }
797
+ }
760
798
  else if let flow { size = flow.fitting(width: limit, stretchChildren: false) }
761
799
  size.width = specifiedWidth ?? (flow != nil && stretch ? limit : min(limit, size.width))
762
800
  if let maximum = dimension(style["maxWidth"], base: width) { size.width = min(size.width, maximum) }
@@ -808,7 +846,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
808
846
  }
809
847
  }
810
848
  if let child = componentRoot {
811
- child.frame = bounds; child.layoutSubtreeIfNeeded()
849
+ child.frame = bounds
812
850
  } else if let scroll = control as? NSScrollView {
813
851
  if let flow {
814
852
  let width = scroll.contentSize.width
@@ -950,7 +988,7 @@ final class CraftMacSidebar: NSScrollView, NSOutlineViewDataSource, NSOutlineVie
950
988
  let badge = NSTextField(labelWithString: item.badge); badge.font = NSFont.systemFont(ofSize: 11); badge.textColor = .secondaryLabelColor
951
989
  badge.translatesAutoresizingMaskIntoConstraints = false; badge.setContentHuggingPriority(.required, for: .horizontal); cell.addSubview(badge)
952
990
  NSLayoutConstraint.activate([label.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: item.icon.isEmpty ? 16 : 39), label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -8), label.centerYAnchor.constraint(equalTo: cell.centerYAnchor), badge.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -16), badge.centerYAnchor.constraint(equalTo: cell.centerYAnchor)])
953
- if !item.icon.isEmpty { let image = NSImageView(image: NSImage(systemSymbolName: item.icon, accessibilityDescription: nil) ?? NSImage()); image.contentTintColor = item.color; image.frame = CGRect(x: 16, y: (rowHeight - 16) / 2, width: 16, height: 16); cell.imageView = image; cell.addSubview(image) }
991
+ if !item.icon.isEmpty { let image = NSImageView(image: CraftMacSymbols.image(item.icon) ?? NSImage()); image.contentTintColor = item.color; image.frame = CGRect(x: 16, y: (rowHeight - 16) / 2, width: 16, height: 16); cell.imageView = image; cell.addSubview(image) }
954
992
  cell.setAccessibilityIdentifier(item.id); cell.setAccessibilityLabel(item.label)
955
993
  return cell
956
994
  }
@@ -967,28 +1005,62 @@ final class CraftMacRenderer {
967
1005
  var boxes: [String: CraftMacBox] = [:]
968
1006
  weak var window: NSWindow?
969
1007
  var sheets: [String: NSPanel] = [:]
1008
+ /// Boxes whose nodes went away: hidden at once, and taken out of the window once the batch is drawn.
1009
+ /// Leaving a window walks a view's whole subtree, every control in it hearing so, and a batch that
1010
+ /// replaced a list's rows spent most of its time on the rows it dropped, before any new row showed.
1011
+ private var leaving: [CraftMacBox] = []
970
1012
  init(runtime: CraftMacRuntime) { self.runtime = runtime }
1013
+
1014
+ private func discard(_ box: CraftMacBox) {
1015
+ box.imageTask?.cancel()
1016
+ guard box.superview != nil else { return }
1017
+ box.isHidden = true
1018
+ if leaving.isEmpty { DispatchQueue.main.async { [weak self] in self?.sweep() } }
1019
+ leaving.append(box)
1020
+ }
1021
+ private func sweep() {
1022
+ let gone = leaving; leaving.removeAll()
1023
+ CATransaction.begin(); CATransaction.setDisableActions(true)
1024
+ gone.forEach { $0.removeFromSuperview() }
1025
+ CATransaction.commit()
1026
+ }
971
1027
  /// The box for `node`, made or reconfigured, and its children's in order; every id drawn goes into `retained`.
972
1028
  func reconcile(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
1029
+ let box = configured(node, retained: &retained)
1030
+ reconcileChildren(of: box, node, retained: &retained)
1031
+ return box
1032
+ }
1033
+
1034
+ /// The box for `node`, made or reconfigured, without its children.
1035
+ private func configured(_ node: [String: Any], retained: inout Set<String>) -> CraftMacBox {
973
1036
  let id = node["id"] as? String ?? "root", kind = node["type"] as? String ?? "View"
974
1037
  retained.insert(id)
975
1038
  let box = boxes[id] ?? CraftMacBox(id: id, kind: kind, renderer: self)
976
1039
  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
1040
  return box
990
1041
  }
991
1042
 
1043
+ /// A box's children in order, then theirs. Each new child joins its parent before its own children are
1044
+ /// made, so views join the window one at a time: AppKit takes about twice as long over a whole new
1045
+ /// subtree joining at once.
1046
+ private func reconcileChildren(of box: CraftMacBox, _ node: [String: Any], retained: inout Set<String>) {
1047
+ guard let flow = box.flow, box.kind != "Button" else { return }
1048
+ let nodes = (node["children"] as? [Any] ?? []).compactMap { $0 as? [String: Any] }
1049
+ let children = nodes.map { configured($0, retained: &retained) }
1050
+ // A child dropped here either moves to another parent in this pass (adding it there takes it from
1051
+ // here) or is gone, and the caller discards it: it is not taken out of the window twice.
1052
+ flow.boxes = children
1053
+ for child in children where child.superview !== flow && sheets[child.id] == nil { flow.addSubview(child) }
1054
+ for (child, childNode) in zip(children, nodes) { reconcileChildren(of: child, childNode, retained: &retained) }
1055
+ // `zIndex` draws a child above its siblings (an overlay over a web page), highest last. After the
1056
+ // children's own children: a component's zIndex is its root's.
1057
+ let raised = children.filter { (number($0.layoutStyle["zIndex"]) ?? 0) > 0 && $0.superview === flow }
1058
+ .sorted { (number($0.layoutStyle["zIndex"]) ?? 0) < (number($1.layoutStyle["zIndex"]) ?? 0) }
1059
+ if !raised.isEmpty, Array(flow.subviews.suffix(raised.count)) != raised {
1060
+ for child in raised { flow.addSubview(child, positioned: .above, relativeTo: nil) }
1061
+ }
1062
+ }
1063
+
992
1064
  /// A batch drawn where it happened rather than across the window: each changed node's subtree
993
1065
  /// (or, for a node the batch created, its nearest drawn ancestor's) is reconciled again, and only
994
1066
  /// those boxes and the boxes above them measure and lay out again. A hover on one row used to
@@ -997,10 +1069,14 @@ final class CraftMacRenderer {
997
1069
  func update(_ ids: [String], parentOf: (String) -> String?, node nodeFor: (String) -> [String: Any]?) -> Bool {
998
1070
  guard !boxes.isEmpty else { return false }
999
1071
  var targets: [String] = []
1072
+ let top = root.boxes.first?.id
1000
1073
  for id in ids {
1001
1074
  var current: String? = id
1002
1075
  while let candidate = current, boxes[candidate] == nil { current = parentOf(candidate) }
1003
- guard let target = current else { return false }
1076
+ // A node the batch removed has no parent left (one it made and removed never had a box): the
1077
+ // removal marked the parent it left, which is drawn instead, discarding its box. Declining here
1078
+ // redrew the whole window for any batch that dropped a row it had also updated.
1079
+ guard let target = current, target == top || parentOf(target) != nil else { continue }
1004
1080
  if !targets.contains(target) { targets.append(target) }
1005
1081
  }
1006
1082
  // A target inside another is drawn with it.
@@ -1019,7 +1095,7 @@ final class CraftMacRenderer {
1019
1095
  _ = reconcile(node, retained: &retained)
1020
1096
  for id in before where !retained.contains(id) {
1021
1097
  guard let gone = boxes.removeValue(forKey: id) else { continue }
1022
- gone.imageTask?.cancel(); gone.removeFromSuperview()
1098
+ discard(gone)
1023
1099
  }
1024
1100
  for id in retained { if let drawn = boxes[id] { drawn.forgetMeasurements(); drawn.needsLayout = true } }
1025
1101
  var above: NSView? = box.superview
@@ -1049,15 +1125,17 @@ final class CraftMacRenderer {
1049
1125
  let bottom = bounds.maxY >= (scroll.documentView?.bounds.height ?? 0) - 8
1050
1126
  scrolls.append((box, bounds.origin, bottom))
1051
1127
  } }
1052
- let box = reconcile(document, retained: &retained)
1128
+ // The top box joins the window before its children are made (see reconcileChildren).
1129
+ let box = configured(document, retained: &retained)
1130
+ root.style = ["flexDirection": "column"]
1131
+ root.boxes = [box]; if box.superview !== root { root.addSubview(box) }
1132
+ reconcileChildren(of: box, document, retained: &retained)
1053
1133
  // Boxes were reconfigured and children replaced: nothing measured before now holds.
1054
1134
  CraftMacBox.measureGeneration &+= 1
1055
1135
  for (id, box) in boxes where !retained.contains(id) {
1056
- box.imageTask?.cancel(); box.removeFromSuperview(); boxes.removeValue(forKey: id)
1136
+ discard(box); boxes.removeValue(forKey: id)
1057
1137
  if let sheet = sheets.removeValue(forKey: id) { window?.endSheet(sheet); sheet.close() }
1058
1138
  }
1059
- root.style = ["flexDirection": "column"]
1060
- root.boxes = [box]; if box.superview !== root { root.addSubview(box) }
1061
1139
  box.frame = root.bounds; box.layoutSubtreeIfNeeded(); root.layoutBoxes(in: root.bounds.size)
1062
1140
  for modal in boxes.values where modal.kind == "Modal" {
1063
1141
  guard let window else { continue }