feat(electron): 多账号第一纵切 —— 账号存储/选择器/聚合收件箱
按 docs/MULTI-ACCOUNT-PLAN.md 实现客户端多账号的前半段(SSE 多连接与 写信账号切换留作下一轮)。 - `src/lib/accounts.ts`:纯逻辑(地址规范化、身份判重、默认账号、聚合合并), 16 条测试钉住每条判据(含反向对照)。 - 持久化在主进程:`userData/accounts.json`,**原子写**(临时文件 + rename)+ 0600。不落 localStorage:那份存储渲染层任何脚本都可读,且 file:// 与 http:// 是两套。无 IPC 时(浏览器)退到 localStorage 并在界面**如实写明**。 - 取信:单账号走原路径(逐字节不变);聚合时**每账号各一次请求、各带自己的 令牌**(`fetchWithAuth`,不碰认证单例,避免并发串号)。 - ★ 只合并**同一网关**的账号:跨网关的邮件混进列表后点开会去问当前账号的 服务器(404,或 mail_id 撞上就打开了别人的信)。如实排除 + 列表上方说明。 - ★ 部分失败可见:某账号取不到时给出账号名与原因 —— 静默丢掉它会让聚合列表 少一整份邮件而界面看起来完全正常。 - `API_BASE` 改为 `let`(切换账号要换网关),api 层不得缓存它 (`client.ts` 的 `const BASE` 快照已改成每次读)。 - UI:列表头下拉(≥2 个可用账号才出现「全部邮箱」)+ 账号徽标 + 账号页 「多账号」一段(添加前调 /auth/me 验证,401 当场拒绝,不写进列表)。 - 测试:vitest 230 通过(原 222 + 新 8)、`test/lib/accounts.test.mjs` 16 通过、 typecheck 通过。新增 `test/manual/multi-account-verify.mjs`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
This commit is contained in:
@ -14,6 +14,7 @@
|
||||
|
||||
const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain } = require('electron');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
// 开发模式:ELECTRON_START_URL 环境变量指向 vite dev server
|
||||
const DEV_URL = process.env.ELECTRON_START_URL || 'http://localhost:5173';
|
||||
@ -133,4 +134,72 @@ app.on('before-quit', () => {
|
||||
// IPC:渲染进程问主进程要 Gateway 地址(Phase 2 用)
|
||||
ipcMain.handle('get-gateway-url', () => {
|
||||
return process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180';
|
||||
});
|
||||
|
||||
// ─── 多账号持久化(docs/MULTI-ACCOUNT-PLAN.md 第二、三节)───────────────
|
||||
//
|
||||
// # 为什么放主进程而不是渲染进程的 localStorage
|
||||
//
|
||||
// 账号里有 **user_key**(永久凭据)。localStorage 是渲染进程的存储,
|
||||
// 一份 XSS 或一次误注入就能读走全部令牌;而且它在 file:// 与 http:// 下
|
||||
// 是两套存储,桌面端和网页端会各存一份、互相看不见。
|
||||
// 放主进程的文件里,渲染层只能通过这两个 IPC 拿到"当前账号列表",
|
||||
// 并且可以选择**不回传令牌**(见 load 的 revealToken 参数)。
|
||||
//
|
||||
// # 落盘方式
|
||||
//
|
||||
// 原子写(临时文件 + rename):写到一半断电/被杀,留下的要么是旧文件、
|
||||
// 要么是新文件,不会是半截 JSON —— 半截 JSON 会让下次启动把账号全丢。
|
||||
// 权限 0600:同机器上的其它用户读不到。
|
||||
// 目录用 app.getPath('userData'),与 Electron 自己的配置同处。
|
||||
function accountsFile() {
|
||||
return path.join(app.getPath('userData'), 'accounts.json');
|
||||
}
|
||||
|
||||
function readAccounts() {
|
||||
try {
|
||||
const raw = fs.readFileSync(accountsFile(), 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed?.accounts) ? parsed.accounts : [];
|
||||
} catch (e) {
|
||||
// 文件不存在是正常首次启动;解析失败则**不能静默当成空列表** ——
|
||||
// 那样下一次保存会用空列表覆盖掉用户的账号。
|
||||
if (e && e.code !== 'ENOENT') {
|
||||
console.error('[accounts] 读取失败(保留原文件,不覆盖):', e.message);
|
||||
throw e;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeAccounts(accounts) {
|
||||
const file = accountsFile();
|
||||
const tmp = `${file}.tmp-${process.pid}`;
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(tmp, JSON.stringify({ version: 1, accounts }, null, 2), { mode: 0o600 });
|
||||
fs.renameSync(tmp, file);
|
||||
try {
|
||||
fs.chmodSync(file, 0o600);
|
||||
} catch {
|
||||
/* 某些文件系统不支持,权限不是失败条件 */
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
ipcMain.handle('accounts:load', () => {
|
||||
try {
|
||||
return { ok: true, accounts: readAccounts(), file: accountsFile() };
|
||||
} catch (e) {
|
||||
return { ok: false, accounts: [], error: String(e?.message || e), file: accountsFile() };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('accounts:save', (_evt, accounts) => {
|
||||
if (!Array.isArray(accounts)) return { ok: false, error: 'accounts 必须是数组' };
|
||||
try {
|
||||
writeAccounts(accounts);
|
||||
return { ok: true, file: accountsFile() };
|
||||
} catch (e) {
|
||||
return { ok: false, error: String(e?.message || e), file: accountsFile() };
|
||||
}
|
||||
});
|
||||
@ -33,6 +33,18 @@ contextBridge.exposeInMainWorld('__AGENTMAIL_TOKEN__', getInjected('token') || u
|
||||
contextBridge.exposeInMainWorld('__AGENTMAIL_SHELL__', 'desktop');
|
||||
|
||||
contextBridge.exposeInMainWorld('agentmail', {
|
||||
/**
|
||||
* 多账号持久化(主进程落盘,见 main.cjs 的 accounts:* IPC)。
|
||||
*
|
||||
* 令牌经这条窄通道回到渲染层是必要的(要拿它发请求),但**不落
|
||||
* localStorage**:那份存储对渲染层的任何脚本都可读,而且在 file:// 与
|
||||
* http:// 下是两套、桌面端与网页端会各存一份互相看不见。
|
||||
*/
|
||||
accounts: {
|
||||
load: () => ipcRenderer.invoke('accounts:load'),
|
||||
save: accounts => ipcRenderer.invoke('accounts:save', accounts)
|
||||
},
|
||||
|
||||
/** Gateway 基础地址(主进程 env 或默认 127.0.0.1:8180) */
|
||||
gatewayUrl: () => ipcRenderer.invoke('get-gateway-url'),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user