From 0f1f7a1ef51d28ce8e657d78062e4127ae206470 Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 2 Aug 2026 22:59:15 +0800 Subject: [PATCH 1/8] fix(desktop): avoid dev permission icon crash --- .../permission-overlay-controller.test.ts | 14 +++++++++++++- .../src/main/permission-overlay/app-bundle.ts | 12 ++++++++++++ .../permission-overlay/permission-overlay-main.ts | 6 +++--- 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts index 7398fb8738..a1d237fe73 100644 --- a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts +++ b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts @@ -19,7 +19,10 @@ import { type PermissionOverlayDeps, type PermissionOverlayWindowLike, } from '../permission-overlay/permission-overlay-controller.js'; -import { resolveAppBundle } from '../permission-overlay/app-bundle.js'; +import { + resolveAppBundle, + shouldLoadNativeBundleIcon, +} from '../permission-overlay/app-bundle.js'; /** Deterministic timer wheel — no real time passes in these tests. */ function createClock() { @@ -292,6 +295,15 @@ describe('drag-to-grant permission overlay', () => { }); describe('app bundle resolution for the drag', () => { + it('loads native bundle icons only for packaged builds', () => { + assert.equal(shouldLoadNativeBundleIcon(true), true); + assert.equal( + shouldLoadNativeBundleIcon(false), + false, + 'development must not ask macOS to resolve the node_modules Electron.app icon', + ); + }); + it('walks three levels up from the executable to the .app', () => { assert.deepEqual( resolveAppBundle({ diff --git a/apps/desktop/src/main/permission-overlay/app-bundle.ts b/apps/desktop/src/main/permission-overlay/app-bundle.ts index a3b3317638..5b62446326 100644 --- a/apps/desktop/src/main/permission-overlay/app-bundle.ts +++ b/apps/desktop/src/main/permission-overlay/app-bundle.ts @@ -33,6 +33,18 @@ export interface ResolveAppBundleDeps { exists(path: string): boolean; } +/** + * Reading a bundle icon is presentation-only. In development Electron's + * `app.getPath('exe')` resolves to the generic Electron.app under + * node_modules; asking macOS for that bundle's icon can terminate the native + * Electron process before the returned promise settles. Keep the native icon + * path for packaged Maka.app builds and let development use an empty drag + * image instead. + */ +export function shouldLoadNativeBundleIcon(isPackaged: boolean): boolean { + return isPackaged; +} + export function resolveAppBundle(deps: ResolveAppBundleDeps): AppBundleResult { const { executablePath, platform, exists } = deps; if (platform !== 'darwin') { diff --git a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts index 143eb4fd80..1b3f25fbc4 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts @@ -24,7 +24,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UiLocale } from '@maka/core'; import { openSystemPermissionPane } from '../permissions-actions.js'; -import { resolveAppBundle } from './app-bundle.js'; +import { resolveAppBundle, shouldLoadNativeBundleIcon } from './app-bundle.js'; import { getPermissionOverlayCopy } from './permission-overlay-copy.js'; import { createPermissionOverlayController, @@ -81,7 +81,7 @@ export function createPermissionOverlayMain( } async function resolveAppIconDataUrl(bundlePath: string | null): Promise { - if (!bundlePath) return null; + if (!bundlePath || !shouldLoadNativeBundleIcon(app.isPackaged)) return null; try { // nativeImage.createFromPath does not decode .icns reliably. Asking // macOS for the bundle icon returns the same image Finder and the TCC @@ -280,7 +280,7 @@ function attachCardGestures(win: import('electron').BrowserWindow): void { const fromRenderer = nativeImage.createFromDataURL(iconDataUrl); if (!fromRenderer.isEmpty()) icon = fromRenderer; } - if (icon.isEmpty()) { + if (icon.isEmpty() && shouldLoadNativeBundleIcon(app.isPackaged)) { try { const fallback = await app.getFileIcon(resolved.bundlePath, { size: 'large' }); if (!fallback.isEmpty()) icon = fallback.resize({ width: 64, height: 64 }); From 4e26c3b45c6880f460e73f89d3a634188e96f91d Mon Sep 17 00:00:00 2001 From: liugddx Date: Sun, 2 Aug 2026 23:40:00 +0800 Subject: [PATCH 2/8] feat(desktop): add stable macOS dev app identity --- apps/desktop/.gitignore | 1 + apps/desktop/README.md | 18 +++ apps/desktop/package.json | 3 +- apps/desktop/scripts/dev-app-runtime.mjs | 117 ++++++++++++++++++ apps/desktop/scripts/dev-app-runtime.test.mjs | 30 +++++ apps/desktop/scripts/dev.mjs | 9 +- apps/desktop/scripts/prepare-dev-app.mjs | 5 + apps/desktop/scripts/start-dev-app.mjs | 41 ++++++ package.json | 2 +- 9 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/scripts/dev-app-runtime.mjs create mode 100644 apps/desktop/scripts/dev-app-runtime.test.mjs create mode 100644 apps/desktop/scripts/prepare-dev-app.mjs create mode 100644 apps/desktop/scripts/start-dev-app.mjs diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 6b580e082e..f9fcbd2968 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,3 +1,4 @@ # Playwright E2E run artifacts: traces, videos, screenshots, last-run state. test-results/ resources/workers/ +.maka-dev/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index a346aa2ece..73ff03bfa9 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,6 +2,24 @@ The Electron desktop app: `main` (Node/Electron main process) + `preload` (context bridge) + `renderer` (React UI). This file covers the three-layer split and the IPC contract. For build/test commands and the test-layer selection guide, see the top-level `README.md`; for the renderer interior, see `src/renderer/README.md`. +## macOS development permissions + +`npm run dev` and `npm start` launch macOS development builds through a generated, ad-hoc-signed +`apps/desktop/.maka-dev/Maka Dev.app`. The stable bundle identity lets macOS TCC +retain Accessibility and Screen Recording grants while renderer and main-process +code change. The generated app is ignored by Git and is rebuilt automatically +when the installed Electron version changes. Run +`npm --workspace @maka/desktop run prepare:dev-app` to prepare it explicitly. + +The scripts launch the bundle through macOS LaunchServices rather than executing +its internal binary from a terminal. This is required for TCC to attribute the +running process to `Maka Dev` and recognize the stored grants. + +Grant permissions to **Maka Dev**, not a generic Electron entry. Screen Recording +changes require restarting the development app. Recreating the app or changing +its Electron version may require granting permissions again. Other platforms +continue to use their normal Electron development executable. + ## Three layers | Layer | Path | Role | diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a4cc504b90..867d5ba47f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -9,7 +9,8 @@ "type": "module", "main": "dist/main/main.js", "scripts": { - "start": "electron .", + "start": "node scripts/start-dev-app.mjs", + "prepare:dev-app": "node scripts/prepare-dev-app.mjs", "dev": "node scripts/dev.mjs", "dev:hmr": "node scripts/dev.mjs", "storybook": "storybook dev -p 6006 -c .storybook", diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs new file mode 100644 index 0000000000..fa0fd8f5fb --- /dev/null +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -0,0 +1,117 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +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 DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); +const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); +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 DEV_BUNDLE_ID = 'com.maka.dev'; +const RUNTIME_SCHEMA_VERSION = 1; + +export function resolveMacosDevelopmentLaunch(appArgs, env = {}) { + if (process.platform !== 'darwin') return null; + const appPath = prepareDevelopmentApp(); + return createMacosDevelopmentLaunch(appPath, appArgs, env); +} + +export function createMacosDevelopmentLaunch(appPath, appArgs, env = {}) { + const launchEnvironment = []; + if (env.VITE_DEV_SERVER_URL) { + launchEnvironment.push('--env', `VITE_DEV_SERVER_URL=${env.VITE_DEV_SERVER_URL}`); + } + return { + command: 'open', + // LaunchServices must own process launch so macOS TCC attributes the + // running executable to Maka Dev rather than to its parent terminal. + args: [ + '-n', + '-W', + ...launchEnvironment, + '-a', + appPath, + '--args', + ...appArgs, + ], + }; +} + +export function quitMacosDevelopmentApp() { + if (process.platform !== 'darwin') return; + spawnSync('osascript', ['-e', `tell application id "${DEV_BUNDLE_ID}" to quit`], { + stdio: 'ignore', + }); +} + +export 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; + + rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); + run('mkdir', ['-p', DEV_RUNTIME_DIR]); + run('ditto', [SOURCE_APP, DEV_APP]); + + const plist = join(DEV_APP, 'Contents', 'Info.plist'); + replacePlistValue(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); + replacePlistValue(plist, 'CFBundleName', 'Maka Dev'); + replacePlistValue(plist, 'CFBundleDisplayName', 'Maka Dev'); + // `ditto` does not copy quarantine metadata by default. Clear any root-level + // attribute without relying on the newer recursive `xattr -r` flag, which + // is unavailable on older supported macOS releases. + run('xattr', ['-c', DEV_APP]); + run('codesign', ['--force', '--deep', '--sign', '-', '--identifier', DEV_BUNDLE_ID, DEV_APP]); + run('codesign', ['--verify', '--deep', '--strict', DEV_APP]); + + writeFileSync( + MARKER, + `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + ); + return DEV_APP; +} + +function isCurrentRuntime(electronVersion) { + if (!existsSync(DEV_EXECUTABLE) || !existsSync(MARKER)) return false; + try { + const marker = JSON.parse(readFileSync(MARKER, 'utf8')); + if ( + (marker.schemaVersion ?? 1) !== RUNTIME_SCHEMA_VERSION || + marker.electronVersion !== electronVersion || + marker.bundleId !== DEV_BUNDLE_ID + ) { + return false; + } + return spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0; + } catch { + return false; + } +} + +function replacePlistValue(plist, key, value) { + const result = spawnSync('plutil', ['-replace', key, '-string', value, plist], { + encoding: 'utf8', + }); + if (result.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}`); +} + +export const developmentAppPath = DEV_APP; 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..db406d147a --- /dev/null +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createMacosDevelopmentLaunch } from './dev-app-runtime.mjs'; + +test('launches the signed development bundle through LaunchServices', () => { + assert.deepEqual(createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop', '--flag']), { + command: 'open', + args: [ + '-n', + '-W', + '-a', + '/repo/Maka Dev.app', + '--args', + '/repo/desktop', + '--flag', + ], + }); +}); + +test('forwards the Vite URL without copying unrelated environment secrets', () => { + const launch = createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop'], { + VITE_DEV_SERVER_URL: 'http://localhost:5173', + API_SECRET: 'do-not-forward', + }); + assert.deepEqual(launch.args.slice(2, 4), [ + '--env', + 'VITE_DEV_SERVER_URL=http://localhost:5173', + ]); + assert.equal(launch.args.some((arg) => arg.includes('do-not-forward')), false); +}); diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index bc73d952ce..5d134407fd 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -24,6 +24,10 @@ import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; import { build as esbuildBuild } from 'esbuild'; import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs'; +import { + quitMacosDevelopmentApp, + resolveMacosDevelopmentLaunch, +} from './dev-app-runtime.mjs'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); @@ -142,7 +146,9 @@ if (!devUrl) { } log('electron', `launching against ${devUrl} (renderer HMR live)`); -const electron = spawn(resolveElectronBin(), ['.', ...process.argv.slice(2)], { +const appArgs = [DESKTOP_DIR, ...process.argv.slice(2)]; +const macosLaunch = resolveMacosDevelopmentLaunch(appArgs, { VITE_DEV_SERVER_URL: devUrl }); +const electron = spawn(macosLaunch?.command ?? resolveElectronBin(), macosLaunch?.args ?? appArgs, { cwd: DESKTOP_DIR, stdio: 'inherit', env: { ...process.env, VITE_DEV_SERVER_URL: devUrl }, @@ -152,6 +158,7 @@ let shuttingDown = false; async function shutdown(code, options = {}) { if (shuttingDown) return; shuttingDown = true; + if (macosLaunch) quitMacosDevelopmentApp(); if (options.killElectron !== false) { await terminateProcessTree(electron); } diff --git a/apps/desktop/scripts/prepare-dev-app.mjs b/apps/desktop/scripts/prepare-dev-app.mjs new file mode 100644 index 0000000000..e0d7ace6e5 --- /dev/null +++ b/apps/desktop/scripts/prepare-dev-app.mjs @@ -0,0 +1,5 @@ +#!/usr/bin/env node +import { developmentAppPath, prepareDevelopmentApp } from './dev-app-runtime.mjs'; + +prepareDevelopmentApp(); +console.log(`[dev-app] ready: ${developmentAppPath}`); diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs new file mode 100644 index 0000000000..e8a1e0ae8e --- /dev/null +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -0,0 +1,41 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + quitMacosDevelopmentApp, + resolveMacosDevelopmentLaunch, +} from './dev-app-runtime.mjs'; + +const desktopDir = resolve(fileURLToPath(new URL('..', import.meta.url))); +const repoRoot = resolve(desktopDir, '..', '..'); +const forwardedArgs = [desktopDir, ...process.argv.slice(2)]; +const macosLaunch = resolveMacosDevelopmentLaunch(forwardedArgs); +const electronBin = + process.platform === 'win32' + ? join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe') + : join(repoRoot, 'node_modules', '.bin', 'electron'); +const command = macosLaunch?.command ?? electronBin; +const args = macosLaunch?.args ?? ['.', ...process.argv.slice(2)]; + +const child = spawn(command, args, { + cwd: desktopDir, + stdio: 'inherit', + env: process.env, +}); +let stopping = false; +function stop() { + if (stopping) return; + stopping = true; + if (macosLaunch) quitMacosDevelopmentApp(); + else child.kill('SIGTERM'); +} +child.on('error', (error) => { + console.error(`[dev-app] failed to start: ${error.message}`); + process.exitCode = 1; +}); +child.on('exit', (code, signal) => { + if (!stopping) process.exitCode = signal ? 1 : (code ?? 0); +}); +process.on('SIGINT', stop); +process.on('SIGTERM', stop); diff --git a/package.json b/package.json index bda3416c65..fe0db34221 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "test:dist": "npm run test:scripts:full && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", "test:dist:serial": "npm run test:scripts:full && node scripts/run-workspace-tests-parallel.mjs --serial", "test:fast": "npm run build:test && npm run test:scripts && node scripts/run-workspace-tests-parallel.mjs --concurrency=3", - "test:scripts": "node --test scripts/fixture-env.test.mjs scripts/electron-lifecycle.test.mjs scripts/check-story-annotations.test.mjs scripts/ci-test-plan.test.mjs scripts/run-headless-tests.test.mjs scripts/run-workspace-tests-parallel.test.mjs scripts/cu-e2e-scenarios.test.mjs scripts/cu-report-sanitize.test.mjs", + "test:scripts": "node --test scripts/fixture-env.test.mjs scripts/electron-lifecycle.test.mjs scripts/check-story-annotations.test.mjs scripts/ci-test-plan.test.mjs scripts/run-headless-tests.test.mjs scripts/run-workspace-tests-parallel.test.mjs scripts/cu-e2e-scenarios.test.mjs scripts/cu-report-sanitize.test.mjs apps/desktop/scripts/dev-app-runtime.test.mjs", "test:scripts:extended": "node --test scripts/cua-driver-provenance.test.mjs scripts/cu-provider-matrix.test.mjs scripts/cu-real-model-launcher.test.mjs scripts/macos-arm64-release.test.mjs scripts/measure-session-bundle.test.mjs", "test:scripts:full": "npm run test:scripts && npm run test:scripts:extended", "dev": "npm --workspace @maka/desktop run dev:hmr --", From c841d7164a2db29d979eb15b6715462c9a2efbc0 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 3 Aug 2026 00:19:34 +0800 Subject: [PATCH 3/8] fix(desktop): complete macOS dev permission flow --- apps/desktop/README.md | 5 + apps/desktop/scripts/dev-app-runtime.mjs | 135 ++++++++++++++---- apps/desktop/scripts/dev-app-runtime.test.mjs | 77 +++++++++- .../__tests__/os-permission-policy.test.ts | 22 ++- .../permission-overlay-controller.test.ts | 24 ++-- apps/desktop/src/main/os-permission-policy.ts | 9 +- .../src/main/permission-overlay/app-bundle.ts | 29 ++-- .../permission-overlay-main.ts | 47 +++--- apps/desktop/src/main/permissions-actions.ts | 24 +++- knip.json | 2 +- 10 files changed, 294 insertions(+), 80 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 73ff03bfa9..cc837fcf2f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,6 +14,11 @@ when the installed Electron version changes. Run The scripts launch the bundle through macOS LaunchServices rather than executing its internal binary from a terminal. This is required for TCC to attribute the running process to `Maka Dev` and recognize the stored grants. +The generated bundle also contains a small local bootstrap, so macOS's Screen +Recording “Quit & Reopen” action can reopen the repository app without relying +on command-line arguments that the system restart discards. +It uses `~/Library/Application Support/Maka Dev` for development state, keeping +development restarts isolated from the packaged Maka profile. Grant permissions to **Maka Dev**, not a generic Electron entry. Screen Recording changes require restarting the development app. Recreating the app or changing diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index fa0fd8f5fb..deca902ce6 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -1,6 +1,7 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; +import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -8,12 +9,13 @@ const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); const DEV_RUNTIME_DIR = join(DESKTOP_DIR, '.maka-dev'); const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); +const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); 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 DEV_BUNDLE_ID = 'com.maka.dev'; -const RUNTIME_SCHEMA_VERSION = 1; +const RUNTIME_SCHEMA_VERSION = 3; export function resolveMacosDevelopmentLaunch(appArgs, env = {}) { if (process.platform !== 'darwin') return null; @@ -21,7 +23,12 @@ export function resolveMacosDevelopmentLaunch(appArgs, env = {}) { return createMacosDevelopmentLaunch(appPath, appArgs, env); } -export function createMacosDevelopmentLaunch(appPath, appArgs, env = {}) { +export function createMacosDevelopmentLaunch( + appPath, + appArgs, + env = {}, + userDataDir = DEV_USER_DATA_DIR, +) { const launchEnvironment = []; if (env.VITE_DEV_SERVER_URL) { launchEnvironment.push('--env', `VITE_DEV_SERVER_URL=${env.VITE_DEV_SERVER_URL}`); @@ -37,7 +44,7 @@ export function createMacosDevelopmentLaunch(appPath, appArgs, env = {}) { '-a', appPath, '--args', - ...appArgs, + ...withDevelopmentUserData(appArgs, userDataDir), ], }; } @@ -60,45 +67,115 @@ export function prepareDevelopmentApp() { const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version; if (isCurrentRuntime(electronVersion)) return DEV_APP; - rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); - run('mkdir', ['-p', DEV_RUNTIME_DIR]); - run('ditto', [SOURCE_APP, DEV_APP]); - - const plist = join(DEV_APP, 'Contents', 'Info.plist'); - replacePlistValue(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); - replacePlistValue(plist, 'CFBundleName', 'Maka Dev'); - replacePlistValue(plist, 'CFBundleDisplayName', 'Maka Dev'); - // `ditto` does not copy quarantine metadata by default. Clear any root-level - // attribute without relying on the newer recursive `xattr -r` flag, which - // is unavailable on older supported macOS releases. - run('xattr', ['-c', DEV_APP]); - run('codesign', ['--force', '--deep', '--sign', '-', '--identifier', DEV_BUNDLE_ID, DEV_APP]); - run('codesign', ['--verify', '--deep', '--strict', DEV_APP]); + rebuildDevelopmentRuntime({ + reset: () => { + rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); + run('mkdir', ['-p', DEV_RUNTIME_DIR]); + }, + build: () => { + run('ditto', [SOURCE_APP, DEV_APP]); + const plist = join(DEV_APP, 'Contents', 'Info.plist'); + replacePlistValue(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); + replacePlistValue(plist, 'CFBundleName', 'Maka Dev'); + replacePlistValue(plist, 'CFBundleDisplayName', 'Maka Dev'); + installRelaunchBootstrap(); + // `ditto` does not copy quarantine metadata by default. Clear any root-level + // attribute without relying on the newer recursive `xattr -r` flag, which + // is unavailable on older supported macOS releases. + run('xattr', ['-c', DEV_APP]); + run('codesign', ['--force', '--deep', '--sign', '-', '--identifier', DEV_BUNDLE_ID, DEV_APP]); + run('codesign', ['--verify', '--deep', '--strict', DEV_APP]); + }, + writeMarker: () => { + writeFileSync( + MARKER, + `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + ); + }, + }); + return DEV_APP; +} +function installRelaunchBootstrap() { + const bootstrapDir = join(DEV_RUNTIME_DIR, 'relaunch-bootstrap'); + mkdirSync(bootstrapDir, { recursive: true }); writeFileSync( - MARKER, - `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + join(bootstrapDir, 'package.json'), + `${JSON.stringify({ name: 'maka-dev-relaunch', main: 'main.cjs', private: true })}\n`, ); - return DEV_APP; + writeFileSync( + join(bootstrapDir, 'main.cjs'), + createRelaunchBootstrapSource(DESKTOP_DIR, DEV_USER_DATA_DIR), + ); + const asarCli = join(REPO_ROOT, 'node_modules', '.bin', 'asar'); + run(asarCli, [ + 'pack', + bootstrapDir, + join(DEV_APP, 'Contents', 'Resources', 'default_app.asar'), + ]); + rmSync(bootstrapDir, { recursive: true, force: true }); +} + +export function createRelaunchBootstrapSource(desktopDir, userDataDir) { + return [ + "const { app } = require('electron');", + "const { join } = require('node:path');", + "const { pathToFileURL } = require('node:url');", + `const desktopDir = ${JSON.stringify(desktopDir)};`, + `const userDataDir = ${JSON.stringify(userDataDir)};`, + 'app.setAppPath(desktopDir);', + "app.setPath('userData', userDataDir);", + 'process.chdir(desktopDir);', + "import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", + " console.error('[maka-dev] relaunch bootstrap failed', error);", + ' app.exit(1);', + '});', + '', + ].join('\n'); +} + +function withDevelopmentUserData(appArgs, userDataDir) { + if (appArgs.some((arg) => arg.startsWith('--user-data-dir='))) return appArgs; + return [...appArgs, `--user-data-dir=${userDataDir}`]; } function isCurrentRuntime(electronVersion) { if (!existsSync(DEV_EXECUTABLE) || !existsSync(MARKER)) return false; try { const marker = JSON.parse(readFileSync(MARKER, 'utf8')); - if ( - (marker.schemaVersion ?? 1) !== RUNTIME_SCHEMA_VERSION || - marker.electronVersion !== electronVersion || - marker.bundleId !== DEV_BUNDLE_ID - ) { - return false; - } - return spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0; + return isDevelopmentRuntimeCurrent({ + marker, + schemaVersion: RUNTIME_SCHEMA_VERSION, + electronVersion, + bundleId: DEV_BUNDLE_ID, + signatureValid: + spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0, + }); } catch { return false; } } +export function isDevelopmentRuntimeCurrent(input) { + const marker = input.marker; + return ( + marker !== null && + typeof marker === 'object' && + marker.schemaVersion === input.schemaVersion && + marker.electronVersion === input.electronVersion && + marker.bundleId === input.bundleId && + input.signatureValid + ); +} + +export function rebuildDevelopmentRuntime(deps) { + deps.reset(); + deps.build(); + // The marker is the cache commit point. Never write it until copying, + // bootstrap generation, signing, and strict signature verification succeed. + deps.writeMarker(); +} + function replacePlistValue(plist, key, value) { const result = spawnSync('plutil', ['-replace', key, '-string', value, plist], { encoding: 'utf8', diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index db406d147a..6cc6af7ea6 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -1,9 +1,16 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createMacosDevelopmentLaunch } from './dev-app-runtime.mjs'; +import { + createMacosDevelopmentLaunch, + createRelaunchBootstrapSource, + isDevelopmentRuntimeCurrent, + rebuildDevelopmentRuntime, +} from './dev-app-runtime.mjs'; test('launches the signed development bundle through LaunchServices', () => { - assert.deepEqual(createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop', '--flag']), { + assert.deepEqual( + createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop', '--flag'], {}, '/dev-data'), + { command: 'open', args: [ '-n', @@ -13,8 +20,10 @@ test('launches the signed development bundle through LaunchServices', () => { '--args', '/repo/desktop', '--flag', + '--user-data-dir=/dev-data', ], - }); + }, + ); }); test('forwards the Vite URL without copying unrelated environment secrets', () => { @@ -28,3 +37,65 @@ test('forwards the Vite URL without copying unrelated environment secrets', () = ]); assert.equal(launch.args.some((arg) => arg.includes('do-not-forward')), false); }); + +test('boots the repository app with stable user data when macOS reopens without arguments', () => { + const source = createRelaunchBootstrapSource('/repo/apps/desktop', '/user-data/Maka Dev'); + assert.match(source, /app\.setAppPath\(desktopDir\)/); + assert.match(source, /app\.setPath\('userData', userDataDir\)/); + assert.match(source, /process\.chdir\(desktopDir\)/); + assert.match(source, /dist\/main\/main\.js/); + assert.match(source, /\/repo\/apps\/desktop/); + assert.match(source, /\/user-data\/Maka Dev/); +}); + +test('reuses only an exact, valid development runtime cache', () => { + const current = { + marker: { schemaVersion: 3, electronVersion: '43.1.1', bundleId: 'com.maka.dev' }, + schemaVersion: 3, + electronVersion: '43.1.1', + bundleId: 'com.maka.dev', + signatureValid: true, + }; + assert.equal(isDevelopmentRuntimeCurrent(current), true, 'exact cache hit'); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, electronVersion: '44.0.0' }), + false, + 'Electron change', + ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, schemaVersion: 4 }), + false, + 'schema change', + ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, bundleId: 'com.maka.other' }), + false, + 'bundle ID change', + ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, signatureValid: false }), + false, + 'failed signature verification', + ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, marker: { ...current.marker, schemaVersion: undefined } }), + false, + 'missing schema is never accepted as a legacy default', + ); +}); + +test('does not commit a cache marker when runtime preparation fails', () => { + let markerWritten = false; + assert.throws(() => + rebuildDevelopmentRuntime({ + reset: () => undefined, + build: () => { + throw new Error('codesign failed'); + }, + writeMarker: () => { + markerWritten = true; + }, + }), + ); + assert.equal(markerWritten, false); +}); diff --git a/apps/desktop/src/main/__tests__/os-permission-policy.test.ts b/apps/desktop/src/main/__tests__/os-permission-policy.test.ts index a70e6d28bf..a4a5f8763b 100644 --- a/apps/desktop/src/main/__tests__/os-permission-policy.test.ts +++ b/apps/desktop/src/main/__tests__/os-permission-policy.test.ts @@ -24,7 +24,17 @@ describe('OS permission platform policy', () => { assert.equal(supportsMediaPermissionProbe('microphone', 'linux'), false); }); - it('never advertises a request button that the main-process action cannot perform', () => { + it('advertises only request actions the main process can perform', () => { + assert.deepEqual(mediaPermissionActions({ + id: 'screen_recording', + platform: 'darwin', + status: 'denied', + }), { canOpenSettings: true, canRequest: true }); + assert.deepEqual(mediaPermissionActions({ + id: 'screen_recording', + platform: 'darwin', + status: 'granted', + }), { canOpenSettings: true, canRequest: false }); assert.deepEqual(mediaPermissionActions({ id: 'microphone', platform: 'darwin', @@ -43,6 +53,16 @@ describe('OS permission platform policy', () => { }); it('routes stale denied requests to System Settings and never fakes notification success', () => { + assert.equal(planPermissionRequest({ + id: 'screen_recording', + platform: 'darwin', + screenStatus: 'denied', + }), 'request_screen_capture'); + assert.equal(planPermissionRequest({ + id: 'screen_recording', + platform: 'darwin', + screenStatus: 'granted', + }), 'already_granted'); assert.equal(planPermissionRequest({ id: 'microphone', platform: 'darwin', diff --git a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts index a1d237fe73..ad519e5eb0 100644 --- a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts +++ b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts @@ -20,8 +20,8 @@ import { type PermissionOverlayWindowLike, } from '../permission-overlay/permission-overlay-controller.js'; import { + loadNativeBundleIcon, resolveAppBundle, - shouldLoadNativeBundleIcon, } from '../permission-overlay/app-bundle.js'; /** Deterministic timer wheel — no real time passes in these tests. */ @@ -295,13 +295,15 @@ describe('drag-to-grant permission overlay', () => { }); describe('app bundle resolution for the drag', () => { - it('loads native bundle icons only for packaged builds', () => { - assert.equal(shouldLoadNativeBundleIcon(true), true); - assert.equal( - shouldLoadNativeBundleIcon(false), - false, - 'development must not ask macOS to resolve the node_modules Electron.app icon', - ); + it('never calls the native icon loader for an unpackaged app', async () => { + let calls = 0; + const icon = await loadNativeBundleIcon(false, async () => { + calls += 1; + return 'icon'; + }); + assert.equal(icon, null); + assert.equal(calls, 0, 'unpackaged development must not call app.getFileIcon()'); + assert.equal(await loadNativeBundleIcon(true, async () => 'icon'), 'icon'); }); it('walks three levels up from the executable to the .app', () => { @@ -315,14 +317,14 @@ describe('app bundle resolution for the drag', () => { ); }); - it('resolves Electron.app in dev, which is the correct TCC identity there', () => { + it('resolves the containing app bundle independent of its development name', () => { assert.deepEqual( resolveAppBundle({ - executablePath: '/repo/node_modules/electron/dist/Electron.app/Contents/MacOS/Electron', + executablePath: '/repo/apps/desktop/.maka-dev/Maka Dev.app/Contents/MacOS/Electron', platform: 'darwin', exists: () => true, }), - { ok: true, bundlePath: '/repo/node_modules/electron/dist/Electron.app' }, + { ok: true, bundlePath: '/repo/apps/desktop/.maka-dev/Maka Dev.app' }, ); }); diff --git a/apps/desktop/src/main/os-permission-policy.ts b/apps/desktop/src/main/os-permission-policy.ts index ef4c28bbc6..7603ff94b3 100644 --- a/apps/desktop/src/main/os-permission-policy.ts +++ b/apps/desktop/src/main/os-permission-policy.ts @@ -31,14 +31,15 @@ export function mediaPermissionActions(input: { canOpenSettings: input.platform === 'darwin', canRequest: input.platform === 'darwin' - && input.id === 'microphone' - && input.status === 'not_determined', + && ((input.id === 'microphone' && input.status === 'not_determined') + || (input.id === 'screen_recording' && input.status !== 'granted')), }; } export type PermissionRequestPlan = | 'unsupported_platform' | 'already_granted' + | 'request_screen_capture' | 'request_microphone' | 'open_settings'; @@ -46,8 +47,12 @@ export function planPermissionRequest(input: { id: OsPermissionId; platform: NodeJS.Platform; microphoneStatus?: string; + screenStatus?: string; }): PermissionRequestPlan { if (input.platform !== 'darwin') return 'unsupported_platform'; + if (input.id === 'screen_recording') { + return input.screenStatus === 'granted' ? 'already_granted' : 'request_screen_capture'; + } if (input.id !== 'microphone') return 'open_settings'; if (input.microphoneStatus === 'granted') return 'already_granted'; if (input.microphoneStatus === 'not-determined') return 'request_microphone'; diff --git a/apps/desktop/src/main/permission-overlay/app-bundle.ts b/apps/desktop/src/main/permission-overlay/app-bundle.ts index 5b62446326..95b35567d6 100644 --- a/apps/desktop/src/main/permission-overlay/app-bundle.ts +++ b/apps/desktop/src/main/permission-overlay/app-bundle.ts @@ -11,9 +11,9 @@ * /Applications/Maka.app/Contents * /Applications/Maka.app <- what we drag * - * Under `electron .` the same walk lands on `Electron.app`, which is - * correct rather than a degradation: in dev the TCC identity really is - * Electron, so that is the bundle the user must grant. The only genuinely + * In the supported macOS development workflow the same walk lands on the + * generated, signed `Maka Dev.app`, which is also the TCC identity the user + * grants. The only genuinely * unresolvable case is a layout where the walk doesn't end in `.app` * (e.g. an unpacked CI tree), and the caller degrades explicitly there * instead of starting a drag that can never be accepted. @@ -34,15 +34,22 @@ export interface ResolveAppBundleDeps { } /** - * Reading a bundle icon is presentation-only. In development Electron's - * `app.getPath('exe')` resolves to the generic Electron.app under - * node_modules; asking macOS for that bundle's icon can terminate the native - * Electron process before the returned promise settles. Keep the native icon - * path for packaged Maka.app builds and let development use an empty drag - * image instead. + * Reading a bundle icon is presentation-only. The original unpackaged npm + * Electron runtime could terminate natively while macOS resolved its bundle + * icon, before the returned promise settled. Keep native icon loading for + * packaged Maka.app builds and let the signed Maka Dev workflow use an empty + * drag image instead. */ -export function shouldLoadNativeBundleIcon(isPackaged: boolean): boolean { - return isPackaged; +export async function loadNativeBundleIcon( + isPackaged: boolean, + load: () => Promise, +): Promise { + if (!isPackaged) return null; + try { + return await load(); + } catch { + return null; + } } export function resolveAppBundle(deps: ResolveAppBundleDeps): AppBundleResult { diff --git a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts index 1b3f25fbc4..4b453cb624 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts @@ -23,8 +23,8 @@ import { existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import type { UiLocale } from '@maka/core'; -import { openSystemPermissionPane } from '../permissions-actions.js'; -import { resolveAppBundle, shouldLoadNativeBundleIcon } from './app-bundle.js'; +import { openSystemPermissionPane, requestPermissionAccess } from '../permissions-actions.js'; +import { loadNativeBundleIcon, resolveAppBundle } from './app-bundle.js'; import { getPermissionOverlayCopy } from './permission-overlay-copy.js'; import { createPermissionOverlayController, @@ -81,17 +81,15 @@ export function createPermissionOverlayMain( } async function resolveAppIconDataUrl(bundlePath: string | null): Promise { - if (!bundlePath || !shouldLoadNativeBundleIcon(app.isPackaged)) return null; - try { - // nativeImage.createFromPath does not decode .icns reliably. Asking - // macOS for the bundle icon returns the same image Finder and the TCC - // list display, and works for both Maka.app and Electron.app in dev. - const icon = await app.getFileIcon(bundlePath, { size: 'large' }); - if (icon.isEmpty()) return null; - return icon.resize({ width: 64, height: 64 }).toDataURL(); - } catch { - return null; - } + if (!bundlePath) return null; + const icon = await loadNativeBundleIcon(app.isPackaged, () => + app.getFileIcon(bundlePath, { size: 'large' }), + ); + if (!icon || icon.isEmpty()) return null; + // nativeImage.createFromPath does not decode .icns reliably. Asking + // macOS for the bundle icon returns the same image Finder and the TCC + // list display for packaged Maka builds. + return icon.resize({ width: 64, height: 64 }).toDataURL(); } const controller = createPermissionOverlayController({ @@ -280,13 +278,12 @@ function attachCardGestures(win: import('electron').BrowserWindow): void { const fromRenderer = nativeImage.createFromDataURL(iconDataUrl); if (!fromRenderer.isEmpty()) icon = fromRenderer; } - if (icon.isEmpty() && shouldLoadNativeBundleIcon(app.isPackaged)) { - try { - const fallback = await app.getFileIcon(resolved.bundlePath, { size: 'large' }); - if (!fallback.isEmpty()) icon = fallback.resize({ width: 64, height: 64 }); - } catch { - // The file drag still works without a decorative drag image. - } + if (icon.isEmpty()) { + const fallback = await loadNativeBundleIcon(app.isPackaged, () => + app.getFileIcon(resolved.bundlePath, { size: 'large' }), + ); + if (fallback && !fallback.isEmpty()) icon = fallback.resize({ width: 64, height: 64 }); + // The file drag still works without a decorative drag image. } if (!win.isDestroyed()) win.webContents.startDrag({ file: resolved.bundlePath, icon }); @@ -305,9 +302,17 @@ export interface PermissionOverlayIpcDeps { */ export function registerPermissionOverlayIpc(deps: PermissionOverlayIpcDeps): void { const electron = requireElectron('electron') as Electron; - const { ipcMain } = electron; + const { ipcMain, systemPreferences } = electron; ipcMain.handle('permissions:startDragOnboarding', async (_event, id: unknown) => { + if (id === 'screen_recording') { + const requested = await requestPermissionAccess(id); + if (!requested.ok) return requested; + if (systemPreferences.getMediaAccessStatus('screen') === 'granted') return requested; + // A real capture request engages TCC, but macOS may still require the + // app bundle to be added in System Settings. Continue into the existing + // drag card instead of replacing that second half of the workflow. + } return deps.controller.start(id); }); } diff --git a/apps/desktop/src/main/permissions-actions.ts b/apps/desktop/src/main/permissions-actions.ts index ebd92acc88..04b7b315cd 100644 --- a/apps/desktop/src/main/permissions-actions.ts +++ b/apps/desktop/src/main/permissions-actions.ts @@ -19,7 +19,7 @@ * System Settings deep link. */ -import { shell, systemPreferences } from 'electron'; +import { desktopCapturer, shell, systemPreferences } from 'electron'; import type { OsPermissionId } from '@maka/core'; import { OS_PERMISSION_IDS } from '@maka/core'; import { planPermissionRequest } from './os-permission-policy.js'; @@ -87,12 +87,34 @@ export async function requestPermissionAccess(input: unknown): Promise Date: Mon, 3 Aug 2026 08:21:44 +0800 Subject: [PATCH 4/8] fix(desktop): preserve dev session across TCC restart --- apps/desktop/README.md | 14 +- apps/desktop/package.json | 1 + apps/desktop/scripts/dev-app-runtime.mjs | 287 ++++++++++++++---- apps/desktop/scripts/dev-app-runtime.test.mjs | 71 +++-- apps/desktop/scripts/dev.mjs | 27 +- apps/desktop/scripts/prepare-dev-app.mjs | 2 +- apps/desktop/scripts/start-dev-app.mjs | 45 ++- .../__tests__/os-permission-policy.test.ts | 14 + .../permission-overlay-controller.test.ts | 12 + apps/desktop/src/main/os-permission-policy.ts | 12 + .../permission-overlay-controller.ts | 10 + .../permission-overlay-main.ts | 14 +- apps/desktop/src/main/permissions-actions.ts | 22 +- knip.json | 5 +- package-lock.json | 2 + 15 files changed, 408 insertions(+), 130 deletions(-) diff --git a/apps/desktop/README.md b/apps/desktop/README.md index cc837fcf2f..67f38744e2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -14,12 +14,20 @@ when the installed Electron version changes. Run The scripts launch the bundle through macOS LaunchServices rather than executing its internal binary from a terminal. This is required for TCC to attribute the running process to `Maka Dev` and recognize the stored grants. -The generated bundle also contains a small local bootstrap, so macOS's Screen -Recording “Quit & Reopen” action can reopen the repository app without relying -on command-line arguments that the system restart discards. +The launcher remains alive as the development-session supervisor until the +terminal receives Ctrl-C or SIGTERM. The generated bundle contains a small local +bootstrap that reads a PID-validated, per-worktree session file, restoring the +Vite URL and a curated environment-variable allowlist before importing the main +process. macOS's Screen Recording “Quit & Reopen” action can therefore reconnect +to the same HMR session without relying on command-line arguments that the +system restart discards. Session files are written atomically with mode `0600`. It uses `~/Library/Application Support/Maka Dev` for development state, keeping development restarts isolated from the packaged Maka profile. +Only one supervised Maka Dev session can use a worktree at a time. Runtime +preparation is protected by a PID lock, and shutdown targets the app PID recorded +by that supervisor rather than every process sharing the development bundle ID. + Grant permissions to **Maka Dev**, not a generic Electron entry. Screen Recording changes require restarting the development app. Recreating the app or changing its Electron version may require granting permissions again. Other platforms diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 867d5ba47f..d0096a669f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -59,6 +59,7 @@ }, "devDependencies": { "@ant-design/icons-svg": "4.5.0", + "@electron/asar": "3.4.1", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@playwright/test": "^1.61.1", diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index deca902ce6..4d3c40d6aa 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -1,8 +1,19 @@ import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, + renameSync, + rmSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; +import { createPackage } from '@electron/asar'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); @@ -11,52 +22,67 @@ const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); const MARKER = join(DEV_RUNTIME_DIR, 'runtime.json'); +const SESSION_FILE = join(DEV_RUNTIME_DIR, 'session.json'); +const APP_PID_FILE = join(DEV_RUNTIME_DIR, 'app.pid'); +const RUNTIME_LOCK = `${DEV_RUNTIME_DIR}.lock`; const ELECTRON_PACKAGE = join(REPO_ROOT, 'node_modules', 'electron', 'package.json'); const SOURCE_APP = join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'Electron.app'); const DEV_BUNDLE_ID = 'com.maka.dev'; -const RUNTIME_SCHEMA_VERSION = 3; +const RUNTIME_SCHEMA_VERSION = 4; +const SESSION_SCHEMA_VERSION = 1; -export function resolveMacosDevelopmentLaunch(appArgs, env = {}) { - if (process.platform !== 'darwin') return null; - const appPath = prepareDevelopmentApp(); - return createMacosDevelopmentLaunch(appPath, appArgs, env); +export async function resolveMacosDevelopmentLaunch() { + if (!shouldUseMacosDevelopmentApp(process.platform)) return null; + const appPath = await prepareDevelopmentApp(); + return createMacosDevelopmentLaunch(appPath); } -export function createMacosDevelopmentLaunch( - appPath, - appArgs, - env = {}, - userDataDir = DEV_USER_DATA_DIR, -) { - const launchEnvironment = []; - if (env.VITE_DEV_SERVER_URL) { - launchEnvironment.push('--env', `VITE_DEV_SERVER_URL=${env.VITE_DEV_SERVER_URL}`); - } +export function shouldUseMacosDevelopmentApp(platform) { + return platform === 'darwin'; +} + +export function createMacosDevelopmentLaunch(appPath) { return { command: 'open', // LaunchServices must own process launch so macOS TCC attributes the // running executable to Maka Dev rather than to its parent terminal. args: [ '-n', - '-W', - ...launchEnvironment, '-a', appPath, - '--args', - ...withDevelopmentUserData(appArgs, userDataDir), ], }; } -export function quitMacosDevelopmentApp() { - if (process.platform !== 'darwin') return; - spawnSync('osascript', ['-e', `tell application id "${DEV_BUNDLE_ID}" to quit`], { - stdio: 'ignore', - }); +export async function quitMacosDevelopmentApp() { + if (process.platform !== 'darwin' || !existsSync(APP_PID_FILE)) return false; + let appProcess; + try { + appProcess = JSON.parse(readFileSync(APP_PID_FILE, 'utf8')); + } catch { + return false; + } + if (appProcess.supervisorPid !== process.pid) return false; + const pid = appProcess.pid; + if (!Number.isSafeInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false; + try { + process.kill(pid, 'SIGTERM'); + } catch { + return false; + } + await new Promise((resolve_) => setTimeout(resolve_, 500)); + if (isProcessAlive(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + // Exited between the liveness check and fallback signal. + } + } + return true; } -export function prepareDevelopmentApp() { +export async function prepareDevelopmentApp() { if (process.platform !== 'darwin') { throw new Error('Maka Dev.app is only available on macOS'); } @@ -66,37 +92,52 @@ export function prepareDevelopmentApp() { const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version; if (isCurrentRuntime(electronVersion)) return DEV_APP; + const releaseLock = acquirePidLock(RUNTIME_LOCK); - rebuildDevelopmentRuntime({ - reset: () => { - rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); - run('mkdir', ['-p', DEV_RUNTIME_DIR]); - }, - build: () => { - run('ditto', [SOURCE_APP, DEV_APP]); - const plist = join(DEV_APP, 'Contents', 'Info.plist'); - replacePlistValue(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); - replacePlistValue(plist, 'CFBundleName', 'Maka Dev'); - replacePlistValue(plist, 'CFBundleDisplayName', 'Maka Dev'); - installRelaunchBootstrap(); - // `ditto` does not copy quarantine metadata by default. Clear any root-level - // attribute without relying on the newer recursive `xattr -r` flag, which - // is unavailable on older supported macOS releases. - run('xattr', ['-c', DEV_APP]); - run('codesign', ['--force', '--deep', '--sign', '-', '--identifier', DEV_BUNDLE_ID, DEV_APP]); - run('codesign', ['--verify', '--deep', '--strict', DEV_APP]); - }, - writeMarker: () => { - writeFileSync( - MARKER, - `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, - ); - }, - }); + try { + // Another process may have completed preparation before this lock landed. + if (isCurrentRuntime(electronVersion)) return DEV_APP; + await rebuildDevelopmentRuntime({ + reset: () => { + rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); + run('mkdir', ['-p', DEV_RUNTIME_DIR]); + }, + build: async () => { + run('ditto', [SOURCE_APP, DEV_APP]); + const plist = join(DEV_APP, 'Contents', 'Info.plist'); + replacePlistValue(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); + replacePlistValue(plist, 'CFBundleName', 'Maka Dev'); + replacePlistValue(plist, 'CFBundleDisplayName', 'Maka Dev'); + await installRelaunchBootstrap(); + // `ditto` does not copy quarantine metadata by default. Clear any root-level + // attribute without relying on the newer recursive `xattr -r` flag, which + // is unavailable on older supported macOS releases. + run('xattr', ['-c', DEV_APP]); + run('codesign', [ + '--force', + '--deep', + '--sign', + '-', + '--identifier', + DEV_BUNDLE_ID, + DEV_APP, + ]); + run('codesign', ['--verify', '--deep', '--strict', DEV_APP]); + }, + writeMarker: () => { + writeFileSync( + MARKER, + `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + ); + }, + }); + } finally { + releaseLock(); + } return DEV_APP; } -function installRelaunchBootstrap() { +async function installRelaunchBootstrap() { const bootstrapDir = join(DEV_RUNTIME_DIR, 'relaunch-bootstrap'); mkdirSync(bootstrapDir, { recursive: true }); writeFileSync( @@ -105,26 +146,45 @@ function installRelaunchBootstrap() { ); writeFileSync( join(bootstrapDir, 'main.cjs'), - createRelaunchBootstrapSource(DESKTOP_DIR, DEV_USER_DATA_DIR), + createRelaunchBootstrapSource( + DESKTOP_DIR, + DEV_USER_DATA_DIR, + SESSION_FILE, + APP_PID_FILE, + ), ); - const asarCli = join(REPO_ROOT, 'node_modules', '.bin', 'asar'); - run(asarCli, [ - 'pack', + await createPackage( bootstrapDir, join(DEV_APP, 'Contents', 'Resources', 'default_app.asar'), - ]); + ); rmSync(bootstrapDir, { recursive: true, force: true }); } -export function createRelaunchBootstrapSource(desktopDir, userDataDir) { +export function createRelaunchBootstrapSource( + desktopDir, + defaultUserDataDir, + sessionFile = SESSION_FILE, + appPidFile = APP_PID_FILE, +) { return [ "const { app } = require('electron');", "const { join } = require('node:path');", "const { pathToFileURL } = require('node:url');", `const desktopDir = ${JSON.stringify(desktopDir)};`, - `const userDataDir = ${JSON.stringify(userDataDir)};`, + `const defaultUserDataDir = ${JSON.stringify(defaultUserDataDir)};`, + `const sessionFile = ${JSON.stringify(sessionFile)};`, + `const appPidFile = ${JSON.stringify(appPidFile)};`, + "let session = null;", + "try {", + " const candidate = JSON.parse(require('node:fs').readFileSync(sessionFile, 'utf8'));", + " process.kill(candidate.supervisorPid, 0);", + ` if (candidate.schemaVersion === ${SESSION_SCHEMA_VERSION}) session = candidate;`, + "} catch {}", + "if (session?.env) Object.assign(process.env, session.env);", + "const userDataDir = session?.userDataDir || defaultUserDataDir;", 'app.setAppPath(desktopDir);', "app.setPath('userData', userDataDir);", + "require('node:fs').writeFileSync(appPidFile, `${JSON.stringify({ pid: process.pid, supervisorPid: session?.supervisorPid })}\n`, { mode: 0o600 });", 'process.chdir(desktopDir);', "import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", " console.error('[maka-dev] relaunch bootstrap failed', error);", @@ -134,9 +194,73 @@ export function createRelaunchBootstrapSource(desktopDir, userDataDir) { ].join('\n'); } -function withDevelopmentUserData(appArgs, userDataDir) { - if (appArgs.some((arg) => arg.startsWith('--user-data-dir='))) return appArgs; - return [...appArgs, `--user-data-dir=${userDataDir}`]; +export function createDevelopmentSession(input) { + return { + schemaVersion: SESSION_SCHEMA_VERSION, + supervisorPid: input.supervisorPid, + userDataDir: input.userDataDir ?? DEV_USER_DATA_DIR, + env: selectDevelopmentEnvironment(input.env, input.viteUrl), + }; +} + +export function selectDevelopmentEnvironment(env, viteUrl) { + const selected = {}; + for (const [key, value] of Object.entries(env)) { + if (typeof value !== 'string') continue; + if (isAllowedDevelopmentEnvironmentKey(key)) selected[key] = value; + } + if (viteUrl) selected.VITE_DEV_SERVER_URL = viteUrl; + return selected; +} + +export function writeDevelopmentSession(session) { + mkdirSync(DEV_RUNTIME_DIR, { recursive: true }); + if (existsSync(SESSION_FILE)) { + try { + const current = JSON.parse(readFileSync(SESSION_FILE, 'utf8')); + if (current.supervisorPid !== process.pid && isProcessAlive(current.supervisorPid)) { + throw new Error(`Maka Dev session is already supervised by PID ${current.supervisorPid}`); + } + } catch (error) { + if (error instanceof Error && error.message.includes('already supervised')) throw error; + // Corrupt or stale sessions are replaced atomically below. + } + } + const temporary = `${SESSION_FILE}.tmp-${process.pid}`; + writeFileSync(temporary, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); + renameSync(temporary, SESSION_FILE); +} + +export function clearDevelopmentSession(supervisorPid = process.pid) { + try { + const current = JSON.parse(readFileSync(SESSION_FILE, 'utf8')); + if (current.supervisorPid === supervisorPid) unlinkSync(SESSION_FILE); + } catch {} +} + +function isAllowedDevelopmentEnvironmentKey(key) { + return ( + key.startsWith('MAKA_') || + key.startsWith('CUA_') || + [ + 'ANTHROPIC_API_KEY', + 'OPENAI_API_KEY', + 'DEEPSEEK_API_KEY', + 'TAVILY_API_KEY', + 'COPILOT_GITHUB_TOKEN', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'PATH', + 'PYTHONPATH', + 'SHELL', + 'NODE_ENV', + 'NO_COLOR', + 'COLORTERM', + 'TERM', + ].includes(key) + ); } function isCurrentRuntime(electronVersion) { @@ -168,12 +292,41 @@ export function isDevelopmentRuntimeCurrent(input) { ); } -export function rebuildDevelopmentRuntime(deps) { - deps.reset(); - deps.build(); +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 signature verification succeed. - deps.writeMarker(); + await deps.writeMarker(); +} + +function acquirePidLock(path) { + try { + const fd = openSync(path, 'wx', 0o600); + writeFileSync(fd, `${process.pid}\n`); + closeSync(fd); + } catch (error) { + if (error?.code !== 'EEXIST') throw error; + let owner = 0; + try { + owner = Number(readFileSync(path, 'utf8').trim()); + } catch {} + if (Number.isSafeInteger(owner) && owner > 0 && isProcessAlive(owner)) { + throw new Error(`Maka Dev runtime preparation is already running in PID ${owner}`); + } + rmSync(path, { force: true }); + return acquirePidLock(path); + } + return () => rmSync(path, { force: true }); +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } } function replacePlistValue(plist, key, value) { diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index 6cc6af7ea6..f986e642f0 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -2,50 +2,62 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { createMacosDevelopmentLaunch, + createDevelopmentSession, createRelaunchBootstrapSource, isDevelopmentRuntimeCurrent, rebuildDevelopmentRuntime, + selectDevelopmentEnvironment, + shouldUseMacosDevelopmentApp, } from './dev-app-runtime.mjs'; test('launches the signed development bundle through LaunchServices', () => { - assert.deepEqual( - createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop', '--flag'], {}, '/dev-data'), - { + assert.deepEqual(createMacosDevelopmentLaunch('/repo/Maka Dev.app'), { command: 'open', - args: [ - '-n', - '-W', - '-a', - '/repo/Maka Dev.app', - '--args', - '/repo/desktop', - '--flag', - '--user-data-dir=/dev-data', - ], - }, - ); + args: ['-n', '-a', '/repo/Maka Dev.app'], + }); + assert.equal(shouldUseMacosDevelopmentApp('darwin'), true); + assert.equal(shouldUseMacosDevelopmentApp('linux'), false); }); -test('forwards the Vite URL without copying unrelated environment secrets', () => { - const launch = createMacosDevelopmentLaunch('/repo/Maka Dev.app', ['/repo/desktop'], { +test('stores the Vite URL and curated environment without command-line secrets', () => { + const env = selectDevelopmentEnvironment({ VITE_DEV_SERVER_URL: 'http://localhost:5173', + OPENAI_API_KEY: 'openai-secret', + MAKA_MODEL: 'test-model', API_SECRET: 'do-not-forward', - }); - assert.deepEqual(launch.args.slice(2, 4), [ - '--env', - 'VITE_DEV_SERVER_URL=http://localhost:5173', - ]); - assert.equal(launch.args.some((arg) => arg.includes('do-not-forward')), false); + }, 'http://localhost:4173'); + assert.equal(env.VITE_DEV_SERVER_URL, 'http://localhost:4173'); + assert.equal(env.OPENAI_API_KEY, 'openai-secret'); + assert.equal(env.MAKA_MODEL, 'test-model'); + assert.equal('API_SECRET' in env, false); + assert.equal(createMacosDevelopmentLaunch('/repo/Maka Dev.app').args.includes('openai-secret'), false); }); -test('boots the repository app with stable user data when macOS reopens without arguments', () => { - const source = createRelaunchBootstrapSource('/repo/apps/desktop', '/user-data/Maka Dev'); +test('boots the repository app and recovers a live supervisor session on reopen', () => { + const source = createRelaunchBootstrapSource( + '/repo/apps/desktop', + '/user-data/Maka Dev', + '/repo/.maka-dev/session.json', + '/repo/.maka-dev/app.pid', + ); assert.match(source, /app\.setAppPath\(desktopDir\)/); assert.match(source, /app\.setPath\('userData', userDataDir\)/); assert.match(source, /process\.chdir\(desktopDir\)/); assert.match(source, /dist\/main\/main\.js/); assert.match(source, /\/repo\/apps\/desktop/); assert.match(source, /\/user-data\/Maka Dev/); + assert.match(source, /candidate\.supervisorPid/); + assert.match(source, /Object\.assign\(process\.env, session\.env\)/); + assert.match(source, /appPidFile/); +}); + +test('honors an explicit development user-data directory in the session', () => { + const session = createDevelopmentSession({ + supervisorPid: 42, + env: {}, + userDataDir: '/tmp/custom-profile', + }); + assert.equal(session.userDataDir, '/tmp/custom-profile'); }); test('reuses only an exact, valid development runtime cache', () => { @@ -82,11 +94,16 @@ test('reuses only an exact, valid development runtime cache', () => { false, 'missing schema is never accepted as a legacy default', ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, marker: 'corrupt' }), + false, + 'corrupt marker shape', + ); }); -test('does not commit a cache marker when runtime preparation fails', () => { +test('does not commit a cache marker when runtime preparation fails', async () => { let markerWritten = false; - assert.throws(() => + await assert.rejects(() => rebuildDevelopmentRuntime({ reset: () => undefined, build: () => { diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index 5d134407fd..298e3d5568 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -25,8 +25,11 @@ import { createServer } from 'vite'; import { build as esbuildBuild } from 'esbuild'; import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs'; import { + clearDevelopmentSession, + createDevelopmentSession, quitMacosDevelopmentApp, resolveMacosDevelopmentLaunch, + writeDevelopmentSession, } from './dev-app-runtime.mjs'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); @@ -147,7 +150,16 @@ if (!devUrl) { log('electron', `launching against ${devUrl} (renderer HMR live)`); const appArgs = [DESKTOP_DIR, ...process.argv.slice(2)]; -const macosLaunch = resolveMacosDevelopmentLaunch(appArgs, { VITE_DEV_SERVER_URL: devUrl }); +const macosLaunch = await resolveMacosDevelopmentLaunch(); +if (macosLaunch) { + const userDataArg = process.argv.slice(2).find((arg) => arg.startsWith('--user-data-dir=')); + writeDevelopmentSession(createDevelopmentSession({ + supervisorPid: process.pid, + viteUrl: devUrl, + env: process.env, + userDataDir: userDataArg?.slice('--user-data-dir='.length), + })); +} const electron = spawn(macosLaunch?.command ?? resolveElectronBin(), macosLaunch?.args ?? appArgs, { cwd: DESKTOP_DIR, stdio: 'inherit', @@ -158,7 +170,10 @@ let shuttingDown = false; async function shutdown(code, options = {}) { if (shuttingDown) return; shuttingDown = true; - if (macosLaunch) quitMacosDevelopmentApp(); + if (macosLaunch) { + await quitMacosDevelopmentApp(); + clearDevelopmentSession(); + } if (options.killElectron !== false) { await terminateProcessTree(electron); } @@ -181,7 +196,13 @@ function terminateProcessTree(child) { return Promise.resolve(); } -electron.on('exit', (code) => shutdown(code ?? 0, { killElectron: false })); +if (!macosLaunch) { + electron.on('exit', (code) => shutdown(code ?? 0, { killElectron: false })); +} else { + electron.on('exit', (code) => { + if (code && code !== 0) shutdown(code, { killElectron: false }); + }); +} electron.on('error', (err) => { console.error(`[dev] failed to start Electron: ${err.message}`); shutdown(1); diff --git a/apps/desktop/scripts/prepare-dev-app.mjs b/apps/desktop/scripts/prepare-dev-app.mjs index e0d7ace6e5..e84e78b4cb 100644 --- a/apps/desktop/scripts/prepare-dev-app.mjs +++ b/apps/desktop/scripts/prepare-dev-app.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node import { developmentAppPath, prepareDevelopmentApp } from './dev-app-runtime.mjs'; -prepareDevelopmentApp(); +await prepareDevelopmentApp(); console.log(`[dev-app] ready: ${developmentAppPath}`); diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs index e8a1e0ae8e..b4174014b2 100644 --- a/apps/desktop/scripts/start-dev-app.mjs +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -3,14 +3,26 @@ import { spawn } from 'node:child_process'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { + clearDevelopmentSession, + createDevelopmentSession, quitMacosDevelopmentApp, resolveMacosDevelopmentLaunch, + writeDevelopmentSession, } from './dev-app-runtime.mjs'; const desktopDir = resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = resolve(desktopDir, '..', '..'); -const forwardedArgs = [desktopDir, ...process.argv.slice(2)]; -const macosLaunch = resolveMacosDevelopmentLaunch(forwardedArgs); +const cliArgs = process.argv.slice(2); +const forwardedArgs = [desktopDir, ...cliArgs]; +const macosLaunch = await resolveMacosDevelopmentLaunch(); +if (macosLaunch) { + const userDataArg = cliArgs.find((arg) => arg.startsWith('--user-data-dir=')); + writeDevelopmentSession(createDevelopmentSession({ + supervisorPid: process.pid, + env: process.env, + userDataDir: userDataArg?.slice('--user-data-dir='.length), + })); +} const electronBin = process.platform === 'win32' ? join(repoRoot, 'node_modules', 'electron', 'dist', 'electron.exe') @@ -23,19 +35,34 @@ const child = spawn(command, args, { stdio: 'inherit', env: process.env, }); +const keepAlive = macosLaunch ? setInterval(() => undefined, 60_000) : null; let stopping = false; -function stop() { +async function stop() { if (stopping) return; stopping = true; - if (macosLaunch) quitMacosDevelopmentApp(); - else child.kill('SIGTERM'); + if (macosLaunch) { + await quitMacosDevelopmentApp(); + clearDevelopmentSession(); + } else child.kill('SIGTERM'); + if (keepAlive) clearInterval(keepAlive); + process.exitCode = 0; } child.on('error', (error) => { console.error(`[dev-app] failed to start: ${error.message}`); - process.exitCode = 1; + void stop().then(() => { + process.exitCode = 1; + }); }); child.on('exit', (code, signal) => { - if (!stopping) process.exitCode = signal ? 1 : (code ?? 0); + if (!macosLaunch && !stopping) { + process.exitCode = signal ? 1 : (code ?? 0); + } else if (macosLaunch && code && code !== 0) { + void stop().then(() => { + process.exitCode = code; + }); + } }); -process.on('SIGINT', stop); -process.on('SIGTERM', stop); +process.on('SIGINT', () => void stop()); +process.on('SIGTERM', () => void stop()); +// `open` exits immediately after handing the launch to LaunchServices. The +// timer above keeps the supervisor alive so app restarts can recover its session. diff --git a/apps/desktop/src/main/__tests__/os-permission-policy.test.ts b/apps/desktop/src/main/__tests__/os-permission-policy.test.ts index a4a5f8763b..31ec7671ad 100644 --- a/apps/desktop/src/main/__tests__/os-permission-policy.test.ts +++ b/apps/desktop/src/main/__tests__/os-permission-policy.test.ts @@ -4,6 +4,7 @@ import { mapMediaAccessStatus, mediaPermissionActions, planPermissionRequest, + requestScreenCaptureConsent, supportsMediaPermissionProbe, } from '../os-permission-policy.js'; @@ -97,4 +98,17 @@ describe('OS permission platform policy', () => { platform: 'linux', }), 'unsupported_platform'); }); + + it('attempts a real screen capture before deciding whether settings are needed', async () => { + let captures = 0; + assert.equal(await requestScreenCaptureConsent({ + capture: async () => { captures += 1; }, + status: () => 'denied', + }), 'open_settings'); + assert.equal(captures, 1); + assert.equal(await requestScreenCaptureConsent({ + capture: async () => { throw new Error('TCC denied'); }, + status: () => 'granted', + }), 'granted'); + }); }); diff --git a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts index ad519e5eb0..6db76d3796 100644 --- a/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts +++ b/apps/desktop/src/main/__tests__/permission-overlay-controller.test.ts @@ -16,6 +16,7 @@ import { GRANT_POLL_MS, createPermissionOverlayController, isDragGrantPermission, + startScreenRecordingOnboarding, type PermissionOverlayDeps, type PermissionOverlayWindowLike, } from '../permission-overlay/permission-overlay-controller.js'; @@ -132,6 +133,17 @@ function createHarness(overrides: Partial = {}) { } describe('drag-to-grant permission overlay', () => { + it('requests screen capture before continuing into the drag card', async () => { + const calls: string[] = []; + const result = await startScreenRecordingOnboarding({ + requestAccess: async () => { calls.push('request'); return { ok: true }; }, + isGranted: () => false, + startDrag: async () => { calls.push('drag'); return { ok: true }; }, + }); + assert.deepEqual(result, { ok: true }); + assert.deepEqual(calls, ['request', 'drag']); + }); + it('only recognises the two drag-to-grant permissions', () => { assert.equal(isDragGrantPermission('accessibility'), true); assert.equal(isDragGrantPermission('screen_recording'), true); diff --git a/apps/desktop/src/main/os-permission-policy.ts b/apps/desktop/src/main/os-permission-policy.ts index 7603ff94b3..d86af4a8f3 100644 --- a/apps/desktop/src/main/os-permission-policy.ts +++ b/apps/desktop/src/main/os-permission-policy.ts @@ -58,3 +58,15 @@ export function planPermissionRequest(input: { if (input.microphoneStatus === 'not-determined') return 'request_microphone'; return 'open_settings'; } + +export async function requestScreenCaptureConsent(deps: { + capture(): Promise; + status(): string; +}): Promise<'granted' | 'open_settings'> { + try { + await deps.capture(); + } catch { + // A denied first request is expected; System Settings is the recovery. + } + return deps.status() === 'granted' ? 'granted' : 'open_settings'; +} diff --git a/apps/desktop/src/main/permission-overlay/permission-overlay-controller.ts b/apps/desktop/src/main/permission-overlay/permission-overlay-controller.ts index adc09df0a3..63581bed25 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-controller.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-controller.ts @@ -36,6 +36,16 @@ export type OverlayStartResult = message?: string; }; +export async function startScreenRecordingOnboarding(deps: { + requestAccess(): Promise; + isGranted(): boolean; + startDrag(): Promise; +}): Promise { + const requested = await deps.requestAccess(); + if (!requested.ok || deps.isGranted()) return requested; + return deps.startDrag(); +} + /** The window surface the controller drives; faked in tests. */ export interface PermissionOverlayWindowLike { setBounds(bounds: { x: number; y: number; width: number; height: number }): void; diff --git a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts index 4b453cb624..97bae180aa 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts @@ -29,6 +29,7 @@ import { getPermissionOverlayCopy } from './permission-overlay-copy.js'; import { createPermissionOverlayController, isDragGrantPermission, + startScreenRecordingOnboarding, type DragGrantPermissionId, type PermissionOverlayController, type PermissionOverlayWindowLike, @@ -306,12 +307,13 @@ export function registerPermissionOverlayIpc(deps: PermissionOverlayIpcDeps): vo ipcMain.handle('permissions:startDragOnboarding', async (_event, id: unknown) => { if (id === 'screen_recording') { - const requested = await requestPermissionAccess(id); - if (!requested.ok) return requested; - if (systemPreferences.getMediaAccessStatus('screen') === 'granted') return requested; - // A real capture request engages TCC, but macOS may still require the - // app bundle to be added in System Settings. Continue into the existing - // drag card instead of replacing that second half of the workflow. + return startScreenRecordingOnboarding({ + requestAccess: () => requestPermissionAccess(id), + isGranted: () => systemPreferences.getMediaAccessStatus('screen') === 'granted', + // A real capture request engages TCC, but macOS may still require the + // bundle in System Settings. Preserve the drag-card second half. + startDrag: () => deps.controller.start(id), + }); } return deps.controller.start(id); }); diff --git a/apps/desktop/src/main/permissions-actions.ts b/apps/desktop/src/main/permissions-actions.ts index 04b7b315cd..0bdc272753 100644 --- a/apps/desktop/src/main/permissions-actions.ts +++ b/apps/desktop/src/main/permissions-actions.ts @@ -22,7 +22,7 @@ import { desktopCapturer, shell, systemPreferences } from 'electron'; import type { OsPermissionId } from '@maka/core'; import { OS_PERMISSION_IDS } from '@maka/core'; -import { planPermissionRequest } from './os-permission-policy.js'; +import { planPermissionRequest, requestScreenCaptureConsent } from './os-permission-policy.js'; export type PermissionActionResult = | { ok: true } @@ -102,16 +102,16 @@ export async function requestPermissionAccess(input: unknown): Promise { + await desktopCapturer.getSources({ + types: ['screen'], + thumbnailSize: { width: 1, height: 1 }, + }); + }, + status: () => systemPreferences.getMediaAccessStatus('screen'), + }); + return outcome === 'granted' ? { ok: true } : openSystemPermissionPane(id); } diff --git a/knip.json b/knip.json index 15dd26dc7e..e954d4a36a 100644 --- a/knip.json +++ b/knip.json @@ -22,10 +22,9 @@ "project": ["src/**/*.{ts,tsx}", "e2e/**/*.ts", "stories/**/*.{ts,tsx}", "scripts/**/*.mjs"], "ignoreDependencies": [ "@fontsource-variable/geist", - "@fontsource-variable/geist-mono", - "electron-builder" + "@fontsource-variable/geist-mono" ], - "ignoreBinaries": ["taskkill", "osascript", "plutil", "electron-builder"] + "ignoreBinaries": ["taskkill", "plutil"] }, "packages/ui": { "entry": ["src/**/*.test.ts", "src/**/*.test.tsx", "stories/**/*.@(ts|tsx)"], diff --git a/package-lock.json b/package-lock.json index 88e6f96e5a..e01683e56d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@ant-design/icons-svg": "4.5.0", + "@electron/asar": "3.4.1", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@playwright/test": "^1.61.1", @@ -68,6 +69,7 @@ "electron": "43.1.1", "electron-builder": "26.8.1", "esbuild": "^0.27.7", + "postcss": "^8.5.20", "simple-icons": "15.22.0", "storybook": "^10.4.6", "vite": "^8.1.5" From 36efa410db28ee52e4e356df33f032501a942829 Mon Sep 17 00:00:00 2001 From: gdliu3 Date: Mon, 3 Aug 2026 15:36:29 +0800 Subject: [PATCH 5/8] fix: satisfy biome format gate on knip.json The knip.json ignoreDependencies array was split across multiple lines, but the two entries fit within Biome's 100-char lineWidth, so "biome format" (the CI check-mode gate) wants them collapsed onto one line. Collapse the array to clear the format check. --- knip.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/knip.json b/knip.json index e954d4a36a..fb2d282c6a 100644 --- a/knip.json +++ b/knip.json @@ -20,10 +20,7 @@ "src/renderer/astryx-theme/makaTheme.ts" ], "project": ["src/**/*.{ts,tsx}", "e2e/**/*.ts", "stories/**/*.{ts,tsx}", "scripts/**/*.mjs"], - "ignoreDependencies": [ - "@fontsource-variable/geist", - "@fontsource-variable/geist-mono" - ], + "ignoreDependencies": ["@fontsource-variable/geist", "@fontsource-variable/geist-mono"], "ignoreBinaries": ["taskkill", "plutil"] }, "packages/ui": { From 8f3e1d10143e6422c2dad451f41e9fa8827a5235 Mon Sep 17 00:00:00 2001 From: gdliu3 Date: Mon, 3 Aug 2026 15:37:29 +0800 Subject: [PATCH 6/8] fix(desktop): harden dev app runtime against pid/session races and silent launch failures Several defects in the macOS dev app supervisor and runtime, all on the same session/launch lifecycle: - Write app.pid only after winning the single-instance lock. The bootstrap previously wrote it before acquiring the lock, so a losing second instance clobbered the winner's pid record and left the live app as an orphan the supervisor could no longer quit. - Isolate userData per linked git worktree. A shared profile made Chromium's single-instance lock treat a second worktree's app as a duplicate: it exited 0 while the supervisor kept serving Vite, giving "looks launched, no window". The primary checkout keeps its historical profile; linked worktrees (.git is a file) get a stable per-checkout profile. The bundle id stays com.maka.dev so TCC identity is unchanged. - Move session/pid state to a sibling dir that survives a runtime rebuild, and refuse a rebuild while a live app owns the lock. A rebuild rmSync'd the whole runtime dir, wiping the session and orphaning any running app; the state now outlives the rebuild and an occupied runtime fails early and legibly instead. - Probe for a live app after launch instead of trusting `open`'s exit code, which returns 0 the moment LaunchServices accepts the request. A crashed bootstrap now fails loudly rather than hanging the supervisor on its keep-alive timer with no window and no error. - Pin desktopDir in the runtime cache marker (schema 4 -> 5). The relaunch bootstrap bakes in an absolute path, so a moved repo must rebuild instead of loading a stale bootstrap. - Forward GH_TOKEN, GITHUB_TOKEN and RIVE_BIN through the curated dev environment (all consumed by dev tooling), and widen the SIGTERM->SIGKILL grace from 500ms to 3s so before-quit cleanup can finish. --- apps/desktop/.gitignore | 5 + apps/desktop/scripts/dev-app-runtime.mjs | 143 ++++++++++++++++-- apps/desktop/scripts/dev-app-runtime.test.mjs | 108 ++++++++++++- apps/desktop/scripts/start-dev-app.mjs | 12 ++ 4 files changed, 254 insertions(+), 14 deletions(-) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index f9fcbd2968..51ea2281af 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -2,3 +2,8 @@ test-results/ resources/workers/ .maka-dev/ +# Siblings of .maka-dev/ not covered by the directory rule above: +# RUNTIME_LOCK (`${DEV_RUNTIME_DIR}.lock`) and DEV_STATE_DIR +# (`${DEV_RUNTIME_DIR}-state`, session/pid state that survives rebuilds). +.maka-dev.lock +.maka-dev-state/ diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index 4d3c40d6aa..739bf24a2f 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -7,9 +7,11 @@ import { readFileSync, renameSync, rmSync, + statSync, unlinkSync, writeFileSync, } from 'node:fs'; +import { createHash } from 'node:crypto'; import { dirname, join, resolve } from 'node:path'; import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; @@ -18,18 +20,29 @@ import { createPackage } from '@electron/asar'; 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'); +// Session/pid state must survive a runtime rebuild (Electron upgrade, +// prepare:dev-app), which `rmSync`s DEV_RUNTIME_DIR wholesale. Keeping them in a +// sibling directory prevents an active app from becoming an unkillable orphan. +const DEV_STATE_DIR = `${DEV_RUNTIME_DIR}-state`; const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); -const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); +// Per-worktree userData. A shared profile makes Chromium's single-instance lock +// treat a second worktree's app as a duplicate -- it exits 0 while the +// supervisor keeps serving Vite, yielding "looks launched, no window". Keying on +// the checkout keeps the bundle id (com.maka.dev) stable for TCC while giving +// each worktree an independent lock + profile. +const DEV_USER_DATA_DIR = developmentUserDataDir(REPO_ROOT); const MARKER = join(DEV_RUNTIME_DIR, 'runtime.json'); -const SESSION_FILE = join(DEV_RUNTIME_DIR, 'session.json'); -const APP_PID_FILE = join(DEV_RUNTIME_DIR, 'app.pid'); +const SESSION_FILE = join(DEV_STATE_DIR, 'session.json'); +const APP_PID_FILE = join(DEV_STATE_DIR, 'app.pid'); const RUNTIME_LOCK = `${DEV_RUNTIME_DIR}.lock`; const ELECTRON_PACKAGE = join(REPO_ROOT, 'node_modules', 'electron', 'package.json'); const SOURCE_APP = join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'Electron.app'); const DEV_BUNDLE_ID = 'com.maka.dev'; -const RUNTIME_SCHEMA_VERSION = 4; +// Bumped to 5: marker now pins desktopDir (baked into the relaunch bootstrap), +// so a moved repo invalidates the cache instead of loading a stale path. +const RUNTIME_SCHEMA_VERSION = 5; const SESSION_SCHEMA_VERSION = 1; export async function resolveMacosDevelopmentLaunch() { @@ -42,6 +55,28 @@ export function shouldUseMacosDevelopmentApp(platform) { return platform === 'darwin'; } +// The primary checkout keeps the historical "Maka Dev" profile; linked git +// worktrees (`.git` is a file, not a directory) get a stable per-checkout +// profile so their apps do not collide on Chromium's single-instance lock. +export function developmentUserDataDir( + repoRoot, + supportDir = join(homedir(), 'Library', 'Application Support'), + isLinkedWorktree = defaultIsLinkedWorktree, +) { + const base = join(supportDir, 'Maka Dev'); + if (!isLinkedWorktree(repoRoot)) return base; + const suffix = createHash('sha256').update(repoRoot).digest('hex').slice(0, 8); + return `${base} (${suffix})`; +} + +function defaultIsLinkedWorktree(repoRoot) { + try { + return statSync(join(repoRoot, '.git')).isFile(); + } catch { + return false; + } +} + export function createMacosDevelopmentLaunch(appPath) { return { command: 'open', @@ -71,7 +106,10 @@ export async function quitMacosDevelopmentApp() { } catch { return false; } - await new Promise((resolve_) => setTimeout(resolve_, 500)); + // SIGTERM triggers Electron's `before-quit` cleanup (window teardown, session + // flush). Give that a few seconds to finish before escalating; 500ms was too + // tight and killed apps mid-cleanup. + await new Promise((resolve_) => setTimeout(resolve_, 3000)); if (isProcessAlive(pid)) { try { process.kill(pid, 'SIGKILL'); @@ -97,6 +135,16 @@ export async function prepareDevelopmentApp() { try { // Another process may have completed preparation before this lock landed. if (isCurrentRuntime(electronVersion)) return DEV_APP; + // A rebuild replaces the bundle and re-signs it. A live app from a previous + // supervisor holds the single-instance lock and cannot be quit by this + // process (supervisorPid mismatch), so rebuilding under it would brick the + // dev loop. Fail early and legibly instead. + const live = readLiveAppPid(); + if (live !== null) { + throw new Error( + `Maka Dev is still running (PID ${live}). Quit it before rebuilding the dev runtime.`, + ); + } await rebuildDevelopmentRuntime({ reset: () => { rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); @@ -127,7 +175,7 @@ export async function prepareDevelopmentApp() { writeMarker: () => { writeFileSync( MARKER, - `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID, desktopDir: DESKTOP_DIR }, null, 2)}\n`, ); }, }); @@ -184,12 +232,23 @@ export function createRelaunchBootstrapSource( "const userDataDir = session?.userDataDir || defaultUserDataDir;", 'app.setAppPath(desktopDir);', "app.setPath('userData', userDataDir);", - "require('node:fs').writeFileSync(appPidFile, `${JSON.stringify({ pid: process.pid, supervisorPid: session?.supervisorPid })}\n`, { mode: 0o600 });", - 'process.chdir(desktopDir);', - "import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", - " console.error('[maka-dev] relaunch bootstrap failed', error);", - ' app.exit(1);', - '});', + // Acquire the single-instance lock BEFORE recording app.pid. A losing + // second instance (another worktree/supervisor already owns userData) must + // exit without clobbering the winner's pid record -- otherwise the + // supervisor loses the handle it needs to quit the live app. main.ts calls + // requestSingleInstanceLock again; a same-process re-acquire returns true, + // so the surviving instance boots normally. + 'if (!app.requestSingleInstanceLock()) {', + ' app.exit(0);', + '} else {', + " require('node:fs').mkdirSync(require('node:path').dirname(appPidFile), { recursive: true });", + " require('node:fs').writeFileSync(appPidFile, `${JSON.stringify({ pid: process.pid, supervisorPid: session?.supervisorPid })}\n`, { mode: 0o600 });", + ' process.chdir(desktopDir);', + " import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", + " console.error('[maka-dev] relaunch bootstrap failed', error);", + ' app.exit(1);', + ' });', + '}', '', ].join('\n'); } @@ -214,7 +273,7 @@ export function selectDevelopmentEnvironment(env, viteUrl) { } export function writeDevelopmentSession(session) { - mkdirSync(DEV_RUNTIME_DIR, { recursive: true }); + mkdirSync(DEV_STATE_DIR, { recursive: true }); if (existsSync(SESSION_FILE)) { try { const current = JSON.parse(readFileSync(SESSION_FILE, 'utf8')); @@ -248,6 +307,9 @@ function isAllowedDevelopmentEnvironmentKey(key) { 'DEEPSEEK_API_KEY', 'TAVILY_API_KEY', 'COPILOT_GITHUB_TOKEN', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'RIVE_BIN', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', @@ -272,6 +334,7 @@ function isCurrentRuntime(electronVersion) { schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID, + desktopDir: DESKTOP_DIR, signatureValid: spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0, }); @@ -288,6 +351,7 @@ export function isDevelopmentRuntimeCurrent(input) { marker.schemaVersion === input.schemaVersion && marker.electronVersion === input.electronVersion && marker.bundleId === input.bundleId && + marker.desktopDir === input.desktopDir && input.signatureValid ); } @@ -329,6 +393,59 @@ function isProcessAlive(pid) { } } +// Returns the pid of a currently-running dev app if app.pid records one that is +// still alive, else null. Used to refuse a rebuild that would orphan it. +function readLiveAppPid() { + if (!existsSync(APP_PID_FILE)) return null; + try { + const record = JSON.parse(readFileSync(APP_PID_FILE, 'utf8')); + const pid = record.pid; + if (Number.isSafeInteger(pid) && pid > 0 && isProcessAlive(pid)) return pid; + } catch {} + return null; +} + +// `open -n -a` returns 0 the moment LaunchServices accepts the request, long +// before (or even if never) the app boots. The bootstrap only writes app.pid +// AFTER it wins the single-instance lock, so a live pid owned by this supervisor +// is the launch's liveness signal. Without this probe a crashed bootstrap leaves +// `npm start` hanging on the keep-alive timer with no window and no error. +export async function waitForDevelopmentAppLaunch(options = {}) { + const { + supervisorPid = process.pid, + timeoutMs = 15000, + intervalMs = 250, + // Injectable for tests; default to wall clock + real state. + now = () => Date.now(), + sleep = (ms) => new Promise((r) => setTimeout(r, ms)), + readRecord = defaultReadAppPidRecord, + isAlive = isProcessAlive, + } = options; + const deadline = now() + timeoutMs; + for (;;) { + const record = readRecord(); + if (record && record.supervisorPid === supervisorPid) { + const pid = record.pid; + if (Number.isSafeInteger(pid) && pid > 0 && isAlive(pid)) return pid; + } + if (now() >= deadline) { + throw new Error( + `Maka Dev did not start within ${timeoutMs}ms (no live app.pid). ` + + 'The bootstrap likely crashed; check the system log (Console.app) for "maka-dev".', + ); + } + await sleep(intervalMs); + } +} + +function defaultReadAppPidRecord() { + try { + return JSON.parse(readFileSync(APP_PID_FILE, 'utf8')); + } catch { + return null; + } +} + function replacePlistValue(plist, key, value) { const result = spawnSync('plutil', ['-replace', key, '-string', value, plist], { encoding: 'utf8', diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index f986e642f0..36c52e5a29 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -1,9 +1,12 @@ import assert from 'node:assert/strict'; +import { join } from 'node:path'; import test from 'node:test'; import { createMacosDevelopmentLaunch, createDevelopmentSession, createRelaunchBootstrapSource, + developmentUserDataDir, + waitForDevelopmentAppLaunch, isDevelopmentRuntimeCurrent, rebuildDevelopmentRuntime, selectDevelopmentEnvironment, @@ -49,6 +52,41 @@ test('boots the repository app and recovers a live supervisor session on reopen' assert.match(source, /candidate\.supervisorPid/); assert.match(source, /Object\.assign\(process\.env, session\.env\)/); assert.match(source, /appPidFile/); + // The single-instance lock must be acquired before app.pid is written, so a + // losing second instance never clobbers the winner's pid record. + assert.match(source, /app\.requestSingleInstanceLock\(\)/); + assert.ok( + source.indexOf('requestSingleInstanceLock') < source.indexOf('writeFileSync(appPidFile'), + 'lock acquisition must precede the app.pid write', + ); +}); + +test('forwards GitHub and Rive development credentials through the curated environment', () => { + const env = selectDevelopmentEnvironment({ + GH_TOKEN: 'gh-token', + GITHUB_TOKEN: 'github-token', + RIVE_BIN: '/opt/rive/bin/rive', + UNRELATED_SECRET: 'do-not-forward', + }); + assert.equal(env.GH_TOKEN, 'gh-token'); + assert.equal(env.GITHUB_TOKEN, 'github-token'); + assert.equal(env.RIVE_BIN, '/opt/rive/bin/rive'); + assert.equal('UNRELATED_SECRET' in env, false); +}); + +test('isolates userData per linked worktree while keeping the primary profile stable', () => { + const support = join('/Users', 'dev', 'Library', 'Application Support'); + // Primary checkout: `.git` is a directory -> historical profile, unchanged. + assert.equal( + developmentUserDataDir('/repo/main', support, () => false), + join(support, 'Maka Dev'), + ); + // Linked worktree: `.git` is a file -> stable, checkout-specific profile. + const worktreeProfile = developmentUserDataDir('/repo/wt-a', support, () => true); + assert.match(worktreeProfile, /Maka Dev \([0-9a-f]{8}\)$/); + // Different worktrees get different profiles; same worktree is deterministic. + assert.notEqual(worktreeProfile, developmentUserDataDir('/repo/wt-b', support, () => true)); + assert.equal(worktreeProfile, developmentUserDataDir('/repo/wt-a', support, () => true)); }); test('honors an explicit development user-data directory in the session', () => { @@ -62,13 +100,24 @@ test('honors an explicit development user-data directory in the session', () => test('reuses only an exact, valid development runtime cache', () => { const current = { - marker: { schemaVersion: 3, electronVersion: '43.1.1', bundleId: 'com.maka.dev' }, + marker: { + schemaVersion: 3, + electronVersion: '43.1.1', + bundleId: 'com.maka.dev', + desktopDir: '/repo/apps/desktop', + }, schemaVersion: 3, electronVersion: '43.1.1', bundleId: 'com.maka.dev', + desktopDir: '/repo/apps/desktop', signatureValid: true, }; assert.equal(isDevelopmentRuntimeCurrent(current), true, 'exact cache hit'); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, desktopDir: '/moved/apps/desktop' }), + false, + 'repo moved: bootstrap path in the marker is stale', + ); assert.equal( isDevelopmentRuntimeCurrent({ ...current, electronVersion: '44.0.0' }), false, @@ -101,6 +150,63 @@ test('reuses only an exact, valid development runtime cache', () => { ); }); +test('resolves once the bootstrap records a live app owned by this supervisor', async () => { + let clock = 0; + let polls = 0; + const pid = await waitForDevelopmentAppLaunch({ + supervisorPid: 100, + timeoutMs: 5000, + intervalMs: 250, + now: () => clock, + sleep: async () => { + clock += 250; + }, + // App.pid only appears (owned by this supervisor) after two polls. + readRecord: () => (polls++ < 2 ? null : { pid: 4242, supervisorPid: 100 }), + isAlive: () => true, + }); + assert.equal(pid, 4242); +}); + +test('rejects when no live app.pid appears before the launch deadline', async () => { + let clock = 0; + await assert.rejects( + () => + waitForDevelopmentAppLaunch({ + supervisorPid: 100, + timeoutMs: 1000, + intervalMs: 250, + now: () => clock, + sleep: async () => { + clock += 250; + }, + readRecord: () => null, // bootstrap crashed: pid never written + isAlive: () => true, + }), + /did not start within 1000ms/, + ); +}); + +test('ignores an app.pid owned by a different supervisor', async () => { + let clock = 0; + await assert.rejects( + () => + waitForDevelopmentAppLaunch({ + supervisorPid: 100, + timeoutMs: 1000, + intervalMs: 250, + now: () => clock, + sleep: async () => { + clock += 250; + }, + // A stale record from another supervisor must not count as our launch. + readRecord: () => ({ pid: 4242, supervisorPid: 999 }), + isAlive: () => true, + }), + /did not start within/, + ); +}); + test('does not commit a cache marker when runtime preparation fails', async () => { let markerWritten = false; await assert.rejects(() => diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs index b4174014b2..feb902ea1d 100644 --- a/apps/desktop/scripts/start-dev-app.mjs +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -7,6 +7,7 @@ import { createDevelopmentSession, quitMacosDevelopmentApp, resolveMacosDevelopmentLaunch, + waitForDevelopmentAppLaunch, writeDevelopmentSession, } from './dev-app-runtime.mjs'; @@ -66,3 +67,14 @@ process.on('SIGINT', () => void stop()); process.on('SIGTERM', () => void stop()); // `open` exits immediately after handing the launch to LaunchServices. The // timer above keeps the supervisor alive so app restarts can recover its session. +// Probe for a live app instead of trusting `open`'s exit code: a crashed +// bootstrap would otherwise leave this supervisor hanging on the keep-alive +// timer with no window and no error. +if (macosLaunch) { + waitForDevelopmentAppLaunch({ supervisorPid: process.pid }).catch((error) => { + console.error(`[dev-app] ${error.message}`); + void stop().then(() => { + process.exitCode = 1; + }); + }); +} From 8973abd58e162c9909332c3478ed12201e48773e Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 3 Aug 2026 21:34:20 +0800 Subject: [PATCH 7/8] fix(desktop): harden macOS dev supervision --- apps/desktop/.gitignore | 1 + apps/desktop/README.md | 13 +- apps/desktop/scripts/dev-app-runtime.mjs | 178 +++++++++++++++--- apps/desktop/scripts/dev-app-runtime.test.mjs | 153 ++++++++++++++- apps/desktop/scripts/dev.mjs | 13 ++ apps/desktop/scripts/start-dev-app.mjs | 15 ++ apps/desktop/src/main/main.ts | 40 ++++ 7 files changed, 381 insertions(+), 32 deletions(-) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index f9fcbd2968..06f866e05a 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -2,3 +2,4 @@ test-results/ resources/workers/ .maka-dev/ +.maka-dev-session/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index b626ce3c75..1e75da7da2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -20,13 +20,20 @@ bootstrap that reads a PID-validated, per-worktree session file, restoring the Vite URL and a curated environment-variable allowlist before importing the main process. macOS's Screen Recording “Quit & Reopen” action can therefore reconnect to the same HMR session without relying on command-line arguments that the -system restart discards. Session files are written atomically with mode `0600`. -It uses `~/Library/Application Support/Maka Dev` for development state, keeping -development restarts isolated from the packaged Maka profile. +system restart discards. Session files live in the ignored +`apps/desktop/.maka-dev-session/` directory, outside the rebuildable app bundle, +and are written atomically with mode `0600`. Startup is acknowledged only after +the single-instance lock and main-process boot succeed; failures and timeouts are +reported in the terminal instead of leaving a windowless supervisor running. + +The default profile is `~/Library/Application Support/Maka Dev-`. +This keeps development isolated from the packaged Maka profile and lets separate +worktrees run concurrently. An explicit `--user-data-dir` still takes precedence. Only one supervised Maka Dev session can use a worktree at a time. Runtime preparation is protected by a PID lock, and shutdown targets the app PID recorded by that supervisor rather than every process sharing the development bundle ID. +Runtime rebuilds refuse to proceed while that worktree has a live supervisor. Grant permissions to **Maka Dev**, not a generic Electron entry. Screen Recording changes require restarting the development app. Recreating the app or changing diff --git a/apps/desktop/scripts/dev-app-runtime.mjs b/apps/desktop/scripts/dev-app-runtime.mjs index 4d3c40d6aa..8007411afc 100644 --- a/apps/desktop/scripts/dev-app-runtime.mjs +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -1,4 +1,5 @@ import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; import { closeSync, existsSync, @@ -18,13 +19,21 @@ import { createPackage } from '@electron/asar'; 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 DEV_SESSION_DIR = join(DESKTOP_DIR, '.maka-dev-session'); const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); -const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); +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}`, +); const MARKER = join(DEV_RUNTIME_DIR, 'runtime.json'); -const SESSION_FILE = join(DEV_RUNTIME_DIR, 'session.json'); -const APP_PID_FILE = join(DEV_RUNTIME_DIR, 'app.pid'); -const RUNTIME_LOCK = `${DEV_RUNTIME_DIR}.lock`; +const SESSION_FILE = join(DEV_SESSION_DIR, 'session.json'); +const APP_PID_FILE = join(DEV_SESSION_DIR, 'app.pid'); +const LAUNCH_STATUS_FILE = join(DEV_SESSION_DIR, 'launch-status.json'); +const RUNTIME_LOCK = join(DEV_SESSION_DIR, 'runtime.lock'); const ELECTRON_PACKAGE = join(REPO_ROOT, 'node_modules', 'electron', 'package.json'); const SOURCE_APP = join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'Electron.app'); @@ -55,26 +64,33 @@ export function createMacosDevelopmentLaunch(appPath) { }; } -export async function quitMacosDevelopmentApp() { - if (process.platform !== 'darwin' || !existsSync(APP_PID_FILE)) return false; +export async function quitMacosDevelopmentApp(options = {}) { + const platform = options.platform ?? process.platform; + const appPidFile = options.appPidFile ?? APP_PID_FILE; + const supervisorPid = options.supervisorPid ?? process.pid; + const processAlive = options.processAlive ?? isProcessAlive; + const kill = options.kill ?? process.kill.bind(process); + const graceMs = options.graceMs ?? 3_000; + const delay = options.delay ?? ((ms) => new Promise((resolve_) => setTimeout(resolve_, ms))); + if (platform !== 'darwin' || !existsSync(appPidFile)) return false; let appProcess; try { - appProcess = JSON.parse(readFileSync(APP_PID_FILE, 'utf8')); + appProcess = JSON.parse(readFileSync(appPidFile, 'utf8')); } catch { return false; } - if (appProcess.supervisorPid !== process.pid) return false; + if (appProcess.supervisorPid !== supervisorPid) return false; const pid = appProcess.pid; - if (!Number.isSafeInteger(pid) || pid <= 0 || !isProcessAlive(pid)) return false; + if (!Number.isSafeInteger(pid) || pid <= 0 || !processAlive(pid)) return false; try { - process.kill(pid, 'SIGTERM'); + kill(pid, 'SIGTERM'); } catch { return false; } - await new Promise((resolve_) => setTimeout(resolve_, 500)); - if (isProcessAlive(pid)) { + await delay(graceMs); + if (processAlive(pid)) { try { - process.kill(pid, 'SIGKILL'); + kill(pid, 'SIGKILL'); } catch { // Exited between the liveness check and fallback signal. } @@ -92,6 +108,7 @@ export async function prepareDevelopmentApp() { const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version; if (isCurrentRuntime(electronVersion)) return DEV_APP; + assertNoActiveDevelopmentSession(); const releaseLock = acquirePidLock(RUNTIME_LOCK); try { @@ -127,7 +144,7 @@ export async function prepareDevelopmentApp() { writeMarker: () => { writeFileSync( MARKER, - `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID }, null, 2)}\n`, + `${JSON.stringify({ schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID, desktopDir: DESKTOP_DIR }, null, 2)}\n`, ); }, }); @@ -151,6 +168,7 @@ async function installRelaunchBootstrap() { DEV_USER_DATA_DIR, SESSION_FILE, APP_PID_FILE, + LAUNCH_STATUS_FILE, ), ); await createPackage( @@ -165,6 +183,7 @@ export function createRelaunchBootstrapSource( defaultUserDataDir, sessionFile = SESSION_FILE, appPidFile = APP_PID_FILE, + launchStatusFile = LAUNCH_STATUS_FILE, ) { return [ "const { app } = require('electron');", @@ -174,6 +193,7 @@ export function createRelaunchBootstrapSource( `const defaultUserDataDir = ${JSON.stringify(defaultUserDataDir)};`, `const sessionFile = ${JSON.stringify(sessionFile)};`, `const appPidFile = ${JSON.stringify(appPidFile)};`, + `const launchStatusFile = ${JSON.stringify(launchStatusFile)};`, "let session = null;", "try {", " const candidate = JSON.parse(require('node:fs').readFileSync(sessionFile, 'utf8'));", @@ -181,10 +201,18 @@ export function createRelaunchBootstrapSource( ` if (candidate.schemaVersion === ${SESSION_SCHEMA_VERSION}) session = candidate;`, "} catch {}", "if (session?.env) Object.assign(process.env, session.env);", + "for (const argument of session?.electronArgs || []) {", + " const match = /^--([^=]+)(?:=(.*))?$/.exec(argument);", + " if (match) app.commandLine.appendSwitch(match[1], match[2]);", + "}", "const userDataDir = session?.userDataDir || defaultUserDataDir;", 'app.setAppPath(desktopDir);', "app.setPath('userData', userDataDir);", - "require('node:fs').writeFileSync(appPidFile, `${JSON.stringify({ pid: process.pid, supervisorPid: session?.supervisorPid })}\n`, { mode: 0o600 });", + "if (session) {", + " process.env.MAKA_DEV_APP_PID_FILE = appPidFile;", + " process.env.MAKA_DEV_LAUNCH_STATUS_FILE = launchStatusFile;", + " process.env.MAKA_DEV_SUPERVISOR_PID = String(session.supervisorPid);", + "}", 'process.chdir(desktopDir);', "import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", " console.error('[maka-dev] relaunch bootstrap failed', error);", @@ -200,6 +228,7 @@ export function createDevelopmentSession(input) { supervisorPid: input.supervisorPid, userDataDir: input.userDataDir ?? DEV_USER_DATA_DIR, env: selectDevelopmentEnvironment(input.env, input.viteUrl), + electronArgs: input.electronArgs ?? [], }; } @@ -213,12 +242,16 @@ export function selectDevelopmentEnvironment(env, viteUrl) { return selected; } -export function writeDevelopmentSession(session) { - mkdirSync(DEV_RUNTIME_DIR, { recursive: true }); - if (existsSync(SESSION_FILE)) { +export function writeDevelopmentSession(session, options = {}) { + const sessionFile = options.sessionFile ?? SESSION_FILE; + const appPidFile = options.appPidFile ?? APP_PID_FILE; + const launchStatusFile = options.launchStatusFile ?? LAUNCH_STATUS_FILE; + const processAlive = options.processAlive ?? isProcessAlive; + mkdirSync(dirname(sessionFile), { recursive: true }); + if (existsSync(sessionFile)) { try { - const current = JSON.parse(readFileSync(SESSION_FILE, 'utf8')); - if (current.supervisorPid !== process.pid && isProcessAlive(current.supervisorPid)) { + const current = JSON.parse(readFileSync(sessionFile, 'utf8')); + if (current.supervisorPid !== session.supervisorPid && processAlive(current.supervisorPid)) { throw new Error(`Maka Dev session is already supervised by PID ${current.supervisorPid}`); } } catch (error) { @@ -226,15 +259,68 @@ export function writeDevelopmentSession(session) { // Corrupt or stale sessions are replaced atomically below. } } - const temporary = `${SESSION_FILE}.tmp-${process.pid}`; + const temporary = `${sessionFile}.tmp-${process.pid}`; + rmSync(appPidFile, { force: true }); + rmSync(launchStatusFile, { force: true }); writeFileSync(temporary, `${JSON.stringify(session, null, 2)}\n`, { mode: 0o600 }); - renameSync(temporary, SESSION_FILE); + renameSync(temporary, sessionFile); } -export function clearDevelopmentSession(supervisorPid = process.pid) { +export async function recoverStaleDevelopmentSession(options = {}) { + const sessionFile = options.sessionFile ?? SESSION_FILE; + const appPidFile = options.appPidFile ?? APP_PID_FILE; + const launchStatusFile = options.launchStatusFile ?? LAUNCH_STATUS_FILE; + const processAlive = options.processAlive ?? isProcessAlive; + let staleSupervisorPid; try { - const current = JSON.parse(readFileSync(SESSION_FILE, 'utf8')); - if (current.supervisorPid === supervisorPid) unlinkSync(SESSION_FILE); + const current = JSON.parse(readFileSync(sessionFile, 'utf8')); + staleSupervisorPid = current.supervisorPid; + } catch { + try { + staleSupervisorPid = JSON.parse(readFileSync(appPidFile, 'utf8')).supervisorPid; + } catch { + return false; + } + } + if (!Number.isSafeInteger(staleSupervisorPid) || processAlive(staleSupervisorPid)) return false; + await quitMacosDevelopmentApp({ + ...options, + appPidFile, + supervisorPid: staleSupervisorPid, + processAlive, + }); + clearDevelopmentSession(staleSupervisorPid, { sessionFile, appPidFile, launchStatusFile }); + return true; +} + +export function assertNoActiveDevelopmentSession(options = {}) { + const sessionFile = options.sessionFile ?? SESSION_FILE; + const processAlive = options.processAlive ?? isProcessAlive; + if (!existsSync(sessionFile)) return; + try { + const current = JSON.parse(readFileSync(sessionFile, 'utf8')); + if (Number.isSafeInteger(current.supervisorPid) && processAlive(current.supervisorPid)) { + throw new Error( + `Maka Dev session is already supervised by PID ${current.supervisorPid}; stop it before rebuilding the development app`, + ); + } + } catch (error) { + if (error instanceof Error && error.message.includes('already supervised')) throw error; + // Corrupt or stale ownership state cannot protect a live session. + } +} + +export function clearDevelopmentSession(supervisorPid = process.pid, options = {}) { + const sessionFile = options.sessionFile ?? SESSION_FILE; + const appPidFile = options.appPidFile ?? APP_PID_FILE; + const launchStatusFile = options.launchStatusFile ?? LAUNCH_STATUS_FILE; + try { + const current = JSON.parse(readFileSync(sessionFile, 'utf8')); + if (current.supervisorPid === supervisorPid) { + unlinkSync(sessionFile); + rmSync(appPidFile, { force: true }); + rmSync(launchStatusFile, { force: true }); + } } catch {} } @@ -248,6 +334,9 @@ function isAllowedDevelopmentEnvironmentKey(key) { 'DEEPSEEK_API_KEY', 'TAVILY_API_KEY', 'COPILOT_GITHUB_TOKEN', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'RIVE_BIN', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', @@ -272,6 +361,7 @@ function isCurrentRuntime(electronVersion) { schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID, + desktopDir: DESKTOP_DIR, signatureValid: spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0, }); @@ -288,6 +378,7 @@ export function isDevelopmentRuntimeCurrent(input) { marker.schemaVersion === input.schemaVersion && marker.electronVersion === input.electronVersion && marker.bundleId === input.bundleId && + marker.desktopDir === input.desktopDir && input.signatureValid ); } @@ -300,7 +391,9 @@ export async function rebuildDevelopmentRuntime(deps) { await deps.writeMarker(); } -function acquirePidLock(path) { +export function acquirePidLock(path, options = {}) { + const processAlive = options.processAlive ?? isProcessAlive; + mkdirSync(dirname(path), { recursive: true }); try { const fd = openSync(path, 'wx', 0o600); writeFileSync(fd, `${process.pid}\n`); @@ -311,11 +404,11 @@ function acquirePidLock(path) { try { owner = Number(readFileSync(path, 'utf8').trim()); } catch {} - if (Number.isSafeInteger(owner) && owner > 0 && isProcessAlive(owner)) { + if (Number.isSafeInteger(owner) && owner > 0 && processAlive(owner)) { throw new Error(`Maka Dev runtime preparation is already running in PID ${owner}`); } rmSync(path, { force: true }); - return acquirePidLock(path); + return acquirePidLock(path, options); } return () => rmSync(path, { force: true }); } @@ -345,3 +438,32 @@ function run(command, args) { } export const developmentAppPath = DEV_APP; + +export async function waitForMacosDevelopmentApp(options = {}) { + const supervisorPid = options.supervisorPid ?? process.pid; + const timeoutMs = options.timeoutMs ?? 30_000; + const pollMs = options.pollMs ?? 100; + const statusFile = options.statusFile ?? LAUNCH_STATUS_FILE; + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const status = JSON.parse(readFileSync(statusFile, 'utf8')); + if (status.supervisorPid !== supervisorPid) throw new Error('stale launch status'); + if (status.status === 'ready' && isProcessAlive(status.pid)) return status; + if (status.status === 'failed') { + throw new Error(status.message || 'Maka Dev failed during startup'); + } + } catch (error) { + if ( + error?.code !== 'ENOENT' && + !(error instanceof Error && error.message === 'stale launch status') + ) { + throw error; + } + } + await new Promise((resolve_) => setTimeout(resolve_, pollMs)); + } + throw new Error( + `Maka Dev did not finish starting within ${timeoutMs}ms; another worktree instance or an app boot failure may be blocking it`, + ); +} diff --git a/apps/desktop/scripts/dev-app-runtime.test.mjs b/apps/desktop/scripts/dev-app-runtime.test.mjs index f986e642f0..ad3d019964 100644 --- a/apps/desktop/scripts/dev-app-runtime.test.mjs +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -1,6 +1,12 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; import { + acquirePidLock, + assertNoActiveDevelopmentSession, + clearDevelopmentSession, createMacosDevelopmentLaunch, createDevelopmentSession, createRelaunchBootstrapSource, @@ -8,6 +14,10 @@ import { rebuildDevelopmentRuntime, selectDevelopmentEnvironment, shouldUseMacosDevelopmentApp, + quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, + waitForMacosDevelopmentApp, + writeDevelopmentSession, } from './dev-app-runtime.mjs'; test('launches the signed development bundle through LaunchServices', () => { @@ -23,12 +33,18 @@ test('stores the Vite URL and curated environment without command-line secrets', const env = selectDevelopmentEnvironment({ VITE_DEV_SERVER_URL: 'http://localhost:5173', OPENAI_API_KEY: 'openai-secret', + GH_TOKEN: 'github-secret', + GITHUB_TOKEN: 'github-fallback', + RIVE_BIN: '/tools/rive', MAKA_MODEL: 'test-model', API_SECRET: 'do-not-forward', }, 'http://localhost:4173'); assert.equal(env.VITE_DEV_SERVER_URL, 'http://localhost:4173'); assert.equal(env.OPENAI_API_KEY, 'openai-secret'); assert.equal(env.MAKA_MODEL, 'test-model'); + assert.equal(env.GH_TOKEN, 'github-secret'); + assert.equal(env.GITHUB_TOKEN, 'github-fallback'); + assert.equal(env.RIVE_BIN, '/tools/rive'); assert.equal('API_SECRET' in env, false); assert.equal(createMacosDevelopmentLaunch('/repo/Maka Dev.app').args.includes('openai-secret'), false); }); @@ -39,6 +55,7 @@ test('boots the repository app and recovers a live supervisor session on reopen' '/user-data/Maka Dev', '/repo/.maka-dev/session.json', '/repo/.maka-dev/app.pid', + '/repo/.maka-dev/launch-status.json', ); assert.match(source, /app\.setAppPath\(desktopDir\)/); assert.match(source, /app\.setPath\('userData', userDataDir\)/); @@ -49,6 +66,10 @@ test('boots the repository app and recovers a live supervisor session on reopen' assert.match(source, /candidate\.supervisorPid/); assert.match(source, /Object\.assign\(process\.env, session\.env\)/); assert.match(source, /appPidFile/); + assert.doesNotMatch(source, /writeFileSync\(appPidFile/); + assert.match(source, /MAKA_DEV_APP_PID_FILE/); + assert.match(source, /app\.commandLine\.appendSwitch/); + assert.doesNotThrow(() => new Function(source)); }); test('honors an explicit development user-data directory in the session', () => { @@ -56,19 +77,32 @@ test('honors an explicit development user-data directory in the session', () => supervisorPid: 42, env: {}, userDataDir: '/tmp/custom-profile', + electronArgs: ['--enable-logging', '--remote-debugging-port=9222'], }); assert.equal(session.userDataDir, '/tmp/custom-profile'); + assert.deepEqual(session.electronArgs, ['--enable-logging', '--remote-debugging-port=9222']); }); test('reuses only an exact, valid development runtime cache', () => { const current = { - marker: { schemaVersion: 3, electronVersion: '43.1.1', bundleId: 'com.maka.dev' }, + marker: { + schemaVersion: 3, + electronVersion: '43.1.1', + bundleId: 'com.maka.dev', + desktopDir: '/repo/apps/desktop', + }, schemaVersion: 3, electronVersion: '43.1.1', bundleId: 'com.maka.dev', + desktopDir: '/repo/apps/desktop', signatureValid: true, }; assert.equal(isDevelopmentRuntimeCurrent(current), true, 'exact cache hit'); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, desktopDir: '/moved/apps/desktop' }), + false, + 'repository path change', + ); assert.equal( isDevelopmentRuntimeCurrent({ ...current, electronVersion: '44.0.0' }), false, @@ -101,6 +135,123 @@ test('reuses only an exact, valid development runtime cache', () => { ); }); +test('session protocol atomically owns, rejects, and clears its state', () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-session-')); + const paths = { + sessionFile: join(dir, 'session.json'), + appPidFile: join(dir, 'app.pid'), + launchStatusFile: join(dir, 'launch-status.json'), + processAlive: (pid) => pid === 42, + }; + writeFileSync(paths.appPidFile, 'stale'); + writeFileSync(paths.launchStatusFile, 'stale'); + const session = createDevelopmentSession({ supervisorPid: 42, env: {} }); + writeDevelopmentSession(session, paths); + assert.equal(statSync(paths.sessionFile).mode & 0o777, 0o600); + assert.equal(JSON.parse(readFileSync(paths.sessionFile)).supervisorPid, 42); + assert.throws( + () => writeDevelopmentSession({ ...session, supervisorPid: 99 }, paths), + /already supervised by PID 42/, + ); + clearDevelopmentSession(99, paths); + assert.equal(JSON.parse(readFileSync(paths.sessionFile)).supervisorPid, 42); + clearDevelopmentSession(42, paths); + assert.throws(() => readFileSync(paths.sessionFile), /ENOENT/); +}); + +test('launch handshake reports ready, failed, and timeout states', async () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-launch-')); + const statusFile = join(dir, 'status.json'); + writeFileSync(statusFile, JSON.stringify({ status: 'ready', pid: process.pid, supervisorPid: 42 })); + assert.equal( + (await waitForMacosDevelopmentApp({ statusFile, supervisorPid: 42, timeoutMs: 20 })).pid, + process.pid, + ); + writeFileSync(statusFile, JSON.stringify({ status: 'failed', supervisorPid: 42, message: 'boom' })); + await assert.rejects( + waitForMacosDevelopmentApp({ statusFile, supervisorPid: 42, timeoutMs: 20 }), + /boom/, + ); + await assert.rejects( + waitForMacosDevelopmentApp({ statusFile: join(dir, 'missing'), timeoutMs: 5, pollMs: 1 }), + /did not finish starting/, + ); +}); + +test('PID-scoped quit waits for cleanup and uses a final fallback signal', async () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-quit-')); + const appPidFile = join(dir, 'app.pid'); + writeFileSync(appPidFile, JSON.stringify({ pid: 77, supervisorPid: 42 })); + const signals = []; + let checks = 0; + assert.equal( + await quitMacosDevelopmentApp({ + platform: 'darwin', + appPidFile, + supervisorPid: 42, + processAlive: () => checks++ < 2, + kill: (pid, signal) => signals.push([pid, signal]), + delay: async () => undefined, + }), + true, + ); + assert.deepEqual(signals, [[77, 'SIGTERM'], [77, 'SIGKILL']]); +}); + +test('a new supervisor recovers an orphan app before replacing stale state', async () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-recover-')); + const paths = { + sessionFile: join(dir, 'session.json'), + appPidFile: join(dir, 'app.pid'), + launchStatusFile: join(dir, 'launch-status.json'), + }; + writeFileSync(paths.sessionFile, JSON.stringify({ supervisorPid: 42 })); + writeFileSync(paths.appPidFile, JSON.stringify({ pid: 77, supervisorPid: 42 })); + writeFileSync(paths.launchStatusFile, 'stale'); + const signals = []; + const alive = new Set([77]); + assert.equal( + await recoverStaleDevelopmentSession({ + ...paths, + platform: 'darwin', + processAlive: (pid) => alive.has(pid), + kill: (pid, signal) => { + signals.push([pid, signal]); + alive.delete(pid); + }, + delay: async () => undefined, + }), + true, + ); + assert.deepEqual(signals, [[77, 'SIGTERM']]); + assert.throws(() => readFileSync(paths.sessionFile), /ENOENT/); + assert.throws(() => readFileSync(paths.appPidFile), /ENOENT/); +}); + +test('runtime lock rejects a live owner and recovers a stale owner', () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-lock-')); + const lock = join(dir, 'runtime.lock'); + writeFileSync(lock, '42\n'); + assert.throws(() => acquirePidLock(lock, { processAlive: () => true }), /PID 42/); + const release = acquirePidLock(lock, { processAlive: () => false }); + assert.equal(Number(readFileSync(lock, 'utf8').trim()), process.pid); + release(); + assert.throws(() => readFileSync(lock), /ENOENT/); +}); + +test('runtime rebuild is blocked while a supervised session is live', () => { + const dir = mkdtempSync(join(tmpdir(), 'maka-dev-owner-')); + const sessionFile = join(dir, 'session.json'); + writeFileSync(sessionFile, JSON.stringify({ supervisorPid: 42 })); + assert.throws( + () => assertNoActiveDevelopmentSession({ sessionFile, processAlive: () => true }), + /stop it before rebuilding/, + ); + assert.doesNotThrow(() => + assertNoActiveDevelopmentSession({ sessionFile, processAlive: () => false }), + ); +}); + test('does not commit a cache marker when runtime preparation fails', async () => { let markerWritten = false; await assert.rejects(() => diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index 298e3d5568..da64fc533c 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -28,7 +28,9 @@ import { clearDevelopmentSession, createDevelopmentSession, quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, resolveMacosDevelopmentLaunch, + waitForMacosDevelopmentApp, writeDevelopmentSession, } from './dev-app-runtime.mjs'; @@ -152,12 +154,14 @@ log('electron', `launching against ${devUrl} (renderer HMR live)`); const appArgs = [DESKTOP_DIR, ...process.argv.slice(2)]; const macosLaunch = await resolveMacosDevelopmentLaunch(); if (macosLaunch) { + await recoverStaleDevelopmentSession(); const userDataArg = process.argv.slice(2).find((arg) => arg.startsWith('--user-data-dir=')); writeDevelopmentSession(createDevelopmentSession({ supervisorPid: process.pid, viteUrl: devUrl, env: process.env, userDataDir: userDataArg?.slice('--user-data-dir='.length), + electronArgs: process.argv.slice(2).filter((arg) => !arg.startsWith('--user-data-dir=')), })); } const electron = spawn(macosLaunch?.command ?? resolveElectronBin(), macosLaunch?.args ?? appArgs, { @@ -207,5 +211,14 @@ electron.on('error', (err) => { console.error(`[dev] failed to start Electron: ${err.message}`); shutdown(1); }); +if (macosLaunch) { + void waitForMacosDevelopmentApp().then( + () => log('electron', 'Maka Dev startup handshake complete'), + (error) => { + console.error(`[dev] ${error.message}`); + shutdown(1); + }, + ); +} process.on('SIGINT', () => shutdown(0)); process.on('SIGTERM', () => shutdown(0)); diff --git a/apps/desktop/scripts/start-dev-app.mjs b/apps/desktop/scripts/start-dev-app.mjs index b4174014b2..6b7f2c4229 100644 --- a/apps/desktop/scripts/start-dev-app.mjs +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -6,7 +6,9 @@ import { clearDevelopmentSession, createDevelopmentSession, quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, resolveMacosDevelopmentLaunch, + waitForMacosDevelopmentApp, writeDevelopmentSession, } from './dev-app-runtime.mjs'; @@ -16,11 +18,13 @@ const cliArgs = process.argv.slice(2); const forwardedArgs = [desktopDir, ...cliArgs]; const macosLaunch = await resolveMacosDevelopmentLaunch(); if (macosLaunch) { + await recoverStaleDevelopmentSession(); const userDataArg = cliArgs.find((arg) => arg.startsWith('--user-data-dir=')); writeDevelopmentSession(createDevelopmentSession({ supervisorPid: process.pid, env: process.env, userDataDir: userDataArg?.slice('--user-data-dir='.length), + electronArgs: cliArgs.filter((arg) => !arg.startsWith('--user-data-dir=')), })); } const electronBin = @@ -64,5 +68,16 @@ child.on('exit', (code, signal) => { }); process.on('SIGINT', () => void stop()); process.on('SIGTERM', () => void stop()); +if (macosLaunch) { + void waitForMacosDevelopmentApp().then( + () => console.log('[dev-app] Maka Dev startup handshake complete'), + (error) => { + console.error(`[dev-app] ${error.message}`); + void stop().then(() => { + process.exitCode = 1; + }); + }, + ); +} // `open` exits immediately after handing the launch to LaunchServices. The // timer above keeps the supervisor alive so app restarts can recover its session. diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f491475f6b..56056f26b2 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,6 +1,27 @@ import { app, dialog } from 'electron'; +import { renameSync, writeFileSync } from 'node:fs'; import { isIsolatedE2e } from './startup-context.js'; +function writeDevelopmentLaunchRecord( + path: string | undefined, + record: Record, +): void { + if (app.isPackaged || !path) return; + try { + const temporary = `${path}.tmp-${process.pid}`; + writeFileSync(temporary, `${JSON.stringify(record)}\n`, { mode: 0o600 }); + renameSync(temporary, path); + } catch (error) { + console.error('[maka-dev] failed to report launch state:', error); + } +} + +function developmentLaunchIdentity(): { pid: number; supervisorPid: number } | undefined { + const supervisorPid = Number(process.env.MAKA_DEV_SUPERVISOR_PID); + if (!Number.isSafeInteger(supervisorPid) || supervisorPid <= 0) return undefined; + return { pid: process.pid, supervisorPid }; +} + // The macOS app menu title and app.getName() consumers read this name. Set it // before ready, unchanged from its historical pre-ready position. app.setName('Maka'); @@ -21,6 +42,10 @@ if (isIsolatedE2e && process.env.MAKA_E2E_USER_DATA_DIR) { if (!app.requestSingleInstanceLock()) { app.exit(0); } else { + const developmentIdentity = developmentLaunchIdentity(); + if (developmentIdentity) { + writeDevelopmentLaunchRecord(process.env.MAKA_DEV_APP_PID_FILE, developmentIdentity); + } // 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 @@ -34,8 +59,23 @@ if (!app.requestSingleInstanceLock()) { console.log('[startup] app ready'); return import('./boot.js'); }) + .then(() => { + if (developmentIdentity) { + writeDevelopmentLaunchRecord(process.env.MAKA_DEV_LAUNCH_STATUS_FILE, { + ...developmentIdentity, + status: 'ready', + }); + } + }) .catch((error: unknown) => { console.error('[startup] fatal:', error); + if (developmentIdentity) { + writeDevelopmentLaunchRecord(process.env.MAKA_DEV_LAUNCH_STATUS_FILE, { + ...developmentIdentity, + status: 'failed', + message: error instanceof Error ? error.message : String(error), + }); + } // E2E runs must not hang on a modal error box (same reasoning as the // fixture-fatal path in boot.ts: print a parseable line and exit fast). if (!isIsolatedE2e) { From f28213979a5c9674399d1562c29f34807242a0e1 Mon Sep 17 00:00:00 2001 From: liugddx Date: Mon, 3 Aug 2026 22:49:32 +0800 Subject: [PATCH 8/8] fix(scripts): keep legacy bundle measurement importable --- scripts/measure-session-bundle.mjs | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/measure-session-bundle.mjs b/scripts/measure-session-bundle.mjs index 49e104d248..5a4036823c 100644 --- a/scripts/measure-session-bundle.mjs +++ b/scripts/measure-session-bundle.mjs @@ -21,12 +21,7 @@ import { createInterface } from 'node:readline'; import { Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { - SESSION_BUNDLE_PORTABLE_SESSION_DIRECTORIES, - SESSION_BUNDLE_PORTABLE_SESSION_FILES, - SESSION_BUNDLE_STATE_ENTRIES, - isArtifactPathForSession, -} from '@maka/storage'; +import { SESSION_BUNDLE_STATE_ENTRIES, isArtifactPathForSession } from '@maka/storage'; import { STORAGE_ROOT_MARKER_FILE } from '@maka/storage/root-authority'; import { constants as zlibConstants, @@ -57,8 +52,23 @@ const SUPPORTED_OPTIONS = new Set([ // must be regenerated when a session export is materialized elsewhere. const STORAGE_ROOT_AUTHORITY_MARKER = STORAGE_ROOT_MARKER_FILE; const PORTABLE_STATE_TOP_LEVEL = new Set(SESSION_BUNDLE_STATE_ENTRIES); -const PORTABLE_SESSION_DIRECTORIES = new Set(SESSION_BUNDLE_PORTABLE_SESSION_DIRECTORIES); -const PORTABLE_SESSION_FILES = new Set(SESSION_BUNDLE_PORTABLE_SESSION_FILES); +// Legacy directory exports may still be supplied to this measurement tool, +// but storage's SQLite-only authority no longer exports these compatibility +// constants. Keep the historical read allowlist local to the legacy reader. +const PORTABLE_SESSION_DIRECTORIES = new Set([ + 'deep-research', + 'projections', + 'runs', + 'shell-runs', + 'turn-admissions', +]); +const PORTABLE_SESSION_FILES = new Set([ + 'execution-boundary.json', + 'plan-events.jsonl', + 'plans.json', + 'task-events.jsonl', + 'tasks.json', +]); const MAX_JSON_BYTES = 1_048_576; const EXCLUDED_WORKSPACE_SEGMENTS = new Set(['.git', 'node_modules']); const SENSITIVE_WORKSPACE_FILE_PATTERNS = [