## 为什么要这条(一个当场踩到、且 tsc 抓不到的坑)
上一个提交给 `preload.cjs` 加窗口动作时,第一版写了 TS 类型标注:
onMaximizedChanged: (cb: (maximized: boolean) => void) => { // ← .cjs 里不能有
`preload.cjs` 是纯 JS,整个文件**静默**加载失败。症状链:
hasBridge: false hasWin: false base: "/api/v1" tok: <null> 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 是否先红了。
849 lines
45 KiB
Markdown
849 lines
45 KiB
Markdown
# GUI 复查与转交报告(2026-10-03)
|
||
|
||
> **基线**: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<string, string>()`,`: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 —— 无。** 三处渲染都是 `<Markdown remarkPlugins={[remarkGfm]}>`,
|
||
`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<void> {
|
||
const myGen = ++this.inboxGen;
|
||
const snap = this.snapshot; // ← 只碰收件箱那份
|
||
…
|
||
// 每次 await 之后、每次写 snap 之前:if (this.inboxGen !== myGen) return;
|
||
}
|
||
|
||
async loadSent(ctx, accountFilter): Promise<void> {
|
||
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<void> {
|
||
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<void> {
|
||
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<void> {
|
||
…
|
||
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}`),
|
||
`<input type="file">` 本身(`: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` 会渲染 `<a href>`)点下去会
|
||
**在应用窗口里导航走**。渲染层的 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`
|
||
|
||
全文件没有任何 `<meta http-equiv="Content-Security-Policy">`。
|
||
当前**无实际漏洞**(无 `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 |
|
||
|---|---|---|
|
||
| 交互元素 | `<button` **139** 处 | `@Component` **27** 个 |
|
||
| 可访问名 | `aria-label` 全库 **22** 次(还含非 button 元素) | `accessibilityText` / `accessibilityLevel` / `accessibilityDescription` **0** |
|
||
| 隐藏装饰 | `aria-hidden` **4** 次 | — |
|
||
| 角色 | `role=` **11** 次 | — |
|
||
| 键盘处理 | `onKeyDown` **4** 次(其中 2 处是 `AddressInput` 的补全上下键) | — |
|
||
| 焦点顺序 | `tabIndex` **0** 次(纯靠原生顺序) | — |
|
||
|
||
**已有的正面样本**(说明作者知道要留钩子):
|
||
`CommTabs.tsx:41-52` 用了 `role="tablist"` + `role="tab"` + `aria-selected`;
|
||
`ComposeFab`(`CommTabs.tsx:88-92`)用了 `data-testid` + `aria-label`。
|
||
|
||
**这不是"顺手加一下"的量级。** 建议的做法:
|
||
- Electron 侧先落一条**不退化**判据:`test/components/a11y.test.mjs`
|
||
数「纯图标按钮(无文本子节点)缺 `aria-label`/`aria-hidden` 的数量 = 0」。
|
||
这条是静态、可机检、今天就会红 —— 所以**先记账**(见 §5.4),再逐个补。
|
||
- HarmonyOS 侧需要设备判据(ArkUI 的无障碍属性只能上设备验)。
|
||
- 两个"4"要一起处理:除 `AddressInput` 外只有 `AccountSwitcher.tsx:44` 一处全局
|
||
`keydown` 监听(用于关闭下拉),**没有**全局快捷键体系 ——
|
||
而鸿蒙侧有完整的 `PopIntent` / `KeyboardShortcuts`(Esc 返回、Enter 打开)。
|
||
**两端的键盘交互面不对等**,窄屏与桌面端的行为会分叉。
|
||
|
||
### G-2 列表虚拟化:两端都全量渲染
|
||
|
||
| | 做法 | 后果 |
|
||
|---|---|---|
|
||
| Electron | `MailList.tsx:99` `groups.map(...)` + `:225` `g.mails.map(...)`,无虚拟滚动 | 251 封长链线索(`GUI-PLAN-HARMONY.md` M4 提到的规模)一次性进 DOM |
|
||
| HarmonyOS | **`LazyForEach` 使用 0 次**,`ForEach` 46 处(含收件箱、发件箱、日历、会话、联系人) | 同上 |
|
||
|
||
★ **计划与实现已经分叉**:`GUI-PLAN-HARMONY.md:152` 明确写了
|
||
「组件用 `struct` + `@Component`,状态用 `@State`/`@Prop`/`@Provide`;列表用 `LazyForEach`
|
||
(邮件量可能上百,`ForEach` 全量渲染会卡)」—— **计划里写了,实现没做,
|
||
而且 `docs/DEBTS.json` 的 56 条里没有这一条。**
|
||
|
||
**线程树是个例外且做得对**:Electron `ThreadView.tsx:122` 用 `IntersectionObserver`
|
||
分块加载(`limit=60` + `next_offset` + `hasMore`),鸿蒙 `MailApi.thread()`(`:348`)
|
||
也有 `dir`/`limit` 参数 —— 但鸿蒙侧 `MailDetailPage.ets:1990` 把树**拍平成缩进字符串**后全量渲染
|
||
(M-3 同一个位置的两个问题)。
|
||
|
||
**建议**:把 `LazyForEach` 这条补进 `docs/DEBTS.json`(§5.4 给了草稿),
|
||
先记账再排期。这正是"计划说了但没人记"的典型,而本仓的 `DEBTS.json`
|
||
就是为了防这种事才存在的。
|
||
|
||
---
|
||
|
||
## 4. 判据纪律(写判据前必读)
|
||
|
||
`client/electron/test/CRITERIA.md` 已经把规矩写全了,这里只挑**本报告相关**的三条复述。
|
||
|
||
### 4.1 静态判据 vs 设备判据
|
||
|
||
`.ets` 的**运行时**行为(状态机、文案、渲染结果)只能上设备验。
|
||
`test/run-all.mjs` 的 `STATIC_ONLY` 名单(`:1395` 起)登记了当前 5 条静态判据
|
||
及其到期前提(探针三值转 true 时自动变红)。
|
||
|
||
⇒ **H-1 的判据应该落在 `harmony-logic.test.mjs`**(它在 `STATIC_ONLY` 里,
|
||
理由是「页面状态机与文案:`.ets` 状态要跑起来才算数」)——
|
||
但注意它目前在静态名单里,所以**升级成设备判据时要从 `STATIC_ONLY` 移除并相应减 count**,
|
||
否则 `debt-visibility.test.mjs` 会因"该升级却没升级"而红。
|
||
★ 这正是 `static-criteria`(count=5)与 `harmony-p4c-boundary-decls`(count=4)
|
||
这两条欠账管理的机制 —— 按它做,不要另创流程。
|
||
|
||
### 4.2 判结构必须配对/解析,不要固定宽度窗口
|
||
|
||
`CRITERIA.md` §1 记的是**同一个坑在本仓露头的第三次**:
|
||
固定宽度窗口正则(如 `\{[\s\S]{0,80}…`)会被"往规则里加一行注释"绕过。
|
||
|
||
⇒ H-2 的判据要断言 `loadWallpaper` 方法体里有 `desiredSize`,
|
||
应该**取出那个方法体再断言**(按 `}` 配对,且**要跳过成对的括号组** ——
|
||
ArkTS 的链式修饰符会让"往前看一个字符是不是 `}`"失效,见 `CRITERIA.md` §1 的第 ③ 次露头)。
|
||
|
||
`CRITERIA.md` §1 还记了**已知存量**:
|
||
`test/background.test.mjs` 里还有一批 `\{[\s\S]{0,80}…` 窗口,
|
||
"下次碰那个文件时按本节形状改成取规则体,不要在那里再加一条注释算了"。
|
||
X-1 的判据若要断言 `main.cjs`,请用取结构体的写法。
|
||
|
||
### 4.3 判据要能红,且红在正确位置
|
||
|
||
每条新判据都要做**变异测试**:故意把代码改成错的,确认判据变红,
|
||
然后改回来确认变绿。本仓有 `test/mutants/` 目录与 `criteria-hygiene.test.mjs` 的自检,
|
||
照它们的形状做(`CRITERIA.md` §6.6 记了"被变异测试抓住两次的窗口式正则"这个反例)。
|
||
|
||
---
|
||
|
||
## 5. 建议的修复顺序与分工
|
||
|
||
### 5.1 优先(两端各一条 HIGH)
|
||
|
||
| # | 项 | 端 | 改动量 | 判据落点 |
|
||
|---|---|---|---|---|
|
||
| 1 | **H-1** `MailStore` 拆快照 + generation 守卫 | 鸿蒙 | 中(store + 1 个 pane + `clear`) | `harmony-logic.test.mjs`(设备) |
|
||
| 2 | **H-2** `AppearanceStore` `release()` + `desiredSize` | 鸿蒙 | 小(一个方法 + 一个清理入口) | `harmony-appearance.test.mjs` |
|
||
|
||
★ 两条都在鸿蒙侧,且**都不需要动服务端**。
|
||
若要分给两个人,建议一人一条 —— 它们没有共享文件。
|
||
|
||
### 5.2 次优先(Electron)
|
||
|
||
| # | 项 | 改动量 |
|
||
|---|---|---|
|
||
| 3 | **X-1** 导航拦截 + `requestSingleInstanceLock` | 3-5 行 |
|
||
| 4 | **E-1** `PermissionPanel` `inFlight` ref(照 `ForwardBar:393` 抄) | 4 行 |
|
||
| 5 | **E-2** `Attachments` `picking` ref | 6 行 |
|
||
|
||
★ E-1 与 E-2 **都在既有判据文件里补格**(`PermissionPanel.test.tsx` 已存在),
|
||
不需要新建测试文件 —— 这是"最便宜的高价值修复"。
|
||
|
||
### 5.3 再次(鸿蒙,多为一致性对齐)
|
||
|
||
M-2(`ContactsTab` 请求令牌,与 Electron `CalendarView` 同形)→
|
||
M-6(`loading` 进 `finally`)→ M-5(`MarkdownController` 复用)→
|
||
M-3(对话树 key)→ M-4(SSE 去抖 + 探针,**抄 Electron 的 `inboxFallbackPoll`**)。
|
||
|
||
### 5.4 先记账,不排期
|
||
|
||
| 项 | 动作 |
|
||
|---|---|
|
||
| **G-2** `LazyForEach` 零使用 | 补进 `docs/DEBTS.json`(草稿见下) |
|
||
| **G-1** a11y | 先落一条"当前数量"的基线判据(今天会红),记 `count`,再逐个补 |
|
||
|
||
`LazyForEach` 欠账草稿(字段名照 `DEBTS.json` 既有形状):
|
||
|
||
```json
|
||
{
|
||
"id": "harmony-no-lazyforeach",
|
||
"count": 1,
|
||
"due": "收件箱/发件箱/日历任一列表的条目数上到三位数之前(判据:设备上滚动掉帧,或用 `inspector` 读出节点数超过量级)",
|
||
"where": "client/harmony/entry/src/main/ets/ 全树:`LazyForEach` 命中 0 次、`ForEach` 46 处。计划侧的依据是 docs/GUI-PLAN-HARMONY.md:152(「列表用 LazyForEach(邮件量可能上百,ForEach 全量渲染会卡)」)—— 计划写了,实现没做,且此前无人记账。",
|
||
"kind": "correctness",
|
||
"note": "线程树是例外且已做对:Electron `ThreadView.tsx` 用 IntersectionObserver 分块(limit=60 + next_offset),鸿蒙 `MailApi.thread()` 也有 dir/limit 参数;分叉的是渲染侧(拍平成缩进字符串后 ForEach,见本报告 M-3)。"
|
||
}
|
||
```
|
||
|
||
⚠️ 两点已核实的口径(省得下一个 agent 再查一遍):
|
||
|
||
1. **`kind` 没有 schema 约束。** 实测 `DEBTS.json` 现有 56 条里 `kind` 只有 7 个值
|
||
(`scope` 17 / `env` 6 / `observability` 3 / `bug` 2 / `correctness` 1 / `consistency` 1,
|
||
剩下 26 条把结论直接写进了 `kind` 字段)。
|
||
全仓**没有任何判据读 `kind`**(grep `DEBTS` 只命中 `commit-hygiene.test.mjs` 等注释)。
|
||
所以填 `correctness` 不会被红,但**与既有枚举一致更可读**。
|
||
|
||
2. **`DEBTS.json` 里只有 `static-criteria` 那笔有机器镜像。**
|
||
`commit-hygiene.test.mjs:180-196` 的 `debtLedgerStaticMatches` 只比对
|
||
`static-criteria.count` 与 `run-all.mjs` 的 `STATIC_ONLY.length`;
|
||
Go 侧那几笔靠 `go test` 的 `TestDebtLedgerMatchesMeasurement` 比对。
|
||
⇒ **新增别的 id 不会触发任何账目漂移检查**,所以 count 要自己算准;
|
||
而**若将来把 H-1 的判据升级成设备判据并从 `STATIC_ONLY` 移除,
|
||
`static-criteria` 与 `harmony-p4c-boundary-decls` 两个 count 必须同时减**(见 §4.1)。
|
||
|
||
---
|
||
|
||
## 6. 不要动的部分
|
||
|
||
| 项 | 原因 |
|
||
|---|---|
|
||
| **M-1** 鸿蒙系统返回键 | 已登记 `harmony-system-back-key`;上一轮在 `EntryAbility` 加 `onBackPressed()` **实测失败并已撤回**(按一次返回键直接销毁 UIAbility)。到期前提是"真机验清 `Navigation` 的返回事件消费点"。**没有真机就别动。** |
|
||
| `MailStore.bump()` 不发布 AppStorage | **是设计**(`MailStore.ets:180-196` 注释写明:两者混用会死循环)。`harmony-state-review.md` #3 是误读。 |
|
||
| 鸿蒙日历 `hourEvents` / `hhmmAtOffset` | §1.3 ① 已用五个时区实测否证,两者恒等。**不要去"统一时间基准"。** |
|
||
| `main.cjs` 的 `webPreferences` | `contextIsolation` / `nodeIntegration: false` / `sandbox: true` 已正确。X-1 只加导航拦截,不要顺手"加强"这部分。 |
|
||
| `backgroundStore` 的 `LEGACY_BACKUP_KEY` | 有意永久残留(注释已论证代价与无法判定安全删除的理由)。 |
|
||
| `.pi-lens.json` / `biome.jsonc` 的 formatter 关闭 | 历史上有一次自动格式化把 `index.html` 里 `classList.add('dark')` 的单引号改成双引号,直接让 `test/theme.test.mjs` 变红。 |
|
||
|
||
---
|
||
|
||
## 7. 本报告的证据边界(如实标注)
|
||
|
||
**我做了什么**:对着 HEAD `5e312c6` 逐条 grep / 读源码复核上游 4 份报告的每一条 finding;
|
||
实测了两个未记录的量(a11y 计数、`LazyForEach` 计数);
|
||
**实测运行**了日历时间基准的时区等价性验证(§1.3 ①,5 个时区)。
|
||
|
||
**我没能做什么**(这些结论的性质必须说清):
|
||
|
||
1. **没有运行任何测试**。`client/electron/node_modules` 不存在,本机 `go` 也不可用
|
||
(`go: 未找到命令`)。所以 `npm test`、`run-all.mjs`、`go test ./...` 一条都没跑。
|
||
⇒ **本文所有"判据建议"都只是建议,没有任何一条经过"能红 / 红在正确位置"的验证。**
|
||
落地时请按 §4.3 做变异测试。
|
||
2. **没有真机 / 模拟器**。H-1、H-2、M-1..M-6 的**运行时症状都是静态推断**,
|
||
不是实测。特别是 H-1:我说清了三条触发路径,但**没有一条在设备上复现过** ——
|
||
旧报告把它标成 CRITICAL 的那个"必然场景"已经不成立(`commTab` 改成只 mount 一个),
|
||
所以它在设备上到底是"切页签必现"还是"极难触发",**我不确定**。
|
||
⇒ 建议接手者**先在设备上构造一次**(切页签时断网 / 慢速网络),
|
||
拿到实测症状再动手,这样判据才知道该红在哪。
|
||
3. **HarmonyOS 侧没有编译过**。本机无 `devecocli` / `hvigorw` / SDK,
|
||
`AppearanceStore` 的修复草案**未经 ArkTS 编译验证** ——
|
||
`@State` 对 class 类型的约束、`image.DecodingOptions` 的字段名、
|
||
`finally` 里 `await` 的合法性都需要在真工程里试。
|
||
4. **没有做性能测量**。G-2 的"卡"是推断(基于 `LazyForEach` 零使用 + 计划自己的话),
|
||
**没有实测帧率或节点数**。§5.4 草稿里的 `due` 因此写成可机检的形式而不是"觉得卡"。
|
||
|
||
★ 交接时请把这份边界一起转过去 —— 上面 4 条里任何一条被当成"已验证",
|
||
下一个 agent 就会在没有依据的地方下结论。
|
||
|
||
---
|
||
|
||
## 8. 报告自身的可机检部分
|
||
|
||
本报告里唯一能写成判据的东西,是 §1.3 ① 的时区等价性实测:
|
||
`hhmmAtOffset(ts, deviceOffsetMinutes(now))` 应恒等于 `new Date(ts).getHours()` 的两位补零形式。
|
||
|
||
**建议**把它落成 `harmony-calendar.test.mjs` 里的一格
|
||
(现有 `:288` 那格已用 `hhmmAtOffset(iso, 480)` 这种显式偏移写法,新增格沿用同一形状)。
|
||
价值是:**日后若有人把 `hourEvents` 改成别的时间基准,判据会红**,
|
||
而不是像现在这样靠"两份 review 都恰好读过这段"来防误改。
|
||
|
||
⚠️ 一条容易踩的坑:`getHours()` 无法注入偏移,所以这一格**只能在同一个
|
||
`process.env.TZ` 下同时断言两侧**,不能写跨 TZ 循环 ——
|
||
跨 TZ 循环验证的是"两套算法在给定偏移下相等",那正是上面实测做的;
|
||
而设备上的情形(ArkTS 在本地时区跑 `getHours`)由
|
||
`deviceOffsetMinutes` 取设备偏移这个事实保证。
|
||
两件事别混在一格里,否则判据会断言一个比真实约束更强的性质。
|
||
|
||
## 9. 本报告引证的自检结果
|
||
|
||
写完正文后对全文 `file:line` 引证做了一次机检(HEAD `5e312c6`):
|
||
|
||
- 53 处引用去重后**全部在文件范围内**,无越界、无失效文件。
|
||
- 其中 **72 个行号落在这段引文真正要指的代码上**;
|
||
另有 12 处刻意指向**注释或空行**(如 `MailStore.ets:175-196` 那段
|
||
"为什么 `bump()` 与 `publishChange()` 必须分开"的说明、`main.cjs:139-148`
|
||
"为什么账号放主进程而不是 localStorage"),这些是有意引注释,不是坐标漂移。
|
||
|
||
★ 首轮自检确实抓到了 4 处漂移,已修正:`CalendarView.tsx:105→104`、
|
||
`AdminUsersPage.tsx:96→97`、`KeyPanel.tsx:35→36`、`App.tsx:131/143-152→134/151-157`,
|
||
另修正 `index.html`(脚本在 `:31-61` 而非 `:40-58`)与 `main.cjs`(原子写段在 `:159-173`)。
|
||
⇒ 这印证了 `DEBTS.json` 里那条「行号引用漂到无关代码」:**行号必须临引现查,不能凭记忆写。**
|