用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:
① 「邮件点进去自动已读的能力不正常」
根因:鸿蒙只有「标记已读」按钮(doMarkRead,对照 WebUI MailView.tsx:522
那个手动按钮),缺 WebUI 的**自动**路径(MailView.tsx:55-65 的 useEffect:
可见且 unread 就 markRead)。⇒ 点开邮件不变已读,必须再点按钮。
修:MailDetailPage.loadMail 成功尾端按 `status==='unread'` 触发 doMarkRead
(复用按钮那条路,因而天然带上「就地改 status」+「失败弹 toast」)。
★ 加 autoReadMailId 守卫:WebUI 靠 useEffect 依赖数组天然只跑一次,
鸿蒙 loadMail 是显式调用的(下拉刷新会重跑),不守会重复打接口。
② 「接收邮件的能力也有点不正常」
三个独立缺口,每个都会单独造成"收不到":
a) **SSE 监听寿命**:原挂在 InboxTab.aboutToAppear,而三个 tab 是
if/else 条件挂载的 ⇒ 切到发件箱/授权时 InboxTab 被销毁、监听跟着
移除 ⇒ 在那两栏时收不到任何新邮件。搬到 MainPage(@Entry,全程在)。
b) **只处理 new_mail**:WebUI 监听 5 种事件(sse.ts:7-13 的 EVENTS);
服务端权限决策后发的是 session_update(permission.go:387)⇒
"授权栏里处理过的申请,收件箱还是旧的样子"。补齐四种。
c) **UTF-8 解码错**:arrayBufferToString 逐字节 String.fromCharCode
(Latin-1 语义)⇒ 中文解成乱码。改用 util.TextDecoder + stream:true,
且**每连接一个实例**(stream 会把半截汉字存在解码器内部,
共享实例会把两个账号的半个字拼在一起)。
③ 「发件箱内容点不开」(第二轮;5483140 补了 onClick 仍点不开)
根因:发件箱对单封组**既画组头又画行**——
this.SentGroupHeader(g); if (isFlatGroup(g)) { this.SentRow(...) }
组头那半张卡没有任何点击处理 ⇒ 点卡片上半部完全无反应。
而 isFlatGroup 自己的注释写着「单封不成组:套一个可折叠的组头只是
多一次点击」—— 实现与注释**直接相反**。收件箱一直是正确形状
(if (isFlatGroup) { MailRow } else { 组头 + 子行 })。
修:与收件箱取同形,单封组只画 SentRow。
④ 「通信页面我觉得没有 webui 那么通透」
这是我自己上一轮改错的:把 WebUI 的「页签条无背景」实现成了
`backgroundColor(Theme.surface)` 实心白。WebUI 的真实层叠是
.comm-pane 是玻璃卡、CommTabs 在它内部且**自己无背景**(透出卡的白)。
铺实心白 = 把玻璃卡换成横条白 ⇒ 壁纸再也透不过来。
⇒ 改回玻璃族(Theme.navMaterial,与底部导航条同档)+ 通栏 +
只左上圆角(右上 0,与窗格那道弧重合)+ 底边线。
判据 harmony-nav.test.mjs:732 在我改错时当场判红,是它先抓到的。
判据(变异验证:还原 bug → 必须判红)
- 新增「单封组不许既画组头又画行」:两栏的 (header, row) 对必须在
else/三目里二选一。
★ 第一版写弱了(用 isFlatGroup(g) 作锚点往后切片,组头在切片之前
⇒ 变异测不红)。改成以**行调用**作锚点往两边开窗后,删掉修复
即判红(实测已验)。
- 新增「列表行必须把 onClick 挂在自己身上」(MailRow/SentRow)。
- harmony-logic 34→37、harmony-nav 22(新增后仍绿)、
harmony-appearance 27→28、animation-audit 12→15 的登记数同步。
设备复验(全部有实测凭据,不是推断)
- 自动已读:点开前 unread → 点开 4s 后服务端 read(连验两封)。
列表组头 4→2、侧栏徽标 4→2,三处数字一致。
- 实时收信:App 保持前台不重启,从 gateway 发信 ⇒ 8s 内自动出现
(新卡片 + 侧栏 2→3 + 页签 2→3 + 3 组 8 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
337 lines
11 KiB
Plaintext
337 lines
11 KiB
Plaintext
/*
|
||
* AgentMail 鸿蒙客户端 — SSE 多账号实时推送服务
|
||
* 每个账号各建一条 SSE 连接(各带自己的 user_key)
|
||
* AccountManager 维护连接集合,按 accountId 分发事件
|
||
*/
|
||
import { http } from '@kit.NetworkKit';
|
||
import { BusinessError } from '@kit.BasicServicesKit';
|
||
import { hilog } from '@kit.PerformanceAnalysisKit';
|
||
/* UTF-8 解码用(见 `arrayBufferToString` 的注释:逐字节 fromCharCode 会把中文解成乱码) */
|
||
import { util } from '@kit.ArkTS';
|
||
import { AccountManager, AccountInfo } from './AccountManager';
|
||
import { normalizeApiBase } from '../model/ApiBase';
|
||
|
||
const DOMAIN = 0x0001;
|
||
const TAG = 'SseService';
|
||
|
||
/** SSE 事件数据 */
|
||
export class SseEvent {
|
||
type: string = '';
|
||
data: string = '';
|
||
accountId: string = '';
|
||
}
|
||
|
||
/** SSE 回调 */
|
||
export type SseListener = (event: SseEvent) => void;
|
||
|
||
/** SSE 连接状态 */
|
||
export type SseStatus = 'disconnected' | 'connecting' | 'connected';
|
||
|
||
/** SSE 状态回调 */
|
||
export type SseStatusListener = (status: SseStatus) => void;
|
||
|
||
/** 单个账号的 SSE 连接状态(纯数据) */
|
||
export class SseConnection {
|
||
accountId: string = '';
|
||
server: string = '';
|
||
token: string = '';
|
||
httpRequest: http.HttpRequest | null = null;
|
||
listeners: SseListener[] = [];
|
||
status: SseStatus = 'disconnected';
|
||
reconnectTimer: number = 0;
|
||
buffer: string = '';
|
||
connected: boolean = false;
|
||
/*
|
||
* ★★ 2026-09-24:**每连接一个** 解码器(不要共享)。
|
||
* `decodeToString(..., {stream:true})` 会把「被 TCP 分片切断的半个汉字」
|
||
* 存在**该解码器实例内部**,等下一片到了再拼。多个连接共用同一个实例
|
||
* 就会把一个账号的半个字与另一个账号的半截拼在一起 ⇒ 乱码。
|
||
*/
|
||
decoder: util.TextDecoder | null = null;
|
||
}
|
||
|
||
export class SseService {
|
||
private static instance: SseService | null = null;
|
||
private connections: Map<string, SseConnection> = new Map();
|
||
private globalListeners: SseListener[] = [];
|
||
private globalStatusListeners: SseStatusListener[] = [];
|
||
|
||
static getInstance(): SseService {
|
||
if (SseService.instance === null) {
|
||
SseService.instance = new SseService();
|
||
}
|
||
return SseService.instance;
|
||
}
|
||
|
||
private constructor() {}
|
||
|
||
/** 添加全局事件监听(所有账号的事件都会收到) */
|
||
addListener(listener: SseListener): void {
|
||
this.globalListeners.push(listener);
|
||
}
|
||
|
||
/** 移除全局事件监听 */
|
||
removeListener(listener: SseListener): void {
|
||
const idx: number = this.globalListeners.indexOf(listener);
|
||
if (idx >= 0) {
|
||
this.globalListeners.splice(idx, 1);
|
||
}
|
||
}
|
||
|
||
/** 添加全局状态监听 */
|
||
addStatusListener(listener: SseStatusListener): void {
|
||
this.globalStatusListeners.push(listener);
|
||
}
|
||
|
||
/** 移除全局状态监听 */
|
||
removeStatusListener(listener: SseStatusListener): void {
|
||
const idx: number = this.globalStatusListeners.indexOf(listener);
|
||
if (idx >= 0) {
|
||
this.globalStatusListeners.splice(idx, 1);
|
||
}
|
||
}
|
||
|
||
/** 为指定账号建立 SSE 连接 */
|
||
connectForAccount(accountId: string, server: string, token: string): void {
|
||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||
if (conn !== undefined && conn.connected) {
|
||
hilog.info(DOMAIN, TAG, 'account %{public}s already connected', accountId);
|
||
return;
|
||
}
|
||
|
||
if (conn === undefined) {
|
||
conn = new SseConnection();
|
||
conn.accountId = accountId;
|
||
this.connections.set(accountId, conn);
|
||
}
|
||
|
||
conn.server = normalizeApiBase(server);
|
||
conn.token = token;
|
||
conn.connected = true;
|
||
this.setConnStatus(conn, 'connecting');
|
||
this.doConnect(conn);
|
||
}
|
||
|
||
/** 断开指定账号的 SSE 连接 */
|
||
disconnectAccount(accountId: string): void {
|
||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||
if (conn === undefined) {
|
||
return;
|
||
}
|
||
conn.connected = false;
|
||
if (conn.reconnectTimer !== 0) {
|
||
clearTimeout(conn.reconnectTimer);
|
||
conn.reconnectTimer = 0;
|
||
}
|
||
if (conn.httpRequest !== null) {
|
||
conn.httpRequest.destroy();
|
||
conn.httpRequest = null;
|
||
}
|
||
this.setConnStatus(conn, 'disconnected');
|
||
this.connections.delete(accountId);
|
||
}
|
||
|
||
/** 断开所有连接 */
|
||
disconnectAll(): void {
|
||
const keys: string[] = [];
|
||
this.connections.forEach((_conn: SseConnection, key: string) => {
|
||
keys.push(key);
|
||
});
|
||
for (let i = 0; i < keys.length; i++) {
|
||
this.disconnectAccount(keys[i]);
|
||
}
|
||
}
|
||
|
||
/** 根据 AccountManager 连接所有账号 */
|
||
async connectAll(acctMgr: AccountManager): Promise<void> {
|
||
await acctMgr.load();
|
||
const accounts: AccountInfo[] = acctMgr.getAccounts();
|
||
for (let i = 0; i < accounts.length; i++) {
|
||
const acct: AccountInfo = accounts[i];
|
||
this.connectForAccount(acct.id, acct.server, acct.token);
|
||
}
|
||
}
|
||
|
||
/** 获取指定账号的连接状态 */
|
||
getStatusForAccount(accountId: string): SseStatus {
|
||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||
if (conn === undefined) {
|
||
return 'disconnected';
|
||
}
|
||
return conn.status;
|
||
}
|
||
|
||
/** 给指定账号添加事件监听 */
|
||
addListenerForAccount(accountId: string, listener: SseListener): void {
|
||
let conn: SseConnection | undefined = this.connections.get(accountId);
|
||
if (conn === undefined) {
|
||
conn = new SseConnection();
|
||
conn.accountId = accountId;
|
||
this.connections.set(accountId, conn);
|
||
}
|
||
conn.listeners.push(listener);
|
||
}
|
||
|
||
/** 移除指定账号的事件监听 */
|
||
removeListenerForAccount(accountId: string, listener: SseListener): void {
|
||
const conn: SseConnection | undefined = this.connections.get(accountId);
|
||
if (conn !== undefined) {
|
||
const idx: number = conn.listeners.indexOf(listener);
|
||
if (idx >= 0) {
|
||
conn.listeners.splice(idx, 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
private setConnStatus(conn: SseConnection, status: SseStatus): void {
|
||
if (conn.status !== status) {
|
||
conn.status = status;
|
||
hilog.info(DOMAIN, TAG, 'status[%{public}s]: %{public}s', conn.accountId, status);
|
||
for (let i = 0; i < this.globalStatusListeners.length; i++) {
|
||
this.globalStatusListeners[i](status);
|
||
}
|
||
}
|
||
}
|
||
|
||
private doConnect(conn: SseConnection): void {
|
||
if (!conn.connected) {
|
||
return;
|
||
}
|
||
|
||
const httpRequest = http.createHttp();
|
||
conn.httpRequest = httpRequest;
|
||
/* 重连要换新的:旧解码器里可能还卡着上次断线时那半个字 */
|
||
conn.decoder = util.TextDecoder.create('utf-8', { ignoreBOM: true });
|
||
|
||
const url: string = conn.server + '/events/stream';
|
||
const header: Record<string, string> = {};
|
||
if (conn.token.length > 0) {
|
||
header['Authorization'] = 'Bearer ' + conn.token;
|
||
}
|
||
|
||
hilog.info(DOMAIN, TAG, 'connecting account %{public}s to %{public}s', conn.accountId, url);
|
||
|
||
httpRequest.on('dataReceive', (data: ArrayBuffer) => {
|
||
const text: string = this.arrayBufferToString(conn, data);
|
||
conn.buffer += text;
|
||
this.processBuffer(conn);
|
||
});
|
||
|
||
httpRequest.on('dataEnd', () => {
|
||
hilog.info(DOMAIN, TAG, 'dataEnd for account %{public}s', conn.accountId);
|
||
this.setConnStatus(conn, 'disconnected');
|
||
this.scheduleReconnect(conn);
|
||
});
|
||
|
||
httpRequest.on('headersReceive', (_headers: Object) => {
|
||
hilog.info(DOMAIN, TAG, 'headers received for account %{public}s', conn.accountId);
|
||
});
|
||
|
||
const options: http.HttpRequestOptions = {
|
||
method: http.RequestMethod.GET,
|
||
header: header,
|
||
connectTimeout: 10000,
|
||
readTimeout: 0
|
||
};
|
||
|
||
httpRequest.requestInStream(url, options, (err: BusinessError, code: number) => {
|
||
if (err !== undefined && err !== null) {
|
||
hilog.error(DOMAIN, TAG, 'requestInStream error account %{public}s: %{public}s', conn.accountId, err.message);
|
||
this.setConnStatus(conn, 'disconnected');
|
||
this.scheduleReconnect(conn);
|
||
return;
|
||
}
|
||
hilog.info(DOMAIN, TAG, 'requestInStream code=%{public}d account=%{public}s', code, conn.accountId);
|
||
if (code === 200) {
|
||
this.setConnStatus(conn, 'connected');
|
||
} else {
|
||
hilog.error(DOMAIN, TAG, 'SSE failed code=%{public}d account=%{public}s', code, conn.accountId);
|
||
this.setConnStatus(conn, 'disconnected');
|
||
this.scheduleReconnect(conn);
|
||
}
|
||
});
|
||
}
|
||
|
||
private processBuffer(conn: SseConnection): void {
|
||
const lines: string[] = conn.buffer.split('\n');
|
||
conn.buffer = lines.pop() ?? '';
|
||
|
||
let eventType: string = '';
|
||
let eventData: string = '';
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line: string = lines[i];
|
||
|
||
if (line.length === 0) {
|
||
if (eventType.length > 0 || eventData.length > 0) {
|
||
const event: SseEvent = new SseEvent();
|
||
event.type = eventType.length > 0 ? eventType : 'message';
|
||
event.data = eventData;
|
||
event.accountId = conn.accountId;
|
||
this.dispatchEvent(conn, event);
|
||
}
|
||
eventType = '';
|
||
eventData = '';
|
||
} else if (line.startsWith('event:')) {
|
||
eventType = line.substring(6).trim();
|
||
} else if (line.startsWith('data:')) {
|
||
const newData: string = line.substring(5).trim();
|
||
if (eventData.length > 0) {
|
||
eventData += '\n' + newData;
|
||
} else {
|
||
eventData = newData;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private dispatchEvent(conn: SseConnection, event: SseEvent): void {
|
||
hilog.info(DOMAIN, TAG, 'event[%{public}s]: %{public}s data: %{public}s', conn.accountId, event.type,
|
||
event.data.substring(0, 100));
|
||
// 分发给账号级监听
|
||
for (let i = 0; i < conn.listeners.length; i++) {
|
||
conn.listeners[i](event);
|
||
}
|
||
// 分发给全局监听
|
||
for (let i = 0; i < this.globalListeners.length; i++) {
|
||
this.globalListeners[i](event);
|
||
}
|
||
}
|
||
|
||
private scheduleReconnect(conn: SseConnection): void {
|
||
if (!conn.connected) {
|
||
return;
|
||
}
|
||
const timer: number | undefined = setTimeout(() => {
|
||
conn.reconnectTimer = 0;
|
||
this.doConnect(conn);
|
||
}, 3000);
|
||
conn.reconnectTimer = timer ?? 0;
|
||
}
|
||
|
||
private arrayBufferToString(conn: SseConnection, buffer: ArrayBuffer): string {
|
||
/*
|
||
* ★★ 2026-09-24 修:**UTF-8 解码**(用户:「鸿蒙 app 接收邮件的能力也有点不正常」)。
|
||
*
|
||
* 原来这里是逐字节 `String.fromCharCode(uint8Array[i])` —— 那是
|
||
* **Latin-1 语义**:字节 ≥ 0x80 各自变成一个独立字符。
|
||
* SSE 流里一个汉字是 3 个 UTF-8 字节(如 「新」 = E6 96 B0)
|
||
* ⇒ 解出来是 `æ\x96°` 这种乱码。
|
||
*
|
||
* WebUI 没这个问题:它用浏览器原生 `EventSource`,规范本身按 UTF-8
|
||
* 解码(`client/electron/src/api/sse.ts:59`)。
|
||
* ⇒ 两端差在**解码这一步**,不是差在有没有 SSE。
|
||
*
|
||
* ★ 为什么不用 `util.TextDecoder`:HarmonyOS 有 `@kit.ArkTS` 的
|
||
* `util.TextDecoder`(标准 API),比自己手写 UTF-8 状态机可靠得多
|
||
* —— 手写容易在「3 字节序列被 TCP 分片切成两段」时错(SSE 常见)。
|
||
* `stream: true` 正是为这种分段场景准备的:不完整的尾字节会
|
||
* 留在内部缓冲里,等下一片到了再拼。
|
||
*/
|
||
const decoder: util.TextDecoder = conn.decoder !== null
|
||
? conn.decoder
|
||
: util.TextDecoder.create('utf-8', { ignoreBOM: true });
|
||
conn.decoder = decoder;
|
||
return decoder.decodeToString(new Uint8Array(buffer), { stream: true });
|
||
}
|
||
} |