源码快照 @ 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
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:
@@ -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);
|
||||
@@ -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'),
|
||||
);
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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`,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user