Files
MailUI4Agents/client/electron/BUILD.md
JianFeeeee 22c9be7181 跨端: 预设色板两套(pi:这不是"观感未验"而是机制上确定不同)+ 手写色清册跨文件 + TMPDIR 按会话分家 + 提交归属可判
pi 读完 `model/Wallpaper.ts` 后指出四处,全部处理。这一提交同时改了
`client/harmony/` 与 `client/electron/`(跨端改动),所以 subject 按新约定自报家门。

## 1 预设色板不随主题 —— **类别判错了:不是"未验",是机制上确定不同**

我上一版把"深色档预设"记成"观感未验"。pi 指出:WebUI 的 `.bg-preset-*` 写的是
`rgb(var(--c-blue-100))`,而 `--c-*` 在 `.dark` 里整体换了一套(blue-100 → `30 43 67`),
所以 **WebUI 的预设自动随主题变**;这边只有浅色那套 = 深色主题下"浅色渐变垫在深色系统表面之下",
正是这一整轮在治的病。**它不需要真机就能判**(机制写在代码里)—— 我把可判的东西
记成了"未验",这跟上一轮把"没做"写成"没验"是同一类错。

选 pi 倾向的那条(跟随主题,与 WebUI 一致):

- 色板两套:`LIGHT_*` 取 CSS `:root`、`DARK_*` 取 CSS `.dark`;`paletteFor(dark)` 选一套,
  `layersFor(id, dark)` 按主题出层;
- `isDarkMode(theme, systemColorMode)` 放在纯逻辑里:选了 dark/light 就照办,
  `system` 看系统当时的 `colorMode`(锚到 SDK:`COLOR_MODE_DARK = 0` / `COLOR_MODE_LIGHT = 1`;
  读不到按浅色,与 WebUI 的 `:root` 默认一致);
- 系统深浅从 `resourceManager.getConfigurationSync().colorMode` 读
  (`Context` 基类没有 `config`;`UIAbilityContext.config` 要转型;两个枚举取值一致,都核过 SDK);
- 判据:两套值与 `:root`/`.dark` **逐个相等**;每个预设的深浅两套**必须真的不同**
  (否则"两套"是抄了两遍);网格线色也要换;`isDarkMode` 五种输入。
- **未做**:运行期间改系统深浅色不会自动重算(要重进页面)——系统侧正确做法是订阅
  `applicationContext.on('environment', …)`,记在 §7.17b。

变异:`DARK_BLUE_100` 偏一位 → 红;`paletteFor` 永远返回浅色(= 我原来那个状态)→ 红;
`isDarkMode` 把系统深浅记反 → 红;页面把深浅写死成 false → 红。

## 2 手写色清册**跨文件按类扫**(原 A2 只保护 `Theme.ets`)

`Wallpaper.ts` 也有手写色。若对照是"按名字枚举"的,第 15 个色就会逃掉 ——
与 A2 要防的是同一件事,只是换了文件。现在一份清册按类扫:全 `ets/` 树里每个
`X: string = '#RRGGBB'` 都必须登记(Theme 的品牌/业务语义色,或预设色板 ——
后者常量名必须带 `LIGHT_`/`DARK_` 前缀,值由 CSS 两段比对负责)。反向也判清册过期。

变异:`Theme.ets` 加未登记色 → 红;`Wallpaper.ts` 加未登记色 → 红;
加一个"看着合规"的 `DARK_EXTRA` → 红。

## 3 `TMPDIR` 互踩(pi 提出)

这个 worktree 可能同时有多个 agent 跑构建,而 fpm 会把 291MB 的 `linux-unpacked`
**整份复制**进 `TMPDIR` —— 撞车就是随机的产物损坏。`whoami` 区分不开(大家都是 root),
所以按**会话**分家:`TMPDIR=$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}`
(进了 `npm run build:linux` 与 BUILD.md 的手敲命令;普通终端退化成"用户+PID")。

## 4 提交归属变成**跑判据就看得出来**(pi 给的形状)

新的 `test/commit-hygiene.test.mjs`:扫最近 40 条提交,**同时改两侧目录**的提交
必须在 subject 里自报家门(`跨端:`)。两条防腐:基线 = 该判据文件自己的引入提交
(**历史不改**,规则管从今往后);分类逻辑拿合成输入自检
(未标注的混合提交必须判红、标注过的不许红)——否则"解析没跑起来"时它会全绿。
变异:`COMMIT_HYGIENE_BASELINE` 指到老提交 → 历史里那两个被卷进去的提交立刻判红。

## 验证

`hvigorw assembleHap` BUILD SUCCESSFUL;`npm test` 退出码 0
(12 个判据文件全绿 + vitest 258/258;`commit-hygiene` 在本提交落地后基线生效)。
2026-09-14 14:57:18 +08:00

193 lines
9.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**:沙箱可用、依赖声明完整、可被包管理器卸载。
### deb 打不出来时:先看 `/tmp` 有没有空间2026-09-14 实测)
> 顺带一条已经**固化进脚本**的前置:本机 `/tmp` 是 tmpfs占内存且常年接近满
> `npm run build:linux` 现在自带 `TMPDIR=${TMPDIR:-$PWD/.tmp/…}`(工作区所在大盘)。
> 手敲 `electron-builder` 时请照着加 —— "记得加 TMPDIR"这种约定活不过两次踩坑hvigor 与 fpm 各踩过一次)。
>
> ⚠️ **`.tmp` 要按"使用者"分家**pi 2026-09-14 指出):这个 worktree 可能同时有
> 多个 agent/人会跑构建,而 fpm 会把 `release/linux-unpacked`(约 291MB**整份复制**进
> `TMPDIR` —— 两边落到同一个临时目录就是随机的产物损坏/构建失败,比"归属错"难查得多。
> 注意 `whoami` **区分不开**(这里大家都是 root所以用**会话**区分:
>
> ```bash
> export TMPDIR="$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}"
> ```
>
> `DSH_*` 是本机 agent 会话的环境变量;在普通终端里退化成"用户+PID",同样唯一。
本机曾以为"deb 打不出来是 fpm 的毛病"(报错停在 `fpm process failed 1`,栈顶是
portable ruby 的 `Dir.chdir`,看着像路径权限)。**实际是 ENOSPC** —— fpm 会把
`release/linux-unpacked`(约 291MB**整份复制**到 `TMPDIR` 里再打包,而本机
`/tmp` 是 9.8G 的 tmpfs 且被 `/tmp/gocache` 等占到 99%(只剩 ~100MB
```bash
df -h /tmp # 先看这里Avail 只剩几十 MB 就该警惕
grep -o 'Errno::ENOSPC' <构建日志> # 真正的报错行(栈里那行只是表象)
# 把 TMPDIR 指到大盘上再打工作区所在文件系统147G 可用)
mkdir -p "$PWD/.tmp"
TMPDIR="$PWD/.tmp/${DSH_SESSION_ID:-$(id -un)-$$}" npx electron-builder --linux deb -c.electronDownload.isVerifyChecksum=false
# → release/agentmail-web_0.1.0_amd64.deb约 100MB
```
结论:**不要**为了"本机打包能过"把 deb 从 `build.linux.target` 里摘掉,
也不要改 fpm 缓存 —— 那是在改测试迁就环境。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'))"
```