CodexBar macOS 小组件实现指南:快照管线、六类 Widget 与可见性排障
2026/9/13 10:25:44 网站建设 项目流程

CodexBar macOS 小组件实现指南:快照管线、六类 Widget 与可见性排障

【免费下载链接】CodexBarShow usage stats for OpenAI Codex and Claude Code, without having to login.项目地址: https://gitcode.com/GitHub_Trending/co/CodexBar

CodexBar 通过一条「主 App 写快照、Widget 扩展读快照」的 WidgetKit 数据管线,把 OpenAI Codex、Claude、Cursor 等十余家 AI 编码服务的用量数据投射到 macOS 桌面上。本文基于仓库中 widgets 文档 与对应源码(快照数据模型、Widget 扩展、快照持久化)展开:读完你可以理解WidgetSnapshotJSON 的字段与写盘/超时/熔断机制,掌握六个 Widget 的配置意图与刷新策略,并能按文档中的六步流程定位「小组件库找不到 CodexBar Widget」这一类签名/注册/守护进程问题。

快照管线:主 App 与 Widget 扩展之间只有一份 JSON

整个管线只有一条数据通道:WidgetSnapshotStore把一份紧凑 JSON 快照写到 app group 共享容器,Widget 扩展在 timeline 中读取这份快照并渲染 usage / credits / history 状态。文件与路径的关键事实都可以从源码确认:

  • 快照文件名固定为widget-snapshot.json,见 AppGroupSupport.swift 中的widgetSnapshotFilename
  • 快照 URL 由 AppGroupSupport.snapshotURL 解析:优先使用当前 teamID 对应的 app group 容器(<TEAMID>.com.steipete.codexbar,debug 构建追加.debug后缀,见 currentGroupID);拿不到容器时退回~/Library/Application Support/CodexBar/本地目录(localFallbackDirectory)——这一「fallback 路径」正是后文「小组件只显示预览数据」的常见根因;
  • teamID 优先从代码签名读取(codeSignatureTeamID),其次读 Info.plist 的CodexBarTeamID,最后回落到默认值Y5PE65HELJ;仓库还内置了从旧版固定 group ID(group.com.steipete.codexbar/...debug)迁移快照与共享 UserDefaults 的一次性逻辑(migrateLegacyDataIfNeeded)。

WidgetSnapshot 数据模型

WidgetSnapshot.swift 定义了扩展与主 App 共同依赖的 Codable 结构,文档强调「保持数据形状与主 App 的WidgetSnapshot同步」即指此处。核心字段:

字段类型说明
entries[ProviderEntry]每个已启用 provider 一条,含updatedAtprimary/secondary/tertiary三档RateWindow(Session/Weekly/Opus 级别)、usageRows明细行、creditsRemainingcodeReviewRemainingPercenttokenUsagedailyUsage每日点、providerCostquotaOwnerKey
enabledProviders[ProviderInstanceID]当前启用的 provider 列表;旧快照缺失时解码器回落到entries中的 provider(自定义 decode)
usageBarsShowUsedBool进度条语义(显示已用量还是剩余量),旧快照默认false
generatedAtDate快照生成时间,序列化使用 ISO8601(encoder/decoder)

TokenUsageSummary承载 token 成本行(session/30 天成本与 token 数、币种、展示标签、自己的updatedAt)。这里有一个值得注意的「双时钟」设计:token 成本行的刷新节奏慢于配额行,当它落后配额数据超过 10 分钟staleLagThreshold = 10 * 60,见 staleLagThreshold)时,widget 会单独披露 token 行自己的时间戳,而不是继承ProviderEntry.updatedAtisStale对无updatedAt的旧版快照按「新鲜」处理(isStale)。

有界 I/O 与进程级熔断器

快照读写全部走WidgetSnapshotStoreperformBounded:把文件 I/O 丢到独立线程并用信号量限时,读 2 秒、写 10 秒(defaultLoadTimeout / defaultSaveTimeout)。一旦超时,会触发进程级熔断器,后续 load/save 全部直接返回 nil,并记录一条 “Widget snapshot I/O timed out; disabling container access for this process” 的 warning(tripBoundedIOCircuitBreaker)。这个设计的目的是防止 app group 容器 I/O 卡死(例如 macOS 26 上 TCC 门控导致的open()阻塞)拖挂 UI 线程——测试代码中也有注释直接说明了这一背景(测试文件)。

快照何时写入:主刷新管线之后

主 App 侧的写入口是 UsageStore.persistWidgetSnapshot(reason:):先基于内存中上一次排队的快照合并出新的WidgetSnapshot,再经由一个串行 Task(等待上一个持久化任务完成)写盘,生产环境先落盘、成功后才请求 WidgetKit reload(saveWidgetSnapshot)。从源码调用点看,写入触发时机覆盖:

  • 主刷新管线(reason: "refresh",见 UsageStore.swift);
  • token 用量刷新("token-usage")、Codex credits("credits")、OpenAI dashboard("dashboard")、Codex 账号状态变化("codex-account-refresh"/"codex-account-invalidate")、强制刷新富化、Codex 本地历史 catch-up("token-usage-catch-up")等十余处。

文档同时点明两条时序约束:

  • 较窄的单 provider 刷新路径可能等到下一次快照写入才会反映到 widget;
  • 定时 provider 刷新会触发常规的 token/cost 刷新,token/cost TTL 决定该次刷新是否合格;timer 驱动的本地历史刷新有15 分钟下限(低功耗模式 30 分钟);手动关闭只停掉周期性刷新 timer,不阻断全部扫描——启动刷新与挂起的 Codex catch-up 仍可能扫本地历史。这个下限只限制重复的本地历史工作与额外的 WidgetKit reload 请求,不改变 provider 用量/状态的新鲜度或用户选择的刷新节奏。

Claude 的两个特殊规则

  1. 无配额数据的账号也能进快照makeWidgetEntry允许 Claude 在完全没有数值 session/weekly 配额(snapshot == nil)时,凭本地成本/token 历史(storedTokenSnapshot)单独进入 widget 快照(判定逻辑)。
  2. 模型级 weekly 配额行是 opt-in 的:Claude Usage widget 可以在常规 Session / Weekly / Opus 行之后展示每个已知模型的作用域 weekly 配额(Claude 暴露 Fable 时包含 Fable)。该行为由Preferences → Providers → Claude → Show model-specific weekly usage in widgets开关控制(settings.claudeModelScopedWeeklyUsageVisible),默认关闭,不影响抓取,也不影响 CodexBar 其他界面;作用域行以claude-weekly-scoped-ID 前缀识别(前缀常量)。关掉开关后,此前快照里保留下来的作用域行也会被移除——即便当前没有新的 Claude 配额数据:保留逻辑preservedClaudeWidgetUsage在复用旧快照行时会重新施加该可见性过滤(re-apply 过滤)。
  3. 配额归属校验:Claude 快照带quotaOwnerKey,账号/profile 切换后 key 不匹配的旧配额数据会被丢弃而不是错误沿用(保留逻辑见 preservedClaudeWidgetUsage)。

测试隔离:必须显式 opt-in 才落盘

文档明确要求「测试必须通过内存 save override 或测试自有的快照 URL 来 opt-in 快照持久化」。源码侧的门是 shouldPersistWidgetSnapshot:运行测试时没有 override/injected URL 就完全不落盘;两种 opt-in 都不会触发 WidgetKit timeline reload。save/reload 辅助函数接受显式的「是否测试模式」与 reload 回调,因此测试可以用临时文件 + 假回调验证「先保存、后 reload」的顺序,而不触碰进程级测试隔离。这一组行为由 WidgetSnapshotTestIsolationTests 逐项钉住,另有 UsageStoreWidgetSnapshotTests 覆盖各 provider 的快照行投影。

扩展结构与六种 Widget

扩展侧的事实如下:

  • Sources/CodexBarWidget 目录包含全部 timeline provider 与视图(AppIntentTimelineProvider/TimelineProvider实现);
  • WidgetExtension/CodexBarWidgetExtension.xcodeproj 把上述源码构建成打包进主 App 的 macOS WidgetKit app extension;从 project.yml 可以看到 target 是app-extension类型、部署目标macOS 14.0、源码直接引用../Sources/CodexBarWidget、依赖CodexBarCore产品,bundle ID 由构建变量CODEXBAR_WIDGET_BUNDLE_ID注入,Info.plist 中NSExtensionPointIdentifier固定为com.apple.widgetkit-extension
  • Usage / Switcher / History / Metric 四类 widget 使用 WidgetKit 的 content margins,视图自身不再叠加第二层 outer inset。

CodexBarWidgetBundle(注册入口)共注册六种 widget:

显示名Widget structkind / 配置意图支持尺寸
CodexBar SwitcherCodexBarSwitcherWidgetStaticConfiguration,无配置small / medium / large
CodexBar UsageCodexBarUsageWidgetAppIntentConfiguration+ProviderSelectionIntentsmall / medium / large
CodexBar HistoryCodexBarHistoryWidgetAppIntentConfiguration+ProviderSelectionIntentmedium / large
CodexBar MetricCodexBarCompactWidgetAppIntentConfiguration+CompactMetricSelectionIntentsmall only
CodexBar Burn DownCodexBarBurnDownWidgetAppIntentConfiguration+BurnDownSelectionIntentmedium only
CodexBar Burn Down (Combined)CodexBarCombinedBurnDownWidgetAppIntentConfiguration+BurnProviderSelectionIntentmedium only

Metric 的指标枚举CompactMetric提供三项:Credits left / Today cost / 30d cost(CompactMetric)。Burn Down 两个 widget 声明了可移除容器背景(containerBackgroundRemovable)。

Switcher 的共享选择 vs Usage 的独立配置

所有 Switcher widget 共享同一个记忆中的 provider 选择:选择存储在 app group 共享 UserDefaults 的widgetSelectedProvider键中(WidgetSelectionStore),点击切换会执行 SwitchWidgetProviderIntent——写共享默认值并WidgetCenter.shared.reloadAllTimelines(),因此切换一个 Switcher 会联动全部 Switcher。若想同时并排观察 Claude 和 Codex,文档给出的做法是添加两个CodexBar Usagewidget 并分别配置各自的Provider:Usage/History 走ProviderSelectionIntent,读的是自己配置的 provider,不受共享 Switcher 选择影响。Switcher 可选列表也来自快照的enabledProviders,并过滤掉没有ProviderChoice的 provider(supportedProviders)。

无快照时的回退

timeline 的timeline(for:)WidgetSnapshotStore.load()返回 nil 时,Usage/Switcher/History 回退到WidgetPreviewData.emptySnapshot()(空数据),placeholder回退到WidgetPreviewData.snapshot()(CodexBarTimelineProvider 等)。这也解释了「小组件出现了但一直显示预览数据」的症状:扩展读到了文件,但读到的是空/陈旧快照,或主 App 根本写在了 fallback 路径上。

Provider Picker 支持范围

可配置的 provider widget(Usage / History / Metric / Burn Down 的ProviderChoice)目前覆盖17 个选项(ProviderChoice 枚举与 DisplayRepresentation):

rawValue配置界面显示名
codexCodex
claudeClaude
geminiGemini
alibabaAlibaba
alibabatokenplanAlibaba Token Plan
qwencloudQwen Cloud
antigravityAntigravity
cursorCursor
zaiz.ai / GLM
copilotCopilot
devinDevin
minimaxMiniMax
kiloKilo
opencodeOpenCode
opencodegoOpenCode Go
mistralMistral
kimiKimi Code

文档正文列出的是早期批次(Codex、Claude、Cursor、Gemini、Alibaba、Antigravity、z.ai、Copilot、MiniMax、Kilo、OpenCode、OpenCode Go),上表是源码中当前完整的可选项。两条实现约束值得注意:

  • caseDisplayRepresentations必须是字面量、穷尽的字典,因为 AppIntents 会静态抽取这份显示名元数据(源码注释),新增 provider 时必须同步补齐,测试会将其与 descriptor registry 钉在一起;
  • 没有ProviderChoicecase 的 provider 仍然可以出现在 app 快照里,只是暂不可从 widget 配置 UI 选择;反向构造ProviderChoice(provider:)还要求对应 descriptor 的widgetSelectable元数据为真(init?)。

Burn-down 类 widget 目前支持 Codex 与 Claude;它们使用专属配置意图BurnDownSelectionIntent/BurnProviderSelectionIntent),不会改动已有 Usage 与 History widget 的配置(意图接线见 CodexBarWidgetBundle.swift)。

可见性排障(macOS 14+)

当 widget 在小组件库里完全看不到时,问题几乎总是注册、签名或守护进程缓存问题,而不是 SwiftUI 代码。以下六步来自 widgets 文档 原文,命令可直接复制执行。

1) 确认扩展 bundle 在 macOS 期望的位置

APP="/Applications/CodexBar.app" WAPPEX="$APP/Contents/PlugIns/CodexBarWidget.appex" WIDGET_ID="com.steipete.codexbar.widget" # debug builds use com.steipete.codexbar.debug.widget ls -la "$WAPPEX" "$WAPPEX/Contents" "$WAPPEX/Contents/MacOS"

2) PlugInKit 注册状态(pkd)

pluginkit -m -p com.apple.widgetkit-extension -v | grep -i codexbar || true pluginkit -m -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv

说明:输出中+表示「被选中使用」、-表示「被忽略」(PlugInKit election)。若缺失或被忽略,强制添加并重新选举:

pluginkit -a "$WAPPEX" pluginkit -e use -p com.apple.widgetkit-extension -i "$WIDGET_ID"

检查重复注册(旧安装或版本优先级问题):

pluginkit -m -D -p com.apple.widgetkit-extension -i "$WIDGET_ID" -vv

若出现多个路径,删除旧版本安装并提升CFBundleVersion

3) 代码签名与 Gatekeeper 评估

widget 由系统守护进程加载,任何签名失败都会隐藏 widget:

codesign --verify --deep --strict --verbose=4 /Applications/CodexBar.app codesign --verify --strict --verbose=4 "$WAPPEX" codesign --verify --strict --verbose=4 "$WAPPEX/Contents/MacOS/CodexBarWidget" spctl --assess --type execute --verbose=4 /Applications/CodexBar.app

4) 重启正确的守护进程(只重启 NotificationCenter 不够)

killall -9 pkd || true sudo killall -9 chronod || true killall Dock NotificationCenter || true

5) 打开小组件库时盯日志

log stream --style compact --predicate '(process == "pkd" OR process == "chronod" OR subsystem CONTAINS "PlugInKit" OR subsystem CONTAINS "WidgetKit")'

6) 打包一致性检查

  • release 构建的 widget bundle ID 应为com.steipete.codexbar.widget,debug 构建为com.steipete.codexbar.debug.widget(与 project.yml 中$(CODEXBAR_WIDGET_BUNDLE_ID)的注入值对应);
  • NSExtensionPointIdentifier必须是com.apple.widgetkit-extension(Info.plist 已固定该值);
  • bundle 目录名必须匹配:CodexBarWidget.appex

可选(很少有效,但风险低):重新播种 LaunchServices:

/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister -seed

出现后仍显示预览数据:stale data 排查

如果 widget 出现了但永远显示预览/空数据,典型原因是写读两侧解析到了不同容器:主 App 把快照写进了 fallback 路径(Application Support/CodexBar),而 widget 读的是 app group 容器。验证方式是确认 app 与 widget 两端解析到同一个 app group 容器——从源码看,两端共用 AppGroupSupport.currentContainerURL 与snapshotURL逻辑,teamID / debug 后缀不一致(例如主 App 是 release、扩展误用了 debug group)就会造成这种错位。

相关文档:UI 指南、打包指南。

【免费下载链接】CodexBarShow usage stats for OpenAI Codex and Claude Code, without having to login.项目地址: https://gitcode.com/GitHub_Trending/co/CodexBar

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询