From d94717aa95a9ab1e40c22cf35e4bbea0fdfb0fb4 Mon Sep 17 00:00:00 2001 From: jianf <2198972886@qq.com> Date: Sun, 4 Oct 2026 11:08:57 +0800 Subject: [PATCH] =?UTF-8?q?test(ele):=20=E2=98=85=E2=98=85=20preload=20?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E9=97=B8=E9=97=A8=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E4=B8=80=E5=8F=A5=E7=B1=BB=E5=9E=8B=E6=A0=87=E6=B3=A8=E8=83=BD?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E5=BA=9F=E6=8E=89=E6=95=B4=E6=9D=A1=E6=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 为什么要这条(一个当场踩到、且 tsc 抓不到的坑) 上一个提交给 `preload.cjs` 加窗口动作时,第一版写了 TS 类型标注: onMaximizedChanged: (cb: (maximized: boolean) => void) => { // ← .cjs 里不能有 `preload.cjs` 是纯 JS,整个文件**静默**加载失败。症状链: hasBridge: false hasWin: false base: "/api/v1" tok: store.n: 0 text: "用户名 | 密码 | 登录" · 账号读不到 ⇒ 回退到**网页版**登录分支(用户名+密码,桌面壳里注定失败) · 标题栏不渲染 ⇒ 自绘窗口那条顶栏整个消失 ★ 而 `npm run typecheck`(`tsc --noEmit`)**不检查 .cjs**,一路全绿。 我当时就是靠它判断「没问题」的 —— 它证明不了这件事。 ## 为什么这条判据要「真的加载」而不是读文件看字符串 失效形状是「**静默少给一整条桥**」:不抛错、不警告,症状出现在**别处** (看起来像「功能没做」而不是「代码坏了」)。 与 `93697c4`(stripComments 两趟正则吃掉 137 行真代码)同一族,但更狠 —— 那条至少还会让某条判据红;这条让**所有**判据都无从察觉 (大家都不读 preload,自然没人会发现它没加载)。 ⇒ 所以闸门是 `new Module(...)._compile(code(PRELOAD), PRELOAD)`, 真的解析并执行到底(stub 掉 `electron` 模块)。 不是 `node -c`、不是正则、不是 `require`。 **变异验证**:把类型标注加回去 ⇒ 失败 ★ preload.cjs 能真的加载 — 实际错误:Unexpected token ':' 主进程安全防线:18 通过,1 失败 ★ 且验证了它在 `code()` 剥掉注释后**仍然有效**(剥注释会改行号, 但这条判的是「能不能解析执行」,不依赖行号)。 ## 另 4 条(都属自绘窗口这一批) * preload 不得暴露任意 IPC 透传(只有具名动作) * 窗口三个动作都过了窄接口(`frame:false` 之后没有系统标题栏兵底) * 最大化状态是订阅来的(`pushMaxState`:`maximize`/`unmaximize`/进退全屏) * `frame: false` 与 `titleBarStyle: 'hidden'` 同时在 ## 自报条数 14 → 19 `run-all.mjs` 的显式编辑(§6.6)。**这条是套件自己抓到的**, 不是我改的 —— 判据报「自报 19 条 > 登记的 14 条,新加的那几条不在 "被删会红"的保护内」。 ## 顺带修一条自己的判据缺陷 新写的闸门一开始用了裸 `readFileSync` 读 preload,被 `criteria-hygiene` 抓(「判据目录里不得出现裸 readFileSync」)。 改成 `code()` —— 判「有没有这个符号」时要的是代码,裸读原文会被 解释性注释骗(同坑本仓踩过两次,其中一次就是这条判据自己)。 ## 两份 GUI 复查报告一并存档 `docs/reviews/gui-review-2026-10-03.md`(基线 `5e312c6`,848 行) `docs/reviews/gui-fixes-review-2026-10-03.md`(基线 `b4610f1`,575 行) ★ 第二份里**否证了上游报告两条**,避免下一任白做工: ① 鸿蒙日历「格/行时间基准不一致」是**误报** —— 5 个时区实测 `hhmmAtOffset(ts, deviceOffsetMinutes)` 恒等于 `getHours()`; ② `MailStore.bump()` 不发布 AppStorage 是**设计**(混用会死循环), `harmony-state-review.md` #3 是误读。 ★ 另外查出一条比那批修复更要紧的事:HarmonyOS 客户端**当前编译不过** (`Theme.hairline` 从未定义,48 个提交前就坏了),而修复提交的信息写着 「BUILD SUCCESSFUL」—— 路径在本机不存在。已用 `git worktree` 对照旧基线 证明**不是这批提交引入的**。已在报告 §2/§5.2 记明并给了修法选项, **未动业务代码**(等决策)。 ## 本机实测读数(装依赖 + 补齐 4 个环境变量后) RESULT files=39 ran=39 checks=635 pass=623 fail=2 skip=10 red=5 broken=0 unreported=0 变异通道 mutants=52 ran=52 on_new_criteria=37 npx vitest run → Test Files 16 passed / Tests 271 passed npm run typecheck → 通过 剩 5 个红**全是环境**,不是缺陷: · `build-stamp` —— 本机没 `npm run build` 时的产物 · `harmony-push` —— `agconnect-services.json` 不存在(该欠账的 `due` 就是"拿到真机") · `--exitcode-selftest` + 因果对照 + `summary.py baseline-residue` —— 三者**同一个根因**:本会话 uid=1000 非 root,`runuser`/`setpriv` 都无法降权(实测 `runuser -u nobody` 报"非 root 用户不能使用") ⇒ `run-all.mjs:1043` 的 `dropTo` 探测返回 null ⇒ 那 6 个需要降权的 变异案例被跳过并计红,连带 baseline 残留检测一起失效。 ★ 那条报错文案写着「优先按变异残留查」会**把人引向查工作区**, 而底本在 `mkdtempSync` 造的 /tmp 迷你仓库里、与工作区无关 —— 已在报告 §4.3 记下,建议文案补一句先看 selftest 是否先红了。 --- .../test/main-process-security.test.mjs | 66 ++ client/electron/test/run-all.mjs | 15 +- docs/reviews/gui-fixes-review-2026-10-03.md | 575 ++++++++++++ docs/reviews/gui-review-2026-10-03.md | 848 ++++++++++++++++++ 4 files changed, 1501 insertions(+), 3 deletions(-) create mode 100644 docs/reviews/gui-fixes-review-2026-10-03.md create mode 100644 docs/reviews/gui-review-2026-10-03.md diff --git a/client/electron/test/main-process-security.test.mjs b/client/electron/test/main-process-security.test.mjs index b20dd9e..46279eb 100644 --- a/client/electron/test/main-process-security.test.mjs +++ b/client/electron/test/main-process-security.test.mjs @@ -36,9 +36,11 @@ import { code } from './lib/read.mjs'; import { check, finish } from './lib/checks.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import Module from 'node:module'; const HERE = dirname(fileURLToPath(import.meta.url)); const MAIN = join(HERE, '..', 'electron', 'main.cjs'); +const PRELOAD = join(HERE, '..', 'electron', 'preload.cjs'); const src = code(MAIN); /** @@ -239,4 +241,68 @@ check('sandbox 仍为 true', winBody !== null && /sandbox\s*:\s*true/.test(winBody), 'sandbox: false 会撤掉 renderer 的进程级隔离'); +// ── ⑥ 自绘窗口外观(2026-10-04)── +// +// ★ 本段的第一条是本文件存在以来最重要的一条:**preload 能不能加载**。 +// 2026-10-04 实测:在 preload.cjs(`.cjs`)里写了一句 TS 类型标注 +// `(cb: (maximized: boolean) => void)` ⇒ **整个 preload 静默加载失败** ⇒ +// 渲染层 `window.agentmail === undefined` ⇒ +// · 账号读不到 → 回退到**网页版**登录页(用户名+密码,桌面壳里注定失败) +// · 标题栏不渲染 → 自绘窗口那条顶栏整个消失 +// 而 `npm run typecheck`(tsc --noEmit)**不检查 .cjs**,照常全绿。 +// +// ⇒ 失效形状是「静默少给一整条桥」:不抛错、不警告,症状出现在**别处**。 +// 与 `stripComments` 吃掉 137 行真代码(见 93697c4)同一族, +// 但更狠 —— 那条至少还会让某条判据红,这条让**所有**判据都无从察觉。 +// +// 所以加载闸门必须**真的 require 一次**,而不是读文件、看字符串。 + +// ★ 读 preload 走 `code()` 而不是裸 `readFileSync`(criteria-hygiene 的硬规矩): +// 判「有没有这个符号/这条约束」时要的是**代码**,裸读原文会被解释性注释骗 +// (同一个坑本仓踩过两次 —— 其中一次就是它自己)。 +// 下面的 `_compile` 用的 source 也用同一份,两处不会分叉。 +const preloadSrc = code(PRELOAD); +let preloadLoads = true; +let preloadErr = ''; +try { + // 桩掉 electron 模块:preload 只用 contextBridge / ipcRenderer 两个符号, + // 而它们的行为不是本条要验的(要验的是「这份文件能不能被解析并执行到底」)。 + const stub = { contextBridge: { exposeInMainWorld: () => {} }, ipcRenderer: { on() {}, invoke() {}, removeListener() {} } }; + const mod = new Module(PRELOAD, null); + mod.filename = PRELOAD; + mod.paths = Module._nodeModulePaths(dirname(PRELOAD)); + mod.require = id => (id === 'electron' ? stub : mod.require(id)); + mod._compile(preloadSrc, PRELOAD); +} catch (e) { + preloadLoads = false; + preloadErr = String(e && e.message || e); +} + +check('★ preload.cjs 能真的加载(`node -c` 那种语法错在这里被抓住)', + preloadLoads, + 'preload 加载失败是**静默**的:渲染层只看到 window.agentmail === undefined,' + + '于是账号读不到、标题栏不渲染,像「功能没做」而不是「代码坏了」。' + + '⚠️ tsc --noEmit 不检查 .cjs,所以 typecheck 全绿不代表这里没问题。' + + (preloadErr ? ` 实际错误:${preloadErr}` : '')); + +check('preload 不得暴露任意 IPC 透传(只有具名动作)', + !/ipcRenderer\.invoke\s*\(\s*(?:channel|name|e)\s*[,)]/.test(preloadSrc), + '把 IPC 名当参数传进来 = 渲染层可以 invoke 任意主进程通道'); + +check('窗口三个动作都过了窄接口', + /minimize:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:minimize'\)/.test(preloadSrc) && + /toggleMaximize:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:toggle-maximize'\)/.test(preloadSrc) && + /close:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('window:close'\)/.test(preloadSrc), + '最小化/最大化/关闭三个动作缺一不可(frame:false 之后没有系统标题栏兵底)'); + +check('最大化状态是订阅来的(pushMaxState 把 maximize/unmaximize 推给渲染层)', + /mainWindow\.on\(\s*'maximize'/.test(src) && /mainWindow\.on\(\s*'unmaximize'/.test(src) && + /maximized-changed/.test(src), + '最大化有三条不经过按钮的路径(双击拖拽区 / Win+↑↓ / 拖边缘 Snap),' + + '只靠按钮点击时查一次 ⇒ 图标与真实状态脱节'); + +check('frame: false 与 titleBarStyle: hidden 同时在(自绘窗口框架)', + winBody !== null && /frame\s*:\s*false/.test(winBody) && /titleBarStyle\s*:\s*'hidden'/.test(winBody), + '只给 titleBarStyle 仍留着系统边框;两者一起才是「整个框架自己接管」'); + finish('主进程安全防线'); \ No newline at end of file diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index a54a9b6..140e7cb 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -214,11 +214,20 @@ const SUITE = [ ['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 不许放松。 + * 登记数 **19**(自报 RESULT pass/fail 的判据,按 `§6.6` 逐条核对过)。 + * 14 → 19(2026-10-04 显式编辑):新增 5 条都属「自绘窗口外观」这一批 —— + * · **preload.cjs 能真的加载**(`_compile` 一次,不是读文件看字符串) + * · preload 不得暴露任意 IPC 透传 + * · 窗口三个动作都过了窄接口 + * · 最大化状态是订阅来的(pushMaxState) + * · `frame: false` 与 `titleBarStyle: 'hidden'` 同时在 + * ★ 第一条是本文件存在以来最重要的一条:preload 里写一句 TS 类型标注 + * (`.cjs` 不接受)就**静默**让整条桥消失,症状是「账号读不到 + 标题栏不渲染」, + * 而 `tsc --noEmit` **不检查 .cjs** ⇒ typecheck 全绿也证明不了它。 + * 守的其余部分:`electron/main.cjs`:导航/新窗口拦截 + 单实例锁 + webPreferences 不许放松。 * 详见该文件头部 —— 它与 `markdown-xss`(渲染层)防的是**两件不同的事**。 */ - ['test/main-process-security.test.mjs', [], 14] + ['test/main-process-security.test.mjs', [], 19] ]; // 自检 1:清单里的文件必须真的存在(写错名字 = 那条判据永远不跑) diff --git a/docs/reviews/gui-fixes-review-2026-10-03.md b/docs/reviews/gui-fixes-review-2026-10-03.md new file mode 100644 index 0000000..d0af568 --- /dev/null +++ b/docs/reviews/gui-fixes-review-2026-10-03.md @@ -0,0 +1,575 @@ +# GUI 修复复查报告(2026-10-03 复核 · 基线 `b4610f1`) + +> **上游**:本会话早前写的 `docs/reviews/gui-review-2026-10-03.md`(基线 `5e312c6`)。 +> 本文只做一件事:**核验那份报告提的问题在 `5e312c6..b4610f1` 这 14 个提交里修到了什么程度**, +> 以及**修的东西本身有没有新问题**。不重写架构分析。 +> **本轮实际跑了什么**:`npm install` + `npm test`(criteria 630 项 + vitest 271 项 + typecheck)、 +> `hvigorw assembleHap`、`git worktree` 旧基线对照编译、4 个变异注入。 +> ★ 与上一份报告不同 —— **本轮的每条结论都有可复现的命令**,见文末「证据」。 + +--- + +## 0. 一句话结论 + +报告里 **7 个具体条目全部动了**,其中 5 个修得干净且判据真能红; +**但 HarmonyOS 客户端在当前 HEAD 上编译不过**(5 个错误,其中 3 个是 48 个提交前就存在的 +`Theme.hairline` 未定义),而修复提交的信息里写着「BUILD SUCCESSFUL」。 +另发现 **1 处新判据有真缺口**(去掉 `desiredSize` 不会被抓)和 **1 处新引入的死锁路径**(附件闸门不复位)。 + +--- + +## 1. 结算表 + +| 条目 | 状态 | 判据 | 变异验证 | +|---|---|---|---| +| **H-1** `MailStore` 收/发共用快照 | ✅ 修得对 | `harmony-arkts` 第 9 条 | ✅ **我亲自注入 2 个变异,全被抓** | +| **H-2** 壁纸 `PixelMap` 泄漏 | ⚠️ 修得对,但**判据有缺口** | `harmony-arkts` 第 10 条 | ⚠️ 2 个变异抓到、**1 个漏了** | +| **E-1** `PermissionPanel` 重复提交 | ✅ 修得对 | `PermissionPanel.test.tsx` | ✅ **我注入变异后确认变红** | +| **E-2** 附件连选覆盖 | ⚠️ 主 bug 修了,**引入新的死锁** | ❌ **无判据** | — | +| **X-1** 主进程零导航拦截 | ✅ 修得对 | `main-process-security.test.mjs`(新文件,14 项) | ✅ 独立跑通 | +| **X-2** 无 CSP | ✅ 修得对 | 无专门判据 | — | +| M-1..M-6 / L-1 / G-1 / G-2 | 未动 | — | — | + +--- + +## 2. 编译:当前 HEAD 是**红的**,且不是这批提交引入的 + +``` +cd client/harmony && hvigorw assembleHap --no-daemon +→ COMPILE RESULT:FAIL {ERROR:6 WARN:38} +``` + +5 个错误: + +| # | 错误 | 位置 | 来源 | +|---|---|---|---| +| 1 | `Cannot find module '@luvi/lv-markdown-in'` | `MailDetailPage.ets:9` | 环境(依赖未装) | +| 2-4 | `Property 'hairline' does not exist on type 'typeof Theme'` | `MainPage.ets:608 / 1028 / 1560` | ★ **提交 `477479a`,48 个提交前** | +| 5 | `Markdown({...}) does not meet UI component syntax` | `MailDetailPage.ets:1324` | 环境(依赖未装 ⇒ 类型退化) | + +**归因已用 `git worktree` 对照证明**(不是推测): + +``` +git worktree add /tmp/mui-base 5e312c6 +cd /tmp/mui-base/client/harmony && hvigorw assembleHap +→ 同样 5 个错误,逐条相同(含同样的行号 608 / 1028 / 1554) +``` + +⇒ **这批修复提交没有引入编译错误**,但**旧基线同样是红的**。 + +### 2.1 真正的源头:`477479a` 引入了一个从未定义的成员 + +``` +git log --all -S "Theme.hairline" -- MainPage.ets → 477479a(唯一命中) +git show 477479a~1:MainPage.ets | grep -c Theme.hairline → 0 +git show 477479a:Theme.ets | grep hairline → 无 +``` + +`477479a`("三页 AppHeader 顶栏避让",提交信息标了"真机实测")新增 3 处 `Theme.hairline`, +但 `common/Theme.ets` 里**从头到尾没有这个字段**(现在也没有)。 +⇒ **该提交把一个编译不过的版本合进了 main,并且 48 个提交里没人再碰过那 3 行**。 + +★ **这条比本报告其余所有内容都重要**,理由见 §5.2。 + +### 2.2 关于提交信息里的「BUILD SUCCESSFUL」 + +`6a8e868` 的信息写着: + +> `/opt/huawei/command-line-tools/bin/hvigorw` 6.26.2 可用, +> `hvigorw assembleHap --no-daemon` 出 **BUILD SUCCESSFUL**(约 26 s)。 + +本机实测: +- `/opt/huawei` **不存在**(`ls: 无法访问`) +- 真实路径是 `/home/jianf/command-line-tools/bin/hvigorw` + +⇒ 那次 BUILD SUCCESSFUL 大概率是在**另一台机器**上跑的(路径都不同)。 +它不必然是假的 —— 但**不能作为"这两条 HIGH 已验证可编译"的证据**, +因为**同一台机器上此刻是 FAIL**。这条信息的价值被高估了。 + +★ 不过要给 credit:`6a8e868` 信息里关于 **`arkts-no-obj-literals-as-types`** +那段是**真知识**(ArkTS 确实不接受对象字面量当类型),且它给出的修法(先声明具名 interface) +与本仓 `harmony-arkts` 的既有形状一致。这类"编译期硬规则"值得留在仓库里。 + +--- + +## 3. 逐条核验 + +### 3.1 H-1 `MailStore` —— ✅ 修得对,且比我提的方案多修了一处 + +实现(`MailStore.ets`):新增 `sentSnapshot` + `inboxGen`/`sentGen` 两个代号; +`loadSent`(`:648`)写 `this.sentSnapshot`;`SentTab`(`MainPage.ets:1416`)改读 `store.sentSnapshot`; +`clear()` 清两份并把两个代号 `+1` 作废。 + +★ **比我报告里的草案多修了一处我没提的回归**:拆分后若只动收件箱, +归档会话会让发件箱**继续显示那个已删会话**。实现把它拆成 +`dropSessionFromInbox` / `dropSessionFromSent`, +且发件箱那份**不用** `splitByPermission`(否则会把"我发出的授权请求"抹掉)。 +这是对的,且判据(`:780`)专门钉了这一条。 + +**判据变异验证(我实跑)**: + +| 变异 | 结果 | +|---|---| +| `loadSent` 的 `const snap = this.sentSnapshot` 改回 `this.snapshot` | ✅ `not ok 9` | +| `SentTab` 的 `store.sentSnapshot` 改回 `store.snapshot` | ✅ `not ok 9` | + +⇒ 这条判据**有分辨力**,不是摆设。 + +⚠️ **但 generation 守卫本身没有任何判据**(全仓 grep `inboxGen` 在 `test/` 下 0 命中)。 +删掉 `if (this.inboxGen !== myGen) return;` 整套判据照样全绿。 +⇒ 这是"**守卫没被钉住**",与 §3.2 的缺口同族,但后果轻(它挡的是同栏乱序,不是跨栏覆写)。 + +### 3.2 H-2 壁纸 —— 实现对,但判据有一处**真缺口** + +实现(`AppearanceStore.ets:233-302`)三件事都做了: +`desiredSize` 取 `display.getDefaultDisplaySync()`(带 catch,取不到退回不限尺寸); +`finally` 里 `src.release()` / `next.release()`; +所有权顺序正确(先 `this.wallpaper = next` → `next = null` → `await old.release()`)。 +另加 `releaseWallpaper()`,`Logout.ets` 已调用。 + +★ 注释里点名了"若在 `finally` 里释放 `this.wallpaper`,会把刚装上的那张释放掉"—— +这正是我草案里写的那个坑,实现避开了。 + +**变异验证(我实跑)**: + +| 变异 | 结果 | +|---|---| +| 释放的改成 `this.wallpaper.release()`(所有权写反) | ✅ `not ok 10` | +| **把 `createPixelMap(decodeOptions)` 改成 `createPixelMap()`** | ❌ **仍然 pass 10/10** | + +### ⚠️ 这是本次唯一一个新引入的判据缺口,根因值得记 + +判据(`harmony-arkts.test.mjs:806`)只断言: + +```js +assert.match(body, /desiredSize/, '必须给 desiredSize …'); +``` + +`desiredSize` **字面量仍然存在于方法体里**(在 `const decodeOptions = { desiredSize: {...} }` 那一行), +即使那个对象**再也没有传给 `createPixelMap`**。⇒ 断言通过,缺陷已回来。 + +⇒ **判据钉的是"字符串出现过",不是"它被用上了"** —— 这恰好是 +`CRITERIA.md` §1 与 `DEBTS.json` 里 `class-boundary-needs-real-mechanism-not-string-shape` +记的**同一族**坑("用字符串形状冒充机制")。**它在本仓已经栽过至少三次,这是第四次。** + +**建议的修法**(不要扩大窗口,照 `CRITERIA.md` 的取结构体形状): + +```js +// 断言 decodeOptions 真的被传进 createPixelMap,而不是只构造出来 +assert.match(body, /createPixelMap\(\s*decodeOptions\s*\)/, + '★ `decodeOptions` 必须真的传给 createPixelMap —— 只构造出来就等于没限尺寸' + + '(26 MB 常驻那一条原样回来)'); +``` + +同时建议把**两个分支**都钉住(现在有 `if (target.width > 0 …)` / `else` 两条路径), +否则把两个分支对调也会绿。 + +### 3.3 E-1 审批闸门 —— ✅ 修得对,判据真能红 + +实现(`MailView.tsx:790`)照 `ForwardBar` 的形状加 `inFlight = useRef(false)` + `if (inFlight.current) return;`。 + +★ 判据(`PermissionPanel.test.tsx:173`)值得单独说一句:它的注释(`:157-172`)记了 +**第一版假绿**(两次 `fireEvent.click` 不放同一个 `act`,中间 React 会提交一次, +第二次点到的是已 `disabled` 的按钮 ⇒ 删掉闸门也绿), +现在把两次点击放进**同一个 `act`**。 + +**我实跑验证**(不靠信任那段注释): + +``` +删掉 inFlight 闸门 → PermissionPanel.test.tsx: + × ★ 同一帧内的两次激活只发出一条 decidePermission +其余 23 项仍 pass +``` + +⇒ 判据**有分辨力**。这是本批里质量最高的一条判据。 + +### 3.4 E-2 附件闸门 —— ⚠️ 主 bug 修了,但引入了一个新的死锁 + +实现(`Attachments.tsx:87-116`):`picking = useRef(false)` + 进函数提前清 `input.value`。 +两件事**成对**做(注释也解释了为什么必须成对),这个判断是对的。 + +★ **但 `picking.current = false` 在 `:115`,不在 `finally` 里。** + +```ts +const picking = useRef(false); +const handleFiles = async (files: FileList | null) => { + if (!files || files.length === 0) return; + if (picking.current) return; + picking.current = true; + … + setUploading(null); + if (added.length > 0) onChange([...items, ...added]); // ← 这一行之后若抛 + picking.current = false; // ← 永远到不了 +}; +``` + +`uploadAttachment` 的失败被内层 `catch` 接住了,所以**上传失败不会卡**。 +但 `onChange(...)` 之后的任何抛出(或未来加在 `setUploading(null)` 之后的抛错) +⇒ `picking.current` 永久为 `true` ⇒ **附件选择器此后永久失效,且不报错**。 + +这与本仓反复防的"spinner 停了就变空列表"是**同一族**: +*失败形态 = 静默地永久卡住*。而**闸门本身的失效恰好也是静默的**。 + +**修法**(一行): + +```ts +} finally { + picking.current = false; +} +``` + +★ **且这条完全没有判据** —— `test/components/` 下 13 个文件里没有 `Attachments` 测试, +grep `picking` 在整个 `test/` 下 0 命中。 +⇒ 与 §3.2 同类:**闸门类改动没有判据,就等于下次重构会把它删掉**。 + +### 3.5 X-1 主进程 —— ✅ 修得对 + +`main.cjs` 加了 `openExternalSafely()`(`new URL` 解析失败静默返回,只放行 http/https)、 +`setWindowOpenHandler` 一律 `{ action: 'deny' }`、`will-navigate` 放行本应用自己的加载 +(dev 走 `DEV_URL`、prod 走 `file://` 前缀)、`requestSingleInstanceLock()` + `second-instance` 把已有窗口叫到前面。 + +新判据 `main-process-security.test.mjs`(14 项)走 `functionBody()` 按括号配对取函数体, +**不用** `\{[\s\S]{0,80}` 窗口 —— 与 `CRITERIA.md` §1 一致。 +我独立跑过:**14 通过 0 失败**,且它额外钉了 `contextIsolation`/`nodeIntegration`/`sandbox` +三项"不许被放松"(=:842 的注释也说明了为什么不判"够不够安全",这个边界是对的)。 + +⚠️ 一个**未覆盖**的场景:`will-navigate` 里放行 `url.startsWith('file://')`, +而恶意链接可以写成 `file:///etc/passwd` 或 `file://` + 本地路径 —— +那会在应用窗口里**加载本地文件**。当前判据只断言"放行了 file://",没断言"只放行 dist/index.html"。 +不是漏洞(`react-markdown` 的 `defaultUrlTransform` 会中和不安全 scheme,且需要先有注入面), +但如果哪天有人加 `rehype-raw`,这就是缺口。记一笔,不建议现在改。 + +### 3.6 X-2 CSP —— ✅ 修得对 + +`index.html:65-67` 加了 CSP。**注释里对每一档为什么是这个值都有交代**, +特别是两个诚实说明: +- `script-src` 含 `'unsafe-inline'` ⇒ **这挡不住注入型 XSS**,只挡 `javascript:`/外部脚本/`eval`; +- `connect-src` 必须宽,因为网关地址**用户可填**(任意 host:port)。 + +首帧防闪屏脚本(`:31-61`)保持内联、保持单引号(`:54` `classList.add('dark')`)—— +`test/theme.test.mjs` 逐字符断言那一段,**没有被改坏**(theme 判据 pass)。 + +### 3.7 未动的部分 + +M-1(系统返回键,**仍不建议动**)、M-2..M-6、L-1、G-1(无障碍)、G-2(`LazyForEach` 零使用) +**全部未动**。`DEBTS.json` 现有 58 条,`harmony-no-lazyforeach`(我上一份报告 §5.4 的草稿) +**仍不在其中** —— G-2 这条"计划与实现分叉"依旧没人登记。 + +--- + +## 4. 判据套件实跑结果 + +``` +npm install → 600 packages +npm run typecheck → 通过(tsc --noEmit 无输出) +npx vitest run → Test Files 16 passed / Tests 271 passed +node test/run-all.mjs → files=39 ran=39 checks=630 pass=614 fail=6 skip=10 red=8 + 变异通道:mutants=52 ran=52 on_new_criteria=37 +``` + +### 4.1 6 个红的归因(逐条查过,不是"环境问题"一句带过) + +| 红判据 | 归因 | 是谁的问题 | +|---|---|---| +| `harmony-system-api` | SDK 在 `/opt/huawei`,本机在 `/home/jianf/command-line-tools` | 环境(该文件**已**支持 `HARMONY_CLT`) | +| `harmony-appearance` | 同上(`:164` 用 `HARMONY_CONFIG_CONSTANT_DTS`) | 环境(**已**支持) | +| `harmony-nav` | `:1011` **硬编码 `/opt/huawei`,不走 `HARMONY_CLT`** | ★ **判据的 portability 缺陷** | +| `harmony-push` | `agconnect-services.json` 不存在(只有 `.example.json`) | 预期内(该欠账的 `due` 就是"拿到真机") | +| `build-stamp` | `dist/assets` 不存在(本机没 `npm run build`) | 环境 | +| `commit-hygiene` | ★ 见下 | ★ **这批提交自己引入的** | + +补齐环境变量后(`HARMONY_CLT` + `HARMONY_CONFIG_CONSTANT_DTS` + `HARMONY_ID_TABLE`), +红的 8 → **7**,且 `harmony-appearance` 转绿 ⇒ 确认前两条纯环境。 + +### 4.2 `commit-hygiene` 那个红是**这批提交自己造的** + +``` +not ok 1 - 跨端提交必须自报家门(同时改 harmony 与 electron 的提交要标 `跨端:`) + 这些提交同时改了 client/harmony/ 与 client/electron/ 却没说自己是跨端提交 + + [ '6a8e868d fix(harmony)★★: MailStore 收/发共用一份快照 + 壁纸 PixelMap 泄漏 —— 两处 HIGH' ] +``` + +`6a8e868` 的 subject 标 `fix(harmony)`,但它同时改了 +`harmony-arkts.test.mjs` / `harmony-logic.test.mjs` / `run-all.mjs`(**electron 侧判据**)。 + +⚠️ **建议不要按判据提示去改 subject**。这条判据存在的理由(见其断言消息) +是"别用 `git add -A` 把别人的改动卷进来"。 +`6a8e868` 的 7 个文件里 4 个是 harmony 代码、3 个是**为这批改动新写的判据** —— +**按路径 add 是做了的**,只是 subject 的命名没覆盖"改了对方侧的判据文件"这一情形。 + +两个选择: +1. 改 subject 为 `跨端:`(最省事,但让"跨端"这个词的含义从"改了两端的产品代码"变成"改了两端的任何文件"); +2. 在 `commit-hygiene` 里为"只改判据文件"开一个分支(更准确,但要动判据本身)。 + +★ **两个都要先想清楚再动** —— 这是判据语义问题,不是笔误。 + +### 4.3 `baseline-residue` 与 `--exitcode-selftest` 的红是**同一个环境原因**(不是残留) + +> ⚠️ 本节初稿写的是「`npm install` 改了 `package-lock.json` 导致」——**那是错的,已更正**。 +> 更正过程本身说明了本仓判据的一个特性,所以保留在 §6。 + +`--exitcode-selftest` 红,报「降权工具(runuser/setpriv)不可用 ⇒ rc=2 的两条分支本机覆盖不到」, +连带 `baseline=0/7✗`。实测根因: + +``` +$ runuser -u nobody -- true → rc=1(非 root 用户不能使用) +$ setpriv --reuid=65534 … true → rc=127(setresuid 失败: 不允许的操作) +$ id nobody → 存在(uid=65534) +``` + +⇒ **两个工具都在 PATH 上,都能执行**,但本会话是 `uid=1000(jianf)` 非 root, +**无法降权** ⇒ `run-all.mjs:1043` 的 `dropTo` 探测返回 `null` ⇒ 那 6 个需要降权的案例 +被 `continue` 跳过并计红。**这条报错文案是准确的**(不是"工具不存在")。 + +而 `baseline-residue` 是**同一个通道坏掉的副产物**:变异残留检测依赖那些被跳过的案例 +去构造 `baseline=` 四态。`run-all.mjs:1078` 的底本目标是 +`client/harmony/entry/src/main/ets/model/Target.ts`,**在 `mkdtempSync` 造的迷你仓库里**, +与工作区无关 —— 所以它与我的 `npm install` 无关。 + +★ 这也是一条有用的判据卫生信息:`baseline-residue` 那条文案写着 +「**有文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**」—— +本例里底本在 `/tmp`,工作区**当然**不同 ⇒ **按文案指引去查工作区会走错方向**。 +文案应补一句「底本在临时目录时本条无意义,先看 `--exitcode-selftest` 是否先红了」。 + +★ 补齐 4 个环境变量后的最终读数(`HARMONY_CLT` / `HARMONY_ID_TABLE` / +`HARMONY_CONFIG_CONSTANT_DTS` / `HARMONY_COMMON_DTS`): + +``` +RESULT files=39 ran=39 checks=630 pass=616 fail=4 skip=10 red=6 verdict=red +红的:build-stamp(无 dist)· harmony-push(无 agconnect-services.json)· commit-hygiene(§4.2) +自检:--exitcode-selftest(§4.3 的降权限制)· 因果对照失败(同一根因)· summary.py baseline-residue(同一根因) +``` + +⇒ **39 个文件全跑、630 项检查、0 broken、0 unreported**(未装依赖时是 3 broken)。 +剩下 6 个红里,**只有 `commit-hygiene` 一个是真问题**,其余是环境。 + +--- + +## 5. 结论与建议 + +### 5.1 这批修复的成色 + +**好的地方**(值得保留的做法): + +1. ★ **判据全部走"取结构体"而非固定宽度窗口**,并在注释里写明为什么 + (`main-process-security.test.mjs` 头部、`harmony-arkts` 新条目)。 +2. ★ **E-1 的判据把踩过的假绿写进了注释**(两次 `fireEvent` 不同 `act` ⇒ 中间会提交 ⇒ 删了闸门也绿), + 而且**实测确实能红**。这是本仓判据该有的样子。 +3. ★ `6a8e868` 主动记下"**判据缺失导致缺陷降级**"这个根因(`commTab` 改成只 mount 一个 tab + ⇒ "必然场景"没了 ⇒ 从"每次都坏"降成"交错时坏"⇒ 没人动)。 + 这条洞察比代码改动本身更值钱。 +4. ★ `dropSessionFromSent` 不用 `splitByPermission` —— 避开了"发件箱一片空白"那个 09-20 修过的真 bug。 + +**仍有问题**(按建议顺序): + +| 优先级 | 项 | 说明 | +|---|---|---| +| **1** | **修 `477479a` 的 `Theme.hairline`** | ★ 见下,**这是唯一挡住"能出包"的东西** | +| **2** | 补 `desiredSize` 的判据(§3.2) | 一行断言,让判据从"字符串出现过"变成"真的用上了" | +| 3 | `picking.current = false` 挪进 `finally`(§3.4) | 一行 | +| 4 | 给 `Attachments` 建判据 | 与 §2 同族:闸门没判据就会被重构删掉 | +| 5 | 给 generation 守卫建判据 | §3.1 | +| 6 | `harmony-nav.test.mjs:1011` 改成走 `HARMONY_CLT` | 与另两个文件对齐 | +| 7 | `commit-hygiene` 那条:改 subject 还是改判据 | §4.2,**先想清楚再动** | + +### 5.2 为什么 `Theme.hairline` 排第一 + +不是因为它本身严重(就是 3 处边框颜色),而是因为**它揭示了一个流程漏洞**: + +- 一个自称"**真机实测**"的提交,把一个**从未定义的 `Theme` 成员**合进了 main; +- 此后 **48 个提交**没人碰那 3 行,**没有一条判据**因此变红; +- 而后续那个修复提交的信息写着"BUILD SUCCESSFUL"—— + 说明**编译这一步在交付流程里是可跳过的**,至少在某些环境下。 + +★ 也就是说:**`ARV` 全绿、271 项测试全绿、提交信息写着 BUILD SUCCESSFUL, +但客户端编译不过。** 本报告 §2 那个 5 错误的 `hvigorw` 输出,是这批工作里 +**唯一能看见这个问题的手段** —— 它没有被任何自动化接进去。 + +**建议(按成本从低到高)**: + +1. **立刻**:补 `Theme.hairline`(或把它改成 `Theme.border`),让编译转绿。 +2. **低成本**:把 `hvigorw assembleHap` 接进 `npm test` 或 CI —— + 本机 6-9 秒就能跑完(实测 `BUILD FAILED in 9 s`),这是**全仓最便宜的一道闸**。 +3. **中成本**:`harmony-arkts` 判据里加一条 + 「`Theme.` 的每个引用都必须在 `Theme` 类里有对应 `static` 成员」—— + 这类"引用了不存在的成员"是纯静态可判的,且**比编译快**(毫秒级,不用 SDK)。 + ★ 注意 `run-all.mjs:810` 与 `harmony-nav.test.mjs:1011` 现在**硬编码 `/opt/huawei`**, + 写这条判据时要用 `HARMONY_CLT` 那种 `process.env.X || 默认值` 的形状。 + +### 5.3 不要动的部分(与上一份报告一致) + +`MailStore.bump()` 不发布 AppStorage(是设计,混用会死循环)、 +鸿蒙日历 `hourEvents`/`hhmmAtOffset`(5 时区实测恒等,误报)、 +系统返回键 M-1(上一轮真机试过失败并撤回)、 +`main.cjs` 的 `webPreferences` 三项、`.pi-lens.json`/`biome.jsonc` 的 formatter 关闭。 + +--- + +## 6. 证据(可复现) + +```bash +# 编译(当前 HEAD 失败,5 个错误) +cd client/harmony && /home/jianf/command-line-tools/bin/hvigorw assembleHap --no-daemon + +# 归因:旧基线同样失败 ⇒ 不是这批提交引入的 +git worktree add /tmp/mui-base 5e312c6 +cd /tmp/mui-base/client/harmony && /home/jianf/command-line-tools/bin/hvigorw assembleHap --no-daemon + +# Theme.hairline 的源头 +git log --all -S "Theme.hairline" -- client/harmony/entry/src/main/ets/pages/MainPage.ets +git show 477479a:client/harmony/entry/src/main/ets/common/Theme.ets | grep hairline # 无输出 + +# 判据套件(本机需先装依赖) +cd client/electron && npm install +HARMONY_CLT=/home/jianf/command-line-tools \ +HARMONY_CONFIG_CONSTANT_DTS=/home/jianf/command-line-tools/sdk/default/openharmony/ets/api/@ohos.app.ability.ConfigurationConstant.d.ts \ +HARMONY_ID_TABLE=/home/jianf/command-line-tools/sdk/default/openharmony/toolchains/id_defined.json \ + node test/run-all.mjs +npx vitest run # 16 files / 271 tests +npm run typecheck + +# §3.2 的判据缺口(去掉 desiredSize 仍 pass) +cd client/harmony/entry/src/main/ets/common +sed -i 's/createPixelMap(decodeOptions)/createPixelMap()/' AppearanceStore.ets +cd ../../../../../../electron && node test/harmony-arkts.test.mjs # 仍 # pass 10 + +# §3.3 的判据有分辨力 +cd src/components && python3 - <<'PY' +import pathlib +p=pathlib.Path('MailView.tsx'); t=p.read_text() +p.write_text(t.replace(" if (inFlight.current) return;\n inFlight.current = true;\n","")) +PY +cd ../../electron && npx vitest run test/components/PermissionPanel.test.tsx # 该项变红 +``` + +⚠️ 跑完变异**务必还原**并 `git status` 确认 —— §4.3 那次 `baseline-residue` +就是我改了 `package-lock.json` 留下的。 +--- + +## 7. Electron GUI 侧专项(初稿漏了的一节) + +§3 只把 Electron 的 4 个 GUI 修复逐条讲了,**漏掉了本批提交里对 Electron 判据体系影响最大的两条**。 +这两条都不改 GUI 行为,但它们决定了"判据在把守"这个前提**到底成不成立** —— +而 GUI 侧的所有结论(包括 §3.1/§3.3 的变异验证)都建立在这个前提上。 + +### 7.1 `93697c4` —— 判据读取器把 137 行真代码当注释吃掉(★★★) + +`stripComments()` 原来是**两趟正则**(先块 `/\*[\s\S]*?\*\//g`、后行)。 +两趟**互相看不见对方**,于是有一类输入会把真代码当注释删掉: + +``` +server/cmd/server/main.go:78 + // 与 /api/v1/agent/* 完全同一份代码 —— 不存在第二套收窄或配额逻辑。 + ↑ 这个 /* 在 // 里面 +``` + +块注释那趟跑在**还没删行注释**的文本上,看到这个 `/*` 就当块注释开头, +一路找下一个 `*/`(在 `:214`)⇒ **137 行 / 37 条路由注册被抹掉**, +含它正在断言的 `r.Get("/auth/me", handler.Me)`。 + +**我实跑复现了这条**(把旧读取器放回去): + +``` +旧读取器 → not ok 23 - ★ `/me` 不是路由:MeApi 必须调 `/auth/me` # pass 29 / fail 1 +新读取器 → # pass 30 / fail 0 +``` + +⇒ **这不是假想的风险,是已经发生过一次的假红**,而且假红的那条 +保护的正是"管理入口对所有人永不显示"这个**线上真实存在过**的 bug。 + +★ **全仓另有 12 处同样写法**(`/me/*`、`/assets/*`、`plugins/*`…)—— +只要落在某个块注释终点之前就触发同一形状。 + +**失效形状值得单独记**:**读取器静默少给一段真代码**(不抛错、不警告), +症状却出现在**被测对象**上 ⇒ 看起来像"服务端把路由删了"。 +这与 `DEBTS.json` 里 `criterion-silently-returns-zero`、 +`history-rewrite-undisclosed-citations-dangle` 同一族。 + +修法(单遍字符扫描)方向对。★ 但注意提交信息自己承认了一个**刻意的残留限制**: +**不认字符串字面量** —— `const s = "/*";` 里的块注释标记仍会被当开头。 +方向是**假绿**(少剥注释),与 `stripStrings` 同性质。 +组合使用 `code()` → `stripStrings()` 是安全的(顺序已在注释里写明)。 + +★ 这条给 GUI 侧的直接教训:**Electron 与 HarmonyOS 两侧的判据共用同一个 `read.mjs`**。 +也就是说这个读取器缺陷**同时影响两端判据**,而我当时正在读的那份 GUI 报告 +(`gui-review-2026-10-03.md`)里**所有 `grep` 出来的行号都可能是被吃掉之后的**。 +所幸那条报告的引用是**我当场用 `grep -n` 在原始文件上量的**,不是过 `read.mjs` 的。 + +### 7.2 `6df3c35` —— 判据套件 24 小时一条都没跑过(★★★) + +**现象**:`inbox-fallback-poll` / `sse-credentials` / `web-comment-only` +三个文件**没接进 `SUITE`**。 + +**为什么严重(三层放大,提交信息已列出,我复核成立)**: + +1. 自检 2「每个 `*.test.mjs` 都要在清单里」在**跑任何判据之前** `exit(1)` + ⇒ 实测 `RESULT` 行数 = **0**。 + ★ **我实跑复现**:从 `SUITE` 里删掉第一项 → `rc=1`、`RESULT` 行数 = `0`。 +2. `npm test` = `run-all && vitest && typecheck` ⇒ vitest(**271 格**)与 + typecheck **一起不跑** —— 而两者单独跑都是绿的。 +3. 失败信息只有一行中文 stderr、**不含"红"字样** ⇒ 看起来像"环境问题"。 + +⇒ 后果是:**下一个人依据上一份报告继续推断"判据在把守"**, +而这个前提**当时是假的**。提交信息把这叫「**报告的证据等级被系统性高估**」。 + +★ **这条直接命中我上一份 GUI 报告的方法论**:我在那份报告 §7 写了 +「我没运行任何测试,所以所有判据建议都只是建议」—— +**那个自我限制恰好是当时唯一没被这条击穿的防线**。 +若我当时因为"判据全绿"就采信某些结论,会直接踩空。 + +**同时它也是好消息**:`CRITERIA.md` 新增了 §6.0「怎么读判据的数」, +补上了"原有 17 节全在讲**怎么写**,缺的就是**怎么读**"这一半。 +§6.0.1 接线守卫的失效形状是「**全停**」不是「那一条不跑」;§6.0.5「当你就是改工具的人, +第一假设是**我弄坏了它**」。 + +### 7.3 §6.0.2 那条陷阱 —— 我在本轮**当场踩了**,记下来 + +§6.0.2:「退出码只能来自不接管道的运行(`cmd >file 2>&1; echo $?`)」, +并记着作者自己「本会话连踩 5 次 `cmd | tail -N` ⇒ 报的是 tail 的码」。 + +★ 我这次也踩了。本轮我一度用: + +``` +node test/run-all.mjs 2>&1 | tail -3 → $? = 0 +``` + +而同一时刻套件实际是红的(`commit-hygiene`)。**`$?=0` 是 `tail` 的码。** +改用重定向取真码后 `rc=1`。 + +⚠️ 更隐蔽的一层:`run-all.mjs` 自己就是**靠退出码**汇报的 +(`UPSTREAM_RC[...]` 表 + `summary.py` 的 rc)。 +所以「管道/重定向搞错退出码」这件事在本仓**会直接污染判据结论**, +不只是"看日志方便一点"的问题。 + +### 7.4 Electron 侧本批修复的最终读数 + +``` +npm install → 600 packages +npm run typecheck → 通过(tsc --noEmit 无输出) +npx vitest run → Test Files 16 passed / Tests 271 passed +node test/run-all.mjs → files=39 ran=39 checks=630 pass=616 fail=4 + skip=10(设备不在,不是通过)red=6 broken=0 unreported=0 + 变异通道:mutants=52 ran=52 on_new_criteria=37 +``` + +★ `broken=0 / unreported=0` 是装完依赖 + 补齐 4 个环境变量之后的状态 —— +未装依赖时是 `broken=3`(`theme` / `background` 等因缺 `tailwindcss` / `postcss` 跑不起来)。 +**这正是 §7.2 那条判据的形状:broken 不是 red,但它证明不了任何东西。** + +### 7.5 Electron 侧我上轮提、至今**未动**的两条(数字复核) + +| 条目 | 上轮读数 | 现读数 | 状态 | +|---|---|---|---| +| **G-1** 无障碍 | `aria-label` 22 / ` **基线**:HEAD `5e312c6`(`main`,工作区干净,923 提交) +> **范围**:`client/electron`(React 18 + Vite + Tailwind + Zustand + Electron 主进程) +> 与 `client/harmony`(ArkTS / ArkUI,Stage 模型) +> **方法**:对现存代码逐条复核,不引用印象。所有 `file:line` 均在 HEAD `5e312c6` 上实测过。 +> **上游**:`docs/reviews/` 2026-09-28 那四份报告(`electron-gui-review.md`、 +> `harmony-client-review.md`、`harmony-pages-review.md`、`harmony-state-review.md`)的每一条 finding 重新判一次。 + +--- + +## 0. 给接手的 agent:怎么用这份报告 + +| 你要做的事 | 看哪节 | +|---|---| +| 只想知道「上一轮报告的东西修了没」 | §1 结算表 | +| 要动手修 | §2 的 **H-1 / H-2**(两端各一条 HIGH),§5 有逐条修复草案 | +| 想先确认我说的缺陷是不是真的 | §2 每条都带**触发路径**与**代码引证**,可直接复现 | +| 想知道哪些**不要动** | §6(一条已实测失败并撤回的修法) | +| 想先立判据再修 | §4(每条给了判据建议与"判据该红在哪") | + +★ **纪律提醒**(本仓 `client/electron/test/CRITERIA.md` 已写明,这里复述三条最常踩的): +1. 判据要钉**结构与行为**,不要钉字面与邻接 —— 本报告里所有行号都请自行 grep 复核, + `DEBTS.json` 已登记「行号引用漂到无关代码」这条债。 +2. 每条判据都要**验证它能红,且红在正确位置**(本仓 `criteria-hygiene.test.mjs` 有自检)。 +3. **不要**用自动格式化工具。`.pi-lens.json` 与 `biome.jsonc` 都关掉了 formatter/linter, + 历史上自动格式化制造过约 7000 行无关 diff。 + +--- + +## 1. 上一轮报告的结算 + +### 1.1 Electron(`electron-gui-review.md`,12 条 → 修掉 9 条) + +| # | 原 finding | 现状 | 证据(HEAD `5e312c6`) | +|---|---|---|---| +| C1 | 切账号不清 store ⇒ 跨账号邮件泄露 | ✅ 已修 | 新增 `src/lib/resetAccountData.ts`;三个入口都调:`AccountSwitcher.tsx:71`(主动切)、`authStore.ts:86`(登出)、`authStore.ts:115`(任意 401)。判据 `test/stores/resetAccountData.test.ts` | +| C2 | `lastUploadedImage` 不分账号 | ✅ 已修 | `appearanceSync.ts:53` 已改成 `new Map()`,`:56` 的 `currentAccountKey()` 逐账号分桶 | +| H1 | `PermissionPanel.submit` 吞掉错误 | ✅ 已修 | `MailView.tsx:772` 有 `submitErrorBanner`,`:780` 的 `submit` 末尾 `setSubmitError(err …)` | +| H2 | `ForwardBar` 双提交 + `Promise.all` 卡住关窗 | ✅ 已修 | `MailView.tsx:393` `inFlight = useRef(false)` 同步闸门;`:415` 改 `Promise.allSettled` | +| H3 | ICS `revokeObjectURL` 在 `click()` 同步之后 | ✅ 已修 | `CalendarView.tsx:292` `setTimeout(() => URL.revokeObjectURL(url), 0)` | +| M1 | `CalendarView.load()` 竞态 | ✅ 已修 | `CalendarView.tsx:104` `const loadGen = useRef(0)`、`:107` `const myGen = ++loadGen.current`、`:111/:114/:117` 三处守卫 | +| M2 | `ComposePage` `setTimeout` 未清 | ✅ 已修 | `ComposePage.tsx:62` `closeTimer` ref + cleanup | +| M3 | `AdminUsersPage` / `KeyPanel` 同形状 | ✅ 已修 | `AdminUsersPage.tsx:97` `noticeTimer` ref(`:90-96` 是说明它的注释)+ cleanup;`KeyPanel.tsx:36` `copiedTimer` ref + `:40` cleanup | +| M4 | 审批 chip 缺同步闸门 | ⚠️ **半修** | 见 §2 **E-1** | +| M5 | `BackgroundPicker` 预设缩略图被 `--bg-image` 盖住 | ✅ 已修 | `BackgroundPicker.tsx:118-127` 是一段解释「**为什么不能**写 `style={{ backgroundImage: 'var(--bg-image)' }}`」的注释,`:116` 只留 `bg-preset-${p.id}` 类 | +| M6 | 附件覆盖上传用陈旧 `items` 闭包 | ⚠️ **半修** | 见 §2 **E-2** | + +**另确认一条不在旧报告里的修复**:`aeb1f41` 修的 SSE 账号凭证问题 —— +`App.tsx:125-133` 的注释记录了症状与根因(依赖只有 `[phase]`,而切号只换 base/token 不改 phase), +`:134` `const credentialSig = useCredentialChange();` 取的是 `api/config` 那个唯一发生地。 +切号不再拿旧账号的 SSE 连接,`aeb1f41` 提交信息里记的用户报「页面停留不动,新邮件不自动同步」已闭环。 +判据:`test/sse-credentials.test.mjs`。 + +### 1.2 HarmonyOS(`harmony-pages-review.md` 22 条 → 修掉约 10 条) + +已修(抽样核过源码): + +| 原 finding | 证据 | +|---|---| +| Enter 守卫的 `mail_type` 写错(`'permission'` ⇒ `'permission_request'`) | `MailDetailPage.ets:1946` 已改 | +| 顶栏轮播的 `setTimeout` 未清 | `MainPage.ets:2921-2923` `this.topFadeTimer` 存句柄 + `clearTimeout` | +| `mail.cc_list.length` 无守卫(`harmony-state-review.md` #4) | `MailStore.ets:513` 与 `:642` 都是 `(mail.cc_list ?? []).length` | +| `MailStore.clear()` 零调用点(`harmony-state-review.md` #2) | `Logout.ets:108` 与 `SettingsPage.ets:605` 都调了;`Logout.ets:92-107` 记了为什么必须放这里(这段流程有**两个入口**) | +| 管理台 fail-open | `AdminUsersPage.ets:337` 改三态 `if (!this.roleKnown) …` | +| 旧 `MyPage` / `InboxPage` 若干文案 | 已收敛(详见 `git show 18b148e`) | + +未修(见 §2 的 H-1 / M-4 / M-5 / L-1): + +| 原 finding | 编号 | +|---|---| +| `loadInbox` / `loadSent` 共用 `this.snapshot` | **H-1**(旧报告标 CRITICAL,**从 9/28 至今没人动**) | +| `closeDetail()` 是死代码 ⇒ 系统返回后 `KEY_OPEN_MAIL_ID` 残留 | M-1(已登记 `harmony-system-back-key`) | +| `ContactsTab.openSession()` 无请求令牌 | M-2 | +| 对话树 `ForEach` key 用数组下标 | M-3 | +| 每个 SSE 事件全量重拉多账号收件箱 | M-4 | +| 每次 `build()` 新建 `MarkdownController` | M-5 | +| `loading` 不在 `finally` | M-6 | +| `Index.ets` Hello World 注册为路由 + 两个孤儿页 | L-1(已登记 `harmony-dead-pages`) | + +### 1.3 我**排除**的疑点(核过,不是问题) + +**① 鸿蒙日历「格与行时间基准不一致」(旧报告 finding 19)—— 误报,已实测否证。** + +`CalendarPage.ets:1317` 的 `hourEvents` 用 `d.getHours()`, +`:1344` 的 `EventRow` 用 `hhmmAtOffset(e.event_time, this.offsetMinutes)`, +而 `offsetMinutes = deviceOffsetMinutes(now) = -now.getTimezoneOffset()`(`model/Calendar.ts:226`)。 +看着像两个基准。实测五个时区,**两者恒等**: + +``` +Asia/Shanghai: hhmmAtOffset=00:30 getHours=00:30 +America/Los_Angeles: hhmmAtOffset=09:30 getHours=09:30 +UTC: hhmmAtOffset=16:30 getHours=16:30 +Pacific/Kiritimati: hhmmAtOffset=06:30 getHours=06:30 +Pacific/Midway: hhmmAtOffset=05:30 getHours=05:30 +``` + +原因:`hhmmAtOffset` 内部是 `new Date(ms + offset*60000)` 读 `getUTCHours()`, +而 `-getTimezoneOffset()` 恰好把设备本地偏移补回去 ⇒ 它算的就是本地钟点。 +**只有判据进程时区 ≠ 设备时区时才会分叉**,那不是产品缺陷。 +`harmony-calendar.test.mjs:288` 那条判据正是为「三个时区都能真跑」写的,方向正确。 + +★ 这条是本报告里唯一一个**否证了上游报告**的条目,请不要再去"修"它。 + +**② 鸿蒙 `MailStore` 的 `bump()` 不发布 AppStorage —— 是设计,不是漏发。** + +`harmony-state-review.md` #3 断言 `loadInbox` 的 `finally` 走 `bump()`(不自增 AppStorage) +会让窗格"静默错过刷新"。实测 `MailStore.ets:175-178`: + +```ts +private bump(): void { this.revision += 1; } +``` + +而 `:180-196` 的注释把为什么分开写得很清楚:`bump()` 是"我自己改了快照,持有者自己接进 `@State`", +`publishChange()` 才是"别的组件可能不知道,要广播"。两者混用会**死循环** +(`bump` → 发布 → `onMailRevChanged` → `loadData` → `loadInbox` → `bump` → …)。 +**不是缺陷,不要合并这两个函数。** + +**③ Electron Markdown XSS —— 无。** 三处渲染都是 ``, +`react-markdown@^9.0.1` 无 `rehype-raw` ⇒ raw HTML 转义、`defaultUrlTransform` 中和 `javascript:`。 +全库 `dangerouslySetInnerHTML` / `innerHTML` / `insertAdjacentHTML` 命中 **0** 次。 +鸿蒙侧用 `@luvi/lv-markdown-in` 原生 ArkTS 引擎,不经 WebView。`test/markdown-xss.test.mjs` 钉着这条。 + +**④ `LEGACY_BACKUP_KEY` 永久残留 —— 有意决策。** `backgroundStore.ts` 注释明确写了 +「没有任何代码路径能判定何时安全删除」并列出代价(体积有界、共享机器上是一份别人的外观)。 + +**⑤ 深色模式对比度 —— 有依据。** `tailwind.config.js` 的 `solid()` / `textColor.white` / `chrome` +三套覆盖都有注释记录**实测对比度数字**(`bg-red-600` 白字 1.6:1、`bg-chrome-700` 白字 1.34:1)。 +不是猜的。 + +--- + +## 2. 仍然成立的问题(按严重度) + +### H-1 【HIGH · 正确性】鸿蒙 `MailStore` 收件箱与发件箱共用一个快照 + +**位置**:`client/harmony/entry/src/main/ets/common/MailStore.ets` + +``` +447: async loadInbox(ctx, accountFilter) { const snap = this.snapshot; … } // ← +608: async loadSent(ctx, accountFilter) { const snap = this.snapshot; … } // ← 同一个对象 +``` + +`MailSnapshot`(`:120-140`)有 8 个字段:`mails` / `groups` / `loaded` / `unread` / +`notice` / `accountErrors` / `loading` / `error`。**两个方法全部往同一份写**, +而且 `loadSent` 写的时候会把 `unread` 置 0(`:678`)。 + +**读侧**:`MainPage.ets` 里 `InboxTab`(`:191`)与 `SentTab`(`:1319`)是两个独立 `@Component`, +各自 `@StorageProp('agentmail.mail.revision') @Watch('onMailRevChanged')`(`:230` / `:1376`), +各自 `await store.loadInbox(...)` / `await store.loadSent(...)` 然后 +`applyStoreSnapshot(store.snapshot)`(`:358` / `:1410`)。 +两个 `applyStoreSnapshot` 都逐字段拷贝(InboxTab 版 `:408-424`),包括 `this.unread = snap.unread`。 + +**触发路径(三条,都是常规操作)**: + +1. **切页签时旧 pane 未回**:`commTab` 分支(`:2357-2371`)同一时刻只 mount 一个 tab, + 所以不是"两个 pane 同时在屏"。但用户点页签时旧 pane 的 `loadInbox` 还在飞, + 新 mount 的 pane 的 `applyStoreSnapshot` 读到的是旧 pane 写到一半的 `mails`。 +2. **SSE 与切页签交错**:`MainPage.ets:3069-3095` 的 `onGlobalSseEvent` 对 + `new_mail` / `permission_decision` / `session_update` / `session_archived` + **四类事件一律** `notifyRemoteChange()` → 叫醒已 mount 的 pane → 开始 `loadInbox`。 + 用户此时点开发件箱页签,`loadSent` 与仍在飞的 `loadInbox` 交错写同一个对象。 +3. **`loading` 互相关闭**:`:462`(收件箱置 true)/ `:596`(收件箱置 false) + 与 `:609` / `:687`(发件箱同)共用一个字段。谁最后完成谁置 false ⇒ + 会出现"转圈停了但列表是空的,无 spinner 无报错"。 +4. **`unread` 单向污染**:发件箱 pane 没有自己的 `unread` 字段(实测 `SentTab` 的 + `@State` 只有 `hoveredMailId` / `groups` / `expandedKeys` / `loading` / `error` / `loaded`), + 所以它**不受影响**;但 `loadSent` 把 `snap.unread = 0` 之后, + 若收件箱 pane 的 `loadInbox` 还没回来,它 `applyStoreSnapshot` 就会读到 `unread = 0` + ⇒ **收件箱未读数被清零**。 + ★ 好消息:底栏与侧栏的徽标走的是**另一条路** —— + `MainPage.refreshCounts()`(`:1934`)自己遍历账号发 `api.inbox('all', …)`, + 不读 `snapshot.unread`。所以**徽标不受污染**,受污染的是收件箱栏内的未读显示。 + +**为什么至今没修**:旧报告(`harmony-state-review.md` #1、`harmony-pages-review.md` #16) +把它标成 CRITICAL 并描述成 `Navigation` 分栏下"两个 pane 同时在屏"的必然场景。 +但 `commTab` 后来改成同一时刻只 mount 一个(`:2357-2371`),**那个"必然"没了**, +于是它从"每次都坏"降级成"切页签/SSE 交错时坏" ⇒ 症状变罕见 ⇒ 没人动。 +★ 这是典型的**判据缺失导致缺陷降级**:`harmony-pages-review.md` 有 22 条, +但没有一条判据钉住"发件箱 pane 拿到的必须是自己那次拉的数据"。 + +**修复草案(三选一)**: + +```ts +// 方案 A(最小,推荐先落):拆成两个快照字段 +export class MailStore { + snapshot: MailSnapshot = new MailSnapshot(); // 收件箱(保持不动,调用点零改) + sentSnapshot: MailSnapshot = new MailSnapshot(); // 发件箱(新增) + private inboxGen: number = 0; + private sentGen: number = 0; + + async loadInbox(ctx, accountFilter): Promise { + const myGen = ++this.inboxGen; + const snap = this.snapshot; // ← 只碰收件箱那份 + … + // 每次 await 之后、每次写 snap 之前:if (this.inboxGen !== myGen) return; + } + + async loadSent(ctx, accountFilter): Promise { + const myGen = ++this.sentGen; + const snap = this.sentSnapshot; // ← 换一份 + … + } +} +``` + +- `SentTab.applyStoreSnapshot`(`MainPage.ets:1410`)改一行:`store.sentSnapshot`。 +- `SentTab` 的 `@Watch('onMailRevChanged')` 建议改订**独立**的 revision key + (否则发件箱的 revision 变化不会叫醒它)。这一步可选,但不做的话 `markReadLocal` + 之类走 `publishChange()` 的路径仍会误叫醒收件箱。 +- `clear()`(`:716`)要同时清两份。 +- generation 守卫**治不了 `loading` 互相关闭**(那是共用字段), + 所以**方案 A 的拆字段是必须的,generation 是附加的**。 + ← 这点很重要:旧报告推荐的"加 generation 守卫"单独用**并不足以**修好 `loading`。 + +**验判据**(`harmony-logic.test.mjs`,设备判据,见 §4.1): +「发件箱 tab 加载完成后,收件箱 tab 的 `unread` 不被清零」+「切页签时新 pane 拿到的 +`from_name`/`to_name` 列方向属于自己的那一份」。 + +--- + +### H-2 【HIGH · 资源】鸿蒙壁纸:`PixelMap` 泄漏 + 全尺寸解码 + UI 线程阻塞 + +**位置**:`client/harmony/entry/src/main/ets/common/AppearanceStore.ets:188-197` + +```ts +async loadWallpaper(api: AppearanceApi): Promise { + try { + const bytes: ArrayBuffer = await api.fetchImageBytes(); + const src: image.ImageSource = image.createImageSource(bytes); + this.wallpaper = await src.createPixelMap(); // ← 无 desiredSize,无 release + } catch (e) { + this.wallpaper = null; + } +} +``` + +三个问题叠在一起: + +1. **旧 `PixelMap` 与 `ImageSource` 都从不 `release()`**。 + `PixelMap` 是 native 内存,`syncFromServer`(`:143`,`:182` 调 `loadWallpaper`) + 每次同步、每次切账号都会重新解码一次,全部留在映射表里等 GC 压力。 + `src` 在**成功路径**上也从不释放。 + 对照 `BackgroundPicker.ets:167-229`:那里 `head`(`:228`)、`passSource`(`:200`)、 + `scaled`(`:197`)都在 `finally` 里 `await ... .release()`,而且注释写明了 + 为什么必须用 `finally` 收口(那条路径有三个出口,逐处 release 必漏一处或重一处)。 + **store 里没有对应物。** +2. **无 `desiredSize`** ⇒ 最坏一张 2560×2560 ARGB ≈ **26 MB** 常驻, + 而它永远被合成器降采样着全屏画。`BackgroundPicker` 已把上传边长卡在 + `MAX_EDGE = 2560`(`ImagePrep.ts:35`),所以来源有界但仍然很大。 + `createPixelMap(options)` 支持 `DecodingOptions.desiredSize`, + `BackgroundPicker.ets:189-193` 就在用,这里没用。 +3. **在 UI 线程内联解码** ⇒ `syncFromServer` 被设置页 `await` 之后才应用主题, + 所以每次同步都有一次几百毫秒的可见卡顿,不只启动时。 + +★ **加重项**:`MailStore.clear()` 已在登出时调(`Logout.ets:108`), +但 `AppearanceStore.wallpaper` **不在任何清理路径里** —— +它是个静态单例,活过登出(实测全文件 `clear` 命中 0 次,`wallpaper` 命中 `:45`/`:172`/`:192`/`:195`)。 +所以"换账号"这条路上,第 1 条的泄漏**必然发生**。 + +**修复草案**(照 `BackgroundPicker.ets:167-229` 的既有形状,不要另创一套): + +```ts +async loadWallpaper(api: AppearanceApi, targetW: number, targetH: number): Promise { + let src: image.ImageSource | null = null; + let next: image.PixelMap | null = null; + try { + const bytes: ArrayBuffer = await api.fetchImageBytes(); + src = image.createImageSource(bytes); + const opts: image.DecodingOptions = { desiredSize: { width: targetW, height: targetH } }; + next = await src.createPixelMap(opts); + const old: image.PixelMap | null = this.wallpaper; + this.wallpaper = next; // 先换引用 + next = null; // 转移所有权,避免 finally 释放掉新的那个 + if (old !== null) { + await old.release(); // ← 释放**旧**的,不是新的 + } + } catch (e) { + this.wallpaper = null; + } finally { + if (next !== null) await next.release(); + if (src !== null) await src.release(); + } +} +``` + +- `targetW/targetH`:从 `display.getDefaultDisplaySync()` 或窗口尺寸取屏幕像素, + 不要写死一个魔数。**判据里要钉住"传了 desiredSize"而不是钉住具体数值** + (数值会随屏幕变)。 +- 离屏解码(`@Concurrent` / taskpool)**本轮不做** —— 全仓 `grep taskpool|worker|@Concurrent` + 命中 0,没有先例,单独引入会扩大风险面。记一笔即可。 + +**验判据**:`harmony-appearance.test.mjs`(设备判据) +「每次切账号后,壁纸对象被 `release()` 过」—— 真机上不好直接观测 native 内存, +可行形态是**静态判据钉形状**:断言 `loadWallpaper` 里存在 `desiredSize`, +且存在 `finally` 且 `finally` 里有 `release()`(按 §4.2 的"取结构体"写法,不要用固定宽度窗口)。 + +--- + +### M-1 【MEDIUM】鸿蒙系统返回键仍不清理详情状态 —— **不要动** + +已登记为 `docs/DEBTS.json` 的 `harmony-system-back-key`(count=2)。 +`MainPage.ets:1845-1856` 那段注释把现状与踩坑都写全了: + +> ★ 注意:这条路**只接 Esc**。系统返回键/手势绕开它走 `Navigation` 自己的 pop +> ⇒ `closeDetail()` 与这里重发的层数都不覆盖那条路(这是已知缺口)。 +> 2026-09-26 试过在 `EntryAbility` 加 `onBackPressed()` 来补,**实测失败并已撤回** +> (模拟器上按一次系统返回键直接销毁 UIAbility)。 + +实测:`closeDetail()`(`MainPage.ets:2305`)定义完整、**零调用点**。 +后果是系统返回 pop 掉 nav 栈后 `KEY_OPEN_MAIL_ID` 仍指向刚看的那封邮件, +下次在根上按 Enter 会跳回一封可能已被归档/删除的邮件; +`publishStackDepth`(`:2232`)同样不在 pop 路径上,Esc 会永久被吞。 + +到期前提已写得准确:**必须真机验证 `Navigation` 的返回事件消费点在哪一层**。 +★ 请不要在没有真机的情况下"顺手修" —— 上一轮就是这么失败的。 + +--- + +### M-2 【MEDIUM】鸿蒙 `ContactsTab.openSession()` 无请求令牌 + +**位置**:`ContactsTab.ets:202-221` + +```ts +async openSession(c: Contact): Promise { + … + this.openSessionId = c.session_id; + this.openSessionTitle = …; + this.sessionMails = []; + this.sessionLoading = true; + try { + this.sessionMails = await m.sessionMails(c.session_id); // ← 无令牌 + } catch (e) { + const ae = e as ApiError; // ← 无收窄 +``` + +连点两个会话:先发的请求后回 ⇒ 标题已经是 B、`sessionMails` 是 A 的。 +**同一类问题 Electron 侧已经修过**(`CalendarView.tsx:104-117` 的 `loadGen` / `myGen`), +只修了一端。 + +另外 `e as ApiError` 无收窄:非 `ApiError` 的 reject(例如 `abort`)会在错误处理里 +再抛一次 `TypeError`,把真正的错误盖掉(旧报告 finding 11,同一形状)。 + +**修复草案**:照 `CalendarView.tsx:104-117` 的形状加 `private reqGen: number = 0`, +`const myGen = ++this.reqGen`,`await` 之后与 `finally` 里各判一次。 +`catch` 改成 `e instanceof ApiError ? e.message : String(e)`。 + +--- + +### M-3 【MEDIUM】鸿蒙对话树 `ForEach` key 用数组下标 + 拍平成字符串 + +**位置**:`MailDetailPage.ets:1990-1999` + +```ts +List() { + ForEach(this.threadLines, (line: string, idx: number) => { + ListItem() { Text(line) … } + }, (line: string, idx: number) => idx.toString()) +} +``` + +`threadLines` 是把树拍平成"缩进字符串"的数组(`:1985-1989` 的注释说明了做法)。 +用数组下标做 key,在数组头部插入/删除时会让**所有后续行**被复用成错误的组件。 +分块加载(`limit` + `next_offset`,`MailApi.ets:348`)一旦在中间插入,新旧行身份全错。 + +**修复草案**:key 用能稳定标识一封邮件的字段(`mail_id`), +或至少用 `(mail_id, depth)` 组合。需要先让 `threadLines` 带上标识而不只是字符串。 + +--- + +### M-4 【MEDIUM】鸿蒙 SSE 事件无合并,每个事件全量重拉多账号收件箱 + +**位置**:`MainPage.ets:3069-3095` + +```ts +private onGlobalSseEvent = (event: SseEvent): void => { + if (event.type === 'new_mail') { …showToast…; MailStore.getInstance().notifyRemoteChange(); return; } + if (event.type === 'permission_decision' || event.type === 'session_update' + || event.type === 'session_archived') { + MailStore.getInstance().notifyRemoteChange(); // ← 三类事件同一个动作 + } +}; +``` + +四类事件**一律**广播 ⇒ 窗格重拉 ⇒ 多账号时逐账号 `GET /me/mail/inbox` 一次。 +一批同时到达的事件就是一批全量拉。 + +★ **Electron 侧已经做了这件事**,而且做法正好可以抄: +`src/lib/inboxFallbackPoll.ts` 用 `total` 当**廉价探针**(`limit=1` 只取计数), +变了才真拉列表(原理见该文件头部注释「为什么轮询只比 `total`」; +接在 `App.tsx:151-157`,判据 `test/inbox-fallback-poll.test.mjs`)。 +**这条优化只在 Electron 侧做了,鸿蒙侧没有对应物。** + +**修复草案**:给 `notifyRemoteChange` 加去抖(例如 300ms 合并窗), +并在 `loadInbox` 里加 `limit=1` 的探针快路径。 +判据建议放在 `harmony-logic.test.mjs`:一批同时到达的事件 ⇒ 探针请求数固定,不随事件数线性增长。 + +--- + +### M-5 【MEDIUM】鸿蒙每次 `build()` 新建 `MarkdownController` + +**位置**:`MailDetailPage.ets:812` + +```ts +private mdController(): MarkdownController { + const c: MarkdownController = new MarkdownController(); + c.setTextColor(Theme.textPrimary); + … +``` + +`build()` 每次重渲染都调它。`build()` 的调用频次由状态决定, +**主题切换**(`@StorageProp('agentmail.appearance.revision') @Watch`)会整页重渲染一次。 +控制器是纯样式对象,每次新建 = 每次重新解析一遍样式。 + +**修复草案**:改成 `@State` 私有字段 + 主题变化时才重建(或 `@Provide` 一份全局的)。 +⚠️ 注意 ArkTS 的 `@State` 装饰器对 class 类型的约束,先确认能编译。 + +--- + +### M-6 【MEDIUM】鸿蒙日历 `loading` 不在 `finally` + +**位置**:`CalendarPage.ets:499-520`(`loadEvents()`) + +```ts +this.loading = true; // :504 +this.error = ''; +try { + const resp = await a.listEvents(this.rangeFrom(), this.rangeTo()); + this.events = resp.events; +} catch (e) { + this.error = e instanceof ApiError ? … : '日程加载失败'; + this.events = []; +} +this.loading = false; // :517 —— 在 try/catch 之后裸写 +/* 事件拉完再拉农历(不 await:农历是附加信息,不该拖慢事件列表的出现) */ +this.loadLunar(); +``` + +`this.loading = true` 与 `this.loading = false` 之间的代码**全部**在 `try` 内, +且 `rangeFrom()` / `rangeTo()` 也是同步调用(`:507` 在 `try` 里), +所以**今天不会真的卡住**。 + +**修复草案**:`try { … } catch { … } finally { this.loading = false; }`, +`loadLunar()` 放在 `finally` 之后(保持"不 await"的既有语义)。 + +--- + +### E-1 【MEDIUM】Electron 审批闸门仍是异步 state,缺同步 ref + +**位置**:`client/electron/src/components/MailView.tsx:780`(`PermissionPanel` 的 `submit`) + +```tsx +const submit = async (decision: string, noteText: string) => { + setSubmitError(''); + setBusy(true); + try { + await api.decidePermission(mail.mail_id, decision, noteText || undefined); +``` + +`PermissionPanel` 里的 `@State`(`:730-741` 附近)有 `note` / `busy` / `decided` / +`picked` / `staleWarning` / `submitError`,**没有 `useRef`**。 +`disabled={busy}` 挡得住渲染后的点击,但 React 提交前的那一帧里 +两次快速激活(双击、Enter 连按、鼠标+键盘同时)仍会打出**两条** `decidePermission`。 + +对审批来说这不是"多点一次"的问题:服务端会**记账两次**,Agent 侧收到的通知也两条。 + +★ **同文件的 `ForwardBar` 已经解决了一模一样的问题**(`:393` `inFlight = useRef(false)`, +`:400` `if (!to.trim() || inFlight.current) return;`)。照抄那份形状即可。 + +**修复草案**: + +```tsx +const inFlight = useRef(false); +const submit = async (decision: string, noteText: string) => { + if (inFlight.current) return; + inFlight.current = true; + setSubmitError(''); + setBusy(true); + try { … } catch { … } finally { inFlight.current = false; setBusy(false); } +}; +``` + +**验判据**:`test/components/PermissionPanel.test.tsx` 补一格 +「同一帧内两次激活只发出**一条** `decidePermission`」。 +★ 该文件已存在(`test/components/PermissionPanel.test.tsx`),不是新建。 + +--- + +### E-2 【MEDIUM】Electron 附件连续选取会丢前一次的选择 + +**位置**:`client/electron/src/components/Attachments.tsx:70-94` + +```tsx +async function handleFiles(files: FileList | null) { + … + for (const file of Array.from(files)) { // 逐个上传,可能几秒 + setUploading({ name: file.name, pct: 0 }); + const r = await api.uploadAttachment(file, pct => setUploading({ name: file.name, pct })); + added.push({ … }); + } + setUploading(null); + if (added.length > 0) onChange([...items, ...added]); // ← items 是渲染闭包里的 + if (inputRef.current) inputRef.current.value = ''; // ← 上传**完成后**才清 +} +``` + +`uploading` 只加在**按钮**的 `disabled` 上(`:121` `disabled={disabled || uploading !== null}`), +`` 本身(`:105-111`)**没有任何闸门**, +而 `inputRef.current.value = ''` 要等**上传全部完成**才执行。 + +用户在第一个文件还在传的时候再选一次文件 ⇒ 第二次的 `handleFiles` 拿到的是 +**旧的 `items` 闭包** ⇒ `onChange([...旧items, ...第二次])` **把第一次的结果整批覆盖掉**。 +且因为 input 还没清,同一个文件重复选也不报错 ⇒ 表现就是"选的附件少了",无任何提示。 + +`onChange` 的签名是 `(next: PendingAttachment[]) => void`(`:61`), +不是函数式更新,所以**改签名会影响所有调用点**(`ComposePage`、`ReplyBar`)。 + +**修复草案(推荐第一种,不动签名)**: + +```tsx +const picking = useRef(false); +async function handleFiles(files: FileList | null) { + if (!files || files.length === 0) return; + if (picking.current) return; // ← 同步闸门,挡住上传期间的重入 + picking.current = true; + if (inputRef.current) inputRef.current.value = ''; // ← 提前清,别等上传完 + try { + … + if (added.length > 0) onChange([...items, ...added]); + } finally { + picking.current = false; + } +} +``` + +⚠️ 若选"提前清 input"要确认不会影响"上传失败后重选同一个文件"这个既有行为 —— +现在这个行为是靠末尾那句 `value = ''` 提供的。**改之前先读一遍失败路径。** + +--- + +### X-1 【MEDIUM · 安全】Electron 主进程零导航/新窗口拦截 + +**位置**:`client/electron/electron/main.cjs`(204 行) + +实测 `grep will-navigate|setWindowOpenHandler|session.webRequest` **命中 0**。 + +`webPreferences` 本身是**对的**(`:47-49` `contextIsolation: true` / +`nodeIntegration: false` / `sandbox: true`),preload 只暴露窄接口, +token 走主进程文件 + `additionalArguments` 而**不落 localStorage** +(理由见 `main.cjs:139-148`),`accounts.json` 原子写(tmp + rename)+ `0600` + +解析失败时**不覆盖**(`main.cjs:159-173`)。 +**这份安全姿态是本工程最扎实的部分之一,不要动它。** + +缺的是最后一道:默认 `loadFile(dist/index.html)` 下, +邮件正文里的链接(`react-markdown` 会渲染 ``)点下去会 +**在应用窗口里导航走**。渲染层的 Markdown 纪律很好(无 `dangerouslySetInnerHTML`、无 `rehype-raw`), +所以**目前没有 XSS 面**;但一个 `href="https://…"` 就能把整个应用窗口变成浏览器 —— +窗口标题、preload 注入的 API base/token 全部暴露在陌生站点上。 + +**修复草案**: + +```js +const { shell } = require('electron'); +const openExternal = (url) => { + if (/^https?:\/\//i.test(url)) shell.openExternal(url); // 只放行 http(s) + else e.preventDefault(); +}; +mainWindow.webContents.setWindowOpenHandler(({ url }) => { openExternal(url); return { action: 'deny' }; }); +mainWindow.webContents.on('will-navigate', (e, url) => { e.preventDefault(); openExternal(url); }); +``` + +(`will-navigate` 的 `e` 要在 handler 签名里拿对,草案里的 `preventDefault` 传参需调整。) + +**另**:`main.cjs` 没有 `requestSingleInstanceLock()` —— +双击图标会起两个进程、两个窗口、两个 SSE 连接、两套 `accounts.json` 写入。 +这是 5 行的事,建议同一批改。 + +**验判据**:`test/packaging.test.mjs` 已存在,可加一格静态断言 +「`main.cjs` 含 `setWindowOpenHandler` 且含 `will-navigate`」 +—— 静态可机检、适合钉住不退化(按 §4.2 的"取结构体"写法)。 + +--- + +### X-2 【LOW · 加固】`index.html` 无 CSP + +**位置**:`client/electron/index.html` + +全文件没有任何 ``。 +当前**无实际漏洞**(无 `dangerouslySetInnerHTML`、无 `eval`、 +动态 `src` 只有本地 data URL),但这是**唯一一道会在有人加一行 +`dangerouslySetInnerHTML` 之后消失的防线** —— +而 `sandbox: true` + `contextIsolation: true` **不会**替你挡渲染层里的注入 +(那两处只挡 node 与跨上下文)。 + +⚠️ 首帧防闪屏那段内联脚本(`index.html:31-61`)**要求** `'unsafe-inline'` +(它必须同步执行,早于任何外链),所以 CSP 要写成 +`script-src 'self' 'unsafe-inline'`,**不要**写成严格 CSP。 +`test/theme.test.mjs` 逐字符断言 `:54` 是 `document.documentElement.classList.add('dark');` +——单引号;`index.html:42-43` 的注释已经写了「引号别顺手统一成双引号, +曾经被 prettier 改写过一次,直接让测试变红」。 + +--- + +### L-1 【LOW】鸿蒙孤儿页与 Hello World 路由仍在 + +**位置**:`client/harmony/entry/src/main/resources/base/profile/main_pages.json` + +仍含 `pages/Index`(DevEco 模板 "Hello World",38 行)。 +`pages/InboxPage.ets`(262 行)与 `pages/SessionsPage.ets`(170 行)仍在树上: +按路由表现状,`SessionsPage` 被 `InboxPage.ets:171` push, +而 `InboxPage` 自己**不在**路由表里 ⇒ 两者一起不可达。 +`InboxPage` 里那份 `loadInbox()` + 未读计数与 `MainPage.InboxTab` 重复。 + +已登记 `harmony-dead-pages`(count=2,到期前提「下次清理鸿蒙页面时」)。 +清理前**先确认**它们不是将来要用的留存实现 —— 旧报告已提醒过,仍然有效。 + +--- + +## 3. 两端共同的空白(不在任何 review 里) + +### G-1 无障碍:接近于零 + +| 指标 | Electron | HarmonyOS | +|---|---|---| +| 交互元素 | `