Files
MailUI4Agents/client/electron/BUILD.md
JianFeeeee 8b2206ed53 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`)。
2026-09-12 20:25:00 +08:00

158 lines
7.1 KiB
Markdown
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.

# 桌面安装包构建
生成 Linux / Windows 安装包。日常开发只需 `npm run build`(出 web 产物),
本文只讲**分发用安装包**。
## 命令
```bash
npm run build:linux # AppImage + deb
npm run build:win # NSIS 安装器
```
产物在 `release/`(已在 `.gitignore` 里,不要提交 —— AppImage 约 122MB、deb 约 100MB
> ⚠️ **安装包里嵌的是前端的一份快照**(构建时的 `dist/`)。
> 因此**任何前端改动(组件、样式、主题、背景)之后都必须重打安装包**
> 否则 `release/` 里那份会静默地是旧界面 —— 而它本身不会报错,
> 只有装上去的人会看到与 Web 不一致的样子。
>
> 自查(不需要真正安装):直接查包内 asar 里的 CSS 有没有你刚加的东西:
>
> ```bash
> node -e "const a=require('@electron/asar');const l=a.listPackage('release/linux-unpacked/resources/app.asar');
> const c=l.find(p=>/\\/dist\\/assets\\/index-.*\\.css$/.test(p));
> console.log(c, a.extractFile('release/linux-unpacked/resources/app.asar', c.replace(/^\\//,'')).toString().includes('--bg-scrim'))"
> ```
## 本机环境的两个坑
### 1. `SHASUMS256.txt` 不可达 → 构建会卡 10 分钟后失败
现象:
```
• downloaded label=electron progress=100%
Timeout awaiting 'request' for 600000ms failedTask=build
```
**不是构件下载失败**。实测该网络下:
| URL | 结果 |
|---|---|
| `…/releases/download/v44.2.0/electron-v44.2.0-linux-x64.zip` | HTTP 2060.8s ✅ |
| `…/releases/download/v44.2.0/SHASUMS256.txt` | 直连 20s 超时、走 Clash 代理也失败 ❌ |
`@electron/get` 即使命中本地缓存,也会去取 `SHASUMS256.txt` 校验,于是卡死。
**绕过**(命令行覆盖,不要写进 `package.json`
```bash
npx electron-builder --linux -c.electronDownload.isVerifyChecksum=false
```
**为什么不写进配置**:那会让今后**每一次**构建都跳过完整性校验。一次性绕过
网络限制不该固化成永久弱化的默认值。关校验后,被篡改的镜像可以在 TLS 之外替换
构件而无人察觉 —— 本场景可接受(构件来自官方 GitHub 的 TLS 连接,且本地缓存
已验证可读),但不该成为默认。
### 2. 依赖缓存齐全,可离线完成
`~/.cache/electron-builder/` 下以下条目的 `.state` 均为 `complete` 即可离线:
`appimage-12.0.1`AppImage 运行时)、`fpm@2.1.4`deb`7zip@1.0.0`
`nsis-resources-3.4.1` + `nsis-3.0.4.1`Windows 安装器)。
Electron 二进制在 `~/.cache/electron/`(首次需要网络,`electron-v44.2.0-linux-x64.zip` 约 123MB
## package.json 里与打包有关的元数据
这些字段缺失会让某个 target 直接失败,报错信息不一定指向字段本身:
| 字段 | 位置 | 不填的后果 |
|---|---|---|
| `description` | 根 | deb 描述为空 |
| `author`(含 email | 根 | deb 缺 maintainer 信息 |
| `homepage` | 根 | **deb 直接构建失败**`Please specify project homepage` |
| `desktopName` | 根 | 窗口无法与 `.desktop` 关联(`StartupWMClass` 缺失) |
| `linux.syncDesktopName` | `build.linux` | 同上,配合 `desktopName` 一起用 |
| `linux.synopsis` | `build.linux` | deb 描述只有一行 |
> ⚠️ 当前 `author.email` 是容器占位值 `jianf@noreply.localhost`
> `homepage` 是内网 Gitea 地址。**正式对外分发前必须替换成真实值。**
## 两个坑:白屏与登录(都是静默的)
### 1. 白屏 —— `vite.config.ts` 必须写 `base: './'`
同一份 `dist/` 有两个宿主:网关在 `/` 下伺服它WebElectron 用
`loadFile()`**`file:///…/dist/index.html`** 加载它(桌面)。
Vite 的默认 base 是 `/`,产物里写的是 `src="/assets/index-xxx.js"` ——
`file://` 下它会解析成 `file:///assets/index-xxx.js`(不存在),
**JS 根本没加载**
现场非常不显眼:进程活着、窗口标题是 `AgentMail`、CDP 连得上、
**``#root`` 里一个子节点都没有**。没有报错对话框,控制台里只有一条
不起眼的资源加载失败。
改回绝对路径的后果是桌面端直接不可用,而 `npm run build`、deb 元数据检查、
asar 内容清点**全都是绿的**(它们只看文件在不在,不看文件引用什么)。
所以有一条结构性断言把它钉住(进 `npm test`
```bash
node --test test/packaging.test.mjs
```
它验三件事:`vite.config.ts` 里的 `base` 是相对的;产物里没有绝对资源引用;
**安装包里的 dist 与当前构建一致**(前端改了没重打包时,装上去的人看到的是旧界面,
两边不一致但谁都不报错)。
### 2. 桌面壳不能用账号密码登录(会话 Cookie 存不下来)
账号密码登录靠 `SameSite=Lax` 的会话 Cookie而桌面壳的页面是 `file://`
**不透明源**)—— Chromium 按第三方上下文处理它,**Cookie 不予存储**。
实测现场:`POST /auth/login` 返回 **200**、响应体能读出用户名,
但 `document.cookie` 是空的,紧接着的 `/auth/me` 返回 **401**
界面停在登录页,看起来像「密码错了」,而同样的账号密码用 curl 登录是成功的。
所以桌面壳里**不提供**账号密码表单(一个必然失败的按钮比没有更糟),
改成粘贴**用户密钥**`Authorization: Bearer`
```bash
# 启动时注入(推荐:脚本化部署)
AGENTMAIL_USER_KEY=<用户密钥> ./agentmail-web
```
也可以用主进程环境变量 `AGENTMAIL_GATEWAY_URL` 指向别的网关。
用户密钥在网页版的「账号 → 用户密钥」里创建,`electron/main.cjs` 把它经 preload
注入成 `__AGENTMAIL_TOKEN__``src/api/config.ts` 从此对**所有**请求(含 SSE 与附件下载)
自动带上。
## 两个产物的实质区别(实测)
| | AppImage | deb |
|---|---|---|
| `.desktop` 的 Exec | `AppRun --no-sandbox %U` | `/opt/AgentMail/agentmail-web %U` |
| Chromium 沙箱 | **禁用** —— squashfs 里无法保留 setuid 的 `chrome-sandbox` | **启用** —— postinst 按能力决定:能跑用户命名空间则 `0755`,否则设 `4755`;并安装 AppArmor 配置Ubuntu 24+ 需要) |
| 卸载 | 删文件即可 | postrm 清理 alternatives、AppArmor 配置、desktop/mime 数据库 |
**要给用户装,优先分发 deb**:沙箱可用、依赖声明完整、可被包管理器卸载。
## 验证产物的方法(别只看文件存在)
```bash
# deb 元数据与依赖
dpkg-deb --info release/*.deb
# deb 内容布局(含 chrome-sandbox 权限、desktop 文件、图标尺寸)
dpkg-deb --contents release/*.deb | grep -E '\.desktop|chrome-sandbox|app\.asar'
# postinst 的沙箱策略
dpkg-deb --control release/*.deb /tmp/ctl && cat /tmp/ctl/postinst
# AppImage不依赖 FUSE 解包并看载荷
./release/AgentMail-0.1.0.AppImage --appimage-extract
ls squashfs-root/resources/app.asar
# 确认 asar 里是真正构建的应用(而不是空壳)
node -e "const a=require('@electron/asar');console.log(a.listPackage('release/linux-unpacked/resources/app.asar').filter(p=>/^\/(dist|electron)\//.test(p)).join('\n'))"
```