This commit is contained in:
2026-09-12 14:15:26 +08:00
commit 9c06d3f4be
99 changed files with 41853 additions and 0 deletions

262
scripts/check-ipc.js Normal file
View File

@@ -0,0 +1,262 @@
#!/usr/bin/env node
// IPC 通道配对审计(取代 preload.js 旧的那 45 行手工注释)
//
// 规则:
// 1. renderer → maininvoke / send
// preload.js 中 `ipcRenderer.invoke('foo')` 必须有 main.js 中 `ipcMain.handle('foo', ...)` 配对。
// `ipcRenderer.send('foo')` 必须有 `ipcMain.on('foo', ...)` 或 `ipcMain.once('foo', ...)` 配对。
// 2. main → renderer推送
// main.js 中 `webContents.send('foo')` 必须有 preload.js 中 `ipcRenderer.on('foo', ...)` 配对。
// "on" 订阅也可以走 `onXxx` 高阶函数(事件名出现在数组/字符串字面量里)—— 用宽松匹配。
// 3. 动态通道名(如 renderer:save-result:${reqId})按"前缀"匹配:发起方提供完整字面量,
// 接收方按前缀 ipcMain.on(`renderer:save-result:${...}`) 即可。
//
// 输出:失败时返回非零退出码 + 把缺漏通道写到 stderr。
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
const PRELOAD = path.join(ROOT, 'preload.js');
const MAIN = path.join(ROOT, 'main.js');
const MAIN_DIR = path.join(ROOT, 'main');
/**
* 递归收集目录下所有 .js 文件(排除 node_modules / dist 等)。
* @param {string} dir
* @returns {string[]}
*/
function walkJs(dir) {
if (!fs.existsSync(dir)) return [];
const out = [];
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, ent.name);
if (ent.isDirectory()) {
if (ent.name === 'node_modules' || ent.name === 'dist') continue;
out.push(...walkJs(p));
} else if (ent.isFile() && ent.name.endsWith('.js')) {
out.push(p);
}
}
return out;
}
/**
* 读文件,返回内容。
* @param {string} p
*/
function read(p) {
return fs.readFileSync(p, 'utf8');
}
/**
* 提取引号字符串字面量(单引号 / 双引号 / 反引号)。
* @param {string} src
* @returns {string[]}
*/
function extractStringLiterals(src) {
/** @type {string[]} */
const out = [];
// 单/双引号
const re1 = /(['"])((?:\\.|(?!\1).)*)\1/g;
let m;
while ((m = re1.exec(src))) out.push(m[2]);
// 模板字符串(无 ${} — 不展开
const re2 = /`([^`\\]*(?:\\.[^`\\]*)*)`/g;
while ((m = re2.exec(src))) {
// 如果包含 ${...},整段作为"模板前缀"
const tpl = m[1];
if (tpl.includes('${')) {
// 取 ${ 之前的字面前缀
const idx = tpl.indexOf('${');
out.push(tpl.slice(0, idx) + '${...}');
} else {
out.push(tpl);
}
}
return out;
}
/**
* 从文件中抽取 ipcRenderer.{invoke,send,on} 的通道名。
* @param {string} src
* @param {RegExp} headRe 形如 /ipcRenderer\.(invoke|send|on)\(\s*(['"`])/g
* @returns {string[]} 通道字面量
*/
function extractIpcCalls(src, headRe) {
/** @type {string[]} */
const out = [];
for (const m of src.matchAll(headRe)) {
const quote = m[1];
const start = m.index + m[0].length;
const end = src.indexOf(quote, start);
if (end === -1) continue;
let literal = src.slice(start, end);
if (literal.includes('${')) {
const idx = literal.indexOf('${');
literal = literal.slice(0, idx) + '${...}';
}
out.push(literal);
}
return out;
}
/**
* 给定发送方通道集合 + 接收方"模式"集合(字面量 + 通配前缀),找出漏配的通道。
* 接收方可以是字面量或 prefix${...}prefix 部分前缀匹配即可。
*
* @param {string[]} sent
* @param {string[]} received
* @returns {string[]} 漏配(按 sent 顺序)
*/
/**
* 收集 `const X = '...' | \`...\`` 形式的字符串字面量赋值(包括带 ${...} 的模板)。
* 仅在脚本作用域内查找(不在函数体内更精确,但 main.js 顶层都在 module 作用域,问题不大)。
* @param {string} src
* @returns {Map<string,string>} varName → 字面量
*/
function collectStringVarAssignments(src) {
/** @type {Map<string,string>} */
const map = new Map();
const re = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*([`'"])((?:\\.|(?!\2).)*)\2/g;
let m;
while ((m = re.exec(src))) {
const name = m[1];
let literal = m[3];
if (literal.includes('${')) {
const idx = literal.indexOf('${');
literal = literal.slice(0, idx) + '${...}';
}
map.set(name, literal);
}
return map;
}
/**
* 提取 ipcMain.once(VAR, ...) / webContents.send(VAR, ...) 中的字面量(用变量映射)。
* @param {string} src
* @param {RegExp} varCallRe
* @param {Map<string,string>} varMap
* @returns {string[]}
*/
function extractIpcCallsByVar(src, varCallRe, varMap) {
/** @type {string[]} */
const out = [];
for (const m of src.matchAll(varCallRe)) {
const varName = m[1];
const lit = varMap.get(varName);
if (lit) out.push(lit);
}
return out;
}
function diffMissing(sent, received) {
return sent.filter((s) => {
if (!s) return false;
if (received.includes(s)) return false;
// 动态模板sent 是字面received 是前缀${...}?两边都可能是字面或模板
for (const r of received) {
if (r === s) return false;
// 一边是 "foo:${...}" 另一边是 "foo:bar" → 视为配对(只要前缀对得上)
if (r.endsWith('${...}') && s.startsWith(r.slice(0, -6))) return false;
if (s.endsWith('${...}') && r.startsWith(s.slice(0, -6))) return false;
}
return true;
});
}
function main() {
const preloadSrc = read(PRELOAD);
const mainSrc = read(MAIN);
// audit fix (Round 8 R-2):合并 main.js + main/**/*.js。
// 旧实现只读 main.js主进程拆出来的子文件main/ai.js / main/file-ops.js /
// main/fs-watcher.js / main/config-store.js里的 ipcMain.handle / webContents.send
// 完全不在审计图里 —— `files:changed`main/fs-watcher.js:149这种动态事件名
// 容易因重构漏配对。
const mainSubSrc = walkJs(MAIN_DIR).map(read).join('\n\n');
const combinedMainSrc = mainSrc + '\n\n' + mainSubSrc;
// preload.jsipcRenderer.invoke / send / on
const invokeRe = /ipcRenderer\.invoke\(\s*([`'"])/g;
const sendRe = /ipcRenderer\.send\(\s*([`'"])/g;
const onRe = /ipcRenderer\.on\(\s*([`'"])/g;
const preloadInvoke = extractIpcCalls(preloadSrc, invokeRe);
const preloadSend = extractIpcCalls(preloadSrc, sendRe);
const preloadOn = extractIpcCalls(preloadSrc, onRe);
// 订阅也可能在 onMenuCommand([...]) 的字符串数组里 — 简单提取那些 ['menu:foo', 'menu:bar'] 数组
const preloadArrOn = [];
const arrRe = /\[\s*((?:['"][\w:.-]+['"]\s*,\s*)+['"][\w:.-]+['"])\s*\]/g;
let am;
while ((am = arrRe.exec(preloadSrc))) {
for (const lit of extractStringLiterals(am[1])) {
if (lit.includes(':') && !lit.includes(' ')) preloadArrOn.push(lit);
}
}
// main.js + main/**/*.jsipcMain.handle / on / once + webContents.send
// 同时识别用变量中转的动态通道:先收集 const X = `prefix:${...}` 这种声明,
// 再把 ipcMain.once(channel, ...) / webContents.send(channel, ...) 里 channel 替换为它的字面量。
const stringVarLiterals = collectStringVarAssignments(combinedMainSrc);
const handleRe = /ipcMain\.handle\(\s*([`'"])/g;
const onMainRe = /ipcMain\.on\(\s*([`'"])/g;
const onceMainRe = /ipcMain\.once\(\s*([`'"])/g;
const sendFromMain = extractIpcCalls(combinedMainSrc, handleRe);
const onMain = extractIpcCalls(combinedMainSrc, onMainRe).concat(extractIpcCalls(combinedMainSrc, onceMainRe));
// 变量形式ipcMain.once(channel, ...) —— channel 来自 stringVarLiterals
const onMainByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.(?:on|once)\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
const handleByVar = extractIpcCallsByVar(combinedMainSrc, /ipcMain\.handle\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
onMain.push(...onMainByVar);
sendFromMain.push(...handleByVar);
// webContents.send(...) 的通道
const wcSendRe = /webContents\.send\(\s*([`'"])/g;
const webContentsSend = extractIpcCalls(combinedMainSrc, wcSendRe);
// 变量形式
const wcSendByVar = extractIpcCallsByVar(combinedMainSrc, /webContents\.send\(\s*([A-Za-z_$][\w$]*)\s*,/g, stringVarLiterals);
webContentsSend.push(...wcSendByVar);
const errors = [];
// 1. renderer→main invoke 必须配 main.handle
const missingForInvoke = diffMissing(preloadInvoke, sendFromMain);
if (missingForInvoke.length) {
errors.push(`renderer→main invoke 缺 main.handle 配对:\n ${missingForInvoke.join('\n ')}`);
}
// 2. renderer→main send 必须配 main.on/once
const missingForSend = diffMissing(preloadSend, onMain);
if (missingForSend.length) {
errors.push(`renderer→main send 缺 main.on/once 配对:\n ${missingForSend.join('\n ')}`);
}
// 3. main→renderer webContents.send 必须配 preload.on (或 onXxx 数组)
const preloadSubscribed = new Set([...preloadOn, ...preloadArrOn]);
const missingForWcSend = diffMissing(webContentsSend, [...preloadSubscribed]);
if (missingForWcSend.length) {
errors.push(`main→renderer send 缺 preload.on 订阅:\n ${missingForWcSend.join('\n ')}`);
}
// 4. 列出所有声明的通道,便于人工核对(不算错误)
const allChannels = new Set([
...preloadInvoke,
...preloadSend,
...preloadOn,
...preloadArrOn,
...sendFromMain,
...onMain,
...webContentsSend,
]);
if (errors.length) {
console.error('[check-ipc] 失败:\n' + errors.map((e) => `${e}`).join('\n\n'));
process.exit(1);
}
console.log(`[check-ipc] 通过 (${allChannels.size} 个通道全部配对)`);
}
main();

86
scripts/check-syntax.js Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env node
// 语法体检:对 main / preload / scripts / src 全部 JS 文件跑 `node --check`。
// 比 ESLint 严格度低,但能捕获**最基础的**语法错误(漏括号、错引号等)
// 在文件压根还没被 linted 之前的快速反馈。
const { execFileSync } = require('node:child_process');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
/** @type {string[]} */
const targets = [
'main.js',
'preload.js',
'scripts/launch.js',
// src/ 下用 ESM import/exportnode --check 会按 ESM 解析package.json 无 "type" 字段 → CJS
// 因此 src/**/*.js 必须以 .mjs 单独跑。
];
const esmTargets = [
'src',
];
function walk(dir, out) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
// audit fix (Q3.7):跳过 symlink 文件 + 目录。
// 之前只用 isFile()/isDirectory() 判定fs.readdirSync withFileTypes 不会跟随 symlink
// 但 isSymbolicLink() 会同步报告出来 —— 显式跳过避免后续 stat 跟着 symlink 跑到
// 项目外文件。实际风险低(项目里没有 symlink但保留防御。
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
walk(full, out);
} else if (entry.isFile() && entry.name.endsWith('.js')) {
out.push(full);
}
}
}
/** @type {string[]} */
const allCjs = targets.map((p) => path.join(ROOT, p)).filter(fs.existsSync);
/** @type {string[]} */
const allEsm = [];
for (const dir of esmTargets) {
const abs = path.join(ROOT, dir);
if (fs.existsSync(abs)) walk(abs, allEsm);
}
let failed = false;
for (const file of allCjs) {
try {
// audit fix (Q3.7):改用 execFileSync 不带 shell。
// 之前用 ``execSync(`node --check "${file}"`, { stdio: 'pipe' })`` 把路径拼进
// shell 字符串 —— 文件名里出现 `; rm -rf ~ #` 就会被 cmd.exe / sh 解释执行。
// 项目内的文件名目前可信,但这是审计工具自身表面的隐患,留着没意义。
execFileSync('node', ['--check', file], { stdio: 'pipe' });
} catch (err) {
failed = true;
console.error(`[check-syntax] CJS 失败: ${path.relative(ROOT, file)}`);
if (err.stderr) console.error(err.stderr.toString());
}
}
for (const file of allEsm) {
try {
// audit fix (Q3.7):同样改用 execFileSync。
// ESM 走 stdin 重定向:用 { input: fs.readFileSync(file) } 把文件内容喂给子进程
// stdin避开 shell 重定向 / 文件名展开。
const source = fs.readFileSync(file);
execFileSync('node', ['--check', '--input-type=module'], { stdio: ['pipe', 'pipe', 'pipe'], input: source });
} catch (err) {
failed = true;
console.error(`[check-syntax] ESM 失败: ${path.relative(ROOT, file)}`);
if (err.stderr) console.error(err.stderr.toString());
}
}
if (failed) {
console.error('[check-syntax] 失败');
process.exit(1);
}
console.log(`[check-syntax] 通过 (${allCjs.length} CJS + ${allEsm.length} ESM)`);

117
scripts/launch.js Normal file
View File

@@ -0,0 +1,117 @@
// Notes 启动包装
//
// 主要职责:
// 1. 处理 Windows 控制台编码cp936 → utf-8让中文日志不乱码
// 2. 在 Windows 上以管理员权限运行时切换工作目录到项目根
// (某些路径如 `C:\Windows\System32` 在普通权限下无法读取 electron 二进制)
// 3. 避免被 ELECTRON_RUN_AS_NODE 影响main.js 也会清除,这里是双保险)
//
// 与 Todo List 的 launch.js 逻辑完全一致 —— 它经过实际使用验证。
const path = require('path');
const { spawn } = require('child_process');
const fs = require('fs');
// 防止 ELECTRON_RUN_AS_NODE=1 让我们作为普通 Node 运行
if (process.env.ELECTRON_RUN_AS_NODE) {
console.log('[launch] 清除 ELECTRON_RUN_AS_NODE 环境变量');
delete process.env.ELECTRON_RUN_AS_NODE;
}
// Windows 控制台 UTF-8
if (process.platform === 'win32') {
try {
process.stdout.setDefaultEncoding('utf8');
process.stderr.setDefaultEncoding('utf8');
} catch {
// 某些嵌入式环境下不可用,忽略
}
// 把当前终端的代码页切换到 UTF-8。
// 否则即使我们输出 UTF-8 字节cmd.exe 默认 cp936 会按 GBK 解析成乱码。
// chcp 65001 = UTF-8 code page
try {
require('child_process').execSync('chcp 65001 > nul', {
stdio: 'ignore',
shell: true,
});
} catch {
// chcp 在某些嵌入式终端不可用,忽略
}
}
// 解析 electron 可执行文件路径
function resolveElectronBinary() {
try {
// 优先使用 require('electron') 暴露的二进制路径dev 模式)
const electronPath = require('electron');
if (typeof electronPath === 'string' && fs.existsSync(electronPath)) {
return electronPath;
}
} catch {
// fallthrough
}
// 兜底:尝试常见路径
const candidates = [
path.join(__dirname, '..', 'node_modules', '.bin', process.platform === 'win32' ? 'electron.cmd' : 'electron'),
path.join(__dirname, '..', 'node_modules', 'electron', 'dist', process.platform === 'win32' ? 'electron.exe' : 'electron'),
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
throw new Error('未找到 electron 可执行文件,请先运行 npm install');
}
const electronPath = resolveElectronBinary();
const projectRoot = path.resolve(__dirname, '..');
console.log('[launch] Electron:', electronPath);
console.log('[launch] 项目根目录:', projectRoot);
// 在 Windows 上以管理员权限运行时,当前工作目录可能是 System32
// electron 二进制所在目录可能存在空格或权限问题,统一切换到项目根
if (process.cwd() !== projectRoot) {
try {
process.chdir(projectRoot);
} catch (e) {
// 切换失败只发警告,不要静默吞掉 —— spawn 仍会用 cwd: projectRoot
// 所以这里失败通常不影响 electron 启动,但日志里有必要让用户看到。
console.warn(`[launch] 切换工作目录到 ${projectRoot} 失败:${e.message}`);
}
}
// 传递所有参数给 electron
const args = [projectRoot, ...process.argv.slice(2)];
console.log('[launch] 启动参数:', args.join(' '));
const child = spawn(electronPath, args, {
stdio: 'inherit',
cwd: projectRoot,
env: {
...process.env,
// 确保不会被子进程继承为 Node 模式
ELECTRON_RUN_AS_NODE: undefined,
},
windowsHide: false,
});
child.on('error', (err) => {
console.error('[launch] 启动失败:', err.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) {
console.log(`[launch] Electron 被信号终止: ${signal}`);
process.exit(1);
}
process.exit(code ?? 0);
});
// 透传 Ctrl+C
process.on('SIGINT', () => {
child.kill('SIGINT');
});
process.on('SIGTERM', () => {
child.kill('SIGTERM');
});