diff --git a/client/electron/package.json b/client/electron/package.json index 2a79058..7392416 100644 --- a/client/electron/package.json +++ b/client/electron/package.json @@ -14,7 +14,7 @@ "scripts": { "dev": "npm run gen:bg && vite", "dev:electron": "ELECTRON_START_URL=http://localhost:5173 electron electron/main.cjs", - "build": "npm run gen:bg && vite build", + "build": "npm run gen:bg && vite build && node scripts/build-info.mjs", "build:win": "vite build && electron-builder --win", "build:linux": "bash scripts/release-linux.sh", "preview": "vite preview", diff --git a/client/electron/scripts/build-info.mjs b/client/electron/scripts/build-info.mjs new file mode 100644 index 0000000..36a56cd --- /dev/null +++ b/client/electron/scripts/build-info.mjs @@ -0,0 +1,110 @@ +/** + * 构建产物的**自证**:`dist/BUILD_INFO.json`。 + * + * # 为什么不是时间戳 + * + * 原来那条判据是"`dist` 比 `src` 新" —— pi 2026-09-14 指出它有两层代理: + * ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿,见 CRITERIA.md §6.7.1); + * ② **`dist` 比 `src` 新也不等于 `dist` 是从这份 `src` 构建的**(`checkout`/`cp`/时钟都骗得过 mtime)。 + * 换成内容指纹之后,②也消掉了:判据说的是"产物记的源状态 == 当前源状态", + * 而不是"谁的时间更晚"。任何"包不是从这份源码来的"都变成一条可读出的不等。 + * + * # 这个文件同时是"套件自报来源"的构建侧同款 + * + * pi 的建议:**一条红/一个包都应自带"它对应哪个源码状态"**(`git status --porcelain` 那一族)。 + * 消费方: + * - `test/build-stamp.test.mjs` 精确比对(不是比新旧); + * - `scripts/release-linux.sh` 打完包把同一份信息打进日志(出问题时先看它)。 + * + * 指纹范围 = **会影响产物的一切**(`src/` + 入口 html + 构建配置 + 生成脚本), + * 排除生成物 `background-takeover.generated.css`(它由 `gen:bg` 现生成, + * 内容已由生成脚本决定 —— 计入会让"先 build 后 gen"这种无害顺序变化产生假红)。 + */ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join, relative, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const ELECTRON_DIR = join(dirname(fileURLToPath(import.meta.url)), '..'); + +/** 参与指纹的文件/目录(相对 client/electron) */ +const TRACKED = [ + 'src', + 'index.html', + 'vite.config.ts', + 'tsconfig.json', + 'package.json', + 'scripts/gen-background-takeover.mjs' +]; + +/** 生成物:由上面某个文件决定内容,计入会产生假红 */ +const EXCLUDE = new Set(['src/background-takeover.generated.css']); + +function walk(abs, rel, out) { + const st = statSync(abs); + if (st.isDirectory()) { + for (const name of readdirSync(abs).sort()) { + walk(join(abs, name), `${rel}/${name}`, out); + } + return; + } + const key = rel.split(sep).join('/'); + if (EXCLUDE.has(key)) return; + out.push(key); +} + +/** + * 当前源码状态。 + * + * 返回 `{ gitRev, gitDirty, srcHash }`: + * - `gitRev`:HEAD 短哈希(读不到 git 时给 `''`,此时只看指纹); + * - `gitDirty`:工作树是否有未提交改动(**只作信息**:共享工作区常年是脏的, + * 判据不拿它当红/绿依据,拿它当"复核时先看这一行"); + * - `srcHash`:影响产物的那批文件的**内容**指纹。 + */ +export function srcState(root = ELECTRON_DIR) { + const files = []; + for (const t of TRACKED) { + const abs = join(root, t); + try { walk(abs, t, files); } catch { /* 缺文件就不计(例如还没有 dist 时) */ } + } + files.sort(); + const h = createHash('sha256'); + for (const f of files) { + h.update(f).update('\0').update(readFileSync(join(root, f))).update('\0'); + } + let gitRev = ''; + let gitDirty = false; + const repoRoot = join(root, '..', '..'); + try { + gitRev = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim(); + gitDirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0; + } catch { /* 不在 git 里(例如解包后的目录):指纹仍然有效 */ } + return { gitRev, gitDirty, srcHash: h.digest('hex'), files: files.length }; +} + +/** 写 `dist/BUILD_INFO.json`(构建路径的最后一步) */ +export function writeBuildInfo(root = ELECTRON_DIR, buildCmd = process.env.npm_lifecycle_event || '') { + const st = srcState(root); + const info = { + gitRev: st.gitRev, + gitDirty: st.gitDirty, + srcHash: st.srcHash, + srcFiles: st.files, + buildCmd, + builtAt: new Date().toISOString(), + // 说明这份文件是什么、谁在比它(读到此文件的人不用去翻代码) + note: '构建自证:test/build-stamp.test.mjs 用 srcHash/gitRev 精确比对,不是比时间戳' + }; + const out = join(root, 'dist', 'BUILD_INFO.json'); + mkdirSync(dirname(out), { recursive: true }); + writeFileSync(out, `${JSON.stringify(info, null, 2)}\n`); + return info; +} + +// 直接执行时 = 构建的最后一步 +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + const info = writeBuildInfo(); + console.log(`[build-info] dist/BUILD_INFO.json 已写:rev=${info.gitRev || '(无 git)'} srcHash=${info.srcHash.slice(0, 12)} dirty=${info.gitDirty}`); +} diff --git a/client/electron/scripts/release-linux.sh b/client/electron/scripts/release-linux.sh index 27ab50c..e563c93 100755 --- a/client/electron/scripts/release-linux.sh +++ b/client/electron/scripts/release-linux.sh @@ -36,4 +36,12 @@ bash -c "$BUILD_CMD" echo "[release] 打包:$PACK_CMD" bash -c "$PACK_CMD" +# 产物自报来源:把 BUILD_INFO 打进日志(pi 提的"一个包要自带它对应哪个源码状态")。 +# 出问题时先看这几行:包是在哪个提交、哪份源码指纹上构建的,以及当时树干不干净。 +if [[ -f dist/BUILD_INFO.json ]]; then + echo "[release] 产物来源:$(cat dist/BUILD_INFO.json | tr -d '\n' | sed 's/ */ /g')" +else + echo "[release] 警告:dist/BUILD_INFO.json 不存在 —— 构建没走 npm run build?(判据会红)" >&2 +fi + echo "[release] 完成:dist 与安装包同批(这一句只在两步都成功后才出现)" diff --git a/client/electron/src/stores/backgroundStore.ts b/client/electron/src/stores/backgroundStore.ts index 345a5b3..32c0da0 100644 --- a/client/electron/src/stores/backgroundStore.ts +++ b/client/electron/src/stores/backgroundStore.ts @@ -65,6 +65,20 @@ export const STORAGE_KEY_PREFIX = 'agentmail.background.'; /** * 旧全局值的手工恢复备份。**没有任何代码读它** —— * 留着它只是为了让"迁移把旧值给了错的账号"这件事可以改回来(见 `readStored` 的说明)。 + * + * # 生命周期:**有意永久残留**(2026-09-14 由 pi 提出后定的,别再问"这键谁写的") + * + * pi 的质疑成立:判据钉死"没有任何代码读它",**也就没有任何代码能删它** —— + * 它是一份永久副本。我考虑过"用户点重置外观时删掉",但**恰恰否决了**: + * 最可能点重置的人,正是那个"外观被别人接管了"的人,而这份备份是他唯一的旧值 —— + * 在那时删掉,等于把恢复数据毁在最需要它的时刻。 + * + * 所以本键的规则是:**没有任何代码路径能判定何时安全删除**(那取决于人, + * 而不取决于代码看到的状态),因此它有意永久保留。代价当场说清,不藏着: + * - 体积有界:值受 `MAX_DATA_URL_BYTES`(2.4MB)约束,最坏是一张用户上传的原图整份副本; + * - 共享机器上它是一份**别人的外观**(可能是图片)—— 想清掉就手工删 + * `localStorage.removeItem('agentmail.background.legacy.bak')`。 + * 这条决定与删除方法**同时**写在 `docs/HARMONY-ALIGN-PLAN.md` §7.12(审计的人先看那里)。 */ export const LEGACY_BACKUP_KEY = 'agentmail.background.legacy.bak'; diff --git a/client/electron/test/CRITERIA.md b/client/electron/test/CRITERIA.md index c54649f..13787de 100644 --- a/client/electron/test/CRITERIA.md +++ b/client/electron/test/CRITERIA.md @@ -238,10 +238,51 @@ strictEqual(storageKey('a').includes('a'), true); - 判"**代码里有没有这个调用/这个来源**"(例如"写缓存只许用 `storageKey()`,不许写死键名") —— 这是**来源**约束,不是值对不对,正则在这里是合适工具; - 对象**跑不起来**时(`.ets` 在本机没有运行时:编译要 hvigorw、运行要设备, - 而设备在这条链上不可用,见计划文档 §7.21),静态匹配是唯一可用的手段 —— + 而设备在这条链上不可用,见计划文档 §7.21、以及本文件 §6.8 的到期机制), + 静态匹配是唯一可用的手段 —— 但要把"这只证明形状、不证明值"写进判据的说明里,别让它冒充行为验证; - 与"自报条数 < 登记条数"同族:**静默放行**是这类判据最危险的失败方式。 +#### 6.7.0 可机检的分流规则:**值 → 行为,来源 → 静态**(pi 2026-09-14 要求写硬) + +上面那段说清了"结论",但没给下一个人一条**照着走就行**的规则 —— 于是仍然靠感觉。 +按对象分流,只有两类: + +| 你要约束的东西 | 只能用哪种判据 | **为什么只能是它**(不是风格问题,是覆盖问题) | +| --- | --- | --- | +| **值算得对不对**(键拼错没有、数字对不对、"点两下真的切换了吗") | **行为判据** | 行为判据只覆盖它**跑到的路径**,所以它**原理上判不了**"有没有别的路绕过去" | +| **有没有别的路绕过去 / 值必须来自某处**(不许自己写死键名、命中区必须引用共享常量) | **静态判据** | 它要的是**全程序可达性**,只有读代码能回答"还有没有第二个写入口" | + +两条推论,写成一句话记住: + +- **静态判据判"值"永远差一个反例** —— §6.7 那张表里我三版各被穿透一次,就是这个; +- **行为判据判"来源"永远差一条路径** —— 你跑的那条路对了,不等于没有第二条路; +- 所以两者**不是强弱关系,是分工**。缺任一条,那一对判据就是**假判据**(看着有,其实漏一半)。 + +**照这条规则回看本仓已有的一对样例**(P5 底栏命中区,正好是"值 + 来源"的完整配对): + +| 判据 | 类型 | 内容 | +| --- | --- | --- | +| `③ 命中区 ≥44vp` | **值**判据 | 从 `NavItems.ts` **导入**数值判(不是正则猜源码) | +| `③ 命中区的应用点` | **来源**判据 | `.ets` 里**不许自己写 `32`**,必须引用 `NAV_ITEM_MIN_HIT` | + +这两条合起来才闭合:只判值 → 组件自己写 `32` 照样"值是对的";只判来源 → 常量被改成 `20` 也没人管。 +(写新判据时先问一句"我这一对齐全吗",比事后补更省事。) + +### 6.8 静态判据是**欠账**,必须能到期(`UNBLOCK` + `RESULT static=N`) + +pi 2026-09-14 指出:规范里的"**暂时**"不是一种状态,是一个**待办** —— 没有任何机制会回来读它, +于是它永远留在原地。改写成能自己到期的形状(已实现,见 `test/run-all.mjs`): + +1. 只能验形态的判据登记在 `run-all.mjs` 的 `STATIC_ONLY` 里,每条**必填到期前提** + (`UNBLOCK`),且前提必须是**可机检的**(如"设备可用"这种探针),不是一句陈述 + (写成 `'vibes'` 这类未知前提 → 套件当场红); +2. 汇总打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见; +3. **前提一旦为真,这些判据自动变红**(实测:把探针改成恒真 → 五条登记判据同时报"到期")。 + 这就是"暂时"的到期机制:设备可用的那天,欠账当场显形,不等人想起来。 + +它是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管"它该升级了没有"。 + ### 6.7.1 附:**探测器**与**门**不是一回事 同一次讨论(pi 2026-09-14 §2)里还有一条:`dist` 比 `src` 新**只说明"src 改了而产物没跟上"**, @@ -257,3 +298,29 @@ strictEqual(storageKey('a').includes('a'), true); (移交信里交代的头号纪律)判据通过了但用户点不到,等于没做。 所以断言尽量落在"用户会触发的那个入口/那条路径"上: 例如"点同意/拒绝后列表要变"要钉到那条链路上,而不是钉"某函数存在"。 + +## 8. 在**共享工作区**上做归因:别动别人的未提交改动 + +(pi 2026-09-14 §4 指出,我当天正是这么干的。) + +事情经过:`narrow-layout` 出现 3 条红,我用 `git stash push -- src/components/MailView.tsx` +把**并发写作者的未提交改动**暂时收起来,看红是否跟着消失,再 `stash pop` 放回去。 +结论是对的,**方法不行**: + +- `git stash` 会**写**共享工作区 —— 那不是我自己的文件。即便随后 `pop` 回来, + 这中间任何一次崩溃、冲突、或对方恰好跑一次 `git add -A`,丢的是**别人的工作**; +- 它还只影响 **tracked** 文件:对方的 untracked 新文件照样留在原地, + 于是"stash 之后干净了"本身是**假的干净**,归因结论也可能是假的; +- 同一工作区里同时有别的写入者时,任何"我来把树弄干净一下"的动作都在赌别人的东西不丢。 + +**该用的手段**(都只读、不碰工作区): + +```bash +git show HEAD:client/electron/src/components/MailView.tsx > /tmp/mv.head.tsx # 拿基线对比 +git worktree add /tmp/attr HEAD # 或者在独立工作树里复现 +``` + +或者直接问写那半代码的人 —— 共享工作区里"谁改的"根本不是能从树上读出来的信息 +(这也是为什么 `BUILD_INFO.json` 要记 `gitRev`/`gitDirty`:产物自带来源,比事后猜强)。 + +**一句话**:归因手段的选择标准是"**会不会让别人的东西处于危险里**",不是"哪条命令最快"。 diff --git a/client/electron/test/appearance-defaults.test.mjs b/client/electron/test/appearance-defaults.test.mjs index bc69e93..824decc 100644 --- a/client/electron/test/appearance-defaults.test.mjs +++ b/client/electron/test/appearance-defaults.test.mjs @@ -47,17 +47,42 @@ function serverDefaults() { */ assert.ok(body.replace(/[\s{}]/g, '').length > 0, 'DefaultAppearance 的函数体切出来是空的(函数被改名/挪走了?判据要跟着改,别静默放行)'); - const field = (name, re, what) => { + /* + * pi 2026-09-14 补充的一条:**标识符应该能解析一层**,否则「读不懂就红」的长期结局是 + * 有人做一次无害重构(`BgDim: defaultDim`)→ 判据红 → 唯一出路是把判据改宽 → + * 下一步通常是"少核一个字段" → 契约又漂了。 + * 所以:字面量直接用;标识符在同一文件里查 `NAME = <字面量>`;查不到(跨包 / 计算 / iota) + * 才报"读不懂"。**只解析一层**:再深就不是"读一个常量"而是"执行 Go"了,那时该报读不懂。 + */ + const resolve = (name, reLiteral, identRe, what) => { assert.ok(new RegExp(`\\b${name}\\s*:`).test(body), `DefaultAppearance 里没有 ${name} 字段了(判据要跟着服务端改)`); - const m = re.exec(body); - assert.ok(m, `${name} 在,但值不是${what} —— **判据读不懂**,请人工核对接线,别让它悄悄跳过。函数体:${body.replace(/\s+/g, ' ')}`); - return m; + const lit = reLiteral.exec(body); + if (lit) return lit; + const ident = identRe.exec(body); + assert.ok(ident, `${name} 在,但值既不是${what}也不是标识符 —— **判据读不懂**,请人工核对接线。函数体:${body.replace(/\s+/g, ' ')}`); + const identName = ident[1]; + /* + * 同一文件里找 `NAME = <字面量>`(也覆盖 const 组里的 `NAME = 12`)。 + * **只解析一层**:再深就不是"读一个常量"而是"执行 Go"了 —— 那时该报读不懂, + * 让人来看,而不是判据自己猜。 + */ + const declRe = new RegExp(`\\b${identName}\\s*=\\s*([^\\n]+)`, 'g'); + const decls = [...src.matchAll(declRe)].map(m => m[1].replace(/\/\/.*$/, '').trim()); + const literalOf = what === '数字字面量' ? /^(\d+)$/ : /^"([^"]+)"$/; + for (const v of decls) { + const hit = literalOf.exec(v); + if (hit) return hit; + } + assert.fail( + `${name} 指向标识符 \`${identName}\`,但同一文件里找不到它的字面量定义 —— ` + + `**判据读不懂**(跨包 / 计算 / iota?),请人工核对。找到的声明:${decls.join('、') || '(无)'}` + ); }; - const dim = field('BgDim', /BgDim:\s*(\d+)/, '数字字面量'); - const blur = field('BgBlur', /BgBlur:\s*(\d+)/, '数字字面量'); - const theme = field('Theme', /Theme:\s*"([^"]+)"/, '字符串字面量'); - const kind = field('BgKind', /BgKind:\s*"([^"]+)"/, '字符串字面量'); - const preset = field('BgPresetID', /BgPresetID:\s*"([^"]+)"/, '字符串字面量'); + const dim = resolve('BgDim', /BgDim:\s*(\d+)/, /BgDim:\s*([A-Za-z_]\w*)/, '数字字面量'); + const blur = resolve('BgBlur', /BgBlur:\s*(\d+)/, /BgBlur:\s*([A-Za-z_]\w*)/, '数字字面量'); + const theme = resolve('Theme', /Theme:\s*"([^"]+)"/, /Theme:\s*([A-Za-z_]\w*)/, '字符串字面量'); + const kind = resolve('BgKind', /BgKind:\s*"([^"]+)"/, /BgKind:\s*([A-Za-z_]\w*)/, '字符串字面量'); + const preset = resolve('BgPresetID', /BgPresetID:\s*"([^"]+)"/, /BgPresetID:\s*([A-Za-z_]\w*)/, '字符串字面量'); return { dim: Number(dim[1]), blur: Number(blur[1]), diff --git a/client/electron/test/build-stamp.test.mjs b/client/electron/test/build-stamp.test.mjs index b567860..459ff78 100644 --- a/client/electron/test/build-stamp.test.mjs +++ b/client/electron/test/build-stamp.test.mjs @@ -13,7 +13,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; + +import { srcState } from '../scripts/build-info.mjs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -87,7 +89,7 @@ test('★ 版本库里不得跟踪缓存/构建产物(.tmp、node_modules、di assert.ok(tracked.includes(EMBED_PLACEHOLDER), 'go:embed 的占位文件丢了 —— 没有它 static/ 目录不存在,Go 侧编不过'); }); -test('★ dist 必须比 src 新(前端改了没重新构建 —— 套件照样全绿,只有这道门会红)', () => { +test('★ 产物必须自报来源:BUILD_INFO 精确比对(不是比时间戳)', () => { /* * 这条是踩出来的(2026-09-14 接手 pi 的 WebUI 开项时): * 我把 `npm run build` 串在管道里(`npm run build 2>&1 | tail -4 && electron-builder …`), @@ -95,35 +97,43 @@ test('★ dist 必须比 src 新(前端改了没重新构建 —— 套件照 * 拿旧的 dist 打了一个新包 —— 而所有判据都是绿的: * - vitest 绿:测试运行时对"缺的具名导出"很宽容(拿到 undefined),只有打包器会报; * - packaging 绿:它比的是"dist vs 安装包",两边都是旧的,自然一致。 - * 结果就是"代码改了、产物没改",而没有任何一条判据看得见。 * - * 这里直接比时间戳:dist 的入口必须不早于 src 下最新的文件。 + * 第一版判据比的是**时间戳**(dist 不早于 src 最新文件),它能抓住那次事故, + * 但 pi 2026-09-14 指出它是**代理变量**,两层都靠不住: + * ① 看不见"构建是否成功"(实测:构建失败但碰过 dist 时它照样绿 —— 那个坑由 + * scripts/release-linux.sh 喂退出码来堵,见 CRITERIA.md §6.7.1); + * ② **dist 比 src 新也不等于 dist 是从这份 src 构建的**(`git checkout`、`cp`、 + * 时钟都能骗过 mtime —— 我确实用 checkout 造过一次假红)。 + * 现在改成**内容自证**:`dist/BUILD_INFO.json` 记下构建时的 srcHash 与 gitRev, + * 判据重算一遍当前的指纹再**精确比对**。于是判据说的是"产物记的源状态 == 当前源状态", + * 而不是"谁的时间更晚" —— "代理"两个字没有了。 */ + const infoPath = join(ROOT, 'client/electron/dist/BUILD_INFO.json'); const distIndex = join(ROOT, 'client/electron/dist/index.html'); if (!existsSync(distIndex)) { console.log('(没有 dist —— 先 npm run build 才验得到这条)'); return; } - const distAt = statSync(distIndex).mtimeMs; - // 构建过程自己会写 src/background-takeover.generated.css(在 dist 之前),不算"源码改动" - const newest = { at: 0, file: '' }; - const walk = dir => { - for (const e of readdirSync(dir, { withFileTypes: true })) { - const p = join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.name !== 'background-takeover.generated.css') { - const at = statSync(p).mtimeMs; - if (at > newest.at) { newest.at = at; newest.file = p; } - } - } - }; - walk(join(ROOT, 'client/electron/src')); - assert.ok(newest.file, 'src 下应当有文件'); - assert.ok( - distAt >= newest.at, - `前端源码比 dist 新(改了没重新构建):\n` + - ` src 最新:${newest.file.replace(ROOT + '/', '')}\n` + - ` dist:${distIndex.replace(ROOT + '/', '')}\n` + - ` 重构建:cd client/electron && npm run build(注意别把它的退出码丢在管道里)` - ); + assert.ok(existsSync(infoPath), + 'dist 里没有 BUILD_INFO.json —— 构建没走 `npm run build`(它最后一步会写这份自证)。\n' + + ' 重构建:cd client/electron && npm run build'); + const info = JSON.parse(readFileSync(infoPath, 'utf8')); + const now = srcState(); + // 精确比对:这一条比"谁更新"强的地方在于它**能读出**差在哪 + assert.equal(info.srcHash, now.srcHash, + `产物与当前源码不是同一份(改了没重新构建):\n` + + ` 产物记录的 srcHash:${String(info.srcHash).slice(0, 12)}(${info.srcFiles} 个文件)\n` + + ` 当前源码的 srcHash:${now.srcHash.slice(0, 12)}(${now.files} 个文件)\n` + + ` 重构建:cd client/electron && npm run build(注意别把它的退出码丢在管道里)`); + if (now.gitRev && info.gitRev) { + assert.equal(info.gitRev, now.gitRev, + `产物是在另一个提交上构建的(产物记 ${info.gitRev},当前 HEAD ${now.gitRev})—— 重构建再复核`); + } + /* + * `gitDirty` 只**展示**不判定:共享工作区常年是脏的(并发写作者的未提交改动), + * 拿它当红/绿依据会让这条判据天天误报。它写在 BUILD_INFO 里是为了复核时先看这一行 + * —— "这个包对应的源码状态干净吗"(pi 提的"一条红自报来源"的构建侧同款)。 + */ + assert.ok(typeof info.gitDirty === 'boolean', 'BUILD_INFO 要记录构建时工作树是否干净(供复核者判断)'); + assert.ok(info.builtAt && info.buildCmd !== undefined, 'BUILD_INFO 要带构建时间与构建命令'); }); diff --git a/client/electron/test/harmony-nav.test.mjs b/client/electron/test/harmony-nav.test.mjs index 34c5599..b3b5bed 100644 --- a/client/electron/test/harmony-nav.test.mjs +++ b/client/electron/test/harmony-nav.test.mjs @@ -125,6 +125,40 @@ test('③ 命中区 ≥44vp:判的是数值本身(从 NavItems.ts 导入, assert.match(bar, /\.height\(NAV_BAR_HEIGHT\)/, '条高取同一常量'); }); +/* + * ③ 的**来源**那一半(pi 2026-09-14;配对规则见 CRITERIA.md §6.7.0): + * 上面的"≥44"判的是**值**,但值对不等于"组件用的是那个值" —— 组件自己写死一个 + * 够大的数字照样绿,而共享常量被改小时它不会跟着变。所以这一条判**来源**: + * 应用点必须引用常量,不许出现裸数字。 + */ +test('③-b 命中区是**来源**判据:`.ets` 不许自己写数字,必须引用 NAV_ITEM_MIN_HIT', () => { + const item = builderBody(main, 'NavItem(item: NavItem, index: number) {'); + const bare = /(minHeight|minWidth|height|width):\s*\d+/.exec(item); + assert.equal(bare, null, + `导航项里出现了裸数字 \`${bare?.[0]}\` —— 命中区/条高必须引用 NavItems.ts 里的常量:` + + `值判据(≥44)管"数字够不够大",来源判据(这条)管"用的是不是同一个数字"。` + + `两条合起来才闭合(CRITERIA.md §6.7.0)。`); +}); + +/* + * 让位高度必须**派生**(pi 2026-09-14 §6:`76` 不该是并列常量)。 + * + * 判的是 NavItems.ts 里的**声明**:算式里要出现条高与离底留白两个常量 —— + * 写死 `= 76` 就会在"哪天把条高改成 64"时静默失配(内容被压住,看得见点不到)。 + * 顺带钉住:算式里不许出现裸数字(余量要有名字)。 + */ +test('④-b 内容让位高度是**派生**的,不是并列常量', () => { + const src = readFileSync(NAV_TS, 'utf8'); + const decl = /export const NAV_CONTENT_RESERVE: number = ([^;]+);/.exec(src); + assert.ok(decl, 'NAV_CONTENT_RESERVE 的声明要能被判据读到(判据跟着改)'); + const expr = decl[1]; + assert.match(expr, /NAV_BAR_HEIGHT/, `让位高度必须含条高,现在是 \`${expr}\``); + assert.match(expr, /NAV_BAR_BOTTOM/, `让位高度必须含离底留白,现在是 \`${expr}\``); + const bare = /[^A-Z_]\d+/.exec(expr.replace(/NAV_[A-Z_]+/g, '')); + assert.equal(bare, null, + `算式里还有裸数字 \`${bare?.[0]}\` —— 余量也要起名字(NAV_CONTENT_GAP),否则"该让多少"永远是谜`); +}); + test('④ 悬浮 + 让位:自绘浮动层(留白/圆角/系统材质),内容底部让出的高度 ≥ 条高 + 离底留白', () => { const bar = builderBody(main, 'NavBar() {'); /* diff --git a/client/electron/test/run-all.mjs b/client/electron/test/run-all.mjs index 773745b..58a34f5 100644 --- a/client/electron/test/run-all.mjs +++ b/client/electron/test/run-all.mjs @@ -206,6 +206,76 @@ for (const [file, flags, expected] of SUITE) { } } +/* + * ─── 静态判据的**欠账**与到期机制(pi 2026-09-14 提议) ─── + * + * 有些判据只能验**形态**(读 `.ets` 源码),因为它们要验的东西在本机跑不起来: + * 鸿蒙侧编译要 hvigorw、运行要设备/模拟器。这类判据登记在下面,并各自写清 + * **什么前提一旦成立它就过期**。 + * + * 为什么不能只写一句"暂时":**"暂时"不是一种状态,是一个待办** —— 规范里写下的 + * "暂时"没有任何机制会回来读它,于是永远留在原地。这里把它变成可机检的形状: + * 1. `UNBLOCK` 必须是**可检测的前提**(这里就是"有没有可用设备"),不是陈述; + * 2. 汇总里打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见; + * 3. **前提一旦为真,欠账当场变红**:不等人想起来,设备可用的那天这些判据必须 + * 改成行为判据(或明确降级并写理由)。 + * + * 这条是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管 + * "它该升级了没有"。 + */ +const PROBES = { + device: { + desc: '有可用的设备/模拟器(hdc 看得到目标)', + run() { + const sdkHdc = '/opt/huawei/command-line-tools/sdk/default/openharmony/toolchains/hdc'; + const candidates = [sdkHdc, 'hdc']; + for (const bin of candidates) { + try { + const out = execFileSync(bin, ['list', 'targets'], { encoding: 'utf8', timeout: 15000 }); + const t = out.trim(); + if (t && !/\[Empty\]/.test(t)) return true; + } catch { /* 没有 hdc 或超时:前提不成立 */ } + } + return false; + } + } +}; + +/** 只能验形态的判据:文件 + 为什么只能静态 + 到期前提 */ +const STATIC_ONLY = [ + ['test/harmony-nav.test.mjs', '底栏结构/命中区常量/挂载关系:`.ets` 要 hvigorw 才能编译、要设备才能点', 'device'], + ['test/harmony-appearance.test.mjs', '壁纸/令牌/遮罩渲染:观感与运行期换肤要设备', 'device'], + ['test/harmony-logic.test.mjs', '页面状态机与文案:`.ets` 状态要跑起来才算数', 'device'], + ['test/cross-client-theme.test.mjs', '跨端令牌与玻璃分工:一端是 `.ets`,只能静态对齐', 'device'], + ['test/appearance-defaults.test.mjs', '默认值契约里 `.ets` 那半:运行时行为要设备', 'device'] +]; + +for (const [file, , probe] of STATIC_ONLY) { + if (!SUITE.some(([f]) => f === file)) { + console.error(`✗ 静态判据登记里的 ${file} 不在套件清单里(登记要跟着套件走)`); + process.exit(1); + } + if (!PROBES[probe]) { + console.error(`✗ ${file} 的到期前提 \`${probe}\` 不是已知探针(UNBLOCK 必须是可机检的前提,不是一句陈述)`); + process.exit(1); + } +} +const dueStatic = STATIC_ONLY.filter(([, , probe]) => PROBES[probe].run()); +if (dueStatic.length > 0) { + console.error(`\n✗ 静态判据**到期**了:${PROBES[dueStatic[0][2]].desc} 现在是成立的 ——`); + for (const [file, why, probe] of dueStatic) { + console.error(` - ${file}(到期前提:${PROBES[probe].desc};当初只能静态的原因:${why})`); + } + console.error( + ' 这些判据当时只能验形态。前提成立后必须做其中一件(别默默留着):\n' + + ' a) 改成**行为判据**(真跑一遍/真点一次),静态那条降级或删掉;\n' + + ' b) 明确写"为什么仍然只能静态"(如设备能编译但点不了),并改换一个更准的到期前提。\n' + + ' 这是"暂时"的到期机制:它的作用就是不等谁想起来。' + ); + process.exit(1); +} +console.log(`RESULT static=${STATIC_ONLY.length}(只能验形态的判据:到期前提成立就自动变红)`); + console.log(`\n========== 判据汇总 ==========`); if (reds.length === 0) { console.log(`全部通过(${SUITE.length} 个判据文件:${SUITE.map(([f]) => f.replace('test/', '').replace('.test.mjs', '')).join('、')})`); diff --git a/client/electron/test/stores/background.test.ts b/client/electron/test/stores/background.test.ts index b35cfe0..7a5a8f8 100644 --- a/client/electron/test/stores/background.test.ts +++ b/client/electron/test/stores/background.test.ts @@ -1,3 +1,5 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; import { DEFAULT_BACKGROUND, @@ -33,6 +35,20 @@ beforeEach(() => { document.documentElement.className = ''; }); + +/** 从当前工作目录往上找仓内文件(vitest 里 `import.meta.url` 不是 file: 方案,别指望它) */ +function findDoc(rel: string): string { + let d = process.cwd(); + for (let i = 0; i < 6; i++) { + const p = join(d, rel); + if (existsSync(p)) return p; + const up = dirname(d); + if (up === d) break; + d = up; + } + throw new Error(`找不到 ${rel}(从 ${process.cwd()} 往上找了 6 层)—— 判据要能自己说清找的是哪个文件`); +} + describe('数值夹取', () => { it('压暗与模糊都被夹在声明区间内', () => { expect(clampDim(-10)).toBe(0); @@ -281,3 +297,19 @@ describe('默认值 = 服务端契约', () => { expect(DEFAULT_BACKGROUND.blur).toBe(4); }); }); + + /* + * `legacy.bak` 的生命周期必须**落在审计的人会看的那一行**(pi 2026-09-14 追问)。 + * + * 这条判的是"决定有没有被记下来",不是"决定对不对" —— 弱判据,但它是必要的: + * 这个键被另一条判据钉成"只写不读"(⇒ 永远删不掉),于是下一个审计 localStorage 的人 + * 一定会问"这谁写的、能删吗"。答案必须就在他会看的地方,而不是藏在某个人的记忆里。 + */ + it('★ legacy.bak 的生命周期写在文档里(谁写的、为什么不删、怎么删)', () => { + const doc = readFileSync(findDoc('docs/HARMONY-ALIGN-PLAN.md'), 'utf8'); + const row = doc.split('\n').find(l => l.includes('agentmail.background.legacy.bak')); + expect(row, '§7.12 里要有一行讲这个键').toBeTruthy(); + expect(row).toContain('有意永久残留'); + expect(row).toContain('没有任何代码路径能判定何时安全删除'); + expect(row, '要给出删除方法,否则审计的人只能猜').toMatch(/removeItem\('agentmail\.background\.legacy\.bak'\)/); + }); diff --git a/client/harmony/entry/src/main/ets/model/NavItems.ts b/client/harmony/entry/src/main/ets/model/NavItems.ts index 16ab06e..dc4b157 100644 --- a/client/harmony/entry/src/main/ets/model/NavItems.ts +++ b/client/harmony/entry/src/main/ets/model/NavItems.ts @@ -45,13 +45,27 @@ export const NAV_BAR_BOTTOM: number = 12; /** 圆角(vp)。用"胶囊"半径(= 高度一半),与 WebUI 的 `rounded-2xl` 观感一致 */ export const NAV_BAR_RADIUS: number = 28; +/** + * 内容与浮动条之间的余量(vp)。 + * + * 单独起名而不是把 `8` 埋在算式里:它是这一族里**唯一**还需要人判断的数 + * (条离底 12 + 条高 56 + 余量 8),命名之后"内容到底让多少"只剩一个数可争, + * 其余全部派生。pi 2026-09-14 提的"让位高度应从条高派生,不要写成并列常量", + * 落实在这两行上。 + */ +export const NAV_CONTENT_GAP: number = 8; + /** * 内容底部要让出的高度(vp):条高 + 离底留白 + 一点余量。 * * **这是"点得到"的问题,不是美观问题**:条是浮在内容之上的,不让出这段高度, * 列表最后一行就永远压在玻璃条底下 —— 看得见、点不到。 + * + * **派生,不并列**:上面三个常量任何一个变大,这里自动跟着变;写死一个数(例如 76) + * 就会在改条高的那天悄悄失配。判据 `harmony-nav.test.mjs` 同时钉"派生关系"与 + * "在算式中出现",防它退回字面量。 */ -export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + 8; +export const NAV_CONTENT_RESERVE: number = NAV_BAR_HEIGHT + NAV_BAR_BOTTOM + NAV_CONTENT_GAP; /** 把任意下标归一化到合法范围(点击/外部传值都过这里,别直接赋值) */ export function normalizeNavIndex(index: number): number { diff --git a/docs/HARMONY-ALIGN-PLAN.md b/docs/HARMONY-ALIGN-PLAN.md index a87b439..e556ea1 100644 --- a/docs/HARMONY-ALIGN-PLAN.md +++ b/docs/HARMONY-ALIGN-PLAN.md @@ -516,7 +516,7 @@ deb 也不必从 targets 里摘。已写进 `client/electron/BUILD.md`(含排 | 动效 | 自定义 transition/时长 | `animateTo` + 系统 `curves` | 动效曲线应跟随系统设置(含"减弱动效") | | 遮罩 | 自声明 `--bg-scrim` + `--bg-dim` 两段式 | 系统 `sys.color.ohos_id_color_mask_regular` | 遮罩要随主题换向(浅色洗白/深色压黑),这件事系统已经做了 | | **品牌色** | `--c-blue-600: 37 99 235` | `Theme.accent = '#2563EB'` | **不允许差异** —— 两个客户端是同一个产品 | -| **本地外观缓存的键** | `agentmail.background.`(**已修**:原来全局) | `appearance.` | **差异已消除**(2026-09-14 dsh 接手 pi 的开项):两端都按账号分键,且都留有「不许退回全局键」的判据。全局键的后果是切到服务端没有记录的账号时 `saved=false` 会把**上一个账号的外观** push 上去(新账号"继承"了外观,还写进了服务端)。WebUI 侧保留旧全局键**仅作一次性迁移源**:接管后立刻删除,且未登录时不迁移。**本地这半的归属问题(pi 指出,同一行的另一半)**:「当前账号」= **升级后第一个读到缓存的账号**,不是**写下旧值的账号**;旧值是谁写的,本机**没有记录**(`accountStore` 的 `activeId` 是**派生的视图状态**、落盘时只存 `accounts` 数组,见其 `persist(accounts)` 与「落盘在 activeId 里不合适」那句注释)⇒ **定向迁移在原理上做不到**。显形方式:**一次性外观错档**(升级前用 B、升级后先登录 A 且 A 自己没有记录 → A 接管 B 的外观),触发条件就这一条。已做的两件补救:**写了回读校验**(写不进去就不删旧键,避免净损失)、**删前另存一份** `agentmail.background.legacy.bak`(没有任何代码读它 ⇒ 不引入新的继承源,但让接管错了可以手工改回)。行为判据在 `test/stores/background.test.ts` | +| **本地外观缓存的键** | `agentmail.background.`(**已修**:原来全局) | `appearance.` | **差异已消除**(2026-09-14 dsh 接手 pi 的开项):两端都按账号分键,且都留有「不许退回全局键」的判据。全局键的后果是切到服务端没有记录的账号时 `saved=false` 会把**上一个账号的外观** push 上去(新账号"继承"了外观,还写进了服务端)。WebUI 侧保留旧全局键**仅作一次性迁移源**:接管后立刻删除,且未登录时不迁移。**本地这半的归属问题(pi 指出,同一行的另一半)**:「当前账号」= **升级后第一个读到缓存的账号**,不是**写下旧值的账号**;旧值是谁写的,本机**没有记录**(`accountStore` 的 `activeId` 是**派生的视图状态**、落盘时只存 `accounts` 数组,见其 `persist(accounts)` 与「落盘在 activeId 里不合适」那句注释)⇒ **定向迁移在原理上做不到**。显形方式:**一次性外观错档**(升级前用 B、升级后先登录 A 且 A 自己没有记录 → A 接管 B 的外观),触发条件就这一条。已做的两件补救:**写了回读校验**(写不进去就不删旧键,避免净损失)、**删前另存一份** `agentmail.background.legacy.bak`(没有任何代码读它 ⇒ 不引入新的继承源,但让接管错了可以手工改回)。行为判据在 `test/stores/background.test.ts`。**`legacy.bak` 的生命周期**(pi 2026-09-14 追问「钉了只写不读,它就永远删不掉」,指定这一行要给出结论):定为**有意永久残留** —— 判据钉死「没有任何代码读它」,因此也**没有任何代码路径能判定何时安全删除**(那取决于人,不取决于代码看到的状态)。**明确否决了「用户点重置外观时删掉」**:最可能点重置的人,正是外观被接管的那个人,而这份备份是他唯一的旧值 —— 那时删等于把恢复数据毁在最需要它的时刻。代价当场说清、不藏着:值受 `MAX_DATA_URL_BYTES`(2.4MB)约束,最坏情况是**一张用户上传原图的整份副本**,在共享机器上即一份**别人的外观**;要清掉就手工 `localStorage.removeItem('agentmail.background.legacy.bak')`。这条决定本身有判据钉着(`test/stores/background.test.ts`:这行必须同时给出「为什么不删」与「怎么删」) | | **遮盖色的令牌** | `--bg-scrim`(浅色白 / 深色黑,"朝底色淡化") | `Theme.wallpaperScrim` = `sys.color.ohos_id_color_background`(同向);`Theme.overlay` = mask **只用于模态弹层** | **不允许混用** —— mask 两套主题下都是深色(浅色 `#99182431`),拿它当壁纸遮盖会在浅色主题下压暗(与 WebUI 反向)。两个语义两个令牌,理由与实测值见 §7.17b-2 | | **遮罩浓度的默认值** | `12 / 4`(**已修**:原来 store 的 `24/8` 与 `clamp` 的 `12/4` 两套并存) | 12 / 4(只有一套) | **不是审美,是服务端契约**(pi 更正了自己上一封):`DefaultAppearance()` 明写 `BgDim: 12, BgBlur: 4` 且注释宣称"与客户端默认值一致" —— WebUI 的 24/8 使那句注释为**假**。现已统一到共享常量 `src/lib/appearanceDefaults.ts`,判据**直接去 Go 源码读**这两个数比对。**措辞要准**(pi 同封指出):这条核对的是「**与这份服务端源码的契约一致**」,**不是**「在跑的那个服务端二进制是 12/4」—— 与「`dist` 是产物、源码修好 ≠ 用户手上的包修好」同构;若服务端由别的流水线构建部署,判据对运行时**没有**发言权。本来后果很重:服务端"没有记录"时客户端以本地为准推上去,**新账号的初始外观由第一个同步它的客户端决定** |