最近在深度使用 Claude Code 和 Codex 进行开发时,一个高频痛点反复出现:每次想快速查看剩余的 API 调用额度或使用情况,都得先放下手头的代码,切换到浏览器或特定应用界面去查询,流程被打断,效率严重打折。对于依赖这些 AI 编码工具提升生产力的开发者来说,这种“开小差”式的查询体验实在不够优雅。
有没有一种方式,能让这些关键信息像系统状态一样,常驻在视线边缘,随时可查,又毫不打扰?答案是肯定的,而且它就藏在 macOS 最经典的设计元素之一——菜单栏(Menu Bar)里。本文将为你完整呈现如何打造一个原生、轻量、信息即时的 macOS 菜单栏应用,专门用于监控 Claude Code 和 Codex 的用量与限制。从 SwiftUI 基础搭建、菜单栏交互设计,到与 AI 服务 API 的实际对接、数据解析与展示,再到应用打包与发布,我们将一步步拆解。无论你是 Swift/macOS 开发新手,还是想为常用工具打造效率利器的进阶开发者,都能从本文获得可直接复用的代码与清晰的项目思路。
1. 核心概念:为何需要菜单栏应用?
在深入代码之前,我们有必要厘清几个核心概念,理解为什么菜单栏应用是解决此类需求的绝佳方案。
1.1 菜单栏应用的本质macOS 的菜单栏位于屏幕顶部,是一个系统级的全局区域。菜单栏应用(Menu Bar App,或称 Status Bar App)将其主要界面精简为一个菜单栏图标(或称状态项),点击后以下拉菜单(Menu)或弹出面板(Popover)的形式与用户交互。这类应用的核心特点是:
- 常驻与轻量:图标始终可见,但应用本身可以非常轻量,仅在需要时激活界面,占用资源极少。
- 即时访问:用户无需切换应用或窗口,一键即可获取信息或执行操作,实现了真正的“零上下文切换”。
- 无干扰:当不需要时,它只是一个安静的图标,不会侵占宝贵的屏幕空间(Dock 或桌面)。
1.2 Claude Code 与 Codex 的用量监控需求Claude Code 和 Codex 作为 AI 编程助手,通常通过 API 提供服务,并伴有使用限制,例如:
- 速率限制(Rate Limits):每分钟/每小时/每天的最大请求次数。
- 配额(Quotas):基于令牌(Token)的用量,如每月免费额度或套餐包含的令牌数。
- 用量统计(Usage):当前周期内已使用的令牌数、请求次数等。
开发者需要频繁查看这些信息,以确保:
- 成本控制:避免意外超出免费额度或产生计划外费用。
- 流程规划:在额度将尽时调整使用策略,或切换模型。
- 故障排查:当 AI 助手无响应时,快速判断是否是达到了速率限制。
1.3 传统查询方式的弊端通常,查询这些信息需要通过:
- 登录相应的开发者门户网站。
- 在复杂的仪表盘中寻找用量页面。
- 或者,通过命令行调用 API(如
curl)。 这些方式都要求中断当前的编码工作流,破坏了心流状态。一个菜单栏应用,能将“查询”这个动作的成本降至一次点击,意义重大。
2. 环境准备与项目创建
我们将使用 Apple 官方的 SwiftUI 框架来构建这个应用,因为它声明式的语法和与 macOS 系统的深度集成,能让我们高效地创建原生体验。
2.1 开发环境要求
- 操作系统:macOS 12 (Monterey) 或更高版本。建议使用最新稳定版以获得最佳 SwiftUI 支持。
- 开发工具:Xcode 14 或更高版本。本文示例基于 Xcode 15。
- 编程语言:Swift 5.9+。
- 目标框架:AppKit (用于菜单栏集成) 与 SwiftUI (用于界面)。
2.2 创建新的 macOS 项目
- 打开 Xcode,选择 “Create New Project…”。
- 在模板选择器中,选择 “macOS” -> “App”,然后点击 “Next”。
- 输入你的产品名称,例如
AICodeUsageMonitor。 - Interface选择 “SwiftUI”,Life Cycle选择 “SwiftUI App”。语言选择 “Swift”。
- 选择一个位置存放项目,取消勾选 “Create Git repository on my Mac”(可根据需要选择),点击 “Create”。
2.3 项目初始结构创建完成后,你会看到以下主要文件:
AICodeUsageMonitorApp.swift: 应用的入口和主结构。ContentView.swift: 初始的视图文件,对于菜单栏应用,我们可能不会直接使用它。Assets.xcassets: 资源文件,用于存放应用图标和菜单栏图标。
3. 构建菜单栏应用的核心骨架
一个标准的菜单栏应用,其生命周期和界面管理与普通窗口应用不同。我们需要创建一个NSApplicationDelegate或利用@main和App协议来管理状态。
3.1 创建菜单栏状态项(Status Item)我们将创建一个新的 Swift 文件来管理菜单栏逻辑。在项目中新建一个 Swift 文件,命名为MenuBarController.swift。
// 文件路径:AICodeUsageMonitor/MenuBarController.swift import AppKit import SwiftUI class MenuBarController: NSObject { private var statusItem: NSStatusItem! private var popover: NSPopover! // 单例模式,便于全局访问 static let shared = MenuBarController() override init() { super.init() setupStatusItem() } private func setupStatusItem() { // 创建状态项,系统会自动将其添加到菜单栏 statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) // 配置状态项图标和点击行为 if let button = statusItem.button { // 这里先使用一个系统图标,后续可以替换为自定义图标 button.image = NSImage(systemSymbolName: "brain.head.profile", accessibilityDescription: "AI Usage") button.action = #selector(togglePopover(_:)) button.target = self } // 初始化 Popover popover = NSPopover() popover.contentSize = NSSize(width: 300, height: 400) // 初始大小 popover.behavior = .transient // 点击外部区域自动关闭 popover.contentViewController = NSHostingController(rootView: ContentView()) // 使用 SwiftUI 视图 } @objc private func togglePopover(_ sender: AnyObject?) { guard let button = statusItem.button else { return } if popover.isShown { popover.performClose(sender) } else { // 显示 Popover,锚定在状态项按钮上 popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) // 激活应用,但保持窗口层级(非激活状态也能显示) NSApplication.shared.activate(ignoringOtherApps: true) } } }3.2 修改应用入口以启动菜单栏我们需要修改AICodeUsageMonitorApp.swift,在应用启动时初始化我们的菜单栏控制器,并隐藏 Dock 图标和菜单栏,因为这是一个“仅菜单栏”的应用。
// 文件路径:AICodeUsageMonitor/AICodeUsageMonitorApp.swift import SwiftUI @main struct AICodeUsageMonitorApp: App { // 使用 @NSApplicationDelegateAdaptor 来接入 AppKit 的生命周期管理 @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate var body: some Scene { // 一个空的 Settings 场景,防止没有 Scene 时报错。 // 对于纯菜单栏应用,我们通常不需要任何 WindowGroup。 Settings { EmptyView() } } } class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { // 1. 初始化菜单栏控制器(这会自动添加图标到菜单栏) _ = MenuBarController.shared // 2. 隐藏 Dock 图标 NSApp.setActivationPolicy(.accessory) // 3. 可选:隐藏主窗口(如果存在) if let window = NSApplication.shared.windows.first { window.close() } } // 处理点击 Dock 图标(如果用户后来想显示窗口) func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { // 如果用户点击了 Dock 图标,我们可以选择显示一个设置窗口或不做任何事。 // 这里我们简单地不处理,保持应用在后台。 return false } }3.3 设计 SwiftUI 内容视图现在,我们来设计点击菜单栏图标后弹出的内容视图。更新ContentView.swift。
// 文件路径:AICodeUsageMonitor/ContentView.swift import SwiftUI struct ContentView: View { // 这里使用模拟数据,后续会替换为真实 API 数据 @State private var claudeUsage: AIUsageData = .mockClaude @State private var codexUsage: AIUsageData = .mockCodex @State private var isLoading = false @State private var lastUpdated: Date = .now var body: some View { VStack(alignment: .leading, spacing: 16) { // 标题和刷新按钮 HStack { Text("AI 用量监控") .font(.headline) Spacer() Button(action: refreshData) { Image(systemName: "arrow.clockwise") } .buttonStyle(.borderless) .disabled(isLoading) } Divider() // Claude Code 用量面板 UsagePanel( serviceName: "Claude Code", iconName: "c.circle.fill", iconColor: .orange, usageData: $claudeUsage ) Divider() // Codex 用量面板 UsagePanel( serviceName: "Codex", iconName: "chevron.left.forwardslash.chevron.right", iconColor: .blue, usageData: $codexUsage ) Divider() // 底部信息 VStack(alignment: .leading, spacing: 4) { Text("最后更新: \(lastUpdated.formatted(date: .omitted, time: .shortened))") .font(.caption) .foregroundColor(.secondary) if isLoading { HStack { ProgressView() .controlSize(.small) Text("更新中...") .font(.caption) .foregroundColor(.secondary) } } } } .padding() .frame(width: 300, height: 400) // 与 Popover 大小匹配 .onAppear { // 视图出现时加载数据 refreshData() } } private func refreshData() { isLoading = true // 模拟网络请求延迟 DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { // 这里后续会替换为真实的 API 调用 claudeUsage = .mockClaude codexUsage = .mockCodex lastUpdated = .now isLoading = false } } } // 用量数据模型 struct AIUsageData { let used: Int let limit: Int let resetTime: Date? let rateLimitRemaining: Int? var usagePercentage: Double { guard limit > 0 else { return 0 } return Double(used) / Double(limit) } var formattedUsage: String { return "\(used) / \(limit)" } // 模拟数据 static let mockClaude = AIUsageData( used: 12500, limit: 100000, resetTime: Calendar.current.date(byAdding: .day, value: 1, to: .now), rateLimitRemaining: 45 ) static let mockCodex = AIUsageData( used: 89000, limit: 120000, resetTime: Calendar.current.date(byAdding: .hour, value: 6, to: .now), rateLimitRemaining: 120 ) } // 用量面板子视图 struct UsagePanel: View { let serviceName: String let iconName: String let iconColor: Color @Binding var usageData: AIUsageData var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { Image(systemName: iconName) .foregroundColor(iconColor) Text(serviceName) .font(.subheadline) .bold() Spacer() Text(usageData.formattedUsage) .font(.caption.monospacedDigit()) .foregroundColor(.secondary) } // 进度条 ProgressView(value: usageData.usagePercentage) .progressViewStyle(.linear) .tint(usageColor) // 详细信息 VStack(alignment: .leading, spacing: 4) { Text("已用 \(Int(usageData.usagePercentage * 100))%") .font(.caption) if let resetTime = usageData.resetTime { Text("重置: \(resetTime, style: .relative)") .font(.caption2) .foregroundColor(.secondary) } if let remaining = usageData.rateLimitRemaining { Text("剩余请求: \(remaining)") .font(.caption2) .foregroundColor(.secondary) } } } } private var usageColor: Color { let percentage = usageData.usagePercentage switch percentage { case 0..<0.7: return .green case 0.7..<0.9: return .yellow default: return .red } } }现在,运行项目 (Cmd + R)。你会看到菜单栏上出现了一个大脑图标。点击它,一个包含模拟用量数据的弹出面板就会出现。我们已经完成了菜单栏应用的核心 UI 骨架。
4. 对接真实 API:获取 Claude Code 与 Codex 用量数据
模拟数据只是开始,核心功能是获取真实数据。Claude Code 和 Codex 通常通过其官方 API 提供用量查询接口。请注意:以下 API 端点、参数和响应格式为示例,你需要根据 Anthropic 和 OpenAI 官方文档进行调整。
4.1 网络请求与模型层首先,创建一个文件来处理网络请求和数据模型。
// 文件路径:AICodeUsageMonitor/Network/APIManager.swift import Foundation class APIManager { static let shared = APIManager() private let session: URLSession private init() { let configuration = URLSessionConfiguration.default configuration.timeoutIntervalForRequest = 10 session = URLSession(configuration: configuration) } // 通用的 GET 请求方法 private func performRequest<T: Decodable>(url: URL, apiKey: String, completion: @escaping (Result<T, Error>) -> Void) { var request = URLRequest(url: url) request.httpMethod = "GET" request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Content-Type") let task = session.dataTask(with: request) { data, response, error in if let error = error { DispatchQueue.main.async { completion(.failure(error)) } return } guard let httpResponse = response as? HTTPURLResponse, (200...299).contains(httpResponse.statusCode) else { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 DispatchQueue.main.async { completion(.failure(NetworkError.httpError(statusCode: statusCode))) } return } guard let data = data else { DispatchQueue.main.async { completion(.failure(NetworkError.noData)) } return } do { let decodedData = try JSONDecoder().decode(T.self, from: data) DispatchQueue.main.async { completion(.success(decodedData)) } } catch { DispatchQueue.main.async { completion(.failure(error)) } } } task.resume() } // 示例:获取 Claude Code 用量 (假设的 API) func fetchClaudeUsage(apiKey: String, completion: @escaping (Result<ClaudeUsageResponse, Error>) -> Void) { // !!! 重要:此 URL 和响应结构为示例,请替换为真实的 Claude API 端点 !!! guard let url = URL(string: "https://api.anthropic.com/v1/usage") else { completion(.failure(NetworkError.invalidURL)) return } performRequest(url: url, apiKey: apiKey, completion: completion) } // 示例:获取 Codex 用量 (使用 OpenAI 格式示例) func fetchCodexUsage(apiKey: String, completion: @escaping (Result<OpenAIUsageResponse, Error>) -> Void) { // !!! 重要:此 URL 和响应结构为示例,请替换为真实的 OpenAI API 端点 !!! guard let url = URL(string: "https://api.openai.com/v1/usage") else { completion(.failure(NetworkError.invalidURL)) return } performRequest(url: url, apiKey: apiKey, completion: completion) } } enum NetworkError: LocalizedError { case invalidURL case httpError(statusCode: Int) case noData case decodingError var errorDescription: String? { switch self { case .invalidURL: return "无效的 API 地址。" case .httpError(let statusCode): return "网络请求失败,状态码: \(statusCode)。" case .noData: return "服务器未返回数据。" case .decodingError: return "解析响应数据失败。" } } } // 假设的 Claude API 响应模型 struct ClaudeUsageResponse: Codable { let totalTokensUsed: Int let tokenLimit: Int let resetTimestamp: TimeInterval? // 可能为 Unix 时间戳 // 可以添加计算属性来转换为我们的 AIUsageData func toAIUsageData() -> AIUsageData { let resetDate = resetTimestamp.map { Date(timeIntervalSince1970: $0) } return AIUsageData( used: totalTokensUsed, limit: tokenLimit, resetTime: resetDate, rateLimitRemaining: nil // Claude API 可能不直接提供这个 ) } } // 假设的 OpenAI API 响应模型 (参考 Billing API) struct OpenAIUsageResponse: Codable { let totalUsage: Int // 单位可能是美分或令牌数,需根据实际 API 调整 let hardLimit: Int let grants: [Grant]? struct Grant: Codable { let expiresAt: TimeInterval? } func toAIUsageData() -> AIUsageData { let resetTime = grants?.first?.expiresAt.map { Date(timeIntervalSince1970: $0) } // 注意:这里需要根据实际 API 文档确认 totalUsage 和 hardLimit 的单位和含义 return AIUsageData( used: totalUsage, limit: hardLimit, resetTime: resetTime, rateLimitRemaining: nil // 可能需要从其他端点获取 ) } }4.2 创建数据存储与配置管理器我们需要安全地存储 API 密钥,并管理应用配置。我们将使用UserDefaults进行简单存储,并考虑使用 Keychain 增强安全性。
// 文件路径:AICodeUsageMonitor/Managers/ConfigManager.swift import Foundation import Security // 用于 Keychain 操作(进阶) class ConfigManager { static let shared = ConfigManager() private let defaults = UserDefaults.standard private enum Keys { static let claudeAPIKey = "claude_api_key" static let openaiAPIKey = "openai_api_key" static let refreshInterval = "refresh_interval_minutes" } // 使用 UserDefaults 存储(简单,但不安全) var claudeAPIKey: String { get { defaults.string(forKey: Keys.claudeAPIKey) ?? "" } set { defaults.set(newValue, forKey: Keys.claudeAPIKey) } } var openaiAPIKey: String { get { defaults.string(forKey: Keys.openaiAPIKey) ?? "" } set { defaults.set(newValue, forKey: Keys.openaiAPIKey) } } var refreshIntervalMinutes: Int { get { defaults.integer(forKey: Keys.refreshInterval) } set { defaults.set(newValue, forKey: Keys.refreshInterval) } } private init() { // 设置默认刷新间隔为 5 分钟 if defaults.object(forKey: Keys.refreshInterval) == nil { defaults.set(5, forKey: Keys.refreshInterval) } } // 进阶:使用 Keychain 安全存储 API 密钥 (示例函数) func saveKeyToKeychain(service: String, account: String, key: String) -> Bool { guard let data = key.data(using: .utf8) else { return false } let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecValueData as String: data ] SecItemDelete(query as CFDictionary) // 先删除旧的 let status = SecItemAdd(query as CFDictionary, nil) return status == errSecSuccess } func loadKeyFromKeychain(service: String, account: String) -> String? { let query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service, kSecAttrAccount as String: account, kSecReturnData as String: true, kSecMatchLimit as String: kSecMatchLimitOne ] var dataTypeRef: AnyObject? let status = SecItemCopyMatching(query as CFDictionary, &dataTypeRef) if status == errSecSuccess, let data = dataTypeRef as? Data { return String(data: data, encoding: .utf8) } return nil } }4.3 集成 API 调用到视图现在,更新ContentView.swift中的refreshData函数,调用真实的 API。
// 在 ContentView 结构体内更新 refreshData 函数 private func refreshData() { isLoading = true let config = ConfigManager.shared let apiManager = APIManager.shared let group = DispatchGroup() var claudeResult: Result<ClaudeUsageResponse, Error>? var codexResult: Result<OpenAIUsageResponse, Error>? // 获取 Claude 数据 if !config.claudeAPIKey.isEmpty { group.enter() apiManager.fetchClaudeUsage(apiKey: config.claudeAPIKey) { result in claudeResult = result group.leave() } } // 获取 Codex 数据 if !config.openaiAPIKey.isEmpty { group.enter() apiManager.fetchCodexUsage(apiKey: config.openaiAPIKey) { result in codexResult = result group.leave() } } group.notify(queue: .main) { // 处理 Claude 结果 if let claudeResult = claudeResult { switch claudeResult { case .success(let response): self.claudeUsage = response.toAIUsageData() case .failure(let error): // 处理错误,例如更新 UI 显示错误信息 print("Claude API 错误: \(error.localizedDescription)") // 可以设置一个错误状态的数据 self.claudeUsage = AIUsageData(used: 0, limit: 0, resetTime: nil, rateLimitRemaining: nil) } } // 处理 Codex 结果 if let codexResult = codexResult { switch codexResult { case .success(let response): self.codexUsage = response.toAIUsageData() case .failure(let error): print("Codex API 错误: \(error.localizedDescription)") self.codexUsage = AIUsageData(used: 0, limit: 0, resetTime: nil, rateLimitRemaining: nil) } } self.lastUpdated = .now self.isLoading = false } }5. 完善功能:设置界面与自动刷新
一个完整的应用还需要配置界面和后台自动刷新的能力。
5.1 创建设置视图新建一个 SwiftUI 视图文件SettingsView.swift,用于输入 API 密钥和配置刷新间隔。
// 文件路径:AICodeUsageMonitor/SettingsView.swift import SwiftUI struct SettingsView: View { @State private var claudeAPIKey: String = ConfigManager.shared.claudeAPIKey @State private var openaiAPIKey: String = ConfigManager.shared.openaiAPIKey @State private var refreshInterval: Int = ConfigManager.shared.refreshIntervalMinutes @Environment(\.dismiss) private var dismiss var body: some View { Form { Section("API 配置") { SecureField("Claude API Key", text: $claudeAPIKey) .textFieldStyle(RoundedBorderTextFieldStyle()) SecureField("OpenAI API Key", text: $openaiAPIKey) .textFieldStyle(RoundedBorderTextFieldStyle()) Text("密钥仅存储在本地,用于查询用量。") .font(.caption) .foregroundColor(.secondary) } Section("刷新设置") { Picker("自动刷新间隔", selection: $refreshInterval) { Text("1 分钟").tag(1) Text("5 分钟").tag(5) Text("15 分钟").tag(15) Text("30 分钟").tag(30) Text("手动").tag(0) } .pickerStyle(.menu) if refreshInterval > 0 { Text("每隔 \(refreshInterval) 分钟自动从菜单栏获取最新用量。") .font(.caption) .foregroundColor(.secondary) } else { Text("仅在点击菜单栏图标或手动刷新时更新。") .font(.caption) .foregroundColor(.secondary) } } Section { HStack { Spacer() Button("保存并关闭") { saveSettings() dismiss() } .keyboardShortcut(.defaultAction) Button("取消") { dismiss() } .keyboardShortcut(.cancelAction) Spacer() } } } .padding() .frame(width: 400, height: 300) } private func saveSettings() { let config = ConfigManager.shared config.claudeAPIKey = claudeAPIKey config.openaiAPIKey = openaiAPIKey config.refreshIntervalMinutes = refreshInterval // 保存后,可以通知主视图或刷新数据 NotificationCenter.default.post(name: .settingsUpdated, object: nil) } } // 定义通知名称 extension Notification.Name { static let settingsUpdated = Notification.Name("settingsUpdated") }5.2 在菜单栏添加设置入口修改MenuBarController的setupStatusItem方法,为其添加一个包含“设置”和“退出”选项的上下文菜单。
// 在 MenuBarController.swift 的 setupStatusItem 方法中,初始化 popover 后添加: private func setupStatusItem() { // ... 之前的代码(创建 statusItem 和 button)... // 初始化 Popover ... // popover = NSPopover() ... // 创建上下文菜单 let menu = NSMenu() let settingsItem = NSMenuItem(title: "设置...", action: #selector(openSettings(_:)), keyEquivalent: ",") settingsItem.target = self menu.addItem(settingsItem) menu.addItem(NSMenuItem.separator()) let quitItem = NSMenuItem(title: "退出", action: #selector(quitApp(_:)), keyEquivalent: "q") quitItem.target = self menu.addItem(quitItem) statusItem.menu = menu // 赋予状态项一个菜单 } @objc private func openSettings(_ sender: AnyObject?) { // 关闭 Popover(如果开着) if popover.isShown { popover.performClose(sender) } // 创建并显示设置窗口 let settingsView = SettingsView() let hostingController = NSHostingController(rootView: settingsView) let window = NSWindow(contentViewController: hostingController) window.title = "AI 用量监控 - 设置" window.setContentSize(NSSize(width: 420, height: 350)) window.styleMask = [.titled, .closable] window.center() window.makeKeyAndOrderFront(nil) // 将窗口关联到当前应用,防止被垃圾回收 NSApp.activate(ignoringOtherApps: true) } @objc private func quitApp(_ sender: AnyObject?) { NSApplication.shared.terminate(nil) }5.3 实现后台自动刷新我们需要一个定时器,根据设置的间隔自动刷新数据。在MenuBarController或一个单独的RefreshService中实现。
// 文件路径:AICodeUsageMonitor/Services/RefreshService.swift import Foundation import Combine class RefreshService: ObservableObject { static let shared = RefreshService() private var timer: Timer? private var cancellables = Set<AnyCancellable>() private init() { setupObserver() } private func setupObserver() { // 监听设置更新的通知 NotificationCenter.default.publisher(for: .settingsUpdated) .sink { [weak self] _ in self?.restartTimer() } .store(in: &cancellables) } func startTimer() { stopTimer() // 先停止现有的定时器 let interval = ConfigManager.shared.refreshIntervalMinutes guard interval > 0 else { print("自动刷新已禁用") return } print("启动自动刷新定时器,间隔 \(interval) 分钟") timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(interval * 60), repeats: true) { [weak self] _ in self?.triggerRefresh() } // 立即触发一次刷新 triggerRefresh() } func stopTimer() { timer?.invalidate() timer = nil } private func triggerRefresh() { print("定时刷新触发于 \(Date())") // 发送一个全局通知,让 ContentView 刷新数据 NotificationCenter.default.post(name: .refreshData, object: nil) } private func restartTimer() { stopTimer() startTimer() } } extension Notification.Name { static let refreshData = Notification.Name("refreshData") }然后,在AppDelegate的applicationDidFinishLaunching中启动服务,并在ContentView中监听刷新通知。
// 在 AppDelegate.swift 的 applicationDidFinishLaunching 末尾添加 RefreshService.shared.startTimer()// 在 ContentView.swift 的 body 内添加 .onReceive 修饰器 var body: some View { VStack(alignment: .leading, spacing: 16) { // ... 原有视图代码 ... } .padding() .frame(width: 300, height: 400) .onAppear { refreshData() } .onReceive(NotificationCenter.default.publisher(for: .refreshData)) { _ in refreshData() } .onReceive(NotificationCenter.default.publisher(for: .settingsUpdated)) { _ in // 设置更新后也刷新一次数据 refreshData() } }6. 常见问题与排查思路
在开发和使用此类菜单栏应用时,你可能会遇到一些典型问题。
| 问题现象 | 可能原因 | 解决思路 |
|---|---|---|
| 菜单栏图标不显示 | 1. 应用没有NSStatusItem。2. 图标资源缺失或命名错误。 3. 应用沙盒权限问题。 | 1. 检查MenuBarController的setupStatusItem是否被调用。2. 确认 button.image设置的NSImage有效。可以使用系统符号NSImage(systemSymbolName:)测试。3. 对于沙盒应用,需要在 Signing & Capabilities中添加App Sandbox并启用User Selected File等权限(如果涉及文件访问)。 |
| 点击图标无反应 | 1. 按钮的action和target未正确设置。2. popover或menu的配置有冲突。 | 1. 确保button.action和button.target已正确关联到MenuBarController实例的方法。2. 如果同时设置了 statusItem.menu和button.action,点击行为可能被菜单覆盖。确保逻辑清晰,通常二选一。 |
| Popover 显示位置异常 | popover.show(relativeTo:of:preferredEdge:)方法的参数不正确。 | 确保relativeTo:传入的是状态项按钮的bounds,of:传入的是按钮本身。preferredEdge:通常用.minY(上方)或.maxY(下方)。 |
| API 请求失败 | 1. 网络连接问题。 2. API 密钥无效或过期。 3. API 端点 URL 或响应格式变化。 4. 未处理 SSL 证书或网络权限。 | 1. 检查网络。 2. 在设置中重新输入并保存有效的 API 密钥。 3.最重要:对照 Claude/OpenAI 最新官方文档,核对 APIManager中的 URL 和响应模型 (ClaudeUsageResponse,OpenAIUsageResponse)。这是最可能出错的地方。4. 对于 macOS 应用,如果访问 https接口,通常没问题。如果自签名证书,需额外处理。 |
| 自动刷新不工作 | 1.RefreshService的定时器未启动或被释放。2. 刷新间隔设置为 0(手动)。 3. ContentView未监听refreshData通知。 | 1. 在AppDelegate中确认RefreshService.shared.startTimer()被调用。2. 检查设置中的刷新间隔。 3. 确保 ContentView添加了.onReceive(NotificationCenter.default.publisher(for: .refreshData))修饰器。 |
| 应用无法退出 | NSApplication.shared.terminate(_:)未被正确调用,或存在未释放的资源/窗口。 | 确保退出菜单项正确连接到quitApp方法。对于有窗口的应用,关闭所有窗口可能有助于退出。纯菜单栏应用使用NSApp.setActivationPolicy(.accessory)后,点击 Dock 菜单的“退出”或我们的菜单项应能正常退出。 |
7. 最佳实践与进阶优化
完成基础功能后,我们可以从工程化角度考虑如何让应用更健壮、更安全、体验更好。
7.1 安全存储 API 密钥如前所述,使用UserDefaults存储明文 API 密钥不安全。强烈建议使用 macOS 的 Keychain Services。上面的ConfigManager已经提供了示例方法。你应该:
- 修改
ConfigManager,使其优先从 Keychain 读取密钥,UserDefaults仅作为备份或标志位。 - 在
SettingsView中,从 Keychain 加载初始值,保存时写入 Keychain。
7.2 错误处理与用户反馈目前的错误处理只是打印到控制台。应该向用户提供友好提示。
- 在 UI 中显示错误:在
ContentView的AIUsageData模型中增加一个errorMessage字段,当 API 请求失败时,在用量面板上显示错误图标和简短提示(如“获取失败”)。 - 使用 Alert:对于严重的配置错误(如未设置 API 密钥),可以在应用启动或尝试刷新时弹出
Alert提示用户去设置。
7.3 数据持久化与离线查看每次打开 Popover 都重新请求 API 可能造成不必要的延迟和流量消耗。
- 缓存机制:将最后一次成功获取的
AIUsageData连同时间戳一起保存到UserDefaults或本地文件。当 Popover 打开时,先显示缓存的数据,然后立即在后台发起新的请求,获取成功后更新 UI。这能提供瞬时的用户体验。 - 在
APIManager的fetch方法成功回调中,将数据归档存储。
7.4 应用图标与打包
- 自定义菜单栏图标:替换
NSImage(systemSymbolName: ...)为你设计的自定义图标。将图标文件(建议使用 PDF 矢量格式或多种尺寸的 PNG)放入Assets.xcassets,然后通过NSImage(named: “YourIconName”)引用。 - 配置应用信息:在 Xcode 项目的
Info.plist或 Target 的Signing & Capabilities中,设置合适的Bundle Identifier、Version和Build。 - 发布准备:如果打算分发,需要考虑代码签名、公证(Notarization)和沙盒配置,以符合 macOS 应用商店或直接分发的安全要求。
7.5 扩展功能思路
- 多账户支持:允许添加多组 Claude/OpenAI API 密钥,并在菜单栏中切换查看。
- 用量预测与告警:根据历史使用速率,预测配额耗尽时间,并在用量达到阈值(如 80%、90%)时,通过本地通知(
UserNotifications)提醒用户。 - 更丰富的菜单栏显示:不点击时,图标本身可以变化(如颜色、填充度)来直观反映总体用量状态(如绿色代表充足,红色代表即将用完)。
- 导出用量报告:将历史用量数据导出为 CSV 或 JSON 文件。
通过以上步骤,你已经拥有了一个功能完整、可扩展的 macOS 菜单栏应用,它能够优雅且高效地解决 Claude Code 和 Codex 用量监控的痛点。这个项目不仅是一个实用工具,也是一个学习 SwiftUI、AppKit 以及 macOS 应用开发生态的绝佳范例。你可以根据实际 AI 服务的 API 文档调整网络请求部分,并在此基础上添加更多个性化功能,打造属于你自己的终极开发效率看板。