fix(electron): Phase 3 验收抓到的两个静默缺陷 —— 白屏与登录

Phase 3(写信 + 附件 + 权限面板)的验收脚本第一次跑就把这两件事翻出来了,
两个都**表现正常**:进程活着、窗口标题对、接口能通,只有结果不对。

## 1. 打包后的应用是白屏(vite 的 base 缺省值)

`vite.config.ts` 没设 `base`,Vite 按默认的 `/` 生成 `src="/assets/index-xxx.js"`。
同一份 dist 有两个宿主:网关在 `/` 下伺服它(Web 正常),Electron 用 `loadFile()`
从 **file:///…/dist/index.html** 加载它 —— 绝对路径在那儿解析成
`file:///assets/index-xxx.js`(不存在),**JS 根本没加载**。

现场:`#root` 里一个子节点都没有。没有报错对话框,控制台里只有一条不起眼的
资源加载失败。而当时所有既有检查都是绿的:`npm run build` 成功、deb 元数据检查、
asar 内容清点(**它们只看文件在不在,不看文件引用什么**)。

修法:`base: './'` —— 两边都对(Web 在 /index.html 里 `./assets/x.js` → `/assets/x.js`;
Electron 在 dist/index.html 里 → `dist/assets/x.js`)。

## 2. 桌面壳用账号密码登录是断的,而且静默失败

账号密码登录靠 `SameSite=Lax` 的会话 Cookie,而桌面壳的页面是 `file://`
(**不透明源**)—— Chromium 按第三方上下文处理它,Cookie **不予存储**。

实测现场:`POST /auth/login` 返 **200**、响应体能读出用户名,但 `document.cookie`
是空的,紧接着的 `/auth/me` 返 **401**;界面停在登录页,看起来像「密码错了」,
而同样的账号密码用 curl 登录是成功的。所以这不是凭据问题。

修法:桌面壳里**不再给账号密码表**(一个必然失败的按钮比没有更糟),改成粘贴
**用户密钥**(`Authorization: Bearer`,桌面端本来就该这么用):
- preload 显式声明 `__AGENTMAIL_SHELL__ = 'desktop'`(宿主契约,而不是让渲染层
  sniff 协议;顺带让 jsdom 里可测 —— 那里的 `location.protocol` 不可重写)
- 新增 `authStore.loginWithKey`:成功后才留下令牌,失败**还原**(否则之后每个请求
  都会带上这个坏 key 并 401,而人看到的是「重输一次也不行」)
- 顺手修了 label 与 input 没有关联(`htmlFor`/`id`)—— 无障碍缺陷,也让测试能按标签查

## 验收

- 结构性守卫进 `npm test`(`test/packaging.test.mjs`,不需要浏览器):base 必须是
  相对路径、产物里不能有绝对资源引用、**安装包里的 dist 与当前构建一致**
  (前端改了没重打包时,装上去的人看到的是旧界面,两边不一致却谁都不报错)。
  判据自检过:把 base 改回 `/` 或把产物改回绝对路径,各自都能让对应那条变红。
- 组件测试 6 条(两种壳的形态、密钥成功/失败、空密钥不可提交)。
- `test/manual/desktop-phase3-verify.mjs`:真起打包好的应用(xvfb + CDP),
  一条贯穿的链 —— 用桌面 UI 写信带附件 → 外部核验信与附件真到了网关 →
  这封信触发 zcode 的真实授权请求 → 在桌面**授权面板**里点同意 →
  外部核验 **Agent 真的执行了**(标记文件出现)。第二次跑 14/14 全绿。
- 客户端全量 222/222;网关换新产物后 Web 依旧正常(相对路径在 `/` 下同样成立,
  实测渲染出收件箱、无控制台错误),并真发一封邮件确认回信到达。

## 判据自己的错(记一笔)

第一次跑时「附件真的挂在信上」报红,而库里那 41 字节的附件**明明挂在信上** ——
我把端点写成了 `/me/mail/{id}`(不存在,404),正确是 `/mail/{id}`。
判据用错端点时以「附件是空的」现形,看起来像功能 bug。

另:`pkill -f 'agentmail-web'` 会把**自己这条命令**也杀掉(命令行里含同样的字符串),
表现是「脚本没有任何输出、退出码 143」。改用端口定位(`ss -tlnp | grep :9223`)。
This commit is contained in:
2026-09-12 20:25:00 +08:00
parent d015d3694c
commit 8b2206ed53
11 changed files with 821 additions and 31 deletions

View File

@ -14,6 +14,8 @@ declare global {
interface Window {
__AGENTMAIL_API_BASE__?: string;
__AGENTMAIL_TOKEN__?: string;
/** 宿主壳标识Electron preload 会设成 'desktop'。见 components/LoginPage 的说明 */
__AGENTMAIL_SHELL__?: string;
}
}

View File

@ -2,15 +2,35 @@ import { useEffect, useRef, useState } from 'react';
import { useAuthStore } from '../stores/authStore';
import { BrandMarkIcon, SpinnerIcon } from './icons';
/**
* 当前是不是桌面壳。
*
* 它决定了**能不能用账号密码登录**:那条路靠 `SameSite=Lax` 的会话 Cookie
* 而 `file://` 是不透明源Chromium 按第三方上下文处理它、不予存储
* (实测:`/auth/login` 返 200但 `document.cookie` 为空,接着 `/auth/me` 401
* 详见 `stores/authStore.ts` 的 `loginWithKey` 注释。
*
* 主路径是宿主显式声明的 `__AGENTMAIL_SHELL__`Electron preload 设 'desktop'
* protocol 是兜底,涵盖「有人直接把 dist 用 file:// 打开」这件事。
*/
function isDesktopShell(): boolean {
if (typeof window === 'undefined') return false;
if (window.__AGENTMAIL_SHELL__ === 'desktop') return true;
return window.location?.protocol === 'file:';
}
export default function LoginPage() {
const login = useAuthStore(s => s.login);
const loginWithKey = useAuthStore(s => s.loginWithKey);
const error = useAuthStore(s => s.error);
const retryAfter = useAuthStore(s => s.retryAfter);
const submitting = useAuthStore(s => s.submitting);
const clearError = useAuthStore(s => s.clearError);
const desktop = isDesktopShell();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [userKey, setUserKey] = useState('');
const [countdown, setCountdown] = useState(0);
const userRef = useRef<HTMLInputElement>(null);
@ -36,11 +56,18 @@ export default function LoginPage() {
}, [retryAfter]);
const locked = countdown > 0;
const canSubmit = username.trim() !== '' && password !== '' && !submitting && !locked;
const canSubmit = desktop
? userKey.trim() !== '' && !submitting
: username.trim() !== '' && password !== '' && !submitting && !locked;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
if (desktop) {
const ok = await loginWithKey(userKey);
if (!ok) setUserKey('');
return;
}
const ok = await login(username, password);
if (!ok) setPassword('');
};
@ -62,35 +89,70 @@ export default function LoginPage() {
<p className="mt-1 text-xs text-gray-500"></p>
</div>
<form onSubmit={submit} className="space-y-3">
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input
ref={userRef}
value={username}
onChange={e => {
setUsername(e.target.value);
if (error) clearError();
}}
autoComplete="username"
spellCheck={false}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<form onSubmit={submit} className="space-y-3" data-shell={desktop ? 'desktop' : 'browser'}>
{/*
桌面壳不给账号密码:那条路在这里**注定失败**(见 authStore.loginWithKey
的注释file:// 是不透明源SameSite=Lax 的会话 Cookie 存不下来)。
给一个必然失败的按钮比不给更糟 —— 人会以为是自己密码打错了。
*/}
{desktop ? (
<>
<div>
<label htmlFor="login-user-key" className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input
id="login-user-key"
ref={userRef}
type="password"
value={userKey}
onChange={e => {
setUserKey(e.target.value);
if (error) clearError();
}}
placeholder="在网页版「账号」页创建,或启动时用 AGENTMAIL_USER_KEY 注入"
autoComplete="off"
spellCheck={false}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
<p className="mt-1 text-3xs text-gray-500 leading-relaxed">
Cookie file://)下不会被保存。
</p>
</div>
</>
) : (
<>
<div>
<label htmlFor="login-username" className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input
id="login-username"
ref={userRef}
value={username}
onChange={e => {
setUsername(e.target.value);
if (error) clearError();
}}
autoComplete="username"
spellCheck={false}
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
if (error) clearError();
}}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
<div>
<label htmlFor="login-password" className="block text-2xs font-medium text-gray-500 mb-1"></label>
<input
id="login-password"
type="password"
value={password}
onChange={e => {
setPassword(e.target.value);
if (error) clearError();
}}
autoComplete="current-password"
className="w-full text-sm border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
</div>
</>
)}
{error && (
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
@ -105,7 +167,7 @@ export default function LoginPage() {
className="w-full inline-flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
{submitting && <SpinnerIcon className="w-3.5 h-3.5" />}
{submitting ? '登录中' : locked ? `已锁定 ${countdown}s` : '登录'}
{submitting ? '登录中' : desktop ? '进入' : locked ? `已锁定 ${countdown}s` : '登录'}
</button>
</form>
</div>

View File

@ -14,6 +14,27 @@ interface AuthState {
bootstrap: () => Promise<void>;
login: (username: string, password: string) => Promise<boolean>;
/**
* 桌面壳file://)专用:用用户密钥登录。
*
* # 为什么桌面端不能用账号密码
*
* 账号密码登录靠**会话 Cookie**,而网关下发的是 `SameSite=Lax`。
* 桌面客户端用 `loadFile()` 从 `file:///…/dist/index.html` 加载页面,
* 那是个**不透明源**`location.origin === 'file://'`Chromium 按第三方上下文
* 处理它,`SameSite=Lax` 的 Cookie **不予存储**。
*
* 实测的现场:`POST /auth/login` 返回 200、响应体能读出用户名
* 但 `document.cookie` 是空的,紧接着的 `/auth/me` 返回 401 ——
* 于是界面停在登录页,看起来像「密码错了」,而密码是对的。
* (外部用 curl 拿同样的账号密码登录是成功的,所以这不是凭据问题。)
*
* 桌面端的本该用法是**用户密钥**`Authorization: Bearer`
* `electron/main.cjs` 会把 `AGENTMAIL_USER_KEY` / `AGENTMAIL_TOKEN`
* 通过 preload 注入成 `__AGENTMAIL_TOKEN__`,这条路径实测完全可用。
* 这个方法给「没注入环境变量、人手工粘一个 key」的情况用。
*/
loginWithKey: (key: string) => Promise<boolean>;
logout: () => Promise<void>;
clearError: () => void;
/** 401 时由 api client 回调 */
@ -59,6 +80,26 @@ export const useAuthStore = create<AuthState>(set => ({
set({ phase: 'anonymous', user: null, error: null });
},
loginWithKey: async key => {
const trimmed = key.trim();
if (!trimmed) return false;
set({ submitting: true, error: null, retryAfter: null });
const previous = api.getToken();
api.setToken(trimmed);
try {
const { user } = await api.me();
set({ phase: 'authenticated', user, submitting: false });
return true;
} catch (err) {
// 失败要把令牌还原:留着一个错的 key 在内存里,会让**之后每一个**请求
// 都带上它并 401而人看到的却是「密钥不对」之后的一次正常登录尝试也失败。
api.setToken(previous);
const msg = err instanceof Error ? err.message : String(err);
set({ error: `密钥不可用:${msg}`, submitting: false });
return false;
}
},
clearError: () => set({ error: null, retryAfter: null }),
markAnonymous: () => set({ phase: 'anonymous', user: null })