/* * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file * distributed with this work for additional information * regarding copyright ownership. The ASF licenses this file * to you under the Apache License, Version 2.0 (the * "License"); you may not use this file except in compliance * with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * KIND, either express or implied. See the License for the * specific language governing permissions and limitations * under the License. */ /** * macOS development app for OS-permission work. * * macOS TCC will not keep an Accessibility or Screen Recording grant for an * unsigned executable launched from a terminal. It needs a stable bundle * identity, a verifiable signature, and a responsible process that is the app * bundle itself. That is what this module builds: an ad-hoc-signed * `Maka Dev.app` launched through LaunchServices. * * The bundle is a copy of the npm Electron app with its identity rewritten and * a small bootstrap injected. Everything the bootstrap needs is a build-time * constant, so a launch with no arguments and no environment — the Dock, * Spotlight, or the system's Screen Recording "Quit & Reopen" — reproduces a * correct app. The launcher's one-shot result file reports only the Electron * lock verdict; it does not supervise the app or carry application state. The * app instance and the dev session remain separate lifecycles: stopping the * launcher never terminates the TCC app. * * `app.isPackaged` is native and computed as * `basename(process.execPath) !== 'electron'`, so the invariant that keeps * every dev-mode gate alive is simply that the inner executable stays named * `Electron` — NOT the payload's location. (It is not derived from * `process.defaultApp`; that flag is set by the stock default_app this module * replaces, and is therefore undefined here.) * * The payload occupies `default_app.asar` rather than `Resources/app` so it * cannot shadow a real packaged app laid down at the standard location. */ import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs'; import { homedir, tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url))); const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..'); const DEV_RUNTIME_DIR = join(DESKTOP_DIR, '.maka-dev'); const STAGING_DIR = join(DESKTOP_DIR, '.maka-dev.staging'); const DEV_APP = join(DEV_RUNTIME_DIR, 'Maka Dev.app'); const STAGING_APP = join(STAGING_DIR, 'Maka Dev.app'); const DEV_EXECUTABLE = join(DEV_APP, 'Contents', 'MacOS', 'Electron'); const DEV_ENV_FILE = join(DEV_RUNTIME_DIR, 'dev-env.json'); const MARKER = join(DEV_RUNTIME_DIR, 'runtime.json'); const ELECTRON_PACKAGE = join(REPO_ROOT, 'node_modules', 'electron', 'package.json'); const SOURCE_APP = join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'Electron.app'); const WORKTREE_ID = createHash('sha256').update(REPO_ROOT).digest('hex').slice(0, 12); // Deliberately NOT worktree-scoped, unlike the bundle identifier above: plain // Electron and the TCC bundle must share the same single-instance authority. export const DEV_USER_DATA_DIR = join(homedir(), 'Library', 'Application Support', 'Maka Dev'); const DEV_ENV_SCHEMA_VERSION = 1; /** * Per-worktree, and deliberately so. An ad-hoc signature designates a bare * `cdhash`, and TCC keys its rows on the bundle identifier — so a shared * identifier would make every worktree overwrite the previous one's stored * requirement and silently break it. Scoping the identifier gives each worktree * its own durable, independently revocable grant. * * There is no explicit `designated => identifier` requirement here. It would * survive rebuilds, but it is forgeable by construction: `codesign --sign -` is * available to every unprivileged process, so any binary anywhere on the disk * can claim this identifier and satisfy the requirement. TCC rows outlive the * code they were granted to, so that would leave a permanently redeemable * Accessibility and Screen Recording token behind even after this repository is * deleted. The cdhash requirement costs a re-grant when the bundle is rebuilt — * which happens only on an Electron bump or a repository move, not on an * ordinary `npm run dev` — and that is the cheaper side of the trade. */ const DEV_BUNDLE_ID = `com.maka.dev.${WORKTREE_ID}`; const RUNTIME_SCHEMA_VERSION = 7; export const developmentAppPath = DEV_APP; async function prepareMacosDevelopmentLaunch(env = process.env) { if (!shouldUseMacosDevelopmentApp(process.platform, env)) return null; const appPath = await prepareDevelopmentApp(); const resultFileArgPrefix = (await devSingleInstanceConstants()) ?.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX; if (!resultFileArgPrefix) { throw new Error('Maka Dev launch-result contract is unavailable; rebuild @maka/core and retry.'); } return { appPath, resultFileArgPrefix }; } /** * The TCC bundle previously wrote per-worktree userData roots * (`Maka Dev-`); sharing the profile means this launch reads the * common `Maka Dev` root, so sessions and settings that lived in the legacy * directory are no longer read. They are NOT deleted — say where they are. */ export function warnAboutLegacyTccDataRoot(options = {}) { if ((options.platform ?? process.platform) !== 'darwin') return; const effectiveUserDataDir = options.effectiveUserDataDir ?? DEV_USER_DATA_DIR; const legacy = options.legacyUserDataDir ?? join(homedir(), 'Library', 'Application Support', `Maka Dev-${WORKTREE_ID}`); const exists = options.exists ?? existsSync; const warn = options.warn ?? console.warn; if (effectiveUserDataDir !== DEV_USER_DATA_DIR || legacy === DEV_USER_DATA_DIR || !exists(legacy)) { return; } warn( `[maka-dev] shared profile: your earlier TCC data (sessions, settings) lives in ${legacy} ` + 'and is no longer read. It is not deleted — copy it back if needed, or remove it.', ); } /** * The signed-bundle workflow exists only so TCC grants survive development. * It costs a codesign rebuild and a LaunchServices handoff that detaches the * app from the terminal's stdio, so `npm run dev` no longer prints main-process * logs. Developers who are not working on OS permissions should not pay that, * so it stays opt-in. */ export function shouldUseMacosDevelopmentApp(platform, env = process.env) { if (platform !== 'darwin') return false; const optIn = env.MAKA_DEV_TCC?.trim().toLowerCase(); return optIn === '1' || optIn === 'true'; } export function createMacosDevelopmentLaunch( appPath, logFile, resultFile, resultFileArgPrefix, ) { // LaunchServices must own the launch so macOS TCC attributes the running // executable to Maka Dev rather than to its parent terminal. const args = ['-n', '-a', appPath]; // Without this the detached app's output only reaches Console.app, which is // also where a bootstrap or boot failure would go silent. if (logFile) args.push('--stdout', logFile, '--stderr', logFile); // The private result path must come LAST: everything after `open --args` is // passed to the app, and the last launcher-owned value wins over any // user-supplied lookalike argument. if (resultFile && resultFileArgPrefix) { args.push('--args', `${resultFileArgPrefix}${resultFile}`); } return { command: 'open', args }; } export function createDevelopmentLaunchFiles(options = {}) { const directory = options.directory ?? join(tmpdir(), 'maka-dev-launches'); const id = options.id ?? randomUUID(); const logFile = join(directory, `${id}.log`); const resultFile = join(directory, `${id}.result.json`); const mkdir = options.mkdir ?? mkdirSync; const write = options.write ?? ((target) => writeFileSync(target, '', { mode: 0o600 })); mkdir(directory, { recursive: true, mode: 0o700 }); write(logFile); return { logFile, resultFile }; } /** * `pkill -f` / `pgrep -f` match an EXTENDED REGEX against the whole command * line, so a path is not a literal. A repository under `~/Dropbox (Personal)` * would otherwise match nothing — leaving the app un-killable — and an * unbalanced `[` makes the pattern fail to compile entirely. */ export function toProcessMatchPattern(executable) { return executable.replace(/[.[\]{}()*+?^$|\\]/g, '\\$&'); } /** * Electron's lock is authoritative. The plain launcher owns its child directly * and reads its exit code, while the detached TCC launcher consumes a private, * one-shot winner/loser result without taking ownership of the app process. * Constants are imported lazily so this module loads before libs are built. */ export async function devSingleInstanceConstants(loader = () => import('@maka/core/dev-single-instance')) { // Explicit failure contract: the import can fail on a fresh clone before // libs are built. Every consumer must handle undefined explicitly — a // throw here would crash the plain launcher. One warning, then undefined. try { return await loader(); } catch (error) { console.warn(`[maka-dev] dev single-instance constants unavailable: ${String(error)}`); return undefined; } } export async function readDevelopmentLaunchResult(resultFile, loader) { const constants = await devSingleInstanceConstants(loader); if (!constants) return undefined; try { return constants.parseDevelopmentLaunchResult(readFileSync(resultFile, 'utf8')); } catch { return undefined; } } /** Exit-code check for the plain child (child is the app process). */ export async function plainLoserExitCode(code, loader) { const constants = await devSingleInstanceConstants(loader); if (!constants) return false; // cannot attribute; do not invent a loser return code === constants.DEV_LOSER_EXIT_CODE; } export function isDevelopmentAppRunning(options = {}) { const bundle = options.executable ?? DEV_EXECUTABLE; const probe = options.probe ?? defaultLivenessProbe; return probe(bundle); } function defaultLivenessProbe(executable) { if (process.platform === 'win32') return false; const status = spawnSync('pgrep', ['-f', toProcessMatchPattern(executable)]).status; if (status !== 0 && status !== 1) { throw new Error(`pgrep failed for ${executable} (exit ${status})`); } return status === 0; } export function assertDevelopmentAppNotRunning(options = {}) { const isRunning = options.isRunning ?? isDevelopmentAppRunning; if (!isRunning(options)) return; throw new Error('Maka Dev.app is running. Quit it (Cmd-Q) and retry the rebuild.'); } /** * Application-control variables to persist for the app. * * `open` itself does forward the parent environment, so this file is not what * makes `npm run dev` work. It exists for the launches that have no parent * shell at all — Dock, Spotlight, and Screen Recording's "Quit & Reopen" — * which is exactly the path the whole design has to survive. * * That is also why the list is curated rather than a copy of `process.env`: * this content is written to disk and outlives the session. PATH is excluded * because a recorded PATH goes stale, and `shell-env.ts` resolves the * login-shell PATH in the main process for precisely the GUI-launch case. */ export function selectDevelopmentEnvironment(env, viteUrl) { const selected = {}; for (const [key, value] of Object.entries(env)) { if (typeof value !== 'string') continue; if (isForwardedEnvironmentKey(key)) selected[key] = value; } if (viteUrl) selected.VITE_DEV_SERVER_URL = viteUrl; return selected; } function isForwardedEnvironmentKey(key) { return ( key.startsWith('MAKA_') || key.startsWith('CUA_') || [ 'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'DEEPSEEK_API_KEY', 'TAVILY_API_KEY', 'COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN', 'RIVE_BIN', 'HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY', 'PYTHONPATH', 'NODE_ENV', 'NO_COLOR', ].includes(key) ); } /** * `--user-data-dir` cannot ride along in `electronArgs`: the bootstrap calls * `app.setPath('userData', …)`, which overrides the switch. It has to be * separated so the bootstrap can honour it as a value. */ export function splitDevelopmentCliArgs(argv = []) { const flag = '--user-data-dir='; return { userDataDir: argv.find((argument) => argument.startsWith(flag))?.slice(flag.length), electronArgs: argv.filter((argument) => !argument.startsWith(flag)), }; } export function createDevelopmentEnvironmentFile(input) { const { userDataDir, electronArgs } = splitDevelopmentCliArgs(input.argv); return { schemaVersion: DEV_ENV_SCHEMA_VERSION, env: selectDevelopmentEnvironment(input.env, input.viteUrl), userDataDir, electronArgs, }; } /** * Publishes the environment the app should adopt. This is plain data with no * owning process: a relaunch minutes later reads the same file and is just as * correct, which is what removes the need for session supervision. */ export function writeDevelopmentEnvironment(content, options = {}) { const file = options.file ?? DEV_ENV_FILE; mkdirSync(join(file, '..'), { recursive: true }); const temporary = `${file}.tmp-${process.pid}`; writeFileSync(temporary, `${JSON.stringify(content, null, 2)}\n`, { mode: 0o600 }); renameSync(temporary, file); } /** * The Vite URL a previous launcher published, if any. * * `npm start` does not run a dev server, but it may reuse the URL published by * the previous `npm run dev`. Republishing without the URL would drop the app to the * prebuilt renderer on disk — stale, or absent entirely on a fresh checkout. */ export function readPublishedViteUrl(file = DEV_ENV_FILE) { try { const published = JSON.parse(readFileSync(file, 'utf8')); if (published.schemaVersion !== DEV_ENV_SCHEMA_VERSION) return undefined; return published.env?.VITE_DEV_SERVER_URL; } catch { return undefined; } } function resolveElectronBinary() { for (let dir = DESKTOP_DIR; ; dir = dirname(dir)) { const executable = process.platform === 'win32' ? join(dir, 'node_modules', 'electron', 'dist', 'electron.exe') : join(dir, 'node_modules', '.bin', 'electron'); if (existsSync(executable)) return executable; if (dirname(dir) === dir) return 'electron'; } } /** * The single way to start the app, on every platform and both entry points. * * `npm run dev` and `npm start` previously each carried their own copy of * this — publish the environment, launch, follow the log, shut down — and the * copies drifted into different bugs rather than different behaviour. * * Returns a handle rather than a child process because the two paths are not * comparable: on the macOS bundle path `open` exits at the LaunchServices * handoff, so its exit code says nothing about the app. * * All asynchronous preparation finishes before launch artifacts or processes * are created. Once `signal` is aborted, this attempt cannot publish either. */ export async function startDevelopmentApp(options = {}) { const prepareMacosLaunch = options.prepareMacosDevelopmentLaunch ?? prepareMacosDevelopmentLaunch; const loadSingleInstanceConstants = options.devSingleInstanceConstants ?? devSingleInstanceConstants; const createLaunchFiles = options.createDevelopmentLaunchFiles ?? createDevelopmentLaunchFiles; const spawnProcess = options.spawn ?? spawn; const signal = options.signal; signal?.throwIfAborted(); const argv = options.argv ?? []; const { userDataDir } = splitDevelopmentCliArgs(argv); warnAboutLegacyTccDataRoot({ effectiveUserDataDir: userDataDir ?? DEV_USER_DATA_DIR }); // Read before preparing: a rebuild republishes the runtime directory and // takes the previous environment file with it. const viteUrl = options.viteUrl ?? readPublishedViteUrl(); const preparedLaunch = await prepareMacosLaunch(); signal?.throwIfAborted(); if (!preparedLaunch) { const launcherFlag = (await loadSingleInstanceConstants())?.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG; signal?.throwIfAborted(); const child = spawnProcess(resolveElectronBinary(), [DESKTOP_DIR, ...argv, ...(launcherFlag ? [launcherFlag] : [])], { cwd: DESKTOP_DIR, stdio: 'inherit', env: viteUrl ? { ...process.env, VITE_DEV_SERVER_URL: viteUrl } : process.env, }); child.once('exit', (code) => { void plainLoserExitCode(code).then((loser) => { if (!loser) return; console.error( 'Maka Dev could not start: another instance holds the shared profile lock ' + `(loser exit code ${code}). Quit it (Cmd-Q) and retry.`, ); }); }); return { child, isMacosBundle: false, stop: () => terminateProcessTree(child) }; } const files = createLaunchFiles(); const launch = { ...createMacosDevelopmentLaunch( preparedLaunch.appPath, files.logFile, files.resultFile, preparedLaunch.resultFileArgPrefix, ), ...files, }; writeDevelopmentEnvironment( createDevelopmentEnvironmentFile({ argv, env: process.env, viteUrl }), ); // LaunchServices detaches the app from this terminal's stdio, so `open` // redirects its output here and we follow it. Logs are observation only; // the lock verdict travels through launch.resultFile instead. const logStream = spawnProcess('tail', ['-n', '+1', '-F', launch.logFile], { stdio: ['ignore', 'inherit', 'inherit'], }); const child = spawnProcess(launch.command, launch.args, { cwd: DESKTOP_DIR, stdio: 'inherit', env: process.env, }); return { child, isMacosBundle: true, logFile: launch.logFile, resultFile: launch.resultFile, stop: () => cleanupDevelopmentLaunch({ logStream, logFile: launch.logFile, resultFile: launch.resultFile, }), }; } /** * Owns the dev launch from process signal through final resource cleanup. * Keeping this coupling here makes the cancellation wire itself testable: a * signal aborts the same attempt `start()` created, then joins and stops any * handle that attempt was already publishing. */ export function createDevelopmentLaunchSession(options = {}) { const startApp = options.startDevelopmentApp ?? startDevelopmentApp; const signals = options.signals ?? process; const close = options.close ?? (() => {}); const exit = options.exit ?? ((code) => process.exit(code)); const controller = new AbortController(); let launchPromise = null; let stopPromise = null; const stop = (code = 0) => { if (stopPromise) return stopPromise; controller.abort(); stopPromise = (async () => { const app = await launchPromise?.catch(() => null); await app?.stop(); try { await close(); } catch { // Shutdown is best-effort; the process must still reach its exit path. } exit(code); })(); return stopPromise; }; signals.on('SIGINT', () => stop(0)); signals.on('SIGTERM', () => stop(0)); signals.on('SIGHUP', () => stop(0)); return { start(startOptions = {}) { launchPromise = Promise.resolve(startApp({ ...startOptions, signal: controller.signal, })); return launchPromise.catch((error) => { if (controller.signal.aborted && error === controller.signal.reason) return null; throw error; }); }, stop, isStopping: () => stopPromise !== null, }; } export function cleanupDevelopmentLaunch({ logStream, logFile, resultFile }) { logStream.kill(); rmSync(logFile, { force: true }); rmSync(resultFile, { force: true }); } function terminateProcessTree(child) { if (child.exitCode !== null || child.killed) return Promise.resolve(); if (process.platform === 'win32' && child.pid) { return new Promise((done) => { const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: ['ignore', 'ignore', 'ignore'], }); killer.on('exit', () => done()); killer.on('error', () => done()); }); } child.kill('SIGTERM'); return Promise.resolve(); } /** * Waits only for Electron's lock verdict. After a winner is known, the detached * TCC app owns its own lifecycle; the launcher neither polls nor terminates it. */ export async function waitForDevelopmentLaunchVerdict(options = {}) { const readLaunchResult = options.readLaunchResult ?? (() => readDevelopmentLaunchResult(options.resultFile)); const delay = options.delay ?? ((ms) => new Promise((done) => setTimeout(done, ms))); const stopped = options.stopped ?? (() => false); const pollMs = options.pollMs ?? 250; const startupAttempts = options.startupAttempts ?? 120; for (let attempt = 0; attempt < startupAttempts; attempt += 1) { const result = await readLaunchResult(); if (result?.status === 'loser') return 'absorbed'; if (result?.status === 'winner') return 'started'; if (stopped()) return 'stopped'; await delay(pollMs); } return 'never-started'; } /** * Launch-outcome decision, fully contained: absorbed/never-started are failures, * stopped is a normal launcher exit, and started deliberately leaves the * launcher's own dev resources running without supervising Electron. */ export function handleDevelopmentLaunchOutcome(outcome, effects = {}) { const log = effects.log ?? console.error; const exit = effects.exit ?? ((code) => { process.exitCode = code; }); switch (outcome) { case 'started': return; case 'absorbed': log('Maka Dev was absorbed by another instance holding the profile lock; quitting.'); exit(1); return; case 'never-started': log('Maka Dev did not start within the startup window.'); exit(1); return; case 'stopped': exit(0); return; default: log(`Unknown Maka Dev monitor outcome: ${String(outcome)}`); exit(1); } } export async function prepareDevelopmentApp() { if (process.platform !== 'darwin') { throw new Error('Maka Dev.app is only available on macOS'); } if (!existsSync(SOURCE_APP)) { throw new Error(`Electron.app is missing at ${SOURCE_APP}; run npm install first`); } const electronVersion = JSON.parse(readFileSync(ELECTRON_PACKAGE, 'utf8')).version; if (isCurrentRuntime(electronVersion)) return DEV_APP; // Rebuilding unlinks the bundle an already-running app was launched from. // Refuse rather than terminating a process the launcher did not create. assertDevelopmentAppNotRunning(); try { await rebuildDevelopmentRuntime({ reset: () => { rmSync(STAGING_DIR, { recursive: true, force: true }); mkdirSync(STAGING_DIR, { recursive: true }); }, build: () => { run('ditto', [SOURCE_APP, STAGING_APP]); const plist = join(STAGING_APP, 'Contents', 'Info.plist'); setPlistString(plist, 'CFBundleIdentifier', DEV_BUNDLE_ID); setPlistString(plist, 'CFBundleName', 'Maka Dev'); setPlistString(plist, 'CFBundleDisplayName', 'Maka Dev'); // Stock Electron seals a SHA256 of its own default_app.asar here. We // replace that payload, so the record would describe a file that no // longer exists — inert today only because the integrity fuse is off. spawnSync('plutil', ['-remove', 'ElectronAsarIntegrity', plist]); installBootstrap(STAGING_APP); // `ditto` preserves quarantine by default (`--qtn`), so clear it from // the whole tree rather than the bundle root alone. run('xattr', ['-cr', STAGING_APP]); // No --identifier: `plutil` already set CFBundleIdentifier, and // codesign derives it per bundle. Passing it with --deep would stamp // the outer app's identifier onto all four nested helper bundles, // whose own identifier is com.github.Electron.helper. // // `--deep` is load-bearing rather than gratuitous here: the npm source // is linker-signed with no bundle seal, so without it the result does // not verify at all. Apple's deprecation concerns distribution // signing, where --deep discards per-target entitlements; this build // has none. run('codesign', ['--force', '--deep', '--sign', '-', STAGING_APP]); run('codesign', ['--verify', '--deep', '--strict', STAGING_APP]); // Publish by rename so a launcher never observes a partial bundle. // This does not make concurrent *builders* safe — they share one // staging path — but two rebuilds in one worktree is not a case worth // locking for. rmSync(DEV_RUNTIME_DIR, { recursive: true, force: true }); renameSync(STAGING_DIR, DEV_RUNTIME_DIR); }, writeMarker: () => { writeFileSync( MARKER, `${JSON.stringify(createRuntimeMarker(electronVersion), null, 2)}\n`, ); }, }); } finally { // A failed build otherwise leaves a ~250 MB copy of Electron.app behind // with no message. Publishing already moved the directory away. rmSync(STAGING_DIR, { recursive: true, force: true }); } return DEV_APP; } /** * The cache key this build commits, and the one `isDevelopmentRuntimeCurrent` * validates. Both sides must read from here: a field renamed on one side alone * would silently force a rebuild on every launch, churning the very bundle the * TCC grant is anchored to. */ export function createRuntimeMarker(electronVersion) { return { schemaVersion: RUNTIME_SCHEMA_VERSION, electronVersion, bundleId: DEV_BUNDLE_ID, desktopDir: DESKTOP_DIR, // Burned into the generated bootstrap, so a change here must invalidate // the cached bundle (isDevelopmentRuntimeCurrent compares every field). userDataDir: DEV_USER_DATA_DIR, }; } /** * Writes the payload as a plain directory named `default_app.asar`. Node * resolves it as an ordinary directory, so no archive step or asar dependency * is needed to occupy the path Electron looks for. */ export function installBootstrap(appPath = DEV_APP) { const payload = join(appPath, 'Contents', 'Resources', 'default_app.asar'); rmSync(payload, { recursive: true, force: true }); mkdirSync(payload, { recursive: true }); writeFileSync( join(payload, 'package.json'), `${JSON.stringify({ name: 'maka-dev', main: 'main.cjs', private: true }, null, 2)}\n`, ); writeFileSync( join(payload, 'main.cjs'), createBootstrapSource(DESKTOP_DIR, DEV_USER_DATA_DIR, DEV_ENV_FILE), ); } /** * Every value here is fixed at build time, which keeps the bundle's cdhash * stable across rebuilds — a rebuild does not invalidate an existing TCC grant. * The environment file is read opportunistically: if it is missing or stale the * app still starts, just without a dev server URL. */ export function createBootstrapSource(desktopDir, defaultUserDataDir, envFile) { return [ "const { app } = require('electron');", "const { join } = require('node:path');", "const { pathToFileURL } = require('node:url');", `const desktopDir = ${JSON.stringify(desktopDir)};`, 'let devEnv = null;', 'try {', ` const candidate = JSON.parse(require('node:fs').readFileSync(${JSON.stringify(envFile)}, 'utf8'));`, ` if (candidate.schemaVersion === ${DEV_ENV_SCHEMA_VERSION}) devEnv = candidate;`, '} catch {}', 'if (devEnv?.env) Object.assign(process.env, devEnv.env);', 'for (const argument of devEnv?.electronArgs || []) {', ' const match = /^--([^=]+)(?:=(.*))?$/.exec(argument);', ' if (match) app.commandLine.appendSwitch(match[1], match[2]);', '}', 'app.setAppPath(desktopDir);', `app.setPath('userData', devEnv?.userDataDir || ${JSON.stringify(defaultUserDataDir)});`, 'process.chdir(desktopDir);', "import(pathToFileURL(join(desktopDir, 'dist/main/main.js')).href).catch((error) => {", " console.error('[maka-dev] bootstrap failed', error);", ' app.exit(1);', '});', '', ].join('\n'); } function isCurrentRuntime(electronVersion) { if (!existsSync(DEV_EXECUTABLE) || !existsSync(MARKER)) return false; try { const marker = JSON.parse(readFileSync(MARKER, 'utf8')); // Compare the cheap marker before paying for a full bundle verification. if (!isDevelopmentRuntimeCurrent(marker, createRuntimeMarker(electronVersion))) return false; return spawnSync('codesign', ['--verify', '--deep', '--strict', DEV_APP]).status === 0; } catch { return false; } } /** * Every field the build committed must still hold. Comparing against a marker * built by `createRuntimeMarker` rather than against a hand-listed set means a * new cache input is covered the moment it is added there. */ export function isDevelopmentRuntimeCurrent(marker, expected) { if (marker === null || typeof marker !== 'object') return false; return Object.entries(expected).every(([key, value]) => marker[key] === value); } export async function rebuildDevelopmentRuntime(deps) { await deps.reset(); await deps.build(); // The marker is the cache commit point. Never write it until copying, // bootstrap generation, signing, and strict verification all succeed. await deps.writeMarker(); } function setPlistString(plist, key, value) { if (spawnSync('plutil', ['-replace', key, '-string', value, plist]).status === 0) return; run('plutil', ['-insert', key, '-string', value, plist]); } function run(command, args) { const result = spawnSync(command, args, { encoding: 'utf8', stdio: 'pipe' }); if (result.status === 0) return; const detail = result.stderr?.trim() || result.stdout?.trim() || `exit ${result.status}`; throw new Error(`${command} failed: ${detail}`); }