Files
MailUI4Agents/client/electron/electron/main.cjs
JianFeeeee addde97600 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`(真起打包产物 +
  两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
2026-09-13 06:16:59 +08:00

205 lines
6.7 KiB
JavaScript
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 Electron 主进程。
*
* 职责:
* - 创建 BrowserWindow 加载前端(dev=<ELECTRON_START_URL>,prod=../dist/index.html)
* - 系统托盘(Phase 4 完整实现)
* - 离线缓存(Phase 4)
* - 把 Gateway 地址与用户密钥注入渲染进程(Phase 2:让 client/electron/src 以独立客户端身份连任意 Gateway)
*
* 设计约束:
* - 渲染进程复用 client/electron/src 的 React 代码,零改动
* - 只通过 contextBridge 暴露必要 API,不放开 nodeIntegration
*/
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';
const isDev = !!process.env.ELECTRON_START_URL || !app.isPackaged;
// Gateway 地址与用户密钥:环境变量注入(桌面客户端用 user_key 认证)
const GATEWAY_URL = (process.env.AGENTMAIL_GATEWAY_URL || 'http://127.0.0.1:8180').replace(/\/+$/, '');
const API_BASE = `${GATEWAY_URL}/api/v1`;
const TOKEN = process.env.AGENTMAIL_TOKEN || process.env.AGENTMAIL_USER_KEY || '';
let mainWindow = null;
let tray = null;
/** 创建主窗口 */
function createMainWindow() {
// 通过 additionalArguments 把 API 基地址与 token 传给 preload(preload 从 renderer 的 process.argv 末尾读)
// 传入的是透传 arg,用户页面不能直接改,preload 能读到
const injectArgs = [`--agentmail-api-base=${API_BASE}`];
if (TOKEN) injectArgs.push(`--agentmail-token=${TOKEN}`);
mainWindow = new BrowserWindow({
width: 1280,
height: 800,
minWidth: 375,
minHeight: 640,
title: 'AgentMail',
icon: path.join(__dirname, '..', 'src', 'icons', 'tray-icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: injectArgs,
},
});
if (isDev) {
mainWindow.loadURL(DEV_URL);
} else {
mainWindow.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
}
// 点关闭按钮时隐藏到托盘而不是退出(Phase 4 行为,但用户要求进托盘,直接做)
mainWindow.on('close', (e) => {
if (!app.isQuitting) {
e.preventDefault();
mainWindow.hide();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
}
/** 系统托盘(Phase 4,这里是骨架:进托盘 + 重新显示) */
function createTray() {
// 用 16x16 像素的图标;缺文件时降级为 nativeImage.createEmpty()
let iconPath = path.join(__dirname, '..', 'src', 'icons', 'tray-icon.png');
let icon = nativeImage.createFromPath(iconPath);
if (icon.isEmpty()) {
icon = nativeImage.createEmpty();
}
tray = new Tray(icon);
tray.setToolTip('AgentMail');
const contextMenu = Menu.buildFromTemplate([
{
label: '打开 AgentMail',
click: () => {
if (!mainWindow) createMainWindow();
mainWindow.show();
mainWindow.focus();
},
},
{ type: 'separator' },
{
label: '退出',
click: () => {
app.isQuitting = true;
app.quit();
},
},
]);
tray.setContextMenu(contextMenu);
// 单击托盘图标显示窗口(Windows 常用行为)
tray.on('click', () => {
if (!mainWindow) createMainWindow();
mainWindow.show();
mainWindow.focus();
});
}
// Electron 生命周期
app.whenReady().then(() => {
createMainWindow();
createTray();
app.on('activate', () => {
// macOS 点击 Dock 图标重新建窗口
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
else if (mainWindow) mainWindow.show();
});
});
app.on('window-all-closed', () => {
// 托盘应用:关窗口不退出(macOS 惯例 + 用户要求的"进托盘"行为)
// 除非显式退出
});
app.on('before-quit', () => {
app.isQuitting = true;
});
// 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() };
}
});