diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index 6b580e082e..06f866e05a 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1,3 +1,5 @@ # Playwright E2E run artifacts: traces, videos, screenshots, last-run state. test-results/ resources/workers/ +.maka-dev/ +.maka-dev-session/ diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 515909334c..1e75da7da2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,6 +2,44 @@ 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. +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 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 +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 7902cc47b4..dd92e544aa 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", @@ -58,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 new file mode 100644 index 0000000000..8007411afc --- /dev/null +++ b/apps/desktop/scripts/dev-app-runtime.mjs @@ -0,0 +1,469 @@ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +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, '..', '..'); +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 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_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'); + +const DEV_BUNDLE_ID = 'com.maka.dev'; +const RUNTIME_SCHEMA_VERSION = 4; +const SESSION_SCHEMA_VERSION = 1; + +export async function resolveMacosDevelopmentLaunch() { + if (!shouldUseMacosDevelopmentApp(process.platform)) return null; + const appPath = await prepareDevelopmentApp(); + return createMacosDevelopmentLaunch(appPath); +} + +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', + '-a', + appPath, + ], + }; +} + +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(appPidFile, 'utf8')); + } catch { + return false; + } + if (appProcess.supervisorPid !== supervisorPid) return false; + const pid = appProcess.pid; + if (!Number.isSafeInteger(pid) || pid <= 0 || !processAlive(pid)) return false; + try { + kill(pid, 'SIGTERM'); + } catch { + return false; + } + await delay(graceMs); + if (processAlive(pid)) { + try { + kill(pid, 'SIGKILL'); + } catch { + // Exited between the liveness check and fallback signal. + } + } + return true; +} + +export async function prepareDevelopmentApp() { + if (process.platform !== 'darwin') { + throw new Error('Maka Dev.app is only available on macOS'); + } + if (!existsSync(SOURCE_APP)) { + throw new Error(`Electron.app is missing at ${SOURCE_APP}; run npm install first`); + } + + const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version; + if (isCurrentRuntime(electronVersion)) return DEV_APP; + assertNoActiveDevelopmentSession(); + const releaseLock = acquirePidLock(RUNTIME_LOCK); + + 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, desktopDir: DESKTOP_DIR }, null, 2)}\n`, + ); + }, + }); + } finally { + releaseLock(); + } + return DEV_APP; +} + +async function installRelaunchBootstrap() { + const bootstrapDir = join(DEV_RUNTIME_DIR, 'relaunch-bootstrap'); + mkdirSync(bootstrapDir, { recursive: true }); + writeFileSync( + join(bootstrapDir, 'package.json'), + `${JSON.stringify({ name: 'maka-dev-relaunch', main: 'main.cjs', private: true })}\n`, + ); + writeFileSync( + join(bootstrapDir, 'main.cjs'), + createRelaunchBootstrapSource( + DESKTOP_DIR, + DEV_USER_DATA_DIR, + SESSION_FILE, + APP_PID_FILE, + LAUNCH_STATUS_FILE, + ), + ); + await createPackage( + bootstrapDir, + join(DEV_APP, 'Contents', 'Resources', 'default_app.asar'), + ); + rmSync(bootstrapDir, { recursive: true, force: true }); +} + +export function createRelaunchBootstrapSource( + desktopDir, + defaultUserDataDir, + sessionFile = SESSION_FILE, + appPidFile = APP_PID_FILE, + launchStatusFile = LAUNCH_STATUS_FILE, +) { + return [ + "const { app } = require('electron');", + "const { join } = require('node:path');", + "const { pathToFileURL } = require('node:url');", + `const desktopDir = ${JSON.stringify(desktopDir)};`, + `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'));", + " process.kill(candidate.supervisorPid, 0);", + ` 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);", + "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);", + ' app.exit(1);', + '});', + '', + ].join('\n'); +} + +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), + electronArgs: input.electronArgs ?? [], + }; +} + +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, 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(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) { + if (error instanceof Error && error.message.includes('already supervised')) throw error; + // Corrupt or stale sessions are replaced atomically below. + } + } + 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, sessionFile); +} + +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(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 {} +} + +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', + 'GH_TOKEN', + 'GITHUB_TOKEN', + 'RIVE_BIN', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'ALL_PROXY', + 'NO_PROXY', + 'PATH', + 'PYTHONPATH', + 'SHELL', + 'NODE_ENV', + 'NO_COLOR', + 'COLORTERM', + 'TERM', + ].includes(key) + ); +} + +function isCurrentRuntime(electronVersion) { + if (!existsSync(DEV_EXECUTABLE) || !existsSync(MARKER)) return false; + try { + const marker = JSON.parse(readFileSync(MARKER, 'utf8')); + return isDevelopmentRuntimeCurrent({ + marker, + schemaVersion: RUNTIME_SCHEMA_VERSION, + electronVersion, + bundleId: DEV_BUNDLE_ID, + desktopDir: DESKTOP_DIR, + 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 && + marker.desktopDir === input.desktopDir && + input.signatureValid + ); +} + +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. + await deps.writeMarker(); +} + +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`); + 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 && processAlive(owner)) { + throw new Error(`Maka Dev runtime preparation is already running in PID ${owner}`); + } + rmSync(path, { force: true }); + return acquirePidLock(path, options); + } + return () => rmSync(path, { force: true }); +} + +function isProcessAlive(pid) { + try { + process.kill(pid, 0); + return true; + } 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; + +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 new file mode 100644 index 0000000000..ad3d019964 --- /dev/null +++ b/apps/desktop/scripts/dev-app-runtime.test.mjs @@ -0,0 +1,269 @@ +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, + isDevelopmentRuntimeCurrent, + rebuildDevelopmentRuntime, + selectDevelopmentEnvironment, + shouldUseMacosDevelopmentApp, + quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, + waitForMacosDevelopmentApp, + writeDevelopmentSession, +} from './dev-app-runtime.mjs'; + +test('launches the signed development bundle through LaunchServices', () => { + assert.deepEqual(createMacosDevelopmentLaunch('/repo/Maka Dev.app'), { + command: 'open', + args: ['-n', '-a', '/repo/Maka Dev.app'], + }); + assert.equal(shouldUseMacosDevelopmentApp('darwin'), true); + assert.equal(shouldUseMacosDevelopmentApp('linux'), false); +}); + +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); +}); + +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', + '/repo/.maka-dev/launch-status.json', + ); + 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/); + 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', () => { + const session = createDevelopmentSession({ + 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', + 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, + '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', + ); + assert.equal( + isDevelopmentRuntimeCurrent({ ...current, marker: 'corrupt' }), + false, + 'corrupt marker shape', + ); +}); + +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(() => + rebuildDevelopmentRuntime({ + reset: () => undefined, + build: () => { + throw new Error('codesign failed'); + }, + writeMarker: () => { + markerWritten = true; + }, + }), + ); + assert.equal(markerWritten, false); +}); diff --git a/apps/desktop/scripts/dev.mjs b/apps/desktop/scripts/dev.mjs index bc73d952ce..da64fc533c 100644 --- a/apps/desktop/scripts/dev.mjs +++ b/apps/desktop/scripts/dev.mjs @@ -24,6 +24,15 @@ import { fileURLToPath } from 'node:url'; import { createServer } from 'vite'; import { build as esbuildBuild } from 'esbuild'; import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs'; +import { + clearDevelopmentSession, + createDevelopmentSession, + quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, + resolveMacosDevelopmentLaunch, + waitForMacosDevelopmentApp, + writeDevelopmentSession, +} from './dev-app-runtime.mjs'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); @@ -142,7 +151,20 @@ 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 = 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, { cwd: DESKTOP_DIR, stdio: 'inherit', env: { ...process.env, VITE_DEV_SERVER_URL: devUrl }, @@ -152,6 +174,10 @@ let shuttingDown = false; async function shutdown(code, options = {}) { if (shuttingDown) return; shuttingDown = true; + if (macosLaunch) { + await quitMacosDevelopmentApp(); + clearDevelopmentSession(); + } if (options.killElectron !== false) { await terminateProcessTree(electron); } @@ -174,10 +200,25 @@ 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); }); +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/prepare-dev-app.mjs b/apps/desktop/scripts/prepare-dev-app.mjs new file mode 100644 index 0000000000..e84e78b4cb --- /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'; + +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 new file mode 100644 index 0000000000..6b7f2c4229 --- /dev/null +++ b/apps/desktop/scripts/start-dev-app.mjs @@ -0,0 +1,83 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + clearDevelopmentSession, + createDevelopmentSession, + quitMacosDevelopmentApp, + recoverStaleDevelopmentSession, + resolveMacosDevelopmentLaunch, + waitForMacosDevelopmentApp, + writeDevelopmentSession, +} from './dev-app-runtime.mjs'; + +const desktopDir = resolve(fileURLToPath(new URL('..', import.meta.url))); +const repoRoot = resolve(desktopDir, '..', '..'); +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 = + 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, +}); +const keepAlive = macosLaunch ? setInterval(() => undefined, 60_000) : null; +let stopping = false; +async function stop() { + if (stopping) return; + stopping = true; + 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}`); + void stop().then(() => { + process.exitCode = 1; + }); +}); +child.on('exit', (code, signal) => { + if (!macosLaunch && !stopping) { + process.exitCode = signal ? 1 : (code ?? 0); + } else if (macosLaunch && code && code !== 0) { + void stop().then(() => { + process.exitCode = code; + }); + } +}); +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/__tests__/os-permission-policy.test.ts b/apps/desktop/src/main/__tests__/os-permission-policy.test.ts index a70e6d28bf..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'; @@ -24,7 +25,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 +54,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', @@ -77,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 7398fb8738..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,10 +16,14 @@ import { GRANT_POLL_MS, createPermissionOverlayController, isDragGrantPermission, + startScreenRecordingOnboarding, type PermissionOverlayDeps, type PermissionOverlayWindowLike, } from '../permission-overlay/permission-overlay-controller.js'; -import { resolveAppBundle } from '../permission-overlay/app-bundle.js'; +import { + loadNativeBundleIcon, + resolveAppBundle, +} from '../permission-overlay/app-bundle.js'; /** Deterministic timer wheel — no real time passes in these tests. */ function createClock() { @@ -129,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); @@ -292,6 +307,17 @@ describe('drag-to-grant permission overlay', () => { }); describe('app bundle resolution for the drag', () => { + 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', () => { assert.deepEqual( resolveAppBundle({ @@ -303,14 +329,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/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) { diff --git a/apps/desktop/src/main/os-permission-policy.ts b/apps/desktop/src/main/os-permission-policy.ts index ef4c28bbc6..d86af4a8f3 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,10 +47,26 @@ 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'; 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/app-bundle.ts b/apps/desktop/src/main/permission-overlay/app-bundle.ts index a3b3317638..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. @@ -33,6 +33,25 @@ export interface ResolveAppBundleDeps { exists(path: string): boolean; } +/** + * 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 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 { const { executablePath, platform, exists } = deps; if (platform !== 'darwin') { 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 143eb4fd80..97bae180aa 100644 --- a/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts +++ b/apps/desktop/src/main/permission-overlay/permission-overlay-main.ts @@ -23,12 +23,13 @@ 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 } 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, isDragGrantPermission, + startScreenRecordingOnboarding, type DragGrantPermissionId, type PermissionOverlayController, type PermissionOverlayWindowLike, @@ -82,16 +83,14 @@ export function createPermissionOverlayMain( async function resolveAppIconDataUrl(bundlePath: string | null): Promise { if (!bundlePath) 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; - } + 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({ @@ -281,12 +280,11 @@ function attachCardGestures(win: import('electron').BrowserWindow): void { if (!fromRenderer.isEmpty()) icon = fromRenderer; } if (icon.isEmpty()) { - 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. - } + 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 +303,18 @@ 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') { + 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 ebd92acc88..0bdc272753 100644 --- a/apps/desktop/src/main/permissions-actions.ts +++ b/apps/desktop/src/main/permissions-actions.ts @@ -19,10 +19,10 @@ * 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'; +import { planPermissionRequest, requestScreenCaptureConsent } from './os-permission-policy.js'; export type PermissionActionResult = | { ok: true } @@ -87,12 +87,34 @@ 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); + } case 'open_settings': return openSystemPermissionPane(id); case 'request_microphone': { diff --git a/knip.json b/knip.json index d0d791b197..07cc997ce2 100644 --- a/knip.json +++ b/knip.json @@ -22,12 +22,8 @@ "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", - "electron-builder" - ], - "ignoreBinaries": ["taskkill", "electron-builder"] + "ignoreDependencies": ["@fontsource-variable/geist", "@fontsource-variable/geist-mono"], + "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 11893f39ef..a0a3793db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,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/package.json b/package.json index 1cb6ab15c4..5bc9770d9b 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 scripts/computer-use-provenance.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 scripts/computer-use-provenance.test.mjs apps/desktop/scripts/dev-app-runtime.test.mjs", "test:scripts:extended": "node --test scripts/cu-provider-matrix.test.mjs scripts/cu-process-restart-harness.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 --", 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 = [