From 63e80b576cf0a22a02efcc96b25ef9aabe8c5aa4 Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 14:13:00 +0800 Subject: [PATCH 1/6] Add DeepSeek/GLM balance display, section toggles, and settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RemoteBalanceStore polls DeepSeek /user/balance and the BigModel CN account report endpoint; API keys live in UserDefaults (set via the in-app settings window), never in source or build products. - Menu bar title adapts to content width and renders Codex quota, DS and GLM balance segments; checkmark menu toggles show/hide each segment. - Settings window (设置…, ⌘,) edits keys with masked echo and saves them with instant effect. --- Sources/AppDelegate.swift | 133 +++++++++++++++---- Sources/RemoteBalanceStore.swift | 171 +++++++++++++++++++++++++ Sources/SettingsWindowController.swift | 128 ++++++++++++++++++ 3 files changed, 404 insertions(+), 28 deletions(-) create mode 100644 Sources/RemoteBalanceStore.swift create mode 100644 Sources/SettingsWindowController.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 64c3c0a..b1b1c81 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1,8 +1,17 @@ import AppKit final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate { - private let statusItem = NSStatusBar.system.statusItem(withLength: 118) + /// 菜单栏各分段的自定义显示开关,持久化在 UserDefaults;未设置时默认全部显示。 + private enum MenuBarSection { + static let codex = "menubar.showCodex" + static let deepSeek = "menubar.showDeepSeek" + static let glm = "menubar.showGLM" + } + + private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let store = RateLimitStore() + private let remoteBalances = RemoteBalanceStore() + private var lastQuotaState = RateLimitDisplayState.initial private let lifecycleMonitor = CodexLifecycleMonitor() private var touchBarVisibilityMenuItem: NSMenuItem? private lazy var touchBarController = CompactHUDViewController( @@ -22,6 +31,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate CodexAutoLauncher.clearManualQuitLock() store.delegate = self + remoteBalances.onUpdate = { [weak self] _ in + self?.renderStatusItem() + } + SettingsWindowController.shared.onSaved = { [weak self] in + self?.remoteBalances.refresh() + } + remoteBalances.start() configureStatusItem() configureLifecycleMonitor() lifecycleMonitor.start() @@ -29,7 +45,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate if lifecycleMonitor.codexIsRunningNow() { codexDidStart() } else { - updateStatusTitle(with: .initial) + renderStatusItem() } } @@ -37,11 +53,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate touchBarController.hideSystemTouchBar() lifecycleMonitor.stop() store.stop() + remoteBalances.stop() } func rateLimitStore(_ store: RateLimitStore, didUpdate state: RateLimitDisplayState) { - updateStatusTitle(with: state) + lastQuotaState = state touchBarController.update(with: state) + renderStatusItem() } private func configureStatusItem() { @@ -49,13 +67,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate return } - button.image = NSImage( - systemSymbolName: "bolt.horizontal.circle.fill", - accessibilityDescription: "Codex" - ) - button.imagePosition = .imageLeft - button.title = " --" - button.toolTip = "Codex 额度" + button.toolTip = "余额" statusItem.menu = makeStatusMenu() } @@ -78,6 +90,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate ) reloadTouchBarItem.target = self menu.addItem(reloadTouchBarItem) + menu.addItem(.separator()) + + // 菜单栏分段显示开关:勾选即显示(HIG 标准的 checkmark 菜单项)。 + let sectionItems: [(key: String, title: String)] = [ + (MenuBarSection.codex, "菜单栏显示 Codex 用量"), + (MenuBarSection.deepSeek, "菜单栏显示 DS 余额"), + (MenuBarSection.glm, "菜单栏显示 GLM 余额"), + ] + for section in sectionItems { + let item = NSMenuItem( + title: section.title, + action: #selector(toggleMenuBarSection(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = section.key + item.state = isSectionVisible(section.key) ? .on : .off + menu.addItem(item) + } + menu.addItem(.separator()) let refreshItem = NSMenuItem( title: "刷新额度", @@ -86,6 +118,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate ) refreshItem.target = self menu.addItem(refreshItem) + + let settingsItem = NSMenuItem( + title: "设置…", + action: #selector(openSettings(_:)), + keyEquivalent: "," + ) + settingsItem.target = self + menu.addItem(settingsItem) menu.addItem(.separator()) let quitItem = NSMenuItem( @@ -107,36 +147,67 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate } } - private func updateStatusTitle(with state: RateLimitDisplayState) { + private func isSectionVisible(_ key: String) -> Bool { + let defaults = UserDefaults.standard + return defaults.object(forKey: key) == nil ? true : defaults.bool(forKey: key) + } + + @objc private func toggleMenuBarSection(_ sender: NSMenuItem) { + guard let key = sender.representedObject as? String else { + return + } + + let newValue = !isSectionVisible(key) + UserDefaults.standard.set(newValue, forKey: key) + sender.state = newValue ? .on : .off + renderStatusItem() + } + + private func renderStatusItem() { guard let button = statusItem.button else { return } + let state = lastQuotaState var titleParts: [String] = [] var tooltipParts: [String] = [] - if let fiveHour = state.fiveHour { - titleParts.append("\(fiveHour.shortTitle) \(fiveHour.remainingText)") - tooltipParts.append("5 小时剩余 \(fiveHour.remainingText)") - } else if let resetCredits = state.resetCredits, resetCredits.availableCount > 0 { - titleParts.append("重置\(resetCredits.availableCount)") - tooltipParts.append("可重置 \(resetCredits.availableCount) 次,\(resetCredits.expirationText)") + if isSectionVisible(MenuBarSection.codex) { + if let fiveHour = state.fiveHour { + titleParts.append("\(fiveHour.shortTitle) \(fiveHour.remainingText)") + tooltipParts.append("Codex 5 小时剩余 \(fiveHour.remainingText)") + } else if let resetCredits = state.resetCredits, resetCredits.availableCount > 0 { + titleParts.append("重置\(resetCredits.availableCount)") + tooltipParts.append("Codex 可重置 \(resetCredits.availableCount) 次,\(resetCredits.expirationText)") + } + + if let weekly = state.weekly { + titleParts.append("\(weekly.shortTitle) \(weekly.remainingText)") + tooltipParts.append("Codex 周限额剩余 \(weekly.remainingText)") + } + + if state.isRefreshing && state.fiveHour == nil && state.weekly == nil { + titleParts.insert("...", at: 0) + } + } + + if isSectionVisible(MenuBarSection.deepSeek) { + titleParts.append(remoteBalances.display.deepSeekText) + tooltipParts.append("DeepSeek \(remoteBalances.display.deepSeekText)") } - if let weekly = state.weekly { - titleParts.append("\(weekly.shortTitle) \(weekly.remainingText)") - tooltipParts.append("周限额剩余 \(weekly.remainingText)") + if isSectionVisible(MenuBarSection.glm) { + titleParts.append(remoteBalances.display.glmText) + tooltipParts.append("GLM \(remoteBalances.display.glmText)") } - if !titleParts.isEmpty { - button.title = " \(titleParts.joined(separator: " "))" - button.toolTip = "Codex 额度:\(tooltipParts.joined(separator: ","))" - } else if state.isRefreshing { - button.title = " ..." - button.toolTip = "Codex 额度:正在刷新" + if titleParts.isEmpty { + button.title = "--" + button.toolTip = "CodexBar" } else { - button.title = " --" - button.toolTip = state.errorMessage ?? "Codex 额度" + button.title = titleParts.joined(separator: " ") + button.toolTip = tooltipParts.joined(separator: ",") + + (isSectionVisible(MenuBarSection.codex) ? (state.errorMessage.map { ",Codex:\($0)" } ?? "") : "") } } @@ -154,6 +225,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate private func refreshQuotaNow() { store.start() + remoteBalances.refresh() } @objc private func toggleTouchBar(_ sender: AnyObject?) { @@ -175,6 +247,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate refreshQuotaNow() } + @objc private func openSettings(_ sender: AnyObject?) { + SettingsWindowController.shared.showSettings() + } + @objc private func reloadTouchBarFromMenu(_ sender: AnyObject?) { touchBarController.hideSystemTouchBar() DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in @@ -199,6 +275,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate touchBarController.hideSystemTouchBar() lifecycleMonitor.stop() store.stop() + remoteBalances.stop() NSApp.terminate(nil) } } diff --git a/Sources/RemoteBalanceStore.swift b/Sources/RemoteBalanceStore.swift new file mode 100644 index 0000000..ebc1a9d --- /dev/null +++ b/Sources/RemoteBalanceStore.swift @@ -0,0 +1,171 @@ +import Foundation + +/// API Key 的存取:保存在 UserDefaults(com.wangjiaxuan666.CodexBar), +/// 通过应用内设置窗口填写,改动即时生效,无需重新构建。 +enum APIKeyStore { + static let deepSeekDefaultsKey = "api.deepseekKey" + static let glmDefaultsKey = "api.glmKey" + + static var deepSeek: String { + UserDefaults.standard.string(forKey: deepSeekDefaultsKey) ?? "" + } + + static var glm: String { + UserDefaults.standard.string(forKey: glmDefaultsKey) ?? "" + } + + static func save(deepSeek: String, glm: String) { + UserDefaults.standard.set(deepSeek, forKey: deepSeekDefaultsKey) + UserDefaults.standard.set(glm, forKey: glmDefaultsKey) + } + + static func maskHint(_ key: String) -> String { + guard !key.isEmpty else { + return "未设置" + } + return "已保存:···" + key.suffix(4) + } +} + +struct RemoteBalanceDisplay: Equatable { + var deepSeekText: String = "DS --¥" + var glmText: String = "GLM --¥" +} + +/// 通过官方 API 轮询 DeepSeek 与智谱 GLM 的账户余额。 +/// 刷新失败时保留上一次成功的数值,不打断菜单栏显示。 +final class RemoteBalanceStore { + var onUpdate: ((RemoteBalanceDisplay) -> Void)? + + private(set) var display = RemoteBalanceDisplay() + + private var timer: Timer? + private var isStarted = false + + private let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 10 + config.waitsForConnectivity = false + return URLSession(configuration: config) + }() + + func start() { + guard !isStarted else { + refresh() + return + } + isStarted = true + refresh() + // 余额变化频率低,10 分钟轮询一次即可;菜单"刷新额度"会立即触发 refresh()。 + timer = Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { [weak self] _ in + self?.refresh() + } + } + + func stop() { + isStarted = false + timer?.invalidate() + timer = nil + } + + func refresh() { + fetchDeepSeek() + fetchGLM() + } + + private func publish() { + onUpdate?(display) + } + + // MARK: - DeepSeek + + private func fetchDeepSeek() { + let key = APIKeyStore.deepSeek + guard !key.isEmpty else { + return + } + + var request = URLRequest(url: URL(string: "https://api.deepseek.com/user/balance")!) + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + session.dataTask(with: request) { [weak self] data, _, _ in + guard let data else { + return + } + + DispatchQueue.main.async { + guard let self, self.isStarted, let amount = Self.parseDeepSeekBalance(data) else { + return + } + self.display.deepSeekText = String(format: "DS %.2f¥", amount) + self.publish() + } + }.resume() + } + + static func parseDeepSeekBalance(_ data: Data) -> Double? { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let infos = json["balance_infos"] as? [[String: Any]] + else { + return nil + } + + let preferred = infos.first { ($0["currency"] as? String) == "CNY" } ?? infos.first + return numberValue(preferred?["total_balance"]) + } + + // MARK: - 智谱 GLM + + private func fetchGLM() { + let key = APIKeyStore.glm + guard !key.isEmpty else { + return + } + + // 按量付费账户余额在控制台域名 www.bigmodel.cn 上;open.bigmodel.cn 的 + // /v4/users/me/balance 已下线(返回 404),/api/monitor/usage/quota/limit + // 仅对 Coding Plan 账号开放。 + var request = URLRequest(url: URL(string: "https://www.bigmodel.cn/api/biz/account/query-customer-account-report")!) + request.setValue("Bearer \(key)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + session.dataTask(with: request) { [weak self] data, _, _ in + guard let data else { + return + } + + DispatchQueue.main.async { + guard let self, self.isStarted, let amount = Self.parseGLMBalance(data) else { + return + } + self.display.glmText = String(format: "GLM %.2f¥", amount) + self.publish() + } + }.resume() + } + + static func parseGLMBalance(_ data: Data) -> Double? { + guard + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + (json["success"] as? Bool) == true, + let payload = json["data"] as? [String: Any] + else { + return nil + } + + return numberValue(payload["availableBalance"]) ?? numberValue(payload["balance"]) + } + + private static func numberValue(_ any: Any?) -> Double? { + switch any { + case let text as String: + return Double(text) + case let number as NSNumber: + return number.doubleValue + default: + return nil + } + } +} diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift new file mode 100644 index 0000000..842f6a9 --- /dev/null +++ b/Sources/SettingsWindowController.swift @@ -0,0 +1,128 @@ +import AppKit + +/// 应用内设置窗口:填写 DeepSeek / 智谱 GLM 的 API Key。 +/// Key 由 APIKeyStore 持久化在 UserDefaults,保存即时生效,无需重新构建。 +final class SettingsWindowController: NSWindowController { + static let shared = SettingsWindowController() + + var onSaved: (() -> Void)? + + private let deepSeekField = NSSecureTextField(frame: .zero) + private let glmField = NSSecureTextField(frame: .zero) + private let deepSeekHint = NSTextField(labelWithString: "") + private let glmHint = NSTextField(labelWithString: "") + + private convenience init() { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 430, height: 206), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.title = "CodexBar 设置" + window.isReleasedWhenClosed = false + self.init(window: window) + buildLayout() + reloadFields() + } + + func showSettings() { + reloadFields() + NSApp.activate(ignoringOtherApps: true) + showWindow(nil) + window?.makeKeyAndOrderFront(nil) + } + + // MARK: - 布局 + + private func buildLayout() { + guard let content = window?.contentView else { + return + } + + deepSeekField.placeholderString = "sk-..." + glmField.placeholderString = "粘贴 API Key" + + let rows = NSStackView(views: [ + row(title: "DeepSeek Key", field: deepSeekField, hint: deepSeekHint), + row(title: "GLM Key", field: glmField, hint: glmHint), + ]) + rows.orientation = .vertical + rows.alignment = .leading + rows.spacing = 12 + rows.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(rows) + + let footnote = NSTextField(labelWithString: "Key 保存在本机 UserDefaults,保存后立即生效。") + footnote.font = .systemFont(ofSize: 11) + footnote.textColor = .secondaryLabelColor + footnote.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(footnote) + + let save = NSButton(title: "保存", target: self, action: #selector(saveClicked(_:))) + save.bezelStyle = .rounded + save.keyEquivalent = "\r" + save.translatesAutoresizingMaskIntoConstraints = false + content.addSubview(save) + + NSLayoutConstraint.activate([ + rows.topAnchor.constraint(equalTo: content.topAnchor, constant: 20), + rows.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 20), + rows.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -20), + + footnote.leadingAnchor.constraint(equalTo: rows.leadingAnchor), + footnote.topAnchor.constraint(equalTo: rows.bottomAnchor, constant: 12), + + save.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -20), + save.topAnchor.constraint(equalTo: footnote.bottomAnchor, constant: 14), + save.widthAnchor.constraint(equalToConstant: 84), + content.bottomAnchor.constraint(equalTo: save.bottomAnchor, constant: 18), + + deepSeekField.widthAnchor.constraint(equalToConstant: 280), + glmField.widthAnchor.constraint(equalToConstant: 280), + ]) + } + + private func row(title: String, field: NSTextField, hint: NSTextField) -> NSView { + let label = NSTextField(labelWithString: title) + label.translatesAutoresizingMaskIntoConstraints = false + + let fieldRow = NSStackView(views: [label, field]) + fieldRow.orientation = .horizontal + fieldRow.alignment = .centerY + fieldRow.spacing = 8 + + hint.font = .systemFont(ofSize: 11) + hint.textColor = .secondaryLabelColor + + let wrapper = NSStackView(views: [fieldRow, hint]) + wrapper.orientation = .vertical + wrapper.alignment = .leading + wrapper.spacing = 3 + + // 固定标签宽度,让两个输入框左缘对齐。 + label.widthAnchor.constraint(equalToConstant: 96).isActive = true + return wrapper + } + + // MARK: - 数据 + + private func reloadFields() { + deepSeekField.stringValue = APIKeyStore.deepSeek + glmField.stringValue = APIKeyStore.glm + refreshHints() + } + + private func refreshHints() { + deepSeekHint.stringValue = APIKeyStore.maskHint(APIKeyStore.deepSeek) + glmHint.stringValue = APIKeyStore.maskHint(APIKeyStore.glm) + } + + @objc private func saveClicked(_ sender: NSButton) { + let deepSeek = deepSeekField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + let glm = glmField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) + APIKeyStore.save(deepSeek: deepSeek, glm: glm) + refreshHints() + onSaved?() + } +} From 66416ed80d7906237347e6427fe1b73a18ea55ca Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 14:18:16 +0800 Subject: [PATCH 2/6] Center status menu on the item, fix key field wrapping, close on save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status item now pops its menu manually so the menu's horizontal center tracks the item center regardless of which balance segments are shown. - Secure key fields set cell wraps=false + scrollable so long keys scroll on one line instead of wrapping dots to a second line. - 保存 now closes the settings window after persisting; the window also opens centered instead of cascading to the bottom-left. --- Sources/AppDelegate.swift | 20 +++++++++++++++++++- Sources/SettingsWindowController.swift | 12 +++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index b1b1c81..4bb8c84 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -62,13 +62,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate renderStatusItem() } + private lazy var statusMenu = makeStatusMenu() + private func configureStatusItem() { guard let button = statusItem.button else { return } button.toolTip = "余额" - statusItem.menu = makeStatusMenu() + // 不用 statusItem.menu 的系统锚定(左对齐、宽度变化时偏移), + // 改为手动弹出,让菜单水平中心与状态项中心对齐。 + button.action = #selector(statusItemClicked(_:)) + button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + } + + @objc private func statusItemClicked(_ sender: NSStatusBarButton) { + guard let button = statusItem.button else { + return + } + + let xOffset = (button.bounds.width - statusMenu.size.width) / 2 + statusMenu.popUp( + positioning: nil, + at: NSPoint(x: xOffset, y: button.bounds.maxY + 3), + in: button + ) } private func makeStatusMenu() -> NSMenu { diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift index 842f6a9..996cdf6 100644 --- a/Sources/SettingsWindowController.swift +++ b/Sources/SettingsWindowController.swift @@ -30,6 +30,7 @@ final class SettingsWindowController: NSWindowController { reloadFields() NSApp.activate(ignoringOtherApps: true) showWindow(nil) + window?.center() window?.makeKeyAndOrderFront(nil) } @@ -43,6 +44,15 @@ final class SettingsWindowController: NSWindowController { deepSeekField.placeholderString = "sk-..." glmField.placeholderString = "粘贴 API Key" + // 长密钥在框内滚动显示,禁止圆点换行。 + for field in [deepSeekField, glmField] { + if let cell = field.cell as? NSTextFieldCell { + cell.wraps = false + cell.isScrollable = true + cell.lineBreakMode = .byTruncatingTail + } + } + let rows = NSStackView(views: [ row(title: "DeepSeek Key", field: deepSeekField, hint: deepSeekHint), row(title: "GLM Key", field: glmField, hint: glmHint), @@ -122,7 +132,7 @@ final class SettingsWindowController: NSWindowController { let deepSeek = deepSeekField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let glm = glmField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) APIKeyStore.save(deepSeek: deepSeek, glm: glm) - refreshHints() onSaved?() + window?.performClose(nil) } } From bdcdd2fd40d52b749b94e1cc78c45a48df67aae0 Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 14:30:04 +0800 Subject: [PATCH 3/6] Match menu appearance to system and align menu top with the menu bar - popUp(in:) made the menu inherit the status bar button's dark-tinted appearance, rendering dark menus in light mode; pin the menu to NSApp.effectiveAppearance so light/dark system modes both render with the standard menu material (HIG: menus follow the system appearance). - Drop the 3pt vertical offset so the menu's top edge sits flush with the bottom of the menu bar. --- Sources/AppDelegate.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 4bb8c84..93a1ac3 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -81,10 +81,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate return } + // popUp 弹出的菜单会继承状态项按钮(跟随菜单栏壁纸色调)的外观, + // 浅色系统下也会渲染成深色;显式跟随应用/系统外观,深浅模式都正确。 + statusMenu.appearance = NSApp.effectiveAppearance let xOffset = (button.bounds.width - statusMenu.size.width) / 2 statusMenu.popUp( positioning: nil, - at: NSPoint(x: xOffset, y: button.bounds.maxY + 3), + at: NSPoint(x: xOffset, y: button.bounds.maxY), in: button ) } From 16f923f8d69187d49645ea6c2b0e06b96a97e64e Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 14:58:59 +0800 Subject: [PATCH 4/6] Present status menu via system anchoring; keep it flush and item-width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manual popUp presentation rendered with the wrong (dark) appearance, sat 9pt inside the menu bar, and could reopen pre-scrolled with the first item hidden under the bar. Go back to the system-anchored presentation, which guarantees correct light/dark materials and flush placement, and achieve stable centering by pinning menu.minimumWidth to the status item width in menuNeedsUpdate (equal width + left anchor = visually centered). Settings window: secure fields scroll long keys on one line instead of wrapping dots; 保存 closes the window; the window opens centered. --- Sources/AppDelegate.swift | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 93a1ac3..0ddd39f 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1,6 +1,6 @@ import AppKit -final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate { +final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate, NSMenuDelegate { /// 菜单栏各分段的自定义显示开关,持久化在 UserDefaults;未设置时默认全部显示。 private enum MenuBarSection { static let codex = "menubar.showCodex" @@ -62,38 +62,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate renderStatusItem() } - private lazy var statusMenu = makeStatusMenu() - private func configureStatusItem() { guard let button = statusItem.button else { return } button.toolTip = "余额" - // 不用 statusItem.menu 的系统锚定(左对齐、宽度变化时偏移), - // 改为手动弹出,让菜单水平中心与状态项中心对齐。 - button.action = #selector(statusItemClicked(_:)) - button.sendAction(on: [.leftMouseUp, .rightMouseUp]) + // 系统原生展示:外观、贴齐菜单栏、滚动行为全部由系统保证。 + statusItem.menu = makeStatusMenu() } - @objc private func statusItemClicked(_ sender: NSStatusBarButton) { - guard let button = statusItem.button else { - return + func menuNeedsUpdate(_ menu: NSMenu) { + // 把菜单最小宽度撑到与状态项等宽:系统将菜单左对齐到状态项, + // 等宽时即视觉居中,与显示哪几段余额无关。 + if let width = statusItem.button?.window?.frame.width, width > 0 { + menu.minimumWidth = width } - - // popUp 弹出的菜单会继承状态项按钮(跟随菜单栏壁纸色调)的外观, - // 浅色系统下也会渲染成深色;显式跟随应用/系统外观,深浅模式都正确。 - statusMenu.appearance = NSApp.effectiveAppearance - let xOffset = (button.bounds.width - statusMenu.size.width) / 2 - statusMenu.popUp( - positioning: nil, - at: NSPoint(x: xOffset, y: button.bounds.maxY), - in: button - ) } private func makeStatusMenu() -> NSMenu { let menu = NSMenu() + menu.delegate = self let visibilityItem = NSMenuItem( title: "隐藏 Touch Bar", From 4ae9f5d432088e2285d0e84cbb0bc1ea78bbabfd Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 15:24:10 +0800 Subject: [PATCH 5/6] Make refresh intervals user-configurable in the settings window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New AppSettings: refresh intervals persist in UserDefaults (interval.quotaSeconds / interval.balanceSeconds) with lower-bound clamping; the old hardcoded 60s/600s values remain as fallback defaults. - Settings window gains 额度刷新 / 余额刷新 pop-up buttons: quota presets 30s/1/2/5min, balance presets 1/2/3/4/5/10/15/20/25/30/60min per request; non-preset values (e.g. written via defaults) are echoed back as an extra row instead of being swallowed. - Both stores rebuild their timer on save (applySettings) and refresh immediately, so new intervals take effect without restarting the app. - defaults write com.wangjiaxuan666.CodexBar interval.* also works (picked up on next launch or settings save). --- Sources/AppDelegate.swift | 4 +- Sources/AppSettings.swift | 42 +++++++++++++++++ Sources/RateLimitStore.swift | 11 ++++- Sources/RemoteBalanceStore.swift | 16 ++++++- Sources/SettingsWindowController.swift | 63 ++++++++++++++++++++++---- 5 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 Sources/AppSettings.swift diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 0ddd39f..a614965 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -35,7 +35,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate self?.renderStatusItem() } SettingsWindowController.shared.onSaved = { [weak self] in - self?.remoteBalances.refresh() + guard let self else { return } + store.applySettings() + remoteBalances.applySettings() } remoteBalances.start() configureStatusItem() diff --git a/Sources/AppSettings.swift b/Sources/AppSettings.swift new file mode 100644 index 0000000..c3c336d --- /dev/null +++ b/Sources/AppSettings.swift @@ -0,0 +1,42 @@ +import Foundation + +/// 用户可调的应用设置(UserDefaults 持久化),与 APIKeyStore 同一模式。 +/// 读取时做下限钳制,设置界面与 defaults write 两条修改路径都被覆盖。 +enum AppSettings { + static let quotaIntervalKey = "interval.quotaSeconds" + static let balanceIntervalKey = "interval.balanceSeconds" + + static let defaultQuotaInterval = 60 + static let defaultBalanceInterval = 600 + + /// 额度刷新预设档位(秒):30秒 / 1 / 2 / 5 分钟 + static let quotaChoices = [30, 60, 120, 300] + + /// 余额刷新预设档位(秒):1 / 2 / 3 / 4 / 5 / 10 / 15 / 20 / 25 / 30 / 60 分钟 + static let balanceChoices = [60, 120, 180, 240, 300, 600, 900, 1200, 1500, 1800, 3600] + + static var quotaInterval: Int { + max(saved(quotaIntervalKey, fallback: defaultQuotaInterval), 30) + } + + static var balanceInterval: Int { + max(saved(balanceIntervalKey, fallback: defaultBalanceInterval), 60) + } + + static func saveQuotaInterval(_ seconds: Int) { + UserDefaults.standard.set(max(seconds, 30), forKey: quotaIntervalKey) + } + + static func saveBalanceInterval(_ seconds: Int) { + UserDefaults.standard.set(max(seconds, 60), forKey: balanceIntervalKey) + } + + static func intervalTitle(_ seconds: Int) -> String { + seconds >= 60 && seconds % 60 == 0 ? "\(seconds / 60)分钟" : "\(seconds)秒" + } + + private static func saved(_ key: String, fallback: Int) -> Int { + let value = UserDefaults.standard.integer(forKey: key) + return value == 0 ? fallback : value + } +} diff --git a/Sources/RateLimitStore.swift b/Sources/RateLimitStore.swift index 057722b..d80d0c9 100644 --- a/Sources/RateLimitStore.swift +++ b/Sources/RateLimitStore.swift @@ -78,9 +78,18 @@ final class RateLimitStore { } } + /// 设置窗口保存后调用:按新间隔重建定时器并立即刷新一次。 + func applySettings() { + guard isStarted else { + return + } + startTimer() + refresh() + } + private func startTimer() { timer?.invalidate() - timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in + timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(AppSettings.quotaInterval), repeats: true) { [weak self] _ in self?.refresh() } } diff --git a/Sources/RemoteBalanceStore.swift b/Sources/RemoteBalanceStore.swift index ebc1a9d..2995483 100644 --- a/Sources/RemoteBalanceStore.swift +++ b/Sources/RemoteBalanceStore.swift @@ -56,12 +56,24 @@ final class RemoteBalanceStore { } isStarted = true refresh() - // 余额变化频率低,10 分钟轮询一次即可;菜单"刷新额度"会立即触发 refresh()。 - timer = Timer.scheduledTimer(withTimeInterval: 600, repeats: true) { [weak self] _ in + // 余额变化频率低,按用户设置的档位轮询;菜单"刷新额度"会立即触发 refresh()。 + timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(AppSettings.balanceInterval), repeats: true) { [weak self] _ in self?.refresh() } } + /// 设置窗口保存后调用:按新间隔重建定时器并立即刷新一次。 + func applySettings() { + guard isStarted else { + return + } + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(AppSettings.balanceInterval), repeats: true) { [weak self] _ in + self?.refresh() + } + refresh() + } + func stop() { isStarted = false timer?.invalidate() diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift index 996cdf6..0c8b117 100644 --- a/Sources/SettingsWindowController.swift +++ b/Sources/SettingsWindowController.swift @@ -11,10 +11,12 @@ final class SettingsWindowController: NSWindowController { private let glmField = NSSecureTextField(frame: .zero) private let deepSeekHint = NSTextField(labelWithString: "") private let glmHint = NSTextField(labelWithString: "") + private let quotaIntervalPopup = NSPopUpButton(frame: .zero, pullsDown: false) + private let balanceIntervalPopup = NSPopUpButton(frame: .zero, pullsDown: false) private convenience init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 430, height: 206), + contentRect: NSRect(x: 0, y: 0, width: 430, height: 262), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -53,9 +55,14 @@ final class SettingsWindowController: NSWindowController { } } + setupIntervalPopup(quotaIntervalPopup, choices: AppSettings.quotaChoices, current: AppSettings.quotaInterval) + setupIntervalPopup(balanceIntervalPopup, choices: AppSettings.balanceChoices, current: AppSettings.balanceInterval) + let rows = NSStackView(views: [ - row(title: "DeepSeek Key", field: deepSeekField, hint: deepSeekHint), - row(title: "GLM Key", field: glmField, hint: glmHint), + row(title: "DeepSeek Key", control: deepSeekField, hint: deepSeekHint), + row(title: "GLM Key", control: glmField, hint: glmHint), + row(title: "额度刷新", control: quotaIntervalPopup, hint: nil), + row(title: "余额刷新", control: balanceIntervalPopup, hint: nil), ]) rows.orientation = .vertical rows.alignment = .leading @@ -93,24 +100,44 @@ final class SettingsWindowController: NSWindowController { ]) } - private func row(title: String, field: NSTextField, hint: NSTextField) -> NSView { + private func setupIntervalPopup(_ popup: NSPopUpButton, choices: [Int], current: Int) { + popup.removeAllItems() + var entries = choices.map { (title: AppSettings.intervalTitle($0), seconds: $0) } + if !choices.contains(current) { + // 用户曾用 defaults write 写过非预设值:追加回显,不吞掉手动配置。 + entries.append((AppSettings.intervalTitle(current), current)) + } + for entry in entries.sorted(by: { $0.seconds < $1.seconds }) { + popup.addItem(withTitle: entry.title) + popup.lastItem?.representedObject = entry.seconds + } + if let index = popup.itemArray.firstIndex(where: { ($0.representedObject as? Int) == current }) { + popup.selectItem(at: index) + } + } + + private func row(title: String, control: NSView, hint: NSTextField?) -> NSView { let label = NSTextField(labelWithString: title) label.translatesAutoresizingMaskIntoConstraints = false - let fieldRow = NSStackView(views: [label, field]) + let fieldRow = NSStackView(views: [label, control]) fieldRow.orientation = .horizontal fieldRow.alignment = .centerY fieldRow.spacing = 8 - hint.font = .systemFont(ofSize: 11) - hint.textColor = .secondaryLabelColor + var views: [NSView] = [fieldRow] + if let hint { + hint.font = .systemFont(ofSize: 11) + hint.textColor = .secondaryLabelColor + views.append(hint) + } - let wrapper = NSStackView(views: [fieldRow, hint]) + let wrapper = NSStackView(views: views) wrapper.orientation = .vertical wrapper.alignment = .leading wrapper.spacing = 3 - // 固定标签宽度,让两个输入框左缘对齐。 + // 固定标签宽度,让各行的控件左缘对齐。 label.widthAnchor.constraint(equalToConstant: 96).isActive = true return wrapper } @@ -121,6 +148,18 @@ final class SettingsWindowController: NSWindowController { deepSeekField.stringValue = APIKeyStore.deepSeek glmField.stringValue = APIKeyStore.glm refreshHints() + reloadIntervalPopups() + } + + private func reloadIntervalPopups() { + let quota = AppSettings.quotaInterval + let balance = AppSettings.balanceInterval + if let index = quotaIntervalPopup.itemArray.firstIndex(where: { ($0.representedObject as? Int) == quota }) { + quotaIntervalPopup.selectItem(at: index) + } + if let index = balanceIntervalPopup.itemArray.firstIndex(where: { ($0.representedObject as? Int) == balance }) { + balanceIntervalPopup.selectItem(at: index) + } } private func refreshHints() { @@ -132,6 +171,12 @@ final class SettingsWindowController: NSWindowController { let deepSeek = deepSeekField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) let glm = glmField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) APIKeyStore.save(deepSeek: deepSeek, glm: glm) + if let quota = quotaIntervalPopup.selectedItem?.representedObject as? Int { + AppSettings.saveQuotaInterval(quota) + } + if let balance = balanceIntervalPopup.selectedItem?.representedObject as? Int { + AppSettings.saveBalanceInterval(balance) + } onSaved?() window?.performClose(nil) } From 19a1d0cf3d29ed751be58ca914f769c8cd337cb4 Mon Sep 17 00:00:00 2001 From: RichardYi-SYSU-Mac <1641687849@qq.com> Date: Sun, 6 Sep 2026 15:58:34 +0800 Subject: [PATCH 6/6] Rename interval rows and rebuild menu items before display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Settings rows renamed: 额度刷新 → Codex额度刷新间隔, 余额刷新 → DS/GLM余额刷新间隔; label column and window widened to fit. - menuNeedsUpdate now rebuilds the menu items in place: reusing one NSMenu instance preserved the previous scroll offset, so reopening shifted every row up by one (first item hidden under the menu bar) and clicks meant for 设置… could land on 退出. --- Sources/AppDelegate.swift | 11 ++++++++++- Sources/SettingsWindowController.swift | 10 +++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index a614965..9c43070 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -75,6 +75,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate } func menuNeedsUpdate(_ menu: NSMenu) { + // 复用同一 NSMenu 实例时,系统会保留上次的滚动位置:再次打开菜单会出现 + // ⌃ 滚动箭头、首项被菜单栏遮住、各行坐标整体上移一格(容易误点相邻项)。 + // 显示前原地重建菜单项,重置滚动状态。 + menu.removeAllItems() + buildMenuItems(menu) + // 把菜单最小宽度撑到与状态项等宽:系统将菜单左对齐到状态项, // 等宽时即视觉居中,与显示哪几段余额无关。 if let width = statusItem.button?.window?.frame.width, width > 0 { @@ -85,7 +91,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate private func makeStatusMenu() -> NSMenu { let menu = NSMenu() menu.delegate = self + buildMenuItems(menu) + return menu + } + private func buildMenuItems(_ menu: NSMenu) { let visibilityItem = NSMenuItem( title: "隐藏 Touch Bar", action: #selector(toggleTouchBar(_:)), @@ -147,7 +157,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate ) quitItem.target = self menu.addItem(quitItem) - return menu } private func configureLifecycleMonitor() { diff --git a/Sources/SettingsWindowController.swift b/Sources/SettingsWindowController.swift index 0c8b117..c00108b 100644 --- a/Sources/SettingsWindowController.swift +++ b/Sources/SettingsWindowController.swift @@ -16,7 +16,7 @@ final class SettingsWindowController: NSWindowController { private convenience init() { let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 430, height: 262), + contentRect: NSRect(x: 0, y: 0, width: 470, height: 262), styleMask: [.titled, .closable], backing: .buffered, defer: false @@ -61,8 +61,8 @@ final class SettingsWindowController: NSWindowController { let rows = NSStackView(views: [ row(title: "DeepSeek Key", control: deepSeekField, hint: deepSeekHint), row(title: "GLM Key", control: glmField, hint: glmHint), - row(title: "额度刷新", control: quotaIntervalPopup, hint: nil), - row(title: "余额刷新", control: balanceIntervalPopup, hint: nil), + row(title: "Codex额度刷新间隔", control: quotaIntervalPopup, hint: nil), + row(title: "DS/GLM余额刷新间隔", control: balanceIntervalPopup, hint: nil), ]) rows.orientation = .vertical rows.alignment = .leading @@ -137,8 +137,8 @@ final class SettingsWindowController: NSWindowController { wrapper.alignment = .leading wrapper.spacing = 3 - // 固定标签宽度,让各行的控件左缘对齐。 - label.widthAnchor.constraint(equalToConstant: 96).isActive = true + // 固定标签宽度(容纳最长的「Codex额度刷新间隔」),让各行的控件左缘对齐。 + label.widthAnchor.constraint(equalToConstant: 130).isActive = true return wrapper }