源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s

This commit is contained in:
agent-radar
2026-09-01 19:05:14 +08:00
commit 7be535059e
3358 changed files with 1306357 additions and 0 deletions
@@ -0,0 +1,359 @@
#!/usr/bin/env electron
/*
* 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.
*/
/**
* P5 — real Electron observe→act smoke for the embedded browser.
*
* Run as an Electron main entry (`electron scripts/browser-observe-act-smoke.mjs`,
* or `npm --workspace @maka/desktop run smoke:browser`). It drives the
* *production* automation path end to end against a live Chromium:
*
* BrowserViewController.attachAutomation() → CdpBridge (sealed ws server)
* → opencli CDPBridge.connect() → real webContents.debugger
* → snapshot / fillText / click / evaluate / extract
*
* This is the one seam the unit tests fake out (they stub the bridge + IPage):
* the real CDP round trip over a real DOM. A loopback HTTP server hosts a tiny
* form fixture, so the run is deterministic and needs no network.
*
* It then drives the visible-lease end to end through BrowserSession + the real
* host/manager/controller (the unit tests cover it only with a fake host): a
* background conversation's navigate/mutate is rejected and creates no view,
* while a shown conversation whose viewport has been reported can click/type.
*
* Exit 0 = every step passed; non-zero = a step failed or the run timed out.
*/
import { createServer } from 'node:http';
import { app, BrowserWindow, session } from 'electron';
import { CDPBridge } from '@jackwener/opencli/browser/cdp';
import { htmlToMarkdown } from '@jackwener/opencli/utils';
import { BrowserViewController } from '../dist/main/browser/controller.js';
import { BrowserViewManager } from '../dist/main/browser/view-manager.js';
import { createBrowserViewHost } from '../dist/main/browser/automation-host.js';
import { provideBrowserViewHost } from '../dist/main/browser/browser-host.js';
import {
withBrowserPage,
releaseBrowserSession,
revokeHiddenBrowserActions,
BrowserActionBlockedError,
BrowserActionRevokedError,
} from '../dist/main/browser/session.js';
const FIXTURE = `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Maka Smoke</title></head>
<body>
<h1>Maka observe-to-act smoke</h1>
<input id="q" type="text" aria-label="search query">
<button id="go" type="button">Go</button>
<div id="out"></div>
<script>
document.getElementById('go').addEventListener('click', () => {
document.getElementById('out').textContent = 'clicked:' + document.getElementById('q').value;
});
</script>
</body>
</html>`;
const RUN_TIMEOUT_MS = 60_000;
const log = (m) => console.log(`[browser-smoke] ${m}`);
/** First `[N]` ref on the snapshot line that mentions `needle`, or null. */
function findRef(snapshot, needle) {
for (const line of String(snapshot).split('\n')) {
if (!line.toLowerCase().includes(needle.toLowerCase())) continue;
const m = /\[(\d+)\]/.exec(line);
if (m) return m[1];
}
return null;
}
async function waitForPageValue(read, expected, timeoutMs = 1_000) {
const deadline = Date.now() + timeoutMs;
let actual;
do {
const remainingMs = deadline - Date.now();
if (remainingMs <= 0) break;
actual = await read(remainingMs);
if (actual === expected) return actual;
const pollDelayMs = Math.min(20, deadline - Date.now());
if (pollDelayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
}
} while (Date.now() < deadline);
return actual;
}
async function runSmoke() {
// Loopback fixture server, OS-assigned port — deterministic, no network.
const server = createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(FIXTURE);
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const fixtureUrl = `http://127.0.0.1:${server.address().port}/`;
log(`fixture served at ${fixtureUrl}`);
// The view must actually paint: opencli's native CDP click hit-tests a
// composited frame. A hidden window with a zero-bounds view never composites,
// so show the window (without stealing focus) and give the view real on-screen
// bounds, mirroring a displayed page.
const win = new BrowserWindow({ show: false, width: 1024, height: 768 });
win.showInactive();
const controller = new BrowserViewController(win, 'smoke', () => {});
controller.setViewport({ x: 0, y: 0, width: 1024, height: 768 });
const bridge = new CDPBridge();
// The visible-lease section (below) wires a real host over this manager;
// declared here so finally can tear it down.
let leaseManager = null;
const checks = [];
const check = (name, ok, detail = '') => {
checks.push({ name, ok });
log(`${ok ? 'PASS' : 'FAIL'} — ${name}${detail ? ` (${detail})` : ''}`);
};
try {
// The page-security backstop is installed once per shared partition session,
// not once per view: a second view must NOT add a second will-download listener.
const partition = session.fromPartition('persist:maka-browser');
const downloadListenersBefore = partition.listenerCount('will-download');
const controller2 = new BrowserViewController(win, 'smoke-backstop', () => {});
check(
'security backstop installs once per partition (no listener pileup)',
downloadListenersBefore === 1 &&
partition.listenerCount('will-download') === downloadListenersBefore,
`${downloadListenersBefore} -> ${partition.listenerCount('will-download')}`,
);
await controller2.dispose();
// Production attach: WebContents debugger → sealed loopback ws endpoint.
const endpoint = await controller.attachAutomation();
check('attachAutomation returns a loopback endpoint', /^ws:\/\/127\.0\.0\.1:\d+\//.test(endpoint.cdpEndpoint));
// Show the view before driving — the precondition the visible lease enforces
// in production, and what un-throttles the view so the native click below
// hit-tests a composited frame (throttling now tracks shown-ness, not attach).
controller.setViewport({ x: 0, y: 0, width: 1024, height: 768 });
// opencli connects as the CDP client (stealth auto-registers on connect).
const page = await bridge.connect({ cdpEndpoint: endpoint.cdpEndpoint });
check('opencli CDPBridge.connect resolves a page', typeof page?.snapshot === 'function');
// Navigate via the agent path (opencli goto, same call browser_navigate makes).
await page.goto(fixtureUrl, { waitUntil: 'load' });
const landed = (await page.getCurrentUrl?.()) ?? '';
check('goto lands on the fixture URL', landed.startsWith(fixtureUrl), landed);
// OBSERVE: snapshot the interactive elements and read their numbered refs.
const snapshot = await page.snapshot({ interactive: true });
const queryRef = findRef(snapshot, 'search query');
const submitRef = findRef(snapshot, 'Go');
check('snapshot lists the query field with a [ref]', Boolean(queryRef), `ref=${queryRef}`);
check('snapshot lists the Go button with a [ref]', Boolean(submitRef), `ref=${submitRef}`);
if (!queryRef || !submitRef) {
log(`--- snapshot dump ---\n${snapshot}\n--- end snapshot ---`);
throw new Error('snapshot did not expose the expected numbered refs');
}
// ── Visible-lease, end to end through BrowserSession + the real host ──────
// Exercises the wiring the fake-host unit tests can't: controller
// .hasLiveViewport() ← setViewport, the host's canDrive, and the gate firing
// before resolveEndpoint. Free the direct-driven view first so this section
// owns the window (the native click needs the one composited frame).
await bridge.close().catch(() => {});
await controller.dispose().catch(() => {});
leaseManager = new BrowserViewManager({ create: (id) => new BrowserViewController(win, id, () => {}) });
let shownSession = null;
provideBrowserViewHost(createBrowserViewHost(leaseManager, () => shownSession));
// Background conversation (not the one on screen): EVERY kind — read,
// navigate, mutate — is rejected, and the gate runs before acquire, so no
// view or connection is created. observe is the P2-A case (a backgrounded
// conversation must not snapshot/extract a logged-in page the user can't
// see); mutate is the click-on-a-hidden-view case. Both get real-host
// coverage here, not just the fake-host unit tests.
shownSession = 'other-conversation';
let bgNavBlocked = false;
try {
await withBrowserPage('leaseS', 'navigate', async () => {}, { takeover: 'navigate' });
} catch (err) {
bgNavBlocked = err instanceof BrowserActionBlockedError;
}
check('visible-lease rejects a background-conversation navigate', bgNavBlocked);
let bgMutateBlocked = false;
try {
await withBrowserPage('leaseS', 'click', async () => {}, { takeover: 'mutate' });
} catch (err) {
bgMutateBlocked = err instanceof BrowserActionBlockedError;
}
check('visible-lease rejects a background-conversation mutate', bgMutateBlocked);
let bgObserveBlocked = false;
try {
await withBrowserPage('leaseS', 'snapshot', async () => {}, { takeover: 'observe' });
} catch (err) {
bgObserveBlocked = err instanceof BrowserActionBlockedError;
}
check('visible-lease rejects a background-conversation observe (no off-screen reads)', bgObserveBlocked);
check('a lease-rejected action creates no view or connection', leaseManager.liveCount() === 0);
// Shown conversation: navigate is allowed (it creates the panel) and loads.
shownSession = 'leaseS';
await withBrowserPage('leaseS', 'navigate', async (lease) => lease.goto(fixtureUrl, { waitUntil: 'load' }), {
takeover: 'navigate',
});
// Renderer reports the on-screen viewport → observe to read the numbered refs.
leaseManager.get('leaseS').setViewport({ x: 0, y: 0, width: 1024, height: 768 });
const leaseSnap = await withBrowserPage('leaseS', 'snapshot', (lease) => lease.snapshot({ interactive: true }), {
takeover: 'observe',
});
const leaseQuery = findRef(leaseSnap, 'search query');
const leaseSubmit = findRef(leaseSnap, 'Go');
// Permission-modal race (the P1 fix): the modal hides the native view
// (viewport cleared) while open, and the renderer restores the strip a moment
// AFTER the user approves. A mutate firing in that gap must WAIT for the
// restore and land, not reject. Clear the viewport, start a mutate, then
// restore the viewport mid-flight — the type must still complete.
leaseManager.get('leaseS').setViewport(null);
const racingType = withBrowserPage('leaseS', 'type', (lease) => lease.fillText(leaseQuery, 'leased'), {
takeover: 'mutate',
});
setTimeout(() => leaseManager.get('leaseS').setViewport({ x: 0, y: 0, width: 1024, height: 768 }), 30);
let raceLanded = false;
try {
const result = await racingType;
raceLanded = result.verified === true && result.actual === 'leased';
} catch {
raceLanded = false;
}
check('visible-lease waits out a modal-close viewport restore and verifies the typed value', raceLanded);
// With the viewport back, a click drives the real DOM.
const leaseClick = await withBrowserPage('leaseS', 'click', (lease) => lease.click(leaseSubmit), {
takeover: 'mutate',
});
check(
'visible-lease click resolves a single match by ref',
leaseClick.matches_n === 1,
`matches_n=${leaseClick.matches_n}`,
);
const leaseOut = await waitForPageValue(
(remainingMs) =>
withBrowserPage(
'leaseS',
'read',
(lease) => lease.evaluate('document.getElementById("out").textContent'),
{ takeover: 'observe', timeoutMs: remainingMs },
),
'clicked:leased',
);
check('visible-lease allows + lands a click once shown with a viewport', leaseOut === 'clicked:leased');
// EXTRACT through the same production session path.
const leaseHtml = await withBrowserPage(
'leaseS',
'extract',
(lease) => lease.evaluate('document.body.outerHTML'),
{ takeover: 'observe' },
);
const markdown = htmlToMarkdown(String(leaseHtml));
check('extract markdown reflects the page effect', markdown.includes('clicked:leased'));
// Throttling tracks shown-ness (the P2 fix): a shown view runs full-speed so
// native clicks composite, and HIDING it restores background throttling so a
// backgrounded conversation's cached page can't drain CPU.
const leaseView = leaseManager.get('leaseS');
check('a shown view runs un-throttled', leaseView.isBackgroundThrottled() === false);
leaseView.setViewport(null); // user switches away / panel hides
check('hiding a view restores background throttling (no hidden-page CPU drain)', leaseView.isBackgroundThrottled() === true);
leaseView.setViewport({ x: 0, y: 0, width: 1024, height: 768 }); // re-show for the revoke check
// Continuous lease (the P1 revoke fix): an action that started while the
// conversation was on screen must be SEVERED if the user switches away mid-
// run — not left reading the now-hidden page. Start a long read on the live
// cached connection, flip the shown conversation, and revoke; it must reject
// with the revoked error against the REAL bridge (the fake-host unit tests
// can't prove the live sever).
let revoked = false;
const longRead = withBrowserPage('leaseS', 'read', () => new Promise(() => {}), { takeover: 'observe' });
longRead.catch(() => {}); // asserted below; pre-handle so the reject is never unhandled
await new Promise((resolve) => setTimeout(resolve, 20)); // reuse the cached conn + enter run()
shownSession = 'other-conversation';
revokeHiddenBrowserActions('other-conversation');
try {
await longRead;
} catch (err) {
revoked = err instanceof BrowserActionRevokedError;
}
check('continuous lease revokes an in-flight read when the conversation goes off screen', revoked);
} finally {
// Teardown mirrors detach: close the client, stop the bridge, drop the view.
try {
await bridge.close();
} catch {
/* already closed */
}
try {
await controller.dispose();
} catch {
/* view already gone */
}
try {
await releaseBrowserSession('leaseS');
await leaseManager?.disposeAll();
provideBrowserViewHost(null);
} catch {
/* lease teardown is best-effort */
}
try {
if (!win.isDestroyed()) win.destroy();
} catch {
/* window already gone */
}
await new Promise((resolve) => server.close(resolve));
}
const passed = checks.filter((c) => c.ok).length;
log(`${passed}/${checks.length} checks passed`);
return passed === checks.length && checks.length > 0;
}
async function main() {
await app.whenReady();
let code = 1;
try {
const ok = await Promise.race([
runSmoke(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error(`smoke timed out after ${RUN_TIMEOUT_MS}ms`)), RUN_TIMEOUT_MS),
),
]);
code = ok ? 0 : 1;
log(ok ? 'RESULT: PASS' : 'RESULT: FAIL');
} catch (err) {
log(`RESULT: FAIL — ${err instanceof Error ? err.message : String(err)}`);
}
app.exit(code);
}
main();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { assertRendererEntryHtml } from './vite-renderer-entry-contract.ts';
export function checkRendererEntryOutput(desktopRoot = resolve(import.meta.dirname, '..')) {
const outputRoot = resolve(desktopRoot, 'dist-renderer');
const attestation = JSON.parse(
readFileSync(resolve(outputRoot, 'renderer-entry-attestation.json'), 'utf8'),
);
if (
attestation?.htmlFile !== 'index.html' ||
attestation?.sourceFile !== 'src/renderer/main.tsx' ||
typeof attestation?.entryFile !== 'string' ||
!/^assets\/[a-z0-9._-]+\.js$/iu.test(attestation.entryFile)
) {
throw new Error('renderer entry build attestation is invalid');
}
const html = readFileSync(resolve(outputRoot, attestation.htmlFile), 'utf8');
assertRendererEntryHtml(html, `./${attestation.entryFile}`);
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
checkRendererEntryOutput();
console.log('Renderer entry output check passed.');
} catch (error) {
console.error(
`Renderer entry output check failed: ${error instanceof Error ? error.message : String(error)}`,
);
process.exitCode = 1;
}
}
@@ -0,0 +1,30 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { copyFile, mkdir, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const source = resolve(desktopRoot, '..', '..', 'packages', 'runtime', 'dist', 'workers', 'filesystem-worker.js');
const target = resolve(desktopRoot, 'resources', 'workers', 'filesystem-worker.js');
const metadata = await stat(source).catch(() => undefined);
if (!metadata?.isFile()) throw new Error(`Runtime filesystem worker bundle is missing: ${source}`);
await mkdir(dirname(target), { recursive: true });
await copyFile(source, target);
+742
View File
@@ -0,0 +1,742 @@
/*
* 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-<WORKTREE_ID>`); 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}`);
}
@@ -0,0 +1,361 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { test } from 'node:test';
test('a launcher signal cancels preparation before Electron can spawn', async () => {
const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs');
const signals = new EventEmitter();
let finishPreparation;
const preparation = new Promise((resolve) => { finishPreparation = resolve; });
let finishStopping;
const stopped = new Promise((resolve) => { finishStopping = resolve; });
const spawns = [];
const exits = [];
let closes = 0;
const session = createDevelopmentLaunchSession({
signals,
close: async () => { closes += 1; },
exit: (code) => {
exits.push(code);
finishStopping();
},
});
const launching = session.start({
prepareMacosDevelopmentLaunch: () => preparation,
createDevelopmentLaunchFiles: () => {
throw new Error('cancelled launch must not create files');
},
spawn: (...args) => spawns.push(args),
});
signals.emit('SIGINT');
finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' });
assert.equal(await launching, null);
await stopped;
assert.deepEqual(spawns, []);
assert.equal(closes, 1);
assert.deepEqual(exits, [0]);
});
test('a launcher signal adopts and stops a delayed launch handle exactly once', async () => {
const { createDevelopmentLaunchSession } = await import('./dev-app-runtime.mjs');
const signals = new EventEmitter();
let publishHandle;
const pendingHandle = new Promise((resolve) => { publishHandle = resolve; });
let finishStopping;
const stopped = new Promise((resolve) => { finishStopping = resolve; });
let stops = 0;
const session = createDevelopmentLaunchSession({
signals,
startDevelopmentApp: () => pendingHandle,
close: async () => {},
exit: finishStopping,
});
const launching = session.start();
signals.emit('SIGTERM');
signals.emit('SIGHUP');
publishHandle({ stop: async () => { stops += 1; } });
await launching;
await stopped;
assert.equal(stops, 1);
});
test('cancelling during launch preparation prevents any later process spawn', async () => {
const { startDevelopmentApp } = await import('./dev-app-runtime.mjs');
const controller = new AbortController();
let finishPreparation;
const preparation = new Promise((resolve) => { finishPreparation = resolve; });
const launchFiles = [];
const spawns = [];
const launching = startDevelopmentApp({
signal: controller.signal,
prepareMacosDevelopmentLaunch: () => preparation,
devSingleInstanceConstants: async () => ({}),
createDevelopmentLaunchFiles: () => {
launchFiles.push('created');
return { logFile: '/tmp/log', resultFile: '/tmp/result' };
},
spawn: (...args) => {
spawns.push(args);
return { exitCode: null, killed: false, once: () => {}, kill: () => {} };
},
});
controller.abort();
finishPreparation({ appPath: '/tmp/Maka Dev.app', resultFileArgPrefix: '--result=' });
await assert.rejects(launching, (error) => error === controller.signal.reason);
assert.deepEqual(launchFiles, []);
assert.deepEqual(spawns, []);
});
test('cancelling while the plain launcher loads its lock contract prevents spawn', async () => {
const { startDevelopmentApp } = await import('./dev-app-runtime.mjs');
const controller = new AbortController();
let finishContractLoad;
const contract = new Promise((resolve) => { finishContractLoad = resolve; });
const spawns = [];
const launching = startDevelopmentApp({
signal: controller.signal,
prepareMacosDevelopmentLaunch: async () => null,
devSingleInstanceConstants: () => contract,
spawn: (...args) => spawns.push(args),
});
await Promise.resolve();
controller.abort();
finishContractLoad({ DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG: '--handled' });
await assert.rejects(launching, (error) => error === controller.signal.reason);
assert.deepEqual(spawns, []);
});
test('launcher and core agree on the loser contract', async () => {
const { devSingleInstanceConstants } = await import('./dev-app-runtime.mjs');
const core = await import('@maka/core/dev-single-instance');
const launcher = await devSingleInstanceConstants();
assert.equal(launcher.DEV_LOSER_EXIT_CODE, core.DEV_LOSER_EXIT_CODE);
assert.equal(launcher.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG, core.DEV_CONFLICT_HANDLED_BY_LAUNCHER_FLAG);
assert.equal(launcher.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX, core.DEV_LAUNCH_RESULT_FILE_ARG_PREFIX);
});
test('plainLoserExitCode matches only the contract value', async () => {
const { plainLoserExitCode } = await import('./dev-app-runtime.mjs');
const { DEV_LOSER_EXIT_CODE } = await import('@maka/core/dev-single-instance');
assert.equal(await plainLoserExitCode(DEV_LOSER_EXIT_CODE), true);
assert.equal(await plainLoserExitCode(0), false);
assert.equal(await plainLoserExitCode(1), false);
});
test('loser contract import failure does not claim a loser', async () => {
const { plainLoserExitCode } = await import('./dev-app-runtime.mjs');
const failingLoader = async () => { throw new Error('dist not built'); };
const warnings = [];
const origWarn = console.warn;
console.warn = (m) => warnings.push(String(m));
try {
assert.equal(await plainLoserExitCode(42, failingLoader), false);
} finally { console.warn = origWarn; }
assert.ok(warnings.some((m) => m.includes('dev single-instance constants unavailable')));
});
async function runVerdictCase({ result, stopped = () => false, startupAttempts = 10 }) {
const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs');
const { writeFileSync, mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const dir = mkdtempSync(join(tmpdir(), 'maka-abs-'));
const resultFile = join(dir, 'launch-result.json');
try {
if (result) writeFileSync(resultFile, `${JSON.stringify(result)}\n`);
return await waitForDevelopmentLaunchVerdict({
resultFile,
pollMs: 1,
startupAttempts,
stopped,
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test('verdict wait reports only its own private loser result', async () => {
const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs');
let resultChecks = 0;
let stopChecks = 0;
assert.equal(
await waitForDevelopmentLaunchVerdict({
readLaunchResult: async () => (++resultChecks >= 2 ? { status: 'loser' } : undefined),
delay: async () => {},
stopped: () => ++stopChecks > 4,
}),
'absorbed',
);
});
test('the TCC launcher stops supervising after Electron wins the lock', async () => {
const { waitForDevelopmentLaunchVerdict } = await import('./dev-app-runtime.mjs');
assert.equal(
await waitForDevelopmentLaunchVerdict({
readLaunchResult: () => ({ status: 'winner' }),
isWinnerRunning: () => {
throw new Error('the launcher must not observe Electron after the lock verdict');
},
delay: async () => {},
startupAttempts: 1,
}),
'started',
);
});
test('verdict wait marks a loser that never starts as absorbed', async () => {
assert.equal(
await runVerdictCase({ result: { status: 'loser' }, startupAttempts: 1 }),
'absorbed',
);
});
test('verdict wait keeps an ordinary never-started launch distinct', async () => {
assert.equal(await runVerdictCase({ startupAttempts: 1 }), 'never-started');
});
test('verdict wait marks a stopped loser as absorbed', async () => {
let stopChecks = 0;
assert.equal(
await runVerdictCase({
result: { status: 'loser' },
stopped: () => ++stopChecks > 1,
}),
'absorbed',
);
});
test('development-app liveness probes only the TCC bundle', async () => {
const { isDevelopmentAppRunning } = await import('./dev-app-runtime.mjs');
const calls = [];
assert.equal(
isDevelopmentAppRunning({
executable: '/wt/Maka Dev.app/Contents/MacOS/Electron',
probe: (executable) => {
calls.push(executable);
return false;
},
}),
false,
);
assert.deepEqual(calls, ['/wt/Maka Dev.app/Contents/MacOS/Electron']);
});
test('a runtime rebuild refuses to replace a running TCC bundle instead of killing it', async () => {
const { assertDevelopmentAppNotRunning } = await import('./dev-app-runtime.mjs');
assert.throws(
() => assertDevelopmentAppNotRunning({ isRunning: () => true }),
/Quit it.*retry/,
);
assert.doesNotThrow(() => assertDevelopmentAppNotRunning({ isRunning: () => false }));
});
test('TCC launcher cleanup removes only its own launch artifacts', async () => {
const { cleanupDevelopmentLaunch } = await import('./dev-app-runtime.mjs');
const { existsSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const directory = mkdtempSync(join(tmpdir(), 'maka-cleanup-'));
const logFile = join(directory, 'app.log');
const resultFile = join(directory, 'launch-result.json');
let logStopped = false;
try {
writeFileSync(logFile, 'log output\n');
writeFileSync(resultFile, '{"status":"winner"}\n');
cleanupDevelopmentLaunch({
logFile,
resultFile,
logStream: { kill: () => { logStopped = true; } },
});
assert.equal(logStopped, true);
assert.equal(existsSync(logFile), false);
assert.equal(existsSync(resultFile), false);
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test('each TCC launch gets private and separate result and log files', async () => {
const { createDevelopmentLaunchFiles } = await import('./dev-app-runtime.mjs');
const writes = [];
const first = createDevelopmentLaunchFiles({
directory: '/tmp/maka-dev-launches',
id: 'first',
mkdir: () => {},
write: (path) => writes.push(path),
});
const second = createDevelopmentLaunchFiles({
directory: '/tmp/maka-dev-launches',
id: 'second',
mkdir: () => {},
write: (path) => writes.push(path),
});
assert.notEqual(first.logFile, second.logFile);
assert.notEqual(first.resultFile, second.resultFile);
assert.deepEqual(writes, [first.logFile, second.logFile]);
});
test('other launch outcomes retain their exit codes', async () => {
const { handleDevelopmentLaunchOutcome } = await import('./dev-app-runtime.mjs');
for (const [outcome, expectedCode] of [['never-started', 1], ['stopped', 0], ['unexpected', 1]]) {
let exitCode;
handleDevelopmentLaunchOutcome(outcome, { log: () => {}, exit: (code) => { exitCode = code; } });
assert.equal(exitCode, expectedCode, outcome);
}
let startedExitCode;
handleDevelopmentLaunchOutcome('started', { exit: (code) => { startedExitCode = code; } });
assert.equal(startedExitCode, undefined);
});
test('shared-profile launches warn about legacy TCC data before choosing plain or bundle mode', async () => {
const { DEV_USER_DATA_DIR, warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs');
const warnings = [];
warnAboutLegacyTccDataRoot({
platform: 'darwin',
effectiveUserDataDir: DEV_USER_DATA_DIR,
legacyUserDataDir: '/tmp/Maka Dev-legacy',
exists: () => true,
warn: (message) => warnings.push(message),
});
assert.equal(warnings.length, 1);
assert.match(warnings[0], /Maka Dev-legacy/);
});
test('an explicit isolated profile does not warn about unrelated legacy TCC data', async () => {
const { warnAboutLegacyTccDataRoot } = await import('./dev-app-runtime.mjs');
const warnings = [];
warnAboutLegacyTccDataRoot({
platform: 'darwin',
effectiveUserDataDir: '/tmp/isolated-profile',
legacyUserDataDir: '/tmp/Maka Dev-legacy',
exists: () => true,
warn: (message) => warnings.push(message),
});
assert.deepEqual(warnings, []);
});
test('createMacosDevelopmentLaunch passes its private result file after --args', async () => {
const { createMacosDevelopmentLaunch } = await import('./dev-app-runtime.mjs');
const { DEV_LAUNCH_RESULT_FILE_ARG_PREFIX } = await import('@maka/core/dev-single-instance');
const launch = createMacosDevelopmentLaunch(
'/tmp/Maka Dev.app',
'/tmp/app.log',
'/tmp/launch-result.json',
DEV_LAUNCH_RESULT_FILE_ARG_PREFIX,
);
assert.equal(launch.command, 'open');
assert.ok(launch.args.indexOf('--stdout') < launch.args.indexOf('--args'));
assert.equal(
launch.args.at(-1),
`${DEV_LAUNCH_RESULT_FILE_ARG_PREFIX}/tmp/launch-result.json`,
);
assert.ok(
!createMacosDevelopmentLaunch('/tmp/Maka Dev.app', '/tmp/app.log').args.includes('--args'),
);
});
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env node
/*
* 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.
*/
/**
* Dev launcher with PARALLEL + INCREMENTAL builds.
*
* Uses `tsc --build` for library packages so the compiler skips
* unchanged sub-projects via .tsbuildinfo (incremental).
*
* Dependency graph (→ compiles after):
* core ─┬→ storage
* ├→ runtime
* └→ ui
*
* libs (tsc --build tsconfig.lib.json) ─── covers core+storage+runtime+ui
* ├─→ preload (esbuild)
* └─→ filesystem worker (esbuild)
* cursor overlay (esbuild) ─── independent
* main (esbuild) ─── fast app bundle for Electron
* Vite dev server + Electron ─── fork
*/
import { spawn } from 'node:child_process';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { build as esbuildBuild } from 'esbuild';
import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs';
import {
createDevelopmentLaunchSession,
handleDevelopmentLaunchOutcome,
waitForDevelopmentLaunchVerdict,
} from './dev-app-runtime.mjs';
const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..');
const TSC_CLI = join(REPO_ROOT, 'node_modules', 'typescript', 'bin', 'tsc');
const MODEL_METADATA_SYNC = join(REPO_ROOT, 'scripts', 'sync-model-metadata.mjs');
const RUNTIME_WORKER_BUILD = join(REPO_ROOT, 'packages', 'runtime', 'scripts', 'build-filesystem-worker.mjs');
// ── helpers ──────────────────────────────────────────────────────────────────
function log(label, msg) {
const ts = new Date().toLocaleTimeString('zh-CN', { hour12: false });
console.log(`[${ts}][${label}] ${msg}`);
}
function runNodeTool(dir, script, args) {
return new Promise((resolve_, reject_) => {
const child = spawn(process.execPath, [script, ...args], {
cwd: dir,
stdio: ['ignore', 'inherit', 'inherit'],
});
child.on('exit', (code) => {
if (code === 0) resolve_();
else reject_(new Error(`"${script} ${args.join(' ')}" exited with code ${code}`));
});
child.on('error', reject_);
});
}
// ── build phases ─────────────────────────────────────────────────────────────
const TIMER_START = Date.now();
// A clean or ignore-scripts install has no generated model modules yet, and
// `tsc --build` bypasses workspace prebuild hooks. Generate from the committed
// snapshot before starting the incremental library graph.
log('build', 'model metadata — generating from committed snapshot');
await runNodeTool(REPO_ROOT, MODEL_METADATA_SYNC, []);
// Phase 1: all library packages via `tsc --build` (single process, shared
// .tsbuildinfo, sub-project incremental detection). The preload bundle imports
// workspace package dist files, so it starts only after that build is ready.
log('build', 'libraries — starting (tsc --build)');
const librariesBuild = runNodeTool(REPO_ROOT, TSC_CLI, ['--build', 'tsconfig.lib.json']).then(
() => log('build', 'libraries (all) — done'),
(e) => {
log('build', `libraries — FAILED: ${e.message}`);
throw e;
},
);
await Promise.all([
librariesBuild,
librariesBuild.then(() => runNodeTool(REPO_ROOT, RUNTIME_WORKER_BUILD, [])).then(
() => log('build', 'filesystem worker bundle — done'),
(e) => { log('build', `filesystem worker bundle — FAILED: ${e.message}`); throw e; },
),
// esbuild via its JS API — NOT `node node_modules/esbuild/bin/esbuild`:
// esbuild's postinstall swaps that file for a platform-native binary,
// and executing a Mach-O file with node throws SyntaxError (broke
// `npm run dev` on any machine where postinstall ran).
librariesBuild.then(() => esbuildBuild({
absWorkingDir: DESKTOP_DIR,
entryPoints: ['src/preload/preload.ts'],
bundle: true,
platform: 'node',
format: 'cjs',
outfile: 'dist/preload/preload.cjs',
external: ['electron'],
logLevel: 'warning',
})).then(
() => log('build', 'preload — done'),
(e) => { log('build', `preload — FAILED: ${e.message}`); throw e; },
),
buildCursorOverlay({ logLevel: 'warning' }).then(
() => log('build', 'cursor overlay — done'),
(e) => { log('build', `cursor overlay — FAILED: ${e.message}`); throw e; },
),
]);
// Phase 2: main — esbuild bundle for dev startup. The full
// tsconfig.main.json still compiles tests for `npm test` and typechecks
// main-process code in verification commands.
log('build', 'main — starting');
await esbuildBuild({
absWorkingDir: DESKTOP_DIR,
entryPoints: ['src/main/main.ts'],
bundle: true,
platform: 'node',
format: 'esm',
packages: 'external',
outfile: 'dist/main/main.js',
external: ['electron'],
logLevel: 'warning',
});
log('build', 'main — done');
const BUILD_MS = Date.now() - TIMER_START;
log('build', `all builds finished in ${(BUILD_MS / 1000).toFixed(1)}s`);
// ── Vite dev server + Electron ───────────────────────────────────────────────
process.chdir(DESKTOP_DIR);
log('vite', 'starting dev server...');
const server = await createServer();
await server.listen();
server.printUrls();
const devUrl = server.resolvedUrls?.local?.[0]?.replace(/\/$/, '');
if (!devUrl) {
console.error('[dev] vite did not report a local URL; aborting.');
await server.close();
process.exit(1);
}
log('electron', `launching against ${devUrl} (renderer HMR live)`);
// Created before launch so signals during codesign/preparation are durable.
// Closing the terminal still stops only launcher resources, not the
// independently owned TCC app.
const launchSession = createDevelopmentLaunchSession({
close: () => server.close(),
});
let app = null;
try {
app = await launchSession.start({ argv: process.argv.slice(2), viteUrl: devUrl });
} catch (error) {
console.error(`[dev] failed to start Electron: ${String(error)}`);
await launchSession.stop(1);
}
if (app) {
if (app.isMacosBundle) log('electron', 'launched Maka Dev.app through LaunchServices');
app.child.on('error', (err) => {
console.error(`[dev] failed to start Electron: ${err.message}`);
launchSession.stop(1);
});
if (app.isMacosBundle) {
// `open` exits 0 at the handoff, so only a failure to hand off is news here.
// The app's later lifetime is deliberately independent from this dev server.
app.child.on('exit', (code) => {
if (code) launchSession.stop(code);
});
// All branching lives in handleDevelopmentLaunchOutcome; this line is the only
// un-automated surface here (launcher scripts are non-exported, darwin-only).
waitForDevelopmentLaunchVerdict({ stopped: launchSession.isStopping, resultFile: app.resultFile }).then((outcome) =>
handleDevelopmentLaunchOutcome(outcome, {
log: (m) => console.error('[dev]', m),
exit: (code) => launchSession.stop(code),
}),
);
} else {
app.child.on('exit', (code) => launchSession.stop(code ?? 0));
}
}
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { developmentAppPath, prepareDevelopmentApp } from './dev-app-runtime.mjs';
await prepareDevelopmentApp();
console.log(`[dev-app] ready: ${developmentAppPath}`);
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
handleDevelopmentLaunchOutcome,
startDevelopmentApp,
waitForDevelopmentLaunchVerdict,
} from './dev-app-runtime.mjs';
let stopping = false;
async function stop(code = 0) {
if (stopping) return;
stopping = true;
await app.stop();
process.exitCode = code;
}
const app = await startDevelopmentApp({ argv: process.argv.slice(2) });
// Preparing the bundle happens before the launcher owns any long-lived
// resources. Once the handle exists, signals clean up only those resources;
// the detached TCC app remains independently owned.
process.on('SIGINT', () => void stop());
process.on('SIGTERM', () => void stop());
process.on('SIGHUP', () => void stop());
app.child.on('error', (error) => {
console.error(`[dev-app] failed to start: ${error.message}`);
void stop(1);
});
if (app.isMacosBundle) {
// `open` exits at the LaunchServices handoff. The log follower keeps this
// command alive, but Ctrl-C stops only launcher-owned resources; the detached
// TCC app remains running until the user quits it.
app.child.on('exit', (code) => {
if (code) void stop(code);
});
const outcome = await waitForDevelopmentLaunchVerdict({
stopped: () => stopping,
resultFile: app.resultFile,
});
// All decisions live in handleDevelopmentLaunchOutcome (see dev.mjs for the
// one-line coupling note); this is the only unobserved part.
handleDevelopmentLaunchOutcome(outcome, {
log: (m) => console.error('[dev-app]', m),
exit: (code) => stop(code),
});
} else {
app.child.on('exit', (code, signal) => {
if (!stopping) process.exitCode = signal ? 1 : (code ?? 0);
});
}
@@ -0,0 +1,176 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { resolve, sep } from 'node:path';
import type { Plugin } from 'vite';
const SOURCE_FILE = /\.(?:(?:c|m)?(?:js|ts)x?)$/u;
const ALLOWED_HTML_TAGS = new Set([
'body',
'div',
'head',
'html',
'link',
'meta',
'script',
'style',
'title',
]);
const CONTENT_SECURITY_POLICY =
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'";
function normalizePath(path: string): string {
return path.split(sep).join('/');
}
function htmlAttribute(attributes: string, name: string): string | undefined {
const match = attributes.match(
new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'iu'),
);
return match?.[1] ?? match?.[2];
}
export function assertRendererEntryHtml(html: string, expectedScriptSource?: string): void {
const tagNames = [...html.matchAll(/<\/?([a-z][a-z0-9-]*)\b/giu)].map((match) =>
match[1].toLowerCase(),
);
const hasForbiddenTag = tagNames.some((tag) => !ALLOWED_HTML_TAGS.has(tag));
const hasEventHandler = /\son[a-z0-9-]+\s*=/iu.test(html);
const openingScriptCount = html.match(/<script\b/giu)?.length ?? 0;
const scripts = [...html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/giu)];
const [script] = scripts;
const scriptSource = htmlAttribute(script?.[1] ?? '', 'src');
const validScriptSource = expectedScriptSource
? scriptSource === expectedScriptSource
: scriptSource === '/main.tsx' || /^\.\/assets\/[a-z0-9._-]+\.js$/iu.test(scriptSource ?? '');
const validScript =
openingScriptCount === 1 &&
scripts.length === 1 &&
htmlAttribute(script?.[1] ?? '', 'type') === 'module' &&
validScriptSource &&
(script?.[2] ?? '').trim().length === 0;
const metas = [...html.matchAll(/<meta\b([^>]*)>/giu)].map((match) => match[1]);
const charsetMetas = metas.filter(
(attributes) => htmlAttribute(attributes, 'charset')?.toLowerCase() === 'utf-8',
);
const viewportMetas = metas.filter(
(attributes) =>
htmlAttribute(attributes, 'name')?.toLowerCase() === 'viewport' &&
htmlAttribute(attributes, 'content') === 'width=device-width, initial-scale=1.0',
);
const policyMetas = metas.filter(
(attributes) =>
htmlAttribute(attributes, 'http-equiv')?.toLowerCase() === 'content-security-policy' &&
htmlAttribute(attributes, 'content')?.trim().replace(/\s+/gu, ' ') === CONTENT_SECURITY_POLICY,
);
const validMetas =
metas.length === 3 &&
charsetMetas.length === 1 &&
viewportMetas.length === 1 &&
policyMetas.length === 1;
const links = [...html.matchAll(/<link\b([^>]*)>/giu)].map((match) => match[1]);
const validLinks = links.every((attributes) => {
const relation = htmlAttribute(attributes, 'rel')?.toLowerCase();
const href = htmlAttribute(attributes, 'href');
return (
['modulepreload', 'stylesheet'].includes(relation ?? '') &&
/^\.\/assets\/[a-z0-9._-]+\.(?:css|js)$/iu.test(href ?? '')
);
});
if (
hasForbiddenTag ||
hasEventHandler ||
!validScript ||
!validMetas ||
!validLinks
) {
throw new Error(
'renderer entry HTML contract forbids transformed executable or navigation surfaces',
);
}
}
/**
* Attests the final Vite module graph rather than trusting the source HTML.
* A transformIndexHtml hook may rewrite a valid index.html before bundling;
* the canonical main.tsx must therefore remain the sole executable source
* imported directly by the emitted HTML entry.
*/
export function rendererEntryContractPlugin(rendererRoot: string): Plugin {
const canonicalIndex = normalizePath(resolve(rendererRoot, 'index.html'));
const canonicalMain = normalizePath(resolve(rendererRoot, 'main.tsx'));
let isRendererBuild = false;
return {
name: 'maka-renderer-entry-contract',
apply: 'build',
configResolved(config) {
isRendererBuild = normalizePath(resolve(config.root)) === normalizePath(resolve(rendererRoot));
},
transformIndexHtml: {
order: 'post',
handler(html) {
if (!isRendererBuild) return html;
assertRendererEntryHtml(html);
return html;
},
},
generateBundle(_options, bundle) {
if (!isRendererBuild) return;
const entryChunks = Object.values(bundle).filter(
(item) => item.type === 'chunk' && item.isEntry,
);
const [entry] = entryChunks;
const facade = entry?.facadeModuleId ? normalizePath(entry.facadeModuleId) : undefined;
const moduleInfo = facade ? this.getModuleInfo(entry.facadeModuleId) : undefined;
const directSourceImports = (moduleInfo?.importedIds ?? [])
.filter((id) => !id.startsWith('\0'))
.map((id) => normalizePath(id.split(/[?#]/u, 1)[0]))
.filter((id) => SOURCE_FILE.test(id));
if (
entryChunks.length !== 1 ||
facade !== canonicalIndex ||
directSourceImports.length !== 1 ||
directSourceImports[0] !== canonicalMain
) {
this.error(
'renderer entry contract requires src/renderer/index.html to import only src/renderer/main.tsx',
);
}
this.emitFile({
type: 'asset',
fileName: 'renderer-entry-attestation.json',
source: `${JSON.stringify(
{
entryFile: entry.fileName,
htmlFile: 'index.html',
sourceFile: 'src/renderer/main.tsx',
},
null,
2,
)}\n`,
});
},
};
}