news 2026/10/10 11:27:03

收不到更新弹窗不是 Bug:UpdateReadiness 把通知“吞“了的真相

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
收不到更新弹窗不是 Bug:UpdateReadiness 把通知“吞“了的真相

收不到更新弹窗不是 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 }

这段代码信息量很大:

  1. 2 分钟重试:只要announce()返回 false(即被暂缓),重试计数 +1,并把下一轮等待压缩到min(wait, 120)秒;
  2. 30 分钟兜底:15 次暂缓后,withheldRetries到达上限,不再高频重试,回到"每日检查"的常规节奏;
  3. 每天只有一次"主动打扰"配额:普通情况下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 提供了三条完全绕开抑制机制的手动通道:

  1. 命令面板:输入"Check for Updates"(命令 ID 为command:check-for-updates,见 CommandID.swift)。触发时 LauncherCoordinator.swift 会先收起面板再调用updateCoordinator.checkForUpdates();
  2. 关于窗口:About 面板里有"Check for Updates"按钮(AboutView.swift);
  3. 菜单栏: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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 11:25:16

CleanCode AI编程标准代码生成器:从源头治理技术债的工程实践

1. 为什么“生成即规范”是个值得死磕的方向写代码这件事&#xff0c;很多人有个误区&#xff1a;觉得功能跑通了就万事大吉。但真正在项目里摸爬滚打过几年的人都知道&#xff0c;代码写出来只是开始&#xff0c;后面还有无数次的修改、调试、交接、扩展。一个功能今天能跑&am…

作者头像 李华
网站建设 2026/10/10 11:18:50

KDD Cup入侵检测三模型实战:贝叶斯+BP神经网络+KNN

简介&#xff1a;本资源是一套基于Python实现的入侵检测系统实战项目&#xff0c;面向网络安全初学者、机器学习入门者及高校相关课程实践者&#xff0c;聚焦于贝叶斯分类器、神经网络&#xff08;BP&#xff09;与K近邻&#xff08;KNN&#xff09;三大算法在IDS中的建模、训练…

作者头像 李华
网站建设 2026/10/10 11:18:17

基于STM32L073RZ与PCA9422的低功耗电源管理方案设计与实测

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/10 11:18:16

PCA9422与STM32F732IE电源管理方案:从硬件设计到软件调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华