Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions dist/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
#!/usr/bin/env node
export declare const identifier = "dev.githubnext.desktop-tools";
/** Builds the native products into `<out>/build` and assembles an unsigned `<out>/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. */
/** Development builds get their own identity, nested under the release one, so they never replace its binaries. */
export declare const devIdentifier = "dev.githubnext.desktop-tools.dev";
/** Builds the native products into `<out>/build` and assembles an unsigned `<out>/Desktop Tools.app`, or `Desktop Tools Dev.app` for `dev`. */
export declare function assemble(out: string, scratch?: string, dev?: boolean): string;
/** Signs inside out; the client's exact identifier, the bundle's plus `.desktop-client`, is what the runtime admits. */
export declare function sign(app: string, identity: string, release: boolean): void;
23 changes: 15 additions & 8 deletions dist/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,20 @@ import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
export const identifier = "dev.githubnext.desktop-tools";
/** Development builds get their own identity, nested under the release one, so they never replace its binaries. */
export const devIdentifier = `${identifier}.dev`;
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 `<out>/build` and assembles an unsigned `<out>/Desktop Tools.app`. */
export function assemble(out, scratch) {
/** Builds the native products into `<out>/build` and assembles an unsigned `<out>/Desktop Tools.app`, or `Desktop Tools Dev.app` for `dev`. */
export function assemble(out, scratch, dev = false) {
const build = join(out, "build");
run(["node", join(root, "dist", "build.js"), "--out", build, ...(scratch ? ["--scratch", scratch] : [])]);
const app = join(out, "Desktop Tools.app");
const name = dev ? "Desktop Tools Dev" : "Desktop Tools";
const app = join(out, `${name}.app`);
rmSync(app, { recursive: true, force: true });
const bin = join(app, "Contents", "MacOS");
mkdirSync(bin, { recursive: true });
Expand All @@ -31,9 +34,9 @@ export function assemble(out, scratch) {
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key><string>${identifier}</string>
<key>CFBundleName</key><string>Desktop Tools</string>
<key>CFBundleDisplayName</key><string>Desktop Tools</string>
<key>CFBundleIdentifier</key><string>${dev ? devIdentifier : identifier}</string>
<key>CFBundleName</key><string>${name}</string>
<key>CFBundleDisplayName</key><string>${name}</string>
<key>CFBundleExecutable</key><string>desktop-tools-app</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleShortVersionString</key><string>${version}</string>
Expand All @@ -58,16 +61,20 @@ export function assemble(out, scratch) {
]);
return app;
}
/** Signs inside out; the client's exact identifier is what the runtime admits. */
/** Signs inside out; the client's exact identifier, the bundle's plus `.desktop-client`, is what the runtime admits. */
export function sign(app, identity, release) {
const plist = join(app, "Contents", "Info.plist");
const bundle = spawnSync("plutil", ["-extract", "CFBundleIdentifier", "raw", "-o", "-", plist], { encoding: "utf8" }).stdout.trim();
if (!bundle)
throw new Error(`No CFBundleIdentifier in ${plist}`);
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, "--identifier", `${bundle}.desktop-client`, join(bin, "desktop-tools-client")]);
run([...codesign, app]);
run(["codesign", "--verify", "--deep", "--strict", app]);
}
Expand Down
11 changes: 11 additions & 0 deletions dist/dev-app.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env node
/** Every checkout and worktree installs here, so macOS sees one development app, never scattered copies. */
export declare const devApp: string;
/** The app's own default when `DESKTOP_TOOLS_STATE` is unset. */
export declare const devState: string;
/** The one signing identity for development builds: the environment, else the repository's Git setting. */
export declare function devIdentity(): string;
/** Stops the development app, waiting up to five seconds. */
export declare function stop(): number[];
/** Opens the installed development app through LaunchServices, so macOS attributes its permission checks to it. */
export declare function launch(): string;
98 changes: 98 additions & 0 deletions dist/dev-app.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env node
// Builds the development app with its own identity, installs it at one fixed path, and opens it through LaunchServices.
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { assemble, sign } from "./app.js";
const root = dirname(dirname(fileURLToPath(import.meta.url)));
/** Every checkout and worktree installs here, so macOS sees one development app, never scattered copies. */
export const devApp = join(homedir(), "Library", "Application Support", "desktop-tools", "dev", "Desktop Tools Dev.app");
/** The app's own default when `DESKTOP_TOOLS_STATE` is unset. */
export const devState = join(homedir(), ".local", "state", "desktop-tools-dev");
/** The one signing identity for development builds: the environment, else the repository's Git setting. */
export function devIdentity() {
const fromEnv = process.env.DESKTOP_TOOLS_CODESIGN_IDENTITY?.trim();
const identity = fromEnv || spawnSync("git", ["config", "--get", "desktopTools.codesignIdentity"], { cwd: root, encoding: "utf8" }).stdout.trim();
if (!identity) {
throw new Error("No signing identity. Set one per Mac with `git config desktopTools.codesignIdentity <SHA-1>` " +
"(from `security find-identity -v -p codesigning`), or set DESKTOP_TOOLS_CODESIGN_IDENTITY.");
}
if (identity === "-")
throw new Error("Development builds refuse ad-hoc signing: it can't keep macOS permissions across builds.");
return identity;
}
/** Process IDs of the development app running from the fixed path. */
function running() {
const executable = join(devApp, "Contents", "MacOS", "desktop-tools-app");
const listing = spawnSync("ps", ["-axo", "pid=,command="], { encoding: "utf8" }).stdout;
return listing
.split("\n")
.map((line) => line.trim().match(/^(\d+) (.*)$/))
.filter((match) => match !== null && match[2] === executable)
.map((match) => Number(match[1]));
}
/** Stops the development app, waiting up to five seconds. */
export function stop() {
const pids = running();
for (const pid of pids)
process.kill(pid, "SIGTERM");
const deadline = Date.now() + 5000;
while (running().length > 0) {
if (Date.now() > deadline)
throw new Error(`The development app (${pids.join(", ")}) didn't quit within 5 seconds.`);
spawnSync("sleep", ["0.2"]);
}
return pids;
}
/** Opens the installed development app through LaunchServices, so macOS attributes its permission checks to it. */
export function launch() {
const state = process.env.DESKTOP_TOOLS_STATE;
const started = Date.now();
const result = spawnSync("open", ["-g", ...(state ? ["--env", `DESKTOP_TOOLS_STATE=${state}`] : []), devApp], { stdio: "inherit" });
if (result.status !== 0)
throw new Error(`Couldn't open ${devApp}`);
// A socket left by an earlier run doesn't count; wait for this run's.
const socket = join(state || devState, "desktop.sock");
const fresh = () => existsSync(socket) && statSync(socket).mtimeMs >= started - 1000 && running().length > 0;
const deadline = started + 10_000;
while (!fresh()) {
if (Date.now() > deadline)
throw new Error(`The development app didn't create ${socket} within 10 seconds.`);
spawnSync("sleep", ["0.2"]);
}
return state || devState;
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const { values } = parseArgs({
options: { stop: { type: "boolean", default: false }, scratch: { type: "string" }, "no-open": { type: "boolean", default: false } },
strict: true,
});
if (values.stop) {
const pids = stop();
console.log(pids.length ? `Quit the development app (${pids.join(", ")}).` : "The development app isn't running.");
}
else {
const identity = devIdentity();
const staging = join(dirname(devApp), `staging-${process.pid}`);
rmSync(staging, { recursive: true, force: true });
mkdirSync(staging, { recursive: true });
try {
const built = assemble(staging, values.scratch && resolve(values.scratch), true);
sign(built, identity, false);
stop();
rmSync(devApp, { recursive: true, force: true });
renameSync(built, devApp);
}
finally {
rmSync(staging, { recursive: true, force: true });
}
console.log(devApp);
if (!values["no-open"]) {
const state = launch();
console.log(`Serving ${join(state, "desktop.sock")}. Use it with: DESKTOP_TOOLS_STATE=${state} node dist/mcp.js`);
}
}
}
9 changes: 3 additions & 6 deletions docs/app.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,12 @@ Identical operations in a row fold into one line with a count. Every desktop cha
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.
`assemble(out, scratch?)` and `sign(app, identity, release)` in `dist/app.js` are what the release workflow uses. `assemble(out, scratch, true)` builds the development app instead, and `sign` takes the client's identifier from the bundle's.

## Development

A development build run alongside the installed app needs its own state directory. Otherwise it relinks `~/.local/state/desktop-tools/desktop-client` to its own client; it cannot take over `desktop.sock` while the installed app serves it. Set `DESKTOP_TOOLS_STATE` for both the app and the MCP server:
Run development builds with `npm run dev-app`, described in [Install and build](build.md#development-app). It installs one `Desktop Tools Dev.app` (`dev.githubnext.desktop-tools.dev`) at a fixed path and serves `~/.local/state/desktop-tools-dev/desktop.sock`, so it never relinks the installed app's `~/.local/state/desktop-tools/desktop-client` or takes its socket. Point the MCP server at it:

```sh
open -n --env DESKTOP_TOOLS_STATE=/tmp/desktop-dev "build/Desktop Tools.app"
DESKTOP_TOOLS_STATE=/tmp/desktop-dev node dist/mcp.js
DESKTOP_TOOLS_STATE=~/.local/state/desktop-tools-dev node dist/mcp.js
```

The app then serves `/tmp/desktop-dev/desktop.sock` and links its client there, and the MCP server uses both by default. A build signed with the same identity and identifier as the installed app shares its macOS grants.
23 changes: 23 additions & 0 deletions docs/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,29 @@ The build does not sign or stage the Swift runtime. Embedding apps own both. If

CI checks that `dist/` matches a fresh compile and that the modules import on Linux. It also builds the native artifacts, unsigned, on macOS with Xcode 26.2.

## Development app

Test native changes in the real app with:

```sh
npm run dev-app # build, sign, install and open
npm run dev-app -- --stop # quit it
```

Every checkout and worktree installs the same `~/Library/Application Support/desktop-tools/dev/Desktop Tools Dev.app`. It has its own bundle identity, `dev.githubnext.desktop-tools.dev`, nested under the release app's, and the name **Desktop Tools Dev**. Building quits the running copy, replaces the bundle in place and opens it again through LaunchServices with `open`. Launching its executable directly can attribute permission checks to the launching terminal or agent instead of the app.

This exists because macOS ties permissions to the app. The Accessibility, Screen Recording and Keyboard input grants belong to a bundle identity and its signing requirement. Screen capture also needs a separate, dated approval to "bypass the system private window picker", which macOS appears to ask for again for each copy of an app that captures, even when the grant is on. Development builds therefore never use the release identity, which would swap the installed app's binaries under its grants, and never run from scattered build folders, each of which would ask again. Grant **Desktop Tools Dev** once in System Settings, and approve its capture prompt once; rebuilds keep both.

The app serves `~/.local/state/desktop-tools-dev/desktop.sock`, the default for the development identity, or `DESKTOP_TOOLS_STATE` when set at build time, which is passed with `open --env`. Point the MCP server or `npm run live-check` at the same folder with `DESKTOP_TOOLS_STATE`. Its approved agents are kept in that folder too, so the development app asks about each agent once, separately from the installed app.

Every build is signed with one identity, so the designated requirement stays the same across rebuilds: `DESKTOP_TOOLS_CODESIGN_IDENTITY` when set, otherwise the repository's Git setting `desktopTools.codesignIdentity`. Set the Git value once per Mac to the SHA-1 of an Apple Development or Developer ID identity from `security find-identity -v -p codesigning`, since several certificates can share a name:

```sh
git config desktopTools.codesignIdentity <SHA-1>
```

It lives in the repository's `.git/config`, which all worktrees share, and is never committed. Ad-hoc signing (`-`) is refused, because it can't keep permissions across builds. Builds use `native/.build` as scratch unless you pass `--scratch <directory>`; give concurrent builds their own. Don't install a development build in `/Applications`.

## Releases

Each change that users would notice adds a bullet under `## [Unreleased]` in [CHANGELOG.md](https://github.com/githubnext/desktop-tools/blob/main/CHANGELOG.md), under Added, Changed or Fixed, with its PR link. To cut a release:
Expand Down
5 changes: 3 additions & 2 deletions docs/live-checks.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ The live checks in [#42](https://github.com/githubnext/desktop-tools/issues/42)

```sh
npm run live-check # the installed app
DESKTOP_TOOLS_STATE=/tmp/dev npm run live-check -- --only 82,88
npm run dev-app # build and open the development app (see build.md)
DESKTOP_TOOLS_STATE=~/.local/state/desktop-tools-dev npm run live-check -- --only 82,88
npm run live-check -- --list # list the checks without running them
npm run live-check -- --json /tmp/live.json
```

It drives the installed Desktop Tools, or the dev app whose state folder `DESKTOP_TOOLS_STATE` names. Each check prints `PASS`, `FAIL` or `SKIP` with its evidence, and a summary groups them by PR. The exit status is 1 when any check fails, and 2 when nothing could run.
It drives the installed Desktop Tools, or the dev app whose state folder `DESKTOP_TOOLS_STATE` names. Use `npm run dev-app` for the dev app, never a copy launched from a build folder. Each check prints `PASS`, `FAIL` or `SKIP` with its evidence, and a summary groups them by PR. The exit status is 1 when any check fails, and 2 when nothing could run.

What it touches:

Expand Down
5 changes: 4 additions & 1 deletion native/sources/app/app.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,12 @@ private final class Host: NSObject, NSApplicationDelegate, NSMenuDelegate {
let bin = Bundle.main.executableURL!.deletingLastPathComponent()
runtime = Runtime(bin.appendingPathComponent("libDesktopTools.dylib").path)
// A development build takes another state directory so it never relinks the installed app's client.
// One with the development identity defaults to its own, even when opened without the variable.
let state = ProcessInfo.processInfo.environment["DESKTOP_TOOLS_STATE"] ?? ""
let dev = Bundle.main.bundleIdentifier!.hasSuffix(".dev")
let home = state.isEmpty
? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/state/desktop-tools")
? FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(dev ? ".local/state/desktop-tools-dev" : ".local/state/desktop-tools")
: URL(fileURLWithPath: state)
try! FileManager.default.createDirectory(
at: home, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"check": "rm -rf .check && tsc --outDir .check && diff -r .check dist && rm -rf .check",
"build:native": "node dist/build.js --out build",
"build:app": "node dist/app.js --out build",
"dev-app": "node dist/dev-app.js",
"live-check": "node scripts/live-check.mjs",
"peekaboo-test": "node scripts/peekaboo-test.mjs"
},
Expand Down
4 changes: 2 additions & 2 deletions scripts/live-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ const { values } = parseArgs({
if (values.help) {
console.log(`Usage: npm run live-check -- [--only <pr>[,<pr>]] [--list] [--json <path>] [--keep-fixtures]

Drives the installed Desktop Tools, or the dev app whose state folder is DESKTOP_TOOLS_STATE,
through dist/mcp.js. Refuses to run while the screen is locked. See docs/live-checks.md.`);
Drives the installed Desktop Tools, or the dev app whose state folder is DESKTOP_TOOLS_STATE
(~/.local/state/desktop-tools-dev for npm run dev-app), through dist/mcp.js. Refuses to run while the screen is locked. See docs/live-checks.md.`);
process.exit(0);
}
const only = new Set((values.only ?? []).flatMap((v) => v.split(",")).map((v) => v.trim().replace(/^#/, "")).filter(Boolean));
Expand Down
Loading
Loading