craft-native 0.0.171 → 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 +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +6 -0
- package/dist/ios/templates/CraftHybrid.swift +33 -18
- package/dist/ios/templates/CraftNativeScreen.swift +56 -6
- package/dist/macos/src/index.d.ts +16 -0
- package/dist/macos/src/index.js +75 -0
- package/dist/macos/templates/CraftMacApplication.swift +342 -0
- package/dist/macos/templates/CraftMacRenderer.swift +481 -0
- package/dist/macos/templates/CraftMacRuntime.swift +266 -0
- package/dist/macos/templates/CraftNativeMutation.swift +390 -0
- package/dist/macos/templates/native_layout.zig +1651 -0
- package/package.json +9 -1
|
@@ -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
|
+
}
|