Files
SayIt/docs/decisions.md
T
crosswk 79c3f8a61c feat(自动更新): 后台静默下载,改由用户或退出时安装
原先下载完就立刻关闭应用装上,正在说话的用户会被一个关不掉的全屏遮罩打断。现在下载完
只在侧栏「关于」图标上提示,由用户点「立即安装」,或在下次关闭应用时静默装好。
顺带补上周期检查(原先只在启动时查一次,常驻托盘的用户几周都发现不了新版本)、安装包
sha512 校验、已下载包的复用;并修复自建服务器用户收不到更新 —— 更新 manifest 原先跟随
用户可改写的后端地址。设置里的「自动检测更新」开关不再暴露,底层设置项保留备用。
2026-08-12 16:16:32 +08:00

8.3 KiB
Raw Blame History

设计决策记录

只记「为什么这么做、否掉了什么方案」。实现细节看代码,操作步骤看 .kiro/skills/。 新决策往后追加,不改旧条目(结论变了就新开一条并注明取代哪条)。


2026-08 自动更新:静默下载 + 退出时安装

改之前是什么样:启动 → 发现新版 → 自动下载 → 停 3 秒 → app.exit(0) 装掉重启。 全程不问用户,UpdateDialog 那个 fixed inset-0 遮罩没有关闭按钮。 坏处很具体:开机自启后用户正按住说话,会被一个关不掉的遮罩糊住,接着应用自己退出。 唯一能拒绝的入口是设置里的「自动检测更新」开关。

现在的形状:后台静默下载 → 停在待安装 → 侧栏「关于」图标变绿闪烁,用户点进关于页装, 或者什么都不做,退出应用时兜底装。

为什么"强制"落在退出路径上,而不是"必须点那个图标"

只靠点击等于把更新变成完全自愿,不点的人永远留在旧版 —— 那就不是强制更新了。 安装本来就要求应用退出,所以退出那一刻是天然的安装时机:用户平时完全不用管更新, 关掉再打开就是新版。变绿的「关于」图标只是给"我现在就想更新"的人的加速通道。

代价是 install_downloaded_update 必须能选择不重新拉起应用(新增 relaunch 参数)。 退出路径上如果还把应用拉起来,表现成"这软件关不掉"。

实现落在 main.rs 的 RunEvent::Exit(因此 .run(ctx) 改成了 .build(ctx)?.run(闭包))。 两处必须让开这条路径,否则会起两个安装程序抢同一个 exe 的文件锁:

  • install_downloaded_update 自己会 app.exit(0),那次退出同样触发 Exit;
  • restart_app(导入配置后重启)的意图是"重启回同一个版本",不是更新。 都靠 INSTALLER_SPAWNED 这个原子标志屏蔽。

为什么必须加周期检查

检查逻辑原来只挂在 App.tsx 的挂载 effect 上,全仓库没有任何定时检查。 SayIt 是常驻托盘 + 开机自启的应用,很多用户几周不重启 —— 只在启动时查一次, 等于绝大多数人永远发现不了新版本。删掉设置里的开关并不能解决这件事,因为压根不触发。 现在 6 小时一次(CHECK_INTERVAL_MS),检查本身只是一个几百字节的 manifest 请求。

为什么「自动检测更新」开关只删 UI、保留读取

删掉整个设置项更干净,但被否掉了。0.0.8 那次更新器把用户锁在"装不上→重开→再更新"的 死循环里(见 pitfalls #3),当时能止血靠的就是这个开关。保留 DB 读取的成本几乎为零, 换来一条不用发新版就能远程指导用户关掉更新的通道 —— 更新链路自身出故障时, 发新版这条路正好是不通的。

附带理由:这是公开仓库,把唯一能关闭的对外网络请求开关整个删掉,在开源语境下容易被质疑。

为什么更新检查不跟随「后端地址」设置

updateChecker.ts 原来用 getBackendBaseUrl(),而这个值优先取用户保存的 backendBaseUrl。 后果:任何把后端指向自建服务器的用户,检查更新会去问他自己的服务器, 拿到 404 后被当成"没配更新"静默返回(resp.status === 404 时 error = null), 于是永远收不到新版本。改用 getUpdateBaseUrl(),固定在官方地址上,与业务后端解耦。 仍然尊重 VITE_BACKEND_BASE_URL,否则开发时没法把更新通道指到测试服务器验流程。

为什么这次才需要校验 sha512、才需要下载幂等

这两件事都是"下载完立刻安装"掩盖掉的:

  • 旧流程下安装包在磁盘上只存在几秒,被替换的窗口期极短;现在它要在 %TEMP%\sayit-update 里躺到用户点更新,可能是几天。manifest 早就给了 sha512(Base64,不是 hex),只是没人用。
  • 旧流程下"下载了但没装"不存在;现在这是常态。downloadedFilePath 原来只在内存里, 进程一退就没了 —— 不落盘的话每次开机都会把整包重下一遍,服务器出网流量按用户数乘。 所以 pendingUpdate(version + filePath + sha512)写进 app_settings, 启动时用 verify_update_package 确认还能用再复用。

顺带修掉 compareVersions 的一个静默失效:非数字段会产生 NaN,而 NaN > 0 为 false, 表现成"有更新也不报"且没有任何报错。现在非数字段按 0 处理。

phase 不许兼职表示「有包等着装」

第一版给 AutoUpdatePhase 加了一个 'ready',同时又有 pending 字段,等于同一件事有 两个真相来源,立刻出了 bug:restorePending 恢复出包后设 phase='ready',紧接着 runCheckAndDownload 一进来就 setState({ phase: 'checking' }) 把它盖掉;而"包已存在、 跳过下载"那条早退分支没人负责把 phase 改回去 —— phase 永久停在 'checking', 表现成关于页一直显示「正在检查更新...」、提醒图标永远不亮。

现在 phase 只表示活动(idle | checking | downloading | installing), 「有没有包等着装」只看 pending,两者正交。phase 的复位收在 runCheckAndDownload 的 finally 一处 —— 让每条早退分支各自记得复位,就是上面那个 bug 的成因。

排查过程本身也留了个教训:当时 sayit.log 里一条更新日志都没有,而 debugLog.ts 的 shouldMirrorPayload 对 info 级只放行 recorder / websocket / backend, source: 'update' 全被过滤掉了。整条更新链路是用户看不见的(后台检查、后台下载、 退出时安装),没有日志就等于没有诊断手段,而且"没有新版本"和"服务压根没跑起来" 在日志里长得一模一样。现在 update 源全放行,并且检查结束无条件记一条 {current, latest, hasUpdate, error}。

提醒放在哪:复用「关于」图标,不新增第四枚图标

第一版做的是侧栏底部新增一枚更新图标,点它直接弹安装确认框。否掉了,改成 让既有的「关于」图标自己变绿闪烁,点击照旧进关于页,安装动作留在关于页的 「立即安装」按钮上(那个按钮不再弹确认框,见下)。

理由:

  • 底部本来就有设置 / 关于 / 引擎指示三枚图标,第四枚既挤,又需要用户先学会它是什么意思;
  • 关于页正是更新所在的位置,那一屏有版本号、说明、「立即安装」和"不装也会自动装"的提示, 是个信息完整的确认现场。图标只负责"这里有事",不负责承载一个会关掉软件十几秒的动作;
  • 那枚图标的既有含义是导航。在它上面挂"点了就开始装",等于让同一个控件在不同时刻 做两件轻重完全不同的事。

外观上试过给绿图标垫一层 bg-success/10 做成徽标,在那排素图标里显得脏,去掉了 —— 颜色加 animate-pulse 已经足够醒目。下载期间图标毫无变化:那会儿没有任何需要 用户知道的事,静默才是本意。

绿色用主题的 --success 令牌,不写死 emerald,深浅主题各自有合适的明度。 注意这不违反 ModeIndicator 上方那条"不给任何好颜色"的规矩 —— 那条针对的是 我们没验证过的事(配置填完了 ≠ 真能用);"包已下载完、哈希校验过、随时可装"是事实。

「立即安装」不弹确认框

一度给它加过确认框,说明"会关闭十几秒、选稍后也不会漏掉"。撤了:用户是被侧栏那枚 变绿的图标引到关于页、看完状态说明之后才按下这个按钮的,按钮上写着「立即安装」, 再叠一层"你确定吗"只是多一次点击。那句"不装的话关闭应用时也会自动完成"移到了 按钮左侧的 about.readyHint,该说的信息没丢,只是不再拦人。

验证这类改动的特殊之处

新的更新逻辑没法自己验证自己。 用户手里的旧版升级时走的还是旧代码,新交互只在 "新版 → 更新版"之间生效。所以真机必须测两段:① 旧版能不能正常把这一版带上来(不砖); ② 造一个版本号 +1 的包推到 dev 通道,验新的下载 / 提醒 / 点击安装 / 退出兜底。