按 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`(真起打包产物 + 两个真实账号,判据落在网络层:聚合必须每账号各一次请求且各带自己的令牌)。
205 lines
6.7 KiB
JavaScript
205 lines
6.7 KiB
JavaScript
/**
|
||
* 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() };
|
||
}
|
||
}); |