diff --git a/README.md b/README.md index e747820..3793900 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ The build: 3. Assembles the ordered patch stack in a private Git index and applies only the missing suffix. Drift from every complete prefix fails the build instead of producing unexpected source. 4. Builds in release mode. -`--out` receives `libDesktopTools.dylib`, `desktop-tools-client`, and `Licenses//` notices for this package and every resolved pin. Existing files in `/Licenses` are kept. +`--out` receives `libDesktopTools.dylib`, `desktop-tools-client`, `desktop-tools-app` (the standalone host below), and `Licenses//` notices for this package and every resolved pin. Existing files in `/Licenses` are kept. Give each concurrent build its own `--scratch` directory, or run builds one at a time. Patches are applied to the scratch's Peekaboo checkout outside SwiftPM's lock. The default scratch is `native/.build` inside the package, which is not suitable for an installed dependency. @@ -62,7 +62,19 @@ CI checks that `dist/` matches a fresh compile and that the modules import on Li | `void desktop_tools_permission(const char *kind)` | Request `accessibility`, `screenRecording`, or `eventSynthesizing` | | `void desktop_tools_free(void *)` | Release a status string | -macOS grants Accessibility, Screen Recording, and event synthesis to the embedding app. Each embedding app needs its own grants on each machine. Clients must be signed by the host's team with the exact allowlisted identifier, and the client verifies the host's team. A vendor-signed companion that serves other consumers is not part of this package. +macOS grants Accessibility, Screen Recording, and event synthesis to the embedding app. Each embedding app needs its own grants on each machine. Clients must be signed by the host's team with the exact allowlisted identifier, and the client verifies the host's team. For agents outside an embedding app, use the Desktop Tools app below. + +## Desktop Tools app + +`Desktop Tools.app` (`dev.githubnext.desktop-tools`) is a menu bar host for agents that don't embed the library, such as Claude Code or Codex through the MCP server. It holds its own Accessibility, Screen Recording and Keyboard input grants, shown and requested from its menu, and serves `~/.local/state/desktop-tools/desktop.sock`. It links `desktop-client` there to its signed client. Turning off **Serve agents** stops the runtime. + +Anything running as the user can use those grants while the app serves, so grant them only on machines where that is acceptable, and quit the app when it isn't needed. + +```sh +node dist/app.js --out build --identity "Developer ID Application: … (TEAM)" --release +``` + +`assemble(out, scratch?)` and `sign(app, identity, release)` in `dist/app.js` are what the release workflow uses. ## Use from Node diff --git a/dist/app.d.ts b/dist/app.d.ts new file mode 100644 index 0000000..8e99d25 --- /dev/null +++ b/dist/app.d.ts @@ -0,0 +1,6 @@ +#!/usr/bin/env node +export declare const identifier = "dev.githubnext.desktop-tools"; +/** Builds the native products into `/build` and assembles an unsigned `/Desktop Tools.app`. */ +export declare function assemble(out: string, scratch?: string): string; +/** Signs inside out; the client's exact identifier is what the runtime admits. */ +export declare function sign(app: string, identity: string, release: boolean): void; diff --git a/dist/app.js b/dist/app.js new file mode 100644 index 0000000..173b97c --- /dev/null +++ b/dist/app.js @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +export const identifier = "dev.githubnext.desktop-tools"; +const root = dirname(dirname(fileURLToPath(import.meta.url))); +function run(command) { + const result = spawnSync(command[0], command.slice(1), { stdio: "inherit" }); + if (result.status !== 0) + throw new Error(`Command failed: ${command.join(" ")}`); +} +/** Builds the native products into `/build` and assembles an unsigned `/Desktop Tools.app`. */ +export function assemble(out, scratch) { + const build = join(out, "build"); + run(["node", join(root, "dist", "build.js"), "--out", build, ...(scratch ? ["--scratch", scratch] : [])]); + const app = join(out, "Desktop Tools.app"); + rmSync(app, { recursive: true, force: true }); + const bin = join(app, "Contents", "MacOS"); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(app, "Contents", "Resources"), { recursive: true }); + for (const name of ["desktop-tools-app", "libDesktopTools.dylib", "desktop-tools-client"]) { + copyFileSync(join(build, name), join(bin, name)); + } + chmodSync(join(bin, "desktop-tools-app"), 0o755); + chmodSync(join(bin, "desktop-tools-client"), 0o755); + cpSync(join(build, "Licenses"), join(app, "Contents", "Resources", "Licenses"), { recursive: true }); + const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); + writeFileSync(join(app, "Contents", "Info.plist"), ` + + + + CFBundleIdentifier${identifier} + CFBundleNameDesktop Tools + CFBundleDisplayNameDesktop Tools + CFBundleExecutabledesktop-tools-app + CFBundlePackageTypeAPPL + CFBundleShortVersionString${version} + CFBundleVersion${version} + LSMinimumSystemVersion15.0 + LSUIElement + NSHighResolutionCapable + + +`); + // Binaries carry an @loader_path/../Frameworks rpath for any Swift libraries the deployment target needs. + run([ + "xcrun", + "swift-stdlib-tool", + "--copy", + "--scan-folder", + bin, + "--platform", + "macosx", + "--destination", + join(app, "Contents", "Frameworks"), + ]); + return app; +} +/** Signs inside out; the client's exact identifier is what the runtime admits. */ +export function sign(app, identity, release) { + const codesign = ["codesign", "--force", "--sign", identity, ...(release ? ["--options", "runtime", "--timestamp"] : [])]; + const frameworks = join(app, "Contents", "Frameworks"); + for (const name of readdirSync(frameworks, { withFileTypes: true }).filter((entry) => entry.isFile())) { + run([...codesign, join(frameworks, name.name)]); + } + const bin = join(app, "Contents", "MacOS"); + run([...codesign, join(bin, "libDesktopTools.dylib")]); + run([...codesign, "--identifier", `${identifier}.desktop-client`, join(bin, "desktop-tools-client")]); + run([...codesign, app]); + run(["codesign", "--verify", "--deep", "--strict", app]); +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + out: { type: "string" }, + scratch: { type: "string" }, + identity: { type: "string" }, + release: { type: "boolean", default: false }, + }, + strict: true, + }); + if (!values.out) { + throw new Error("Usage: desktop-tools-app --out [--scratch ] [--identity [--release]]"); + } + const app = assemble(resolve(values.out), values.scratch && resolve(values.scratch)); + if (values.identity) + sign(app, values.identity, values.release); + console.log(app); +} diff --git a/dist/build.js b/dist/build.js index 393da78..70c0da3 100644 --- a/dist/build.js +++ b/dist/build.js @@ -132,7 +132,7 @@ if (location.status !== 0) throw new Error("Cannot locate the native build products"); const products = location.stdout.trim(); mkdirSync(out, { recursive: true }); -for (const name of ["libDesktopTools.dylib", "desktop-tools-client"]) { +for (const name of ["libDesktopTools.dylib", "desktop-tools-client", "desktop-tools-app"]) { copyFileSync(join(products, name), join(out, name)); } // Every artifact carries the notices of what it links; other files in /Licenses stay. diff --git a/native/Package.swift b/native/Package.swift index 3ad0afb..0740e8c 100644 --- a/native/Package.swift +++ b/native/Package.swift @@ -8,6 +8,7 @@ let package = Package( products: [ .library(name: "DesktopTools", type: .dynamic, targets: ["DesktopTools"]), .executable(name: "desktop-tools-client", targets: ["DesktopToolsClient"]), + .executable(name: "desktop-tools-app", targets: ["DesktopToolsApp"]), ], dependencies: [ .package(url: "https://github.com/openclaw/Peekaboo.git", exact: "4.8.0"), @@ -37,6 +38,7 @@ let package = Package( ], path: "sources/client" ), + .executableTarget(name: "DesktopToolsApp", path: "sources/app"), ], swiftLanguageModes: [.v6] ) diff --git a/native/sources/app/app.swift b/native/sources/app/app.swift new file mode 100644 index 0000000..d732690 --- /dev/null +++ b/native/sources/app/app.swift @@ -0,0 +1,129 @@ +import AppKit +import Darwin + +// The runtime is loaded, not linked, so the app stays a thin host for the same library embedding apps ship. +private struct Runtime { + let start: @convention(c) (UnsafePointer, UnsafePointer) -> Void + let status: @convention(c) () -> UnsafeMutablePointer? + let stop: @convention(c) () -> Void + let free: @convention(c) (UnsafeMutableRawPointer?) -> Void + let permission: @convention(c) (UnsafePointer) -> Void + + init(_ path: String) { + guard let handle = dlopen(path, RTLD_NOW) else { fatalError(String(cString: dlerror())) } + func symbol(_ name: String) -> T { + guard let pointer = dlsym(handle, name) else { fatalError("Missing \(name) in \(path)") } + return unsafeBitCast(pointer, to: T.self) + } + start = symbol("desktop_tools_start") + status = symbol("desktop_tools_status") + stop = symbol("desktop_tools_stop") + free = symbol("desktop_tools_free") + permission = symbol("desktop_tools_permission") + } +} + +private struct Status: Decodable { + var state: String + var error: String? + var accessibility: Bool + var screenRecording: Bool + var eventSynthesizing: Bool +} + +@MainActor +private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate { + private let runtime: Runtime + private let socket: String + private let client: String + private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + private let menu = NSMenu() + private var serving = true + + override init() { + let bin = Bundle.main.executableURL!.deletingLastPathComponent() + runtime = Runtime(bin.appendingPathComponent("libDesktopTools.dylib").path) + let home = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".local/state/desktop-tools") + try! FileManager.default.createDirectory( + at: home, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700]) + // Consumers find the signed client through a stable link that follows the app wherever it is installed. + let link = home.appendingPathComponent("desktop-client").path + let pending = link + ".\(getpid())" + unlink(pending) + guard symlink(bin.appendingPathComponent("desktop-tools-client").path, pending) == 0, + rename(pending, link) == 0 + else { fatalError("Cannot link the desktop client at \(link)") } + socket = home.appendingPathComponent("desktop.sock").path + client = Bundle.main.bundleIdentifier! + ".desktop-client" + super.init() + } + + func applicationDidFinishLaunching(_: Notification) { + item.button?.image = NSImage(systemSymbolName: "cursorarrow.rays", accessibilityDescription: "Desktop Tools") + menu.delegate = self + item.menu = menu + runtime.start(socket, client) + render() + } + + func applicationWillTerminate(_: Notification) { + runtime.stop() + } + + func menuNeedsUpdate(_: NSMenu) { + render() + } + + private func read() -> Status? { + guard let pointer = runtime.status() else { return nil } + defer { runtime.free(pointer) } + return try? JSONDecoder().decode(Status.self, from: Data(String(cString: pointer).utf8)) + } + + private func render() { + menu.removeAllItems() + let status = read() + let state = status.map { $0.error.map { "Error: \($0)" } ?? $0.state.capitalized } ?? "Unavailable" + menu.addItem(withTitle: "Desktop Tools: \(state)", action: nil, keyEquivalent: "") + menu.addItem(withTitle: socket, action: nil, keyEquivalent: "") + menu.addItem(.separator()) + let grants: [(String, String, Bool)] = [ + ("Accessibility", "accessibility", status?.accessibility ?? false), + ("Screen Recording", "screenRecording", status?.screenRecording ?? false), + ("Keyboard input", "eventSynthesizing", status?.eventSynthesizing ?? false), + ] + for (title, kind, granted) in grants { + let entry = NSMenuItem( + title: granted ? "\(title): granted" : "Grant \(title)…", + action: granted ? nil : #selector(grant(_:)), + keyEquivalent: "") + entry.target = self + entry.representedObject = kind + entry.state = granted ? .on : .off + menu.addItem(entry) + } + menu.addItem(.separator()) + let toggle = NSMenuItem(title: "Serve agents", action: #selector(toggle), keyEquivalent: "") + toggle.target = self + toggle.state = serving ? .on : .off + menu.addItem(toggle) + menu.addItem(.separator()) + menu.addItem(withTitle: "Quit Desktop Tools", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + } + + @objc private func grant(_ sender: NSMenuItem) { + (sender.representedObject as! String).withCString(runtime.permission) + } + + @objc private func toggle() { + serving.toggle() + if serving { runtime.start(socket, client) } else { runtime.stop() } + } +} + +let app = NSApplication.shared +private let host = MainActor.assumeIsolated { Host() } +app.delegate = host +app.setActivationPolicy(.accessory) +app.run() diff --git a/package.json b/package.json index 2ff38b2..767624a 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,13 @@ "./protocol": { "types": "./dist/protocol.d.ts", "default": "./dist/protocol.js" }, "./package.json": "./package.json" }, - "bin": { "desktop-tools-build": "dist/build.js" }, + "bin": { "desktop-tools-build": "dist/build.js", "desktop-tools-app": "dist/app.js" }, "files": ["dist", "native/Package.swift", "native/Package.resolved", "native/sources", "native/patches", "LICENSE", "README.md"], "scripts": { "build:js": "tsc", "check": "rm -rf .check && tsc --outDir .check && diff -r .check dist && rm -rf .check", - "build:native": "node dist/build.js --out build" + "build:native": "node dist/build.js --out build", + "build:app": "node dist/app.js --out build" }, "devDependencies": { "@types/node": "24.19.0", diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..ed08c67 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, cpSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +export const identifier = "dev.githubnext.desktop-tools"; +const root = dirname(dirname(fileURLToPath(import.meta.url))); + +function run(command: string[]) { + const result = spawnSync(command[0]!, command.slice(1), { stdio: "inherit" }); + if (result.status !== 0) throw new Error(`Command failed: ${command.join(" ")}`); +} + +/** Builds the native products into `/build` and assembles an unsigned `/Desktop Tools.app`. */ +export function assemble(out: string, scratch?: string) { + const build = join(out, "build"); + run(["node", join(root, "dist", "build.js"), "--out", build, ...(scratch ? ["--scratch", scratch] : [])]); + const app = join(out, "Desktop Tools.app"); + rmSync(app, { recursive: true, force: true }); + const bin = join(app, "Contents", "MacOS"); + mkdirSync(bin, { recursive: true }); + mkdirSync(join(app, "Contents", "Resources"), { recursive: true }); + for (const name of ["desktop-tools-app", "libDesktopTools.dylib", "desktop-tools-client"]) { + copyFileSync(join(build, name), join(bin, name)); + } + chmodSync(join(bin, "desktop-tools-app"), 0o755); + chmodSync(join(bin, "desktop-tools-client"), 0o755); + cpSync(join(build, "Licenses"), join(app, "Contents", "Resources", "Licenses"), { recursive: true }); + const { version } = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version: string }; + writeFileSync( + join(app, "Contents", "Info.plist"), + ` + + + + CFBundleIdentifier${identifier} + CFBundleNameDesktop Tools + CFBundleDisplayNameDesktop Tools + CFBundleExecutabledesktop-tools-app + CFBundlePackageTypeAPPL + CFBundleShortVersionString${version} + CFBundleVersion${version} + LSMinimumSystemVersion15.0 + LSUIElement + NSHighResolutionCapable + + +`, + ); + // Binaries carry an @loader_path/../Frameworks rpath for any Swift libraries the deployment target needs. + run([ + "xcrun", + "swift-stdlib-tool", + "--copy", + "--scan-folder", + bin, + "--platform", + "macosx", + "--destination", + join(app, "Contents", "Frameworks"), + ]); + return app; +} + +/** Signs inside out; the client's exact identifier is what the runtime admits. */ +export function sign(app: string, identity: string, release: boolean) { + const codesign = ["codesign", "--force", "--sign", identity, ...(release ? ["--options", "runtime", "--timestamp"] : [])]; + const frameworks = join(app, "Contents", "Frameworks"); + for (const name of readdirSync(frameworks, { withFileTypes: true }).filter((entry) => entry.isFile())) { + run([...codesign, join(frameworks, name.name)]); + } + const bin = join(app, "Contents", "MacOS"); + run([...codesign, join(bin, "libDesktopTools.dylib")]); + run([...codesign, "--identifier", `${identifier}.desktop-client`, join(bin, "desktop-tools-client")]); + run([...codesign, app]); + run(["codesign", "--verify", "--deep", "--strict", app]); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const { values } = parseArgs({ + options: { + out: { type: "string" }, + scratch: { type: "string" }, + identity: { type: "string" }, + release: { type: "boolean", default: false }, + }, + strict: true, + }); + if (!values.out) { + throw new Error("Usage: desktop-tools-app --out [--scratch ] [--identity [--release]]"); + } + const app = assemble(resolve(values.out), values.scratch && resolve(values.scratch)); + if (values.identity) sign(app, values.identity, values.release); + console.log(app); +} diff --git a/src/build.ts b/src/build.ts index 7ebb553..fdee0b6 100644 --- a/src/build.ts +++ b/src/build.ts @@ -133,7 +133,7 @@ const location = spawn([...build, "--show-bin-path"]); if (location.status !== 0) throw new Error("Cannot locate the native build products"); const products = location.stdout.trim(); mkdirSync(out, { recursive: true }); -for (const name of ["libDesktopTools.dylib", "desktop-tools-client"]) { +for (const name of ["libDesktopTools.dylib", "desktop-tools-client", "desktop-tools-app"]) { copyFileSync(join(products, name), join(out, name)); }