SwiftUI实现macOS菜单栏Claude订阅用量监控的实践总结
2026/9/23 13:45:24 网站建设 项目流程

一个能在 macOS 菜单栏直接显示 Claude 订阅用量的原生小工具,放在平时可能只是“方便了一点”,但如果你每天都在用 Claude 网页版,又会在终端里跑各种 Claude 相关命令,就会很理解这种需求:你不想等到对话突然被限流,才发现周期额度快用完了。这个 Show HN 项目的核心价值,不只是让你省去一次打开页面的操作,而是把一个云端服务最容易被忽视的信息——额度和周期——变成系统级别的状态提示。

这篇文章适合两类人。一类是 Claude 订阅用户,想自己动手做一个不依赖网页的用量提示器;另一类是 macOS 开发者,想知道 MenuBarExtra、网络拉取、定时刷新这种小型常驻工具怎么落地。我不会夸大它的功能,也不会假设你手里已经有源码,而是按普通开发者拿到这类题目后最可能遇到的顺序,把思路和坑都拆开讲。

1. 先不急着写代码,把“订阅用量”这个数字定义准确

很多项目一开始会直接冲去翻 API 文档,找“订阅用量查询接口”,但这个方向特别容易走偏。Claude 的用量体系里有好几套数字,第一版如果不能区分清楚,界面上显示的数字要么不准,要么会误导你。

1.1 订阅配额和 API 消耗不是同一套账

Claude 的收费和用量体系里,最少存在两套完全不同的统计:

  • 套餐订阅:面向 Claude 网页端和客户端用户,按周期提供一定量的高级模型使用机会。这类额度通常关心的是当前周期已经用了多少、还剩下多少、什么时候重置。
  • API 计费:按输入输出 token 计算费用。Message 接口返回里的 usage 字段,只表示这一次请求消耗了多少 token,和套餐剩余额度不是一回事。

如果你同时使用网页订阅和 API key,本地脚本一旦跑起来,API 的 token 消耗会快速增长。但菜单栏 App 标题写的是 subscription usage,那就应该优先处理套餐周期额度,而不是把 API 返回里的 usage 直接当成订阅余量。这两套账可以属于同一个账号,但计量单位、周期、计费规则完全不同。

项目标题其实已经把方向回答了一半:要显示的是订阅使用情况,不是 API cost。

1.2 先确定要展示“剩多少、用了多少,还是何时重置”

菜单栏的空间极其有限,不能指望用户像看网页一样在一条文字里读懂所有信息。第一版至少要明确这几个问题:

  • 当前周期的起止时间是什么
  • 已用额度和总限额分别是多少
  • 是否已经接近或超过上限
  • 周期什么时候重置

这些小问题决定了 UI 怎么设计。如果你的需求只是“显示剩余百分比”,那菜单栏一行文字就够了。如果还要知道“还有几天重置”,那点击后的弹出面板里就需要展示更多字段。

我建议第一版不要做复杂图表,先做三行内容:标题、百分比、剩余时间。第二版再考虑加历史和趋势。很多菜单栏工具翻车,不是因为功能不够多,而是因为用户连最基本的“当前到底用了多少”都看不明白。

2. 菜单栏工具的开发环境与最小骨架

这类工具本质上是一个常驻后台的轻量 App。难点不在网络请求,而在“怎么让一个 SwiftUI 应用只在菜单栏出现,不干扰正常使用”。

2.1 SwiftUI MenuBarExtra 是更省力的入口

如果你的 Mac 系统在 macOS 13 或更高版本,用 SwiftUI 的 MenuBarExtra 会比 AppKit 的 NSStatusItem 省很多事。它把菜单栏图标、点击弹出、下拉内容都统一成一个 Scene 来描述,代码量明显更少。

开发环境其实不复杂:

  • 安装最新版 Xcode
  • 新建一个 macOS App 项目
  • 部署目标按需设置为 macOS 13 以上
  • 本地直接运行即可,不一定要开发者账号

如果打算只做个人自用工具,本地 Build 后运行就能出现在菜单栏。如果要发布给其他人,那就需要 Apple 开发者账号和签名流程。这里说的是通用经验,具体签名要求以你当时的开发者账号能力为准。

2.2 把状态对象放 App 级别,别放进弹出视图

菜单栏小工具最常见的一个错误,是把网络请求和刷新逻辑放在 MenuBarExtra 的 content 视图里。

看起来这样写顺手,但实际使用中会遇到一个很尴尬的问题:用户点开菜单栏弹出面板,然后点空白处收起面板,这个 View 的任务可能就被取消。等下一次再点开,刷新又从零开始,定时任务也可能永远跑不到。

更稳的做法是,把一个 ObservableObject 放 App 结构里,让它成为全局单例。菜单栏的弹出视图只负责读取状态和触发刷新,不持有核心数据。

2.3 一个方便调试的最小实现

下面这套代码是示意图,突出最小骨架,不包含具体请求地址和解析逻辑。

import SwiftUI import AppKit @main struct ClaudeUsageMenuBarApp: App { @StateObject private var monitor = UsageMonitor() var body: some Scene { MenuBarExtra { UsageMenuView() .environmentObject(monitor) } label: { Image(systemName: "chart.bar.doc.horizontal") } .menuBarExtraStyle(.window) } }

核心状态对象可以长这样:

@MainActor final class UsageMonitor: ObservableObject { @Published var usageText = "Claude --" func refresh() async { do { let status = try await ClaudeUsageLoader().fetch() usageText = "Claude \(Int(status.used / status.limit * 100))%" } catch { usageText = "Claude !!" } } }

为了让项目能先跑起来,可以先返回固定值:

struct ClaudeUsageLoader { func fetch() async throws -> UsageStatus { // 这里先返回固定值,方便测试 UI // 等数据源确认后,再替换成真实请求 return UsageStatus(used: 68, limit: 100) } } struct UsageStatus { let used: Double let limit: Double }

点击后的弹窗视图可以读取同一个 monitor:

struct UsageMenuView: View { @EnvironmentObject var monitor: UsageMonitor var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Claude 订阅用量") .font(.headline) Text(monitor.usageText) Divider() Button("立即刷新") { Task { await monitor.refresh() } } Button("退出") { NSApplication.shared.terminate(nil) } } .padding() } }

这段代码不完整,但足够表达一个重要的结构:UI 只负责展示,真正的数据获取和状态更新在 monitor 里。后面你要改数据源,不需要动菜单栏 UI。

3. 数据从哪来:先从你能访问的官方后台里找答案

做这种菜单栏工具,最难的不是写菜单栏,而是找到一个稳定、可访问、并且合理的用量数据来源。

3.1 优先使用你自己账号能正常访问的官方页面请求

如果还没有官方公开的订阅用量 API,那么比较常见的做法是:登录 Claude 官方网页端,通过浏览器开发者工具,找到后台页面加载时返回用量数据的请求。

我没办法替你确认具体接口地址,因为这类内部接口随时可能调整。你可以按这个思路自己找一遍:

  1. 打开官方网页并登录自己的账号
  2. 打开浏览器开发者工具里的 Network 面板
  3. 切换到用量、账户、订阅等页面,观察网络请求
  4. 在返回 JSON 里找 used、limit、endDate、currentPeriod 这类字段
  5. 找到一个能返回完整数值的请求,再用真实会话请求验证

注意,这个流程只应该涉及你自己账号的数据。不要拿别人的账号或伪造请求。会话信息要妥善保管,绝不能贴到 GitHub Issue、公开博客或第三方服务里。

3.2 登录态、过期和本地缓存是三个常见泪点

网页接口通常依赖 Cookie 或 token。把它从浏览器复制到 App 里,只能作为本地调试手段,不适合作为稳定产品逻辑。

这类工具跑一段时间后,最常见的问题是 Cookie 过期。表现就是菜单栏还显示昨天的百分比,但实际已经无法拉取新数据。更隐蔽的问题是 401 和 403 被静默吞掉,用户看到的永远是旧数据。

所以第一版就要明确:

  • 请求失败时,显示“需要重新登录”或明确的错误时间
  • 定时刷新失败时,不要直接清空上一次成功结果
  • 把上次成功的数据缓存到本地,启动时先展示缓存再请求
  • 如果有能力,把敏感会话信息放 Keychain,而不是明文写在偏好设置里

这里给的是通用安全建议,具体存储方式可以根据你的使用范围取舍。如果是个人自用工具,至少也要保证不把你的 Cookie 提交到代码仓库。

3.3 解析 JSON 时要有容错意识

网页端返回的结构不会像文档一样稳定。字段可能会改名,数值可能为空,周期可能因为时区发生变化。

建议把状态对象拆成“请求层”和“展示层”。请求层只负责拿数据,展示层负责把数据翻译成“剩余 68%”“还剩 3 天”。解析时不要硬编码下标,而是用 Optional 和默认值兜底。

下面是一个示意结构:

{ "data": { "cycle": { "start": "2025-05-01T00:00:00Z", "end": "2025-05-31T23:59:59Z" }, "usage": { "used": 68, "limit": 100 } } }

代码里尽量这么处理:

struct UsageResponse: Decodable { struct UsageData: Decodable { struct UsageItem: Decodable { let used: Double? let limit: Double? } let usageItem: UsageItem? } let data: UsageData? }

然后在使用时判断 limit 是否存在,避免直接除以 0。开发时先打印日志,再用 UI 展示,否则你根本分不清是请求失败、字段没找到还是 UI 写错。

注意:这里说的是通用流程,真实返回字段要以你自己登录后看到的请求为准。不要照抄网上任何一份过期的接口定义。

4. 从“能拉数据”到“菜单栏能长期看”,还要处理刷新、阈值和生命周期

菜单栏工具一旦做出来,很可能在开机后一直常驻。这时候“能拉一次数据”和“能稳定显示半年”是完全不同的两个问题。

4.1 刷新频率不能拍脑袋,按资源占用和官方忍耐度来

有些开发者第一版会把刷新间隔设成 30 秒,觉得这样数据最新。但实际没有必要,而且会带来两个问题:

  • 频繁请求更容易触发官方限流或临时封禁
  • 过多唤醒网络会让 Mac 耗电变快,尤其是长期开着不关盖

我建议按下面这套节奏起步:

场景建议原因
开发调试手动点击刷新避免刚写完网络逻辑就高频打接口
常规运行15 分钟到 30 分钟一次订阅用量变化不频繁,足够发现异常
失败后重试先等 5 分钟,再逐步拉长避免接口异常时反复请求
有手动刷新按钮保留并显示最后更新时间用户可以自己控制一次刷新

你可以在 Timer 上设置 tolerance。比如给 15 分钟定时器设置 60 秒的容忍窗口,让系统安排更合理的执行时机,降低耗电。

4.2 阈值提醒和界面状态设计

菜单栏里不能只显示一个百分数,用户需要知道“现在算正常还是危险”。我在实际使用中会分三档处理:

  • 使用率低于 70%,属于正常区
  • 使用率达到 70% 到 100%,属于接近上限区
  • 使用率达到 100% 或超出,属于已耗尽区

比如可以在状态文本里加一个符号前缀,让扫一眼就能判断。

var displayText: String { let percent = used / max(limit, 1) if percent >= 1.0 { return "Claude 已用尽" } else if percent >= 0.7 { return "Claude 接近上限" } else { return "Claude \(Int(percent * 100))%" } }

不要一上来就把功能铺到“支持多种颜色图标”“动态切换 SF Symbol”,先把这三个状态的文案和更新逻辑跑稳。

4.3 不要让刷新任务跟着弹出视图一起消失

这是我前面提到的关键点。如果 MenuBarExtra 的弹出内容只在用户点击时创建,第一次 Task 可能没跑完就被打断,Timer 也可能被系统回收。

更稳的做法是,把自动刷新 Timer 创建在 App 启动阶段,或者挂在 AppDelegate 上。

final class UsageMonitor: ObservableObject { private var timer: Timer? func startAutoRefresh() { timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: 900, repeats: true) { [weak self] _ in Task { await self?.refresh() } } timer?.tolerance = 60 } }

同时要在刷新方法里加一个并发保护。如果上一次请求还没返回,就不要立刻发起下一次请求。

private var isRefreshing = false func refresh() async { guard !isRefreshing else { return } isRefreshing = true defer { isRefreshing = false } // 发起请求并更新状态 }

这行判断看着简单,但能避免很多“请求被重复触发”的边界问题。

5. 如果数据一直不对,按这个顺序排查

菜单栏不显示数据、数字看起来不准、打开后一直转圈,问题往往不在 SwiftUI,而在数据源和请求逻辑上。我之前遇到过几次类似问题,排查步骤基本可以沉淀成一套流程。

5.1 开发期先手动验证请求,再让 UI 背锅

不要第一版就把网络请求封装进复杂异步代码里。更省事的顺序是:

  1. 先用浏览器开发者工具找到返回数据的请求
  2. 复制请求地址、请求头、请求体
  3. 在终端里用 curl 手动跑一次
  4. 确认返回结果和网页里显示的数值一致
  5. 再把这套请求翻译成 URLSession 代码

建议不要直接把 Cookie 贴进公共代码,可以在本地环境变量里临时读取。例如:

curl -i 'https://example.com/usage' \ -H 'Cookie: YOUR_SESSION_PLACEHOLDER'

这里只是一个占位示例,真实地址要从你实际看到的请求里取。手动跑通一次,比在 Xcode 里反复打日志高效得多。

5.2 常见现象和排查顺序

我把这类工具最常见的现象整理成一个表:

现象优先排查
菜单栏没有出现图标进程是否真的启动,Mac 是否缩起菜单栏
图标一直显示“Claude --”请求返回 401、403、429,还是 JSON 解析失败
数字和网页端不一致数据口径、周期起止、缓存未更新
长时间不刷新Timer 是否被取消,是否放在弹出视图里
点击按钮没反应是否重复请求被 guard 拦住,是否主线程阻塞

如果一个菜单栏 App 弹出的内容能正常显示固定值“68%”,只有开启网络后才变成“--”,那问题基本可以确定在网络请求或登录态上。不要反过来怀疑 MenuBarExtra 写错了。

另一个容易被忽略的问题是沙盒网络权限。如果你的 App 开启了 App Sandbox,但没有加网络客户端权限,URLSession 请求会被系统拒绝。这属于配置问题,不是代码逻辑问题。

5.3 长期运行要关注的资源问题

菜单栏工具很轻量,但如果刷新逻辑写得粗糙,资源占用会被用户明显感知。几个容易踩中的点:

  • 每次刷新都创建新的 URLSession 对象,长期运行后可能出现连接堆积
  • 网络请求失败后无脑重试,导致日志刷屏,也增加耗电
  • 打印敏感字段到控制台,开发完忘了删,最终混进日志文件
  • 不处理 Mac 休眠唤醒后的时间差,导致 Timer 在下一次计划执行前永远不跑

如果没有更复杂的理由,尽量复用同一个 URLSession,并且重试要有退避策略。比如失败 3 次后暂停自动刷新,只保留手动刷新入口。这样用户能收到一个稳定的“需要重新登录或稍后再试”的提示,而不是反复失败。

注意:这个问题看起来是“网络请求没有返回”,实际原因可能是权限、Cookie 过期、代理设置、系统时区,甚至只是 JSON 里多套了一层 data。先看请求状态码,再改代码。

6. 让菜单栏工具继续长出能力

如果你是做给自己的,第一版跑到“能显示百分比”基本就够了。但如果你想把这类项目做得更有复用性,可以从下面几个方向继续扩展。

6.1 记录历史数据,看用量增长的节奏

订阅用量不是一个实时变化值,更适合看趋势。每次成功刷新时,把当前使用量写进本地,按天记录一次,就能看到一周用量变化。

历史记录不用上重型数据库,一个 JSON 文件或 SQLite 都可以。重点是要给每次记录打上时间戳,方便区分“周期重置前的数据”和“周期开始后的数据”。

有了历史数据还能做一个小提醒:如果按当前速度消耗,这个周期会在第三天就达到 80%,可以提前检查是否有后台脚本在频繁调用。

6.2 区分多账号和多模型场景

现在很多人的使用场景不是单一账号。一个账号用于网页订阅,另一个账号用于 API 测试,都是很常见的情况。

菜单栏 App 可以支持多个配置,分别保存各自的登录态和请求地址。但要记住,不能让多个账号的状态在一个 Token 文件里相互覆盖。每个配置都要有独立目录、独立缓存、独立日志。

越早把这个抽象做出来,后面加账号越轻松。如果一开始就把所有数据写进一个共享 UserDefaults 里,后面会改得很头疼。

6.3 和 Claude Code 的本地使用打通

很多人会把 Claude 相关命令跑在终端里,比如 Claude Code 或者各类脚本。这些本地工具消耗的到底是套餐额度还是 API 费用,需要看你自己的账号配置,不能一概而论。

一个比较实用的功能是,把菜单栏用量监控和本地命令使用记录放在一起看。菜单栏负责展示订阅配额,本地小工具负责记录某段时间内发起了多少次请求、大概消耗了多少 token。两者放在同一个面板里,就能帮助你判断当前周期的用量到底是来自网页对话还是本地自动化任务。

这一块属于很个人化的场景,没有统一的方案。如果你的用法比较固定,可以统计一个“今天跑了多少次 Claude 相关命令”的计数;如果你的用法更偏 API,那就直接把 API usage 的统计读出来。关键是不要把所有数字塞进同一个百分比里,否则又回到了第一节里说的口径问题。

最后留几个我实际踩过的判断标准

菜单栏工具真正值得花时间的部分,不在按钮样式和图标颜色,而在数据口径、刷新策略和失败恢复。

我个人的落地顺序是:先手动拉一次数据,再显示固定值,再接入真实请求,最后才加定时器和复杂弹窗。任何一个环节没跑通,都不要急着开下一步。

如果看到这里你正准备做一个类似的 Claude 订阅用量工具,我建议你第一版先不用考虑开源、多账号、历史图表这些功能。先把“单账号、单周期、单数据源”跑稳,再把弹出面板里最后刷新时间显示出来。这个最小闭环稳定之后,再谈扩展。很多项目一开始功能太多,反而容易在基础数据上栽跟头。

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

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

立即咨询