craft-native 0.0.408 → 0.0.410

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.408";
3050
+ var version = "0.0.410";
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.408";
378
+ var version = "0.0.410";
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.408";
120
+ var version = "0.0.410";
121
121
 
122
122
  // src/sdk-version.ts
123
123
  var SDK_VERSION = version;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Screens run on zig-js, and only on zig-js. zig-js exports JavaScriptCore's C
3
+ * API under the same names, and a host built with `webkit` sits one framework
4
+ * away from Apple's JavaScriptCore. So a `JS*` function zig-js does not define,
5
+ * or a Swift import that autolinks JavaScriptCore, would quietly bind part of
6
+ * the screen's engine to the system one, and fail far from the cause (a value
7
+ * from one engine handed to the other). The build checks the linked host.
8
+ */
9
+ /** What in a host's imports would run on the system's JavaScriptCore; empty when everything is zig-js. */
10
+ export declare function systemEngineUse(undefinedSymbols: string[], linkedLibraries: string[]): string[];
11
+ /** Fails when the linked host would run any script on the system's JavaScriptCore. */
12
+ export declare function assertZigJsEngine(executable: string): Promise<void>;
@@ -1,3 +1,4 @@
1
+ export { assertZigJsEngine, systemEngineUse } from './engine.js';
1
2
  export { macosToolchain, pantryExecutable, resolveMacOSToolchain, resolveZig, toolchainCacheDir } from './toolchain.js';
2
3
  export type { MacOSToolchain } from './toolchain.js';
3
4
  export interface MacOSNativeBuildOptions {
@@ -5,6 +5,36 @@ import { existsSync as existsSync2 } from "fs";
5
5
  import { join as join2, resolve } from "path";
6
6
  import process2 from "process";
7
7
 
8
+ // ../macos/src/engine.ts
9
+ var SYSTEM_ENGINE_SYMBOL = /^_(?:kJS[A-Z]|JS(?:Value|Object|String|BigInt|Context|GlobalContext|Class|PropertyName|EvaluateScript|CheckScriptSyntax|GarbageCollect|Script|Weak|ReportExtraMemoryCost|Synchronous)|OBJC_CLASS_\$_JS(?:Context|Value|ManagedValue|VirtualMachine)$)/;
10
+ function systemEngineUse(undefinedSymbols, linkedLibraries) {
11
+ return [
12
+ ...linkedLibraries.filter((library) => library.includes("/JavaScriptCore.framework/")),
13
+ ...undefinedSymbols.filter((symbol) => SYSTEM_ENGINE_SYMBOL.test(symbol))
14
+ ];
15
+ }
16
+ async function lines(args) {
17
+ const child = Bun.spawn(args, { stdout: "pipe", stderr: "inherit" });
18
+ const text = await new Response(child.stdout).text();
19
+ if (await child.exited !== 0)
20
+ throw new Error(`Could not inspect the host: ${args.join(" ")}`);
21
+ return text.split(`
22
+ `).map((line) => line.trim()).filter(Boolean);
23
+ }
24
+ async function assertZigJsEngine(executable) {
25
+ const [undefinedSymbols, linked] = await Promise.all([
26
+ lines(["xcrun", "nm", "-u", "-j", executable]),
27
+ lines(["xcrun", "otool", "-L", executable])
28
+ ]);
29
+ const leaks = systemEngineUse(undefinedSymbols, linked.slice(1).map((line) => line.split(" (")[0]));
30
+ if (leaks.length) {
31
+ throw new Error(`The host would run scripts on the system's JavaScriptCore instead of zig-js:
32
+ ${leaks.join(`
33
+ `)}
34
+ Define the missing functions in zig-js, or keep JavaScriptCore out of the Swift imports.`);
35
+ }
36
+ }
37
+
8
38
  // ../macos/src/toolchain.ts
9
39
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
10
40
  import { homedir } from "os";
@@ -157,16 +187,19 @@ async function buildMacOSNative(options) {
157
187
  "-o",
158
188
  executable
159
189
  ]);
190
+ await assertZigJsEngine(executable);
160
191
  const bundle = join2(outdir, "native-screen.js");
161
192
  if (resolve(options.nativeBundle) !== bundle)
162
193
  await cp(options.nativeBundle, bundle);
163
194
  return { executable, bundle };
164
195
  }
165
196
  export {
197
+ assertZigJsEngine,
166
198
  buildMacOSNative,
167
199
  macosToolchain,
168
200
  pantryExecutable,
169
201
  resolveMacOSToolchain,
170
202
  resolveZig,
203
+ systemEngineUse,
171
204
  toolchainCacheDir
172
205
  };
@@ -16,12 +16,17 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
16
16
  let window: NSWindow
17
17
  let runtime: CraftMacRuntime
18
18
  let renderer: CraftMacRenderer
19
+ /// Where the close button's top-left corner sits, from the window's top-left (`--traffic-lights 18,18`):
20
+ /// a browser lines the buttons up with its own toolbar row. Nil keeps AppKit's place.
21
+ let trafficLights: CGPoint?
19
22
  init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
20
23
  self.name = name
21
24
  runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
22
25
  renderer = CraftMacRenderer(runtime: runtime)
23
26
  let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
24
27
  let transparent = (options["titlebarStyle"] as? String ?? application.titlebarStyle) == "transparent"
28
+ let lights = options["trafficLights"] as? [Any] ?? (name == "main" ? application.trafficLights : nil)
29
+ trafficLights = lights.flatMap { values in values.count == 2 ? number(values[0]).flatMap { x in number(values[1]).map { CGPoint(x: x, y: $0) } } : nil }
25
30
  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)
26
31
  panel.runtime = runtime
27
32
  window = panel
@@ -39,8 +44,24 @@ final class CraftMacScene: NSObject, NSWindowDelegate {
39
44
  window.delegate = self; window.center()
40
45
  if name == "main" && application.testDirectory == nil { window.setFrameAutosaveName("\(application.appName)NativeMain") }
41
46
  window.makeKeyAndOrderFront(nil)
47
+ placeTrafficLights()
42
48
  runtime.load(application.bundle)
43
49
  }
50
+
51
+ /// AppKit lays the title bar out again on resize, full screen and appearance changes, so this runs after each.
52
+ func placeTrafficLights() {
53
+ guard let inset = trafficLights, let close = window.standardWindowButton(.closeButton), let titlebar = close.superview?.superview else { return }
54
+ let buttons: [NSWindow.ButtonType] = [.closeButton, .miniaturizeButton, .zoomButton]
55
+ let size = close.frame.size
56
+ let height = size.height + inset.y * 2
57
+ titlebar.frame = CGRect(x: titlebar.frame.minX, y: window.frame.height - height, width: titlebar.frame.width, height: height)
58
+ // The standard spacing: each button 6pt after the last.
59
+ for (index, type) in buttons.enumerated() {
60
+ window.standardWindowButton(type)?.setFrameOrigin(CGPoint(x: inset.x + CGFloat(index) * (size.width + 6), y: (height - size.height) / 2))
61
+ }
62
+ }
63
+ func windowDidResize(_ notification: Notification) { placeTrafficLights() }
64
+ func windowDidExitFullScreen(_ notification: Notification) { placeTrafficLights() }
44
65
  func windowShouldClose(_ sender: NSWindow) -> Bool {
45
66
  if name == "main" { sender.orderOut(nil); return false }
46
67
  return true
@@ -64,6 +85,7 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
64
85
  var bundle = ""
65
86
  var initialRoute = "Main"
66
87
  var titlebarStyle = "standard"
88
+ var trafficLights: [Any]?
67
89
  /// Apps that ship their own agent (Attic) hand out a session cookie at
68
90
  /// `/native/session`. An app that talks to a hosted API has no such
69
91
  /// endpoint and starts with `--no-session`.
@@ -121,6 +143,8 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
121
143
  let scheme = CraftMacColor.isDark ? "dark" : "light"
122
144
  for scene in scenes.values {
123
145
  scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
146
+ // The protocol's own message: the screen restyles its `dark:` classes and tells `craft.appearance.onChange`.
147
+ scene.runtime.send("APPEARANCE", ["colorScheme": scheme])
124
148
  scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
125
149
  for box in scene.renderer.boxes.values {
126
150
  box.layer?.backgroundColor = CraftMacColor.parse(box.style["backgroundColor"], fallback: .clear).cgColor
@@ -158,10 +182,19 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
158
182
  NSApp.mainMenu = bar; NSApp.windowsMenu = window
159
183
  }
160
184
  private func addMenu(_ menu: NSMenu, to bar: NSMenu) { let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: ""); item.submenu = menu; bar.addItem(item) }
185
+ /// Keys a shortcut names rather than types (`ctrl+tab`, `cmd+alt+right`), as AppKit's key equivalents spell them.
186
+ static let namedKeys: [String: String] = {
187
+ var keys = ["up": "\u{F700}", "down": "\u{F701}", "left": "\u{F702}", "right": "\u{F703}",
188
+ "tab": "\t", "return": "\r", "enter": "\r", "escape": "\u{1B}", "esc": "\u{1B}", "space": " ",
189
+ "backspace": "\u{08}", "delete": "\u{08}", "forwarddelete": "\u{F728}",
190
+ "home": "\u{F729}", "end": "\u{F72B}", "pageup": "\u{F72C}", "pagedown": "\u{F72D}", "plus": "+", "minus": "-"]
191
+ for number in 1...12 { keys["f\(number)"] = String(UnicodeScalar(0xF703 + number)!) }
192
+ return keys
193
+ }()
161
194
  private func menuItem(_ raw: [String: Any], target: AnyObject, action: Selector) -> NSMenuItem {
162
195
  if raw["separator"] as? Bool == true { return .separator() }
163
196
  let parts = (raw["shortcut"] as? String ?? "").lowercased().split(separator: "+").map(String.init)
164
- let key = ["up": "\u{F700}", "down": "\u{F701}", "left": "\u{F702}", "right": "\u{F703}" ][parts.last ?? ""] ?? parts.last ?? ""
197
+ let key = Self.namedKeys[parts.last ?? ""] ?? parts.last ?? ""
165
198
  let item = NSMenuItem(title: raw["label"] as? String ?? raw["title"] as? String ?? "", action: action, keyEquivalent: key)
166
199
  item.target = target; item.representedObject = raw["id"]
167
200
  item.keyEquivalentModifierMask = []
@@ -302,6 +335,26 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
302
335
  scene.renderer.root.layoutSubtreeIfNeeded()
303
336
  return ["ok": true]
304
337
  }
338
+ // A control by its testID, driven the way a person would: pressed, typed into, submitted, a key, the pointer over it.
339
+ if ["press", "type", "submit", "key", "hover"].contains(kind) {
340
+ guard let target = action["target"] as? String,
341
+ 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"] ?? "")"] }
342
+ let text = action["text"] as? String ?? ""
343
+ switch kind {
344
+ case "press": if let button = box.control as? NSButton { button.performClick(nil) } else { box.fire("onPress") }
345
+ case "type":
346
+ if let field = box.control as? NSTextField { field.stringValue = text }
347
+ else if let view = (box.control as? NSScrollView)?.documentView as? NSTextView { view.string = text }
348
+ box.input(text)
349
+ case "submit": box.fire("onSubmitEditing", ["text": text, "value": text, "nativeEvent": ["text": text]])
350
+ case "key": box.fire("onKeyPress", ["key": action["key"] as? String ?? "", "shiftKey": action["shift"] as? Bool == true, "text": text])
351
+ default:
352
+ let hovered = action["value"] as? Bool ?? true
353
+ box.hovering = hovered; box.applyAppearance(animated: false)
354
+ box.fire("onHoverChange", ["hovered": hovered])
355
+ }
356
+ return ["ok": true]
357
+ }
305
358
  if kind == "screenshot" {
306
359
  guard let directory = testDirectory, let view = scene.window.contentView?.superview,
307
360
  let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return ["ok": false, "error": "Window cannot be rendered"] }
@@ -397,6 +450,9 @@ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotifica
397
450
  application.appName = argument("--title", key: "title", fallback: "Craft")
398
451
  application.initialRoute = argument("--route", key: "route", fallback: "Main")
399
452
  application.titlebarStyle = argument("--titlebar-style", key: "titlebarStyle", fallback: "standard")
453
+ // `18,18` on the command line; `[18, 18]` in craft-app.json.
454
+ let lights = argument("--traffic-lights", key: "trafficLights").split(separator: ",").compactMap { Double($0.trimmingCharacters(in: CharacterSet(charactersIn: " []"))) }
455
+ if lights.count == 2 { application.trafficLights = lights }
400
456
  application.requiresSession = !CommandLine.arguments.contains("--no-session") && packaged["session"] as? Bool != false
401
457
  for (flag, key) in [("--width", "width"), ("--height", "height"), ("--min-width", "minWidth"), ("--min-height", "minHeight")] {
402
458
  if let value = Double(argument(flag, key: key)), value.isFinite, value > 0 { application.mainWindowOptions[key] = value }
@@ -276,6 +276,7 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
276
276
  var downPoint: NSPoint?
277
277
  var lastDropPosition = ""
278
278
  var acceptsDrops = false
279
+ var wantedFocus = false
279
280
  var componentRoot: CraftMacBox? { props["data-stx-component-wrapper"] != nil && flow?.boxes.count == 1 && flow?.boxes.first?.kind != "Modal" ? flow?.boxes.first : nil }
280
281
  var layoutStyle: [String: Any] {
281
282
  guard let child = componentRoot else { return style }
@@ -366,8 +367,13 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
366
367
  border.isHidden = (number(style["border\(edge)Width"]) ?? 0) <= 0
367
368
  }
368
369
  if let name = props["material"] as? String {
369
- if material == nil { let effect = NSVisualEffectView(); effect.blendingMode = .behindWindow; effect.state = .followsWindowActiveState; addSubview(effect, positioned: .below, relativeTo: control); material = effect }
370
- material?.material = name == "sidebar" ? .sidebar : .headerView
370
+ if material == nil { let effect = NSVisualEffectView(); effect.state = .followsWindowActiveState; addSubview(effect, positioned: .below, relativeTo: control); material = effect }
371
+ // Chrome shows the desktop through it; a floating surface (a command bar, a popover) blurs the window's own content.
372
+ let materials: [String: NSVisualEffectView.Material] = ["sidebar": .sidebar, "header": .headerView, "window": .windowBackground, "titlebar": .titlebar,
373
+ "popover": .popover, "menu": .menu, "hud": .hudWindow, "sheet": .sheet, "underPage": .underPageBackground]
374
+ material?.material = materials[name] ?? .headerView
375
+ material?.blendingMode = ["popover", "menu", "hud", "sheet"].contains(name) ? .withinWindow : .behindWindow
376
+ if name == "hud" { material?.state = .active }
371
377
  } else { material?.removeFromSuperview(); material = nil }
372
378
  let identifier = props["testID"] as? String ?? props["testId"] as? String ?? props["accessibilityIdentifier"] as? String ?? id
373
379
  control?.setAccessibilityIdentifier(identifier)
@@ -405,7 +411,19 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
405
411
  if props["variant"] as? String == "primary" { button.bezelColor = .controlAccentColor }
406
412
  if props["variant"] as? String == "danger" { button.contentTintColor = .systemRed }
407
413
  }
408
- if kind == "TextInput" { configureInput(font: font) }
414
+ if kind == "TextInput" {
415
+ configureInput(font: font)
416
+ // `autoFocus` takes the keyboard when it turns on (a command bar opening); `selectTextOnFocus` then selects it all.
417
+ let focus = props["autoFocus"] as? Bool == true
418
+ if focus && !wantedFocus {
419
+ DispatchQueue.main.async { [weak self] in
420
+ guard let self, let window = self.window, let target = (self.control as? NSScrollView)?.documentView ?? self.control else { return }
421
+ window.makeFirstResponder(target)
422
+ if self.props["selectTextOnFocus"] as? Bool == true { (target as? NSTextField)?.currentEditor()?.selectAll(nil) ?? (target as? NSTextView)?.selectAll(nil) }
423
+ }
424
+ }
425
+ wantedFocus = focus
426
+ }
409
427
  if let picker = control as? NSPopUpButton {
410
428
  let rawOptions = props["options"] as? [[String: Any]] ?? (node["children"] as? [[String: Any]] ?? []).map { child in
411
429
  ["value": (child["props"] as? [String: Any])?["value"] ?? nativeText(child), "label": nativeText(child)]
@@ -489,8 +507,16 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
489
507
  text.setAccessibilityLabel(props["placeholder"] as? String ?? "Message")
490
508
  } else if let field = control as? NSTextField {
491
509
  if field.stringValue != value { field.stringValue = value }
492
- field.placeholderString = props["placeholder"] as? String
493
510
  field.font = font; field.isEnabled = props["disabled"] as? Bool != true
511
+ // A field the style colours (a light address pill in a dark window) follows it, placeholder included.
512
+ if style["color"] != nil {
513
+ let color = CraftMacColor.parse(style["color"])
514
+ field.textColor = color
515
+ field.placeholderAttributedString = (props["placeholder"] as? String).map { NSAttributedString(string: $0, attributes: [.foregroundColor: color.withAlphaComponent(0.5), .font: font]) }
516
+ } else {
517
+ field.textColor = .labelColor
518
+ field.placeholderString = props["placeholder"] as? String
519
+ }
494
520
  if let bordered = props["bordered"] as? Bool { field.isBordered = bordered; field.drawsBackground = bordered }
495
521
  field.setAccessibilityIdentifier(props["testID"] as? String ?? props["testId"] as? String ?? id)
496
522
  }
@@ -720,6 +746,16 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
720
746
  control?.frame = CGRect(x: padding.left, y: padding.top, width: max(0, bounds.width - padding.left - padding.right), height: max(0, bounds.height - padding.top - padding.bottom))
721
747
  } else { control?.frame = bounds }
722
748
  material?.frame = bounds
749
+ // A visual effect view ignores its layer's corner radius; a stretchable mask rounds it (as the tray popover does).
750
+ if let material {
751
+ let radius = layer?.cornerRadius ?? 0
752
+ if radius > 0, material.maskImage?.capInsets.top != radius {
753
+ let edge = radius * 2 + 1
754
+ let mask = NSImage(size: CGSize(width: edge, height: edge), flipped: false) { rect in NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius).fill(); return true }
755
+ mask.capInsets = NSEdgeInsets(top: radius, left: radius, bottom: radius, right: radius); mask.resizingMode = .stretch
756
+ material.maskImage = mask
757
+ } else if radius == 0 { material.maskImage = nil }
758
+ }
723
759
  gradient?.frame = bounds
724
760
  gradient?.cornerRadius = layer?.cornerRadius ?? 0
725
761
  // An explicit path spares Core Animation an offscreen pass to find the shadow's shape, every frame.
@@ -782,6 +818,12 @@ final class CraftMacBox: NSView, NSTextFieldDelegate, NSTextViewDelegate, NSDrag
782
818
  renderer?.runtime.send("CRAFT_EVENT", ["event": "craftDesktopCommand", "detail": ["id": "escape"]]); return true
783
819
  }
784
820
  if commandSelector == #selector(NSResponder.insertNewline(_:)), events["onSubmitEditing"] != nil { fire("onSubmitEditing", ["text": textView.string, "value": textView.string, "nativeEvent": ["text": textView.string]]); return true }
821
+ // The keys a list driven from a field needs (a command bar, an address bar's suggestions), in the web's names.
822
+ let keys: [Selector: String] = [#selector(NSResponder.moveUp(_:)): "ArrowUp", #selector(NSResponder.moveDown(_:)): "ArrowDown",
823
+ #selector(NSResponder.insertTab(_:)): "Tab", #selector(NSResponder.insertBacktab(_:)): "Tab"]
824
+ if let key = keys[commandSelector], events["onKeyPress"] != nil {
825
+ fire("onKeyPress", ["key": key, "shiftKey": commandSelector == #selector(NSResponder.insertBacktab(_:)), "text": textView.string]); return true
826
+ }
785
827
  return false
786
828
  }
787
829
  func controlTextDidEndEditing(_ notification: Notification) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.408",
3
+ "version": "0.0.410",
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>",