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 +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/CraftNativeScreen.swift +6 -0
- 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
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.
|
|
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
package/dist/index.js
CHANGED
|
@@ -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
|
+
}
|