Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/desktop/forge-pre-run-node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { execFileSync } from 'node:child_process';
import { chmodSync, copyFileSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { nodeRuntimeArtifact } from './src/main/managed-tools/nodeArtifact';
import { extractToolArchive } from './src/main/managed-tools/archive';
import { downloadToFileWithTimeout } from '../../tools/shared/fetch-with-timeout.mjs';
import { assertSha256, sha256File } from '../../tools/shared/verify-sha256.mjs';

/** Only prune the package's copy, never the shared multi-target source cache.
* Validate the complete target before deleting stale generations/other hosts. */
export function prunePackagedPreRunNode(resources: string, platform: string, arch: string): void {
const artifact = nodeRuntimeArtifact(platform, arch);
if (!artifact) throw new Error('Unsupported packaged pre-run Node target: ' + platform + '-' + arch);
const root = path.join(resources, 'tools', 'pre-run-node');
const target = path.join(root, artifact.host);
try {
if (!lstatSync(root).isDirectory() || !lstatSync(target).isDirectory()) throw new Error('Target is not a directory');
for (const file of [platform === 'win32' ? 'node.exe' : 'node', platform === 'win32' ? 'supervisor.exe' : 'supervisor', 'gate-client.cjs', 'NODE-LICENSE.txt', 'manifest.json']) {
const stat = lstatSync(path.join(target, file));
if (!stat.isFile() || stat.size === 0) throw new Error('Missing or empty ' + file);
}
const manifest = JSON.parse(readFileSync(path.join(target, 'manifest.json'), 'utf8'));
if (manifest.host !== artifact.host || manifest.version !== artifact.version || manifest.archiveSha256 !== artifact.sha256) {
throw new Error('Target manifest does not match the pinned artifact');
}
} catch (error) {
throw new Error('Incomplete packaged pre-run Node: ' + artifact.host, { cause: error });
}
for (const entry of readdirSync(root)) {
if (entry !== artifact.host) rmSync(path.join(root, entry), { recursive: true, force: true });
}
}

/** Small native helper, no Cargo/npm install or runtime download. */
export function compilePreRunSupervisor(source: string, output: string, platform: string = process.platform, arch: string = process.arch): void {
if (platform !== process.platform || (platform !== 'darwin' && arch !== process.arch)) {
throw new Error('Pre-run supervisor requires a native target build');
}
mkdirSync(path.dirname(output), { recursive: true });
if (platform === 'win32') {
const object = output + '.obj';
try {
execFileSync('cl.exe', [
'/nologo', '/std:c11', '/W4', '/O2', '/MT', '/Fo' + object, '/Fe' + output,
source, '/link', 'advapi32.lib',
], { stdio: 'pipe', windowsHide: true });
} finally { rmSync(object, { force: true }); }
}
else execFileSync('cc', [
'-std=c11', '-Wall', '-Wextra', '-Werror', '-O2', '-pthread',
...(platform === 'darwin' ? ['-arch', arch === 'arm64' ? 'arm64' : 'x86_64'] : []),
source, '-o', output,
], { stdio: 'pipe' });
}

/** Package-time preparation only. Verified publisher archive, isolated stage,
* upstream full LICENSE, then a complete generation replaces the target. */
export async function stagePreRunNode(platform: string, arch: string, options: {
desktopRoot?: string; outputRoot?: string; archive?: string; download?: boolean;
} = {}): Promise<void> {
const artifact = nodeRuntimeArtifact(platform, arch);
if (!artifact) throw new Error('Unsupported packaged pre-run Node target: ' + platform + '-' + arch);
const desktop = options.desktopRoot ?? __dirname;
const outputRoot = options.outputRoot ?? path.join(desktop, 'resources', 'tools', 'pre-run-node');
const scratch = mkdtempSync(path.join(os.tmpdir(), 'cindy-pre-run-build-'));
const target = path.join(outputRoot, artifact.host);
Comment thread
shenjiacheng-sudo marked this conversation as resolved.
const staged = path.join(outputRoot, '.' + artifact.host + '-' + path.basename(scratch));
const old = staged + '-old';
let movedOld = false;
try {
mkdirSync(staged, { recursive: true });
const archive = options.archive ?? path.join(scratch, 'node.' + artifact.format);
if (!options.archive) {
if (options.download === false) throw new Error('Verified Node archive required for offline preparation');
await downloadToFileWithTimeout(artifact.url, archive, {}, { minThroughputBytesPerSec: 0 });
}
assertSha256({ actualHex: sha256File(archive), expected: artifact.sha256, label: artifact.host + ' Node archive' });
const extracted = path.join(scratch, 'extracted'); mkdirSync(extracted);
await extractToolArchive(archive, extracted, artifact, new AbortController().signal);
const node = path.join(extracted, ...artifact.executable.split('/'));
const executable = path.join(staged, platform === 'win32' ? 'node.exe' : 'node');
copyFileSync(node, executable); chmodSync(executable, 0o755);
Comment thread
shenjiacheng-sudo marked this conversation as resolved.
copyFileSync(path.join(extracted, artifact.executable.split('/')[0], 'LICENSE'), path.join(staged, 'NODE-LICENSE.txt'));
copyFileSync(path.join(desktop, 'native', 'pre-run-node', 'gate-client.cjs'), path.join(staged, 'gate-client.cjs'));
compilePreRunSupervisor(path.join(desktop, 'native', 'pre-run-node', 'supervisor.c'), path.join(staged, platform === 'win32' ? 'supervisor.exe' : 'supervisor'), platform, arch);
writeFileSync(path.join(staged, 'manifest.json'), JSON.stringify({ version: artifact.version, archiveSha256: artifact.sha256, host: artifact.host }) + '\n');
// Never expose a partially built directory or destroy the prior good stage.
try { renameSync(target, old); movedOld = true; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
try { renameSync(staged, target); } catch (error) { if (movedOld) renameSync(old, target); throw error; }
if (movedOld) rmSync(old, { recursive: true, force: true });
} finally {
rmSync(staged, { recursive: true, force: true }); rmSync(scratch, { recursive: true, force: true });
}
}
8 changes: 6 additions & 2 deletions apps/desktop/forge.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prunePackagedPreRunNode, stagePreRunNode } from './forge-pre-run-node';
import { execSync, spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import { createRequire } from 'node:module';
Expand Down Expand Up @@ -1913,9 +1914,11 @@ const config: ForgeConfig = {
// 未设置时由 electron-packager 回落到 package.json 里的占位版本(仅 dev 用)。
...(process.env.APP_VERSION ? { appVersion: process.env.APP_VERSION } : {}),
afterCopyExtraResources: [
(buildPath, _electronVersion, platform, _arch, callback) => {
(buildPath, _electronVersion, platform, arch, callback) => {
try {
prunePackagedMigrationSnapshots(packagedResourcesPath(buildPath, platform));
const resources = packagedResourcesPath(buildPath, platform);
prunePackagedPreRunNode(resources, platform, arch);
prunePackagedMigrationSnapshots(resources);
callback();
} catch (err) {
callback(err as Error);
Expand Down Expand Up @@ -1959,6 +1962,7 @@ const config: ForgeConfig = {
);
buildCindyUpdater();
}
await stagePreRunNode(targetPlatform, targetArch);
stageRipgrep(targetPlatform, targetArch);
stageAndroidPlatformTools(targetPlatform, targetArch);
buildWindowsVoiceInputFunctionKeyListener(targetPlatform);
Expand Down
43 changes: 43 additions & 0 deletions apps/desktop/native/pre-run-node/gate-client.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use strict';
/** Cooperative gate-body protocol. Loading this module never attests entry.
* Call enter() in the check body, complete(code) after its work, then exit.
* This is not an attestation mechanism against hostile same-process code. */
const { createConnection } = require('node:net');
const { isMainThread } = require('node:worker_threads');
let socket, pending, failure, entered = false, completed = false, response = '';
function frame(verb, code) {
if (failure || !socket || socket.destroyed) return Promise.reject(failure || new Error('Gate protocol is closed'));
if (pending) return Promise.reject(new Error('Concurrent pre-run gate protocol call'));
return new Promise((resolve, reject) => {
pending = { resolve, reject };
socket.write(verb + ' ' + process.env.CINDY_PRE_RUN_GATE_NONCE + (code === undefined ? '' : ' ' + code) + '\n');
});
}
async function enter() {
if (!isMainThread || entered || completed) throw new Error('Gate entry must occur once in the root main thread');
const endpoint = process.env.CINDY_PRE_RUN_GATE_ENDPOINT;
if (!endpoint || !process.env.CINDY_PRE_RUN_GATE_NONCE) throw new Error('Gate is not running under the Cindy pre-run supervisor');
socket = createConnection(endpoint);
socket.setEncoding('utf8');
socket.on('data', chunk => {
response += chunk;
if (response.length > 16) { socket.destroy(new Error('Invalid gate acknowledgement')); return; }
if (response.endsWith('\n')) {
const operation = pending; pending = undefined;
if (!operation) { socket.destroy(new Error('Unexpected gate acknowledgement')); return; }
const ok = response === 'ok\n'; response = '';
if (ok) operation.resolve(); else operation.reject(new Error('Gate proof rejected'));
}
});
socket.on('error', error => { failure = error; const operation = pending; pending = undefined; operation?.reject(error); });
socket.on('close', () => { failure ??= new Error('Gate protocol closed before acknowledgement'); const operation = pending; pending = undefined; operation?.reject(failure); });
await new Promise((resolve, reject) => { socket.once('connect', resolve); socket.once('error', reject); });
await frame('ENTER'); entered = true;
}
async function complete(code) {
if (!entered || completed || ![0, 2].includes(code)) throw new Error('Gate must enter and complete once with 0 or 2');
await frame('COMPLETE', code); completed = true; process.exitCode = code;
await new Promise((resolve, reject) => { socket.once('close', resolve); socket.once('error', reject); socket.end(); });
}
async function exit(code) { await complete(code); process.exit(code); }
module.exports = { enter, complete, exit };
8 changes: 8 additions & 0 deletions apps/desktop/native/pre-run-node/node.entitlements.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
</dict>
</plist>
Loading