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();