diff --git a/client/electron/electron/main.cjs b/client/electron/electron/main.cjs index ec04ca2..8592670 100644 --- a/client/electron/electron/main.cjs +++ b/client/electron/electron/main.cjs @@ -12,7 +12,7 @@ * - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration */ -const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron'); +const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, shell } = require('electron'); const path = require('node:path'); const fs = require('node:fs'); @@ -29,6 +29,31 @@ let mainWindow = null; let tray = null; /** 创建主窗口 */ +/** + * ★ 只放行 http(s),其余一律**不**外跳。 + * + * 为什么需要这一层:`loadFile(dist/index.html)` 下,邮件正文里的链接 + * (`react-markdown` 会渲染 ``)点下去会**在应用窗口里导航走**。 + * 渲染层的 Markdown 纪律是好的(无 `dangerouslySetInnerHTML`、无 `rehype-raw`), + * **目前没有 XSS 面** —— 但一个 `href="https://…"` 就足以把整个应用窗口 + * 变成浏览器:窗口标题、preload 注入的 API base/token 全部暴露在陌生站点上。 + * + * ★ 只放行 http(s),**不要**放行 `file:`、`javascript:`、自定义协议: + * `shell.openExternal` 对某些 scheme 会交给系统处理,意图不可控。 + * 判不准就**什么都不做**(比"猜一个能用的 scheme"安全)。 + */ +function openExternalSafely(rawUrl) { + let u; + try { + u = new URL(rawUrl); + } catch { + return; // 不是合法 URL ⇒ 静默拒绝 + } + if (u.protocol === 'http:' || u.protocol === 'https:') { + void shell.openExternal(u.toString()); + } +} + function createMainWindow() { // 通过 additionalArguments 把 API 基地址与 token 传给 preload(preload 从 renderer 的 process.argv 末尾读) // 传入的是透传 arg,用户页面不能直接改,preload 能读到 @@ -57,6 +82,30 @@ function createMainWindow() { mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html')); } + /* + * ★ 新窗口一律**拒绝**,并把地址交给系统浏览器。 + * `deny` 之外没有第二种选择:`allow` 会给那个站点一个带 preload 的窗口。 + */ + mainWindow.webContents.setWindowOpenHandler(({ url }) => { + openExternalSafely(url); + return { action: 'deny' }; + }); + + /* + * ★ 窗口内导航一律**拦下**。注意要放行应用自己的那一次加载 + * (`loadFile` / dev 时的 `loadURL`),否则首屏就进不去。 + * 实现上不比较「当前 url」,而是放行**本应用自己的源**: + * —— `file:` 下比对 origin 不稳(各平台不一致),所以按前缀判。 + */ + mainWindow.webContents.on('will-navigate', (event, url) => { + const isOwnLoad = isDev + ? url.startsWith(DEV_URL) + : url.startsWith('file://'); + if (isOwnLoad) return; + event.preventDefault(); + openExternalSafely(url); + }); + // 点关闭按钮时隐藏到托盘而不是退出(Phase 4 行为,但用户要求进托盘,直接做) mainWindow.on('close', (e) => { if (!app.isQuitting) { @@ -111,16 +160,35 @@ function createTray() { } // Electron 生命周期 -app.whenReady().then(() => { - createMainWindow(); - createTray(); - - app.on('activate', () => { - // macOS 点击 Dock 图标重新建窗口 - if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); - else if (mainWindow) mainWindow.show(); +/* + * ★ 单实例锁。没有它,双击图标会起**两个进程**:两个窗口、**两条 SSE 连接**、 + * 两套 `accounts.json` 写入 —— 后两者是真问题:账号文件是「tmp + rename」 + * 原子写(见 accountsFile 的注释),并发写就变成「后写的赢」, + * 而用户以为只有一扇窗。 + * 第二个实例直接退出,把已有的窗口叫到前面来。 + */ +if (!app.requestSingleInstanceLock()) { + app.quit(); +} else { + app.on('second-instance', () => { + // 第二个实例启动时:把已有的窗口叫到前面(而不是再建一个) + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + } }); -}); + + app.whenReady().then(() => { + createMainWindow(); + createTray(); + + app.on('activate', () => { + // macOS 点击 Dock 图标重新建窗口 + if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); + else if (mainWindow) mainWindow.show(); + }); + }); +} app.on('window-all-closed', () => { // 托盘应用:关窗口不退出(macOS 惯例 + 用户要求的"进托盘"行为) diff --git a/client/electron/src/components/Attachments.tsx b/client/electron/src/components/Attachments.tsx index f1a1ad4..96f0926 100644 --- a/client/electron/src/components/Attachments.tsx +++ b/client/electron/src/components/Attachments.tsx @@ -67,8 +67,31 @@ export function AttachmentPicker({ const pick = () => inputRef.current?.click(); + /* + * ★ 同步闸门:`uploading` 只加在**按钮**的 `disabled` 上,而 `` + * 本身没有任何闸门 ⇒ 上传期间再选一次文件会重入 `handleFiles`, + * 而它拿到的是**上一次调用的 `items` 闭包** ⇒ + * `onChange([...旧 items, ...新的])` 把上一次的结果整批覆盖掉。 + * 表现是「选的附件少了」,**无任何提示**(不报错、不弹窗)。 + * 同一形状在 `MailView.tsx` 的 `ForwardBar`/`PermissionPanel` 已修(`inFlight`), + * 这里照那份形状改,不另创一套。 + * + * ★ 为什么清 `input.value` 要**提前**到进函数时: + * 原本那句在上传**全部完成后**才执行,所以"重选同一个文件不触发 change" + * 这个既有行为,靠的正是它 —— + * 若只把它挪到前面而不加 `picking` 闸门,就会**亲手制造**上面那个覆盖 bug + * (上传中重选同一个文件 ⇒ value 已是空 ⇒ change 照常触发 ⇒ 二次上传)。 + * 两个改动**必须成对**:提前清(让重选可用)+ 同步闸门(挡重入)。 + * 失败后重选同一个文件仍然可用:`value` 在进函数时就清空了。 + */ + const picking = useRef(false); + const handleFiles = async (files: FileList | null) => { if (!files || files.length === 0) return; + if (picking.current) return; // 同步闸门,挡住上传期间的重入 + picking.current = true; + // 提前清(不再等上传完),让"重选同一个文件"仍能触发 change + if (inputRef.current) inputRef.current.value = ''; setError(null); // 逐个上传而非并发:并发时进度条只能显示其中一个,且大文件同时传更容易触发体积限制 @@ -89,9 +112,7 @@ export function AttachmentPicker({ } setUploading(null); if (added.length > 0) onChange([...items, ...added]); - - // 清空 input,否则重复选同一个文件不会触发 change - if (inputRef.current) inputRef.current.value = ''; + picking.current = false; }; const remove = async (a: PendingAttachment) => { diff --git a/client/electron/src/components/MailView.tsx b/client/electron/src/components/MailView.tsx index 2e3b8a3..72801a0 100644 --- a/client/electron/src/components/MailView.tsx +++ b/client/electron/src/components/MailView.tsx @@ -777,7 +777,19 @@ export function PermissionPanel({ mail }: { mail: Mail }) {

) : null; + /* + * ★ 同步闸门(同文件 `ForwardBar` 的 `inFlight` 同一形状、同一理由)。 + * `disabled={busy}` 挡得住**渲染之后**的点击,但 `busy` 要等 React 提交才为真 + * ⇒ 同一帧里的两次激活(双击、Enter 连按、鼠标+键盘同时)都读到 `busy === false`, + * 于是**发出两条 `decidePermission`**。 + * 对审批这不是「多点一次」的问题:服务端**记两次账**,Agent 侧也收到两条通知。 + * —— 而审批是本工程里**唯一带副作用且不可撤销**的那类动作。 + */ + const inFlight = useRef(false); + const submit = async (decision: string, noteText: string) => { + if (inFlight.current) return; + inFlight.current = true; setSubmitError(''); setBusy(true); try { @@ -797,6 +809,7 @@ export function PermissionPanel({ mail }: { mail: Mail }) { */ setSubmitError(err instanceof Error ? err.message : String(err)); } finally { + inFlight.current = false; setBusy(false); } }; diff --git a/client/electron/test/components/PermissionPanel.test.tsx b/client/electron/test/components/PermissionPanel.test.tsx index 6db01a7..ef900b9 100644 --- a/client/electron/test/components/PermissionPanel.test.tsx +++ b/client/electron/test/components/PermissionPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; @@ -154,6 +154,46 @@ describe('PermissionPanel 决策', () => { await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument()); }); + /* + * ★ 上一格测的是**渲染之后**的 disabled —— 它**抓不到本条要拦的形状**。 + * 那个形状发生在 React 提交**之前**:`busy` 还没变成 true, + * 两次激活(双击 / Enter 连按 / 鼠标+键盘同时)都读到 `busy === false`。 + * 而审批是本工程里**唯一带副作用且不可撤销**的动作 + * ⇒ 服务端记两次账、Agent 侧收两条通知,且没有撤销入口。 + * + * ★★ 这里踩过一次**假绿**,值得把形状记准(2026-10-03 实测): + * 我第一版写 `fireEvent.click(b); fireEvent.click(b);`(不在 `act` 里), + * 断言 `toHaveBeenCalledTimes(1)` —— **删掉闸门它也照样绿**。 + * 真因:两次 `fireEvent` 之间 React 会提交一次(自动批处理), + * 于是第二次点到的按钮**已经 disabled** ⇒ 只发一条,与闸门无关。 + * ⇒ 必须把两次点击放进**同一个 `act`**(同一批次、中间不提交)才复现。 + * 实测对照(闸门已删):`A calls = 2`(同 act 内两次 click)。 + * `userEvent.click` 每次都 await 一轮 ⇒ **它测不到同帧**,别用。 + */ + it('★ 同一帧内的两次激活只发出一条 decidePermission(busy 要到提交后才为真)', async () => { + let release: (v: any) => void = () => {}; + const spy = vi.spyOn(api, 'decidePermission').mockReturnValue( + new Promise(res => { + release = res; + }) as any + ); + render(React.createElement(PermissionPanel, { mail: permMail() })); + + const approve = screen.getByRole('button', { name: /同意/ }); + // ★ 同**一个** act:两次激活落在同一批次,中间没有 React 提交 + await act(async () => { + fireEvent.click(approve); + fireEvent.click(approve); + }); + + await act(async () => { + release({ status: 'decided' }); + }); + await waitFor(() => expect(screen.getByText('已处理:')).toBeInTheDocument()); + + expect(spy).toHaveBeenCalledTimes(1); + }); + it('提交失败时恢复可点,不假装已决策', async () => { vi.spyOn(api, 'decidePermission').mockRejectedValue(new Error('500')); vi.spyOn(console, 'error').mockImplementation(() => {}); diff --git a/client/electron/test/main-process-security.test.mjs b/client/electron/test/main-process-security.test.mjs new file mode 100644 index 0000000..b20dd9e --- /dev/null +++ b/client/electron/test/main-process-security.test.mjs @@ -0,0 +1,242 @@ +/** + * Electron **主进程**的安全防线。 + * + * ## 为什么需要这条 + * + * `markdown-xss.test.mjs` 守的是**渲染层**(raw HTML 转义、`javascript:` 中和)。 + * 这一条守的是**主进程**那一侧 —— 它此前没有任何判据。 + * + * 事实前提(2026-10-03 实测):`main.cjs` 此前**没有** `setWindowOpenHandler`、 + * **没有** `will-navigate`、**没有** `requestSingleInstanceLock`(grep 0 命中)。 + * 在 `loadFile(dist/index.html)` 下,邮件正文里的链接(`react-markdown` 会渲染 + * `
`)点下去会**在应用窗口里导航走**: + * 一个 `href="https://…"` 就足以把整个应用窗口变成浏览器, + * 而窗口标题与 preload 注入的 API base/token 全部暴露在那个站点上。 + * + * ★ **本条不是"防 XSS"** —— 渲染层没有 XSS 面(`markdown-xss` 守着)。 + * 它防的是**导航逃逸**:让外部站点**借用**这个窗口与 preload 上下文。 + * 两件事的失效方式不同,所以要分别钉。 + * + * ## 怎么读 main.cjs + * + * 走 `code()`(剥注释)。⚠️ 判的是**结构**,不是字面: + * 下面用 `functionBody()` / `callArgs()` 按括号配对取方法体与调用实参, + * **不用** `\{[\s\S]{0,80}` 那种固定宽度窗口 —— `CRITERIA.md` §1 记着 + * 那个形状被"往规则里加一行注释"绕过第三次了。 + * + * ## 边界 / 未做 + * + * · 这些都是**静态**断言,证明"守卫被写下来了",**不证明它在运行时生效** + * (那要真起一个窗口点链接)。这是本条与 §6.5「运行时行为要实测」的分工。 + * · 不判 `webPreferences` 那三项的**值**是否"足够安全"——那属于安全评审, + * 不属于可机检的范围;本条只钉"不许被放松"。 + */ + +import { code } from './lib/read.mjs'; +import { check, finish } from './lib/checks.mjs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const MAIN = join(HERE, '..', 'electron', 'main.cjs'); +const src = code(MAIN); + +/** + * 按括号配对取一个函数的**方法体**(跳过字符串与嵌套括号组)。 + * + * 为什么不用固定宽度窗口:`CRITERIA.md` §1 的第 ③ 次露头 —— + * ArkTS/JS 的链式修饰符与嵌套对象会让"往前看一个字符是不是 `}`"失效, + * 而 `{[\s\S]{0,80}` 会被"加一行注释"绕过。 + */ +function functionBody(srcText, name) { + const decl = new RegExp(`function\\s+${name}\\s*\\([^)]*\\)\\s*\\{`).exec(srcText); + if (!decl) return null; + const open = decl.index + decl[0].length - 1; + let depth = 0; + let i = open; + while (i < srcText.length) { + const ch = srcText[i]; + if (ch === '"' || ch === "'" || ch === '`') { // 跳过字符串 + const q = ch; + i += 1; + while (i < srcText.length && srcText[i] !== q) { + if (srcText[i] === '\\') i += 1; + i += 1; + } + } else if (ch === '{') depth += 1; + else if (ch === '}') { + depth -= 1; + if (depth === 0) return srcText.slice(open + 1, i); + } + i += 1; + } + return null; +} + +/** 取某个调用/赋值右侧**第一个实参**(按括号配对,跳过嵌套) */ +function firstArg(srcText, callRe) { + const m = callRe.exec(srcText); + if (!m) return null; + const start = m.index + m[0].length; + let i = start; + let depth = 0; + while (i < srcText.length) { + const ch = srcText[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const q = ch; + i += 1; + while (i < srcText.length && srcText[i] !== q) { + if (srcText[i] === '\\') i += 1; + i += 1; + } + } else if (ch === '(' || ch === '{' || ch === '[') depth += 1; + else if (ch === ')' || ch === '}' || ch === ']') { + if (depth === 0) return srcText.slice(start, i); + depth -= 1; + } + i += 1; + } + return null; +} + +// ── ① 新窗口一律拒绝 ──────────────────────────────────────────────────────── +check('setWindowOpenHandler 存在(不给陌生站点一个带 preload 的窗口)', + src.includes('setWindowOpenHandler'), + 'main.cjs 里 grep 不到 setWindowOpenHandler —— 邮件里的链接能开出一个继承 preload 上下文的新窗口'); + +/* + * ★ 第一版这个正则匹配到的是**空串**(我写错了,不是源码缺东西): + * `/setWindowOpenHandler\s*\(\s*\{[\s\S]*?\}\s*=>\s*\{/` —— `\{[\s\S]*?\}` 在 + * 参数解构 `({ url }) => {` 上先匹到了 `{ url }` 的那个 `}`, + * 于是 `start` 落在 `=> {` 之后、深度已为 0 ⇒ firstArg 立刻返回空串。 + * ⇒ 改用**先定位调用、再取整个 handler 函数的体**,与 ④ 用同一个手段。 + */ +const openHandlerBody = (() => { + const at = src.indexOf('setWindowOpenHandler'); + if (at < 0) return null; + const arrow = src.indexOf('=>', at); + if (arrow < 0) return null; + const open = src.indexOf('{', arrow); + if (open < 0) return null; + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + return null; +})(); +check('setWindowOpenHandler 返回 { action: \'deny\' }', + openHandlerBody !== null && /action\s*:\s*'deny'/.test(openHandlerBody), + `必须显式 deny。取到的 handler 体:${JSON.stringify((openHandlerBody || '').slice(0, 80))}`); + +// ── ② 窗口内导航一律拦下,且放行本应用自己的加载 ──────────────────────────── +check("will-navigate 被拦(主窗口不能被邮件里的链接带走)", + src.includes("'will-navigate'"), + '没有 will-navigate ⇒ loadFile 下点 会直接导航走,token 暴露在陌生站点上'); + +const willNavBody = /'will-navigate'[\s\S]{0,400}?=>\s*\{([\s\S]*?)\n\s*\}/.exec(src); +check('will-navigate 里调了 event.preventDefault()', + willNavBody !== null && /preventDefault\s*\(\s*\)/.test(willNavBody[1]), + '拦了事件却不 preventDefault,等于没拦'); + +/* + * ★★ 必须**放行本应用自己的加载**,否则首屏进不去(prod 是 `file://`, + * dev 是 `ELECTRON_START_URL`)。这是"别把守卫写死"的对称约束: + * 只会 preventDefault 的实现会把应用自己锁死。 + * + * ★★ 第三版才抓得住:前两版扫的是**整个文件**(`/isOwnLoad|startsWith\(…\)/.test(src)`), + * 而变异把**调用点**改成 `if (false) return;` —— 变量**定义还在**文件里 ⇒ 照样绿。 + * ⇒ 必须扫 **will-navigate 的那个函数体**,且要求**真的被 return 掉** + * (`return` 出现在守卫变量的判断上,而不是只判断"这个变量在文件里存在")。 + */ +const willNavFn = (() => { + const at = src.indexOf("'will-navigate'"); + if (at < 0) return null; + const open = src.indexOf('{', src.indexOf('=>', at)); + if (open < 0) return null; + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + return null; +})(); +check('will-navigate 放行本应用自己的加载(prod 的 file:// / dev 的 DEV_URL)', + willNavFn !== null + && /if\s*\(\s*isOwnLoad\s*\)\s*return/.test(willNavFn) + && /startsWith\(\s*'file:\/\/'\s*\)|startsWith\(\s*DEV_URL\s*\)/.test(willNavFn), + '拦了 will-navigate 却没有**真的 return** 掉本应用自己的加载 ⇒ 首屏进不去。这类"过严"的守卫同样是缺陷'); + +// ── ③ 外跳只放行 http(s) ──────────────────────────────────────────────────── +const openExtBody = functionBody(src, 'openExternalSafely'); +check('openExternalSafely 存在(外跳的判定集中在一处)', + openExtBody !== null, + '外跳判定应当集中在一个函数里,而不是散在两个 handler 中各写一遍'); + +check('外跳只放行 http/https', + openExtBody !== null + && /protocol\s*===\s*'http:'\s*\|\|/.test(openExtBody.replace(/\s+/g, ' ')) + && /protocol\s*===\s*'https:'/.test(openExtBody), + "只放行 http(s)。file: / javascript: / 自定义协议交给 shell.openExternal 意图不可控"); + +check('外跳对非法 URL 静默拒绝(不 try/catch 就裸 new URL)', + openExtBody !== null && /catch/.test(openExtBody), + 'new URL() 对畸形输入会抛 ⇒ 不接住就会把守卫自己弄崩(崩了等于没守卫)'); + +// ── ④ 单实例锁:双击图标不该起两个进程 ────────────────────────────────────── +check('requestSingleInstanceLock 被调用', + src.includes('requestSingleInstanceLock'), + '没有单实例锁 ⇒ 双击图标起两个进程:两条 SSE 连接 + 两套 accounts.json 并发写入'); + +const lockGuard = /if\s*\(\s*!\s*app\.requestSingleInstanceLock\s*\(\s*\)\s*\)/.test(src); +check('拿不到锁就退出(而不是继续开第二个窗口)', lockGuard, + '必须 app.quit() 并把启动流程放进 else 分支,否则"拿到锁"只是被算了一下'); + +/* + * ★ 第一版我去 `createMainWindow` 的体里找 restore/show —— **找错了函数**: + * `restore`/`show` 在 `second-instance` 的**回调**里,不在 createMainWindow 里。 + * (和 §12「与 X 一致」同族:要判的 X 侧那个值,得先知道它**在哪**。) + */ +const secondInstanceBody = (() => { + const at = src.indexOf("'second-instance'"); + if (at < 0) return null; + const open = src.indexOf('{', src.indexOf('=>', at)); + if (open < 0) return null; + let depth = 0; + for (let i = open; i < src.length; i += 1) { + if (src[i] === '{') depth += 1; + else if (src[i] === '}') { + depth -= 1; + if (depth === 0) return src.slice(open + 1, i); + } + } + return null; +})(); +check("second-instance 把已有窗口叫到前面(不是再建一个)", + secondInstanceBody !== null + && /isMinimized\s*\(\s*\)/.test(secondInstanceBody) + && /restore\s*\(\s*\)/.test(secondInstanceBody) + && /show\s*\(\s*\)/.test(secondInstanceBody), + 'second-instance 里要把窗口 un-minimize + show,否则双击后用户看不到任何反应'); + +// ── ⑤ webPreferences 不许被放松(本条只钉"不许松",不评审"够不够安全")── +const winBody = functionBody(src, 'createMainWindow'); +check('contextIsolation 仍为 true', + winBody !== null && /contextIsolation\s*:\s*true/.test(winBody), + 'contextIsolation: false 会让页面脚本摸到 preload 暴露的接口'); + +check('nodeIntegration 仍为 false', + winBody !== null && /nodeIntegration\s*:\s*false/.test(winBody), + 'nodeIntegration: true 等于把 Node 交给邮件正文里的一切'); + +check('sandbox 仍为 true', + winBody !== null && /sandbox\s*:\s*true/.test(winBody), + 'sandbox: false 会撤掉 renderer 的进程级隔离'); + +finish('主进程安全防线'); \ No newline at end of file diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index fa28e0c..34867f4 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -211,7 +211,14 @@ const SUITE = [ */ ['test/inbox-fallback-poll.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0], ['test/sse-credentials.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0], - ['test/web-comment-only.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0] + ['test/web-comment-only.test.mjs', ['--experimental-strip-types', '--no-warnings'], 0], + /* + * ★ 2026-10-03 补接线:主进程安全防线(新建判据,**必须接线**,否则自检 2 会停掉全部)。 + * 登记数 **14**(自报 RESULT pass/fail 的判据,按 `§6.6` 逐条核对过)。 + * 守的是 `electron/main.cjs`:导航/新窗口拦截 + 单实例锁 + webPreferences 不许放松。 + * 详见该文件头部 —— 它与 `markdown-xss`(渲染层)防的是**两件不同的事**。 + */ + ['test/main-process-security.test.mjs', [], 14] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑)