Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 128 additions & 30 deletions Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import AppKit

final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate {
private let statusItem = NSStatusBar.system.statusItem(withLength: 118)
final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate, NSMenuDelegate {
/// 菜单栏各分段的自定义显示开关,持久化在 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(
Expand All @@ -22,46 +31,71 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate
CodexAutoLauncher.clearManualQuitLock()

store.delegate = self
remoteBalances.onUpdate = { [weak self] _ in
self?.renderStatusItem()
}
SettingsWindowController.shared.onSaved = { [weak self] in
guard let self else { return }
store.applySettings()
remoteBalances.applySettings()
}
remoteBalances.start()
configureStatusItem()
configureLifecycleMonitor()
lifecycleMonitor.start()

if lifecycleMonitor.codexIsRunningNow() {
codexDidStart()
} else {
updateStatusTitle(with: .initial)
renderStatusItem()
}
}

func applicationWillTerminate(_ notification: Notification) {
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() {
guard let button = statusItem.button else {
return
}

button.image = NSImage(
systemSymbolName: "bolt.horizontal.circle.fill",
accessibilityDescription: "Codex"
)
button.imagePosition = .imageLeft
button.title = " --"
button.toolTip = "Codex 额度"
button.toolTip = "余额"
// 系统原生展示:外观、贴齐菜单栏、滚动行为全部由系统保证。
statusItem.menu = makeStatusMenu()
}

func menuNeedsUpdate(_ menu: NSMenu) {
// 复用同一 NSMenu 实例时,系统会保留上次的滚动位置:再次打开菜单会出现
// ⌃ 滚动箭头、首项被菜单栏遮住、各行坐标整体上移一格(容易误点相邻项)。
// 显示前原地重建菜单项,重置滚动状态。
menu.removeAllItems()
buildMenuItems(menu)

// 把菜单最小宽度撑到与状态项等宽:系统将菜单左对齐到状态项,
// 等宽时即视觉居中,与显示哪几段余额无关。
if let width = statusItem.button?.window?.frame.width, width > 0 {
menu.minimumWidth = width
}
}

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(_:)),
Expand All @@ -78,6 +112,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: "刷新额度",
Expand All @@ -86,6 +140,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(
Expand All @@ -95,7 +157,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate
)
quitItem.target = self
menu.addItem(quitItem)
return menu
}

private func configureLifecycleMonitor() {
Expand All @@ -107,36 +168,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 let weekly = state.weekly {
titleParts.append("\(weekly.shortTitle) \(weekly.remainingText)")
tooltipParts.append("周限额剩余 \(weekly.remainingText)")
if isSectionVisible(MenuBarSection.deepSeek) {
titleParts.append(remoteBalances.display.deepSeekText)
tooltipParts.append("DeepSeek \(remoteBalances.display.deepSeekText)")
}

if !titleParts.isEmpty {
button.title = " \(titleParts.joined(separator: " "))"
button.toolTip = "Codex 额度:\(tooltipParts.joined(separator: ","))"
} else if state.isRefreshing {
button.title = " ..."
button.toolTip = "Codex 额度:正在刷新"
if isSectionVisible(MenuBarSection.glm) {
titleParts.append(remoteBalances.display.glmText)
tooltipParts.append("GLM \(remoteBalances.display.glmText)")
}

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)" } ?? "") : "")
}
}

Expand All @@ -154,6 +246,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate

private func refreshQuotaNow() {
store.start()
remoteBalances.refresh()
}

@objc private func toggleTouchBar(_ sender: AnyObject?) {
Expand All @@ -175,6 +268,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
Expand All @@ -199,6 +296,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate
touchBarController.hideSystemTouchBar()
lifecycleMonitor.stop()
store.stop()
remoteBalances.stop()
NSApp.terminate(nil)
}
}
42 changes: 42 additions & 0 deletions Sources/AppSettings.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
11 changes: 10 additions & 1 deletion Sources/RateLimitStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
Expand Down
Loading