feat(ai-usage): 缓存额度并修复菜单栏预览

This commit is contained in:
ysicing committed 2026-09-24 06:59:29 +08:00
1 parent f91c2b9460
commit f636af7f03
27 files changed
+336 -56

No files matched your search

+10 -5
View File
@@ -229,15 +229,19 @@ history; `started_at` is not compared against the session creation time, because
`started_at` is its parent turn's start and recent Codex builds omit the field entirely.
Unknown models remain unknown.
Local logs cannot reliably identify the paying account or usage on other devices. No cost,
subscription limits or HTTP success rates are inferred from them. The separate in-memory quota
subscription limits or HTTP success rates are inferred from them. The separate quota
snapshot instead queries Codex `wham/usage` and Claude `api/oauth/usage` using CLI OAuth tokens.
It maps five-hour and seven-day windows, plus Claude's model-specific weekly windows, without
combining them with local token totals. Credential discovery is repeated on each refresh; Codex
reads `auth.json` under CODEX_HOME or the standard locations, while Claude reads its CLI credential
file or a non-interactive Keychain item. Neither token nor quota result is persisted by XStats.
file or a non-interactive Keychain item. Tokens are not persisted by XStats. Each provider's last
successful quota snapshot is stored in a separate table of `ai-usage.sqlite`, without credentials.
The controller restores it before the first network request and labels it with its fetch time.
Requests use isolated URL sessions and reject redirects so bearer tokens cannot be forwarded.
Missing or expired credentials clear that provider's quota display; transient errors keep the last
in-memory result marked stale. Both quota readers stop when AI Usage is disabled.
successful result marked stale across restarts. Authentication failures or removal of a manual
account delete that provider's cached snapshot. Both quota readers stop when AI Usage is disabled;
disabled providers are hidden while their cached snapshot remains available on re-enable.
Codex and Claude Code each have an optional, independently configured Sub2API source, tried only
when that provider's direct quota fetch fails. Each HTTPS base URL, admin email and account ID remain
in local preferences outside settings backups; passwords use separate Keychain items. The fallback
@@ -246,8 +250,9 @@ signs in to its configured server, reads the account record to verify its platfo
If the detailed endpoint is unavailable, the account's `extra` fields provide coarser five-hour and
weekly windows. Claude may also expose Sonnet and Fable weekly windows. Credentials and quota results
are never sent to XStats servers; the UI marks fallback results as Sub2API.
When one provider has a subscription snapshot, the menu bar AI reading shows its weekly window
(or five-hour window if weekly is unavailable), remaining percentage and compact reset time.
When one provider has a subscription snapshot, the menu bar AI reading shows its name and remaining
percentage. It prefers the weekly window (or five-hour window if weekly is unavailable); the tooltip
shows the chosen window and reset time without crowding the 22-point menu bar.
When both sources are enabled and either has quota data, the item shows a compact named reading
for each source; one without quota data shows a dash. The tooltip lists reset times and missing
data by source. Without any subscription snapshot it keeps the local daily Token reading.
@@ -0,0 +1,88 @@
// Copyright (C) 2026 ysicing
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import SQLite3
/// 只保存每个来源最后一次成功的额度快照,不包含登录凭据或请求内容。
public final class AIQuotaCacheStore {
public static var defaultURL: URL { UsageScanStore.defaultURL }
private enum StoreError: Error { case sqlite, invalidSnapshot }
private let db: OpaquePointer
private let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self)
public init(url: URL = defaultURL) throws {
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
var connection: OpaquePointer?
guard sqlite3_open_v2(url.path, &connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
let connection else {
sqlite3_close(connection)
throw StoreError.sqlite
}
sqlite3_busy_timeout(connection, 5_000)
guard sqlite3_exec(connection, """
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS ai_quota_cache_v1 (
provider TEXT PRIMARY KEY, payload BLOB NOT NULL
);
""", nil, nil, nil) == SQLITE_OK else {
sqlite3_close(connection)
throw StoreError.sqlite
}
db = connection
}
deinit { sqlite3_close(db) }
public func load() throws -> [AIProviderID: AIQuotaSnapshot] {
let query = try statement("SELECT provider,payload FROM ai_quota_cache_v1")
defer { sqlite3_finalize(query) }
var snapshots: [AIProviderID: AIQuotaSnapshot] = [:]
var status = sqlite3_step(query)
while status == SQLITE_ROW {
if let rawProvider = sqlite3_column_text(query, 0),
let provider = AIProviderID(rawValue: String(cString: rawProvider)),
let bytes = sqlite3_column_blob(query, 1),
let snapshot = try? JSONDecoder().decode(AIQuotaSnapshot.self,
from: Data(bytes: bytes, count: Int(sqlite3_column_bytes(query, 1)))),
snapshot.provider == provider, Self.isValid(snapshot) {
snapshots[provider] = snapshot
}
status = sqlite3_step(query)
}
guard status == SQLITE_DONE else { throw StoreError.sqlite }
return snapshots
}
public func save(_ snapshot: AIQuotaSnapshot) throws {
guard Self.isValid(snapshot) else { throw StoreError.invalidSnapshot }
let data = try JSONEncoder().encode(snapshot)
let query = try statement("INSERT OR REPLACE INTO ai_quota_cache_v1(provider,payload) VALUES (?,?)")
defer { sqlite3_finalize(query) }
sqlite3_bind_text(query, 1, snapshot.provider.rawValue, -1, transient)
_ = data.withUnsafeBytes { sqlite3_bind_blob(query, 2, $0.baseAddress, Int32($0.count), transient) }
guard sqlite3_step(query) == SQLITE_DONE else { throw StoreError.sqlite }
}
public func clear(_ provider: AIProviderID) throws {
let query = try statement("DELETE FROM ai_quota_cache_v1 WHERE provider=?")
defer { sqlite3_finalize(query) }
sqlite3_bind_text(query, 1, provider.rawValue, -1, transient)
guard sqlite3_step(query) == SQLITE_DONE else { throw StoreError.sqlite }
}
private func statement(_ sql: String) throws -> OpaquePointer {
var query: OpaquePointer?
guard sqlite3_prepare_v2(db, sql, -1, &query, nil) == SQLITE_OK, let query else {
throw StoreError.sqlite
}
return query
}
private static func isValid(_ snapshot: AIQuotaSnapshot) -> Bool {
!snapshot.windows.isEmpty && snapshot.windows.allSatisfy {
$0.usedPercent.isFinite && (0...100).contains($0.usedPercent)
}
}
}
@@ -5,12 +5,12 @@ import Foundation
import Security
import LocalAuthentication
public enum AIQuotaKind: String, Sendable, Identifiable {
public enum AIQuotaKind: String, Codable, Sendable, Identifiable {
case session, weekly, fableWeekly, opusWeekly, sonnetWeekly
public var id: Self { self }
}
public struct AIQuotaWindow: Equatable, Sendable, Identifiable {
public struct AIQuotaWindow: Codable, Equatable, Sendable, Identifiable {
public let kind: AIQuotaKind
public let usedPercent: Double
public let resetsAt: Date?
@@ -26,7 +26,7 @@ public struct AIQuotaWindow: Equatable, Sendable, Identifiable {
}
}
public struct AIQuotaSnapshot: Equatable, Sendable {
public struct AIQuotaSnapshot: Codable, Equatable, Sendable {
public let provider: AIProviderID
public let windows: [AIQuotaWindow]
public let fetchedAt: Date
@@ -43,7 +43,7 @@ public struct AIQuotaSnapshot: Equatable, Sendable {
public func window(_ kind: AIQuotaKind) -> AIQuotaWindow? { windows.first { $0.kind == kind } }
}
public enum AIQuotaSource: Equatable, Sendable {
public enum AIQuotaSource: String, Codable, Equatable, Sendable {
case direct, sub2api
}
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 استخدام رموز C
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 الحصة السابقة
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Lokale Codex / Claude Cod
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 Vorheriges Kontingent
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Tokens locales de Codex /
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 Cuota anterior
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Tokens locaux Codex / Cla
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 Quota précédent
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code の
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 前回の利用枠
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1253,6 +1253,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code 로
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 이전 한도
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
1 # Copyright (C) 2026 ysicing
1253 服务端用量 · 与本机 Token 统计不同
1254 重置:
1255 查询失败,显示上次额度
1256 上次额度
1257 暂无额度数据
1258 未检测到登录凭据,请先登录对应 CLI
1259 登录已失效,请在对应 CLI 重新登录
@@ -1254,6 +1254,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Codex / Claude Code 本
服务端用量 · 与本机 Token 统计不同 服務端用量 · 與本機 Token 統計不同
重置: 重置:
查询失败,显示上次额度 查詢失敗,顯示上次額度
上次额度 上次額度
暂无额度数据 暫無額度資料
未检测到登录凭据,请先登录对应 CLI 未偵測到登入憑證,請先登入對應的 CLI
登录已失效,请在对应 CLI 重新登录 登入已失效,請在對應的 CLI 重新登入
1 # Copyright (c) 2026 GiantAccel, LLC
1254 服务端用量 · 与本机 Token 统计不同
1255 重置:
1256 查询失败,显示上次额度
1257 上次额度
1258 暂无额度数据
1259 未检测到登录凭据,请先登录对应 CLI
1260 登录已失效,请在对应 CLI 重新登录
@@ -42,6 +42,7 @@ Codex / Claude Code 本机 Token 用量与订阅额度 Local Codex / Claude Code
服务端用量 · 与本机 Token 统计不同 Server usage · separate from local token statistics
重置: Resets:
查询失败,显示上次额度 Query failed; showing previous quota
上次额度 Previous quota
暂无额度数据 No quota data yet
未检测到登录凭据,请先登录对应 CLI No login credentials found; sign in with the corresponding CLI
登录已失效,请在对应 CLI 重新登录 Login expired; sign in again with the corresponding CLI
@@ -4,13 +4,14 @@
// See LICENSE, LICENSING.md and LICENSES/OpenStats-MIT.txt.
import AppKit
import AIUsage
import Localization
import Metrics
import SwiftUI
@MainActor
public final class AppController: NSObject, NSApplicationDelegate {
private let model = AppModel()
private let model = AppModel(quotaCacheURL: AIQuotaCacheStore.defaultURL)
private var menuBar: MenuBarController!
private var calendarMenuBar: CalendarMenuBarController!
private var mainWindow: MainWindowController!
@@ -7,6 +7,8 @@ struct MenuBarQuota {
let provider: AIProviderID
let window: AIQuotaWindow
let source: AIQuotaSource
let fetchedAt: Date
let isStale: Bool
var remainingPercent: Int { Int(window.remainingPercent.rounded()) }
var sourceName: String { provider == .codex ? "Codex" : "Claude" }
@@ -79,14 +81,16 @@ struct MenuBarReading {
aiQuotaProviders = model.settings.aiUsageEnabled
? AIProviderID.allCases.filter { model.settings.aiUsageSources.contains($0) } : []
aiQuotas = model.aiUsage.visibleQuotaProviders(for: nil).compactMap { provider in
guard let snapshot = model.aiUsage.visibleQuotaState(for: provider).snapshot else { return nil }
let state = model.aiUsage.visibleQuotaState(for: provider)
guard let snapshot = state.snapshot else { return nil }
let windows = snapshot.windows
// 周额度比短时会话额度更适合作为常驻读数;模型专属周额度仅在通用周额度缺席时使用。
let window = windows.first(where: { $0.kind == .weekly })
?? windows.first(where: { $0.kind == .fableWeekly })
?? windows.first(where: { $0.kind == .opusWeekly || $0.kind == .sonnetWeekly })
?? windows.first(where: { $0.kind == .session })
return window.map { MenuBarQuota(provider: provider, window: $0, source: snapshot.source) }
return window.map { MenuBarQuota(provider: provider, window: $0, source: snapshot.source,
fetchedAt: snapshot.fetchedAt, isStale: state.isStale) }
}
batteryCharging = store.battery?.isCharging ?? false
if let lowest = model.bluetooth.lowest,
@@ -156,7 +160,11 @@ struct MenuBarReading {
guard let quota = aiQuotas.first(where: { $0.provider == provider }) else {
return "\(provider == .codex ? "Codex" : "Claude") · \(tr("暂无额度数据"))"
}
return "\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)"
let previous = quota.isStale ? "\(tr("上次额度")) · " : ""
let checked = quota.isStale
? " · \(tr("上次检查:\(quota.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))"
: ""
return "\(previous)\(quota.sourceName)\(quota.source == .sub2api ? " (Sub2API)" : "") · \(quota.shortWindowName) · \(tr("剩余")) \(quota.remainingPercent)% · \(tr("重置:")) \(quota.resetText)\(checked)"
}.joined(separator: "\n")
} else {
aiTokens.map { "AI · \(UsageNumber.exact($0)) Tokens" } ?? ("AI · " + tr("暂无本机用量数据"))
@@ -173,14 +181,15 @@ enum MenuBarRenderer {
private static func tokenText(_ value: Int) -> String {
UsageNumber.short(value)
}
/// 菜单栏只有 22pt 高,字号刻意小于面板的字号体系,与 Stats 等菜单栏工具同一量级。
/// 每种风格内部只用这一套排版:两行布局是 7pt 标签 + 10pt 数值,单行布局 11pt,网速两行 9pt。
/// 菜单栏只有 22pt 高。额度读数单独使用更清晰的字号,其余指标保持紧凑排版。
private enum Metrics {
@MainActor static let labelFont = NSFont.systemFont(ofSize: 7, weight: .medium)
@MainActor static let stackedValueFont = NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .medium)
@MainActor static let inlineLabelFont = NSFont.systemFont(ofSize: 10, weight: .regular)
@MainActor static let inlineValueFont = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .medium)
@MainActor static let networkFont = NSFont.monospacedDigitSystemFont(ofSize: 9, weight: .regular)
@MainActor static let quotaLabelFont = NSFont.systemFont(ofSize: 9, weight: .semibold)
@MainActor static let quotaValueFont = NSFont.monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
static let barHeight: CGFloat = 22
static let upperBaseline: CGFloat = 13
static let lowerBaseline: CGFloat = 3
@@ -300,32 +309,34 @@ enum MenuBarRenderer {
let status = combine(providers.map { provider in
let name = provider == .codex ? "Codex" : "Claude"
guard let quota = reading.aiQuotas.first(where: { $0.provider == provider }) else {
return style == .inline
? inlineText(label: name, value: "—", sample: "100%", alert: false)
: stackedText(label: name, value: "—", sample: "100%", alert: false)
switch style {
case .ring, .history, .line, .pie, .meter, .dot:
return aiQuotaInlineText(label: name, value: "—")
case .inline, .icon:
return inlineText(label: name, value: "—", sample: "100%", alert: false)
case .stacked:
return aiQuotaText(label: name, value: "—")
}
}
let label = "\(name) · \(quota.shortWindowName)"
let value = "\(quota.remainingPercent)%"
let stacked = stackedText(label: label, value: value, sample: "100%", alert: false)
let stacked = aiQuotaText(label: name, value: value)
let inline = aiQuotaInlineText(label: name, value: value)
let fraction = min(1, max(0, Double(quota.remainingPercent) / 100))
let color = aiQuotaColor(quota.remainingPercent)
switch style {
case .stacked:
// 单来源保留重置时间,双来源保持短标签,避免菜单栏占用过宽。
return providers.count == 1
? stackedText(label: "\(quota.shortWindowName) · \(quota.resetText)",
value: "\(quota.sourceName) \(value)", sample: "Claude 100%", alert: false)
: stacked
case .stacked: return stacked
case .inline: return inlineText(label: label, value: value, sample: "100%", alert: false)
case .icon:
return providers.count == 1
? combine([symbolSegment(MenuBarItem.aiUsage.symbol),
inlineValue(value, sample: "100%", alert: false)])
: inlineText(label: name, value: value, sample: "100%", alert: false)
case .ring, .history, .line: return combine([ring(fraction: fraction, alert: false, color: color), stacked])
case .pie: return combine([pie(fraction: fraction, alert: false, color: color), stacked])
case .meter: return combine([meter(fraction: fraction, alert: false, color: color), stacked])
case .dot: return combine([quotaDot(color: color), stacked])
case .ring, .history, .line:
return combine([ring(fraction: fraction, alert: false, color: color, diameter: 15, lineWidth: 2.5), inline])
case .pie: return combine([pie(fraction: fraction, alert: false, color: color), inline])
case .meter: return combine([meter(fraction: fraction, alert: false, color: color), inline])
case .dot: return combine([quotaDot(color: color), inline])
}
}, gap: DS.Space.s2)
return style == .icon && providers.count > 1
@@ -343,6 +354,42 @@ enum MenuBarRenderer {
}
}
/// 额度是常驻读数:来源和百分比都用主文字色,时间窗及重置时间由悬停提示承载。
private static func aiQuotaText(label: String, value: String) -> Segment {
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: Metrics.quotaLabelFont, .foregroundColor: NSColor.labelColor,
]
let valueAttributes: [NSAttributedString.Key: Any] = [
.font: Metrics.quotaValueFont, .foregroundColor: NSColor.labelColor,
]
let width = ceil(max(textWidth(label, labelAttributes), textWidth(value, valueAttributes),
textWidth("100%", valueAttributes)))
return Segment(width: width) { rect in
let base = baselineOrigin(in: rect)
drawText(label, labelAttributes, rightEdge: rect.maxX, baseline: base + 12)
drawText(value, valueAttributes, rightEdge: rect.maxX, baseline: base + 1)
}
}
private static func aiQuotaInlineText(label: String, value: String) -> Segment {
let labelAttributes: [NSAttributedString.Key: Any] = [
.font: Metrics.inlineValueFont, .foregroundColor: NSColor.labelColor,
]
let valueAttributes: [NSAttributedString.Key: Any] = [
.font: Metrics.quotaValueFont, .foregroundColor: NSColor.labelColor,
]
let labelWidth = ceil(textWidth(label, labelAttributes))
let valueWidth = ceil(max(textWidth(value, valueAttributes), textWidth("100%", valueAttributes)))
return Segment(width: labelWidth + Metrics.innerGap + valueWidth) { rect in
drawText(label, labelAttributes, rightEdge: rect.minX + labelWidth,
baseline: rect.midY - Metrics.inlineValueFont.capHeight / 2)
// 保留 100% 的宽度以免数字变化时状态项跳动,但数值紧跟来源名。
drawText(value, valueAttributes,
rightEdge: rect.minX + labelWidth + Metrics.innerGap + textWidth(value, valueAttributes),
baseline: rect.midY - Metrics.quotaValueFont.capHeight / 2)
}
}
/// 电池:按电量画图形,电量低时着色为警示;充电中在前面加一道闪电。
/// 没有电池的 Mac 显示电量最低的蓝牙设备;开了提示且某个设备电量低时,附在电池后面
private static func batterySegment(reading: MenuBarReading, style: MenuBarStyle, colorizeHighLoad: Bool) -> Segment {
@@ -486,20 +533,22 @@ enum MenuBarRenderer {
// MARK: 图形
private static func ring(fraction: Double, alert: Bool, color: NSColor? = nil) -> Segment {
Segment(width: Metrics.gaugeDiameter, colored: color != nil) { rect in
let inset = Metrics.ringWidth / 2
let circle = NSRect(x: rect.minX + inset, y: rect.midY - Metrics.gaugeDiameter / 2 + inset,
width: Metrics.gaugeDiameter - Metrics.ringWidth, height: Metrics.gaugeDiameter - Metrics.ringWidth)
private static func ring(fraction: Double, alert: Bool, color: NSColor? = nil,
diameter: CGFloat = Metrics.gaugeDiameter,
lineWidth: CGFloat = Metrics.ringWidth) -> Segment {
Segment(width: diameter, colored: color != nil) { rect in
let inset = lineWidth / 2
let circle = NSRect(x: rect.minX + inset, y: rect.midY - diameter / 2 + inset,
width: diameter - lineWidth, height: diameter - lineWidth)
let track = NSBezierPath(ovalIn: circle)
track.lineWidth = Metrics.ringWidth
track.lineWidth = lineWidth
NSColor.labelColor.withAlphaComponent(Metrics.trackAlpha).setStroke()
track.stroke()
guard fraction > 0 else { return }
let arc = NSBezierPath()
arc.appendArc(withCenter: NSPoint(x: circle.midX, y: circle.midY), radius: circle.width / 2,
startAngle: 90, endAngle: 90 - 360 * fraction, clockwise: true)
arc.lineWidth = Metrics.ringWidth
arc.lineWidth = lineWidth
arc.lineCapStyle = .round
(color ?? foreground(alert)).setStroke()
arc.stroke()
@@ -151,13 +151,15 @@ private struct LocalUsageContent: View {
if let reset = window.resetsAt {
Text(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))
.dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
TimelineView(.periodic(from: .now, by: 60)) { context in
if let fraction = window.remainingTimeFraction(at: context.date) {
// 时间条越接近重置越满、颜色越偏绿;额度条则随剩余量减少而变红。
ProgressTrack(fraction: 1 - fraction,
color: progressTint(for: (1 - fraction) * 100))
.accessibilityLabel(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))
.accessibilityValue("\(Int(((1 - fraction) * 100).rounded()))%")
if !state.isStale {
TimelineView(.periodic(from: .now, by: 60)) { context in
if let fraction = window.remainingTimeFraction(at: context.date) {
// 旧快照不再推算重置进度;只对新鲜额度画动态时间条。
ProgressTrack(fraction: 1 - fraction,
color: progressTint(for: (1 - fraction) * 100))
.accessibilityLabel(tr("重置:") + " " + reset.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))
.accessibilityValue("\(Int(((1 - fraction) * 100).rounded()))%")
}
}
}
}
@@ -174,9 +176,18 @@ private struct LocalUsageContent: View {
.dsFont(.xs)
.foregroundStyle(DS.Palette.textTertiary)
}
if state.failure != nil {
Text(tr("查询失败,显示上次额度"))
.dsFont(.xs).foregroundStyle(DS.Palette.warning)
if state.isStale {
VStack(alignment: .leading, spacing: DS.Space.s1) {
Label(tr("上次额度"), systemImage: "clock")
.foregroundStyle(DS.Palette.warning)
Text(tr("上次检查:\(snapshot.fetchedAt.formatted(Date.FormatStyle(date: .abbreviated, time: .shortened, locale: L10n.locale)))"))
.foregroundStyle(DS.Palette.textTertiary)
if state.failure != nil {
Text(quotaFailureText(state.failure))
.foregroundStyle(DS.Palette.warning)
}
}
.dsFont(.xs)
}
} else if state.isRefreshing {
ProgressView().controlSize(.small)
@@ -23,7 +23,7 @@ You can turn off automatic checks in Settings → About. You decide whether to d
## 3. Other network features
- When AI Usage & Quotas is enabled, XStats uses local Codex / Claude Code login credentials to contact the quota endpoints on `chatgpt.com` and `api.anthropic.com` directly. Those providers can observe the login token and connection metadata. Quota results are held only in app memory and are not uploaded to XStats servers.
- When AI Usage & Quotas is enabled, XStats uses local Codex / Claude Code login credentials to contact the quota endpoints on `chatgpt.com` and `api.anthropic.com` directly. Those providers can observe the login token and connection metadata. The last successful quota snapshot for each source is stored in this Mac's `ai-usage.sqlite` database with percentages, reset times, source, and fetch time, but no login token. Failed queries may show that snapshot marked as old data. Authentication failure or removal of a manual configuration deletes that source's snapshot. Disabling the feature stops queries and hides quotas but retains the local snapshot for re-enabling. Quota results are not uploaded to XStats servers.
- With a Codex or Claude Code Sub2API backup configured, a failed automatic lookup for that source causes a direct request to its chosen Sub2API server. That server receives the admin email, password, login token, and account ID being queried. XStats provides no preset Sub2API server and does not upload these values or quota results to XStats servers.
- When you open network details with public-IP lookup enabled, XStats contacts Cloudflare `1.1.1.1`, falling back to ipify. It sends your public IP address to `cleanip.io` for location, ASN, network type, and cleanliness information. Results are cached locally.
- Connection probing sends ICMP packets to your selected target when enabled. Speed tests, egress checks, and DNS lookups contact selected test nodes, websites, or DNS services only when you use those features. Those services can observe connection metadata and the data needed for the test.
@@ -23,7 +23,7 @@ CPU、GPU、内存、网络、温度、风扇、进程等监控数据来自本
## 3. 其他联网功能
- 启用“AI 用量与额度”后,应用使用本机 Codex / Claude Code 的登录凭据,分别直接请求 `chatgpt.com` 和 `api.anthropic.com` 的额度接口;对方可以看到登录令牌及连接元数据。额度结果仅在应用运行期间保留在内存中,不上传到 XStats 服务器。
- 启用“AI 用量与额度”后,应用使用本机 Codex / Claude Code 的登录凭据,分别直接请求 `chatgpt.com` 和 `api.anthropic.com` 的额度接口;对方可以看到登录令牌及连接元数据。每个来源最近一次成功的额度快照保存在本机 `ai-usage.sqlite`,包含百分比、重置时间、数据来源及获取时间,不含登录令牌;查询失败时可显示并标记为旧数据。认证失效或移除手动配置会删除对应快照。关闭功能后停止查询并隐藏额度,本地快照仍保留以便重新启用;额度结果不上传到 XStats 服务器。
- 配置 Codex 或 Claude Code 的 Sub2API 备选来源后,对应的自动额度查询失败时,应用直接请求你为该来源指定的 Sub2API 服务器;该服务器会收到管理员邮箱、密码、登录令牌和查询账号 ID。XStats 不预设 Sub2API 服务器,也不向 XStats 服务器上传这些信息或额度结果。
- 打开网络详情且启用公网 IP 查询时,应用直接访问 Cloudflare `1.1.1.1`,失败时回退到 ipify;归属地、ASN、网络类型和纯净度查询会把公网 IP 发送给 `cleanip.io`。结果在本机缓存。
- 启用连接探测时,应用向所选目标发送 ICMP 探测。测速、出口检测和 DNS 查询仅在你使用相应功能时,直接连接所选测试节点、网站或 DNS 服务;对方可看到连接元数据及测试所需内容。
@@ -561,7 +561,8 @@ private struct MenuBarPreview: View {
let image = MenuBarRenderer.image(for: model)
VStack(alignment: .leading, spacing: DS.Space.s2) {
Text(tr("当前效果(实时数据)")).dsFont(.xs, weight: .medium).foregroundStyle(DS.Palette.textSecondary)
HStack(spacing: DS.Space.s3) {
// 实时图标宽度会随启用的项目变化;并排时两张预览可能撑出设置页。
VStack(spacing: DS.Space.s2) {
preview(image: image, dark: false)
preview(image: image, dark: true)
}
@@ -569,10 +570,17 @@ private struct MenuBarPreview: View {
}
private func preview(image: NSImage, dark: Bool) -> some View {
HStack {
Spacer()
Image(nsImage: MenuBarRenderer.preview(image, dark: dark))
Spacer()
GeometryReader { geometry in
ScrollView(.horizontal) {
HStack {
Spacer(minLength: DS.Space.s2)
Image(nsImage: MenuBarRenderer.preview(image, dark: dark))
Spacer(minLength: DS.Space.s2)
}
.frame(minWidth: geometry.size.width)
.frame(height: DS.Size.controlHeight + DS.Space.s2)
}
.scrollIndicators(.hidden)
}
.frame(height: DS.Size.controlHeight + DS.Space.s2)
.background(dark ? Color(nsColor: NSColor(hex: 0x1F2937)) : Color(nsColor: NSColor(hex: 0xE5E7EB)),
@@ -148,6 +148,7 @@ struct Sub2APISettingsView: View {
private func clear() {
do {
try Sub2APISettingsStore(provider: provider).clear()
model.aiUsage.clearQuotaSnapshot(for: provider)
draft.address = ""; draft.email = ""; draft.password = ""; draft.accountID = ""
draft.configured = false
show(tr("Sub2API 配置已移除"), error: false)
@@ -18,6 +18,8 @@ public struct AIQuotaProviderState: Equatable, Sendable {
public let provider: AIProviderID
public var snapshot: AIQuotaSnapshot?
public var failure: AIQuotaFailure?
/// 快照来自磁盘,或本次查询失败后保留的上次成功值。
public var isStale = false
public var isRefreshing = false
public init(provider: AIProviderID) { self.provider = provider }
@@ -35,6 +37,7 @@ public final class AIUsageController {
@ObservationIgnored private let settings: AppSettings
@ObservationIgnored private let providers: [any AIUsageProvider]
@ObservationIgnored private let quotaProviders: [any AIQuotaProvider]
@ObservationIgnored private let quotaCache: AIQuotaCacheStore?
@ObservationIgnored private let now: @Sendable () -> Date
@ObservationIgnored private var cadenceTask: Task<Void, Never>?
@ObservationIgnored private var refreshTask: Task<Void, Never>?
@@ -43,13 +46,27 @@ public final class AIUsageController {
public init(settings: AppSettings, providers: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()],
quotaProviders: [any AIQuotaProvider] = [],
quotaCacheURL: URL? = nil,
now: @escaping @Sendable () -> Date = Date.init) {
self.settings = settings
self.providers = providers
self.quotaProviders = quotaProviders
quotaCache = quotaCacheURL.flatMap { try? AIQuotaCacheStore(url: $0) }
self.now = now
states = Dictionary(uniqueKeysWithValues: providers.map { ($0.id, AIUsageProviderState(provider: $0.id)) })
quotaStates = Dictionary(uniqueKeysWithValues: quotaProviders.map { ($0.id, AIQuotaProviderState(provider: $0.id)) })
restoreCachedQuotas()
}
private func restoreCachedQuotas() {
guard let cached = try? quotaCache?.load() else { return }
for provider in quotaProviders where settings.aiUsageSources.contains(provider.id) {
guard var state = quotaStates[provider.id], state.snapshot == nil,
let snapshot = cached[provider.id] else { continue }
state.snapshot = snapshot
state.isStale = true
quotaStates[provider.id] = state
}
}
public func state(for provider: AIProviderID) -> AIUsageProviderState {
@@ -60,6 +77,12 @@ public final class AIUsageController {
quotaStates[provider] ?? AIQuotaProviderState(provider: provider)
}
/// 移除手动账号时同步丢弃该来源的旧额度,避免离线时继续显示已移除账号的数据。
func clearQuotaSnapshot(for provider: AIProviderID) {
quotaStates[provider] = AIQuotaProviderState(provider: provider)
try? quotaCache?.clear(provider)
}
public func visibleQuotaState(for provider: AIProviderID) -> AIQuotaProviderState {
guard settings.aiUsageEnabled, settings.aiUsageSources.contains(provider) else {
return AIQuotaProviderState(provider: provider)
@@ -117,6 +140,7 @@ public final class AIUsageController {
}
return
}
restoreCachedQuotas()
let seconds = settings.aiUsageRefreshMinutes * 60
cadenceTask = Task { [weak self] in
guard !Task.isCancelled else { return }
@@ -211,8 +235,16 @@ public final class AIUsageController {
case .success(let snapshot):
state.snapshot = snapshot
state.failure = nil
state.isStale = false
try? quotaCache?.save(snapshot)
case .failure(let failure):
if !failure.preservesLastGood { state.snapshot = nil }
if failure.preservesLastGood {
state.isStale = state.snapshot != nil
} else {
state.snapshot = nil
state.isStale = false
try? quotaCache?.clear(id)
}
state.failure = failure
}
state.isRefreshing = false
@@ -69,11 +69,13 @@ public final class AppModel {
public init(settings: AppSettings = AppSettings(), historyURL: URL? = HistoryDatabase.defaultURL,
aiUsageProviders: [any AIUsageProvider] = [CodexLocalUsageProvider(), ClaudeLocalUsageProvider()],
aiQuotaProviders: [any AIQuotaProvider] = [CodexQuotaProvider(), ClaudeQuotaProvider()]) {
aiQuotaProviders: [any AIQuotaProvider] = [CodexQuotaProvider(), ClaudeQuotaProvider()],
quotaCacheURL: URL? = nil) {
let store = MetricsStore()
let helper = HelperClient()
self.settings = settings
aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders)
aiUsage = AIUsageController(settings: settings, providers: aiUsageProviders, quotaProviders: aiQuotaProviders,
quotaCacheURL: quotaCacheURL)
self.store = store
self.helper = helper
fans = FanController(helper: helper, store: store, settings: settings)
@@ -24,6 +24,33 @@ private actor StubQuotaHTTPClient: QuotaHTTPClient {
}
@Suite struct QuotaProviderTests {
@Test func quotaCacheKeepsOnlyTheLatestSnapshotPerProvider() throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("xstats-quota-store-\(UUID().uuidString).sqlite")
defer {
for suffix in ["", "-wal", "-shm"] {
try? FileManager.default.removeItem(atPath: url.path + suffix)
}
}
let store = try AIQuotaCacheStore(url: url)
let date = Date(timeIntervalSince1970: 1_800_000_000)
let codex = AIQuotaSnapshot(provider: .codex,
windows: [AIQuotaWindow(kind: .weekly, usedPercent: 25, resetsAt: date)], fetchedAt: date)
let updatedCodex = AIQuotaSnapshot(provider: .codex,
windows: [AIQuotaWindow(kind: .weekly, usedPercent: 40, resetsAt: date)], fetchedAt: date)
let claude = AIQuotaSnapshot(provider: .claude,
windows: [AIQuotaWindow(kind: .fableWeekly, usedPercent: 70, resetsAt: nil)],
fetchedAt: date, source: .sub2api)
try store.save(codex)
try store.save(claude)
try store.save(updatedCodex)
let reopened = try AIQuotaCacheStore(url: url)
#expect(try reopened.load() == [.codex: updatedCodex, .claude: claude])
try reopened.clear(.codex)
#expect(try reopened.load() == [.claude: claude])
}
@Test func quotaPresentationUsesRemainingAmountAndTimeUntilReset() throws {
let now = Date(timeIntervalSince1970: 1_000_000)
let weekly = AIQuotaWindow(kind: .weekly, usedPercent: 74,
@@ -7,6 +7,7 @@ import Testing
let table = Translations.shared
#expect(table.translate("内存") == "Memory")
#expect(table.translate("AI 用量与额度") == "AI Usage & Quotas")
#expect(table.translate("上次额度") == "Previous quota")
#expect(table.translate("12 个进程") == "12 processes")
#expect(table.translate("1 个进程") == "1 process")
#expect(table.translate("不存在的文案") == nil)
@@ -211,6 +211,49 @@ private actor GatedUsageProvider: AIUsageProvider {
#expect(controller.visibleQuotaProviders(for: nil) == [.codex])
}
@Test func lastSuccessfulQuotaSurvivesRestartAndAuthFailureClearsDiskCache() async {
let cacheURL = FileManager.default.temporaryDirectory
.appendingPathComponent("xstats-quota-\(UUID().uuidString).sqlite")
defer {
for suffix in ["", "-wal", "-shm"] {
try? FileManager.default.removeItem(atPath: cacheURL.path + suffix)
}
}
let settings = AppSettings(defaults: defaultsForAIUsage())
settings.aiUsageEnabled = true
let previous = AIQuotaSnapshot(provider: .codex, windows: [
AIQuotaWindow(kind: .weekly, usedPercent: 76, resetsAt: Date(timeIntervalSince1970: 1_800_000_000)),
], fetchedAt: Date(timeIntervalSince1970: 1_799_000_000), source: .sub2api)
let first = AIUsageController(settings: settings, providers: [],
quotaProviders: [StubQuotaProvider(results: [.success(previous)])], quotaCacheURL: cacheURL)
await first.refresh()
let restored = AIUsageController(settings: settings, providers: [],
quotaProviders: [StubQuotaProvider(results: [.failure(.network)])], quotaCacheURL: cacheURL)
#expect(restored.visibleQuotaState(for: .codex).snapshot == previous)
#expect(restored.visibleQuotaState(for: .codex).isStale)
await restored.refresh()
#expect(restored.visibleQuotaState(for: .codex).snapshot == previous)
#expect(restored.visibleQuotaState(for: .codex).failure == .network)
let newer = AIQuotaSnapshot(provider: .codex, windows: [
AIQuotaWindow(kind: .weekly, usedPercent: 81, resetsAt: Date(timeIntervalSince1970: 1_800_000_000)),
], fetchedAt: Date(timeIntervalSince1970: 1_799_100_000))
let refreshed = AIUsageController(settings: settings, providers: [],
quotaProviders: [StubQuotaProvider(results: [.success(newer)])], quotaCacheURL: cacheURL)
await refreshed.refresh()
#expect(refreshed.visibleQuotaState(for: .codex).snapshot == newer)
#expect(!refreshed.visibleQuotaState(for: .codex).isStale)
let logout = AIUsageController(settings: settings, providers: [],
quotaProviders: [StubQuotaProvider(results: [.failure(.unauthorized)])], quotaCacheURL: cacheURL)
await logout.refresh()
#expect(logout.visibleQuotaState(for: .codex).snapshot == nil)
let afterLogout = AIUsageController(settings: settings, providers: [],
quotaProviders: [StubQuotaProvider(results: [.failure(.network)])], quotaCacheURL: cacheURL)
#expect(afterLogout.visibleQuotaState(for: .codex).snapshot == nil)
}
@Test func disabledSourceIsNotQueriedForQuota() async {
let settings = AppSettings(defaults: defaultsForAIUsage())
settings.aiUsageEnabled = true
+1
View File
@@ -227,6 +227,7 @@ Every feature that touches the network can be turned off. Public IP and connecti
automatic requests. When a new version is out it asks; nothing installs without your click.
**AI Usage & Quotas** is off by default. When enabled, local token statistics read only Codex / Claude Code session logs; quota checks read CLI login credentials and contact `chatgpt.com` / `api.anthropic.com` directly for five-hour and weekly usage. Tokens go only to the corresponding provider and are not saved in XStats preferences or its statistics database. Session logs are not sent with quota requests; quota failures do not affect local statistics.
The last successful quota for each source stays in a local SQLite database. XStats shows it with its fetch time after a restart, then replaces it when a scheduled or manual refresh succeeds. Authentication failure or removal of a manual configuration clears that source’s cache.
If local Codex or Claude Code sign-in cannot provide quota data, you can configure a separate Sub2API HTTPS address, admin email and password, and account ID for each source in AI Usage & Quotas settings. Each fallback is used only when that source's automatic lookup fails, and XStats checks the account platform before displaying its quota. Background requests use `force=false` to read cached data without triggering an active probe. Five-hour and seven-day windows are supported, along with Sonnet and Fable weekly windows when a Claude account provides them. The two admin passwords stay separately in this Mac's Keychain; connection settings stay local and are excluded from WebDAV backups. Sign-in sends the admin email and password to the configured Sub2API server; quota data is never uploaded to XStats servers.
When Sub2API provides no Fable value, the page says that quota data is unavailable instead of treating the missing value as 0% used.
+1
View File
@@ -170,6 +170,7 @@ XStats の終了時やクラッシュ時には自動制御へ戻り、蓋を閉
指標はローカルのカーネル、IOKit、SMC から取得し、設定はアプリの UserDefaults に保存します。監視指標、履歴、ハードウェアのシリアル番号、プロセス一覧はアップロードしません。
AI の使用量と利用枠は初期状態で無効です。有効にすると、ローカル Codex / Claude Code ログからモデル別 Token、キャッシュヒット率、日別推移を集計し、CLI のログイン情報を読み取り専用で確認して各サービスへ直接アクセスし、5 時間・週間の利用枠も取得します。既定は過去1年の活動ヒートマップ。日別・週別・累計の3表示はいずれも過去1年を表示します。SQLite に読取位置と統計を保存し、再起動後も差分を読み取ります。ログイン情報は XStats の設定や統計 DB に保存せず、セッションログは利用枠の問い合わせに送信しません。
各ソースで最後に取得できた利用枠はローカルの SQLite に保存します。再起動後は取得時刻を添えて以前の値を表示し、定期更新または手動更新が成功すると置き換えます。認証失効や手動設定の削除時には、そのソースのキャッシュを削除します。
両方のソースが有効で、いずれかに利用枠データがある場合、メニューバーの AI 項目に Codex と Claude を別々に表示します。データがないソースはダッシュで示し、リセット時刻はツールチップで確認できます。
ローカルの Codex または Claude Code ログインから利用枠を取得できない場合は、AI の使用量と利用枠の設定で各ソースに別々の Sub2API HTTPS アドレス、管理者メールアドレスとパスワード、アカウント ID を登録できます。それぞれの自動取得に失敗した場合だけ対応する設定を使用し、アカウントのプラットフォームも確認します。バックグラウンド照会は `force=false` で能動的な検査を行いません。管理者パスワードはソースごとにこの Mac のキーチェーンに保存し、接続設定は WebDAV バックアップに含めません。ログイン時には設定した Sub2API サーバーへ管理者情報を送信します。
Sub2API が Fable の数値を返さない場合、画面には利用枠データがないことを表示し、未取得の値を使用率 0% として扱いません。
+1
View File
@@ -170,6 +170,7 @@ XStats를 종료하거나 앱이 비정상 종료되면 팬이 자동 모드로
지표는 로컬 커널, IOKit, SMC에서 읽으며 설정은 앱의 UserDefaults에 저장합니다. 모니터링 지표, 기록, 하드웨어 일련번호와 프로세스 목록은 업로드하지 않습니다.
AI 사용량 및 한도는 기본적으로 꺼져 있습니다. 켜면 로컬 Codex / Claude Code 로그로 모델별 토큰, 캐시 적중률, 일별 추이를 집계하고, CLI 로그인 정보를 읽기 전용으로 확인하여 각 서비스에서 5시간 및 주간 사용 한도를 직접 조회합니다. 기본값은 최근 1년 활동 히트맵입니다. SQLite에 읽기 위치와 통계를 저장하여 재시작 후에도 증분을 읽습니다. 일별·주별·누적 보기 모두 최근 1년을 표시합니다. 로그인 정보는 XStats 설정이나 통계 DB에 저장하지 않으며 세션 로그를 한도 조회 요청에 보내지 않습니다.
각 소스에서 마지막으로 성공한 한도 정보는 로컬 SQLite에 저장합니다. 재시작 후에는 조회 시각과 함께 이전 값을 표시하고, 정기 또는 수동 새로고침이 성공하면 갱신합니다. 인증이 만료되거나 수동 설정을 삭제하면 해당 소스의 캐시도 삭제합니다.
두 소스가 모두 켜져 있고 하나라도 할당량 데이터가 있으면 메뉴 막대의 AI 항목에 Codex와 Claude를 각각 표시합니다. 데이터가 없는 소스는 대시로 표시하고 재설정 시각은 툴팁에 표시합니다.
로컬 Codex 또는 Claude Code 로그인으로 할당량을 가져올 수 없다면 AI 사용량 및 한도 설정에서 각 소스에 별도의 Sub2API HTTPS 주소, 관리자 이메일과 비밀번호, 계정 ID를 등록할 수 있습니다. 각 소스의 자동 조회가 실패할 때만 해당 예비 설정을 사용하고 계정 플랫폼도 확인합니다. 백그라운드 요청은 `force=false`로 캐시된 할당량을 읽어 능동 검사를 실행하지 않습니다. 관리자 비밀번호는 소스별로 이 Mac의 키체인에 저장하고 연결 설정은 WebDAV 백업에 포함하지 않습니다. 로그인할 때는 지정한 Sub2API 서버로 관리자 정보를 전송합니다.
Sub2API가 Fable 수치를 반환하지 않으면 페이지에 할당량 데이터가 없다고 표시하며, 누락된 값을 사용량 0%로 취급하지 않습니다.
+1
View File
@@ -229,6 +229,7 @@ XStats 暂无官网。自动更新与现有 GeoIP 服务保持不变;账号登
- **检查更新与安装统计**:启动时与之后每天发送当前版本和随机安装标识的 SHA-256,并读取版本清单。中国地区优先使用 `x-stats.china.12306.work`,其他地区优先使用 `xstats-apps.12306.work`,首选失败才串行回退到另一个,首次成功后停止。服务端只保存该哈希、当前版本、首次与最近检查时间及检查次数,不保存硬件序列号,也不持久化请求 IP(IP 只用于一分钟内的内存限流)。原始随机值只在本机偏好设置中,不访问钥匙串。关闭自动检查更新后不会再自动请求;发现新版本时仍由你决定是否安装。
**AI 用量与额度**默认关闭。启用后,本机 Token 统计只读取 Codex / Claude Code 会话日志;额度部分会只读检查 CLI 登录凭据,直接向 `chatgpt.com` / `api.anthropic.com` 查询 5 小时及每周用量。登录令牌只发送到对应服务,不保存到 XStats 设置或统计数据库,日志内容不随请求发送。额度接口可能变化或限流,失败时不影响本机统计。
每个来源最后一次成功的额度会保存在本机 SQLite 数据库中;重启后先显示这份数据并标明获取时间,定时刷新或点击刷新成功后再替换。认证失效或移除手动配置时会清除对应缓存。
若无法通过本机 Codex 或 Claude Code 登录获取额度,可在“AI 用量与额度”设置中分别为这两个来源配置自己的 Sub2API HTTPS 地址、管理员邮箱与密码、账号 ID。每个来源仅在自身自动查询失败时使用对应的备选配置,并核对账号平台;后台请求使用 `force=false` 读取额度,不触发主动探测。可显示 5 小时、7 天,以及 Claude 账号提供时的 Sonnet / Fable 周窗口。两份管理员密码分别保存在本机钥匙串,连接信息只留在本机设置中,不进入 WebDAV 备份。登录请求会把管理员邮箱和密码发送到你配置的 Sub2API 服务器,额度数据不会上传到 XStats 服务器。
Sub2API 未提供 Fable 数值时,页面显示“暂无额度数据”,不会把缺失值当成 0% 已用。