From 19061b50c0a03901c8565539961db61ae250bf27 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Sun, 23 Aug 2026 18:18:42 +0800 Subject: [PATCH] refactor(desktop): share the dev userData profile across worktrees Use one Maka Dev profile for plain and TCC development while keeping the TCC bundle identity worktree-scoped. Electron's single-instance lock remains the sole app-instance authority, and a detached TCC launcher consumes only its private one-shot lock verdict. The launcher owns its log, result file, and Vite server, but never the Electron process: Ctrl-C stops launcher resources while the TCC app remains open until the user quits it. Remove winner PID reporting, PID liveness supervision, and TERM/KILL teardown. Preserve legacy profile data with an upgrade warning and fail clearly when another development instance owns the shared profile. Generated-by: Codex --- apps/desktop/README.md | 36 +- apps/desktop/package.json | 2 +- apps/desktop/scripts/dev-app-runtime.mjs | 367 ++++++++++++------ apps/desktop/scripts/dev-app-runtime.test.mjs | 361 +++++++++++++++++ apps/desktop/scripts/dev.mjs | 79 ++-- apps/desktop/scripts/start-dev-app.mjs | 43 +- .../dev-single-instance-result.test.ts | 43 ++ .../src/main/dev-single-instance-result.ts | 43 ++ apps/desktop/src/main/main.ts | 38 +- packages/core/package.json | 3 +- .../src/__tests__/dev-single-instance.test.ts | 51 +++ packages/core/src/dev-single-instance.ts | 67 ++++ 12 files changed, 945 insertions(+), 188 deletions(-) create mode 100644 apps/desktop/scripts/dev-app-runtime.test.mjs create mode 100644 apps/desktop/src/main/__tests__/dev-single-instance-result.test.ts create mode 100644 apps/desktop/src/main/dev-single-instance-result.ts create mode 100644 packages/core/src/__tests__/dev-single-instance.test.ts create mode 100644 packages/core/src/dev-single-instance.ts diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0ef70668ff..6b51badbfc 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -44,14 +44,17 @@ or this repository's path changes; run This workflow is opt-in because it costs a codesign rebuild and puts an extra app in your Dock and in System Settings. Developers who are not touching OS permissions should not pay for it. Main-process logs are still streamed to the -terminal — `open` redirects them to `.maka-dev/app.log`, which the launcher -follows. +terminal. Each TCC launch gets separate private log and result files: logs are +observation only, while the one-shot result carries the single-instance verdict. Everything the bundle needs is fixed when it is built, so a launch with no arguments and no environment — the Dock, Spotlight, or Screen Recording's -“Quit & Reopen” — produces a correct app. There is no session protocol or -supervising process: the app instance and the dev session are separate -lifecycles. Application-control variables (API keys, `MAKA_*`, the Vite URL) +“Quit & Reopen” — produces a correct app. There is no long-lived session +protocol or supervising process: the app instance and the dev session are +separate lifecycles. Ctrl-C stops the launcher and, for `npm run dev`, its Vite +server; it does not quit a TCC app launched through LaunchServices. Quit that +app with Cmd-Q. Likewise, quitting the TCC app does not stop the dev server. +Application-control variables (API keys, `MAKA_*`, the Vite URL) are published to an ignored `0600` file at `.maka-dev/dev-env.json` rather than a command line. That file, not the shell, is what makes a Dock or “Quit & Reopen” launch work, since those have no parent shell at all. `PATH` is not @@ -79,13 +82,22 @@ signature seal. Write access to this repository is therefore a deliberate trust assumption of the development workflow — a separate matter from who may claim the bundle's identity. -The default profile is `~/Library/Application Support/Maka Dev-`, -which keeps development isolated from the packaged Maka profile; an explicit -`--user-data-dir` takes precedence. Shutdown matches this worktree's own bundle -path, so a concurrent worktree's app is unaffected. Because that lock is keyed -on the profile, a launch first reclaims any app left over from a hard-killed -session — otherwise the stale app would absorb the new launch and keep showing -its old, dead Vite URL. +The default profile is `~/Library/Application Support/Maka Dev`, which keeps +development isolated from the packaged Maka profile and is shared by the plain +dev build and the TCC dev build; an explicit `--user-data-dir` takes +precedence. (The README previously also claimed the repository CLI +(`npm run cli:dev`) shares it — unverified; the CLI entry does not go through +the desktop dev launcher, so this claim is dropped until verified.) Electron's +single-instance lock is the only authority for the shared development profile. +A second launch exits with an explicit conflict instead of scanning for or +terminating an existing Electron process. Plain dev owns its direct child; +the TCC launcher consumes only the one-shot lock verdict and never owns or +signals the detached app process. + +Known limitation: Chromium may kill an unresponsive lock holder after its +20-second acknowledgement timeout and let the new instance take the lock. +Sharing the profile widens the launch pairs that can reach this existing +behavior; investigation and evidence live in #3539. Known limitation: `dev-env.json` outlives the session, so launching from the Dock long after `npm run dev` has stopped points the app at a Vite URL that is diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b39e0c2c79..e387cce96e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -32,7 +32,7 @@ "typecheck:stories": "tsc -p tsconfig.storybook.json --noEmit", "pretest": "npm run build:workspace-deps", "test": "npm run clean:main && npm run build:main && node --test \"dist/main/**/*.test.js\"", - "test:dist": "node --test \"dist/main/**/*.test.js\"", + "test:dist": "node --test \"dist/main/**/*.test.js\" scripts/dev-app-runtime.test.mjs", "e2e": "npm run build:with-deps && playwright test --config e2e/playwright.config.ts", "build:with-deps": "npm run build:workspace-deps && npm run build", "smoke:real-window": "npm run build:with-deps && node ../../scripts/desktop-real-window-smoke.mjs", diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index 9eb5181e66..9f6464f8f7 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -30,10 +30,10 @@ * 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. + * correct app. The launcher's one-shot result file reports only the Electron + * lock verdict; it does not supervise the app or carry application state. The + * app instance and the dev session remain separate lifecycles: stopping the + * launcher never terminates the TCC app. * * `app.isPackaged` is native and computed as * `basename(process.execPath) !== 'electron'`, so the invariant that keeps @@ -46,9 +46,16 @@ * 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 { createHash, randomUUID } from 'node:crypto'; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -64,13 +71,10 @@ 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}`, -); - +// Deliberately NOT worktree-scoped, unlike the bundle identifier above: plain +// Electron and the TCC bundle must share the same single-instance authority. +export const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); +const DEV_ENV_SCHEMA_VERSION = 1; /** * 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 @@ -90,17 +94,40 @@ const DEV_USER_DATA_DIR = join( */ 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; -const developmentExecutablePath = DEV_EXECUTABLE; -export async function resolveMacosDevelopmentLaunch(env = process.env) { +async function prepareMacosDevelopmentLaunch(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); + const resultFileArgPrefix = (await devSingleInstanceConstants()) + ?.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX; + if (!resultFileArgPrefix) { + throw new Error('Maka Dev launch-result contract is unavailable; rebuild @maka/core and retry.'); + } + return { appPath, resultFileArgPrefix }; +} + +/** + * The TCC bundle previously wrote per-worktree userData roots + * (`Maka Dev-`); sharing the profile means this launch reads the + * common `Maka Dev` root, so sessions and settings that lived in the legacy + * directory are no longer read. They are NOT deleted — say where they are. + */ +export function warnAboutLegacyTccDataRoot(options = {}) { + if ((options.platform ?? process.platform) !== 'darwin') return; + const effectiveUserDataDir = options.effectiveUserDataDir ?? DEV_USER_DATA_DIR; + const legacy = options.legacyUserDataDir ?? + join(homedir(), 'Library', 'Application Support', `Maka Dev-${WORKTREE_ID}`); + const exists = options.exists ?? existsSync; + const warn = options.warn ?? console.warn; + if (effectiveUserDataDir !== DEV_USER_DATA_DIR || legacy === DEV_USER_DATA_DIR || !exists(legacy)) { + return; + } + warn( + `[maka-dev] shared profile: your earlier TCC data (sessions, settings) lives in ${legacy} ` + + 'and is no longer read. It is not deleted — copy it back if needed, or remove it.', + ); } /** @@ -116,17 +143,38 @@ export function shouldUseMacosDevelopmentApp(platform, env = process.env) { return optIn === '1' || optIn === 'true'; } -export function createMacosDevelopmentLaunch(appPath, logFile) { +export function createMacosDevelopmentLaunch( + appPath, + logFile, + resultFile, + resultFileArgPrefix, +) { // 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); + // The private result path must come LAST: everything after `open --args` is + // passed to the app, and the last launcher-owned value wins over any + // user-supplied lookalike argument. + if (resultFile && resultFileArgPrefix) { + args.push('--args', `${resultFileArgPrefix}${resultFile}`); + } return { command: 'open', args }; } -export const developmentLogFile = join(DEV_RUNTIME_DIR, 'app.log'); +export function createDevelopmentLaunchFiles(options = {}) { + const directory = options.directory ?? join(tmpdir(), 'maka-dev-launches'); + const id = options.id ?? randomUUID(); + const logFile = join(directory, `${id}.log`); + const resultFile = join(directory, `${id}.result.json`); + const mkdir = options.mkdir ?? mkdirSync; + const write = options.write ?? ((target) => writeFileSync(target, '', { mode: 0o600 })); + mkdir(directory, { recursive: true, mode: 0o700 }); + write(logFile); + return { logFile, resultFile }; +} /** * `pkill -f` / `pgrep -f` match an EXTENDED REGEX against the whole command @@ -139,41 +187,48 @@ export function toProcessMatchPattern(executable) { } /** - * 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. + * Electron's lock is authoritative. The plain launcher owns its child directly + * and reads its exit code, while the detached TCC launcher consumes a private, + * one-shot winner/loser result without taking ownership of the app process. + * Constants are imported lazily so this module loads before libs are built. */ -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; +export async function devSingleInstanceConstants(loader = () => import('@maka/core/dev-single-instance')) { + // Explicit failure contract: the import can fail on a fresh clone before + // libs are built. Every consumer must handle undefined explicitly — a + // throw here would crash the plain launcher. One warning, then undefined. + try { + return await loader(); + } catch (error) { + console.warn(`[maka-dev] dev single-instance constants unavailable: ${String(error)}`); + return undefined; + } } -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})`); +export async function readDevelopmentLaunchResult(resultFile, loader) { + const constants = await devSingleInstanceConstants(loader); + if (!constants) return undefined; + try { + return constants.parseDevelopmentLaunchResult(readFileSync(resultFile, 'utf8')); + } catch { + return undefined; } - return status === 0; +} + +/** Exit-code check for the plain child (child is the app process). */ +export async function plainLoserExitCode(code, loader) { + const constants = await devSingleInstanceConstants(loader); + if (!constants) return false; // cannot attribute; do not invent a loser + return code === constants.DEV_LOSER_EXIT_CODE; } export function isDevelopmentAppRunning(options = {}) { - const executable = options.executable ?? DEV_EXECUTABLE; + const bundle = options.executable ?? DEV_EXECUTABLE; const probe = options.probe ?? defaultLivenessProbe; - return probe(executable); + return probe(bundle); } function defaultLivenessProbe(executable) { + if (process.platform === 'win32') return false; const status = spawnSync('pgrep', ['-f', toProcessMatchPattern(executable)]).status; if (status !== 0 && status !== 1) { throw new Error(`pgrep failed for ${executable} (exit ${status})`); @@ -181,38 +236,10 @@ function defaultLivenessProbe(executable) { 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', - ); +export function assertDevelopmentAppNotRunning(options = {}) { + const isRunning = options.isRunning ?? isDevelopmentAppRunning; + if (!isRunning(options)) return; + throw new Error('Maka Dev.app is running. Quit it (Cmd-Q) and retry the rebuild.'); } /** @@ -301,8 +328,8 @@ export function writeDevelopmentEnvironment(content, options = {}) { /** * 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 + * `npm start` does not run a dev server, but it may reuse the URL published by + * the previous `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) { @@ -336,33 +363,65 @@ function resolveElectronBinary() { * 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. + * + * All asynchronous preparation finishes before launch artifacts or processes + * are created. Once `signal` is aborted, this attempt cannot publish either. */ export async function startDevelopmentApp(options = {}) { + const prepareMacosLaunch = options.prepareMacosDevelopmentLaunch ?? prepareMacosDevelopmentLaunch; + const loadSingleInstanceConstants = options.devSingleInstanceConstants ?? devSingleInstanceConstants; + const createLaunchFiles = options.createDevelopmentLaunchFiles ?? createDevelopmentLaunchFiles; + const spawnProcess = options.spawn ?? spawn; + const signal = options.signal; + signal?.throwIfAborted(); const argv = options.argv ?? []; + const { userDataDir } = splitDevelopmentCliArgs(argv); + warnAboutLegacyTccDataRoot({ effectiveUserDataDir: userDataDir ?? DEV_USER_DATA_DIR }); // 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], { + const preparedLaunch = await prepareMacosLaunch(); + signal?.throwIfAborted(); + if (!preparedLaunch) { + const launcherFlag = (await loadSingleInstanceConstants())?.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG; + signal?.throwIfAborted(); + const child = spawnProcess(resolveElectronBinary(), [DESKTOP_DIR, ...argv, ...(launcherFlag ? [launcherFlag] : [])], { cwd: DESKTOP_DIR, stdio: 'inherit', env: viteUrl ? { ...process.env, VITE_DEV_SERVER_URL: viteUrl } : process.env, }); + child.once('exit', (code) => { + void plainLoserExitCode(code).then((loser) => { + if (!loser) return; + console.error( + 'Maka Dev could not start: another instance holds the shared profile lock ' + + `(loser exit code ${code}). Quit it (Cmd-Q) and retry.`, + ); + }); + }); return { child, isMacosBundle: false, stop: () => terminateProcessTree(child) }; } + const files = createLaunchFiles(); + const launch = { + ...createMacosDevelopmentLaunch( + preparedLaunch.appPath, + files.logFile, + files.resultFile, + preparedLaunch.resultFileArgPrefix, + ), + ...files, + }; 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], { + // redirects its output here and we follow it. Logs are observation only; + // the lock verdict travels through launch.resultFile instead. + const logStream = spawnProcess('tail', ['-n', '+1', '-F', launch.logFile], { stdio: ['ignore', 'inherit', 'inherit'], }); - const child = spawn(launch.command, launch.args, { + const child = spawnProcess(launch.command, launch.args, { cwd: DESKTOP_DIR, stdio: 'inherit', env: process.env, @@ -370,13 +429,73 @@ export async function startDevelopmentApp(options = {}) { return { child, isMacosBundle: true, - stop: async () => { - logStream.kill(); - await quitMacosDevelopmentApp(); + logFile: launch.logFile, + resultFile: launch.resultFile, + stop: () => cleanupDevelopmentLaunch({ + logStream, + logFile: launch.logFile, + resultFile: launch.resultFile, + }), + }; +} + +/** + * Owns the dev launch from process signal through final resource cleanup. + * Keeping this coupling here makes the cancellation wire itself testable: a + * signal aborts the same attempt `start()` created, then joins and stops any + * handle that attempt was already publishing. + */ +export function createDevelopmentLaunchSession(options = {}) { + const startApp = options.startDevelopmentApp ?? startDevelopmentApp; + const signals = options.signals ?? process; + const close = options.close ?? (() => {}); + const exit = options.exit ?? ((code) => process.exit(code)); + const controller = new AbortController(); + let launchPromise = null; + let stopPromise = null; + + const stop = (code = 0) => { + if (stopPromise) return stopPromise; + controller.abort(); + stopPromise = (async () => { + const app = await launchPromise?.catch(() => null); + await app?.stop(); + try { + await close(); + } catch { + // Shutdown is best-effort; the process must still reach its exit path. + } + exit(code); + })(); + return stopPromise; + }; + + signals.on('SIGINT', () => stop(0)); + signals.on('SIGTERM', () => stop(0)); + signals.on('SIGHUP', () => stop(0)); + + return { + start(startOptions = {}) { + launchPromise = Promise.resolve(startApp({ + ...startOptions, + signal: controller.signal, + })); + return launchPromise.catch((error) => { + if (controller.signal.aborted && error === controller.signal.reason) return null; + throw error; + }); }, + stop, + isStopping: () => stopPromise !== null, }; } +export function cleanupDevelopmentLaunch({ logStream, logFile, resultFile }) { + logStream.kill(); + rmSync(logFile, { force: true }); + rmSync(resultFile, { force: true }); +} + function terminateProcessTree(child) { if (child.exitCode !== null || child.killed) return Promise.resolve(); if (process.platform === 'win32' && child.pid) { @@ -393,33 +512,52 @@ function terminateProcessTree(child) { } /** - * 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. + * Waits only for Electron's lock verdict. After a winner is known, the detached + * TCC app owns its own lifecycle; the launcher neither polls nor terminates it. */ -export async function monitorDevelopmentApp(options = {}) { - const isRunning = options.isRunning ?? isDevelopmentAppRunning; +export async function waitForDevelopmentLaunchVerdict(options = {}) { + const readLaunchResult = options.readLaunchResult ?? + (() => readDevelopmentLaunchResult(options.resultFile)); 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) { + for (let attempt = 0; attempt < startupAttempts; attempt += 1) { + const result = await readLaunchResult(); + if (result?.status === 'loser') return 'absorbed'; + if (result?.status === 'winner') return 'started'; 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'; + return 'never-started'; +} + +/** + * Launch-outcome decision, fully contained: absorbed/never-started are failures, + * stopped is a normal launcher exit, and started deliberately leaves the + * launcher's own dev resources running without supervising Electron. + */ +export function handleDevelopmentLaunchOutcome(outcome, effects = {}) { + const log = effects.log ?? console.error; + const exit = effects.exit ?? ((code) => { process.exitCode = code; }); + switch (outcome) { + case 'started': + return; + case 'absorbed': + log('Maka Dev was absorbed by another instance holding the profile lock; quitting.'); + exit(1); + return; + case 'never-started': + log('Maka Dev did not start within the startup window.'); + exit(1); + return; + case 'stopped': + exit(0); + return; + default: + log(`Unknown Maka Dev monitor outcome: ${String(outcome)}`); + exit(1); + } } export async function prepareDevelopmentApp() { @@ -433,9 +571,9 @@ export async function prepareDevelopmentApp() { 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(); + // Rebuilding unlinks the bundle an already-running app was launched from. + // Refuse rather than terminating a process the launcher did not create. + assertDevelopmentAppNotRunning(); try { await rebuildDevelopmentRuntime({ @@ -503,6 +641,9 @@ export function createRuntimeMarker(electronVersion) { electronVersion, bundleId: DEV_BUNDLE_ID, desktopDir: DESKTOP_DIR, + // Burned into the generated bootstrap, so a change here must invalidate + // the cached bundle (isDevelopmentRuntimeCurrent compares every field). + userDataDir: DEV_USER_DATA_DIR, }; } diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs new file mode 100644 index 0000000000..8ba66ec576 --- /dev/null +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -0,0 +1,361 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { test } from 'node:test'; + +test('a launcher signal cancels preparation before Electron can spawn', async () => { + const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs'); + const signals = new EventEmitter(); + let finishPreparation; + const preparation = new Promise((resolve) => { finishPreparation = resolve; }); + let finishStopping; + const stopped = new Promise((resolve) => { finishStopping = resolve; }); + const spawns = []; + const exits = []; + let closes = 0; + const session = createDevelopmentLaunchSession({ + signals, + close: async () => { closes += 1; }, + exit: (code) => { + exits.push(code); + finishStopping(); + }, + }); + const launching = session.start({ + prepareMacosDevelopmentLaunch: () => preparation, + createDevelopmentLaunchFiles: () => { + throw new Error('cancelled launch must not create files'); + }, + spawn: (...args) => spawns.push(args), + }); + + signals.emit('SIGINT'); + finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' }); + + assert.equal(await launching, null); + await stopped; + assert.deepEqual(spawns, []); + assert.equal(closes, 1); + assert.deepEqual(exits, [0]); +}); + +test('a launcher signal adopts and stops a delayed launch handle exactly once', async () => { + const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs'); + const signals = new EventEmitter(); + let publishHandle; + const pendingHandle = new Promise((resolve) => { publishHandle = resolve; }); + let finishStopping; + const stopped = new Promise((resolve) => { finishStopping = resolve; }); + let stops = 0; + const session = createDevelopmentLaunchSession({ + signals, + startDevelopmentApp: () => pendingHandle, + close: async () => {}, + exit: finishStopping, + }); + const launching = session.start(); + + signals.emit('SIGTERM'); + signals.emit('SIGHUP'); + publishHandle({ stop: async () => { stops += 1; } }); + + await launching; + await stopped; + assert.equal(stops, 1); +}); + +test('cancelling during launch preparation prevents any later process spawn', async () => { + const { startDevelopmentApp } = await import('./dev-app-runtime.mjs'); + const controller = new AbortController(); + let finishPreparation; + const preparation = new Promise((resolve) => { finishPreparation = resolve; }); + const launchFiles = []; + const spawns = []; + const launching = startDevelopmentApp({ + signal: controller.signal, + prepareMacosDevelopmentLaunch: () => preparation, + devSingleInstanceConstants: async () => ({}), + createDevelopmentLaunchFiles: () => { + launchFiles.push('created'); + return { logFile: '/tmp/log', resultFile: '/tmp/result' }; + }, + spawn: (...args) => { + spawns.push(args); + return { exitCode: null, killed: false, once: () => {}, kill: () => {} }; + }, + }); + + controller.abort(); + finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' }); + + await assert.rejects(launching, (error) => error === controller.signal.reason); + assert.deepEqual(launchFiles, []); + assert.deepEqual(spawns, []); +}); + +test('cancelling while the plain launcher loads its lock contract prevents spawn', async () => { + const { startDevelopmentApp } = await import('./dev-app-runtime.mjs'); + const controller = new AbortController(); + let finishContractLoad; + const contract = new Promise((resolve) => { finishContractLoad = resolve; }); + const spawns = []; + const launching = startDevelopmentApp({ + signal: controller.signal, + prepareMacosDevelopmentLaunch: async () => null, + devSingleInstanceConstants: () => contract, + spawn: (...args) => spawns.push(args), + }); + + await Promise.resolve(); + controller.abort(); + finishContractLoad({ DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG: '--handled' }); + + await assert.rejects(launching, (error) => error === controller.signal.reason); + assert.deepEqual(spawns, []); +}); + +test('launcher and core agree on the loser contract', async () => { + const { devSingleInstanceConstants } = await import('./dev-app-runtime.mjs'); + const core = await import('@maka/core/dev-single-instance'); + const launcher = await devSingleInstanceConstants(); + assert.equal(launcher.DEV_LOSER_EXIT_CODE, core.DEV_LOSER_EXIT_CODE); + assert.equal(launcher.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG, core.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG); + assert.equal(launcher.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, core.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX); +}); + +test('plainLoserExitCode matches only the contract value', async () => { + const { plainLoserExitCode } = await import('./dev-app-runtime.mjs'); + const { DEV_LOSER_EXIT_CODE } = await import('@maka/core/dev-single-instance'); + assert.equal(await plainLoserExitCode(DEV_LOSER_EXIT_CODE), true); + assert.equal(await plainLoserExitCode(0), false); + assert.equal(await plainLoserExitCode(1), false); +}); + +test('loser contract import failure does not claim a loser', async () => { + const { plainLoserExitCode } = await import('./dev-app-runtime.mjs'); + const failingLoader = async () => { throw new Error('dist not built'); }; + const warnings = []; + const origWarn = console.warn; + console.warn = (m) => warnings.push(String(m)); + try { + assert.equal(await plainLoserExitCode(42, failingLoader), false); + } finally { console.warn = origWarn; } + assert.ok(warnings.some((m) => m.includes('dev single-instance constants unavailable'))); +}); + +async function runVerdictCase({ result, stopped = () => false, startupAttempts = 10 }) { + const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); + const { writeFileSync, mkdtempSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const dir = mkdtempSync(join(tmpdir(), 'maka-abs-')); + const resultFile = join(dir, 'launch-result.json'); + try { + if (result) writeFileSync(resultFile, `${JSON.stringify(result)}\n`); + return await waitForDevelopmentLaunchVerdict({ + resultFile, + pollMs: 1, + startupAttempts, + stopped, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('verdict wait reports only its own private loser result', async () => { + const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); + let resultChecks = 0; + let stopChecks = 0; + assert.equal( + await waitForDevelopmentLaunchVerdict({ + readLaunchResult: async () => (++resultChecks >= 2 ? { status: 'loser' } : undefined), + delay: async () => {}, + stopped: () => ++stopChecks > 4, + }), + 'absorbed', + ); +}); + +test('the TCC launcher stops supervising after Electron wins the lock', async () => { + const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs'); + assert.equal( + await waitForDevelopmentLaunchVerdict({ + readLaunchResult: () => ({ status: 'winner' }), + isWinnerRunning: () => { + throw new Error('the launcher must not observe Electron after the lock verdict'); + }, + delay: async () => {}, + startupAttempts: 1, + }), + 'started', + ); +}); + +test('verdict wait marks a loser that never starts as absorbed', async () => { + assert.equal( + await runVerdictCase({ result: { status: 'loser' }, startupAttempts: 1 }), + 'absorbed', + ); +}); + +test('verdict wait keeps an ordinary never-started launch distinct', async () => { + assert.equal(await runVerdictCase({ startupAttempts: 1 }), 'never-started'); +}); + +test('verdict wait marks a stopped loser as absorbed', async () => { + let stopChecks = 0; + assert.equal( + await runVerdictCase({ + result: { status: 'loser' }, + stopped: () => ++stopChecks > 1, + }), + 'absorbed', + ); +}); + +test('development-app liveness probes only the TCC bundle', async () => { + const { isDevelopmentAppRunning } = await import('./dev-app-runtime.mjs'); + const calls = []; + assert.equal( + isDevelopmentAppRunning({ + executable: '/wt/Maka Dev.app/Contents/MacOS/Electron', + probe: (executable) => { + calls.push(executable); + return false; + }, + }), + false, + ); + assert.deepEqual(calls, ['/wt/Maka Dev.app/Contents/MacOS/Electron']); +}); + +test('a runtime rebuild refuses to replace a running TCC bundle instead of killing it', async () => { + const { assertDevelopmentAppNotRunning } = await import('./dev-app-runtime.mjs'); + assert.throws( + () => assertDevelopmentAppNotRunning({ isRunning: () => true }), + /Quit it.*retry/, + ); + assert.doesNotThrow(() => assertDevelopmentAppNotRunning({ isRunning: () => false })); +}); + +test('TCC launcher cleanup removes only its own launch artifacts', async () => { + const { cleanupDevelopmentLaunch } = await import('./dev-app-runtime.mjs'); + const { existsSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const directory = mkdtempSync(join(tmpdir(), 'maka-cleanup-')); + const logFile = join(directory, 'app.log'); + const resultFile = join(directory, 'launch-result.json'); + let logStopped = false; + try { + writeFileSync(logFile, 'log output\n'); + writeFileSync(resultFile, '{"status":"winner"}\n'); + cleanupDevelopmentLaunch({ + logFile, + resultFile, + logStream: { kill: () => { logStopped = true; } }, + }); + assert.equal(logStopped, true); + assert.equal(existsSync(logFile), false); + assert.equal(existsSync(resultFile), false); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); + +test('each TCC launch gets private and separate result and log files', async () => { + const { createDevelopmentLaunchFiles } = await import('./dev-app-runtime.mjs'); + const writes = []; + const first = createDevelopmentLaunchFiles({ + directory: '/tmp/maka-dev-launches', + id: 'first', + mkdir: () => {}, + write: (path) => writes.push(path), + }); + const second = createDevelopmentLaunchFiles({ + directory: '/tmp/maka-dev-launches', + id: 'second', + mkdir: () => {}, + write: (path) => writes.push(path), + }); + assert.notEqual(first.logFile, second.logFile); + assert.notEqual(first.resultFile, second.resultFile); + assert.deepEqual(writes, [first.logFile, second.logFile]); +}); + +test('other launch outcomes retain their exit codes', async () => { + const { handleDevelopmentLaunchOutcome } = await import('./dev-app-runtime.mjs'); + for (const [outcome, expectedCode] of [['never-started', 1], ['stopped', 0], ['unexpected', 1]]) { + let exitCode; + handleDevelopmentLaunchOutcome(outcome, { log: () => {}, exit: (code) => { exitCode = code; } }); + assert.equal(exitCode, expectedCode, outcome); + } + let startedExitCode; + handleDevelopmentLaunchOutcome('started', { exit: (code) => { startedExitCode = code; } }); + assert.equal(startedExitCode, undefined); +}); + +test('shared-profile launches warn about legacy TCC data before choosing plain or bundle mode', async () => { + const { DEV_USER_DATA_DIR, warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs'); + const warnings = []; + warnAboutLegacyTccDataRoot({ + platform: 'darwin', + effectiveUserDataDir: DEV_USER_DATA_DIR, + legacyUserDataDir: '/tmp/Maka Dev-legacy', + exists: () => true, + warn: (message) => warnings.push(message), + }); + assert.equal(warnings.length, 1); + assert.match(warnings[0], /Maka Dev-legacy/); +}); + +test('an explicit isolated profile does not warn about unrelated legacy TCC data', async () => { + const { warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs'); + const warnings = []; + warnAboutLegacyTccDataRoot({ + platform: 'darwin', + effectiveUserDataDir: '/tmp/isolated-profile', + legacyUserDataDir: '/tmp/Maka Dev-legacy', + exists: () => true, + warn: (message) => warnings.push(message), + }); + assert.deepEqual(warnings, []); +}); + +test('createMacosDevelopmentLaunch passes its private result file after --args', async () => { + const { createMacosDevelopmentLaunch } = await import('./dev-app-runtime.mjs'); + const { DEV_LAUNCH_RESULT_FILE_ARG_PREFIX } = await import('@maka/core/dev-single-instance'); + const launch = createMacosDevelopmentLaunch( + '/tmp/Maka Dev.app', + '/tmp/app.log', + '/tmp/launch-result.json', + DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, + ); + assert.equal(launch.command, 'open'); + assert.ok(launch.args.indexOf('--stdout') < launch.args.indexOf('--args')); + assert.equal( + launch.args.at(-1), + `${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}/tmp/launch-result.json`, + ); + assert.ok( + !createMacosDevelopmentLaunch('/tmp/Maka Dev.app', '/tmp/app.log').args.includes('--args'), + ); +}); diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index 3988b75b14..f73bdec843 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -42,7 +42,11 @@ import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; import { build as esbuildBuild } from 'esbuild'; import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs'; -import { monitorDevelopmentApp, startDevelopmentApp } from './dev-app-runtime.mjs'; +import { + createDevelopmentLaunchSession, + handleDevelopmentLaunchOutcome, + waitForDevelopmentLaunchVerdict, +} from './dev-app-runtime.mjs'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); @@ -151,47 +155,42 @@ if (!devUrl) { log('electron', `launching against ${devUrl} (renderer HMR live)`); +// Created before launch so signals during codesign/preparation are durable. +// Closing the terminal still stops only launcher resources, not the +// independently owned TCC app. +const launchSession = createDevelopmentLaunchSession({ + close: () => server.close(), +}); let app = null; -let shuttingDown = false; -async function shutdown(code) { - if (shuttingDown) return; - shuttingDown = true; - await app?.stop(); - await server.close().catch(() => {}); - process.exit(code); +try { + app = await launchSession.start({ argv: process.argv.slice(2), viteUrl: devUrl }); +} catch (error) { + console.error(`[dev] failed to start Electron: ${String(error)}`); + await launchSession.stop(1); } -// Registered before the launch await: preparing the bundle can take a codesign -// rebuild, and a signal arriving with no handler installed takes the default -// action, leaving the dev server and any app behind. -process.on('SIGINT', () => shutdown(0)); -process.on('SIGTERM', () => shutdown(0)); -// Closing the terminal window sends SIGHUP; without this the detached bundle -// survives as an orphan holding the single-instance lock. -process.on('SIGHUP', () => shutdown(0)); - -app = await startDevelopmentApp({ argv: process.argv.slice(2), viteUrl: devUrl }); -if (app.isMacosBundle) log('electron', 'launched Maka Dev.app through LaunchServices'); - -app.child.on('error', (err) => { - console.error(`[dev] failed to start Electron: ${err.message}`); - shutdown(1); -}); -if (app.isMacosBundle) { - // `open` exits 0 at the handoff, so only a failure to hand off is news here; - // the app's own lifetime is what the monitor reports. - app.child.on('exit', (code) => { - if (code) shutdown(code); - }); - monitorDevelopmentApp({ stopped: () => shuttingDown }).then((outcome) => { - if (outcome === 'never-started') { - console.error('[dev] Maka Dev.app did not start (see the output above)'); - shutdown(1); - } else if (outcome === 'exited') { - log('electron', 'Maka Dev.app quit'); - shutdown(0); - } +if (app) { + if (app.isMacosBundle) log('electron', 'launched Maka Dev.app through LaunchServices'); + + app.child.on('error', (err) => { + console.error(`[dev] failed to start Electron: ${err.message}`); + launchSession.stop(1); }); -} else { - app.child.on('exit', (code) => shutdown(code ?? 0)); + if (app.isMacosBundle) { + // `open` exits 0 at the handoff, so only a failure to hand off is news here. + // The app's later lifetime is deliberately independent from this dev server. + app.child.on('exit', (code) => { + if (code) launchSession.stop(code); + }); + // All branching lives in handleDevelopmentLaunchOutcome; this line is the only + // un-automated surface here (launcher scripts are non-exported, darwin-only). + waitForDevelopmentLaunchVerdict({ stopped: launchSession.isStopping, resultFile: app.resultFile }).then((outcome) => + handleDevelopmentLaunchOutcome(outcome, { + log: (m) => console.error('[dev]', m), + exit: (code) => launchSession.stop(code), + }), + ); + } else { + app.child.on('exit', (code) => launchSession.stop(code ?? 0)); + } } diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs index 2b574f01c2..e9a88b7ff2 100644 --- a/apps/desktop/scripts/start-dev-app.mjs +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -18,45 +18,50 @@ * under the License. */ -import { monitorDevelopmentApp, startDevelopmentApp } from './dev-app-runtime.mjs'; +import { + handleDevelopmentLaunchOutcome, + startDevelopmentApp, + waitForDevelopmentLaunchVerdict, +} from './dev-app-runtime.mjs'; -let app = null; let stopping = false; async function stop(code = 0) { if (stopping) return; stopping = true; - await app?.stop(); + await app.stop(); process.exitCode = code; } -// Registered before any await. Preparing the bundle and monitoring the app both -// suspend this module for a long time, and a signal arriving while no handler -// is installed takes the default action — killing this process mid-teardown and -// orphaning the app it was supposed to stop. +const app = await startDevelopmentApp({ argv: process.argv.slice(2) }); + +// Preparing the bundle happens before the launcher owns any long-lived +// resources. Once the handle exists, signals clean up only those resources; +// the detached TCC app remains independently owned. process.on('SIGINT', () => void stop()); process.on('SIGTERM', () => void stop()); process.on('SIGHUP', () => void stop()); -app = await startDevelopmentApp({ argv: process.argv.slice(2) }); - app.child.on('error', (error) => { console.error(`[dev-app] failed to start: ${error.message}`); void stop(1); }); if (app.isMacosBundle) { - // `open` exits 0 at the LaunchServices handoff, so this process would end - // immediately and leave nothing to stop the app on Ctrl-C. Monitoring the - // detached app is both what keeps it alive and what reports the app quitting. + // `open` exits at the LaunchServices handoff. The log follower keeps this + // command alive, but Ctrl-C stops only launcher-owned resources; the detached + // TCC app remains running until the user quits it. app.child.on('exit', (code) => { if (code) void stop(code); }); - const outcome = await monitorDevelopmentApp({ stopped: () => stopping }); - if (outcome === 'never-started') { - console.error('[dev-app] Maka Dev.app did not start (see the output above)'); - void stop(1); - } else if (outcome === 'exited') { - void stop(0); - } + const outcome = await waitForDevelopmentLaunchVerdict({ + stopped: () => stopping, + resultFile: app.resultFile, + }); + // All decisions live in handleDevelopmentLaunchOutcome (see dev.mjs for the + // one-line coupling note); this is the only unobserved part. + handleDevelopmentLaunchOutcome(outcome, { + log: (m) => console.error('[dev-app]', m), + exit: (code) => stop(code), + }); } else { app.child.on('exit', (code, signal) => { if (!stopping) process.exitCode = signal ? 1 : (code ?? 0); diff --git a/apps/desktop/src/main/__tests__/dev-single-instance-result.test.ts b/apps/desktop/src/main/__tests__/dev-single-instance-result.test.ts new file mode 100644 index 0000000000..d41b63c815 --- /dev/null +++ b/apps/desktop/src/main/__tests__/dev-single-instance-result.test.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { DEV_LAUNCH_RESULT_FILE_ARG_PREFIX } from '@maka/core/dev-single-instance'; +import { reportDevelopmentLaunchResult } from '../dev-single-instance-result.js'; + +test('the Electron lock owner publishes one private launch result', () => { + const directory = mkdtempSync(join(tmpdir(), 'maka-main-result-')); + const resultFile = join(directory, 'launch-result.json'); + const argv = [`${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}${resultFile}`]; + try { + assert.equal(reportDevelopmentLaunchResult(argv, { status: 'winner' }), true); + assert.deepEqual(JSON.parse(readFileSync(resultFile, 'utf8')), { + status: 'winner', + }); + assert.equal(reportDevelopmentLaunchResult(argv, { status: 'loser' }), false); + assert.deepEqual(JSON.parse(readFileSync(resultFile, 'utf8')), { + status: 'winner', + }); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/dev-single-instance-result.ts b/apps/desktop/src/main/dev-single-instance-result.ts new file mode 100644 index 0000000000..eec07273f3 --- /dev/null +++ b/apps/desktop/src/main/dev-single-instance-result.ts @@ -0,0 +1,43 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { + developmentLaunchResultFile, + serializeDevelopmentLaunchResult, + type DevelopmentLaunchResult, +} from '@maka/core/dev-single-instance'; +import { writeFileSync } from 'node:fs'; + +/** Publishes the Electron lock verdict once, outside ordinary application logs. */ +export function reportDevelopmentLaunchResult( + argv: readonly string[], + result: DevelopmentLaunchResult, +): boolean { + const file = developmentLaunchResultFile(argv); + if (!file) return false; + try { + writeFileSync(file, serializeDevelopmentLaunchResult(result), { + encoding: 'utf8', + flag: 'wx', + mode: 0o600, + }); + return true; + } catch { + return false; + } +} diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index e7bb10e5fa..3068460b46 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -18,6 +18,11 @@ */ import { resolveSystemUiLocale } from '@maka/core/ui-locale'; +import { + DEV_LOSER_EXIT_CODE, + developmentLaunchResultFile, + shouldShowLoserDialog, +} from '@maka/core/dev-single-instance'; import { app, clipboard, dialog } from 'electron'; import { join } from 'node:path'; import { resolveBuildInfo } from './build-info.js'; @@ -39,6 +44,7 @@ import { showPreviousMainProcessInterruptionDialog, } from './native-diagnostic-dialog.js'; import { isIsolatedE2e } from './startup-context.js'; +import { reportDevelopmentLaunchResult } from './dev-single-instance-result.js'; let recoveryJournal: MainProcessRecoveryJournal | undefined; installMainProcessLogCapture(mainProcessLogBuffer, () => recoveryJournal?.markDirty()); @@ -67,8 +73,37 @@ if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { // before touching shared state. See the 'second-instance' listener in // runtime-host-boot.ts for what the surviving process does about it. if (!app.requestSingleInstanceLock()) { - app.exit(0); + if (!app.isPackaged) { + // Dev: losing the lock must NOT pretend to have started (exit 0 would be + // read as a clean launch while the app was absorbed). A direct launcher + // reads the child exit code; a detached TCC launcher reads its private, + // one-shot result file. A direct launcher explicitly promises to consume + // the exit code; a TCC launcher proves it has a consumer only when the + // result write succeeds. Any other entry (Dock, Spotlight, Quit & Reopen) + // gets a native box — fail toward the dialog. Linux pre-ready showErrorBox + // degrades to stderr (no GUI); documented in electron.d.ts. Packaged builds + // keep the existing UX (double-click focuses the first window) — the gate + // is a semantic boundary. + const resultReported = reportDevelopmentLaunchResult(process.argv, { status: 'loser' }); + if (!resultReported && shouldShowLoserDialog(process.argv)) { + dialog.showErrorBox( + 'Maka Dev', + `Another instance holds the Maka Dev profile (${app.getPath('userData')}). Quit it and retry.`, + ); + } + app.exit(DEV_LOSER_EXIT_CODE); + } else { + app.exit(0); + } } else { + if (!app.isPackaged) { + const resultReported = reportDevelopmentLaunchResult(process.argv, { + status: 'winner', + }); + if (developmentLaunchResultFile(process.argv) && !resultReported) { + console.error('[dev] could not publish the single-instance launch result'); + } + } const buildInfo = resolveBuildInfo(app.isPackaged, app.getAppPath()); try { recoveryJournal = createMainProcessRecoveryJournal({ @@ -90,7 +125,6 @@ if (!app.requestSingleInstanceLock()) { } catch (error) { console.error('[diagnostics] main-process recovery unavailable:', error); } - // The full boot must not run in the top-level module-evaluation chain: // Electron ESM emits `ready` only after the entry module finishes // evaluating, so a top-level `await app.whenReady()` (which the diff --git a/packages/core/package.json b/packages/core/package.json index aa51d1cdde..260c251476 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -134,7 +134,8 @@ "./tool-result-record-schema": "./dist/tool-result-record-schema.js", "./tool-result-status": "./dist/tool-result-status.js", "./ui-locale": "./dist/ui-locale.js", - "./unified-diff": "./dist/unified-diff.js" + "./unified-diff": "./dist/unified-diff.js", + "./dev-single-instance": "./dist/dev-single-instance.js" }, "scripts": { "clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo", diff --git a/packages/core/src/__tests__/dev-single-instance.test.ts b/packages/core/src/__tests__/dev-single-instance.test.ts new file mode 100644 index 0000000000..ef757b8729 --- /dev/null +++ b/packages/core/src/__tests__/dev-single-instance.test.ts @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG, + DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, + developmentLaunchResultFile, + parseDevelopmentLaunchResult, + shouldShowLoserDialog, +} from '../dev-single-instance.js'; + +test('shouldShowLoserDialog defaults to showing; the launcher flag silences', () => { + assert.equal(shouldShowLoserDialog([]), true); + assert.equal(shouldShowLoserDialog(['--no-sandbox']), true); + assert.equal(shouldShowLoserDialog([DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG]), false); +}); + +test('the last launcher-owned result argument selects the private result file', () => { + assert.equal(developmentLaunchResultFile([]), undefined); + assert.equal(developmentLaunchResultFile([DEV_LAUNCH_RESULT_FILE_ARG_PREFIX]), undefined); + assert.equal( + developmentLaunchResultFile([ + `${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}/tmp/untrusted.json`, + `${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}/tmp/launch result.json`, + ]), + '/tmp/launch result.json', + ); +}); + +test('a winner verdict carries no process identity', () => { + assert.deepEqual(parseDevelopmentLaunchResult('{"status":"winner"}\n'), { + status: 'winner', + }); +}); diff --git a/packages/core/src/dev-single-instance.ts b/packages/core/src/dev-single-instance.ts new file mode 100644 index 0000000000..af435e1940 --- /dev/null +++ b/packages/core/src/dev-single-instance.ts @@ -0,0 +1,67 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +// Single source for the dev single-instance launch contract between the +// Electron main process (the authority) and the dev launcher (the consumer). +// Packaged builds keep the existing behavior (second instance exits 0 and +// the first window is focused); in dev (`!app.isPackaged`) a losing process +// must NOT pretend to have started. A direct launcher reads +// DEV_LOSER_EXIT_CODE; a LaunchServices-detached launcher reads its private, +// one-shot verdict file. The verdict never transfers app-process ownership. + +export const DEV_LOSER_EXIT_CODE = 42 as const; +export const DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG = + '--maka-dev-conflict-handled-by-launcher' as const; +export const DEV_LAUNCH_RESULT_FILE_ARG_PREFIX = '--maka-dev-launch-result-file=' as const; + +export type DevelopmentLaunchResult = { readonly status: 'winner' } | { readonly status: 'loser' }; + +export function developmentLaunchResultFile(argv: readonly string[]): string | undefined { + for (let index = argv.length - 1; index >= 0; index -= 1) { + const argument = argv[index]; + if (!argument?.startsWith(DEV_LAUNCH_RESULT_FILE_ARG_PREFIX)) continue; + const file = argument.slice(DEV_LAUNCH_RESULT_FILE_ARG_PREFIX.length); + return file.length > 0 ? file : undefined; + } + return undefined; +} + +export function serializeDevelopmentLaunchResult(result: DevelopmentLaunchResult): string { + return `${JSON.stringify(result)}\n`; +} + +export function parseDevelopmentLaunchResult(value: string): DevelopmentLaunchResult | undefined { + try { + const result: unknown = JSON.parse(value); + if (typeof result !== 'object' || result === null || !('status' in result)) return undefined; + if (result.status === 'loser') return { status: 'loser' }; + if (result.status === 'winner') return { status: 'winner' }; + return undefined; + } catch { + return undefined; + } +} + +/** + * Whether the dev loser should show the native dialog: default yes; the flag + * is a capability promise ("the launcher handles the conflict"), so CI/CLI + * can suppress the dialog by passing it. Pure so the decision is testable. + */ +export function shouldShowLoserDialog(argv: readonly string[]): boolean { + return !argv.includes(DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG); +}