`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 清理), 不碰语法层。
18 KiB
鸿蒙客户端代码审查报告
审查对象: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)
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 仍在,所以不是越权。
但非管理员会看到完整用户列表、建号表单、改密/重置入口,并向管理端点发起请求 —— 属于
客户端信息泄露 + 无意义的失败请求风暴。
修法:
if (!this.roleKnown) { /* 读不到:显示"正在确认身份",不要渲染管理台 */ }
else if (!this.isAdmin) { /* 现有那面墙 */ }
else { /* 管理台 */ }
2. MailStore.ets:446 / :593 — 收件箱与发件箱共用同一份快照
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() 零调用方,登出不清内存
/** 退出登录/换账号时清空 —— 否则下一个账号会看到上一个人的邮件(哪怕只有一帧)。 */
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 — 回车键守卫比较了一个不存在的值
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 句柄被丢弃
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() 是死代码,系统返回键绕过了它
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() 没有请求令牌
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 少了 ?? [] 兜底
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 泄漏 + 全尺寸解码
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 事件都触发一次全量多账号重拉
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 — 同一事件按两个时间基准放置与标注
if (new Date(e.event_time).getHours() === hour) { ... } // 设备本地时
Text(hhmmAtOffset(e.event_time, this.offsetMinutes)) // 日历配置时区
日历时区与设备时区不一致的用户,每个事件被画在这一行、却被标注成另一时间。
nowHour() 同样用设备本地 getHours(),所以"当前时间"参考线也对不上所有标签。
13. ApiClient.ets:119 — persistBase() 绕过了归一化闸口
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 忽略账号,而分组键带账号
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 与 UTCDTSTART都正确。 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 条(管理员门禁 fail-open)—— 唯一的"安全边界失效",改动量最小
- 第 3 条(
clear()零调用)—— 一行调用,堵住跨账号数据泄露 - 第 4 条(
'permission'拼写)—— 一个字符串,修掉一处死守卫 - 第 6、7 条(系统返回键相关的两处状态未复位)—— 一起改,都需要真实 pop 观察
- 第 2、11 条(快照争用 + 无 debounce 重拉)—— 一起做,加 generation 与 debounce
- 第 14 条(SSE 重连销毁 /
Last-Event-ID)—— 影响实时性,可排后 - 其余按需
附:测试基线
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 服务/模拟器不在), 按本仓自己的约定「跳过不是通过」⇒ 上述运行时行为类问题仍缺设备侧验证。