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 一条,含updatedAt、primary/secondary/tertiary三档RateWindow(Session/Weekly/Opus 级别)、usageRows明细行、creditsRemaining、codeReviewRemainingPercent、tokenUsage、dailyUsage每日点、providerCost、quotaOwnerKey |
enabledProviders | [ProviderInstanceID] | 当前启用的 provider 列表;旧快照缺失时解码器回落到entries中的 provider(自定义 decode) |
usageBarsShowUsed | Bool | 进度条语义(显示已用量还是剩余量),旧快照默认false |
generatedAt | Date | 快照生成时间,序列化使用 ISO8601(encoder/decoder) |
TokenUsageSummary承载 token 成本行(session/30 天成本与 token 数、币种、展示标签、自己的updatedAt)。这里有一个值得注意的「双时钟」设计:token 成本行的刷新节奏慢于配额行,当它落后配额数据超过 10 分钟(staleLagThreshold = 10 * 60,见 staleLagThreshold)时,widget 会单独披露 token 行自己的时间戳,而不是继承ProviderEntry.updatedAt;isStale对无updatedAt的旧版快照按「新鲜」处理(isStale)。
有界 I/O 与进程级熔断器
快照读写全部走WidgetSnapshotStore的performBounded:把文件 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 的两个特殊规则
- 无配额数据的账号也能进快照:
makeWidgetEntry允许 Claude 在完全没有数值 session/weekly 配额(snapshot == nil)时,凭本地成本/token 历史(storedTokenSnapshot)单独进入 widget 快照(判定逻辑)。 - 模型级 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 过滤)。 - 配额归属校验: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 struct | kind / 配置意图 | 支持尺寸 |
|---|---|---|---|
| CodexBar Switcher | CodexBarSwitcherWidget | StaticConfiguration,无配置 | small / medium / large |
| CodexBar Usage | CodexBarUsageWidget | AppIntentConfiguration+ProviderSelectionIntent | small / medium / large |
| CodexBar History | CodexBarHistoryWidget | AppIntentConfiguration+ProviderSelectionIntent | medium / large |
| CodexBar Metric | CodexBarCompactWidget | AppIntentConfiguration+CompactMetricSelectionIntent | small only |
| CodexBar Burn Down | CodexBarBurnDownWidget | AppIntentConfiguration+BurnDownSelectionIntent | medium only |
| CodexBar Burn Down (Combined) | CodexBarCombinedBurnDownWidget | AppIntentConfiguration+BurnProviderSelectionIntent | medium 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 | 配置界面显示名 |
|---|---|
codex | Codex |
claude | Claude |
gemini | Gemini |
alibaba | Alibaba |
alibabatokenplan | Alibaba Token Plan |
qwencloud | Qwen Cloud |
antigravity | Antigravity |
cursor | Cursor |
zai | z.ai / GLM |
copilot | Copilot |
devin | Devin |
minimax | MiniMax |
kilo | Kilo |
opencode | OpenCode |
opencodego | OpenCode Go |
mistral | Mistral |
kimi | Kimi 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.app4) 重启正确的守护进程(只重启 NotificationCenter 不够)
killall -9 pkd || true sudo killall -9 chronod || true killall Dock NotificationCenter || true5) 打开小组件库时盯日志
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),仅供参考