Files
AskaEth f7d1c3557b docs: 完善开发文档(面向零上下文接手)+ README 说明两个 Windows 包该用哪个
DEVELOP.md 重写为接手友好的版本,新增:
- 第〇节「接手先读」:现状速览、已有产物、第一件事(pnpm install --ignore-scripts + npm test)
- 第六节「分支策略与 Windows 7」:main(38)/win7(22) 的差别与维护方式、
  为什么锁 22(官方公告 + 真实报错)、**判断一个包能不能在 Win7 上跑的三条方法**
  (包内 version 文件 / PE 子系统版本 / 导入表,附可直接跑的脚本)
- 第八节踩坑记录扩到 7 条:新增「判断兼容性别搜文件字节、要读导入表」
  与「与当前时间耦合的 bug 只在特定时段露头」
- 第九节「已知可再做」补上两分支同步、国产平台(飞腾可 / 龙芯不行)
- 第十节变更记录

README:说明 Windows 7 该用 win7 那个包,并给出拿错包时的典型报错。
2026-09-15 15:43:35 +08:00

339 lines
19 KiB
Markdown
Raw Permalink 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.
# ClockLab · 开发文档
面向**要改这个项目**的人(包括接手的新会话)。使用者请看 [README.md](./README.md)。
> **接手先读这三处**:第〇节(五分钟了解现状)→ 第六节(**两个分支与 Windows 7 限制,最容易踩**)→ 第八节(踩坑记录,能省几个小时)。
---
## 〇、接手先读
### 这是什么
一个用于**演练/演示的桌面时钟**:一个**可调速的时钟**(走快、走慢、暂停、**倒着走**、一键快进)+ 一个**无边框的满屏时钟窗口**。Electron 应用,两个窗口(控制窗口 / 时钟窗口),**没有服务端、没有数据库**。
### 当前状态(截至最后一次提交)
| 项 | 状态 |
| --- | --- |
| 功能 | **全部完成**:两窗口、各自置顶、数字/表盘两种样式、实时设定时间、倍率(正负 0 小数)、暂停/继续、快进五档、跳转动画 |
| 测试 | **152 条全过**(6 个文件,见第五节) |
| 平台 | Windows x64(含**Windows 7**,见第六节)、Linux ARM64 |
| 产物 | 见下面「已有的包」 |
| 未做 | Windows 的 portable 单文件 exe(卡在第八节 ①);表盘刻度数字 |
### 已有的包
```
dist/ClockLab-1.0.0-win7-x64.zip Windows 7/10/11 x64(Electron 22)★ 会议室那台用这个
dist/ClockLab-1.0.0-win-x64.zip Windows 10/11 x64(Electron 38,主线)
dist/ClockLab-1.0.0-linux-arm64.tar.gz Linux ARM64(飞腾 D2000 等)
```
`dist/` 与 `node_modules/` 都在 `.gitignore` 里,**不进仓库** —— 包按需重新打(第七节)。
### 第一件事:跑测试
```bash
pnpm install --ignore-scripts # ⚠️ 必须 --ignore-scripts,原因见第八节 ④
npm test # 期望:152 条全过、退出码 0
```
**`--ignore-scripts` 不是可选项**:本机网络下 electron 的 postinstall 会失败,进而把整个 `install` 拖垮。跳过脚本不影响跑测试(测试是纯 Node)。
### 目录一览
```bash
main.js preload.js renderer/ src/ test/ build/ README.md DEVELOP.md
```
---
## 一、项目结构与分层
```
main.js 主进程:建两个窗口、持有走时模型、注册全部 IPC、夹窗口尺寸
preload.js contextBridge 暴露 window.clockApi(contextIsolation:true,无 nodeIntegration)
renderer/
control.html/.css/.js 控制窗口:三块分区(时钟窗口 / 显示样式 / 时间与流速)
clock.html/.css/.js 时钟窗口:拖动区 + 8 条改大小热区 + 数字/表盘两种视图
resize.js 拖边缘改大小的纯几何计算(单独拆出来是为了能在 node 里跑用例)
presented-time.js 呈现时间层:跳转动画(纯逻辑,浏览器/node 双向可加载)
src/
clock-model.js 走时模型:基准时间 + 基准时刻 + 倍率(纯函数,可单测)
selftest.js GUI 自检(electron . --selftest),跑完出截图,只在开发机上用
test/ 6 个文件、152 条用例(见第五节)
build/icon.ico, icon.png 程序图标
.npmrc 依赖镜像(electron 运行时下载也走它)
pnpm-workspace.yaml pnpm 11 的依赖构建放行表(见第八节 ④,**别删**)
```
**分层原则**:凡是能用纯函数表达的(走时、改大小几何、呈现时间)都**单独成模块**,页面里只做接线。所以 152 条用例里有 **103 条不需要浏览器环境**就能跑(改这三块时不用启动 Electron)。
---
## 二、三个关键实现
### 2.1 走时模型:锚点式,不累加
`src/clock-model.js` 只有三个动作,**每一个都是"重新锚定基准"**,没有一处累加:
```js
displayMs(clock, now) = clock.baseMs + (now - clock.baseRealMs) * clock.rate
setRate(clock, now, rate) // 倍率、暂停(0)、倒转(负)、小数都走这里
clock.baseMs = displayMs(clock, now) // 先把当前显示时间固化 → 切倍率不跳变
clock.baseRealMs = now
clock.rate = rate
if (rate !== 0) clock.lastRate = rate // 暂停时记住上次倍率,供「继续」用
shift(clock, now, deltaMs) // 快进/快退:与倍率完全解耦
setTimeOfDay(clock, now, h, m, s) // 设定时间
```
为什么这么写:如果用 `setInterval` 每次 `+1 秒`,倍率不是整数时会累积漂移,而且暂停/倒转都要另开分支。锚点式写法下,**倍率、暂停、倒转、快进是同一套机制的四个用法**,互不打架("先倒转、再暂停、再 +5 分钟"不会丢掉倒转的偏移)。
两个窗口各自用 `Date.now()`(同一台机器)跑同一个公式,画面 60fps 重算。
### 2.2 呈现时间层:跳转动画
`renderer/presented-time.js`。**数字和指针都从"呈现时间"推导**,而不是各做一套动画。
```js
function jumpBetween(previous, next) { // 同一真实时刻,两个参数算出的目标值之差
const at = next.baseRealMs;
return targetAt(next, at) - targetAt(previous, at);
}
const animate = layer.snapshot !== null && snapshot.rate !== 0
&& Math.abs(jump) > JUMP_THRESHOLD_MS; // 1500ms
```
三个细节,**别在重构时弄丢**:
1. **改倍率不会被误判成跳变** —— 比较的是"同一真实时刻的两个目标值",而模型在切倍率时先把当前显示时间固化进基准,两个公式在这一刻必然相等;
2. **数字和指针永远同步** —— 它们读同一个呈现时间,不会出现"数字到了、指针还在半路";
3. **不会跨 12 点倒着转一圈** —— 动画的是**时间**,指针角度照样按正常公式从时间算(359°→1° 自然往前扫)。这是这类动画最经典的坑,用"动画时间而不是动画角度"直接绕开。
阈值 1500ms、缓动 0.35s `ease-out`。**动画没结束又来新跳变时,从当前呈现值重新出发**(不是跳回旧值重来)。
### 2.3 无边框窗口:拖动与改大小都是自己实现的
`frame: false` 之后,Windows 上**靠系统做边缘命中不可靠**,所以:
- **拖动移动**:CSS `-webkit-app-region: drag`。整块 `.stage` 是拖动区,**按钮和 8 条热区单独声明 `no-drag`** —— 否则按钮会被拖动区盖住、点不动。
- **拖边缘改大小**:渲染进程热区接 `pointerdown/move`,用「按下瞬间的窗口 bounds + 鼠标位移」算新 bounds,走 IPC 让主进程 `setBounds`。几何计算在 `renderer/resize.js`(纯函数,17 条用例)。
- `pointerdown` 时用 `sendSync` 取 bounds —— **必须在这一刻拿到精确基准**;
- 顶到最小尺寸后继续拖,**被拖的那条边停住**,而不是把整个窗口推走(`resize.js` 里有专门处理与用例);
- 主进程 `applyBounds` 再夹一次最小值并挡掉 `NaN`。
最小尺寸 **260×150**(`main.js` 的 `MIN_SIZE`)。
---
## 三、环境与命令
```bash
pnpm install --ignore-scripts # 依赖(见第〇节,必须带这个参数)
npm start # 起应用
npm test # 152 条用例(纯逻辑 + DOM 桩)
npm run selftest # GUI 自检:electron . --selftest,跑完出截图
```
- **本机(Linux)跑 `npm start` 要有显示环境**:无头时用 `Xvfb :99` + `--no-sandbox`,且 GPU 会刷一堆 MESA 报错 —— **那是正常的**(没有显卡),只要主进程不抛 JS 异常就算过。
- **纯逻辑测试不依赖显示环境**,随时能跑。
---
## 四、改代码的几条约定
- **纯逻辑尽量往模块里放**(`src/clock-model.js`、`renderer/resize.js`、`renderer/presented-time.js`),页面只接线 —— 这样测试不用浏览器。
- **改行为就同时改用例**,但**改断言之前先想清楚**:现有 152 条里有不少是钉边界的(例如"拖西边超过最小宽度时右边界不动"),它们是有意为之。
- **数字输入的两个坑**:
1. HTML/框架里 `type="number"` 的值可能是 `number` 而不是字符串 —— 本项目一律显式 `String(x)` 再用;
2. **补零**:`String(0)` 是 `"0"`,而时钟输入框按 `HH:MM:SS` 解析,所以必须 `pad2`。
⚠️ 这个 bug 只在**上午 9 点前**才露出来(`getHours()` 返回个位数),白天怎么测都是绿的 —— **凡是跟"当前时间"耦合的逻辑,测试里都要当心**(我们就在这里翻过一次车)。
- 注释写**为什么**,不写"这里做了什么"。
---
## 五、测试(152 条)
| 文件 | 条数 | 覆盖 |
| --- | --- | --- |
| `test/clock-model.test.js` | 26 | 走时:零漂移、倍率切换不跳变、暂停/继续、倒转、快进 |
| `test/resize.test.js` | 17 | 改大小几何:四边四角、最小尺寸夹紧 |
| `test/presented-time.test.js` | 30 | 跳变判定、阈值、重定向、暂停不缓动、换倍率不算跳变 |
| `test/renderer-clock.dom-test.js` | 24 | 时钟窗口 DOM 桩:渲染、样式切换、热区接线 |
| `test/renderer-clock-anim.dom-test.js` | 26 | 跳转动画全链路:参数下发 → 呈现时间 → 数字 + 指针 |
| `test/renderer-control.dom-test.js` | 29 | 控制窗口:各按钮发出的调用、输入夹紧、置顶开关 |
DOM 桩的做法是:把渲染进程的 JS 读进 `vm` 跑,喂一套最小 DOM 实现,断言它发出的 `clockApi` 调用。**不必启动 Electron**。
---
## 六、分支策略与 Windows 7 ★
### 两个分支,只差 Electron 版本
| 分支 | Electron | 给谁 | 备注 |
| --- | --- | --- | --- |
| **`main`** | **38.2.2** | Windows 10 / 11、Linux ARM64 | 主线,功能开发在这里做 |
| **`win7`** | **22.3.27** | **会议室那台 Windows 7 x64** | 也照样能在 Win10/11 上跑 |
**代码本身两个分支完全一样**(连测试都是同一套)—— 只有 `package.json` 里的 Electron 版本不同。所以**新功能在 `main` 上做完之后,把代码摘到 `win7` 上(cherry-pick 或直接合)重新出包即可**,不需要维护两份逻辑。
### 为什么锁在 Electron 22
**Electron 23 起彻底不支持 Windows 7 / 8 / 8.1**。官方在 [Windows 7/8/8.1 弃用公告](https://electronjs.org/blog/windows-7-to-8-1-deprecation-notice) 里写明:23 用的 Chromium 调用了 Win7 没有的系统 API,程序在 Win7 上一启动就报:
```
The procedure entry point GetPackageFamilyName could not be located
in the dynamic link library KERNEL32.dll
```
`GetPackageFamilyName` 这类是 Windows 8+ 才有的入口,Win7 的 `kernel32.dll` 里没有 → 加载器直接失败。
**Electron 22.3.27 是最后一个支持 Win7 的版本**(Chromium 108 / Node 16)。本项目要跑在会议室那台 **Windows 7 x64** 上,所以 `win7` 分支锁在这一版。
**升级 Electron 之前先确认那台机器还需不需要 Win7** —— 还想保 Win7 就不能越过 22。
### 怎么判断一个包能不能在 Windows 7 上跑(三条,从易到难)
```bash
# ① 看包内的 Electron 版本(不是看 package.json!)
type <解压目录>\version # 期望:22.x.x
# ② 看 PE 的子系统版本(这条最直观)
file <解压目录>/ClockLab.exe
# Electron 22 → "PE32+ executable for MS Windows 5.02 (GUI)" ← 兼容到 XP 时代 ✓
# Electron 38 → "PE32+ executable for MS Windows 10.00 (GUI)" ← 声明需要 Win10 ✗
# ③ 看导入表里有没有 Win8+/Win10+ 专有 API(最硬的一条)
pip install --break-system-packages pefile # 一次就够
python3 - <<'PY'
import pefile
pe = pefile.PE('ClockLab.exe')
names = {(e.dll.decode().lower(), i.name.decode()) for e in pe.DIRECTORY_ENTRY_IMPORT for i in pe.imports if i.name}
for t in ['GetPackageFamilyName','SetProcessDpiAwarenessContext','GetDpiForWindow','SetThreadDescription']:
hits = [d for d, n in names if t in n]
print(('FOUND ' if hits else 'absent '), t, hits)
PY
# 22 版的实测结果:这四条全部 absent(644 个导入条目里一个都没有)
```
> ⚠️ **别用"在文件里搜字符串"判断** —— Chromium 会**运行时动态解析**大量 API,这些名字出现在文件字节里很正常,搜到不代表会在启动时被调用(我第一次就是这么误报的,见第八节 ⑥)。
---
## 七、构建与打包
### 7.1 Windows(在 Windows 构建机上)
**必须在真 Windows 上打**,不要在这台 Linux 上跨平台打。踩过的坑见第八节,能跑的流程:
```bat
:: 1) 依赖:必须 --ignore-scripts
pnpm install --ignore-scripts
:: 2) 运行时:把对应版本的 electron zip 手工解压到一个目录
:: ⚠️ node_modules/electron 是 pnpm 的**符号链接**,tar 拒绝"穿过符号链接"解压
:: → 先解到临时目录,再 robocopy 进去(或像下面这样直接用独立目录)
mkdir D:\tmp\electron-dist
cd /d D:\tmp\electron-dist && tar -xf D:\tmp\electron-v22.3.27-win32-x64.zip
:: 3) 出包:直接调 electron-builder 的 CLI(`pnpm run` 会先做依赖检查、可能 EPERM)
:: 用 --config.electronDist 明确告诉它运行时在哪,别让它走"下载"那条分支
node node_modules\electron-builder\out\cli\cli.js --win --x64 ^
--config.electronDist=D:\tmp\electron-dist
:: 4) 压缩:它的 zip target 会去下 7zip 工具(见第八节 ①),自己压更稳
cd dist && ren win-unpacked ClockLab-1.0.0-win7-x64
tar -a -c -f ClockLab-1.0.0-win7-x64.zip ClockLab-1.0.0-win7-x64
```
- 第 3 步跑完,`dist\win-unpacked\` 就是**可运行的完整程序**(第 4 步只是把它压成 zip);
- 第 4 步里那个 `ren` 是**必须的**(第 4 步要在 `dist` 目录里执行)—— 顺手把目录名带上平台,免得两份包解压出来混淆。
- **运行时下载**:`https://npmmirror.com/mirrors/electron/<版本>/electron-v<版本>-win32-x64.zip`(浏览器/curl 都行;**别依赖 `ELECTRON_MIRROR` 环境变量**,它的安装脚本可能因为要抓校验文件而 404)。
### 7.2 Linux ARM64(在本机打)
**手工拼**,不要用 electron-builder(它在 Linux 上有一条必崩的路径,见第八节 ①):
```bash
cp -a node_modules/electron/dist/. dist/ClockLab-1.0.0-linux-arm64/ # 运行时
mv dist/ClockLab-1.0.0-linux-arm64/electron dist/ClockLab-1.0.0-linux-arm64/clocklab
mkdir -p dist/ClockLab-1.0.0-linux-arm64/resources/app # 应用代码
cp main.js preload.js package.json dist/ClockLab-1.0.0-linux-arm64/resources/app/
cp -a src renderer dist/ClockLab-1.0.0-linux-arm64/resources/app/
tar -C dist -czf dist/ClockLab-1.0.0-linux-arm64.tar.gz ClockLab-1.0.0-linux-arm64
```
**验证**:本机就是 arm64,解压后 `./clocklab` 起得来即说明链接与资源都对(无头时配 Xvfb)。
---
## 八、踩坑记录
> **看这一节能省几个小时。** 这些都是实际撞过的,不是转述。
### ① electron-builder 在"需要下载"这条分支上必崩
```
⨯ Cannot read properties of undefined (reading 'ReadWrite')
at resolveCacheMode (app-builder-lib/src/util/electronGet.ts:131)
```
根因:`ElectronDownloadCacheMode` 在运行环境里是 `undefined`,代码却直接读它的 `.ReadWrite`。**26.15.3 与 26.16.1 都有**,Windows 与 Linux 都复现。触发点是"它认为需要下载 Electron 运行时 / 7zip 工具",所以:
- 用 `--config.electronDist=<运行时目录>` 让它**不需要下载**;
- 打 zip 时它仍要 7zip 工具,那条路也会崩 → **压缩自己来**。
### ② 手工放运行时还不够,得让它"知道"运行时在哪
- electron 包装里的 `path.txt` **不是** electron-builder 的判据(它走 `@electron/get` 的缓存机制);
- 正解是给它 **`electronDist`**。
### ③ 网络:下 Electron 运行时的几种办法与各自的坏法
- **直连 GitHub**:可能失败(`%TEMP%` 里堆一排 `electron-download-*` 重试目录);**但也不一定** —— 我们后来实测同一条网络两个源都通 200,所以**先试直连,失败再绕**;
- **`ELECTRON_MIRROR` 指 npmmirror**:可能 404(它的安装脚本会去抓校验文件,镜像上没有);
- **gh 加速站**:实测可能 403;
- **最稳的兜底**:**在能上网的机器上把 zip 下好**(下载跟平台无关,Windows 版的 zip 在 Linux 上下是一模一样的),再传过去手工放。
### ④ pnpm 11 的 `strictDepBuilds`
只要有依赖的构建脚本被跳过,`pnpm install` 会**直接以退出码 1 失败**(`ERR_PNPM_IGNORED_BUILDS`),而 electron 的 postinstall 正是下载运行时那一步。放行写在 `pnpm-workspace.yaml` 的 `allowBuilds`(`package.json` 的 `pnpm` 字段与 `.npmrc` 的 `onlyBuiltDependencies` 在 pnpm 11 已不生效)—— **这个文件别删**。
### ⑤ `pnpm run` 会先做依赖状态检查
`pnpm run dist:win` 可能触发一次 `install`,在 `node_modules` 被动过的目录上会 `ERR_PNPM_EPERM`(rename 失败)。**直接调 CLI**(`node node_modules/electron-builder/out/cli/cli.js …`)可绕过。
### ⑥ 判断 Win7 兼容性:**别搜文件字节,要读导入表**
第一次我用"在整个 exe 里搜 `GetPackageFamilyName`"来判断,**误报**了 —— 那个字符串确实在文件里,但**不在导入表**里。Chromium 大量使用运行时动态解析(`GetProcAddress`)来兼容老系统,字符串出现很正常。
**判据是导入表**(见第六节末尾那段脚本),再配上 `file` 读出的 PE 子系统版本,两条一起看。
### ⑦ 时间相关的 bug 只在特定时段露头
`String(0)` 是 `"0"` 而输入框要 `HH:MM:SS` —— 这个 bug **白天测全绿、上午 9 点前必挂**(`getHours()` 返回个位数)。凡是与"当前时间"耦合的逻辑,写测试时要格外小心(要么注入时钟,要么至少知道它在什么时段会失败)。
---
## 九、已知可以再做的
- **Windows 的 portable 单文件 exe** 还没做成(卡在 ⑧① 的 7zip 工具那条路)—— 绿色版 zip 功能等价,只是多一步解压;
- **表盘没有刻度数字**(只有刻度线与指针);
- **`main` 与 `win7` 的代码同步目前靠手工** —— 如果以后两边都要频繁改,可以考虑用一个构建期替换(例如把 Electron 版本做成环境变量)来避免分叉;
- **飞腾/龙芯等国产平台的适配**:本项目的 Linux 包是 **ARM64**(飞腾 D2000 可用);**龙芯是 LoongArch,Electron 没有那个版本**,跑不了。
---
## 十、变更记录(简)
| 时间 | 内容 |
| --- | --- |
| 2026-09-14 深夜 | 项目建立:两窗口、走时模型、无边框窗口的拖动与改大小、152 条测试;Linux ARM64 包 |
| 2026-09-15 | 加了**跳转动画**(呈现时间层,数字与指针共用);修了 `pad2` 那个时段性 bug |
| 2026-09-15 | 得知会议室是 **Windows 7** → 开 `win7` 分支降到 **Electron 22.3.27** 并出包(`main` 保持 38 不变) |