From e44de0bf35bc6c6369bab06b9ee9844ae8446148 Mon Sep 17 00:00:00 2001 From: ysicing Date: Tue, 22 Sep 2026 20:55:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-usage):=20=E9=9B=86=E6=88=90=20Codex=20?= =?UTF-8?q?=E5=8F=AA=E8=AF=BB=E9=85=8D=E9=A2=9D=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ARCHITECTURE.md | 21 ++ CHANGELOG.md | 4 + DEVELOPMENT.md | 1 + LICENSES/AI-Usage-and-OpenUsage-MIT.txt | 25 ++ LICENSES/usage-bar-BSD-2-Clause.txt | 25 ++ Packages/XStatsKit/Package.swift | 5 +- .../XStatsKit/Sources/AIUsage/AIUsage.swift | 105 +++++++ .../Sources/AIUsage/CodexCredentials.swift | 61 ++++ .../Sources/AIUsage/CodexProvider.swift | 139 +++++++++ .../Sources/AIUsage/CodexUsageMapper.swift | 130 +++++++++ .../Sources/Localization/Resources/ar.tsv | 34 +++ .../Sources/Localization/Resources/de.tsv | 34 +++ .../Sources/Localization/Resources/es.tsv | 34 +++ .../Sources/Localization/Resources/fr.tsv | 34 +++ .../Sources/Localization/Resources/ja.tsv | 34 +++ .../Sources/Localization/Resources/ko.tsv | 34 +++ .../Localization/Resources/zh-Hant.tsv | 34 +++ .../Sources/Localization/Translations.swift | 34 +++ .../XStatsUI/AppKit/AppController.swift | 55 +++- .../XStatsUI/AppKit/MenuBarRenderer.swift | 34 ++- .../Sources/XStatsUI/Panel/AIUsagePage.swift | 271 ++++++++++++++++++ .../XStatsUI/Panel/MainWindowView.swift | 1 + .../XStatsUI/Popover/CombinedPopover.swift | 11 + .../XStatsUI/Popover/PopoverRootView.swift | 1 + .../XStatsUI/Settings/SettingsPages.swift | 2 +- .../XStatsUI/Snapshot/SnapshotRenderer.swift | 27 +- .../XStatsUI/State/AIUsageController.swift | 140 +++++++++ .../Sources/XStatsUI/State/AppModel.swift | 6 +- .../Sources/XStatsUI/State/AppSettings.swift | 41 ++- .../AIUsageTests/CodexProviderTests.swift | 78 +++++ .../Tests/AIUsageTests/CodexUsageTests.swift | 141 +++++++++ .../AIUsageControllerTests.swift | 142 +++++++++ README.en.md | 15 +- README.ja.md | 7 +- README.ko.md | 7 +- README.md | 18 +- ThirdPartyNotices.md | 12 + 37 files changed, 1767 insertions(+), 30 deletions(-) create mode 100644 LICENSES/AI-Usage-and-OpenUsage-MIT.txt create mode 100644 LICENSES/usage-bar-BSD-2-Clause.txt create mode 100644 Packages/XStatsKit/Sources/AIUsage/AIUsage.swift create mode 100644 Packages/XStatsKit/Sources/AIUsage/CodexCredentials.swift create mode 100644 Packages/XStatsKit/Sources/AIUsage/CodexProvider.swift create mode 100644 Packages/XStatsKit/Sources/AIUsage/CodexUsageMapper.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift create mode 100644 Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift create mode 100644 Packages/XStatsKit/Tests/AIUsageTests/CodexProviderTests.swift create mode 100644 Packages/XStatsKit/Tests/AIUsageTests/CodexUsageTests.swift create mode 100644 Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0b46275..eacf5a0 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -15,6 +15,8 @@ hosting SwiftUI, no third-party dependencies. The Xcode project is generated fro - `Packages/XStatsKit/Sources/Metrics` — one sampler per metric and `MetricsHub`; power and CPU frequency (`PowerSampler`), disk activity and NVMe SMART (`DiskSamplers`), Bluetooth battery, the SQLite history store. +- `Packages/XStatsKit/Sources/AIUsage` — provider-neutral quota models plus the read-only Codex + credential reader, usage client and response mapper. It never refreshes or writes third-party credentials. - `Packages/XStatsKit/Sources/Cleaner` — cleanup rules, `SafetyGuard`, `CleanEngine`, the app uninstaller's leftover search and the launchd startup-item list. - `Packages/XStatsKit/Sources/Updates` — the update manifest and the download → verify → @@ -165,6 +167,25 @@ an empty compact toolbar, so the traffic lights sit on the same ground colour as line up with the 40 pt page header; the app switches to a regular activation policy while a window is open and back to accessory when all are closed. +## AI quota + +AI quota polling is deliberately separate from `MetricsHub`: system metrics run every 1–5 seconds, +while provider quota defaults to 30 minutes. `AIUsageController` owns scheduling, stale-value policy +and provider cooldowns; a provider performs one read-only fetch and returns an `AIUsageSnapshot`. +Temporary network, server, schema and rate-limit failures retain the last snapshot and mark it stale; +authentication failures clear it. A `Retry-After` deadline is enforced before the provider is called again. + +The first provider is Codex. Credential lookup checks `$CODEX_HOME/auth.json`, then +`~/.config/codex/auth.json` and `~/.codex/auth.json`. Only `tokens.access_token` and the optional +account ID are held in memory for the request to `https://chatgpt.com/backend-api/wham/usage`. +API-key-only authentication cannot expose ChatGPT subscription quota. XStats does not persist, +refresh or rewrite Codex credentials, and diagnostics never include token or response-body data. + +The menu bar shows the quota window with the highest underlying used percentage; the popover and +main-window page show every returned window, reset time and credit balance. Users opt in explicitly +and can switch between remaining and used percentages. The setting is local to each Mac because the +credential source is machine-specific. + ## Network details `NetworkController` runs only while it is needed: diff --git a/CHANGELOG.md b/CHANGELOG.md index 9393003..3b979cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ ### 2026-09-22 +#### 新增 + +- 新增 AI 配额监控:只读 Codex CLI 登录,显示会话、周配额、Spark、积分与重置时间;支持剩余/已用、菜单栏自动或固定指标,以及限流退避和过期提示。 + #### 修复 - 随机安装标识改存本机偏好设置,检查更新不再因读取钥匙串而弹出授权窗口;网络请求仍只发送 SHA-256。 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a7c6e91..eab3eda 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -157,6 +157,7 @@ git push - `Helper/`:特权辅助工具及 launchd 配置。 - `Packages/XStatsKit/Sources/SMC`:SMC 通信、温度传感器和风扇控制。 - `Packages/XStatsKit/Sources/Metrics`:CPU、内存、网络、GPU、磁盘、电池、进程和传感器采集。 +- `Packages/XStatsKit/Sources/AIUsage`:AI Provider 配额模型、只读凭据发现、网络请求与响应映射。 - `Packages/XStatsKit/Sources/Cleaner`:清理规则、安全守卫、扫描和执行。 - `Packages/XStatsKit/Sources/Updates`:版本清单、下载校验和应用替换。 - `Packages/XStatsKit/Sources/HelperShared`:应用与辅助工具共用的 XPC 协议和维护命令。 diff --git a/LICENSES/AI-Usage-and-OpenUsage-MIT.txt b/LICENSES/AI-Usage-and-OpenUsage-MIT.txt new file mode 100644 index 0000000..7660959 --- /dev/null +++ b/LICENSES/AI-Usage-and-OpenUsage-MIT.txt @@ -0,0 +1,25 @@ +AI Usage +Copyright (c) 2026 Burak Gon + +OpenUsage +Copyright (c) 2026 Robin Ebers + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/LICENSES/usage-bar-BSD-2-Clause.txt b/LICENSES/usage-bar-BSD-2-Clause.txt new file mode 100644 index 0000000..5341dff --- /dev/null +++ b/LICENSES/usage-bar-BSD-2-Clause.txt @@ -0,0 +1,25 @@ +BSD 2-Clause License + +Copyright (c) 2026, Krystian +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Packages/XStatsKit/Package.swift b/Packages/XStatsKit/Package.swift index 775d1c4..94b4cb0 100644 --- a/Packages/XStatsKit/Package.swift +++ b/Packages/XStatsKit/Package.swift @@ -7,6 +7,7 @@ let package = Package( platforms: [.macOS(.v14)], products: [ .library(name: "Localization", targets: ["Localization"]), + .library(name: "AIUsage", targets: ["AIUsage"]), .library(name: "SMC", targets: ["SMC"]), .library(name: "Metrics", targets: ["Metrics"]), .library(name: "HelperShared", targets: ["HelperShared"]), @@ -17,13 +18,14 @@ let package = Package( ], targets: [ .target(name: "Localization", resources: [.process("Resources")]), + .target(name: "AIUsage"), .target(name: "SMC", dependencies: ["Localization"]), .target(name: "Metrics", dependencies: ["SMC", "Localization"], linkerSettings: [.linkedLibrary("IOReport")]), .target(name: "HelperShared", dependencies: ["Localization"]), .target(name: "Cleaner", dependencies: ["Localization"]), .target(name: "Updates", dependencies: ["Localization"]), .target(name: "WebDAVSync", dependencies: ["Localization"]), - .target(name: "XStatsUI", dependencies: ["Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync"], + .target(name: "XStatsUI", dependencies: ["AIUsage", "Localization", "Metrics", "SMC", "HelperShared", "Cleaner", "Updates", "WebDAVSync"], resources: [.copy("Resources/Flags"), .copy("Resources/Logos")]), .testTarget(name: "MetricsTests", dependencies: ["Metrics", "SMC"]), .testTarget(name: "CleanerTests", dependencies: ["Cleaner"]), @@ -32,6 +34,7 @@ let package = Package( .testTarget(name: "WebDAVSyncTests", dependencies: ["WebDAVSync"]), .testTarget(name: "XStatsUITests", dependencies: ["XStatsUI", "Metrics", "WebDAVSync", "Localization", "Updates"]), .testTarget(name: "LocalizationTests", dependencies: ["Localization"]), + .testTarget(name: "AIUsageTests", dependencies: ["AIUsage"]), ], swiftLanguageModes: [.v6] ) diff --git a/Packages/XStatsKit/Sources/AIUsage/AIUsage.swift b/Packages/XStatsKit/Sources/AIUsage/AIUsage.swift new file mode 100644 index 0000000..1784ac0 --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/AIUsage.swift @@ -0,0 +1,105 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +public enum AIProviderID: String, CaseIterable, Codable, Identifiable, Sendable { + case codex + + public var id: Self { self } +} + +public enum AIQuotaKind: String, CaseIterable, Codable, Identifiable, Sendable { + case session, weekly, sparkSession, sparkWeekly + + public var id: Self { self } +} + +public enum AIUsageDisplayMode: String, CaseIterable, Codable, Identifiable, Sendable { + case remaining, used + + public var id: Self { self } +} + +public struct AIQuotaWindow: Equatable, Sendable, Identifiable { + public let kind: AIQuotaKind + public let usedPercent: Double + public let resetsAt: Date? + + public var id: AIQuotaKind { kind } + public var renderedUsedFraction: Double { min(max(usedPercent / 100, 0), 1) } + + public init(kind: AIQuotaKind, usedPercent: Double, resetsAt: Date?) { + self.kind = kind + self.usedPercent = usedPercent + self.resetsAt = resetsAt + } +} + +public struct AIUsageSnapshot: Equatable, Sendable { + public let provider: AIProviderID + public let planName: String? + public let windows: [AIQuotaWindow] + public let remainingCredits: Double? + public let fetchedAt: Date + + public init(provider: AIProviderID, planName: String?, windows: [AIQuotaWindow], + remainingCredits: Double?, fetchedAt: Date) { + self.provider = provider + self.planName = planName + self.windows = windows + self.remainingCredits = remainingCredits + self.fetchedAt = fetchedAt + } + + public func window(_ kind: AIQuotaKind) -> AIQuotaWindow? { + windows.first { $0.kind == kind } + } + + /// 菜单栏自动模式展示底层已用比例最高的一项,避免平均值掩盖即将耗尽的额度。 + public var attentionWindow: AIQuotaWindow? { + windows.max { $0.usedPercent < $1.usedPercent } + } + + public func displayedPercent(for window: AIQuotaWindow, mode: AIUsageDisplayMode) -> Double { + switch mode { + case .used: window.usedPercent + case .remaining: max(0, 100 - min(max(window.usedPercent, 0), 100)) + } + } +} + +public enum AIUsageFailure: Error, Equatable, Sendable, CustomStringConvertible { + case notConfigured + case apiKeyUnsupported + case invalidCredentials + case unauthorized + case rateLimited(Date?) + case network + case invalidResponse + case server(Int) + + public var preservesLastGood: Bool { + switch self { + case .rateLimited, .network, .invalidResponse, .server: true + case .notConfigured, .apiKeyUnsupported, .invalidCredentials, .unauthorized: false + } + } + + public var retryAt: Date? { + if case .rateLimited(let date) = self { date } else { nil } + } + + public var description: String { + switch self { + case .notConfigured: "notConfigured" + case .apiKeyUnsupported: "apiKeyUnsupported" + case .invalidCredentials: "invalidCredentials" + case .unauthorized: "unauthorized" + case .rateLimited: "rateLimited" + case .network: "network" + case .invalidResponse: "invalidResponse" + case .server(let status): "server(\(status))" + } + } +} diff --git a/Packages/XStatsKit/Sources/AIUsage/CodexCredentials.swift b/Packages/XStatsKit/Sources/AIUsage/CodexCredentials.swift new file mode 100644 index 0000000..1b67a98 --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/CodexCredentials.swift @@ -0,0 +1,61 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +public struct CodexCredentials: Equatable, Sendable { + public let accessToken: String + public let accountID: String? + + public init(accessToken: String, accountID: String?) { + self.accessToken = accessToken + self.accountID = accountID + } +} + +public enum CodexCredentialStore { + public static func candidateURLs(environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser) -> [URL] { + if let override = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !override.isEmpty { + return [URL(fileURLWithPath: override, isDirectory: true).appendingPathComponent("auth.json")] + } + return [ + home.appendingPathComponent(".config/codex/auth.json"), + home.appendingPathComponent(".codex/auth.json"), + ] + } + + public static func load(environment: [String: String] = ProcessInfo.processInfo.environment, + home: URL = FileManager.default.homeDirectoryForCurrentUser) throws -> CodexCredentials { + for url in candidateURLs(environment: environment, home: home) { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + guard let data = try? Data(contentsOf: url) else { throw AIUsageFailure.invalidCredentials } + return try parse(data) + } + throw AIUsageFailure.notConfigured + } + + public static func parse(_ data: Data) throws -> CodexCredentials { + guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIUsageFailure.invalidCredentials + } + if let apiKey = string(object["OPENAI_API_KEY"]), !apiKey.isEmpty, + object["tokens"] == nil { + throw AIUsageFailure.apiKeyUnsupported + } + guard let tokens = object["tokens"] as? [String: Any], + let accessToken = string(tokens["access_token"] ?? tokens["accessToken"]), + !accessToken.isEmpty else { + throw AIUsageFailure.invalidCredentials + } + return CodexCredentials( + accessToken: accessToken, + accountID: string(tokens["account_id"] ?? tokens["accountId"]) + ) + } + + private static func string(_ value: Any?) -> String? { + (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Packages/XStatsKit/Sources/AIUsage/CodexProvider.swift b/Packages/XStatsKit/Sources/AIUsage/CodexProvider.swift new file mode 100644 index 0000000..85e33cd --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/CodexProvider.swift @@ -0,0 +1,139 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +enum AIHTTPMethod: String, Sendable { + case get = "GET" +} + +struct AIHTTPRequest: Sendable { + let method: AIHTTPMethod + let url: URL + var headers: [String: String] = [:] + var body: Data? +} + +struct AIHTTPResponse: Sendable { + let statusCode: Int + let headers: [String: String] + let body: Data + + func header(_ name: String) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} + +protocol AIHTTPClient: Sendable { + func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse +} + +private actor URLSessionAIHTTPClient: AIHTTPClient { + private let session: URLSession + + init() { + let configuration = URLSessionConfiguration.ephemeral + configuration.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.timeoutIntervalForRequest = 20 + configuration.timeoutIntervalForResource = 30 + session = URLSession(configuration: configuration, delegate: RejectQuotaRedirects(), delegateQueue: nil) + } + + func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse { + var value = URLRequest(url: request.url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData, timeoutInterval: 20) + value.httpMethod = request.method.rawValue + value.httpBody = request.body + for (name, header) in request.headers { value.setValue(header, forHTTPHeaderField: name) } + let (data, response) = try await session.data(for: value) + guard let response = response as? HTTPURLResponse else { throw AIUsageFailure.network } + let headers = Dictionary(uniqueKeysWithValues: response.allHeaderFields.compactMap { key, value in + (key as? String).map { ($0, String(describing: value)) } + }) + return AIHTTPResponse(statusCode: response.statusCode, headers: headers, body: data) + } +} + +/// 配额凭据只能发往固定端点,任何重定向都交回调用方按错误处理。 +private final class RejectQuotaRedirects: NSObject, URLSessionTaskDelegate, Sendable { + func urlSession(_ session: URLSession, task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest) async -> URLRequest? { nil } +} + +public protocol AIUsageProvider: Sendable { + var id: AIProviderID { get } + func fetch() async throws -> AIUsageSnapshot +} + +public actor CodexProvider: AIUsageProvider { + public nonisolated let id = AIProviderID.codex + + private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")! + private let credentials: @Sendable () throws -> CodexCredentials + private let http: any AIHTTPClient + private let now: @Sendable () -> Date + + public init() { + credentials = { try CodexCredentialStore.load() } + http = URLSessionAIHTTPClient() + now = Date.init + } + + init(credentials: @escaping @Sendable () throws -> CodexCredentials, + http: any AIHTTPClient, + now: @escaping @Sendable () -> Date = Date.init) { + self.credentials = credentials + self.http = http + self.now = now + } + + public func fetch() async throws -> AIUsageSnapshot { + let credentials = try credentials() + var headers = [ + "Authorization": "Bearer \(credentials.accessToken)", + "Accept": "application/json", + "User-Agent": "XStats AIUsage", + ] + if let accountID = credentials.accountID, !accountID.isEmpty { + headers["ChatGPT-Account-Id"] = accountID + } + let response: AIHTTPResponse + do { + response = try await http.send(AIHTTPRequest(method: .get, url: Self.usageURL, headers: headers)) + } catch let failure as AIUsageFailure { + throw failure + } catch { + throw AIUsageFailure.network + } + + switch response.statusCode { + case 200..<300: + return try CodexUsageMapper.map(data: response.body, headers: response.headers, now: now()) + case 401, 403: + throw AIUsageFailure.unauthorized + case 429: + throw AIUsageFailure.rateLimited(Self.retryDate(response.header("Retry-After"), now: now())) + case 500...: + throw AIUsageFailure.server(response.statusCode) + default: + throw AIUsageFailure.invalidResponse + } + } + + private static func retryDate(_ raw: String?, now: Date) -> Date { + if let seconds = raw.flatMap(Double.init), seconds.isFinite, seconds >= 0 { + return now.addingTimeInterval(seconds) + } + if let raw { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + if let date = formatter.date(from: raw) { return date } + } + return now.addingTimeInterval(5 * 60) + } +} diff --git a/Packages/XStatsKit/Sources/AIUsage/CodexUsageMapper.swift b/Packages/XStatsKit/Sources/AIUsage/CodexUsageMapper.swift new file mode 100644 index 0000000..32118f2 --- /dev/null +++ b/Packages/XStatsKit/Sources/AIUsage/CodexUsageMapper.swift @@ -0,0 +1,130 @@ +// Copyright (c) 2026 Burak Gon +// Copyright (c) 2026 Robin Ebers +// XStats modifications Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT +// Adapted from AI Usage quota mapping; see LICENSES/AI-Usage-and-OpenUsage-MIT.txt. + +import Foundation +import CoreFoundation + +enum CodexUsageMapper { + private enum WindowSlot { case session, weekly } + + private struct Candidate { + let object: [String: Any] + let usedPercent: Double? + let fallback: WindowSlot + } + + static func map(data: Data, headers: [String: String], now: Date) throws -> AIUsageSnapshot { + guard let body = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw AIUsageFailure.invalidResponse + } + var windows = classify( + rateLimit: object(body["rate_limit"]), + kinds: (.session, .weekly), + headerPercents: ( + header("x-codex-primary-used-percent", in: headers).flatMap(number), + header("x-codex-secondary-used-percent", in: headers).flatMap(number) + ), + now: now + ) + if let entries = body["additional_rate_limits"] as? [Any] { + for raw in entries { + guard let entry = object(raw), isSpark(entry), let rateLimit = object(entry["rate_limit"]) else { continue } + windows += classify(rateLimit: rateLimit, kinds: (.sparkSession, .sparkWeekly), + headerPercents: (nil, nil), now: now) + break + } + } + guard !windows.isEmpty || credits(in: body, headers: headers) != nil else { + throw AIUsageFailure.invalidResponse + } + return AIUsageSnapshot(provider: .codex, planName: planName(body["plan_type"]), windows: windows, + remainingCredits: credits(in: body, headers: headers), fetchedAt: now) + } + + private static func classify(rateLimit: [String: Any]?, + kinds: (session: AIQuotaKind, weekly: AIQuotaKind), + headerPercents: (primary: Double?, secondary: Double?), + now: Date) -> [AIQuotaWindow] { + let candidates = [ + candidate(rateLimit?["primary_window"], headerPercent: headerPercents.primary, fallback: .session), + candidate(rateLimit?["secondary_window"], headerPercent: headerPercents.secondary, fallback: .weekly), + ].compactMap { $0 } + return [ + window(slot: .session, kind: kinds.session, candidates: candidates, now: now), + window(slot: .weekly, kind: kinds.weekly, candidates: candidates, now: now), + ].compactMap { $0 } + } + + private static func candidate(_ value: Any?, headerPercent: Double?, fallback: WindowSlot) -> Candidate? { + let object: [String: Any] + if let parsed = self.object(value) { object = parsed } + else if headerPercent != nil { object = [:] } + else { return nil } + return Candidate(object: object, usedPercent: number(object["used_percent"]) ?? headerPercent, fallback: fallback) + } + + private static func window(slot: WindowSlot, kind: AIQuotaKind, candidates: [Candidate], now: Date) -> AIQuotaWindow? { + let exact = candidates.first { exactSlot($0.object) == slot } + let fallback = candidates.first { exactSlot($0.object) == nil && $0.fallback == slot } + guard let candidate = exact ?? fallback, let used = candidate.usedPercent, used.isFinite else { return nil } + return AIQuotaWindow(kind: kind, usedPercent: used, resetsAt: resetDate(candidate.object, now: now)) + } + + private static func exactSlot(_ object: [String: Any]) -> WindowSlot? { + guard let seconds = number(object["limit_window_seconds"]) else { return nil } + return switch seconds { + case 18_000: .session + case 604_800: .weekly + default: nil + } + } + + private static func resetDate(_ object: [String: Any], now: Date) -> Date? { + if let value = number(object["reset_at"]) { return Date(timeIntervalSince1970: value) } + if let value = number(object["reset_after_seconds"]) { return now.addingTimeInterval(value) } + return nil + } + + private static func isSpark(_ entry: [String: Any]) -> Bool { + [entry["limit_name"], entry["metered_feature"]] + .compactMap { $0 as? String } + .contains { $0.localizedCaseInsensitiveContains("spark") } + } + + private static func credits(in body: [String: Any], headers: [String: String]) -> Double? { + if let values = object(body["credits"]) { + if let balance = number(values["balance"]) { return max(0, balance) } + if values["has_credits"] as? Bool == false { return 0 } + } + return header("x-codex-credits-balance", in: headers).flatMap(number).map { max(0, $0) } + } + + private static func planName(_ value: Any?) -> String? { + guard let raw = (value as? String)?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + switch raw.lowercased() { + case "prolite", "pro_lite": return "Pro 5x" + case "pro": return "Pro 20x" + default: + return raw.replacingOccurrences(of: "_", with: " ").split(separator: " ") + .map { $0.prefix(1).uppercased() + $0.dropFirst().lowercased() }.joined(separator: " ") + } + } + + private static func object(_ value: Any?) -> [String: Any]? { value as? [String: Any] } + + private static func number(_ value: Any?) -> Double? { + if let number = value as? NSNumber { + guard CFGetTypeID(number) != CFBooleanGetTypeID(), number.doubleValue.isFinite else { return nil } + return number.doubleValue + } + if let string = value as? String, let number = Double(string), number.isFinite { return number } + return nil + } + + private static func header(_ name: String, in headers: [String: String]) -> String? { + headers.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } +} diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv index 133685d..22372e9 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ar.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 ذاكرة حزم P ,充电中 ، جارٍ الشحن ,已从程序坞移除 ، أُزيل من Dock :{} : {} +AI 配额 حصة الذكاء الاصطناعي +AI 配额 {} حصة الذكاء الاصطناعي {} +API Key 无法查询 ChatGPT 订阅配额。 لا يمكن لمفتاح API الاستعلام عن حصة اشتراك ChatGPT. +Codex · 数据已过期 Codex · بيانات قديمة +Codex · 自动显示最紧张的配额 Codex · عرض الحصة الأقل المتبقية تلقائيًا +Codex 登录凭据无法读取,请重新登录。 تعذرت قراءة بيانات دخول Codex. سجّل الدخول مجددًا. +Codex 登录已过期,请重新登录。 انتهت صلاحية دخول Codex. سجّل الدخول مجددًا. +Codex 订阅配额、重置时间与积分余额 حصص Codex وأوقات إعادة التعيين ورصيد الاعتمادات +Codex 返回了无法识别的配额数据。 أعاد Codex بيانات حصة غير معروفة. +Codex 配额 حصة Codex +Codex 配额服务暂时不可用(HTTP {})。 خدمة حصة Codex غير متاحة مؤقتًا (HTTP {}). +Spark 周配额 حصة Spark الأسبوعية +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 يقرأ XStats بيانات دخول Codex CLI المحلية ويستعلم مباشرة من ChatGPT. لا يحفظ بيانات الدخول أو يجدّدها أو يعدّلها. +会话 الجلسة +关闭后停止读取凭据和查询配额 يوقف قراءة بيانات الدخول والاستعلام عن الحصص عند التعطيل +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 يدير Codex CLI بيانات الدخول. عند انتهاء الصلاحية، سجّل الدخول مجددًا عبر Codex CLI. +剩余 المتبقي +后重置 حتى إعادة التعيين +启用 Codex 配额 تفعيل حصة Codex +周配额 الحصة الأسبوعية +尚未启用 غير مفعّل +尚未登录 Codex,请先在 Codex CLI 中登录。 لم تسجّل الدخول إلى Codex. سجّل الدخول عبر Codex CLI أولًا. +暂无配额数据 لا توجد بيانات حصة +暂时无法连接 Codex 配额服务。 يتعذر الاتصال بخدمة حصة Codex مؤقتًا. +本机设置 إعداد محلي +查询过于频繁,可在 {} 后重试。 طلبات كثيرة. أعد المحاولة بعد {}. +查询过于频繁,请稍后重试。 طلبات كثيرة. أعد المحاولة لاحقًا. +正在刷新 جارٍ التحديث +正在检查本机登录… جارٍ فحص الدخول المحلي… +积分 اعتمادات +立即刷新 تحديث الآن +跟踪 Codex تتبّع Codex +配额查询独立于系统指标刷新 استعلامات الحصة مستقلة عن تحديث مقاييس النظام +默认关闭 معطّل افتراضيًا diff --git a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv index ce9875c..5aac7c5 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/de.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/de.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Von uv geladener Py ,充电中 , wird geladen ,已从程序坞移除 , aus dem Dock entfernt :{} : {} +AI 配额 KI-Kontingent +AI 配额 {} KI-Kontingent {} +API Key 无法查询 ChatGPT 订阅配额。 Ein API-Schlüssel kann das ChatGPT-Abonnementkontingent nicht abfragen. +Codex · 数据已过期 Codex · Veraltete Daten +Codex · 自动显示最紧张的配额 Codex · Knappstes Kontingent automatisch anzeigen +Codex 登录凭据无法读取,请重新登录。 Codex-Anmeldedaten konnten nicht gelesen werden. Bitte erneut anmelden. +Codex 登录已过期,请重新登录。 Codex-Anmeldung abgelaufen. Bitte erneut anmelden. +Codex 订阅配额、重置时间与积分余额 Codex-Kontingente, Rücksetzzeiten und Guthaben +Codex 返回了无法识别的配额数据。 Codex hat unbekannte Kontingentdaten zurückgegeben. +Codex 配额 Codex-Kontingent +Codex 配额服务暂时不可用(HTTP {})。 Codex-Kontingentdienst vorübergehend nicht verfügbar (HTTP {}). +Spark 周配额 Spark-Wochenkontingent +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats liest lokale Codex-CLI-Anmeldedaten und fragt ChatGPT direkt nach dem Kontingent. Anmeldedaten werden weder gespeichert noch erneuert oder verändert. +会话 Sitzung +关闭后停止读取凭据和查询配额 Deaktivieren beendet das Lesen der Anmeldedaten und die Kontingentabfragen +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 Die Anmeldedaten werden von der Codex CLI verwaltet. Bei Ablauf bitte dort erneut anmelden. +剩余 Übrig +后重置 bis zum Zurücksetzen +启用 Codex 配额 Codex-Kontingent aktivieren +周配额 Wochenkontingent +尚未启用 Nicht aktiviert +尚未登录 Codex,请先在 Codex CLI 中登录。 Nicht bei Codex angemeldet. Bitte zuerst über die Codex CLI anmelden. +暂无配额数据 Noch keine Kontingentdaten +暂时无法连接 Codex 配额服务。 Codex-Kontingentdienst vorübergehend nicht erreichbar. +本机设置 Lokale Einstellung +查询过于频繁,可在 {} 后重试。 Zu viele Anfragen. Nach {} erneut versuchen. +查询过于频繁,请稍后重试。 Zu viele Anfragen. Bitte später erneut versuchen. +正在刷新 Wird aktualisiert +正在检查本机登录… Lokale Anmeldung wird geprüft… +积分 Credits +立即刷新 Jetzt aktualisieren +跟踪 Codex Codex verfolgen +配额查询独立于系统指标刷新 Kontingentabfragen sind unabhängig von Systemmessungen +默认关闭 Standardmäßig aus diff --git a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv index 5c4c81f..f06b459 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/es.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/es.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Cachés de paquetes ,充电中 , cargando ,已从程序坞移除 , eliminado del Dock :{} : {} +AI 配额 Cuota de IA +AI 配额 {} Cuota de IA {} +API Key 无法查询 ChatGPT 订阅配额。 Una clave API no permite consultar la cuota de suscripción de ChatGPT. +Codex · 数据已过期 Codex · Datos antiguos +Codex · 自动显示最紧张的配额 Codex · Mostrar automáticamente la cuota más limitada +Codex 登录凭据无法读取,请重新登录。 No se pudieron leer las credenciales de Codex. Inicia sesión de nuevo. +Codex 登录已过期,请重新登录。 La sesión de Codex ha caducado. Inicia sesión de nuevo. +Codex 订阅配额、重置时间与积分余额 Cuotas de Codex, tiempos de restablecimiento y saldo de créditos +Codex 返回了无法识别的配额数据。 Codex devolvió datos de cuota no reconocidos. +Codex 配额 Cuota de Codex +Codex 配额服务暂时不可用(HTTP {})。 El servicio de cuota de Codex no está disponible temporalmente (HTTP {}). +Spark 周配额 Cuota semanal de Spark +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats lee las credenciales locales de Codex CLI y consulta directamente a ChatGPT. No guarda, renueva ni modifica los datos de inicio de sesión. +会话 Sesión +关闭后停止读取凭据和查询配额 Al desactivar, deja de leer credenciales y consultar cuotas +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 Codex CLI gestiona las credenciales. Si caducan, inicia sesión de nuevo desde Codex CLI. +剩余 Restante +后重置 hasta restablecer +启用 Codex 配额 Activar cuota de Codex +周配额 Cuota semanal +尚未启用 No activado +尚未登录 Codex,请先在 Codex CLI 中登录。 No has iniciado sesión en Codex. Inicia sesión con Codex CLI primero. +暂无配额数据 Sin datos de cuota +暂时无法连接 Codex 配额服务。 No se puede conectar al servicio de cuota de Codex. +本机设置 Ajuste local +查询过于频繁,可在 {} 后重试。 Demasiadas consultas. Inténtalo después de {}. +查询过于频繁,请稍后重试。 Demasiadas consultas. Inténtalo más tarde. +正在刷新 Actualizando +正在检查本机登录… Comprobando la sesión local… +积分 Créditos +立即刷新 Actualizar ahora +跟踪 Codex Seguir Codex +配额查询独立于系统指标刷新 Las consultas de cuota son independientes de las métricas del sistema +默认关闭 Desactivado por defecto diff --git a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv index 8c6ad4a..1527d9a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/fr.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Caches de paquets P ,充电中 , en charge ,已从程序坞移除 , retiré du Dock :{} : {} +AI 配额 Quota IA +AI 配额 {} Quota IA {} +API Key 无法查询 ChatGPT 订阅配额。 Une clé API ne permet pas de consulter le quota d’abonnement ChatGPT. +Codex · 数据已过期 Codex · Données périmées +Codex · 自动显示最紧张的配额 Codex · Affichage automatique du quota le plus limité +Codex 登录凭据无法读取,请重新登录。 Impossible de lire les identifiants Codex. Reconnectez-vous. +Codex 登录已过期,请重新登录。 La connexion Codex a expiré. Reconnectez-vous. +Codex 订阅配额、重置时间与积分余额 Quotas Codex, dates de réinitialisation et crédits +Codex 返回了无法识别的配额数据。 Codex a renvoyé des données de quota non reconnues. +Codex 配额 Quota Codex +Codex 配额服务暂时不可用(HTTP {})。 Le service de quota Codex est temporairement indisponible (HTTP {}). +Spark 周配额 Quota hebdomadaire Spark +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats lit les identifiants locaux de Codex CLI et interroge directement ChatGPT. Il ne conserve, ne renouvelle et ne modifie pas les identifiants. +会话 Session +关闭后停止读取凭据和查询配额 La désactivation arrête la lecture des identifiants et les requêtes de quota +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 Codex CLI gère les identifiants. En cas d’expiration, reconnectez-vous dans Codex CLI. +剩余 Restant +后重置 avant réinitialisation +启用 Codex 配额 Activer le quota Codex +周配额 Quota hebdomadaire +尚未启用 Non activé +尚未登录 Codex,请先在 Codex CLI 中登录。 Vous n’êtes pas connecté à Codex. Connectez-vous d’abord avec Codex CLI. +暂无配额数据 Aucune donnée de quota +暂时无法连接 Codex 配额服务。 Le service de quota Codex est temporairement inaccessible. +本机设置 Réglage local +查询过于频繁,可在 {} 后重试。 Trop de requêtes. Réessayez après {}. +查询过于频繁,请稍后重试。 Trop de requêtes. Réessayez plus tard. +正在刷新 Actualisation +正在检查本机登录… Vérification de la connexion locale… +积分 Crédits +立即刷新 Actualiser maintenant +跟踪 Codex Suivre Codex +配额查询独立于系统指标刷新 Les requêtes de quota sont indépendantes des mesures système +默认关闭 Désactivé par défaut diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv index 41121ab..0466c34 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ja.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uvのPythonパッ ,充电中 、充電中 ,已从程序坞移除 、Dockから削除済み :{} :{} +AI 配额 AI 使用枠 +AI 配额 {} AI 使用枠 {} +API Key 无法查询 ChatGPT 订阅配额。 API キーでは ChatGPT のサブスクリプション使用枠を照会できません。 +Codex · 数据已过期 Codex · 古いデータ +Codex · 自动显示最紧张的配额 Codex · 残りが最も少ない使用枠を自動表示 +Codex 登录凭据无法读取,请重新登录。 Codex の認証情報を読み取れません。再ログインしてください。 +Codex 登录已过期,请重新登录。 Codex のログインが期限切れです。再ログインしてください。 +Codex 订阅配额、重置时间与积分余额 Codex の使用枠、リセット時刻、クレジット残高 +Codex 返回了无法识别的配额数据。 Codex が認識できない使用枠データを返しました。 +Codex 配额 Codex 使用枠 +Codex 配额服务暂时不可用(HTTP {})。 Codex 使用枠サービスを一時的に利用できません(HTTP {})。 +Spark 周配额 Spark 週間枠 +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats はローカルの Codex CLI 認証情報を読み取り、ChatGPT に使用枠を直接照会します。ログイン情報の保存、更新、変更は行いません。 +会话 セッション +关闭后停止读取凭据和查询配额 オフにすると認証情報の読み取りと使用枠の照会を停止します +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 認証情報は Codex CLI が管理します。期限切れの場合は Codex CLI で再ログインしてください。 +剩余 残り +后重置 後にリセット +启用 Codex 配额 Codex 使用枠を有効化 +周配额 週間枠 +尚未启用 未有効 +尚未登录 Codex,请先在 Codex CLI 中登录。 Codex に未ログインです。先に Codex CLI でログインしてください。 +暂无配额数据 使用枠データなし +暂时无法连接 Codex 配额服务。 Codex 使用枠サービスに接続できません。 +本机设置 ローカル設定 +查询过于频繁,可在 {} 后重试。 照会が多すぎます。{} 以降に再試行してください。 +查询过于频繁,请稍后重试。 照会が多すぎます。後でもう一度お試しください。 +正在刷新 更新中 +正在检查本机登录… ローカルのログインを確認中… +积分 クレジット +立即刷新 今すぐ更新 +跟踪 Codex Codex を追跡 +配额查询独立于系统指标刷新 使用枠の照会はシステム指標の更新とは独立しています +默认关闭 初期状態でオフ diff --git a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv index 0a569c9..303c7c2 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/ko.tsv @@ -1217,3 +1217,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv의 Python 패키 ,充电中 , 충전 중 ,已从程序坞移除 , Dock에서 제거됨 :{} : {} +AI 配额 AI 할당량 +AI 配额 {} AI 할당량 {} +API Key 无法查询 ChatGPT 订阅配额。 API 키로는 ChatGPT 구독 할당량을 조회할 수 없습니다. +Codex · 数据已过期 Codex · 오래된 데이터 +Codex · 自动显示最紧张的配额 Codex · 잔여량이 가장 적은 할당량 자동 표시 +Codex 登录凭据无法读取,请重新登录。 Codex 자격 증명을 읽을 수 없습니다. 다시 로그인하세요. +Codex 登录已过期,请重新登录。 Codex 로그인이 만료되었습니다. 다시 로그인하세요. +Codex 订阅配额、重置时间与积分余额 Codex 구독 할당량, 초기화 시간 및 크레딧 잔액 +Codex 返回了无法识别的配额数据。 Codex가 인식할 수 없는 할당량 데이터를 반환했습니다. +Codex 配额 Codex 할당량 +Codex 配额服务暂时不可用(HTTP {})。 Codex 할당량 서비스를 일시적으로 사용할 수 없습니다(HTTP {}). +Spark 周配额 Spark 주간 할당량 +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats는 로컬 Codex CLI 자격 증명을 읽고 ChatGPT에 구독 할당량을 직접 조회합니다. 로그인 정보를 저장, 갱신 또는 수정하지 않습니다. +会话 세션 +关闭后停止读取凭据和查询配额 끄면 자격 증명 읽기와 할당량 조회를 중지합니다 +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 자격 증명은 Codex CLI에서 관리합니다. 로그인이 만료되면 Codex CLI에서 다시 로그인하세요. +剩余 남음 +后重置 후 초기화 +启用 Codex 配额 Codex 할당량 사용 +周配额 주간 할당량 +尚未启用 사용 안 함 +尚未登录 Codex,请先在 Codex CLI 中登录。 Codex에 로그인하지 않았습니다. 먼저 Codex CLI에서 로그인하세요. +暂无配额数据 할당량 데이터 없음 +暂时无法连接 Codex 配额服务。 Codex 할당량 서비스에 연결할 수 없습니다. +本机设置 로컬 설정 +查询过于频繁,可在 {} 后重试。 요청이 너무 많습니다. {} 이후에 다시 시도하세요. +查询过于频繁,请稍后重试。 요청이 너무 많습니다. 나중에 다시 시도하세요. +正在刷新 새로 고치는 중 +正在检查本机登录… 로컬 로그인 확인 중… +积分 크레딧 +立即刷新 지금 새로 고침 +跟踪 Codex Codex 추적 +配额查询独立于系统指标刷新 할당량 조회는 시스템 지표 새로 고침과 별개입니다 +默认关闭 기본적으로 꺼짐 diff --git a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv index a28fab0..8ea3a0a 100644 --- a/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv +++ b/Packages/XStatsKit/Sources/Localization/Resources/zh-Hant.tsv @@ -1218,3 +1218,37 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 uv 下載的 Python ,充电中 ,充電中 ,已从程序坞移除 ,已從程序塢移除 :{} :{} +AI 配额 AI 配額 +AI 配额 {} AI 配額 {} +API Key 无法查询 ChatGPT 订阅配额。 API Key 無法查詢 ChatGPT 訂閱配額。 +Codex · 数据已过期 Codex · 資料已過期 +Codex · 自动显示最紧张的配额 Codex · 自動顯示最緊張的配額 +Codex 登录凭据无法读取,请重新登录。 無法讀取 Codex 登入憑證,請重新登入。 +Codex 登录已过期,请重新登录。 Codex 登入已過期,請重新登入。 +Codex 订阅配额、重置时间与积分余额 Codex 訂閱配額、重設時間與點數餘額 +Codex 返回了无法识别的配额数据。 Codex 傳回無法辨識的配額資料。 +Codex 配额 Codex 配額 +Codex 配额服务暂时不可用(HTTP {})。 Codex 配額服務暫時無法使用(HTTP {})。 +Spark 周配额 Spark 週配額 +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats 唯讀 Codex CLI 的本機登入憑證,直接向 ChatGPT 查詢訂閱配額;不會儲存、更新或修改登入資訊。 +会话 工作階段 +关闭后停止读取凭据和查询配额 關閉後停止讀取憑證和查詢配額 +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 憑證始終由 Codex CLI 管理。登入過期時,請回到 Codex CLI 重新登入。 +剩余 剩餘 +后重置 後重設 +启用 Codex 配额 啟用 Codex 配額 +周配额 週配額 +尚未启用 尚未啟用 +尚未登录 Codex,请先在 Codex CLI 中登录。 尚未登入 Codex,請先在 Codex CLI 中登入。 +暂无配额数据 暫無配額資料 +暂时无法连接 Codex 配额服务。 暫時無法連線至 Codex 配額服務。 +本机设置 本機設定 +查询过于频繁,可在 {} 后重试。 查詢過於頻繁,可在 {} 後重試。 +查询过于频繁,请稍后重试。 查詢過於頻繁,請稍後重試。 +正在刷新 正在更新 +正在检查本机登录… 正在檢查本機登入… +积分 點數 +立即刷新 立即更新 +跟踪 Codex 追蹤 Codex +配额查询独立于系统指标刷新 配額查詢獨立於系統指標更新 +默认关闭 預設關閉 diff --git a/Packages/XStatsKit/Sources/Localization/Translations.swift b/Packages/XStatsKit/Sources/Localization/Translations.swift index a5e3a6e..7e998ff 100644 --- a/Packages/XStatsKit/Sources/Localization/Translations.swift +++ b/Packages/XStatsKit/Sources/Localization/Translations.swift @@ -1174,6 +1174,40 @@ uv 下载的 Python 包与构建缓存,由 uv 自行清理 Python package and 风险评分 Risk score 饼图 Pie 饼图表示当前占用比例 A pie shows current usage +AI 配额 AI Quota +AI 配额 {} AI quota {} +API Key 无法查询 ChatGPT 订阅配额。 An API key cannot read ChatGPT subscription quota. +Codex 配额 Codex Quota +Codex 登录已过期,请重新登录。 Your Codex login has expired. Sign in again. +Codex 返回了无法识别的配额数据。 Codex returned unrecognized quota data. +Codex 登录凭据无法读取,请重新登录。 Codex login credentials could not be read. Sign in again. +Codex 订阅配额、重置时间与积分余额 Codex subscription quota, reset times, and credit balance +Codex 配额服务暂时不可用(HTTP {})。 Codex quota service is temporarily unavailable (HTTP {}). +Codex · 数据已过期 Codex · Data is stale +Codex · 自动显示最紧张的配额 Codex · Automatically showing the tightest quota +Spark 周配额 Spark Weekly +XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。 XStats reads the local Codex CLI login credentials and queries ChatGPT directly for subscription quota. It never saves, refreshes, or modifies the login. +会话 Session +关闭后停止读取凭据和查询配额 Stops reading credentials and querying quota when turned off +积分 Credits +后重置 until reset +周配额 Weekly +启用 Codex 配额 Enable Codex Quota +尚未启用 Not enabled +尚未登录 Codex,请先在 Codex CLI 中登录。 You are not signed in to Codex. Sign in with the Codex CLI first. +暂无配额数据 No quota data yet +本机设置 Local setting +正在刷新 Refreshing +正在检查本机登录… Checking the local login… +立即刷新 Refresh Now +查询过于频繁,可在 {} 后重试。 Too many requests. Try again after {}. +查询过于频繁,请稍后重试。 Too many requests. Try again later. +跟踪 Codex Track Codex +配额查询独立于系统指标刷新 Quota queries are independent from system metric refreshes +剩余 Left +凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。 Credentials always remain managed by the Codex CLI. If the login expires, sign in again from the Codex CLI. +暂时无法连接 Codex 配额服务。 Codex quota service is temporarily unreachable. +默认关闭 Off by default 首次发送时系统会询问是否允许通知 The system asks for permission the first time 高占用进程 Top Processes 高负载 Heavy load diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift index fa6fbf4..0cacaac 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/AppController.swift @@ -70,6 +70,10 @@ public final class AppController: NSObject, NSApplicationDelegate { observeWorkspace() observeModel() observeProbeSettings() + observeAIUsageSchedule() + observeAIUsagePresentation() + observeAIUsageState() + model.aiUsage.start() applyAppearance() updateNetworkVisibility() appliedLanguage = model.settings.language @@ -82,7 +86,7 @@ public final class AppController: NSObject, NSApplicationDelegate { hotKeys.onAction = { [weak self] action in self?.perform(action) } applyHotKeys() - // 开发调试:--show-panel [overview|cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗 + // 开发调试:--show-panel [overview|cpu|memory|network|gpu|disk|temperature|fan|battery|aiUsage] 启动后展开并固定弹窗 // (overview 是合并模式的状态总览;合并模式下给某一项则打开面板并切到这一项);--show-window 打开主窗口; // --show-egress 打开出口与分流窗口 let arguments = CommandLine.arguments @@ -136,6 +140,7 @@ public final class AppController: NSObject, NSApplicationDelegate { } public func applicationWillTerminate(_ notification: Notification) { + model.aiUsage.stop() model.keepAwake.releaseForTermination() if model.fans.mode != .automatic || model.keepAwake.lidClosedActive { model.helper.restoreDefaultsSynchronously() @@ -263,6 +268,52 @@ public final class AppController: NSObject, NSApplicationDelegate { } } + /// AI 配额使用独立的分钟级轮询;开关或间隔变化时重建这一条调度,不影响系统指标采样。 + private func observeAIUsageSchedule() { + withObservationTracking { + _ = model.settings.aiUsageEnabled + _ = model.settings.aiUsageRefreshMinutes + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.model.aiUsage.start() + self.menuBar.refreshImages() + self.menuBar.refreshPopoverHeight() + self.observeAIUsageSchedule() + } + } + } + + /// “剩余 / 已用”只改变呈现,不触发网络请求。 + private func observeAIUsagePresentation() { + withObservationTracking { + _ = model.settings.aiUsageDisplayMode + _ = model.settings.aiUsageFocus + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.menuBar.refreshImages() + self.menuBar.refreshPopoverHeight() + self.observeAIUsagePresentation() + } + } + } + + /// 一次低频查询结束后只重绘菜单栏和弹窗;不重启轮询,避免形成刷新回路。 + private func observeAIUsageState() { + withObservationTracking { + _ = model.aiUsage.states + _ = model.aiUsage.lastAttemptAt + } onChange: { [weak self] in + Task { @MainActor in + guard let self else { return } + self.menuBar.refreshImages() + self.menuBar.refreshPopoverHeight() + self.observeAIUsageState() + } + } + } + private var appliedLanguage: AppLanguage? /// 切换语言后重建应用菜单、菜单栏图标文字与已打开的弹窗 @@ -310,6 +361,7 @@ public final class AppController: NSObject, NSApplicationDelegate { guard let self else { return } self.menuBar.dismissPopovers() self.model.network.setPaused(true) + self.model.aiUsage.setPaused(true) self.model.history.flush() Task { await self.model.hub.setPaused(true) } } @@ -320,6 +372,7 @@ public final class AppController: NSObject, NSApplicationDelegate { MainActor.assumeIsolated { guard let self else { return } self.model.network.setPaused(false) + self.model.aiUsage.setPaused(false) Task { await self.model.hub.setPaused(false) await self.model.fans.reapply() diff --git a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift index 418fa2b..c4a0774 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/AppKit/MenuBarRenderer.swift @@ -19,6 +19,7 @@ struct MenuBarReading { var fanRPM: Double? var battery: Double? var batteryHistory: [Double] = [] + var aiUsage: AIUsageMenuBarReading? var batteryCharging = false /// 附在电池项旁的蓝牙设备:电量低的那一个,或没有电池的 Mac 上电量最低的那一个 var bluetoothDevice: (symbol: String, percent: Int)? @@ -44,6 +45,7 @@ struct MenuBarReading { fanRPM = store.fastestFan?.current battery = store.battery?.level batteryHistory = battery.map { Array(repeating: $0, count: 30) } ?? [] + aiUsage = model.aiUsage.menuBarReading batteryCharging = store.battery?.isCharging ?? false if let lowest = model.bluetooth.lowest, battery == nil || (model.settings.bluetoothLowBatteryInMenuBar && lowest.percent <= Self.lowBluetoothPercent) { @@ -75,6 +77,8 @@ struct MenuBarReading { reading.fanRPM = 1840 reading.battery = 0.82 reading.batteryHistory = Array(repeating: 0.82, count: 30) + reading.aiUsage = AIUsageMenuBarReading(provider: .codex, kind: .weekly, + displayedFraction: 0.42, usedFraction: 0.58, isStale: false) return reading }() @@ -85,6 +89,7 @@ struct MenuBarReading { case .memory: (memory, memoryHistory) case .disk: (disk, diskHistory) case .battery: (battery, batteryHistory) + case .aiUsage: (aiUsage?.displayedFraction, []) default: (nil, []) } } @@ -104,6 +109,11 @@ struct MenuBarReading { case .battery: battery.map { tr("电池 \(Format.percent($0))") + (batteryCharging ? tr(",充电中") : "") } ?? bluetoothDevice.map { tr("蓝牙设备电量 \($0.percent)%") } + case .aiUsage: + aiUsage.map { reading in + let suffix = reading.isStale ? tr(",数据已过期") : "" + return tr("AI 配额 \(Format.percent(reading.displayedFraction))") + suffix + } } } .joined(separator: "\n") @@ -225,6 +235,13 @@ enum MenuBarRenderer { return textSegment(item: item, value: reading.fanRPM.map { "\(Int($0))" } ?? "—", sample: "8888", style: style) case .battery: return batterySegment(reading: reading, style: style, colorizeHighLoad: colorizeHighLoad) + case .aiUsage: + let value = reading.aiUsage?.displayedFraction + let used = reading.aiUsage?.usedFraction + let resolved: MenuBarStyle = style == .history || style == .line ? .ring : style + return percentSegment(item: item, value: value, history: [], style: resolved, + colorizeHighLoad: colorizeHighLoad, + riskFraction: used, stale: reading.aiUsage?.isStale ?? false) } } @@ -279,19 +296,22 @@ enum MenuBarRenderer { } private static func percentSegment(item: MenuBarItem, value: Double?, history: [Double], - style: MenuBarStyle, colorizeHighLoad: Bool) -> Segment { + style: MenuBarStyle, colorizeHighLoad: Bool, + riskFraction: Double? = nil, stale: Bool = false) -> Segment { let fraction = min(1, max(0, value ?? 0)) - let text = value.map { Format.percent($0) } ?? "—" - let alert = colorizeHighLoad && fraction >= Metrics.highLevel - let stacked = stackedText(label: item.menuBarLabel, value: text, sample: "100%", alert: alert) + let text = (value.map { Format.percent($0) } ?? "—") + (stale ? "⚠︎" : "") + let alertFraction = riskFraction ?? fraction + let alert = colorizeHighLoad && alertFraction >= Metrics.highLevel + let sample = stale ? "100%⚠︎" : "100%" + let stacked = stackedText(label: item.menuBarLabel, value: text, sample: sample, alert: alert) switch style { case .stacked: return stacked case .inline: - return inlineText(label: item.menuBarLabel, value: text, sample: "100%", alert: alert) + return inlineText(label: item.menuBarLabel, value: text, sample: sample, alert: alert) case .icon: - return combine([symbolSegment(item.symbol), inlineValue(text, sample: "100%", alert: alert)]) + return combine([symbolSegment(item.symbol), inlineValue(text, sample: sample, alert: alert)]) case .ring: return combine([ring(fraction: fraction, alert: alert), stacked]) case .pie: @@ -303,7 +323,7 @@ enum MenuBarRenderer { case .meter: return combine([meter(fraction: fraction, alert: alert), stacked]) case .dot: - return combine([levelDot(fraction: fraction), stacked]) + return combine([levelDot(fraction: alertFraction), stacked]) } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift new file mode 100644 index 0000000..1adbc82 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/AIUsagePage.swift @@ -0,0 +1,271 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AIUsage +import Localization +import Metrics +import SwiftUI + +struct AIUsagePage: View { + var body: some View { + PageScroll { AIUsageContent(showsSettings: true) } + } +} + +struct AIUsagePopover: View { + var body: some View { AIUsageContent(showsSettings: false) } +} + +private struct AIUsageContent: View { + let showsSettings: Bool + @Environment(AppModel.self) private var model + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + VStack(spacing: DS.Space.s3) { + controls + if model.settings.aiUsageEnabled { + CodexUsageSection(state: model.aiUsage.state(for: .codex), mode: model.settings.aiUsageDisplayMode) + } else { + enableCard + } + if showsSettings { settingsCard } + if let updated = model.aiUsage.lastSuccessfulRefreshAt, model.settings.aiUsageEnabled { + HStack { + Text(tr("上次检查:\(updated.formatted(date: .omitted, time: .shortened))")) + Spacer() + Text(tr("\(model.settings.aiUsageRefreshMinutes) 分钟")) + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } + } + .animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(response: 0.32, dampingFraction: 1), + value: model.settings.aiUsageEnabled) + } + + private var controls: some View { + @Bindable var settings = model.settings + return HStack(spacing: DS.Space.s2) { + SegmentedControl(selection: $settings.aiUsageDisplayMode, + options: AIUsageDisplayMode.allCases.map { ($0, $0.title) }) + .frame(width: DS.Size.sidebarWidth - DS.Space.s8) + Spacer(minLength: DS.Space.s2) + MiniIconButton(systemName: model.aiUsage.isRefreshing ? "hourglass" : "arrow.clockwise", + help: model.aiUsage.isRefreshing ? tr("正在刷新") : tr("立即刷新")) { + Task { await model.aiUsage.refresh() } + } + .disabled(!settings.aiUsageEnabled || model.aiUsage.isRefreshing || + (model.aiUsage.state(for: .codex).failure?.retryAt.map { $0 > Date() } ?? false)) + } + } + + private var enableCard: some View { + Card { + CardHeader(icon: "sparkles", title: tr("Codex 配额"), detail: tr("默认关闭")) + Text(tr("XStats 只读 Codex CLI 的本机登录凭据,并直接向 ChatGPT 查询订阅配额;不会保存、刷新或修改登录信息。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + Button(tr("启用 Codex 配额")) { + model.settings.aiUsageEnabled = true + } + .buttonStyle(DSButtonStyle(kind: .primary)) + } + .transition(reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.98, anchor: .top))) + } + + private var settingsCard: some View { + @Bindable var settings = model.settings + return Card { + CardHeader(icon: "gearshape", title: tr("数据来源"), detail: tr("本机设置")) + SettingRow(title: tr("跟踪 Codex"), subtitle: tr("关闭后停止读取凭据和查询配额")) { + DSToggle(isOn: Binding(get: { settings.aiUsageEnabled }, set: { value in + settings.aiUsageEnabled = value + }), label: tr("跟踪 Codex")) + } + HairlineDivider() + SettingRow(title: tr("菜单栏"), subtitle: tr("Codex · 自动显示最紧张的配额")) { + Picker(tr("菜单栏"), selection: $settings.aiUsageFocus) { + Text(tr("自动")).tag(AIQuotaKind?.none) + ForEach(AIQuotaKind.allCases) { kind in + Text(kind.title).tag(AIQuotaKind?.some(kind)) + } + } + .labelsHidden() + .fixedSize() + } + HairlineDivider() + SettingRow(title: tr("刷新间隔"), subtitle: tr("配额查询独立于系统指标刷新")) { + Picker(tr("刷新间隔"), selection: Binding(get: { settings.aiUsageRefreshMinutes }, set: { value in + settings.aiUsageRefreshMinutes = value + })) { + ForEach(AppSettings.aiUsageRefreshOptions, id: \.self) { minutes in + Text(tr("\(minutes) 分钟")).tag(minutes) + } + } + .labelsHidden() + .fixedSize() + } + Text(tr("凭据始终由 Codex CLI 管理。登录过期时,请回到 Codex CLI 重新登录。")) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .fixedSize(horizontal: false, vertical: true) + } + } +} + +private struct CodexUsageSection: View { + let state: AIUsageProviderState + let mode: AIUsageDisplayMode + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + var body: some View { + Card { + HStack(spacing: DS.Space.s2) { + Image(systemName: "terminal") + .font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.textSecondary) + Text("Codex").dsFont(.sm, weight: .semibold) + Spacer(minLength: DS.Space.s2) + if let plan = state.snapshot?.planName { + TagBadge(text: plan, tone: .neutral, compact: true) + } + } + + if let snapshot = state.snapshot { + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: DS.Space.s2) { + ForEach(snapshot.windows) { window in + AIQuotaTile(window: window, mode: mode) + } + } + if let credits = snapshot.remainingCredits { + InfoRow(label: tr("积分"), text: tr("剩余 \(credits.formatted(.number.precision(.fractionLength(0...1))))")) + } + } else if state.isRefreshing { + HStack(spacing: DS.Space.s2) { + ProgressView().controlSize(.small) + Text(tr("正在检查本机登录…")) + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + } else { + AIUsageFailureView(failure: state.failure) + } + + if state.isStale, let failure = state.failure { + Label(failure.message, systemImage: "exclamationmark.triangle.fill") + .dsFont(.xs) + .foregroundStyle(DS.Palette.warning) + .fixedSize(horizontal: false, vertical: true) + } + } + .transition(reduceMotion ? .opacity : .opacity.combined(with: .scale(scale: 0.98, anchor: .top))) + .animation(reduceMotion ? .easeOut(duration: 0.15) : .spring(response: 0.32, dampingFraction: 1), value: state) + } +} + +private struct AIQuotaTile: View { + let window: AIQuotaWindow + let mode: AIUsageDisplayMode + + var body: some View { + TimelineView(.periodic(from: .now, by: 60)) { _ in + VStack(alignment: .leading, spacing: DS.Space.s1) { + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1) { + Text(window.kind.title).dsFont(.xs, weight: .medium) + Spacer(minLength: DS.Space.s1) + Text(Format.percent(displayedFraction)) + .dsFont(.sm, weight: .semibold) + .monospacedDigit() + .contentTransition(.numericText(value: displayedFraction)) + } + ProgressTrack(fraction: displayedFraction, color: riskColor, height: DS.Space.s1) + if let reset = window.resetsAt { + HStack(spacing: DS.Space.s1) { + Image(systemName: "clock") + Text(reset, style: .relative) + Text(tr("后重置")) + } + .dsFont(.xs) + .foregroundStyle(DS.Palette.textTertiary) + .lineLimit(1) + } + } + .padding(DS.Space.s2) + .background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md)) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(window.kind.title),\(Format.percent(displayedFraction)),\(mode.title)") + } + } + + private var displayedFraction: Double { + switch mode { + case .used: min(max(window.usedPercent / 100, 0), 1) + case .remaining: max(0, 1 - min(max(window.usedPercent / 100, 0), 1)) + } + } + + private var riskColor: Color { + switch window.usedPercent { + case 85...: DS.Palette.error + case 60..<85: DS.Palette.warning + default: DS.Palette.primary + } + } +} + +private struct AIUsageFailureView: View { + let failure: AIUsageFailure? + + var body: some View { + Label(failure?.message ?? tr("暂无配额数据"), systemImage: failure?.symbol ?? "chart.bar.xaxis") + .dsFont(.xs) + .foregroundStyle(failure == nil ? DS.Palette.textSecondary : DS.Palette.warning) + .fixedSize(horizontal: false, vertical: true) + } +} + +private extension AIUsageDisplayMode { + var title: String { + switch self { + case .remaining: tr("剩余") + case .used: tr("已用") + } + } +} + +private extension AIQuotaKind { + var title: String { + switch self { + case .session: tr("会话") + case .weekly: tr("周配额") + case .sparkSession: "Spark" + case .sparkWeekly: tr("Spark 周配额") + } + } +} + +private extension AIUsageFailure { + var message: String { + switch self { + case .notConfigured: tr("尚未登录 Codex,请先在 Codex CLI 中登录。") + case .apiKeyUnsupported: tr("API Key 无法查询 ChatGPT 订阅配额。") + case .invalidCredentials: tr("Codex 登录凭据无法读取,请重新登录。") + case .unauthorized: tr("Codex 登录已过期,请重新登录。") + case .rateLimited(let retryAt): retryAt.map { tr("查询过于频繁,可在 \($0.formatted(date: .omitted, time: .shortened)) 后重试。") } + ?? tr("查询过于频繁,请稍后重试。") + case .network: tr("暂时无法连接 Codex 配额服务。") + case .invalidResponse: tr("Codex 返回了无法识别的配额数据。") + case .server(let status): tr("Codex 配额服务暂时不可用(HTTP \(status))。") + } + } + + var symbol: String { + switch self { + case .notConfigured, .apiKeyUnsupported, .invalidCredentials, .unauthorized: "person.crop.circle.badge.exclamationmark" + case .rateLimited: "hourglass" + case .network, .invalidResponse, .server: "exclamationmark.triangle" + } + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift index 1a13efc..98ad2fc 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Panel/MainWindowView.swift @@ -68,6 +68,7 @@ public struct MainWindowView: View { case .overview: OverviewPage() case .system: SystemInfoPage() case .history: HistoryPage() + case .aiUsage: AIUsagePage() case .cpu: DetailPage { CPUPopover() } case .gpu: DetailPage { GPUPopover() } case .memory: DetailPage { MemoryPopover() } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift index 94404b8..ae85e86 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/CombinedPopover.swift @@ -308,6 +308,17 @@ private struct OverviewReading { } else { detail = tr("没有电池") } + case .aiUsage: + if let reading = model.aiUsage.menuBarReading { + setPercent(reading.displayedFraction) + tone = Self.loadTone(reading.usedFraction) + let color = reading.usedFraction >= 0.85 ? DS.Palette.error + : reading.usedFraction >= 0.6 ? DS.Palette.warning : DS.Palette.primary + chart = .level(reading.displayedFraction, color) + detail = reading.isStale ? tr("Codex · 数据已过期") : tr("Codex · 自动显示最紧张的配额") + } else { + detail = model.settings.aiUsageEnabled ? tr("暂无配额数据") : tr("尚未启用") + } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift index 082d3a1..422ddd0 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Popover/PopoverRootView.swift @@ -61,6 +61,7 @@ struct PopoverDetail: View { case .disk: DiskPopover() case .temperature, .fan: ThermalPopover(item: item) case .battery: BatteryPopover() + case .aiUsage: AIUsagePopover() } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift index 8c6eed9..0762806 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Settings/SettingsPages.swift @@ -333,7 +333,7 @@ struct MenuBarSettings: View { } if settings.isEnabled(item) { ItemStyleRow(item: item) - PopoverSectionPicker(item: item) + if !item.popoverSections.isEmpty { PopoverSectionPicker(item: item) } } } } diff --git a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift index ee12097..a3f97a8 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/Snapshot/SnapshotRenderer.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later AND MIT // See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt. +import AIUsage import AppKit import Localization import Metrics @@ -19,7 +20,8 @@ enum SnapshotRenderer { let defaults = UserDefaults(suiteName: "XStats.snapshot") ?? .standard let settings = AppSettings(defaults: defaults) settings.language = L10n.language - settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery] + settings.menuBarItems = [.cpu, .gpu, .memory, .network, .disk, .temperature, .battery, .aiUsage] + settings.aiUsageEnabled = true // 历史页用示例数据:最近 24 小时每分钟一条,中间留一段“睡眠”空档 let historyURL = FileManager.default.temporaryDirectory.appendingPathComponent("xstats-snapshot-history.sqlite") try? FileManager.default.removeItem(at: historyURL) @@ -36,7 +38,9 @@ enum SnapshotRenderer { power: index > 1200 ? 20 + wave * 25 : nil)) } } - let model = AppModel(settings: settings, historyURL: historyURL) + let model = AppModel(settings: settings, historyURL: historyURL, + aiUsageProviders: [SnapshotAIUsageProvider()]) + await model.aiUsage.refresh() model.isMainWindowVisible = true model.network.setVisibility(inMenuBar: true, detailVisible: true) @@ -150,3 +154,22 @@ enum SnapshotRenderer { try? bitmap.representation(using: .png, properties: [:])?.write(to: url) } } + +private struct SnapshotAIUsageProvider: AIUsageProvider { + let id = AIProviderID.codex + + func fetch() async throws -> AIUsageSnapshot { + let now = Date() + return AIUsageSnapshot( + provider: .codex, + planName: "Pro 20x", + windows: [ + AIQuotaWindow(kind: .session, usedPercent: 28, resetsAt: now.addingTimeInterval(95 * 60)), + AIQuotaWindow(kind: .weekly, usedPercent: 87, resetsAt: now.addingTimeInterval(4.5 * 24 * 60 * 60)), + AIQuotaWindow(kind: .sparkWeekly, usedPercent: 42, resetsAt: now.addingTimeInterval(6 * 24 * 60 * 60)), + ], + remainingCredits: 820, + fetchedAt: now + ) + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift new file mode 100644 index 0000000..4607589 --- /dev/null +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AIUsageController.swift @@ -0,0 +1,140 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AIUsage +import Foundation +import Observation + +public struct AIUsageProviderState: Equatable, Sendable { + public let provider: AIProviderID + public var snapshot: AIUsageSnapshot? + public var failure: AIUsageFailure? + public var isRefreshing = false + + public var isStale: Bool { snapshot != nil && failure != nil } + + public init(provider: AIProviderID) { self.provider = provider } +} + +public struct AIUsageMenuBarReading: Equatable, Sendable { + public let provider: AIProviderID + public let kind: AIQuotaKind + public let displayedFraction: Double + public let usedFraction: Double + public let isStale: Bool +} + +/// AI 配额的低频刷新入口。Provider 只负责一次只读查询;调度、退避与“保留旧值”策略集中在这里。 +@MainActor +@Observable +public final class AIUsageController { + public private(set) var states: [AIProviderID: AIUsageProviderState] + public private(set) var lastAttemptAt: Date? + public private(set) var lastSuccessfulRefreshAt: Date? + + @ObservationIgnored private let settings: AppSettings + @ObservationIgnored private let providers: [any AIUsageProvider] + @ObservationIgnored private let now: @Sendable () -> Date + @ObservationIgnored private var cadenceTask: Task? + @ObservationIgnored private var refreshInProgress = false + @ObservationIgnored private var generation = 0 + @ObservationIgnored private var paused = false + + public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexProvider()], + now: @escaping @Sendable () -> Date = Date.init) { + self.settings = settings + self.providers = providers + self.now = now + states = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, AIUsageProviderState(provider: $0.id)) }) + } + + public func state(for provider: AIProviderID) -> AIUsageProviderState { + states[provider] ?? AIUsageProviderState(provider: provider) + } + + public var isRefreshing: Bool { states.values.contains(where: \.isRefreshing) } + + public var menuBarReading: AIUsageMenuBarReading? { + guard settings.aiUsageEnabled else { return nil } + let candidates = states.values.compactMap { state -> (AIUsageProviderState, AIQuotaWindow)? in + let window = settings.aiUsageFocus.map { state.snapshot?.window($0) } ?? state.snapshot?.attentionWindow + return window.map { (state, $0) } + } + guard let (state, window) = candidates.max(by: { $0.1.usedPercent < $1.1.usedPercent }), + let snapshot = state.snapshot else { return nil } + return AIUsageMenuBarReading( + provider: state.provider, + kind: window.kind, + displayedFraction: snapshot.displayedPercent(for: window, mode: settings.aiUsageDisplayMode) / 100, + usedFraction: min(max(window.usedPercent / 100, 0), 1), + isStale: state.isStale + ) + } + + public func start() { + cadenceTask?.cancel() + cadenceTask = nil + guard settings.aiUsageEnabled, !paused else { + generation += 1 + if !settings.aiUsageEnabled { + states = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, AIUsageProviderState(provider: $0.id)) }) + } + return + } + let seconds = settings.aiUsageRefreshMinutes * 60 + cadenceTask = Task { [weak self] in + await self?.refresh() + while !Task.isCancelled { + do { try await Task.sleep(for: .seconds(seconds)) } catch { return } + guard !Task.isCancelled else { return } + await self?.refresh() + } + } + } + + public func stop() { + generation += 1 + cadenceTask?.cancel() + cadenceTask = nil + } + + public func setPaused(_ value: Bool) { + paused = value + if value { stop() } else { start() } + } + + public func refresh() async { + guard settings.aiUsageEnabled, !paused, !refreshInProgress, !Task.isCancelled else { return } + refreshInProgress = true + let currentGeneration = generation + defer { + refreshInProgress = false + for id in states.keys { states[id]?.isRefreshing = false } + } + let attempt = now() + + for provider in providers { + var state = state(for: provider.id) + if let retryAt = state.failure?.retryAt, retryAt > attempt { continue } + state.isRefreshing = true + states[provider.id] = state + do { + let snapshot = try await provider.fetch() + guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } + state.snapshot = snapshot + state.failure = nil + lastSuccessfulRefreshAt = snapshot.fetchedAt + } catch let failure as AIUsageFailure { + guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } + if !failure.preservesLastGood { state.snapshot = nil } + state.failure = failure + } catch { + guard currentGeneration == generation, settings.aiUsageEnabled, !Task.isCancelled else { return } + state.failure = .network + } + state.isRefreshing = false + states[provider.id] = state + } + lastAttemptAt = attempt + } +} diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift index b3a2837..9f235f2 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppModel.swift @@ -1,3 +1,4 @@ +import AIUsage import Foundation import Metrics import Observation @@ -10,6 +11,7 @@ import Updates @Observable public final class AppModel { public let settings: AppSettings + public let aiUsage: AIUsageController public let store: MetricsStore public let helper: HelperClient public let fans: FanController @@ -61,10 +63,12 @@ public final class AppModel { @ObservationIgnored var openSpeedTestWindow: () -> Void = {} @ObservationIgnored var quit: () -> Void = {} - public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL) { + public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL, + aiUsageProviders: [any AIUsageProvider] = [CodexProvider()]) { let store = MetricsStore() let helper = HelperClient() self.settings = settings + aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders) self.store = store self.helper = helper fans = FanController(helper: helper, store: store, settings: settings) diff --git a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift index be1aca9..cb80593 100644 --- a/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift +++ b/Packages/XStatsKit/Sources/XStatsUI/State/AppSettings.swift @@ -3,13 +3,14 @@ // SPDX-License-Identifier: AGPL-3.0-or-later AND MIT // See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt. +import AIUsage import Foundation import Localization import Metrics import Observation public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { - case cpu, memory, network, gpu, disk, temperature, fan, battery + case cpu, memory, network, gpu, disk, temperature, fan, battery, aiUsage public var id: String { rawValue } @@ -23,6 +24,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: tr("CPU 温度") case .fan: tr("风扇转速") case .battery: tr("电池") + case .aiUsage: tr("AI 配额") } } @@ -36,6 +38,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: tr("CPU 核心最高温度") case .fan: tr("转速最高的风扇") case .battery: tr("电量与充电状态;没有电池的 Mac 显示蓝牙设备电量") + case .aiUsage: tr("Codex 订阅配额、重置时间与积分余额") } } @@ -50,6 +53,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: "TEMP" case .fan: "FAN" case .battery: "BAT" + case .aiUsage: "AI" } } @@ -64,6 +68,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: tr("温度") case .fan: tr("风扇") case .battery: tr("电池") + case .aiUsage: tr("AI 配额") } } @@ -78,6 +83,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: [.thermalSensors, .thermalFans, .thermalPower] case .fan: [.thermalFans, .thermalSensors, .thermalPower] case .battery: [.batteryHistory, .batteryPower, .batteryHealth, .batteryBluetooth] + case .aiUsage: [] } } @@ -94,6 +100,7 @@ public enum MenuBarItem: String, CaseIterable, Identifiable, Sendable { case .temperature: "thermometer.medium" case .fan: "fan" case .battery: "battery.75" + case .aiUsage: "sparkles" } } } @@ -200,6 +207,7 @@ public enum MenuBarStyle: String, CaseIterable, Identifiable, Sendable { static func options(for item: MenuBarItem) -> [MenuBarStyle] { switch item { case .temperature, .fan: [.stacked, .inline, .icon] + case .aiUsage: [.stacked, .inline, .icon, .ring, .pie, .meter, .dot] default: allCases } } @@ -272,12 +280,12 @@ public enum AppearanceMode: String, CaseIterable, Identifiable, Sendable { /// 主窗口侧边栏的页面 public enum PanelTab: String, CaseIterable, Identifiable, Sendable { - case overview, system, history, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, cleaner, uninstaller, startupItems + case overview, system, history, aiUsage, cpu, gpu, memory, disk, network, thermal, battery, processes, keepAwake, cleaner, uninstaller, startupItems case settingsGeneral, settingsMenuBar, settingsNotifications, settingsAccount, settingsHelper, settingsAbout public var id: String { rawValue } - static let monitors: [PanelTab] = [.overview, .system, .history, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery] + static let monitors: [PanelTab] = [.overview, .system, .history, .aiUsage, .cpu, .gpu, .memory, .disk, .network, .thermal, .battery] static let tools: [PanelTab] = [.processes, .startupItems, .keepAwake, .cleaner, .uninstaller] static let settings: [PanelTab] = [.settingsGeneral, .settingsMenuBar, .settingsNotifications, .settingsAccount, .settingsHelper, .settingsAbout] @@ -291,6 +299,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .overview: tr("仪表盘") case .system: tr("本机信息") case .history: tr("历史") + case .aiUsage: tr("AI 配额") case .cpu: "CPU" case .gpu: "GPU" case .memory: tr("内存") @@ -317,6 +326,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .overview: "square.grid.2x2" case .system: "laptopcomputer" case .history: "clock.arrow.circlepath" + case .aiUsage: "sparkles" case .cpu: "cpu" case .gpu: "square.3.layers.3d" case .memory: "memorychip" @@ -348,6 +358,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .disk: .disk case .thermal: .temperature case .battery: .battery + case .aiUsage: .aiUsage default: nil } } @@ -369,6 +380,7 @@ public enum PanelTab: String, CaseIterable, Identifiable, Sendable { case .disk: self = .disk case .temperature, .fan: self = .thermal case .battery: self = .battery + case .aiUsage: self = .aiUsage } } } @@ -398,6 +410,20 @@ public final class AppSettings { public var refreshSeconds: Int { didSet { defaults.set(refreshSeconds, forKey: Keys.refreshSeconds) } } + /// AI 配额是外部网络查询,默认不启用;启用后独立于系统指标的秒级采样。 + public var aiUsageEnabled: Bool { + didSet { defaults.set(aiUsageEnabled, forKey: Keys.aiUsageEnabled) } + } + public var aiUsageRefreshMinutes: Int { + didSet { defaults.set(aiUsageRefreshMinutes, forKey: Keys.aiUsageRefreshMinutes) } + } + public var aiUsageDisplayMode: AIUsageDisplayMode { + didSet { defaults.set(aiUsageDisplayMode.rawValue, forKey: Keys.aiUsageDisplayMode) } + } + /// nil 自动选择;固定窗口暂时缺失时显示无数据,不暗中换成其他指标。 + public var aiUsageFocus: AIQuotaKind? { + didSet { defaults.set(aiUsageFocus?.rawValue, forKey: "aiUsageFocus") } + } public var colorizeHighLoad: Bool { didSet { defaults.set(colorizeHighLoad, forKey: Keys.colorizeHighLoad) } } @@ -511,6 +537,7 @@ public final class AppSettings { } public static let refreshOptions = [1, 2, 3, 5] + public static let aiUsageRefreshOptions = [5, 15, 30, 60] public static let batteryFloorOptions = [10, 20, 30, 40] public static let fanSafetyOptions = [85, 90, 95, 100] public static let alertTemperatureOptions = [85, 90, 95, 100] @@ -530,6 +557,11 @@ public final class AppSettings { }) refreshSeconds = Self.refreshOptions.contains(defaults.integer(forKey: Keys.refreshSeconds)) ? defaults.integer(forKey: Keys.refreshSeconds) : 2 + aiUsageEnabled = defaults.bool(forKey: Keys.aiUsageEnabled) + aiUsageRefreshMinutes = Self.aiUsageRefreshOptions.contains(defaults.integer(forKey: Keys.aiUsageRefreshMinutes)) + ? defaults.integer(forKey: Keys.aiUsageRefreshMinutes) : 30 + aiUsageDisplayMode = defaults.string(forKey: Keys.aiUsageDisplayMode).flatMap(AIUsageDisplayMode.init(rawValue:)) ?? .remaining + aiUsageFocus = defaults.string(forKey: "aiUsageFocus").flatMap(AIQuotaKind.init(rawValue:)) colorizeHighLoad = defaults.bool(forKey: Keys.colorizeHighLoad) bluetoothLowBatteryInMenuBar = defaults.object(forKey: Keys.bluetoothLowBatteryInMenuBar) as? Bool ?? true useFahrenheit = defaults.bool(forKey: Keys.useFahrenheit) @@ -605,6 +637,9 @@ public final class AppSettings { static let networkStyle = "networkStyle" static let styleOverrides = "styleOverrides" static let refreshSeconds = "refreshSeconds" + static let aiUsageEnabled = "aiUsageEnabled" + static let aiUsageRefreshMinutes = "aiUsageRefreshMinutes" + static let aiUsageDisplayMode = "aiUsageDisplayMode" static let colorizeHighLoad = "colorizeHighLoad" static let bluetoothLowBatteryInMenuBar = "bluetoothLowBatteryInMenuBar" static let useFahrenheit = "useFahrenheit" diff --git a/Packages/XStatsKit/Tests/AIUsageTests/CodexProviderTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/CodexProviderTests.swift new file mode 100644 index 0000000..42e86d3 --- /dev/null +++ b/Packages/XStatsKit/Tests/AIUsageTests/CodexProviderTests.swift @@ -0,0 +1,78 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +@testable import AIUsage +import Foundation +import Testing + +private actor HTTPClient: AIHTTPClient { + private var responses: [AIHTTPResponse] + private(set) var requests: [AIHTTPRequest] = [] + + init(_ responses: [AIHTTPResponse]) { self.responses = responses } + + func send(_ request: AIHTTPRequest) async throws -> AIHTTPResponse { + requests.append(request) + guard !responses.isEmpty else { throw URLError(.cannotConnectToHost) } + return responses.removeFirst() + } + + func capturedRequests() -> [AIHTTPRequest] { requests } +} + +@Suite struct CodexProviderTests { + @Test func sendsReadOnlyCredentialsToUsageEndpoint() async throws { + let body = Data(#"{"rate_limit":{"primary_window":{"used_percent":25,"limit_window_seconds":18000}}}"#.utf8) + let http = HTTPClient([AIHTTPResponse(statusCode: 200, headers: [:], body: body)]) + let provider = CodexProvider( + credentials: { CodexCredentials(accessToken: "access", accountID: "account") }, + http: http, + now: { Date(timeIntervalSince1970: 1_800_000_000) } + ) + + let snapshot = try await provider.fetch() + let request = try #require(await http.capturedRequests().first) + + #expect(snapshot.window(.session)?.usedPercent == 25) + #expect(request.method == .get) + #expect(request.url == URL(string: "https://chatgpt.com/backend-api/wham/usage")) + #expect(request.headers["Authorization"] == "Bearer access") + #expect(request.headers["ChatGPT-Account-Id"] == "account") + #expect(request.body == nil) + } + + @Test func classifiesAuthenticationAndServerFailures() async { + let credentials: @Sendable () throws -> CodexCredentials = { + CodexCredentials(accessToken: "access", accountID: nil) + } + let unauthorized = CodexProvider( + credentials: credentials, + http: HTTPClient([AIHTTPResponse(statusCode: 401, headers: [:], body: Data())]) + ) + let unavailable = CodexProvider( + credentials: credentials, + http: HTTPClient([AIHTTPResponse(statusCode: 503, headers: [:], body: Data())]) + ) + + await #expect(throws: AIUsageFailure.unauthorized) { try await unauthorized.fetch() } + await #expect(throws: AIUsageFailure.server(503)) { try await unavailable.fetch() } + } + + @Test func parsesRetryAfterWithoutRetryingInsideProvider() async throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let http = HTTPClient([AIHTTPResponse(statusCode: 429, headers: ["Retry-After": "120"], body: Data())]) + let provider = CodexProvider( + credentials: { CodexCredentials(accessToken: "access", accountID: nil) }, + http: http, + now: { now } + ) + + do { + _ = try await provider.fetch() + Issue.record("Expected rate limit") + } catch { + #expect(error as? AIUsageFailure == .rateLimited(now.addingTimeInterval(120))) + } + #expect(await http.capturedRequests().count == 1) + } +} diff --git a/Packages/XStatsKit/Tests/AIUsageTests/CodexUsageTests.swift b/Packages/XStatsKit/Tests/AIUsageTests/CodexUsageTests.swift new file mode 100644 index 0000000..e58e42e --- /dev/null +++ b/Packages/XStatsKit/Tests/AIUsageTests/CodexUsageTests.swift @@ -0,0 +1,141 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +@testable import AIUsage +import Foundation +import Testing + +@Suite struct CodexCredentialStoreTests { + @Test func resolvesConfiguredHomeBeforeDefaultLocations() { + let home = URL(fileURLWithPath: "/Users/test") + + let overridden = CodexCredentialStore.candidateURLs(environment: ["CODEX_HOME": "/tmp/codex"], home: home) + let defaults = CodexCredentialStore.candidateURLs(environment: [:], home: home) + + #expect(overridden.map(\.path) == ["/tmp/codex/auth.json"]) + #expect(defaults.map(\.path) == [ + "/Users/test/.config/codex/auth.json", + "/Users/test/.codex/auth.json", + ]) + } + + @Test func parsesOAuthCredentialsWithoutRetainingRefreshMaterial() throws { + let data = Data(#"{"tokens":{"access_token":"access","refresh_token":"refresh","id_token":"id","account_id":"account"}}"#.utf8) + + let credentials = try CodexCredentialStore.parse(data) + + #expect(credentials.accessToken == "access") + #expect(credentials.accountID == "account") + } + + @Test func rejectsAPIKeyOnlyAuthentication() { + let data = Data(#"{"OPENAI_API_KEY":"sk-test"}"#.utf8) + + #expect(throws: AIUsageFailure.apiKeyUnsupported) { + try CodexCredentialStore.parse(data) + } + } + + @Test func rejectsMalformedAuthenticationWithoutIncludingInput() { + let secret = "secret-that-must-not-escape" + let data = Data("{\"tokens\":{\"access_token\":\"\(secret)\"".utf8) + + do { + _ = try CodexCredentialStore.parse(data) + Issue.record("Expected invalid credentials") + } catch { + #expect(error as? AIUsageFailure == .invalidCredentials) + #expect(!String(describing: error).contains(secret)) + } + } +} + +@Suite struct CodexUsageMapperTests { + @Test func acceptsZeroAndOnePercentAndRejectsNonfiniteCredits() throws { + let data = Data(#"{"rate_limit":{"primary_window":{"used_percent":0,"limit_window_seconds":18000},"secondary_window":{"used_percent":1,"limit_window_seconds":604800}},"credits":{"balance":"inf"}}"#.utf8) + let snapshot = try CodexUsageMapper.map(data: data, headers: [:], now: Date()) + #expect(snapshot.windows.map(\.usedPercent) == [0, 1]) + #expect(snapshot.remainingCredits == nil) + } + @Test func classifiesWindowsByDurationAndMapsSparkAndCredits() throws { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let data = Data(#""" + { + "plan_type": "prolite", + "rate_limit": { + "primary_window": { + "used_percent": 73, + "limit_window_seconds": 604800, + "reset_at": 1800100000 + }, + "secondary_window": { + "used_percent": 21, + "limit_window_seconds": 18000, + "reset_after_seconds": 600 + } + }, + "additional_rate_limits": [{ + "limit_name": "GPT-Codex-Spark", + "rate_limit": { + "primary_window": {"used_percent": 91, "limit_window_seconds": 18000}, + "secondary_window": {"used_percent": 44, "limit_window_seconds": 604800} + } + }], + "credits": {"balance": "820.9"} + } + """#.utf8) + + let snapshot = try CodexUsageMapper.map(data: data, headers: [:], now: now) + + #expect(snapshot.planName == "Pro 5x") + #expect(snapshot.windows.map(\.kind) == [.session, .weekly, .sparkSession, .sparkWeekly]) + #expect(snapshot.windows.map(\.usedPercent) == [21, 73, 91, 44]) + #expect(snapshot.window(.session)?.resetsAt == now.addingTimeInterval(600)) + #expect(snapshot.window(.weekly)?.resetsAt == Date(timeIntervalSince1970: 1_800_100_000)) + #expect(snapshot.remainingCredits == 820.9) + } + + @Test func usesHeadersWhenResponseOmitsPercentages() throws { + let data = Data(#"{"rate_limit":{"primary_window":{"limit_window_seconds":18000},"secondary_window":{"limit_window_seconds":604800}}}"#.utf8) + + let snapshot = try CodexUsageMapper.map( + data: data, + headers: [ + "X-Codex-Primary-Used-Percent": "17.25", + "x-codex-secondary-used-percent": "68", + ], + now: Date() + ) + + #expect(snapshot.windows.map(\.usedPercent) == [17.25, 68]) + } + + @Test func rejectsBooleanPercentInsteadOfTreatingItAsOne() { + let data = Data(#"{"rate_limit":{"primary_window":{"used_percent":true,"limit_window_seconds":18000}}}"#.utf8) + + #expect(throws: AIUsageFailure.invalidResponse) { + try CodexUsageMapper.map(data: data, headers: [:], now: Date()) + } + } +} + +@Suite struct AIUsageSnapshotTests { + @Test func automaticFocusChoosesHighestUnderlyingUsage() throws { + let snapshot = AIUsageSnapshot( + provider: .codex, + planName: "Pro 20x", + windows: [ + AIQuotaWindow(kind: .session, usedPercent: 32, resetsAt: nil), + AIQuotaWindow(kind: .weekly, usedPercent: 87, resetsAt: nil), + ], + remainingCredits: nil, + fetchedAt: Date() + ) + + let focus = try #require(snapshot.attentionWindow) + + #expect(focus.kind == .weekly) + #expect(snapshot.displayedPercent(for: focus, mode: .remaining) == 13) + #expect(snapshot.displayedPercent(for: focus, mode: .used) == 87) + } +} diff --git a/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift new file mode 100644 index 0000000..c431c81 --- /dev/null +++ b/Packages/XStatsKit/Tests/XStatsUITests/AIUsageControllerTests.swift @@ -0,0 +1,142 @@ +// Copyright (C) 2026 ysicing +// SPDX-License-Identifier: AGPL-3.0-or-later + +@testable import AIUsage +import Foundation +import Testing +@testable import XStatsUI + +private actor StubUsageProvider: AIUsageProvider { + nonisolated let id = AIProviderID.codex + private var results: [Result] + private(set) var fetchCount = 0 + + init(_ results: [Result]) { self.results = results } + + func fetch() async throws -> AIUsageSnapshot { + fetchCount += 1 + guard !results.isEmpty else { throw AIUsageFailure.network } + return try results.removeFirst().get() + } + + func count() -> Int { fetchCount } +} + +private func defaultsForAIUsage() -> UserDefaults { + let name = "AIUsageControllerTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defaults.removePersistentDomain(forName: name) + return defaults +} + +private func usageSnapshot(percent: Double, at date: Date = Date(timeIntervalSince1970: 1_800_000_000)) -> AIUsageSnapshot { + AIUsageSnapshot(provider: .codex, planName: "Pro 20x", + windows: [AIQuotaWindow(kind: .weekly, usedPercent: percent, resetsAt: nil)], + remainingCredits: nil, fetchedAt: date) +} + +@MainActor +@Suite struct AIUsageControllerTests { + @Test func navigationExposesAIUsageAsAMonitorAndMenuBarItem() { + #expect(PanelTab.monitors.contains(.aiUsage)) + #expect(PanelTab(item: .aiUsage) == .aiUsage) + #expect(MenuBarStyle.options(for: .aiUsage) == [.stacked, .inline, .icon, .ring, .pie, .meter, .dot]) + } + + @Test func settingsDefaultToOptInAndThirtyMinuteRefresh() { + let settings = AppSettings(defaults: defaultsForAIUsage()) + + #expect(!settings.aiUsageEnabled) + #expect(settings.aiUsageRefreshMinutes == 30) + #expect(settings.aiUsageDisplayMode == .remaining) + } + + @Test func transientFailurePreservesLastGoodSnapshotAsStale() async { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 72)), .failure(.network)]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider]) + + await controller.refresh() + await controller.refresh() + + #expect(controller.state(for: .codex).snapshot?.window(.weekly)?.usedPercent == 72) + #expect(controller.state(for: .codex).failure == .network) + #expect(controller.state(for: .codex).isStale) + } + + @Test func authenticationFailureClearsLastGoodSnapshot() async { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 72)), .failure(.unauthorized)]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider]) + + await controller.refresh() + await controller.refresh() + + #expect(controller.state(for: .codex).snapshot == nil) + #expect(controller.state(for: .codex).failure == .unauthorized) + } + + @Test func rateLimitPreventsRequestsUntilRetryDate() async { + let now = Date(timeIntervalSince1970: 1_800_000_000) + let provider = StubUsageProvider([.failure(.rateLimited(now.addingTimeInterval(300)))]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider], now: { now }) + + await controller.refresh() + await controller.refresh() + + #expect(await provider.count() == 1) + #expect(controller.state(for: .codex).failure == .rateLimited(now.addingTimeInterval(300))) + } + + @Test func menuBarReadingUsesAttentionWindowAndDisplayMode() async throws { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 87))]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider]) + + await controller.refresh() + let reading = try #require(controller.menuBarReading) + + #expect(reading.displayedFraction == 0.13) + #expect(reading.usedFraction == 0.87) + #expect(reading.kind == .weekly) + } + + @Test func disablingTrackingHidesCachedMenuBarReading() async { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 87))]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider]) + await controller.refresh() + + settings.aiUsageEnabled = false + + #expect(controller.menuBarReading == nil) + } + + @Test func fixedWindowNeverFallsBackToAnotherQuota() async { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 87))]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + settings.aiUsageFocus = .session + let controller = AIUsageController(settings: settings, providers: [provider]) + await controller.refresh() + #expect(controller.menuBarReading == nil) + settings.aiUsageFocus = .weekly + #expect(controller.menuBarReading?.kind == .weekly) + } + + @Test func pausedControllerDoesNotFetch() async { + let provider = StubUsageProvider([.success(usageSnapshot(percent: 87))]) + let settings = AppSettings(defaults: defaultsForAIUsage()) + settings.aiUsageEnabled = true + let controller = AIUsageController(settings: settings, providers: [provider]) + controller.setPaused(true) + await controller.refresh() + #expect(await provider.count() == 0) + } +} diff --git a/README.en.md b/README.en.md index ae37618..7a69050 100644 --- a/README.en.md +++ b/README.en.md @@ -15,11 +15,11 @@ [![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE) XStats is a macOS menu-bar app that shows what your Mac is doing right now — per-core -CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and fans — and +CPU load, GPU, memory pressure, network speed, disk, battery, temperatures and fans, with optional Codex subscription quota — and lets you act on it: spin the fans up, keep the Mac awake with the lid closed, clear caches, fully uninstall apps and disable startup items. Network details also check how clean your public IP is — whether it is flagged as a VPN, proxy, data center or for abuse. -All monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in local preferences, is not a credential, and is excluded from WebDAV sync. Other optional network features include a public-IP and cleanliness lookup when you open network details, a ping probe to a target you choose, and a daily check for a new version. +All system-monitoring metrics are read on your own Mac and are never uploaded. No XStats account is required: manually back up and restore preferences through your own WebDAV server. Update checks send the current version and the SHA-256 of a random installation ID for anonymous installation and version-distribution statistics; the original random value remains in local preferences, is not a credential, and is excluded from WebDAV sync. Other optional network features include a public-IP and cleanliness lookup, a ping probe, a read-only Codex CLI login used to query ChatGPT subscription quota directly, and a daily update check. [Download](https://github.com/ysicing/xstats/releases) · [Changelog](CHANGELOG.md) · @@ -49,7 +49,7 @@ Arabic uses a right-to-left layout. -Latest release **0.7.1** (2026-09-22) · **1** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.7.1** (2026-09-22) · **2** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese) @@ -103,6 +103,7 @@ Choose which sections each popover shows in Settings. `Esc` closes it. 60-second history; SSD health; the apps reading and writing the most. - **GPU**, **Temperature & fans.** History, sensor groups, fan speeds and quick modes. - **Battery.** Charge level, time remaining, adapter wattage and battery temperature; a 24-hour charge curve; power draw; health and cycle count; the batteries of connected Bluetooth devices (AirPods, Magic Keyboard / Mouse / Trackpad). Macs without a battery show the Bluetooth devices only. +- **AI quota.** Optionally reads the Codex CLI login to show session, weekly and Spark quotas, reset times and credit balance. Switch between left and used; temporary failures retain and mark the last value as stale.

CPU popover @@ -194,8 +195,7 @@ Metrics come from the kernel (`host_processor_info`, `host_statistics64`, `sysct SMC on your own Mac. Preferences live in the app's user defaults and contain nothing personal. Monitoring metrics, history, the hardware serial number and process lists are never uploaded. -Every feature that touches the network can be turned off — the first two in Settings → Network, the update -check in Settings → About: +Every feature that touches the network can be turned off. Public IP and connection probes live on the Network page, AI quota on its monitor page, and update checks in Settings → About: - **Public IP**: when you open network details, one request to Cloudflare `1.1.1.1` (ipify as fallback) for your public address, cached for 10 minutes. Location, ASN, network type and the cleanliness score @@ -204,6 +204,9 @@ check in Settings → About: - **Connection probe**: an ICMP ping to the target you pick (Cloudflare, Google, Alibaba Cloud, Tencent or your router) once a second while network details are open (every 10 seconds in the background), only while the network item is in the menu bar or network details are open. +- **AI quota**: off by default. When enabled, XStats reads the access token managed by Codex CLI from + `$CODEX_HOME/auth.json`, `~/.config/codex/auth.json` or `~/.codex/auth.json` and directly requests + `chatgpt.com/backend-api/wham/usage`. XStats never stores, refreshes or writes the token. - **Update check and installation statistics**: at launch and once a day, the app sends the current version and the SHA-256 of a random installation ID and reads the version manifest. China-region locales prefer `x-stats.china.12306.work`; all others prefer `xstats-apps.12306.work`. The app falls back serially to the other endpoint @@ -244,6 +247,8 @@ XStats builds on these open-source projects. Thank you. | [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC access, the Apple Silicon fan unlock sequence and the menu-bar mini widget metrics | | [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | Which folders are worth cleaning and which must never be touched; the cleaner is an independent Swift implementation and contains no Mole code | | [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | This README's layout, the changelog sync and the activity chart | +| [AI Usage](https://github.com/burakgon/ai-usage-menubar) / [OpenUsage](https://github.com/robinebers/openusage) | Burak Gon / Robin Ebers | MIT | AI provider contracts, Codex credential discovery and quota-response research | +| [usage-bar](https://github.com/methol-dev/usage-bar) | Krystian | BSD-2-Clause | Read-only credential, rate-limit and provider-state design references | Details in [ThirdPartyNotices.md](ThirdPartyNotices.md). diff --git a/README.ja.md b/README.ja.md index 8e3fda4..6b508a2 100644 --- a/README.ja.md +++ b/README.ja.md @@ -14,10 +14,10 @@ XStats は、Mac の各 CPU コア、GPU、メモリ負荷、通信速度、ディスク、バッテリー、温度、ファンの状態を表示するメニューバーアプリです。 状態の確認に加えて、ファンの回転数調整、蓋を閉じた状態でのスリープ防止、キャッシュ削除、アプリと関連ファイルの削除、起動項目の管理ができます。 -ネットワーク画面では、公開 IP が VPN、プロキシ、データセンター、不正利用などに関連付けられているかも確認できます。 +ネットワーク画面では、公開 IP が VPN、プロキシ、データセンター、不正利用などに関連付けられているかも確認できます。必要に応じて Codex CLI のログインを読み取り、サブスクリプション使用枠も表示できます。 監視指標は Mac 内だけで処理され、アップロードされません。XStats のアカウントは不要で、自分の WebDAV サーバーを使って設定を手動でバックアップ・復元できます。更新確認では、匿名のインストール数とバージョン分布を集計するため、現在のバージョンとランダムなインストール ID の SHA-256 を送信します。元のランダム値はローカルのキーチェーンにのみ保存されます。 -公開 IP の照会、接続テスト、更新確認などのネットワーク機能は任意です。 +公開 IP の照会、接続テスト、Codex 使用枠、更新確認などのネットワーク機能は任意です。 [ダウンロード](https://github.com/ysicing/xstats/releases) · [変更履歴(中国語)](CHANGELOG.md) · [開発ガイド(中国語)](DEVELOPMENT.md) @@ -156,6 +156,7 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉 ## データとプライバシー 指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。 +AI 使用枠は初期状態で無効です。有効にすると Codex CLI が管理するローカルのアクセストークンを読み取り、`chatgpt.com/backend-api/wham/usage` へ直接問い合わせます。XStats はトークンを保存、更新、書き戻ししません。 公開 IP は Cloudflare(失敗時は ipify)、IP 評価は cleanip.io に直接問い合わせます。接続テストは選択した対象へ ICMP ping を送ります。更新確認は現在のバージョンとランダムなインストール ID の SHA-256 を送信してバージョン情報を取得します。中国地域では `x-stats.china.12306.work`、その他では `xstats-apps.12306.work` を優先し、失敗時のみもう一方へ順番にフォールバックして、最初の成功後は停止します。サーバーはこのハッシュ、現在のバージョン、初回/最終確認時刻、確認回数のみを保存し、シリアル番号を保存せず、リクエスト IP も永続化しません(1 分間のメモリ内レート制限にのみ使用します)。自動更新確認を無効にすると自動送信も停止します。 これらは設定で無効化できます。Apple Intelligence によるプロセス説明は端末内で行います。 @@ -186,6 +187,8 @@ JSON ファイル自体は追加暗号化しないため、非公開のディレ | [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC 通信、Apple Silicon のファン制御、メニューバー表示 | | [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | 清掃対象と保護対象の考え方。クリーンアップは独自の Swift 実装で、Mole のコードを含みません | | [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | README の構成、変更履歴の同期、活動グラフ | +| [AI Usage](https://github.com/burakgon/ai-usage-menubar) / [OpenUsage](https://github.com/robinebers/openusage) | Burak Gon / Robin Ebers | MIT | AI Provider 契約、Codex 認証情報の検出、使用枠レスポンスの調査 | +| [usage-bar](https://github.com/methol-dev/usage-bar) | Krystian | BSD-2-Clause | 認証情報の読み取り専用処理、レート制限、Provider 状態設計の参考 | 詳細は [ThirdPartyNotices.md](ThirdPartyNotices.md) を参照してください。 XStats は独立した第三者アプリであり、Apple や本文中の他社による承認・支援を受けた製品ではありません。 diff --git a/README.ko.md b/README.ko.md index d0bce89..c4ce13b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -14,10 +14,10 @@ XStats는 CPU 코어별 사용량, GPU, 메모리 압력, 네트워크 속도, 디스크, 배터리, 온도와 팬 상태를 보여 주는 macOS 메뉴 막대 앱입니다. 팬 속도 조절, 덮개를 닫은 상태에서 실행 유지, 캐시 정리, 앱과 관련 파일 제거, 시작 항목 관리도 지원합니다. -네트워크 화면에서는 공인 IP가 VPN, 프록시, 데이터 센터 또는 악용 기록과 관련되어 있는지 확인할 수 있습니다. +네트워크 화면에서는 공인 IP가 VPN, 프록시, 데이터 센터 또는 악용 기록과 관련되어 있는지 확인할 수 있습니다. 필요하면 Codex CLI 로그인을 읽어 구독 할당량도 표시합니다. 모니터링 지표는 Mac 안에서만 처리하며 업로드하지 않습니다. XStats 계정 없이 자신의 WebDAV 서버로 설정을 수동 백업하고 복원할 수 있습니다. 업데이트 확인 시 익명 설치 수와 버전 분포를 집계하기 위해 현재 버전과 무작위 설치 ID의 SHA-256을 전송하며, 원본 무작위 값은 로컬 키체인에만 저장합니다. -공인 IP 조회, 연결 테스트, 업데이트 확인 등의 네트워크 기능은 선택 사항입니다. +공인 IP 조회, 연결 테스트, Codex 할당량, 업데이트 확인 등의 네트워크 기능은 선택 사항입니다. [다운로드](https://github.com/ysicing/xstats/releases) · [변경 기록(중국어)](CHANGELOG.md) · [개발 안내(중국어)](DEVELOPMENT.md) @@ -156,6 +156,7 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로 ## 데이터와 개인정보 지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다. +AI 할당량은 기본적으로 꺼져 있습니다. 사용하면 Codex CLI가 관리하는 로컬 액세스 토큰을 읽고 `chatgpt.com/backend-api/wham/usage`에 직접 요청합니다. XStats는 토큰을 저장하거나 갱신하거나 다시 쓰지 않습니다. 공인 IP는 Cloudflare(실패 시 ipify), IP 평판은 cleanip.io에 직접 조회합니다. 연결 테스트는 선택한 대상으로 ICMP ping을 보냅니다. 업데이트 확인은 현재 버전과 무작위 설치 ID의 SHA-256을 보내고 버전 정보를 읽습니다. 중국 지역은 `x-stats.china.12306.work`, 그 외 지역은 `xstats-apps.12306.work`를 우선 사용하며, 실패한 경우에만 다른 엔드포인트로 순차 전환하고 첫 성공 후 중지합니다. 서버에는 해당 해시, 현재 버전, 최초·최근 확인 시각과 확인 횟수만 저장하며, 하드웨어 일련번호를 저장하지 않고 요청 IP도 영구 저장하지 않습니다(1분 동안의 메모리 내 속도 제한에만 사용). 자동 업데이트 확인을 끄면 자동 요청도 중지됩니다. 이 기능들은 설정에서 끌 수 있습니다. Apple Intelligence 프로세스 설명은 기기 안에서 처리됩니다. @@ -186,6 +187,8 @@ JSON 파일 자체는 추가 암호화하지 않으므로 비공개 디렉터리 | [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC 통신, Apple Silicon 팬 제어, 메뉴 막대 표시 | | [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | 정리·보호 대상에 대한 아이디어. 정리 기능은 독립적인 Swift 구현이며 Mole 코드를 포함하지 않습니다 | | [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | README 구성, 변경 기록 동기화, 활동 그래프 | +| [AI Usage](https://github.com/burakgon/ai-usage-menubar) / [OpenUsage](https://github.com/robinebers/openusage) | Burak Gon / Robin Ebers | MIT | AI Provider 계약, Codex 자격 증명 탐색, 할당량 응답 연구 | +| [usage-bar](https://github.com/methol-dev/usage-bar) | Krystian | BSD-2-Clause | 읽기 전용 자격 증명, 속도 제한, Provider 상태 설계 참고 | 자세한 내용은 [ThirdPartyNotices.md](ThirdPartyNotices.md)를 참고하세요. XStats는 독립적인 서드파티 앱이며 Apple이나 본문에 언급된 다른 회사가 승인하거나 후원하는 제품이 아닙니다. diff --git a/README.md b/README.md index 2c381aa..02d9a75 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ [![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue)](LICENSE) XStats 是一款 macOS 菜单栏应用,实时显示 Mac 正在做什么:各核心 CPU 负载、GPU、内存压力、 -网速、磁盘、电池、温度和风扇,并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存、彻底卸载应用、停用启动项; +网速、磁盘、电池、温度和风扇,也可以按需查看 Codex 订阅配额;并且可以直接处理:给风扇提速、合盖后继续运行、清理缓存、彻底卸载应用、停用启动项; 网络详情还能检测公网 IP 的纯净度,看出出口是否被标记为 VPN、代理、机房或有滥用记录。 -所有监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机偏好设置中,不作为凭据,也不参与 WebDAV 同步。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标,以及每天检查一次新版本。 +所有系统监控指标都在你自己的 Mac 上读取,不会上传。不需要 XStats 账号,可通过自己的 WebDAV 服务器手动备份和恢复偏好设置。检查更新时会发送当前版本和随机安装标识的 SHA-256,用于匿名安装与版本分布统计;原始随机值只保存在本机偏好设置中,不作为凭据,也不参与 WebDAV 同步。其他可关闭的联网功能包括:打开网络详情时查询公网 IP 与纯净度、定时 ping 你选择的探测目标、只读本机 Codex CLI 登录后向 ChatGPT 查询订阅配额,以及每天检查一次新版本。 [下载](https://github.com/ysicing/xstats/releases) · [更新日志](CHANGELOG.md) · @@ -46,10 +46,14 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 -最新版本 **0.7.1**(2026-09-22) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md) +最新版本 **0.7.1**(2026-09-22) · 开发中 **2** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)

-2026-09-22 · 未发布 · 修复 1 +2026-09-22 · 未发布 · 新增 1 · 修复 1 + +**新增** + +- 新增 AI 配额监控:只读 Codex CLI 登录,显示会话、周配额、Spark、积分与重置时间;支持剩余/已用、菜单栏自动或固定指标,以及限流退避和过期提示。 **修复** @@ -130,6 +134,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 - **磁盘**:启动磁盘容量分段条(已用、可清除、可用);读写速度与 60 秒走势;SSD 健康;读写最多的应用。 - **GPU**、**温度与风扇**:使用历史、各组温度、风扇转速与快捷模式。 - **电池**:电量与剩余 / 充满时间、适配器功率与电池温度;最近 24 小时电量曲线;功耗;健康度与循环次数;已连接蓝牙设备的电量(AirPods、妙控键盘 / 鼠标 / 触控板)。没有电池的 Mac 只显示蓝牙设备。 +- **AI 配额**:按需只读 Codex CLI 登录,显示会话、周配额、Spark 配额、重置时间和积分余额;可切换剩余 / 已用,临时失败时保留旧值并标记过期。

CPU 详情弹窗 @@ -205,10 +210,11 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登 指标来自本机的内核接口(`host_processor_info`、`host_statistics64`、`sysctl`)、IOKit 与 SMC。 偏好设置保存在应用自己的 user defaults 中,不含个人信息。监控指标、历史记录、硬件序列号和进程列表不会上传。 -会联网的功能都可以关闭。前两项在“设置 → 网络”中,检查更新在“设置 · 关于”中: +会联网的功能都可以关闭。公网 IP 与连接探测在网络页中,AI 配额在对应监控页中,检查更新在“设置 · 关于”中: - **公网 IP**:打开网络详情时向 Cloudflare `1.1.1.1`(回退 ipify)请求一次公网地址,10 分钟内不重复请求。归属地、ASN、网络类型与纯净度评分由这台 Mac 直接向 `cleanip.io` 查询,只发送公网地址、不经过我们的服务器;地址没变、不满 7 天就沿用上次结果,点刷新才重查。 - **连接探测**:打开网络详情时每秒(后台每 10 秒)向你选择的目标(Cloudflare、Google、阿里云、腾讯或路由器)发送一次 ICMP ping,只在菜单栏显示网络项或打开网络详情时运行。 +- **AI 配额**:默认关闭。启用后只读 `$CODEX_HOME/auth.json`、`~/.config/codex/auth.json` 或 `~/.codex/auth.json` 中由 Codex CLI 管理的访问令牌,直接请求 `chatgpt.com/backend-api/wham/usage`;XStats 不保存、不刷新也不写回令牌。 - **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机偏好设置中,不访问钥匙串。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。 WebDAV 只向你配置的服务器传输偏好设置,不上传监控数据、历史记录或 WebDAV 凭据。 @@ -242,6 +248,8 @@ XStats 基于以下开源项目,谢谢。 | [Stats](https://github.com/exelban/stats) | Serhiy Mytrovtsiy | MIT | SMC 访问、Apple Silicon 风扇解锁流程、菜单栏迷你样式的字号参数 | | [Mole](https://github.com/tw93/Mole) | tw93 | GPL-3.0 | 哪些目录值得清理、哪些绝对不能碰;清理模块为独立的 Swift 实现,不含 Mole 代码 | | [QuotaBar](https://github.com/gentpan/quotabar) | GiantAccel, LLC | MIT | 本 README 的版式、更新日志同步与活跃度图脚本 | +| [AI Usage](https://github.com/burakgon/ai-usage-menubar) / [OpenUsage](https://github.com/robinebers/openusage) | Burak Gon / Robin Ebers | MIT | AI Provider 契约、Codex 凭据发现与配额响应研究 | +| [usage-bar](https://github.com/methol-dev/usage-bar) | Krystian | BSD-2-Clause | 只读凭据、限流退避与 Provider 状态设计参考 | 详见 [ThirdPartyNotices.md](ThirdPartyNotices.md)。 diff --git a/ThirdPartyNotices.md b/ThirdPartyNotices.md index 14ff0f3..5bf70d1 100644 --- a/ThirdPartyNotices.md +++ b/ThirdPartyNotices.md @@ -69,3 +69,15 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` + +## AI 配额 Provider 契约参考 + +XStats 的 AI 配额数据模型、Codex 凭据发现与配额响应映射参考了以下项目公开的 Provider 契约、测试样例与实现模式; +XStats 未引入这些项目的可执行程序、Sparkle 更新模块或浏览器扩展,并保持凭据只读: + +- [burakgon/ai-usage-menubar](https://github.com/burakgon/ai-usage-menubar),MIT License,Copyright (c) 2026 Burak Gon +- [robinebers/openusage](https://github.com/robinebers/openusage),MIT License,Copyright (c) 2026 Robin Ebers +- [methol-dev/usage-bar](https://github.com/methol-dev/usage-bar),BSD-2-Clause,Copyright (c) 2026 Krystian + +完整许可证文本见 [LICENSES/AI-Usage-and-OpenUsage-MIT.txt](LICENSES/AI-Usage-and-OpenUsage-MIT.txt) +与 [LICENSES/usage-bar-BSD-2-Clause.txt](LICENSES/usage-bar-BSD-2-Clause.txt)。