update
This commit is contained in:
262
scripts/check-ipc.js
Normal file
262
scripts/check-ipc.js
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
// IPC 通道配对审计(取代 preload.js 旧的那 45 行手工注释)
|
||||
//
|
||||
// 规则:
|
||||
// 1. renderer → main(invoke / 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.js:ipcRenderer.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/**/*.js:ipcMain.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
86
scripts/check-syntax.js
Normal 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/export,node --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
117
scripts/launch.js
Normal 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');
|
||||
});
|
||||
Reference in New Issue
Block a user