原先下载完就立刻关闭应用装上,正在说话的用户会被一个关不掉的全屏遮罩打断。现在下载完 只在侧栏「关于」图标上提示,由用户点「立即安装」,或在下次关闭应用时静默装好。 顺带补上周期检查(原先只在启动时查一次,常驻托盘的用户几周都发现不了新版本)、安装包 sha512 校验、已下载包的复用;并修复自建服务器用户收不到更新 —— 更新 manifest 原先跟随 用户可改写的后端地址。设置里的「自动检测更新」开关不再暴露,底层设置项保留备用。
8.3 KiB
设计决策记录
只记「为什么这么做、否掉了什么方案」。实现细节看代码,操作步骤看 .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 通道,验新的下载 / 提醒 / 点击安装 / 退出兜底。