blob: 55cf6c804d2ce31d2aed289b5ac650bb80918a09 [file]
/**
* macOS development app for OS-permission work.
*
* macOS TCC will not keep an Accessibility or Screen Recording grant for an
* unsigned executable launched from a terminal. It needs a stable bundle
* identity, a verifiable signature, and a responsible process that is the app
* bundle itself. That is what this module builds: an ad-hoc-signed
* `Maka Dev.app` launched through LaunchServices.
*
* The bundle is a copy of the npm Electron app with its identity rewritten and
* a small bootstrap injected. Everything the bootstrap needs is a build-time
* constant, so a launch with no arguments and no environment — the Dock,
* Spotlight, or the system's Screen Recording "Quit & Reopen" — reproduces a
* correct app. That is why there is no session protocol, pid file, or
* supervisor here: the app instance and the dev session are separate
* lifecycles, and nothing about the app's correctness depends on who started
* it.
*
* `app.isPackaged` is native and computed as
* `basename(process.execPath) !== 'electron'`, so the invariant that keeps
* every dev-mode gate alive is simply that the inner executable stays named
* `Electron` — NOT the payload's location. (It is not derived from
* `process.defaultApp`; that flag is set by the stock default_app this module
* replaces, and is therefore undefined here.)
*
* The payload occupies `default_app.asar` rather than `Resources/app` so it
* cannot shadow a real packaged app laid down at the standard location.
*/
import { spawn, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..');
const DEV_RUNTIME_DIR = join(DESKTOP_DIR, '.maka-dev');
const STAGING_DIR = join(DESKTOP_DIR, '.maka-dev.staging');
const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app');
const STAGING_APP = join(STAGING_DIR, 'Maka Dev.app');
const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron');
const DEV_ENV_FILE = join(DEV_RUNTIME_DIR, 'dev-env.json');
const MARKER = join(DEV_RUNTIME_DIR, 'runtime.json');
const ELECTRON_PACKAGE = join(REPO_ROOT, 'node_modules', 'electron', 'package.json');
const SOURCE_APP = join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'Electron.app');
const WORKTREE_ID = createHash('sha256').update(REPO_ROOT).digest('hex').slice(0, 12);
const DEV_USER_DATA_DIR = join(
homedir(),
'Library',
'Application Support',
`Maka Dev-${WORKTREE_ID}`,
);
/**
* Per-worktree, and deliberately so. An ad-hoc signature designates a bare
* `cdhash`, and TCC keys its rows on the bundle identifier — so a shared
* identifier would make every worktree overwrite the previous one's stored
* requirement and silently break it. Scoping the identifier gives each worktree
* its own durable, independently revocable grant.
*
* There is no explicit `designated => identifier` requirement here. It would
* survive rebuilds, but it is forgeable by construction: `codesign --sign -` is
* available to every unprivileged process, so any binary anywhere on the disk
* can claim this identifier and satisfy the requirement. TCC rows outlive the
* code they were granted to, so that would leave a permanently redeemable
* Accessibility and Screen Recording token behind even after this repository is
* deleted. The cdhash requirement costs a re-grant when the bundle is rebuilt —
* which happens only on an Electron bump or a repository move, not on an
* ordinary `npm run dev` — and that is the cheaper side of the trade.
*/
const DEV_BUNDLE_ID = `com.maka.dev.${WORKTREE_ID}`;
const RUNTIME_SCHEMA_VERSION = 7;
const DEV_ENV_SCHEMA_VERSION = 1;
export const developmentAppPath = DEV_APP;
export const developmentExecutablePath = DEV_EXECUTABLE;
export async function resolveMacosDevelopmentLaunch(env = process.env) {
if (!shouldUseMacosDevelopmentApp(process.platform, env)) return null;
const appPath = await prepareDevelopmentApp();
// A leftover app would absorb this launch through the single-instance lock.
await ensureNoRunningDevelopmentApp();
return createMacosDevelopmentLaunch(appPath, developmentLogFile);
}
/**
* The signed-bundle workflow exists only so TCC grants survive development.
* It costs a codesign rebuild and a LaunchServices handoff that detaches the
* app from the terminal's stdio, so `npm run dev` no longer prints main-process
* logs. Developers who are not working on OS permissions should not pay that,
* so it stays opt-in.
*/
export function shouldUseMacosDevelopmentApp(platform, env = process.env) {
if (platform !== 'darwin') return false;
const optIn = env.MAKA_DEV_TCC?.trim().toLowerCase();
return optIn === '1' || optIn === 'true';
}
export function createMacosDevelopmentLaunch(appPath, logFile) {
// LaunchServices must own the launch so macOS TCC attributes the running
// executable to Maka Dev rather than to its parent terminal.
const args = ['-n', '-a', appPath];
// Without this the detached app's output only reaches Console.app, which is
// also where a bootstrap or boot failure would go silent.
if (logFile) args.push('--stdout', logFile, '--stderr', logFile);
return { command: 'open', args };
}
export const developmentLogFile = join(DEV_RUNTIME_DIR, 'app.log');
/**
* `pkill -f` / `pgrep -f` match an EXTENDED REGEX against the whole command
* line, so a path is not a literal. A repository under `~/Dropbox (Personal)`
* would otherwise match nothing — leaving the app un-killable — and an
* unbalanced `[` makes the pattern fail to compile entirely.
*/
export function toProcessMatchPattern(executable) {
return executable.replace(/[.[\]{}()*+?^$|\\]/g, '\\$&');
}
/**
* Terminates this worktree's development app. The bundle path is unique per
* worktree, so matching on it is precise without tracking a pid: concurrent
* worktrees own different bundles and are unaffected.
*/
export async function quitMacosDevelopmentApp(options = {}) {
const platform = options.platform ?? process.platform;
const executable = options.executable ?? DEV_EXECUTABLE;
const graceMs = options.graceMs ?? 3_000;
const signal = options.signal ?? sendSignalToExecutable;
const delay = options.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
if (platform !== 'darwin') return false;
if (!signal('TERM', executable)) return false;
// Main-process cleanup runs on before-quit and can outlive a plain SIGTERM.
await delay(graceMs);
signal('KILL', executable);
return true;
}
function sendSignalToExecutable(name, executable) {
const status = spawnSync('pkill', [`-${name}`, '-f', toProcessMatchPattern(executable)]).status;
// 0 = signalled, 1 = no match. Anything else is a usage or pattern error and
// must not be read as "nothing was running".
if (status !== 0 && status !== 1) {
throw new Error(`pkill -${name} failed for ${executable} (exit ${status})`);
}
return status === 0;
}
export function isDevelopmentAppRunning(options = {}) {
const executable = options.executable ?? DEV_EXECUTABLE;
const probe = options.probe ?? defaultLivenessProbe;
return probe(executable);
}
function defaultLivenessProbe(executable) {
const status = spawnSync('pgrep', ['-f', toProcessMatchPattern(executable)]).status;
if (status !== 0 && status !== 1) {
throw new Error(`pgrep failed for ${executable} (exit ${status})`);
}
return status === 0;
}
/**
* Takes ownership of this worktree's app instance before launching or
* rebuilding. Electron's single-instance lock is keyed on userData, so an app
* left over from a hard-killed session would absorb the new launch: the new
* process exits 0, the OLD window is raised, and it stays pointed at a dead
* Vite URL while a liveness probe still reports success.
*/
export async function ensureNoRunningDevelopmentApp(options = {}) {
const running = options.isRunning ?? isDevelopmentAppRunning;
const quit = options.quit ?? quitMacosDevelopmentApp;
const delay = options.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
const attempts = options.attempts ?? 10;
const pollMs = options.pollMs ?? 200;
// Forward each callee only what it accepts. Passing this whole object through
// would make `delay` double as the SIGTERM grace period, and would let a
// caller that stubs `probe` still fall through to a real `pkill`.
const liveness = { executable: options.executable, probe: options.probe };
const shutdown = {
platform: options.platform,
executable: options.executable,
graceMs: options.graceMs,
signal: options.signal,
};
if (!running(liveness)) return false;
await quit(shutdown);
for (let attempt = 0; attempt < attempts; attempt += 1) {
if (!running(liveness)) return true;
await delay(pollMs);
}
throw new Error(
'A previous Maka Dev.app is still running and could not be stopped; quit it manually (Cmd-Q) and retry',
);
}
/**
* Application-control variables to persist for the app.
*
* `open` itself does forward the parent environment, so this file is not what
* makes `npm run dev` work. It exists for the launches that have no parent
* shell at all — Dock, Spotlight, and Screen Recording's "Quit & Reopen" —
* which is exactly the path the whole design has to survive.
*
* That is also why the list is curated rather than a copy of `process.env`:
* this content is written to disk and outlives the session. PATH is excluded
* because a recorded PATH goes stale, and `shell-env.ts` resolves the
* login-shell PATH in the main process for precisely the GUI-launch case.
*/
export function selectDevelopmentEnvironment(env, viteUrl) {
const selected = {};
for (const [key, value] of Object.entries(env)) {
if (typeof value !== 'string') continue;
if (isForwardedEnvironmentKey(key)) selected[key] = value;
}
if (viteUrl) selected.VITE_DEV_SERVER_URL = viteUrl;
return selected;
}
function isForwardedEnvironmentKey(key) {
return (
key.startsWith('MAKA_') ||
key.startsWith('CUA_') ||
[
'ANTHROPIC_API_KEY',
'OPENAI_API_KEY',
'DEEPSEEK_API_KEY',
'TAVILY_API_KEY',
'COPILOT_GITHUB_TOKEN',
'GH_TOKEN',
'GITHUB_TOKEN',
'RIVE_BIN',
'HTTP_PROXY',
'HTTPS_PROXY',
'ALL_PROXY',
'NO_PROXY',
'PYTHONPATH',
'NODE_ENV',
'NO_COLOR',
].includes(key)
);
}
/**
* `--user-data-dir` cannot ride along in `electronArgs`: the bootstrap calls
* `app.setPath('userData', …)`, which overrides the switch. It has to be
* separated so the bootstrap can honour it as a value.
*/
export function splitDevelopmentCliArgs(argv = []) {
const flag = '--user-data-dir=';
return {
userDataDir: argv.find((argument) => argument.startsWith(flag))?.slice(flag.length),
electronArgs: argv.filter((argument) => !argument.startsWith(flag)),
};
}
export function createDevelopmentEnvironmentFile(input) {
const { userDataDir, electronArgs } = splitDevelopmentCliArgs(input.argv);
return {
schemaVersion: DEV_ENV_SCHEMA_VERSION,
env: selectDevelopmentEnvironment(input.env, input.viteUrl),
userDataDir,
electronArgs,
};
}
/**
* Publishes the environment the app should adopt. This is plain data with no
* owning process: a relaunch minutes later reads the same file and is just as
* correct, which is what removes the need for session supervision.
*/
export function writeDevelopmentEnvironment(content, options = {}) {
const file = options.file ?? DEV_ENV_FILE;
mkdirSync(join(file, '..'), { recursive: true });
const temporary = `${file}.tmp-${process.pid}`;
writeFileSync(temporary, `${JSON.stringify(content, null, 2)}\n`, { mode: 0o600 });
renameSync(temporary, file);
}
/**
* The Vite URL a previous launcher published, if any.
*
* `npm start` does not run a dev server, but it may be reclaiming an app from a
* live `npm run dev`. Republishing without the URL would drop the app to the
* prebuilt renderer on disk — stale, or absent entirely on a fresh checkout.
*/
export function readPublishedViteUrl(file = DEV_ENV_FILE) {
try {
const published = JSON.parse(readFileSync(file, 'utf8'));
if (published.schemaVersion !== DEV_ENV_SCHEMA_VERSION) return undefined;
return published.env?.VITE_DEV_SERVER_URL;
} catch {
return undefined;
}
}
function resolveElectronBinary() {
for (let dir = DESKTOP_DIR; ; dir = dirname(dir)) {
const executable =
process.platform === 'win32'
? join(dir, 'node_modules', 'electron', 'dist', 'electron.exe')
: join(dir, 'node_modules', '.bin', 'electron');
if (existsSync(executable)) return executable;
if (dirname(dir) === dir) return 'electron';
}
}
/**
* The single way to start the app, on every platform and both entry points.
*
* `npm run dev` and `npm start` previously each carried their own copy of
* this — publish the environment, launch, follow the log, shut down — and the
* copies drifted into different bugs rather than different behaviour.
*
* Returns a handle rather than a child process because the two paths are not
* comparable: on the macOS bundle path `open` exits at the LaunchServices
* handoff, so its exit code says nothing about the app.
*/
export async function startDevelopmentApp(options = {}) {
const argv = options.argv ?? [];
// Read before preparing: a rebuild republishes the runtime directory and
// takes the previous environment file with it.
const viteUrl = options.viteUrl ?? readPublishedViteUrl();
const launch = await resolveMacosDevelopmentLaunch();
if (!launch) {
const child = spawn(resolveElectronBinary(), [DESKTOP_DIR, ...argv], {
cwd: DESKTOP_DIR,
stdio: 'inherit',
env: viteUrl ? { ...process.env, VITE_DEV_SERVER_URL: viteUrl } : process.env,
});
return { child, isMacosBundle: false, stop: () => terminateProcessTree(child) };
}
writeDevelopmentEnvironment(
createDevelopmentEnvironmentFile({ argv, env: process.env, viteUrl }),
);
// LaunchServices detaches the app from this terminal's stdio, so `open`
// redirects its output here and we follow it. The log carries whatever the
// app prints, so it gets the same mode as the environment file beside it.
writeFileSync(developmentLogFile, '', { mode: 0o600 });
const logStream = spawn('tail', ['-n', '+1', '-F', developmentLogFile], {
stdio: ['ignore', 'inherit', 'inherit'],
});
const child = spawn(launch.command, launch.args, {
cwd: DESKTOP_DIR,
stdio: 'inherit',
env: process.env,
});
return {
child,
isMacosBundle: true,
stop: async () => {
logStream.kill();
await quitMacosDevelopmentApp();
},
};
}
function terminateProcessTree(child) {
if (child.exitCode !== null || child.killed) return Promise.resolve();
if (process.platform === 'win32' && child.pid) {
return new Promise((done) => {
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], {
stdio: ['ignore', 'ignore', 'ignore'],
});
killer.on('exit', () => done());
killer.on('error', () => done());
});
}
child.kill('SIGTERM');
return Promise.resolve();
}
/**
* Watches the detached bundle for the whole session, because `open` exits 0 at
* the handoff and reports nothing afterwards.
*
* Waiting a fixed interval and checking once cannot work in either direction: a
* first launch of the freshly signed bundle can still be starting, and quitting
* the app is a normal thing to do at any moment. So this waits for the app to
* appear, then reports the eventual disappearance as an ordinary session end.
*/
export async function monitorDevelopmentApp(options = {}) {
const isRunning = options.isRunning ?? isDevelopmentAppRunning;
const delay = options.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
const stopped = options.stopped ?? (() => false);
const pollMs = options.pollMs ?? 250;
const startupAttempts = options.startupAttempts ?? 120;
let appeared = false;
for (let attempt = 0; attempt < startupAttempts && !appeared; attempt += 1) {
if (stopped()) return 'stopped';
if (isRunning()) appeared = true;
else await delay(pollMs);
}
if (!appeared) return 'never-started';
while (!stopped()) {
await delay(pollMs);
if (stopped()) break;
if (!isRunning()) return 'exited';
}
return 'stopped';
}
export async function prepareDevelopmentApp() {
if (process.platform !== 'darwin') {
throw new Error('Maka Dev.app is only available on macOS');
}
if (!existsSync(SOURCE_APP)) {
throw new Error(`Electron.app is missing at ${SOURCE_APP}; run npm install first`);
}
const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version;
if (isCurrentRuntime(electronVersion)) return DEV_APP;
// Rebuilding unlinks the bundle an already-running app was launched from,
// and deletes the environment file it would read on relaunch.
await ensureNoRunningDevelopmentApp();
try {
await rebuildDevelopmentRuntime({
reset: () => {
rmSync(STAGING_DIR, { recursive: true, force: true });
mkdirSync(STAGING_DIR, { recursive: true });
},
build: () => {
run('ditto', [SOURCE_APP, STAGING_APP]);
const plist = join(STAGING_APP, 'Contents', 'Info.plist');
setPlistString(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID);
setPlistString(plist, 'CFBundleName', 'Maka Dev');
setPlistString(plist, 'CFBundleDisplayName', 'Maka Dev');
// Stock Electron seals a SHA256 of its own default_app.asar here. We
// replace that payload, so the record would describe a file that no
// longer exists — inert today only because the integrity fuse is off.
spawnSync('plutil', ['-remove', 'ElectronAsarIntegrity', plist]);
installBootstrap(STAGING_APP);
// `ditto` preserves quarantine by default (`--qtn`), so clear it from
// the whole tree rather than the bundle root alone.
run('xattr', ['-cr', STAGING_APP]);
// No --identifier: `plutil` already set CFBundleIdentifier, and
// codesign derives it per bundle. Passing it with --deep would stamp
// the outer app's identifier onto all four nested helper bundles,
// whose own identifier is com.github.Electron.helper.
//
// `--deep` is load-bearing rather than gratuitous here: the npm source
// is linker-signed with no bundle seal, so without it the result does
// not verify at all. Apple's deprecation concerns distribution
// signing, where --deep discards per-target entitlements; this build
// has none.
run('codesign', ['--force', '--deep', '--sign', '-', STAGING_APP]);
run('codesign', ['--verify', '--deep', '--strict', STAGING_APP]);
// Publish by rename so a launcher never observes a partial bundle.
// This does not make concurrent *builders* safe — they share one
// staging path — but two rebuilds in one worktree is not a case worth
// locking for.
rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true });
renameSync(STAGING_DIR, DEV_RUNTIME_DIR);
},
writeMarker: () => {
writeFileSync(
MARKER,
`${JSON.stringify(createRuntimeMarker(electronVersion), null, 2)}\n`,
);
},
});
} finally {
// A failed build otherwise leaves a ~250 MB copy of Electron.app behind
// with no message. Publishing already moved the directory away.
rmSync(STAGING_DIR, { recursive: true, force: true });
}
return DEV_APP;
}
/**
* The cache key this build commits, and the one `isDevelopmentRuntimeCurrent`
* validates. Both sides must read from here: a field renamed on one side alone
* would silently force a rebuild on every launch, churning the very bundle the
* TCC grant is anchored to.
*/
export function createRuntimeMarker(electronVersion) {
return {
schemaVersion: RUNTIME_SCHEMA_VERSION,
electronVersion,
bundleId: DEV_BUNDLE_ID,
desktopDir: DESKTOP_DIR,
};
}
/**
* Writes the payload as a plain directory named `default_app.asar`. Node
* resolves it as an ordinary directory, so no archive step or asar dependency
* is needed to occupy the path Electron looks for.
*/
export function installBootstrap(appPath = DEV_APP) {
const payload = join(appPath, 'Contents', 'Resources', 'default_app.asar');
rmSync(payload, { recursive: true, force: true });
mkdirSync(payload, { recursive: true });
writeFileSync(
join(payload, 'package.json'),
`${JSON.stringify({ name: 'maka-dev', main: 'main.cjs', private: true }, null, 2)}\n`,
);
writeFileSync(
join(payload, 'main.cjs'),
createBootstrapSource(DESKTOP_DIR, DEV_USER_DATA_DIR, DEV_ENV_FILE),
);
}
/**
* Every value here is fixed at build time, which keeps the bundle's cdhash
* stable across rebuilds — a rebuild does not invalidate an existing TCC grant.
* The environment file is read opportunistically: if it is missing or stale the
* app still starts, just without a dev server URL.
*/
export function createBootstrapSource(desktopDir, defaultUserDataDir, envFile) {
return [
"const { app } = require('electron');",
"const { join } = require('node:path');",
"const { pathToFileURL } = require('node:url');",
`const desktopDir = ${JSON.stringify(desktopDir)};`,
'let devEnv = null;',
'try {',
` const candidate = JSON.parse(require('node:fs').readFileSync(${JSON.stringify(envFile)}, 'utf8'));`,
` if (candidate.schemaVersion === ${DEV_ENV_SCHEMA_VERSION}) devEnv = candidate;`,
'} catch {}',
'if (devEnv?.env) Object.assign(process.env, devEnv.env);',
'for (const argument of devEnv?.electronArgs || []) {',
' const match = /^--([^=]+)(?:=(.*))?$/.exec(argument);',
' if (match) app.commandLine.appendSwitch(match[1], match[2]);',
'}',
'app.setAppPath(desktopDir);',
`app.setPath('userData', devEnv?.userDataDir || ${JSON.stringify(defaultUserDataDir)});`,
'process.chdir(desktopDir);',
"import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {",
" console.error('[maka-dev] bootstrap failed', error);",
' app.exit(1);',
'});',
'',
].join('\n');
}
function isCurrentRuntime(electronVersion) {
if (!existsSync(DEV_EXECUTABLE) || !existsSync(MARKER)) return false;
try {
const marker = JSON.parse(readFileSync(MARKER, 'utf8'));
// Compare the cheap marker before paying for a full bundle verification.
if (!isDevelopmentRuntimeCurrent(marker, createRuntimeMarker(electronVersion))) return false;
return spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0;
} catch {
return false;
}
}
/**
* Every field the build committed must still hold. Comparing against a marker
* built by `createRuntimeMarker` rather than against a hand-listed set means a
* new cache input is covered the moment it is added there.
*/
export function isDevelopmentRuntimeCurrent(marker, expected) {
if (marker === null || typeof marker !== 'object') return false;
return Object.entries(expected).every(([key, value]) => marker[key] === value);
}
export async function rebuildDevelopmentRuntime(deps) {
await deps.reset();
await deps.build();
// The marker is the cache commit point. Never write it until copying,
// bootstrap generation, signing, and strict verification all succeed.
await deps.writeMarker();
}
function setPlistString(plist, key, value) {
if (spawnSync('plutil', ['-replace', key, '-string', value, plist]).status === 0) return;
run('plutil', ['-insert', key, '-string', value, plist]);
}
function run(command, args) {
const result = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe' });
if (result.status === 0) return;
const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`;
throw new Error(`${command} failed: ${detail}`);
}