Files
MailUI4Agents/client/harmony/entry/src/main/ets/api/SseService.ets
JianFeeeee 187609480f 跨端: 鸿蒙修收信/自动已读/发件箱点开 + 页签条通透(用户报的四个问题)
用户报了四个问题,逐个实测复现 → 定位根因 → 修 → 设备复验:

① 「邮件点进去自动已读的能力不正常」
   根因:鸿蒙只有「标记已读」按钮(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 封)。
- 发件箱点开:点原先点不动的卡片上半区 ⇒ 右栏出正文 + 蓝色选中态。
- 页签条:截图确认为玻璃通透(不再是实心白横条)。
2026-09-24 16:34:44 +08:00

337 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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 });
}
}