diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 64c3c0a..9c43070 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) +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( @@ -22,6 +31,15 @@ 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() @@ -29,7 +47,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate if lifecycleMonitor.codexIsRunningNow() { codexDidStart() } else { - updateStatusTitle(with: .initial) + renderStatusItem() } } @@ -37,11 +55,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,19 +69,33 @@ 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() } + 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(_:)), @@ -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: "刷新额度", @@ -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( @@ -95,7 +157,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate ) quitItem.target = self menu.addItem(quitItem) - return menu } private func configureLifecycleMonitor() { @@ -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)" } ?? "") : "") } } @@ -154,6 +246,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate private func refreshQuotaNow() { store.start() + remoteBalances.refresh() } @objc private func toggleTouchBar(_ sender: AnyObject?) { @@ -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 @@ -199,6 +296,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, RateLimitStoreDelegate touchBarController.hideSystemTouchBar() lifecycleMonitor.stop() store.stop() + remoteBalances.stop() NSApp.terminate(nil) } } 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 new file mode 100644 index 0000000..2995483 --- /dev/null +++ b/Sources/RemoteBalanceStore.swift @@ -0,0 +1,183 @@ +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() + // 余额变化频率低,按用户设置的档位轮询;菜单"刷新额度"会立即触发 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() + 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..c00108b --- /dev/null +++ b/Sources/SettingsWindowController.swift @@ -0,0 +1,183 @@ +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 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: 470, height: 262), + 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?.center() + window?.makeKeyAndOrderFront(nil) + } + + // MARK: - 布局 + + private func buildLayout() { + guard let content = window?.contentView else { + return + } + + 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 + } + } + + setupIntervalPopup(quotaIntervalPopup, choices: AppSettings.quotaChoices, current: AppSettings.quotaInterval) + setupIntervalPopup(balanceIntervalPopup, choices: AppSettings.balanceChoices, current: AppSettings.balanceInterval) + + let rows = NSStackView(views: [ + row(title: "DeepSeek Key", control: deepSeekField, hint: deepSeekHint), + row(title: "GLM Key", control: glmField, hint: glmHint), + row(title: "Codex额度刷新间隔", control: quotaIntervalPopup, hint: nil), + row(title: "DS/GLM余额刷新间隔", control: balanceIntervalPopup, hint: nil), + ]) + 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 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, control]) + fieldRow.orientation = .horizontal + fieldRow.alignment = .centerY + fieldRow.spacing = 8 + + var views: [NSView] = [fieldRow] + if let hint { + hint.font = .systemFont(ofSize: 11) + hint.textColor = .secondaryLabelColor + views.append(hint) + } + + let wrapper = NSStackView(views: views) + wrapper.orientation = .vertical + wrapper.alignment = .leading + wrapper.spacing = 3 + + // 固定标签宽度(容纳最长的「Codex额度刷新间隔」),让各行的控件左缘对齐。 + label.widthAnchor.constraint(equalToConstant: 130).isActive = true + return wrapper + } + + // MARK: - 数据 + + private func reloadFields() { + 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() { + 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) + 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) + } +}