import { execFileSync, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { delimiter, dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const APP_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const MARKER_PATH = join(APP_DIR, '.desktop-deps.json');
const FORCE = process.argv.includes('--force');
const CHECK_ONLY = process.argv.includes('--check');
const PACKAGES = [
{ name: 'Server', dir: 'server', probe: 'node_modules/better-sqlite3/package.json' },
{ name: 'Electron', dir: 'electron', probe: 'node_modules/electron/package.json' },
];
function supportedNode(version = process.versions.node) {
const [major = 0] = String(version).split('.').map(Number);
return major >= 24;
}
function fail(message) {
console.error(`\n[setup] ${message}\n`);
process.exitCode = 1;
}
if (!supportedNode()) {
fail(`Node ${process.version} 太旧。请安装 Node 24,再重新执行 npm install。`);
process.exit();
}
async function lockFingerprint() {
const hash = createHash('sha256');
for (const item of PACKAGES) {
const lockPath = join(APP_DIR, item.dir, 'package-lock.json');
hash.update(item.dir);
hash.update(await readFile(lockPath));
}
return hash.digest('hex');
}
async function expectedMarker() {
return {
fingerprint: await lockFingerprint(),
platform: process.platform,
arch: process.arch,
nodeMajor: Number(process.versions.node.split('.')[0]),
nodeModulesAbi: process.versions.modules,
};
}
async function markerMatches(expected) {
if (!PACKAGES.every((item) => existsSync(join(APP_DIR, item.dir, item.probe)))) return false;
try {
const actual = JSON.parse(await readFile(MARKER_PATH, 'utf8'));
return Object.entries(expected).every(([key, value]) => actual[key] === value);
} catch {
return false;
}
}
function electronExecutablePath() {
const packageDir = join(APP_DIR, 'electron', 'node_modules', 'electron');
const relative = process.platform === 'darwin'
? join('Electron.app', 'Contents', 'MacOS', 'Electron')
: process.platform === 'win32'
? 'electron.exe'
: 'electron';
return join(packageDir, 'dist', relative);
}
function electronBinaryReady() {
return existsSync(electronExecutablePath());
}
function nativeBinaryArchitectures(filePath) {
if (!existsSync(filePath)) return [];
if (process.platform === 'darwin') {
try {
return execFileSync('lipo', ['-archs', filePath], { encoding: 'utf8' }).trim().split(/\s+/).filter(Boolean);
} catch {
return [];
}
}
return [process.arch];
}
function nativeBinariesMatchRuntime() {
if (!electronBinaryReady()) return false;
if (process.platform !== 'darwin') return true;
const expected = process.arch === 'x64' ? 'x86_64' : process.arch;
const sqliteNative = join(APP_DIR, 'server', 'node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node');
return nativeBinaryArchitectures(electronExecutablePath()).includes(expected)
&& nativeBinaryArchitectures(sqliteNative).includes(expected);
}
function ensureElectronBinary() {
if (electronBinaryReady()) return Promise.resolve();
const packageDir = join(APP_DIR, 'electron', 'node_modules', 'electron');
const installScript = join(packageDir, 'install.js');
if (!existsSync(installScript)) throw new Error('Electron 安装脚本不存在,请重新执行 npm run setup');
console.log('[setup] 下载 Electron 开发运行时...');
return new Promise((resolveInstall, reject) => {
const child = spawn(process.execPath, [installScript], {
cwd: packageDir,
env: {
...process.env,
PATH: `${dirname(process.execPath)}${delimiter}${process.env.PATH || ''}`,
},
stdio: 'inherit',
shell: false,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0 && electronBinaryReady()) resolveInstall();
else reject(new Error(`Electron 运行时安装失败(code=${code}, signal=${signal || 'none'})`));
});
});
}
const expected = await expectedMarker();
const ready = await markerMatches(expected) && nativeBinariesMatchRuntime();
if (CHECK_ONLY) {
if (!ready) {
fail('依赖未安装,或 Node/CPU 架构已经变化。请先执行 npm install;切换架构后执行 npm run setup。');
} else {
console.log(`[setup] 依赖就绪: ${process.platform}/${process.arch}, Node ${process.versions.node}`);
}
process.exit();
}
if (ready && !FORCE) {
await ensureElectronBinary();
console.log(`[setup] 依赖没有变化,跳过重复安装 (${process.platform}/${process.arch})`);
process.exit();
}
const npmCli = process.env.npm_execpath;
if (!npmCli) {
fail('找不到 npm。请通过 npm install 或 npm run setup 执行安装。');
process.exit();
}
function install(item) {
return new Promise((resolveInstall, reject) => {
console.log(`[setup] 安装 ${item.name} 依赖...`);
const nodeDir = dirname(process.execPath);
const child = spawn(process.execPath, [npmCli, 'ci', '--no-audit', '--no-fund'], {
cwd: join(APP_DIR, item.dir),
env: {
...process.env,
PATH: `${nodeDir}${delimiter}${process.env.PATH || ''}`,
npm_node_execpath: process.execPath,
npm_config_arch: process.arch,
},
stdio: 'inherit',
shell: false,
});
child.once('error', reject);
child.once('exit', (code, signal) => {
if (code === 0) resolveInstall();
else reject(new Error(`${item.name} 安装失败(code=${code}, signal=${signal || 'none'})`));
});
});
}
for (const item of PACKAGES) await install(item);
await ensureElectronBinary();
if (!nativeBinariesMatchRuntime()) {
throw new Error(`依赖安装完成后原生模块仍与 ${process.platform}/${process.arch} 不一致`);
}
await writeFile(MARKER_PATH, `${JSON.stringify({ ...expected, installedAt: new Date().toISOString() }, null, 2)}\n`);
console.log('\n[setup] 安装完成。建议先运行 npm run doctor,再运行 npm run dev。');