拆开 UpdateReadiness:7 种忙碌场景、三道闸门,一个更新弹窗的产品哲学 拆开 UpdateReadiness7 种忙碌场景、三道闸门一个更新弹窗的产品哲学【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast对于一款靠全局热键随叫随到、常驻内存的启动器来说更新弹窗的打扰成本和其他应用不在一个量级用户可能正在让片段展开到某个文档里可能正在跑一条扩展命令也可能正按着热键录制快捷键。而 macOS 上的自更新意味着把正在运行的应用从脚下换掉——一旦打断的时机不对丢的不是一次通知而是正在进行的操作。Tinycast 在 Swift 中用一个不足两百行的UpdateReadiness机制回答了这个问题它不追求弹得快而是追求弹得准。本文基于 Tinycast/Features/Updates 模块的真实源码拆解这套就绪判定逻辑与三道防打扰闸门看看一个更新弹窗背后藏了多少产品判断。一、为什么启动器是最怕更新弹窗的那类应用先看 Tinycast 自更新的姿势它没有引入 Sparkle也没有 appcast更新源就是网站已经在读的 GitHub Releases 接口由UpdateCheckStore每天检查一次。这个选择本身意味着更新链路完全内聚在 Updates 模块里而模块注释第一行就写明了问题本质——Whether it is safe to interrupt the user and swap the app out from under them是否安全到可以打断用户、把应用从他们脚下换掉见 UpdateReadiness.swift。换掉正在运行的应用是自更新与手动下载的本质区别。Tinycast 又是一个高频交互工具剪贴板粘贴序列、片段展开、扩展命令、快捷键录制都可能处于进行中状态。docs 对这条不变量的表述是An automatic prompt defers to whatever the user is doing, and is never spent unshown——自动弹窗必须让位于用户正在做的事而且被让位的弹窗不能算已经展示过见 docs/features/updates.md。这两句话就是整套机制的价值观起点。二、七个判定分支六种忙碌场景外加一个就绪态UpdateReadiness的输入是一个只含六个布尔位的纯数据结构UpdateActivity代码注释点明设计意图Every flag is injected, so the decision stays pure每个标志位都被注入决策因此保持纯净struct UpdateActivity: Sendable { var isExpandingSnippet false var isRunningExtension false var isUninstalling false var isRecordingHotKey false var isShowingDialog false var isPaletteVisible false }这些标志位不是凭空捏造的而是AppCore从各功能模块读取的真实运行时信号集中在 AppCore.swift 的currentActivity计算属性里var currentActivity: UpdateActivity { UpdateActivity( isExpandingSnippet: textInjector.isDelivering, isRunningExtension: extensions.running ! nil, isUninstalling: uninstall.isTrashing, isRecordingHotKey: hotKeys.recordingAction ! nil, isShowingDialog: isShowingDialog, isPaletteVisible: paletteCoordinator.isVisible) }每一个信号的来源都对应真实的有进行中操作状态textInjector.isDelivering表示一次粘贴仍在飞行中、或剪贴板租约未释放见 TextInjector.swiftextensions.running ! nil表示有扩展命令在跑uninstall.isTrashing表示卸载器正在删除文件见 UninstallSession.swifthotKeys.recordingAction ! nil表示快捷键录制会话正在捕获按键见 HotKeyManager.swiftisShowingDialog由DialogController的呈现回调驱动paletteCoordinator.isVisible则是启动器主面板本身的可见性——毕竟更新弹窗抢的正是这个面板的屏幕和焦点。判定函数本身极其朴素是一个按优先级顺序手写 if 的纯函数/// Ordered by consequence: an interrupted install loses work, an open panel does not. static func evaluate(_ activity: UpdateActivity) - Blocker? { if activity.isExpandingSnippet { return .expandingSnippet } if activity.isRunningExtension { return .runningExtension } if activity.isUninstalling { return .uninstalling } if activity.isRecordingHotKey { return .recordingHotKey } if activity.isShowingDialog { return .dialogOpen } if activity.isPaletteVisible { return .paletteOpen } return nil }这段代码的信息量不在语法而在顺序本身就是产品判断。注释写得很直白按后果排序——中断安装会丢失工作一个开着的面板则不会。所以当用户同时开着面板、弹着对话框、还在展开片段时evaluate只报告代价最高的那一个expandingSnippet。测试 updates-test.swift 的blocksWhileBusy专门验证了这一点六种忙碌全部置位时返回的必须是.expandingSnippet。这也是代码与文档口径完全一致的七个判定分支support.md 称之为 seven-case gate六种忙碌场景加一个空闲就绪态构成七种结果。每个Blocker还自带一句人话消息直接显示在更新窗口里Waiting for a snippet to finish expanding.等待片段展开完成、Close the open dialog first.请先关闭打开的对话框……更新窗口收到.blocked状态时会渲染一个时钟图标加上这句消息并给出 Try Again 按钮见 UpdateWindowView.swift。用户看到的不是更新失败而是现在不适合更新以及为什么——这是阻止逻辑的产品化表达。三、三道闸门从提醒到安装的完整防线就绪判定只是第一层。真正让不打扰成立的是三道闸门它们分别守卫提醒、安装、复现三个环节。闸门一单版本单次提醒被抑制的提示仍然欠着UpdateCheckStore维护着两个版本级别的记忆announcedVersion本次启动内已提醒过的版本和skippedVersion用户永久跳过的版本。announce()是唯一入口private func announce() - Bool { guard !Task.isCancelled else { return true } guard let release unskippedUpdate, announcedVersion ! release.version else { return true } guard onUpdateAvailable?(release) ?? true else { return false } announcedVersion release.version return true }注意最后两行的联动onUpdateAvailable回调到UpdateCoordinator.presentIfAvailable只有当弹窗真正落地返回 true时announcedVersion才被写入。如果弹窗被就绪判定拦下返回 false版本不会被标记为已提醒——这条提示仍然欠着用户后台泵会每 2 分钟回来重试一次最多 15 次即最长 30 分钟内确保送达然后才回落到每日检查的节奏private static let withheldInterval: TimeInterval 120 private static let withheldRetryLimit 1530 秒的启动延迟startupDelay也是这条防线的组成部分首次检查被推迟到启动半分钟后注释解释了原因——Keeps the first check, and any window it raises, clear of the login rush让第一次检查以及它可能弹起的窗口避开登录时段的拥挤。用户一开机就直奔启动器弹窗绝不能在此时抢跑。docs 里有一个更具体的场景用户手动拉起应用后 30 秒唯一的提醒机会正好撞上用户打开的面板此时被抑制的提示会通过 2 分钟重试机制还给用户——这正是这套再投递设计存在的意义。闸门二安装前二次校验点击瞬间重新问一次第一道闸门防的是不该提醒时提醒第二道闸门防的是该装时状态已经变了。install()在用户点击Update Now的瞬间重新执行一次就绪判定而不是信任弹窗出现时的旧状态// Re-asked at the moment of the click, never read from a flag that could have gone stale. if let blocker UpdateReadiness.evaluate(core.currentActivity) { stage .blocked(blocker, release) return }这个设计非常关键从弹窗出现到用户点击之间可能隔了几十秒用户可能已经又展开了片段、又打开了对话框。代码注释特意强调never read from a flag that could have gone stale绝不读取可能过期的标志。被二次拦截后状态机转入.blocked分支窗口切换为等待原因 Try Again安装动作本身被彻底拒之门外。闸门三跳过即永久静默且只对这一个版本生效第三道闸门处理用户明确不想要。窗口里的Later按钮不是简单关闭而是调用skip()func skip() { if let release pendingRelease { store.skip(release) } window.close() }store.skip把版本号写进skippedVersion并持久化到update-check.json缓存文件。docs 对它的定义只有一句话Later means skip——跳过这个版本这个版本就此闭嘴但下一个新版本照常询问。ReleaseFeed.offer的逻辑保证了这一点if let skipped, release.version skipped { return nil }——只有不高于已跳过版本的更新被过滤更新的版本不受影响见 ReleaseFeed.swift。手动触发的 Check for Updates 则完全无视跳过记录永远展示比当前版本新的内容——静默只约束自动打扰不约束用户的主动查询。值得一提的是闸门二和三在状态机里是联动的pendingRelease对.available和.blocked两种状态都能取出版本所以用户被二次拦截后点Try Againretry()会重新回到.available即稍后重试永远不会丢版本、丢下载成果。四、纯函数、零配置一套可共享的不打扰基础设施把三道闸门串起来看能发现一个共同的设计基调决策是纯的状态是有归属的配置是近乎为零的。UpdateReadiness.evaluate是一个无副作用的静态纯函数输入UpdateActivity输出Blocker?。六个标志位由AppCore.currentActivity注入而UpdateCoordinator只负责读结果、改状态机。这种信号采集与决策计算分离的结构让判定逻辑可以脱离 UI 独立存在也被测试完整覆盖——blocksWhileBusy遍历了每一种忙碌场景、多场景叠加的优先级以及每个 blocker 消息的完整性。配置层面同样克制。整套机制没有任何公开的配置项没有更新提醒间隔、没有免打扰时段、没有忙碌敏感度滑块。唯一的开关是 Settings → General 里的Automatically check for updates默认开启它控制的是整个后台检查任务的存在与否——关掉后store.stop()会取消泵任务、重置重试计数任何在途的自动请求都随之终止见 UpdateCheckStore.swift。机制本身怎么工作用户不需要也不应该关心这正是纯函数式、零配置的产品取舍把复杂度全部收敛在代码内部把用户界面留给一个二元开关。而不打扰这件事的价值最终体现在它的复用上。AppCore.canInterruptUser就是UpdateReadiness.evaluate(currentActivity) nil这个判断同时被支持提醒功能使用——SupportReminderStore在弹提醒前先问canInterruptUser被让位的提醒同样保持欠着状态10 分钟后而非一个月后再来。docs/features/support.md 特别注明这是the same seven-case gate the update prompt uses, shared rather than copied更新弹窗用的同一个七分支闸门共享而非复制。一个纯函数同时约束了两条打扰用户的通道——这才是它作为基础设施的意义。结语宁可晚送达不可打断回看这套机制最值得借鉴的不是某个算法而是三个朴素的产品原则提醒要避让用户的真实动作被避让的提醒不能算数安装动作要在点击瞬间重新验证绝不信任过期状态静默只针对当前版本永远给下一个版本留机会。三者共同保证了更新提醒在 Tinycast 里不是一个会抢焦点的意外而是一个被调度得体的服务。对一个每天被高频召唤的启动器来说什么时候不打扰和什么时候提醒同样重要。UpdateReadiness 用不到两百行代码给出了一种相当优雅的答案把打扰变成一次有状态、有优先级、可重试、可放弃的完整决策而不是一个简单的 if 弹窗。【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考