craft-native 0.0.172 → 0.0.173

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
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.172";
3306
+ var version = "0.0.173";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.172";
313
+ var version = "0.0.173";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.172";
61
+ var version = "0.0.173";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -561,7 +561,13 @@ struct CraftApp: App {
561
561
  .ignoresSafeArea(.keyboard, edges: .bottom)
562
562
  } else if appState.config.renderer == "native" {
563
563
  // One native screen and its stack: no paths, no tabs.
564
+ // The screen keeps itself out of the keyboard's way (its
565
+ // own bottom safe area, CraftNativeScreen.swift); SwiftUI
566
+ // shrinking it as well took the keyboard's height twice,
567
+ // and a screen's scroll views and text views were squeezed
568
+ // to nothing whenever the keyboard came up.
564
569
  CraftNativeScreen(config: appState.config)
570
+ .ignoresSafeArea(.keyboard, edges: .bottom)
565
571
  } else {
566
572
  // The page's own chrome, drawn natively over it: the tab
567
573
  // bar the page asks for, and the splash until it is ready.
@@ -5398,6 +5398,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5398
5398
  view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
5399
5399
  }
5400
5400
  }
5401
+ // A multiline TextInput is a UITextView, which works out its traits
5402
+ // as it goes: while it has the keyboard they say it is editing, and
5403
+ // that is what UI tests and VoiceOver read focus from. Setting them
5404
+ // froze them, so it read as static text that never had focus ("neither
5405
+ // element nor any descendant has keyboard focus"); it keeps its own.
5406
+ if view is UITextView { return }
5401
5407
  view.accessibilityTraits = traits
5402
5408
  }
5403
5409
 
@@ -0,0 +1,16 @@
1
+ export interface MacOSNativeBuildOptions {
2
+ outdir: string;
3
+ /** A compiled stx native route bundle, never HTML. */
4
+ nativeBundle: string;
5
+ zig?: string;
6
+ /** zig-js checkout and its public static archive. No system JSC fallback. */
7
+ zigJS: {
8
+ source: string;
9
+ library: string;
10
+ };
11
+ }
12
+ /** Compile Craft's AppKit host and the shared Zig layout core. No WebKit. */
13
+ export declare function buildMacOSNative(options: MacOSNativeBuildOptions): Promise<{
14
+ executable: string;
15
+ bundle: string;
16
+ }>;
@@ -0,0 +1,75 @@
1
+ // @bun
2
+ // ../macos/src/index.ts
3
+ import { cp, mkdir } from "fs/promises";
4
+ import { existsSync } from "fs";
5
+ import { join, resolve } from "path";
6
+ import process from "process";
7
+ async function buildMacOSNative(options) {
8
+ if (process.platform !== "darwin")
9
+ throw new Error("The AppKit host must be built on macOS.");
10
+ const outdir = resolve(options.outdir);
11
+ const templates = join(import.meta.dir, "../templates");
12
+ const sourceRoot = join(import.meta.dir, "../..");
13
+ const layout = existsSync(join(sourceRoot, "zig/src/native_layout.zig")) ? join(sourceRoot, "zig/src/native_layout.zig") : join(templates, "native_layout.zig");
14
+ const mutation = existsSync(join(sourceRoot, "ios/templates/CraftNativeMutation.swift")) ? join(sourceRoot, "ios/templates/CraftNativeMutation.swift") : join(templates, "CraftNativeMutation.swift");
15
+ if (!existsSync(options.nativeBundle))
16
+ throw new Error(`Native STX bundle not found: ${options.nativeBundle}`);
17
+ await mkdir(outdir, { recursive: true });
18
+ async function run(args) {
19
+ const child = Bun.spawn(args, { stdout: "inherit", stderr: "inherit" });
20
+ if (await child.exited !== 0)
21
+ throw new Error(`Native build failed: ${args[0]}`);
22
+ }
23
+ const zig = options.zig || process.env.CRAFT_ZIG || Bun.which("zig");
24
+ if (!zig)
25
+ throw new Error("Install the pinned Craft Zig toolchain or set CRAFT_ZIG.");
26
+ const layoutLibrary = join(outdir, "libcraft-layout.a");
27
+ await run([zig, "build-lib", layout, "-O", "ReleaseSafe", "-static", "-lc", `-femit-bin=${layoutLibrary}`]);
28
+ const executable = join(outdir, "craft-native-macos");
29
+ const engine = options.zigJS;
30
+ if (!existsSync(engine.library))
31
+ throw new Error(`Build zig-js first: ${engine.library}`);
32
+ if (!existsSync(join(engine.source, "include/module.modulemap")))
33
+ throw new Error("zig-js must expose its owned ZigJS Swift module.");
34
+ const bridge = join(outdir, "zig-js-objc.o");
35
+ const dispatch = join(outdir, "zig-js-dispatch.o");
36
+ const assembly = join(engine.source, `src/objc_abi_${process.arch === "arm64" ? "aarch64" : "x86_64"}.S`);
37
+ await run(["xcrun", "clang", "-fobjc-arc", "-fblocks", "-Wno-incomplete-implementation", "-I", join(engine.source, "include"), "-include", join(engine.source, "include/ZigJS.h"), "-c", join(engine.source, "src/objc_bridge.m"), "-o", bridge]);
38
+ await run([zig, "build-obj", join(engine.source, "src/objc_abi_dispatch.zig"), assembly, "-O", "ReleaseFast", `-femit-bin=${dispatch}`]);
39
+ await run([
40
+ "swiftc",
41
+ "-O",
42
+ "-swift-version",
43
+ "5",
44
+ "-I",
45
+ join(engine.source, "include"),
46
+ "-framework",
47
+ "Cocoa",
48
+ "-framework",
49
+ "AVKit",
50
+ "-framework",
51
+ "UserNotifications",
52
+ join(templates, "CraftMacRuntime.swift"),
53
+ join(templates, "CraftMacRenderer.swift"),
54
+ join(templates, "CraftMacApplication.swift"),
55
+ mutation,
56
+ bridge,
57
+ dispatch,
58
+ engine.library,
59
+ "-Xlinker",
60
+ "-force_load",
61
+ "-Xlinker",
62
+ layoutLibrary,
63
+ "-Xlinker",
64
+ "-export_dynamic",
65
+ "-o",
66
+ executable
67
+ ]);
68
+ const bundle = join(outdir, "native-screen.js");
69
+ if (resolve(options.nativeBundle) !== bundle)
70
+ await cp(options.nativeBundle, bundle);
71
+ return { executable, bundle };
72
+ }
73
+ export {
74
+ buildMacOSNative
75
+ };
@@ -0,0 +1,342 @@
1
+ import AppKit
2
+ import AVKit
3
+ import CoreImage
4
+ import UserNotifications
5
+ import UniformTypeIdentifiers
6
+
7
+ final class CraftMacScene: NSObject, NSWindowDelegate {
8
+ let name: String
9
+ let window: NSWindow
10
+ let runtime: CraftMacRuntime
11
+ let renderer: CraftMacRenderer
12
+ init(name: String, route: String, options: [String: Any], application: CraftMacApplication) {
13
+ self.name = name
14
+ runtime = CraftMacRuntime(route: route, params: options["params"] as? [String: Any] ?? [:], network: application.network)
15
+ renderer = CraftMacRenderer(runtime: runtime)
16
+ let width = number(options["width"]) ?? 1120, height = number(options["height"]) ?? 740
17
+ window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: width, height: height), styleMask: [.titled, .closable, .resizable, .miniaturizable], backing: .buffered, defer: false)
18
+ super.init()
19
+ runtime.scene = self; renderer.window = window
20
+ window.title = options["title"] as? String ?? application.appName
21
+ window.minSize = CGSize(width: number(options["minWidth"]) ?? 640, height: number(options["minHeight"]) ?? 400)
22
+ window.isReleasedWhenClosed = false
23
+ renderer.root.autoresizingMask = [.width, .height]
24
+ window.contentView = renderer.root
25
+ window.delegate = self; window.center()
26
+ if name == "main" { window.setFrameAutosaveName("\(application.appName)NativeMain") }
27
+ window.makeKeyAndOrderFront(nil)
28
+ runtime.load(application.bundle)
29
+ }
30
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
31
+ if name == "main" { sender.orderOut(nil); return false }
32
+ return true
33
+ }
34
+ func windowWillClose(_ notification: Notification) {
35
+ runtime.dispose()
36
+ CraftMacApplication.shared.scenes.removeValue(forKey: name)
37
+ }
38
+ func windowDidBecomeKey(_ notification: Notification) { runtime.send("SCREEN_FOCUS", ["focused": true]) }
39
+ func windowDidResignKey(_ notification: Notification) { runtime.send("SCREEN_FOCUS", ["focused": false]) }
40
+ }
41
+
42
+ final class CraftMacMenuTarget: NSObject {
43
+ var chosen: String?
44
+ @objc func choose(_ item: NSMenuItem) { chosen = item.representedObject as? String }
45
+ }
46
+
47
+ final class CraftMacApplication: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate {
48
+ static let shared = CraftMacApplication()
49
+ var appName = "Craft"
50
+ var bundle = ""
51
+ var initialRoute = "Main"
52
+ var network: CraftMacNetwork!
53
+ var scenes: [String: CraftMacScene] = [:]
54
+ var panels: [NSWindow] = []
55
+ var chosenFiles = Set<String>()
56
+ var testDirectory: URL?
57
+ var testTimer: Timer?
58
+ var processedActions = Set<String>()
59
+ var appearanceObservation: NSKeyValueObservation?
60
+
61
+ func applicationDidFinishLaunching(_ notification: Notification) {
62
+ installMenus()
63
+ appearanceObservation = NSApp.observe(\.effectiveAppearance, options: [.new]) { [weak self] _, _ in
64
+ DispatchQueue.main.async { self?.appearanceChanged() }
65
+ }
66
+ NSApp.activate(ignoringOtherApps: true)
67
+ // The agent issues an HttpOnly cookie. It stays in URLSession, shared
68
+ // by native windows, and is never handed to the JavaScript context.
69
+ let request = URLRequest(url: network.base.appendingPathComponent("native/session"))
70
+ network.session.dataTask(with: request) { [weak self] _, response, error in
71
+ DispatchQueue.main.async {
72
+ guard let self else { return }
73
+ guard error == nil, (response as? HTTPURLResponse)?.statusCode == 200 else {
74
+ 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
75
+ }
76
+ self.open(name: "main", route: self.initialRoute, options: ["title": self.appName])
77
+ self.startTestDriver()
78
+ }
79
+ }.resume()
80
+ }
81
+ func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { scenes["main"]?.window.makeKeyAndOrderFront(nil); return true }
82
+ func applicationWillTerminate(_ notification: Notification) { scenes.values.forEach { $0.runtime.dispose() }; testTimer?.invalidate() }
83
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false }
84
+ func open(name: String, route: String, options: [String: Any]) {
85
+ if let scene = scenes[name] { scene.window.makeKeyAndOrderFront(nil); return }
86
+ scenes[name] = CraftMacScene(name: name, route: route, options: options, application: self)
87
+ }
88
+ @objc func command(_ item: NSMenuItem) {
89
+ guard let id = item.representedObject as? String else { return }
90
+ broadcast(id)
91
+ }
92
+ func broadcast(_ id: String) { for scene in scenes.values { scene.runtime.send("CRAFT_EVENT", ["event": "craftDesktopCommand", "detail": ["id": id]]) } }
93
+ func appearanceChanged() {
94
+ let scheme = CraftMacColor.isDark ? "dark" : "light"
95
+ for scene in scenes.values {
96
+ scene.runtime.context.evaluateScript("__stxNativeBridge.colorScheme = '\(scheme)';")
97
+ scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
98
+ for box in scene.renderer.boxes.values {
99
+ box.layer?.backgroundColor = CraftMacColor.parse(box.style["backgroundColor"], fallback: .clear).cgColor
100
+ box.layer?.borderColor = CraftMacColor.parse(box.style["borderColor"], fallback: .separatorColor).cgColor
101
+ }
102
+ }
103
+ }
104
+ broadcast("appearance")
105
+ }
106
+
107
+ private func installMenus() {
108
+ let bar = NSMenu()
109
+ let app = NSMenu(title: appName)
110
+ app.addItem(withTitle: "About \(appName)", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
111
+ app.addItem(.separator())
112
+ let settings = app.addItem(withTitle: "Settings…", action: #selector(command(_:)), keyEquivalent: ","); settings.target = self; settings.representedObject = "settings"
113
+ app.addItem(.separator())
114
+ app.addItem(withTitle: "Hide \(appName)", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
115
+ app.addItem(withTitle: "Quit \(appName)", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
116
+ addMenu(app, to: bar)
117
+ let edit = NSMenu(title: "Edit")
118
+ edit.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
119
+ edit.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
120
+ edit.addItem(.separator())
121
+ edit.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
122
+ edit.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
123
+ edit.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
124
+ edit.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
125
+ addMenu(edit, to: bar)
126
+ let window = NSMenu(title: "Window")
127
+ window.addItem(withTitle: "Close", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
128
+ window.addItem(withTitle: "Minimize", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
129
+ window.addItem(withTitle: "Zoom", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
130
+ addMenu(window, to: bar)
131
+ NSApp.mainMenu = bar; NSApp.windowsMenu = window
132
+ }
133
+ private func addMenu(_ menu: NSMenu, to bar: NSMenu) { let item = NSMenuItem(title: menu.title, action: nil, keyEquivalent: ""); item.submenu = menu; bar.addItem(item) }
134
+ private func menuItem(_ raw: [String: Any], target: AnyObject, action: Selector) -> NSMenuItem {
135
+ if raw["separator"] as? Bool == true { return .separator() }
136
+ let parts = (raw["shortcut"] as? String ?? "").lowercased().split(separator: "+").map(String.init)
137
+ let key = ["up": "\u{F700}", "down": "\u{F701}", "left": "\u{F702}", "right": "\u{F703}" ][parts.last ?? ""] ?? parts.last ?? ""
138
+ let item = NSMenuItem(title: raw["label"] as? String ?? raw["title"] as? String ?? "", action: action, keyEquivalent: key)
139
+ item.target = target; item.representedObject = raw["id"]
140
+ item.keyEquivalentModifierMask = []
141
+ if parts.contains("cmd") { item.keyEquivalentModifierMask.insert(.command) }
142
+ if parts.contains("ctrl") { item.keyEquivalentModifierMask.insert(.control) }
143
+ if parts.contains("shift") { item.keyEquivalentModifierMask.insert(.shift) }
144
+ if parts.contains("alt") { item.keyEquivalentModifierMask.insert(.option) }
145
+ item.isEnabled = raw["disabled"] as? Bool != true
146
+ item.state = raw["checked"] as? Bool == true ? .on : .off
147
+ if let symbol = raw["icon"] as? String { item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) }
148
+ return item
149
+ }
150
+ func perform(_ method: String, args: [Any], scene: CraftMacScene?, reply: @escaping (Any) -> Void) {
151
+ let options = args.first as? [String: Any] ?? [:]
152
+ switch method {
153
+ case "openWindow":
154
+ let name = options["name"] as? String ?? options["route"] as? String ?? "window"
155
+ let route = options["route"] as? String ?? initialRoute
156
+ open(name: name, route: route, options: options); reply(["name": name])
157
+ case "closeWindow": scene?.window.performClose(nil); reply(NSNull())
158
+ case "focus":
159
+ if let identifier = args.first as? String, let box = scene?.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == identifier }) {
160
+ scene?.window.makeFirstResponder((box.control as? NSScrollView)?.documentView ?? box.control)
161
+ }
162
+ reply(NSNull())
163
+ case "setMenus":
164
+ for raw in args.first as? [[String: Any]] ?? [] {
165
+ guard let title = raw["label"] as? String, ![appName, "Edit", "Window"].contains(title), let bar = NSApp.mainMenu else { continue }
166
+ if let old = bar.items.first(where: { $0.title == title }) { bar.removeItem(old) }
167
+ let menu = NSMenu(title: title); menu.autoenablesItems = false
168
+ for item in raw["items"] as? [[String: Any]] ?? [] { menu.addItem(menuItem(item, target: self, action: #selector(command(_:)))) }
169
+ addMenu(menu, to: bar)
170
+ }; reply(true)
171
+ case "contextMenu":
172
+ let target = CraftMacMenuTarget(), menu = NSMenu(); menu.autoenablesItems = false
173
+ for item in args.first as? [[String: Any]] ?? [] { menu.addItem(menuItem(item, target: target, action: #selector(CraftMacMenuTarget.choose(_:)))) }
174
+ menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil); reply(target.chosen ?? NSNull())
175
+ case "pickFiles":
176
+ let panel = NSOpenPanel(); panel.canChooseFiles = true; panel.canChooseDirectories = false; panel.allowsMultipleSelection = true
177
+ guard panel.runModal() == .OK else { reply([]); return }
178
+ let files: [[String: String]] = panel.urls.map { url in
179
+ chosenFiles.insert(url.path)
180
+ return ["path": url.path, "name": url.lastPathComponent, "type": UTType(filenameExtension: url.pathExtension)?.preferredMIMEType ?? "application/octet-stream"]
181
+ }; reply(files)
182
+ case "uploadFiles":
183
+ upload(args.first as? [[String: String]] ?? [], endpoint: args.count > 1 ? args[1] as? String ?? "" : "", reply: reply)
184
+ case "preview": preview(options, scene: scene); reply(true)
185
+ case "exportFile": export(options, reply: reply)
186
+ case "qrCode":
187
+ let text = args.first as? String ?? ""
188
+ let filter = CIFilter(name: "CIQRCodeGenerator")!; filter.setValue(Data(text.utf8), forKey: "inputMessage"); filter.setValue("M", forKey: "inputCorrectionLevel")
189
+ guard let image = filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8)), let cg = CIContext().createCGImage(image, from: image.extent), let data = NSBitmapImageRep(cgImage: cg).representation(using: .png, properties: [:]) else { reply(NSNull()); return }
190
+ reply("data:image/png;base64," + data.base64EncodedString())
191
+ case "setBadge": let count = (args.first as? NSNumber)?.intValue ?? 0; NSApp.dockTile.badgeLabel = count > 0 ? String(count) : nil; reply(true)
192
+ default: reply(["error": "Unsupported desktop operation: \(method)"])
193
+ }
194
+ }
195
+ private func upload(_ files: [[String: String]], endpoint: String, reply: @escaping (Any) -> Void) {
196
+ var results: [Any] = []
197
+ func next(_ index: Int) {
198
+ guard index < files.count else { reply(results); return }
199
+ let file = files[index]
200
+ guard let path = file["path"], chosenFiles.contains(path), let data = try? Data(contentsOf: URL(fileURLWithPath: path)), data.count <= 256 * 1024 * 1024,
201
+ 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 }
202
+ request.httpBody = data
203
+ network.session.dataTask(with: request) { data, response, error in DispatchQueue.main.async {
204
+ 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 }
205
+ results.append(value); next(index + 1)
206
+ } }.resume()
207
+ }; next(0)
208
+ }
209
+ private func preview(_ options: [String: Any], scene: CraftMacScene?) {
210
+ guard let request = network.request(["url": options["url"] ?? ""]) else { return }
211
+ let panel = NSPanel(contentRect: CGRect(x: 0, y: 0, width: 760, height: 600), styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
212
+ panel.title = options["title"] as? String ?? "Preview"; panel.isReleasedWhenClosed = false; panels.append(panel)
213
+ if options["type"] as? String == "video" || options["type"] as? String == "audio" {
214
+ let player = AVPlayerView(); player.controlsStyle = .floating; panel.contentView = player
215
+ let headers = network.session.configuration.httpCookieStorage?.cookies(for: request.url!).map { HTTPCookie.requestHeaderFields(with: $0) } ?? [:]
216
+ player.player = AVPlayer(playerItem: AVPlayerItem(asset: AVURLAsset(url: request.url!, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])))
217
+ player.player?.play()
218
+ } else {
219
+ let image = NSImageView(); image.imageScaling = .scaleProportionallyUpOrDown; panel.contentView = image
220
+ network.session.dataTask(with: request) { data, _, _ in DispatchQueue.main.async { image.image = data.flatMap(NSImage.init(data:)) } }.resume()
221
+ }
222
+ panel.center(); panel.makeKeyAndOrderFront(nil)
223
+ }
224
+ private func export(_ options: [String: Any], reply: @escaping (Any) -> Void) {
225
+ let panel = NSSavePanel(); panel.nameFieldStringValue = options["name"] as? String ?? "Attachment"
226
+ guard panel.runModal() == .OK, let destination = panel.url, let request = network.request(["url": options["url"] ?? ""]) else { reply(false); return }
227
+ network.session.downloadTask(with: request) { location, response, error in
228
+ var saved = false
229
+ 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 {} }
230
+ DispatchQueue.main.async { reply(saved) }
231
+ }.resume()
232
+ }
233
+ func notification(_ method: String, options: [String: Any], reply: @escaping (Any) -> Void) {
234
+ guard method == "schedule" else { reply(false); return }
235
+ let center = UNUserNotificationCenter.current()
236
+ let content = UNMutableNotificationContent(); content.title = options["title"] as? String ?? appName; content.body = options["body"] as? String ?? ""
237
+ let identifier = options["id"] as? String ?? UUID().uuidString
238
+ center.add(UNNotificationRequest(identifier: identifier, content: content, trigger: nil)) { error in DispatchQueue.main.async { reply(error == nil) } }
239
+ }
240
+
241
+ /// Explicit local test mode. Exercises AppKit controls on the main event
242
+ /// loop and snapshots actual view classes, never a DOM or an HTTP driver.
243
+ private func startTestDriver() {
244
+ guard let directory = testDirectory else { return }
245
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
246
+ testTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
247
+ guard let self else { return }
248
+ let files = (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)) ?? []
249
+ for file in files where file.lastPathComponent.hasPrefix("action-") && file.pathExtension == "json" && !self.processedActions.contains(file.lastPathComponent) {
250
+ guard let data = try? Data(contentsOf: file), let action = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
251
+ self.processedActions.insert(file.lastPathComponent)
252
+ let result = self.testAction(action)
253
+ if let bytes = try? JSONSerialization.data(withJSONObject: result) { try? bytes.write(to: directory.appendingPathComponent(file.lastPathComponent.replacingOccurrences(of: "action-", with: "result-")), options: .atomic) }
254
+ }
255
+ if let data = try? JSONSerialization.data(withJSONObject: self.snapshot()) { try? data.write(to: directory.appendingPathComponent("state.json"), options: .atomic) }
256
+ }
257
+ }
258
+ private func testAction(_ action: [String: Any]) -> [String: Any] {
259
+ let kind = action["action"] as? String ?? ""
260
+ if kind == "command", let id = action["id"] as? String { broadcast(id); return ["ok": true] }
261
+ if kind == "quit" { DispatchQueue.main.async { NSApp.terminate(nil) }; return ["ok": true] }
262
+ if kind == "appearance" {
263
+ NSApp.appearance = NSAppearance(named: action["value"] as? String == "dark" ? .darkAqua : .aqua)
264
+ appearanceChanged(); return ["ok": true]
265
+ }
266
+ let scene = scenes[action["window"] as? String ?? "main"]
267
+ guard let scene else { return ["ok": false, "error": "Window not found"] }
268
+ if kind == "close" { scene.window.performClose(nil); return ["ok": true] }
269
+ if kind == "screenshot" {
270
+ guard let directory = testDirectory, let view = scene.window.contentView?.superview,
271
+ let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return ["ok": false, "error": "Window cannot be rendered"] }
272
+ scene.window.effectiveAppearance.performAsCurrentDrawingAppearance {
273
+ func invalidate(_ current: NSView) { current.needsDisplay = true; for child in current.subviews { invalidate(child) } }
274
+ invalidate(view); view.displayIfNeeded()
275
+ view.cacheDisplay(in: view.bounds, to: bitmap)
276
+ }
277
+ guard let png = bitmap.representation(using: .png, properties: [:]) else { return ["ok": false, "error": "PNG encoding failed"] }
278
+ let destination = directory.appendingPathComponent("screenshot-\(scene.name)-\(CraftMacColor.isDark ? "dark" : "light").png")
279
+ do { try png.write(to: destination, options: .atomic); return ["ok": true, "path": destination.path] }
280
+ catch { return ["ok": false, "error": error.localizedDescription] }
281
+ }
282
+ guard let identifier = action["target"] as? String,
283
+ let box = scene.renderer.boxes.values.first(where: { ($0.props["testID"] as? String ?? $0.props["testId"] as? String ?? $0.id) == identifier }) else { return ["ok": false, "error": "Control not found"] }
284
+ box.scrollToVisible(box.bounds)
285
+ guard box.window?.isVisible == true, !box.isHidden, box.frame.width > 0, box.frame.height > 0 else { return ["ok": false, "error": "Control is not visible"] }
286
+ if let control = box.control as? NSControl, !control.isEnabled { return ["ok": false, "error": "Control is disabled"] }
287
+ switch kind {
288
+ case "click": if let button = box.control as? NSButton { button.performClick(nil) } else { box.pressed() }
289
+ case "fill":
290
+ let value = action["value"] as? String ?? ""
291
+ if let field = box.control as? NSTextField { field.stringValue = value; box.input(value) }
292
+ else if let text = (box.control as? NSScrollView)?.documentView as? NSTextView { text.string = value; box.input(value) }
293
+ else { return ["ok": false, "error": "Not a text input"] }
294
+ case "pick":
295
+ if let picker = box.control as? NSPopUpButton, let item = picker.itemArray.first(where: { ($0.representedObject as? String) == action["value"] as? String }) { picker.select(item); box.changed() }
296
+ else if let sidebar = box.control as? CraftMacSidebar, let id = action["value"] as? String {
297
+ guard let row = (0..<sidebar.outline.numberOfRows).first(where: { (sidebar.outline.item(atRow: $0) as? CraftMacSidebar.Item)?.id == id }) else { return ["ok": false, "error": "Sidebar item not found"] }
298
+ sidebar.outline.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
299
+ } else { return ["ok": false, "error": "Not a picker"] }
300
+ default: return ["ok": false, "error": "Unsupported test action"]
301
+ }
302
+ return ["ok": true]
303
+ }
304
+ private func snapshot() -> [String: Any] {
305
+ let windows: [[String: Any]] = scenes.values.map { scene in
306
+ let nodes: [[String: Any]] = scene.renderer.boxes.values.map { box in
307
+ var node: [String: Any] = ["id": box.props["testID"] as? String ?? box.props["testId"] as? String ?? box.id, "type": box.kind,
308
+ "nativeClass": box.control.map { String(describing: type(of: $0)) } ?? "NSView", "visible": !box.isHidden && box.window?.isVisible == true && box.frame.width > 0 && box.frame.height > 0,
309
+ "frame": [box.frame.minX, box.frame.minY, box.frame.width, box.frame.height]]
310
+ if let field = box.control as? NSTextField { node["value"] = field.stringValue }
311
+ if let text = (box.control as? NSScrollView)?.documentView as? NSTextView { node["value"] = text.string; node["editorClass"] = NSStringFromClass(type(of: text)) }
312
+ if let button = box.control as? NSButton { node["label"] = button.title; node["enabled"] = button.isEnabled }
313
+ if let picker = box.control as? NSPopUpButton { node["value"] = picker.selectedItem?.representedObject ?? "" }
314
+ if let sidebar = box.control as? CraftMacSidebar { node["value"] = (sidebar.outline.item(atRow: sidebar.outline.selectedRow) as? CraftMacSidebar.Item)?.id ?? "" }
315
+ return node
316
+ }
317
+ func classes(_ view: NSView) -> [String] { [String(describing: type(of: view))] + view.subviews.flatMap(classes) }
318
+ 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]
319
+ }
320
+ let webViews = windows.reduce(0) { count, window in count + ((window["viewClasses"] as? [String] ?? []).filter { $0.contains("WKWebView") || $0 == "WebView" }.count) }
321
+ return ["renderer": "appkit", "scriptEngine": "zig-js", "appearance": CraftMacColor.isDark ? "dark" : "light", "windows": windows, "webViews": webViews]
322
+ }
323
+ }
324
+
325
+ @main struct CraftMacMain {
326
+ static func main() throws {
327
+ func argument(_ flag: String, fallback: String = "") -> String { guard let index = CommandLine.arguments.firstIndex(of: flag), CommandLine.arguments.indices.contains(index + 1) else { return fallback }; return CommandLine.arguments[index + 1] }
328
+ let application = CraftMacApplication.shared
329
+ if CommandLine.arguments.contains("--version") { print("craft native macOS renderer 0.0.172 (zig-js)"); return }
330
+ guard let api = URL(string: argument("--api-url")), ["http", "https"].contains(api.scheme ?? ""), api.host != nil else { throw NSError(domain: "Craft", code: 1, userInfo: [NSLocalizedDescriptionKey: "--api-url must name the application's HTTP agent"] ) }
331
+ let bundlePath = argument("--native-bundle")
332
+ guard !bundlePath.isEmpty else { throw NSError(domain: "Craft", code: 2, userInfo: [NSLocalizedDescriptionKey: "--native-bundle must name a compiled STX bundle"] ) }
333
+ application.bundle = try String(contentsOfFile: bundlePath, encoding: .utf8)
334
+ application.appName = argument("--title", fallback: "Craft")
335
+ application.initialRoute = argument("--route", fallback: "Main")
336
+ application.network = CraftMacNetwork(base: api)
337
+ let testDirectory = argument("--test-dir")
338
+ if !testDirectory.isEmpty { application.testDirectory = URL(fileURLWithPath: testDirectory, isDirectory: true) }
339
+ let app = NSApplication.shared
340
+ app.setActivationPolicy(.regular); app.delegate = application; app.run()
341
+ }
342
+ }