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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions Sources/PokeTokenBar/Core/AppEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,10 @@ enum AppEnv {
isBundledApp || isParityRun || allowLiveFetchForTesting
}

/// 사용자 상태 파일(Application Support 등)을 실제로 읽고 쓸 것인가.
///
/// 경로를 주입받았으면 항상 참 — 테스트가 격리된 임시 파일로 지속성을 검증하는 통로다.
/// 주입이 없어 *기본(사용자) 경로*로 떨어졌다면 실앱에서만 참이다. `swift test` 는 번들이
/// 아니므로 거짓이 되고, 그래서 스위트가 사용자의 실제 파일을 읽거나 덮어쓰지 않는다.
///
/// 이 판정을 스토어마다 복사하지 않는다 — 한 곳이 고쳐지고 다른 곳이 남는 게 이 부류의
/// 전형적인 재발 경로다(defect-log "Application Support 기본 경로" 항목).
/// Whether a store may read and write its file. Always true for an injected path, which is how
/// tests check persistence against a temporary file. A store that fell back to its default
/// (user) path persists only in the app bundle, so `swift test` never reads or rewrites the
/// user's real files there. Keep this rule here instead of copying it into each store.
static func persistsToUserLocation(injectedFileURL: URL?,
isBundledApp: Bool = AppEnv.isBundledApp) -> Bool {
injectedFileURL != nil || isBundledApp
Expand Down
10 changes: 6 additions & 4 deletions Sources/PokeTokenBar/Core/AppLog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,11 @@ enum AppLog {
backend.writeAndFlush(formatted(message))
}

/// 상태가 그대로면 억제하고, 바뀌면 즉시 기록하는 `write` — 폴마다 반복되는 상태 줄 전용.
/// 판정은 `LogRepeatSuppressor`(순수)에 있고 여기서는 잠금만 책임진다.
/// 값이 매 틱 변하는 줄(토큰 수·합계)에는 쓰지 말 것 — 억제가 걸리지 않아 이득 없이 잠금만 든다.
/// `write` for status lines repeated on every poll: an unchanged state is suppressed (reaffirmed
/// once per `repeatAfter`), a changed one is written at once. The decision lives in
/// `LogRepeatSuppressor`; this only adds the lock. Do not use it for lines whose values change
/// every tick (token counts, totals) or for events: nothing would be suppressed, or an event
/// would be lost.
static func writeIfChanged(_ key: String, _ message: String, repeatAfter: TimeInterval = 3600) {
guard AppEnv.isBundledApp else { return }
suppressorLock.lock()
Expand All @@ -62,7 +64,7 @@ enum AppLog {
backend.write(formatted(message))
}

/// 딕셔너리 변이라 Bool 플래그와 달리 실제 경합이 위험하다 — 잠금으로 감싼다.
/// A dictionary mutated from several threads (unlike a Bool flag), so it needs the lock.
nonisolated(unsafe) private static var suppressor = LogRepeatSuppressor()
private static let suppressorLock = NSLock()

Expand Down
4 changes: 2 additions & 2 deletions Sources/PokeTokenBar/Core/CursorRateLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public struct CursorRateLimitsProvider: CursorLimitsProviding, Sendable {
public func fetch() async throws -> CursorRateLimitStatus? {
guard UsageEnvironment.value("CURSOR_USAGE_API") != "0" else { return nil }
guard var token = CursorUsageAPI.sessionToken() else {
AppLog.write("cursor limits: no session token")
AppLog.writeIfChanged("cursor-limits-session", "cursor limits: no session token")
return nil
}

Expand Down Expand Up @@ -71,7 +71,7 @@ public struct CursorRateLimitsProvider: CursorLimitsProviding, Sendable {
private static func refreshAccessToken() async throws -> String? {
guard let refreshToken = LocalAdditionalUsageReader.cursorAuthValue("cursorAuth/refreshToken")?
.trimmingCharacters(in: .whitespacesAndNewlines), !refreshToken.isEmpty else {
AppLog.write("cursor limits: no refresh token")
AppLog.writeIfChanged("cursor-limits-refresh", "cursor limits: no refresh token")
return nil
}
var request = URLRequest(url: tokenURL, timeoutInterval: 10)
Expand Down
2 changes: 1 addition & 1 deletion Sources/PokeTokenBar/Core/CursorUsageAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ enum CursorUsageAPI {
return UsageResult(entries: [], isAuthoritative: false)
}
guard let token = sessionToken() else {
AppLog.write("cursor api: no session token — \(LocalAdditionalUsageReader.cursorAuthDiagnostics())")
AppLog.writeIfChanged("cursor-api-session", "cursor api: no session token — \(LocalAdditionalUsageReader.cursorAuthDiagnostics())")
return UsageResult(entries: [], isAuthoritative: false)
}
AppLog.write("cursor api: session token ready (\(token.count) chars)")
Expand Down
21 changes: 10 additions & 11 deletions Sources/PokeTokenBar/Core/LocalUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@ actor LocalUsageCache {
private let piRoots: [URL]?
private let ompRoots: [URL]?
private let fileURL: URL
/// 기본(사용자) 경로에 실제로 읽고 쓸 것인가 — `AppEnv.persistsToUserLocation`.
/// private 이 아닌 이유는 테스트가 IO 없이 게이트 자체를 확인할 수 있게 하려는 것.
/// Whether this cache reads and writes `fileURL` (`AppEnv.persistsToUserLocation`). Not private so
/// tests can check the gate without doing any IO.
nonisolated let persistsToDisk: Bool
private let now: @Sendable () -> Date
/// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다.
Expand Down Expand Up @@ -156,12 +156,11 @@ actor LocalUsageCache {
self.piRoots = piRoots
self.ompRoots = ompRoots
self.fileURL = fileURL ?? Self.defaultFileURL
// `LocalUsageProvider` uses `.shared`, so without this a single `refresh()` during `swift test`
// reads and rewrites the user's real usage-cache.json.
self.persistsToDisk = AppEnv.persistsToUserLocation(injectedFileURL: fileURL)
self.now = now
self.codexProbe = codexProbe
// 기본 경로로 떨어졌고 실앱이 아니면 디스크를 아예 만지지 않는다. `LocalUsageProvider` 가
// `.shared` 를 쓰므로, 이 게이트가 없으면 테스트 중 일어난 단 한 번의 `refresh()` 가
// 사용자의 실제 `usage-cache.json` 을 읽고 덮어쓴다(실측: 전체 스위트 1회 = 1회 쓰기).
self.persistsToDisk = AppEnv.persistsToUserLocation(injectedFileURL: fileURL)
}

private static let defaultFileURL: URL = {
Expand Down Expand Up @@ -421,19 +420,19 @@ actor LocalUsageCache {

// MARK: 영속화

/// 로드된 blob 총수 — 읽기 게이트의 관측점. 기본(사용자) 경로 캐시가 실제 파일을 읽어버리면
/// 픽스처 한 개만 있어야 할 자리에 사용자의 수백 개 blob 이 섞여 이 수가 튄다.
/// Number of loaded blobs, used to observe the read gate: if a default-path cache read the user's
/// real file, hundreds of their blobs would show up next to a single fixture.
var cachedBlobCount: Int {
ensureLoaded()
return claudeCache.count + codexCache.count + geminiCache.count
+ grokCache.count + piCache.count
+ grokCache.count + piCache.count + ompCache.count
}

private func ensureLoaded() {
guard !loaded else { return }
loaded = true
// 읽기도 막는다 — 쓰기만 막으면 테스트가 사용자의 실제 캐시(수십만 엔트리)를 읽어
// 픽스처와 섞인 결과로 단언하게 된다. 격리는 양방향이라야 성립한다.
// Reads are gated too: a test that read the user's real cache would assert on fixtures mixed
// with live data.
guard persistsToDisk, let raw = try? Data(contentsOf: fileURL) else { return }
// zlib 압축 스냅샷(현행) → 실패 시 평문 JSON(구버전 캐시) 폴백
let data = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw
Expand Down
2 changes: 1 addition & 1 deletion Sources/PokeTokenBar/Core/LocalUsageReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ enum LocalUsageReader {
// 깊이로 잘렸으면 무엇을 찾았든 남긴다. `out.isEmpty` 를 조건에 넣으면 **부분 절단**
// (세션 루트는 찾고 더 깊은 작업 디렉터리만 놓친 경우)이 조용히 지나가는데, 그게 가장 흔한 형태다.
if prunedByDepth {
AppLog.write("claude desktop scan: depth \(maxDepth) reached under \(base.lastPathComponent), found \(out.count) root(s) — deeper roots may be missed")
AppLog.writeIfChanged("claude-desktop-scan-\(base.lastPathComponent)", "claude desktop scan: depth \(maxDepth) reached under \(base.lastPathComponent), found \(out.count) root(s) — deeper roots may be missed")
}
return out
}
Expand Down
22 changes: 22 additions & 0 deletions Sources/PokeTokenBar/Core/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,28 @@ struct L {
var todayTokensShort: String { t("오늘 토큰", "Today's tokens", "本日のトークン", "Tokens de hoy", "Tokens du jour", "Tokens de hoje", "Heutige Tokens") }
var todayCost: String { t("오늘 비용 ($)", "Today's cost ($)", "本日のコスト ($)", "Coste de hoy ($)", "Coût du jour ($)", "Custo de hoje ($)", "Heutige Kosten ($)") }
var limitPercent: String { t("한도 %", "Limit %", "上限 %", "Límite %", "Limite %", "Limite %", "Limit %") }
var menuLimitColor: String {
t("퍼센트 색상", "Percent color", "パーセントの色", "Color del porcentaje", "Couleur du pourcentage",
"Cor da porcentagem", "Prozentfarbe")
}
var menuLimitColorHint: String {
t("팝오버 한도 게이지와 같은 색", "Same colors as the popover limit gauges",
"ポップオーバーの上限ゲージと同じ色", "Los mismos colores que los medidores de límite",
"Les mêmes couleurs que les jauges de limite", "As mesmas cores dos medidores de limite",
"Dieselben Farben wie die Limit-Balken")
}
func menuLimitColorMode(_ mode: UsageStore.MenuLimitColorMode) -> String {
switch mode {
case .gauge:
return t("게이지와 같게", "Match gauges", "ゲージと同じ", "Como los medidores",
"Comme les jauges", "Como os medidores", "Wie die Balken")
case .attention:
return t("주의 단계만", "Only when fast", "速いときだけ", "Solo si va rápido",
"Seulement si rapide", "Só quando rápido", "Nur wenn schnell")
case .off:
return t("끔", "Off", "オフ", "Desactivado", "Désactivé", "Desligado", "Aus")
}
}
var animationQualityLabel: String { t("애니메이션", "Animation", "アニメーション", "Animación", "Animation", "Animação", "Animation") }
var animationQualityHint: String {
t("부드러울수록 배터리를 더 씁니다", "Smoother uses more battery",
Expand Down
30 changes: 15 additions & 15 deletions Sources/PokeTokenBar/Core/LogRepeatSuppressor.swift
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
import Foundation

/// 반복 상태 줄의 억제 판정 — 순수 상태 머신이라 픽스처로 테스트한다
/// (`AppLog.write` 는 `swift test` 에서 no-op 이라 파일로는 검증할 수 없다).
/// Decides whether a repeated status line is written. A pure state machine, so it is tested with
/// fixtures (`AppLog.write` is a no-op under `swift test`, so the log file cannot be checked).
///
/// 왜 필요한가: 미설치 프로바이더는 폴마다 **같은 줄**을 남긴다. 실측(2026-08-30) 로그 9,093줄 중
/// `phase1 recv id=<미설치> today=nil err=none` 류가 507회씩 반복돼 60% 가량을 차지했다.
/// `AppLog` 는 2MB 에서 회전하므로 이 잡음이 실제 진단 이력을 밀어낸다 — 회전 주기가 짧아
/// "장애 직전 컨텍스트"가 사라지던 것이 이미 알려진 문제였다(AppLog.maxBytes 주석).
/// Why: status lines that describe an unchanged state are written on every poll. In a real log
/// (19,751 lines over three weeks) `cursor api: no session token — state.vscdb: missing` alone
/// appeared 2,658 times. `AppLog` rotates at 2 MB, so this noise pushes out the history needed to
/// diagnose a crash or a failed sync.
///
/// 억제하되 **상태 전이는 절대 놓치지 않는다**: 메시지가 달라지면 즉시 기록한다. 같은 상태가
/// 길게 이어져도 `repeatAfter` 마다 한 번은 남긴다 — 로그만 보고 "언제부터 이랬나"와
/// "지금도 그런가"를 구분할 수 있어야 한다.
/// It suppresses repeats but never a state change: a different message is written at once. An
/// unchanged state is still written once per `repeatAfter`, so the log can tell "since when" from
/// "still true now".
struct LogRepeatSuppressor {
private var lastWritten: [String: (message: String, at: Date)] = [:]

/// 기록해야 하면 true 를 돌려주고 내부 상태를 갱신한다.
/// - key: 같은 슬롯으로 볼 줄들의 식별자(예: `"phase1-recv-codex"`). 프로바이더별로 나눠야
/// 한 프로바이더의 변화가 다른 프로바이더의 억제를 풀지 않는다.
/// Returns true when the line should be written, and records it.
/// - key: the slot that lines share (e.g. `"cursor-limits"`). Use one key per provider or state,
/// so one provider's change does not lift another's suppression.
mutating func shouldWrite(key: String,
message: String,
now: Date = Date(),
repeatAfter: TimeInterval) -> Bool
{
guard let previous = lastWritten[key] else {
lastWritten[key] = (message, now)
return true // 첫 관측은 항상 남긴다
return true // first observation: always written
}
if previous.message != message {
lastWritten[key] = (message, now)
return true // 상태 전이 — 즉시 기록
return true // state change: written at once
}
if now.timeIntervalSince(previous.at) >= repeatAfter {
lastWritten[key] = (message, now)
return true // 같은 상태의 주기적 확인
return true // same state, periodic reaffirmation
}
return false
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/PokeTokenBar/Core/PaceTier.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,17 @@ enum PaceTier: Int, CaseIterable {
static func roundedDelta(utilization: Double, pace: Double) -> Int {
Int((utilization - pace * 100).rounded())
}

/// The tier a quota gauge is drawn in, with or without a pace. Rows without a pace (or still in the
/// early-window hold) map the absolute thresholds onto the same palette — green, orange from warn,
/// red from crit, the colors `limitColor` uses — so the popover gauge and the menu bar share one rule.
static func gauge(utilization: Double, pace: Double?, warnThreshold: Double, critThreshold: Double) -> PaceTier {
if let tier = tier(utilization: utilization, pace: pace, critThreshold: critThreshold) { return tier }
if utilization >= critThreshold { return .wayOver }
if utilization >= warnThreshold { return .over }
return .onPace
}

/// On pace or slower — nothing to act on yet.
var isCalm: Bool { rawValue <= PaceTier.onPace.rawValue }
}
Loading