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:
@ -14,6 +14,8 @@ declare global {
|
||||
interface Window {
|
||||
__AGENTMAIL_API_BASE__?: string;
|
||||
__AGENTMAIL_TOKEN__?: string;
|
||||
/** 宿主壳标识;Electron preload 会设成 'desktop'。见 components/LoginPage 的说明 */
|
||||
__AGENTMAIL_SHELL__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user