
收不到更新弹窗不是 BugUpdateReadiness 把通知吞了的真相【免费下载链接】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.swiftstruct 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 days 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.swiftfunc 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),仅供参考