收不到更新弹窗不是 Bug:UpdateReadiness 把通知"吞"了的真相
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
在 macOS 启动器这类高频工具上,"更新弹窗"是一个随时可能砸断工作流的东西。但不少 Tinycast 用户遇到过这样的困惑:明明 GitHub Releases 上已经发布了新版本,应用却迟迟没有弹出更新提示,仿佛自动更新机制"坏掉了"。
这既不是网络问题,也不是更新机制失灵。真相藏在 UpdateReadiness.swift 这个不到 50 行的文件里——弹窗没有被"吞掉",而是被有意识地**暂缓(withheld)**了。而且官方文档在 docs/features/updates.md 里写得很直白:"An automatic prompt defers to whatever the user is doing, and is never spent unshown."(自动提示服从用户正在做的事,且永远不会被白白耗掉。)
下面用源码逐层拆开这个机制的真相。
弹窗先过"就绪检查",再决定是否出现
更新弹窗的自动路径入口是 UpdateCoordinator.swift 里的presentIfAvailable(_:):
/// The automatic path: `false` answers that it withheld the prompt, so the store re-offers it. func presentIfAvailable(_ release: AvailableRelease) -> Bool { guard core.settings.automaticallyCheckForUpdates else { return false } switch stage { // Already in hand: re-offering would throw away a download or the relaunch it earned. case .installing, .readyToRelaunch: return true case .checking, .upToDate, .localBuild, .available, .blocked, .failed: guard UpdateReadiness.evaluate(core.currentActivity) == nil else { return false } stage = .available(release) present() return true } }注意这个方法的注释:返回false意味着"弹窗被暂缓,还欠着一次提醒"。调用方 UpdateCheckStore.swift 里的announce()正是依据这个返回值决定"这版要不要记入已提醒名单":
/// `false` only when a pending release was withheld, so the pump comes back for it. 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 }所以整个链路是:发现新版本 → 查就绪状态 → 就绪才弹窗,不就绪则返回 false → 该版本不被标记为"已提醒" → 后台泵稍后再次尝试。这就是"被吞掉"的假象来源。
真相一:6 种忙碌场景,弹窗统统让路
UpdateReadiness是一个纯函数式判断器。它不持有任何状态,全部输入来自一个被注入的UpdateActivity结构体(UpdateReadiness.swift):
struct UpdateActivity: Sendable { var isExpandingSnippet = false var isRunningExtension = false var isUninstalling = false var isRecordingHotKey = false var isShowingDialog = false var isPaletteVisible = false } enum UpdateReadiness { enum Blocker: Equatable, Sendable { case expandingSnippet case runningExtension case uninstalling case recordingHotKey case dialogOpen case paletteOpen ... } /// 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 } }六个忙碌场景各自对应一个明确的"后果理由",Blocker.message会原样显示在更新窗口里:
- Snippet 正在展开(
expandingSnippet):文本正在被注入到光标处,此时抢焦点会切断输入; - 扩展命令正在运行(
runningExtension):等命令跑完,避免窗口抢占导致命令被中断; - 正在卸载(
uninstalling):卸载器正在清理文件,不能被替换中的应用打断; - 正在录制快捷键(
recordingHotKey):打断录制会让用户刚按下的键丢失; - 有弹窗开着(
dialogOpen):先关掉对话框再谈更新; - 启动器面板可见(
paletteOpen):用户正开着面板准备干活,弹窗不该盖上来。
这些标志位从哪来?在 AppCore.swift 的currentActivity中实时汇总:textInjector.isDelivering(文本注入中)、extensions.running(扩展运行中)、uninstall.isTrashing(正在卸载)、hotKeys.recordingAction(录制热键中)、isShowingDialog、paletteCoordinator.isVisible(面板可见)。
注释里那句 "Ordered by consequence" 是这套判断的排序哲学:按后果严重程度排列——打断一次安装会丢工作,而只是面板开着则无关痛痒。所以六种场景按从重到轻依次短路判断,一旦命中任何一个,弹窗就让路。
顺带一提,官方文档把这套机制列为更新模块的**硬性不变量(invariant)**之一,也就是说它不是临时补丁,而是被刻意维护的行为契约。
真相二:每 2 分钟重试一次,30 分钟内保证送达
暂缓不是放弃。真正撑起"送达保证"的是 UpdateCheckStore.swift 里三个精心调过的常量:
/// A withheld prompt is re-offered this often, this many times, then left to the daily check. private static let withheldInterval: TimeInterval = 120 private static let withheldRetryLimit = 15 /// Keeps the first check, and any window it raises, clear of the login rush. private static let startupDelay = Duration.seconds(30)120秒(2 分钟)一重试,最多15次——正好是 30 分钟。配合advance()的调度逻辑:
private func advance() async -> TimeInterval { let age = max(0, lastCheckedAt.map { Date().timeIntervalSince($0) } ?? .infinity) var wait = Self.refreshInterval - age if wait <= 0 { wait = await check() ? Self.refreshInterval : Self.retryInterval } if announce() { withheldRetries = 0 } else if withheldRetries < Self.withheldRetryLimit { // A launch straight into the palette must not spend the day's only announcement. withheldRetries += 1 wait = min(wait, Self.withheldInterval) } return wait }这段代码信息量很大:
- 2 分钟重试:只要
announce()返回 false(即被暂缓),重试计数 +1,并把下一轮等待压缩到min(wait, 120)秒; - 30 分钟兜底:15 次暂缓后,
withheldRetries到达上限,不再高频重试,回到"每日检查"的常规节奏; - 每天只有一次"主动打扰"配额:普通情况下
refreshInterval是 24 小时(按lastCheckedAt计算,重启也不会重复请求 GitHub),网络失败才降级为 2 小时重试;首次检查还会刻意延迟 30 秒,避开登录开机潮。
评论区那句"每 2 分钟重试、30 分钟内送达"的说法,在源码层面是精确成立的:只要用户在 30 分钟内有一次"空闲",弹窗就会出现;即使一直忙,30 分钟后机制也知趣地退回每日节奏,而不是无限骚扰。
三重防线:为什么它永远不会变成"骚扰弹窗"
暂缓机制能成立,还因为它有三道配套的防打扰闸门:
第一道:单版本单次提醒。announcedVersion一旦被设置,同一次启动里该版本就不会再主动弹窗(UpdateCheckStore.swift 中的 "At most one uninvited appearance per version per launch")。弹窗"暂缓→重试→送达"的过程只消耗这一次配额,送达后即封口。
第二道:点击瞬间二次校验。即便弹窗已经显示出来,用户点"Update Now"的那一毫秒,install()还会再查一次就绪状态(UpdateCoordinator.swift):
func install() { guard let release = pendingRelease else { return } // 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 } ... }"Re-asked at the moment of the click, never read from a flag that could have gone stale"——就绪状态是实时的,弹窗显示期间用户如果又开始录制热键或打开面板,点击安装会被礼貌地挡下,并进入.blocked状态,窗口里显示对应理由和"Try Again"按钮(见 UpdateWindowView.swift)。
第三道:跳过即静默。用户点"Later"会调用skip(),把该版本写入skippedVersion并持久化到缓存文件。此后这个版本彻底不再询问,直到更新的版本出现。
想主动看更新?正确姿势是手动"Check for Updates"
如果你不想等自动机制,Tinycast 提供了三条完全绕开抑制机制的手动通道:
- 命令面板:输入"Check for Updates"(命令 ID 为
command:check-for-updates,见 CommandID.swift)。触发时 LauncherCoordinator.swift 会先收起面板再调用updateCoordinator.checkForUpdates(); - 关于窗口:About 面板里有"Check for Updates"按钮(AboutView.swift);
- 菜单栏:Menu Bar 菜单中同样提供入口(MenuBarItem.swift)。
手动路径与自动路径的关键差异在 UpdateCoordinator.swift 的注释里:"The manual action: always opens the window and always asks GitHub."——永远打开窗口、永远实时询问 GitHub。它不受announcedVersion配额限制,也无视跳过记录(手动检查用的是store.update,该取值刻意忽略了skippedVersion),只要 GitHub 上有比你当前版本新的发布,就一定会展示出来。
这是排查"为什么没弹窗"时最值得记住的一步:弹窗机制的工作对象只是自动检查。如果你在Settings → General里关掉了 "Automatically check for updates"(设置项general.automaticallyCheckForUpdates,见 GeneralSettingsView.swift 和 docs/features/updates.md),那么自动弹窗本来就不会出现——而手动检查依然可用,界面上那句 "Check for Updates remains available when off" 说的就是这个。
结语:一次被"吞掉"的弹窗,背后是一次清醒的设计
回过头看,这个"更新弹窗被吞"的谜题,折射的是启动器这类工具的一个核心矛盾:更新提示本质上是一次打断,而打断的时机决定了它是体贴还是冒犯。
Tinycast 的答案不是简单的"弹"或"不弹",而是一整套有状态机的递进逻辑——用 6 个忙碌场景做实时判断,用 2 分钟/15 次/30 分钟做送达兜底,用"单版本单次 + 点击时二次校验 + 跳过即静默"三道闸门守住不骚扰的底线,最后把"主动查看"的通道永远留给用户。整个机制完全内聚在 Updates 模块内,没有暴露任何配置项,纯函数式的UpdateReadiness让每个分支都可测试、可推理。
所以下次再遇到"新版本没弹窗",不必怀疑更新机制坏了。先想三件事:你是不是正处在某个忙碌场景里?自动检查开关是不是关着?以及——你随时可以主动按一下 "Check for Updates",它永远不会让你失望。
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考