`docs/reviews/` 此前一直是**未跟踪**状态 —— 审查报告只在磁盘上, 不进版本库 ⇒ 换机器、换会话、给别人看时全部拿不到, 而它们正是本轮五个修复(hap 出库 / SSE 写锁 / 换身份清数据 / 鸿蒙门禁三态 / HMS 配额)的**来源**。 push-and-gui-review.md 推送链 + Electron GUI(HMS 配额那两条) electron-gui-review.md 换身份不清数据 harmony-client-review.md 鸿蒙:门禁 fail-open / MailStore 快照共用 / clear() 零调用方 harmony-pages-review.md 页面层 harmony-state-review.md 状态层 fix-report-2026-09-26.md 上述修复的实施记录 其中 `harmony-client-review.md` §三.1 记的那条值得单独留意: 该报告自己声明「ArkTS 语言规范层面零违规,本文所有问题都是**逻辑缺陷**」—— 本次提交的三处鸿蒙改动也只动逻辑(门禁条件、logout 清理), 不碰语法层。
374 lines
18 KiB
Markdown
374 lines
18 KiB
Markdown
# 鸿蒙客户端代码审查报告
|
||
|
||
审查对象:`client/harmony/entry/src/main/ets/`(65 个源文件,约 23,600 行)
|
||
审查方式:加载 `arkts-grammar-standards` 技能后逐文件通读 + 交叉核对服务端契约
|
||
基线:`node test/run-all.mjs` → **577 判据 / 565 通过 / 3 红 / 9 跳过**
|
||
|
||
---
|
||
|
||
## 一、总体评价
|
||
|
||
这份客户端的**工程素养显著高于常见水平**,审查中需要特别说明:
|
||
|
||
- 大量注释带日期 + 现象 + 根因 + 判据,可追溯到具体 commit
|
||
- 已系统性修过 ArkUI 生命周期、SSE 解码、UTF-8 分片、主题应用、键盘避让等问题
|
||
- 已接入 34 个判据文件、577 条判据的自动化回归网
|
||
|
||
**ArkTS 语言规范层面零违规**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、
|
||
无 `for...in`、无 `@ts-ignore`、无 V1/V2 装饰器混用、无伪造的 ArkUI 修饰符、无属性名与
|
||
ArkUI 通用属性冲突。本文所有问题都是**逻辑缺陷**,不是语法问题。
|
||
|
||
因此下面列出的不是"代码很烂",而是**一处安全边界失效 + 一处数据隔离失效 + 一批边界态缺陷**。
|
||
|
||
---
|
||
|
||
## 二、必须修(会出事)
|
||
|
||
### 1. `AdminUsersPage.ets:319` — 管理员门禁**失败开放**(fail-open)
|
||
|
||
```ts
|
||
if (this.roleKnown && !this.isAdmin) { // 只有"确定不是管理员"才拦
|
||
```
|
||
|
||
`loadRole()` 在**任何**失败路径上都把 `roleKnown` 置 false(`:113-116` —— 网络抖动、
|
||
token 过期、500、DNS 失败全都一样)。于是:
|
||
|
||
- `aboutToAppear` 里 `loadRole()` 与 `load()` **都未 await 且并行** ⇒ 首帧 `roleKnown=false`
|
||
⇒ 完整管理控制台**在角色校验返回之前就已挂载**
|
||
- 此后每次网络失败都会再次 fail-open
|
||
|
||
**讽刺的是该文件自己的头注释(`:100-103`)写的就是正确规则**:
|
||
> 「不能把"读不到"当成"是管理员"(那会让一次网络抖动对所有人显示管理入口)」
|
||
|
||
代码做的正是这条注释禁止的事。
|
||
|
||
**影响边界**:服务端 `middleware/user.go:70-77` 的 `AdminOnly` 仍在,所以**不是越权**。
|
||
但非管理员会看到完整用户列表、建号表单、改密/重置入口,并向管理端点发起请求 —— 属于
|
||
客户端信息泄露 + 无意义的失败请求风暴。
|
||
|
||
**修法**:
|
||
|
||
```ts
|
||
if (!this.roleKnown) { /* 读不到:显示"正在确认身份",不要渲染管理台 */ }
|
||
else if (!this.isAdmin) { /* 现有那面墙 */ }
|
||
else { /* 管理台 */ }
|
||
```
|
||
|
||
---
|
||
|
||
### 2. `MailStore.ets:446` / `:593` — 收件箱与发件箱**共用同一份快照**
|
||
|
||
```ts
|
||
async loadInbox(...) { const snap = this.snapshot; ... snap.mails = merged; ... }
|
||
async loadSent (...) { const snap = this.snapshot; ... snap.mails = merged; ... }
|
||
```
|
||
|
||
`loadSent` 会把 `snap.unread` 置 0、用发件箱分组重建 `groups`。
|
||
`MainPage.ets:1947` 的 `navPathStack.clear()` 只保证了"同一时刻只挂载一个页签",
|
||
**没有串行化在途请求**:SSE 事件在 `loadSent` await 期间落地
|
||
→ `notifyRemoteChange()` → `InboxTab.onMailRevChanged()` → `loadInbox()`,
|
||
两个 promise 同时在飞,**后落地的覆盖先落地的**。
|
||
|
||
**修法**:给 `MailStore` 加 generation 计数器,`await` 之后校验自己仍是最新一代,
|
||
否则丢弃结果;或者按视图拆成 `inboxSnapshot` / `sentSnapshot` 两个对象。
|
||
|
||
---
|
||
|
||
### 3. `MailStore.ets:702` — `clear()` **零调用方**,登出不清内存
|
||
|
||
```ts
|
||
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
|
||
clear(): void { this.snapshot = new MailSnapshot(); this.bump(); }
|
||
```
|
||
|
||
全仓检索确认:**除了定义处,没有任何调用点**。
|
||
`api/Logout.ets:34-101` 的 `performLogout()` 与 `SettingsPage` 的 `removeAccount()` 都没调。
|
||
|
||
⇒ 上一个账号的邮件、`AppearanceStore.wallpaper`、`TopbarStore` 内容全部留在内存里。
|
||
新账号走 `LoginPage.aboutToAppear` 快速路径时,会在请求返回前**先渲染出上一个账号的邮件**。
|
||
这正是 `clear()` 注释自己声称要防的那件事。
|
||
|
||
**修法**:`performLogout()` 与 `removeAccount()` 里补 `MailStore.getInstance().clear()`。
|
||
|
||
---
|
||
|
||
### 4. `MailDetailPage.ets:1939` — 回车键守卫比较了一个**不存在的值**
|
||
|
||
```ts
|
||
if (this.mailType === 'permission' && this.permissionResult.length === 0) {
|
||
```
|
||
|
||
服务端只发 `'normal'`、`'permission_request'`(`server/internal/handler/permission.go:286`、
|
||
`repo/repo.go:442`)。同文件 `:1100` 与 `:1388` 都正确写的是 `'permission_request'`,
|
||
**只有这一处是 `'permission'`** ⇒ 该守卫恒为假,是死代码。
|
||
|
||
后果:在一封**尚未决策**的权限请求上按回车,会走进"打开回复框"的分支
|
||
(`MainPage.ets:4263-4274` → `ReplyIntent.request()`),而正确行为是进入决策面板。
|
||
|
||
**修法**:改为 `'permission_request'`。
|
||
|
||
---
|
||
|
||
## 三、应修(生产环境会撞上)
|
||
|
||
### 5. `MainPage.ets:2759` — 顶栏轮播的内层 `setTimeout` 句柄被丢弃
|
||
|
||
```ts
|
||
this.topTimer = setInterval(() => {
|
||
...
|
||
setTimeout(() => { this.topIndex = ...; ui.animateTo(...) }, TOPBAR_FADE_MS);
|
||
}, TOPBAR_ROTATE_MS);
|
||
```
|
||
|
||
`stopTopbarRotation()`(`:2768-2773`)**只 `clearInterval(topTimer)`**。
|
||
那个 `setTimeout` 的句柄被丢弃 ⇒ 页面销毁后回调仍会执行,在正在销毁的组件上写
|
||
`topIndex`/`topOpacity`,并对已失效的 `UIContext` 调 `animateTo`。
|
||
|
||
同一块还**缺重入保护**:`TOPBAR_ROTATE_MS`(5500) 远大于 `TOPBAR_FADE_MS`(260),
|
||
但一次超过一个周期的卡顿(大收件箱 `JSON.parse`、切后台)会让下一 tick 再排一个淡入,
|
||
`topIndex` 前进两次而只显示一次淡入 ⇒ 静默跳过一条。
|
||
|
||
**修法**:把 timeout 句柄与 `topTimer` 一起保存/一起清;加一个"淡入中"标志。
|
||
|
||
---
|
||
|
||
### 6. `MainPage.ets:2154` — `closeDetail()` 是死代码,系统返回键绕过了它
|
||
|
||
```ts
|
||
private closeDetail(): void {
|
||
this.currentMailId = '';
|
||
AppStorage.setOrCreate<string>(KEY_OPEN_MAIL_ID, '');
|
||
}
|
||
```
|
||
|
||
全仓**仅此一处定义、零调用**。`KEY_OPEN_MAIL_ID` 由 `openMail` 写、
|
||
由根部按键分发器(`:4263-4274`)读;唯一的清除点是 `NavDestinations.ets:54-57` 里
|
||
应用内返回箭头的 `onBack` 回调。
|
||
|
||
**两个 `Navigation` 都没有注册 `onPop`**(已核对 `MainPage.ets` / `ContactsTab.ets`,
|
||
都只调了 `.navDestination(...)`)⇒ 硬件返回键与侧滑返回直接弹栈,绕过那个回调。
|
||
|
||
后果:用系统返回键退出详情页后,`KEY_OPEN_MAIL_ID` 仍是旧值 ⇒ 在收件箱按回车
|
||
会打开**一封你已不在看的邮件的回复框**。`currentMailId` 同样不复位 ⇒ 已读行高亮残留、
|
||
`groupHasActive()` 继续给已离开的组描边。
|
||
|
||
---
|
||
|
||
### 7. `MainPage.ets:2081` — `KEY_COMM_STACK_DEPTH` 只在按 Esc 时递减
|
||
|
||
`publishStackDepth()` 的调用点里,**唯一能减的地方是 `PopIntent` 监听器(`:1708`),
|
||
而它只在按 Esc 时才跑**。
|
||
|
||
推入详情 → 用系统返回键弹出 ⇒ 真实栈已空但发布出去的深度仍是 1。
|
||
下一次 Esc 读到 `depth > 0` → 触发 `PopIntent.request()` → `navPathStack.pop()`
|
||
在空栈上是空操作 → handler `return true`(`:4239`)**吞掉了这次按键**。
|
||
|
||
⇒ 此后 Esc 永远无法透传给系统,**用户无法用键盘退出应用**。
|
||
|
||
**修法**:深度必须由真实的 pop 观察驱动(`NavDestination.onHidden`,或任何栈变化时重发),
|
||
而不是只由 Esc 路径驱动。
|
||
|
||
---
|
||
|
||
### 8. `ContactsTab.ets:202` — `openSession()` 没有请求令牌
|
||
|
||
```ts
|
||
this.openSessionId = c.session_id;
|
||
this.sessionTitle = ...;
|
||
this.sessionMails = [];
|
||
this.sessionLoading = true;
|
||
try { this.sessionMails = await m.sessionMails(c.session_id); } ...
|
||
```
|
||
|
||
连点 A 再点 B,两个请求同时在飞。若 A 的响应后到(账号慢/冷连接),
|
||
赋值会**用 A 的列表覆盖 B 的**,而标题仍显示 B ⇒ 面板上方是 B 的名字和主题、
|
||
下方是 A 的邮件列表;`openSessionId`(`confirmArchive` 用)指向 B,内容却是 A 的。
|
||
`sessionLoading` 也被先完成者清掉,另一个还在飞却已无转圈。
|
||
|
||
**修法**:加序号,`await` 之后校验仍是最新一次请求。
|
||
|
||
---
|
||
|
||
### 9. `MailStore.ets:499` — `cc_list.length` 少了 `?? []` 兜底
|
||
|
||
```ts
|
||
mail.cc_count = mail.cc_list.length; // ← 无兜底
|
||
mail.attach_count = (mail.attachments ?? []).length; // ← 两行之下就有兜底
|
||
```
|
||
|
||
注释里详细记录了 `attachments` 因为服务端 `omitempty` 导致 94/96 缺失、
|
||
`.length` 当场崩掉的全过程 —— 紧接着的下一行**又犯了同形状的错**(只是 `cc_list`
|
||
目前恰好 96/96 都有)。
|
||
|
||
聚合模式下单个账号的 `try/catch`(`:529`)会把这个 TypeError 吞成
|
||
`failed.push(...)` ⇒ **静默丢掉一整个账号的邮件,并污染未读总数**。
|
||
|
||
---
|
||
|
||
### 10. `AppearanceStore.ets:188-197` — 壁纸 PixelMap 泄漏 + 全尺寸解码
|
||
|
||
```ts
|
||
const bytes: ArrayBuffer = await api.fetchImageBytes();
|
||
const src: image.ImageSource = image.createImageSource(bytes);
|
||
this.wallpaper = await src.createPixelMap(); // 无 desiredSize,且不 release
|
||
```
|
||
|
||
三个问题叠在一起:
|
||
|
||
- 旧 `PixelMap` **从不 `release()`**,`ImageSource` 也不释放 —— 而同仓
|
||
`BackgroundPicker.ets:167-229` 对释放是极其严谨的,这里的标准不一致
|
||
- `createPixelMap()` **不带 `desiredSize`** ⇒ 2560px 壁纸按全尺寸解码(约 26 MB ARGB),
|
||
并在整个会话里由静态单例持有 ⇒ **低内存设备 OOM 风险**
|
||
- 全尺寸解码**跑在 UI 线程**上,且设置页会 `await` 它才应用主题 ⇒ 每次同步都有
|
||
数百毫秒卡顿
|
||
|
||
---
|
||
|
||
### 11. `MainPage.ets:2917` — 每条 SSE 事件都触发一次全量多账号重拉
|
||
|
||
```ts
|
||
MailStore.getInstance().notifyRemoteChange(); // → 所有已挂载页签 loadData()
|
||
```
|
||
|
||
`loadInbox` 会**逐个账号**各发一次 `GET /me/mail/inbox`(`:477-487`),且
|
||
**没有 debounce、没有在途抑制、没有 "正在加载" 早退** ⇒ 事件会排队而不是合并。
|
||
一分钟 20 条 `session_update`、3 个账号 = 60 次冗余请求。
|
||
|
||
这是"接收邮件不正常/卡顿"最可能的来源。
|
||
|
||
**修法**:修订号处理侧加短 debounce + `loadInbox` 入口加在途早退。
|
||
|
||
---
|
||
|
||
### 12. `CalendarPage.ets:1312` vs `:1344` — 同一事件按两个时间基准放置与标注
|
||
|
||
```ts
|
||
if (new Date(e.event_time).getHours() === hour) { ... } // 设备本地时
|
||
Text(hhmmAtOffset(e.event_time, this.offsetMinutes)) // 日历配置时区
|
||
```
|
||
|
||
日历时区与设备时区不一致的用户,**每个事件被画在这一行、却被标注成另一时间**。
|
||
`nowHour()` 同样用设备本地 `getHours()`,所以"当前时间"参考线也对不上所有标签。
|
||
|
||
---
|
||
|
||
### 13. `ApiClient.ets:119` — `persistBase()` 绕过了归一化闸口
|
||
|
||
```ts
|
||
async persistBase(base: string): Promise<void> {
|
||
this.apiBase = base; // ← 直接赋值,未经 normalizeApiBase
|
||
pref.putSync(PREF_KEY_API_BASE, base);
|
||
```
|
||
|
||
`setBase()`(`:95-97`)的注释明确说它是"归一化的唯一闸口",
|
||
但 `persistBase` 绕过了它。`LoginPage.ets:216-217` 虽然先调了 `setBase`,
|
||
可传入 `persistBase` 的是**未经归一化的原始输入** `this.serverAddr`。
|
||
|
||
后果:用户填 `https://x.com/` ⇒ 内存里是对的,**存进 preferences 的是不带 `/api/v1` 的**,
|
||
下次冷启 `init()` 虽会再归一化一次,但这条路径本身就是设计上的漏洞。
|
||
另外该方法还会**丢弃调用方已算好的 `check.base`**。
|
||
|
||
---
|
||
|
||
### 14. `SseService.ets` — 重连不销毁旧连接,且不支持事件回放
|
||
|
||
- **`doConnect` 没有 `destroy()` 旧 `httpRequest`**(`:201-202` 直接新建并覆盖
|
||
`conn.httpRequest`)⇒ 每次重连泄漏一个 `HttpRequest`,旧实例的回调也不会被解除。
|
||
`disconnectAccount` 里的 `destroy()`(`:127`)只对**当前**那个生效。
|
||
- **完全不支持 `Last-Event-ID`**(全仓零命中)。服务端 `server/internal/sse/manager.go:184-193`
|
||
专门实现了环形缓冲回放,而 WebUI 靠 `EventSource` 自动带这个头。
|
||
⇒ 鸿蒙端**断线期间的事件永久丢失**,这正是服务端那段回放代码想解决的问题。
|
||
- 重连固定 3 秒、无指数退避(`:305-308`),WebUI 侧有 backoff。
|
||
|
||
---
|
||
|
||
### 15. `MailStore.ets:690` — `dropSession` 忽略账号,而分组键带账号
|
||
|
||
```ts
|
||
if (m.session_id !== sessionId) { kept.push(m); }
|
||
```
|
||
|
||
而 `MailGrouping.ts:193` 的 `sessionKey` 明确要求键必须是 `account + '/' + session_id`
|
||
("同一个 `session_id` 出现在两个账号里是两件事")。
|
||
|
||
后果:聚合模式下归档 A 账号的某条会话,会把**其他账号里同 id 的会话一并删掉**,
|
||
且要等下次重拉才恢复。
|
||
|
||
---
|
||
|
||
### 16. `MailStore.ets:375-419` — 缓存读路径没有归一化
|
||
|
||
`paintFromCache` 走 `JSON.parse(...) as MailLike[]` 后**直接使用**,
|
||
不像 `MailApi.inbox()` 那样过 `MailSummary.normalize()`。
|
||
旧版本构建留下的缓存里,`session_alias` / `permission_result` / `attachments`
|
||
都会是 `undefined` ⇒ **复现 `Models.ets:139-163` 记录过的那次白屏崩溃**
|
||
(`Cannot read property length of undefined`)。
|
||
|
||
---
|
||
|
||
## 四、可选清理
|
||
|
||
| 位置 | 问题 |
|
||
|---|---|
|
||
| `Index.ets:1-38` | DevEco "Hello World" 模板页仍注册在 `main_pages.json` 里,可被路由到并显示模板文案 |
|
||
| `InboxPage.ets` | 既不在 `main_pages.json`,也无任何 `pushUrl` 指向 —— 完全死代码,~262 行,且自带一套与 `MainPage.InboxTab` 重复的取数与未读计数 |
|
||
| `SessionsPage.ets` | 仅被上面那个死页面引用 |
|
||
| `MainPage.ets:38` | `import { MailDetailView }` 从未使用(细节渲染走 `MailDetailDestination`)—— 半途重构的痕迹 |
|
||
| `MainPage.ets:455` | `InboxTab.openCompose()` 仍是迁移前的 `pushUrl` 全页路径(当前不可达,但离回归只差一个调用点) |
|
||
| `WideSidebar.ets:97` | 注释声称"订阅 SSE 状态变化",实际只在 `aboutToAppear` 读一次 ⇒ 连接指示灯永远冻结在挂载时的状态 |
|
||
| `MailDetailPage.ets:113` | `autoReadMailId` 带着 7 行去重注释但从未被读或写,真正的去重没实现(`status` 在 await 之后才置 `'read'`,不构成重入锁) |
|
||
| `MailDetailPage.ets:2008` | `const sessionId: string \| null = this.sessionId;` 而 `@State sessionId: string = ''` 永不为 null ⇒ 那个 `=== null` 守卫恒假,空串会漏过去 |
|
||
| `SessionApi.ets:48` | `proposal: RenameProposal \| null` 用了 `null`(本仓约定偏向 `undefined`) |
|
||
| `ContactsTab.ets:416` | `ForEach` 用数组下标做 key(其余列表都用稳定标识),归档后行组件会错位复用 |
|
||
| `CalendarPage.ets:499/993/1036/1060` | `loading = false` 写在 `catch` 之后而非 `finally`(同仓 `PermissionTab`/`AdminUsersPage` 是对的) |
|
||
|
||
---
|
||
|
||
## 五、审查过程中**排除**的疑点
|
||
|
||
避免这些被当成 bug 去"修":
|
||
|
||
- **ICS 生成**:客户端**根本不生成** ICS,`IcsFile.ets` 只负责选/读/写文本;
|
||
生成在服务端 `handler/calendar.go`,CRLF 与 UTC `DTSTART` 都正确。
|
||
- **`EntryAbility.onCreate` 里登录前就 `reportToken`**:已知且**已修**(`LoginPage:179`
|
||
的 `reportPushToken` 补报,注释记录了完整因果)。
|
||
- **顶栏 `setInterval`**:外层 interval 在 `aboutToDisappear` 确实清了(内层 timeout 漏了,见第 5 条)。
|
||
- **SSE 监听器生命周期**:`addListener`/`removeListener` 在 `MainPage:2868/2876` 正确配对。
|
||
- **`bump()` 与 `publishChange()` 共用 revision 计数器**:曾被怀疑会导致 AppStorage 值永久漂移,
|
||
实际不会 —— `@Watch` 关心的是"值有没有变",两者同步自增反而是必需的。
|
||
- **`CalendarPage.ets:335` 的 `Date.UTC(year, month-2, 1)`**:1 月时索引为 -1 是**故意的**溢出算术。
|
||
- **Markdown 渲染远端邮件正文**:全仓无 WebView / `loadUrl` / `innerHTML` 路径,
|
||
走原生 `@luvi/lv-markdown-in` 组件 ⇒ **不存在 XSS 执行路径**。
|
||
- **`.onClick` 签名**:16 个页面里全部零参,无 `ClickEvent`/`GestureEvent` 混用。
|
||
- **ARKTS 语法层**:无 `any`/`unknown`、无解构、无正则字面量、无 `Object.assign`、
|
||
无 `for...in`、无 `@ts-ignore`、无 V1/V2 混用、无伪造修饰符、无 `Record` 字面量未加引号的键。
|
||
|
||
---
|
||
|
||
## 六、建议的修复顺序
|
||
|
||
1. **第 1 条**(管理员门禁 fail-open)—— 唯一的"安全边界失效",改动量最小
|
||
2. **第 3 条**(`clear()` 零调用)—— 一行调用,堵住跨账号数据泄露
|
||
3. **第 4 条**(`'permission'` 拼写)—— 一个字符串,修掉一处死守卫
|
||
4. **第 6、7 条**(系统返回键相关的两处状态未复位)—— 一起改,都需要真实 pop 观察
|
||
5. **第 2、11 条**(快照争用 + 无 debounce 重拉)—— 一起做,加 generation 与 debounce
|
||
6. 第 14 条(SSE 重连销毁 / `Last-Event-ID`)—— 影响实时性,可排后
|
||
7. 其余按需
|
||
|
||
---
|
||
|
||
## 附:测试基线
|
||
|
||
`node test/run-all.mjs` 本身是**红的**,3 条失败均与鸿蒙客户端代码无关:
|
||
|
||
- `build-stamp` —— 产物是在旧提交 `d9e71a4` 上构建的,当前 HEAD 是 `3b46126`(需重构建)
|
||
- `commit-hygiene` —— **两个 `.hap` 产物被 git 跟踪**,内含 AGC 信封密钥/校验码/api_key。
|
||
已确认**尚未推送到 `origin/main`**(`git cat-file -e origin/main:<path>` 均失败),
|
||
提交于 `f51c9c8` ⇒ **赶在下一次 push 前**执行
|
||
`git rm --cached AgentMail-v1.2.1-pushdiag.hap AgentMail-v1.2.1-pushlog2.hap` 即可;
|
||
但既然是客户端签名凭证,仍建议**轮换**
|
||
- `criteria-hygiene` —— 判据自身的探针路径在本地历史里查不到(探针自身有问题)
|
||
|
||
9 条"跳过"全部是**设备相关**判据(hdc 服务/模拟器不在),
|
||
按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。
|