diff --git a/README.ja.md b/README.ja.md index 45bdce786..6de29f36b 100644 --- a/README.ja.md +++ b/README.ja.md @@ -174,6 +174,8 @@ swift test # ユニットテスト | `status.claude.com`, `status.openai.com` | プロバイダ障害バナー | statuspage の要約;表示専用 — 設定でオフにできます | | `api.github.com` | アップデート確認 | 最新リリースのタグ;起動時とポップオーバーを開いた時 | +ログが**上記の既定パスの外**にある場合は、**設定 → 詳細 → 追加スキャンフォルダ**にそのフォルダを追加します。先にプロバイダーを選んでください — フォルダはそのプロバイダーだけが解析するので、Gemini 欄に Claude のログを指定するとトークンの帰属が壊れます。追加フォルダは既定の場所に*足すだけ*で、置き換えません。 + ## プライバシー & 権限 - **オンデバイス。** トークン使用量はローカルの Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI データから直接読み取ります。使用量のアップロードも、モデルの推論実行も行いません。 diff --git a/README.ko.md b/README.ko.md index f213ac65e..4e056263b 100644 --- a/README.ko.md +++ b/README.ko.md @@ -174,6 +174,8 @@ swift test # 단위 테스트 | `status.claude.com`, `status.openai.com` | 프로바이더 장애 배너 | statuspage 요약; 표시 전용 — 설정에서 끌 수 있음 | | `api.github.com` | 업데이트 확인 | 최신 릴리스 태그; 기동 시와 팝오버를 열 때 | +로그가 **위 기본 경로 밖**에 있으면 **설정 → 고급 → 추가 스캔 폴더**에 그 폴더를 넣습니다. 프로바이더를 먼저 고르세요 — 폴더는 그 프로바이더만 파싱하므로, Gemini 칸에 Claude 로그를 넣으면 토큰이 잘못 귀속됩니다. 추가 폴더는 기본 위치에 *더해질* 뿐 대체하지 않습니다. + ## 프라이버시 & 권한 - **온디바이스.** 토큰 사용량은 로컬 Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI 데이터에서 직접 읽습니다. 사용량을 업로드하거나 모델 turn을 실행하지 않습니다. diff --git a/README.md b/README.md index 2e424f4dd..ab7ea4ffd 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ swift test # unit tests | `status.claude.com`, `status.openai.com` | provider incident banner | statuspage summary; display only — turn it off in Settings | | `api.github.com` | update check | latest release tag; on launch and when the popover opens | +If a provider's logs live **outside** those built-in paths, add the folder in **Settings → Advanced → Additional scan folders**. Pick the provider first — each folder is parsed only by that provider, so pointing a Gemini field at Claude logs would mis-attribute tokens. Extra folders are added to the built-in locations; they never replace them. + ## Privacy & permissions - **On-device.** Token usage is read directly from local Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, and Kiro CLI data. The app never uploads usage or runs model turns. diff --git a/Sources/PokeTokenBar/Core/CustomScanRoots.swift b/Sources/PokeTokenBar/Core/CustomScanRoots.swift new file mode 100644 index 000000000..69c3bfdc4 --- /dev/null +++ b/Sources/PokeTokenBar/Core/CustomScanRoots.swift @@ -0,0 +1,118 @@ +import Darwin +import Foundation + +/// Per-provider extra scan folders from Settings (#177). +/// +/// One list per provider, stored under `customScanRoots.`. A flat list shared +/// across readers is unsafe — `jsonlFiles(allowJSON:)` exists specifically so Gemini +/// does not ingest Claude `.meta.json`. Custom roots only *add* to curated defaults; +/// an ancestor extra is dropped so `normalizedRoots` cannot fold a hidden default +/// away (#162-B: scanning `~` with `skipsHiddenFiles` silently zeros usage). +enum CustomScanRoots { + + static func defaultsKey(for providerID: String) -> String { + "customScanRoots.\(providerID)" + } + + static func storedValue(for providerID: String, defaults: UserDefaults = .standard) -> String? { + guard let raw = defaults.string(forKey: defaultsKey(for: providerID)) else { return nil } + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : raw + } + + /// Comma/newline split, tilde expansion, per-segment `*`/`?`/`[]` via `fnmatch`. + /// Entries point at log roots themselves (no `/projects` suffix). Missing paths + /// are not errors — a pattern can be registered before the instance exists. + static func expand(_ raw: String) -> [URL] { + let fm = FileManager.default + var out: [URL] = [] + for part in raw.split(whereSeparator: { $0 == "," || $0.isNewline }) { + let pattern = part.trimmingCharacters(in: .whitespaces) + guard !pattern.isEmpty else { continue } + let absolute = NSString(string: pattern).expandingTildeInPath + guard absolute.hasPrefix("/") else { continue } + + var paths = [""] + for segment in absolute.split(separator: "/").map(String.init) { + if segment.contains("*") || segment.contains("?") || segment.contains("[") { + var next: [String] = [] + for base in paths { + guard let names = try? fm.contentsOfDirectory(atPath: base.isEmpty ? "/" : base) + else { continue } + next.append(contentsOf: names.sorted() + .filter { fnmatch(segment, $0, 0) == 0 } + .map { base + "/" + $0 }) + } + paths = next + } else { + paths = paths.map { $0 + "/" + segment } + } + } + if paths == [""] { paths = ["/"] } + var isDir: ObjCBool = false + out.append(contentsOf: paths + .filter { fm.fileExists(atPath: $0, isDirectory: &isDir) && isDir.boolValue } + .map { URL(fileURLWithPath: $0) }) + } + return out + } + + /// Curated defaults first; extras that would evict a default are dropped; then fold. + static func union(defaults: [URL], extraRaw: String?) -> [URL] { + let protected = LocalUsageReader.normalizedRoots(defaults) + guard let extraRaw else { return protected } + let added = expand(extraRaw).filter { extra in + !wouldEvict(extra, protected: protected) + } + return LocalUsageReader.normalizedRoots(protected + added) + } + + /// Folded extras that are not one of the curated defaults — the number Settings shows. + static func survivingExtraCount(defaults: [URL], extraRaw: String) -> Int { + let protected = Set(LocalUsageReader.normalizedRoots(defaults).map(normalizedPath)) + let united = union(defaults: defaults, extraRaw: extraRaw) + return united.filter { !protected.contains(normalizedPath($0)) }.count + } + + /// Production curated roots for the Settings match count. Uses the same + /// env/home resolution as the readers so the number matches what will be scanned. + static func curatedRoots(for providerID: String) -> [URL] { + switch providerID { + case "claude_code": + return LocalUsageReader.computeClaudeProjectRoots(customRootsValue: nil) + case "codex": + return LocalUsageReader.codexSessionRoots(customRootsValue: nil) + case "gemini": + return LocalUsageReader.geminiScanRoots(customRootsValue: nil) + case "grok": + return LocalUsageReader.grokSessionRoots(customRootsValue: nil) + case "antigravity": + return LocalAntigravityUsageReader.resolvedRoots(customRootsValue: nil) + case "opencode": + return LocalAdditionalUsageReader.openCodeRoots(customRootsValue: nil) + case "hermes": + return LocalAdditionalUsageReader.hermesRoots(customRootsValue: nil) + case "cursor": + return LocalAdditionalUsageReader.cursorRoots(customRootsValue: nil) + case "copilot": + return LocalAdditionalUsageReader.copilotRoots(customRootsValue: nil) + case "kiro": + return LocalAdditionalUsageReader.kiroRoots(customRootsValue: nil) + default: + return [] + } + } + + private static func wouldEvict(_ extra: URL, protected: [URL]) -> Bool { + let extraPath = normalizedPath(extra) + return protected.contains { def in + let d = normalizedPath(def) + if extraPath == "/" { return d != extraPath } + return d != extraPath && d.hasPrefix(extraPath + "/") + } + } + + private static func normalizedPath(_ url: URL) -> String { + url.resolvingSymlinksInPath().standardizedFileURL.path.lowercased() + } +} diff --git a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift index e004897f6..e4f68d6d5 100644 --- a/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift +++ b/Sources/PokeTokenBar/Core/LocalAdditionalUsageProvider.swift @@ -148,6 +148,14 @@ private actor LocalAdditionalUsageCache { private var cached: [LocalAdditionalSource: Cached] = [:] private var inFlight: [LocalAdditionalSource: Task] = [:] + /// Bumped by `invalidate()` so a scan that started on old roots cannot republish. + private var epoch = 0 + + func invalidate() { + epoch += 1 + cached = [:] + inFlight = [:] + } func entries(for source: LocalAdditionalSource) async -> [LocalUsageReader.Entry] { let now = Date() @@ -237,8 +245,12 @@ private actor LocalAdditionalUsageCache { highWaterByPath: loaded.highWaterByPath) } } + let startEpoch = epoch inFlight[source] = task let result = await task.value + if startEpoch != epoch { + return result.entries + } inFlight[source] = nil cached[source] = Cached( loadedAt: now, monthKey: monthKey, entries: result.entries, @@ -253,16 +265,45 @@ private actor LocalAdditionalUsageCache { enum LocalAdditionalUsageReader { typealias Object = [String: Any] + /// Drop the 30s hit so a newly saved extra folder is scanned on the next refresh (#177). + static func invalidateScanCache() async { + await LocalAdditionalUsageCache.shared.invalidate() + } + static var defaultOpenCodeRoots: [URL] { environmentPaths("OPENCODE_DATA_DIR") ?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".local/share/opencode")] } + static func openCodeRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated = environmentPaths("OPENCODE_DATA_DIR") + ?? [home.appendingPathComponent(".local/share/opencode")] + return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue) + } + + static var defaultHermesRoots: [URL] { + environmentPaths("HERMES_HOME") + ?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".hermes")] + } + + static func hermesRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated = environmentPaths("HERMES_HOME") + ?? [home.appendingPathComponent(".hermes")] + return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue) + } + static func openCodeEntries( modifiedSince: Date, roots: [URL]? = nil ) -> [LocalUsageReader.Entry] { - let sourceRoots = roots ?? defaultOpenCodeRoots + let sourceRoots = roots ?? openCodeRoots( + customRootsValue: CustomScanRoots.storedValue(for: "opencode")) var entries: [LocalUsageReader.Entry] = [] for root in sourceRoots { if let database = preferredOpenCodeDatabase(in: root) { @@ -283,8 +324,8 @@ enum LocalAdditionalUsageReader { modifiedSince: Date, roots: [URL]? = nil ) -> [LocalUsageReader.Entry] { - let sourceRoots = roots ?? environmentPaths("HERMES_HOME") - ?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".hermes")] + let sourceRoots = roots ?? hermesRoots( + customRootsValue: CustomScanRoots.storedValue(for: "hermes")) var seen = Set() var entries: [LocalUsageReader.Entry] = [] for root in sourceRoots { @@ -568,6 +609,17 @@ enum LocalAdditionalUsageReader { ] } + static func cursorRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated = environmentPaths("CURSOR_DATA_DIR") ?? [ + home.appendingPathComponent("Library/Application Support/Cursor/User/globalStorage"), + home.appendingPathComponent("Library/Application Support/Cursor Nightly/User/globalStorage"), + ] + return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue) + } + static func cursorEntries( modifiedSince: Date, afterRowID: Int64 = 0, @@ -575,7 +627,7 @@ enum LocalAdditionalUsageReader { roots: [URL]? = nil ) -> CursorLoadResult { scanIncrementalStores( - roots: roots ?? defaultCursorRoots, + roots: roots ?? cursorRoots(customRootsValue: CustomScanRoots.storedValue(for: "cursor")), modifiedSince: modifiedSince, afterRowID: afterRowID, afterRowIDByPath: afterRowIDByPath, @@ -693,6 +745,15 @@ enum LocalAdditionalUsageReader { ?? [FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".copilot")] } + static func copilotRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated = environmentPaths("COPILOT_HOME") + ?? [home.appendingPathComponent(".copilot")] + return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue) + } + /// Read Copilot CLI usage rows newer than `modifiedSince`. /// /// Every row in `assistant_usage_events` is one billed API call, including the ones a @@ -708,7 +769,7 @@ enum LocalAdditionalUsageReader { roots: [URL]? = nil ) -> CopilotLoadResult { scanIncrementalStores( - roots: roots ?? defaultCopilotRoots, + roots: roots ?? copilotRoots(customRootsValue: CustomScanRoots.storedValue(for: "copilot")), modifiedSince: modifiedSince, afterRowID: afterRowID, afterRowIDByPath: afterRowIDByPath, @@ -803,6 +864,15 @@ enum LocalAdditionalUsageReader { .appendingPathComponent("Library/Application Support/kiro-cli")] } + static func kiroRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated = environmentPaths("KIRO_CLI_HOME") + ?? [home.appendingPathComponent("Library/Application Support/kiro-cli")] + return CustomScanRoots.union(defaults: curated, extraRaw: customRootsValue) + } + /// Read Kiro CLI usage turns newer than `modifiedSince`. /// /// Kiro persists a conversation as one row whose `value` column holds the *entire* @@ -833,7 +903,8 @@ enum LocalAdditionalUsageReader { knownSignatures: [String: (mtime: Date, size: Int)], roots: [URL]? = nil ) -> (entries: [LocalUsageReader.Entry], signatures: [String: (mtime: Date, size: Int)]) { - let sourceRoots = roots ?? defaultKiroRoots + let sourceRoots = roots ?? kiroRoots( + customRootsValue: CustomScanRoots.storedValue(for: "kiro")) var entries: [LocalUsageReader.Entry] = [] var signatures: [String: (mtime: Date, size: Int)] = [:] for root in sourceRoots { diff --git a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift index 652b1988e..d6e2d3d80 100644 --- a/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift @@ -51,23 +51,38 @@ enum LocalAntigravityUsageReader { /// Known conversation directories across Antigravity editions (2.0/Core, CLI, IDE). /// The directory is absent unless the respective Antigravity flavor ran. - static var defaultRoots: [URL] { - let home = FileManager.default.homeDirectoryForCurrentUser - return [ + static func defaultRoots( + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + [ home.appendingPathComponent(".gemini/antigravity/conversations"), home.appendingPathComponent(".gemini/antigravity-cli/conversations"), home.appendingPathComponent(".gemini/antigravity-ide/conversations"), ] } + static var defaultRoots: [URL] { defaultRoots() } + /// Primary default directory for single-root callers and backwards compatibility. static var defaultRoot: URL { defaultRoots[0] } + static func resolvedRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + CustomScanRoots.union( + defaults: defaultRoots(home: home), + extraRaw: customRootsValue) + } + /// Usage rows whose `created_at` falls at or after `modifiedSince`. static func entries(modifiedSince: Date, roots: [URL]? = nil) -> [LocalUsageReader.Entry] { - let scanned = scan(roots: roots ?? defaultRoots, modifiedSince: modifiedSince, known: [:]) + let scanned = scan( + roots: roots ?? resolvedRoots( + customRootsValue: CustomScanRoots.storedValue(for: "antigravity")), + modifiedSince: modifiedSince, known: [:]) // The one place the side effects live. `AppLog.write` returns early outside the bundled // app, so the decisions above it are kept pure and tested on their own. for line in scanned.log { AppLog.write(line) } @@ -76,7 +91,10 @@ enum LocalAntigravityUsageReader { /// Single-root overload for backwards compatibility and tests. static func entries(modifiedSince: Date, root: URL?) -> [LocalUsageReader.Entry] { - entries(modifiedSince: modifiedSince, roots: root.map { [$0] } ?? defaultRoots) + entries( + modifiedSince: modifiedSince, + roots: root.map { [$0] } ?? resolvedRoots( + customRootsValue: CustomScanRoots.storedValue(for: "antigravity"))) } /// One conversation store's rows, valid for as long as its `(mtime, size)` hold. The rows @@ -666,10 +684,9 @@ actor LocalAntigravityUsageCache { // Join rather than start a second scan, and leave the log to the owner. scanned = await inFlight.value } else { - let targetRoots = self.roots ?? LocalAntigravityUsageReader.defaultRoots + let targetRoots = self.roots ?? LocalAntigravityUsageReader.resolvedRoots( + customRootsValue: CustomScanRoots.storedValue(for: "antigravity")) let known = blobs - // Nothing awaits between the miss above and this assignment — that is what stops two - // callers from both starting a scan. let task = Task.detached(priority: .utility) { LocalAntigravityUsageReader.scan(roots: targetRoots, modifiedSince: since, known: known) } diff --git a/Sources/PokeTokenBar/Core/LocalUsageCache.swift b/Sources/PokeTokenBar/Core/LocalUsageCache.swift index 643d1890b..0cfdd686b 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageCache.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageCache.swift @@ -89,6 +89,8 @@ actor LocalUsageCache { private let codexRoots: [URL]? private let geminiRoot: URL? private let grokRoot: URL? + private let geminiRoots: [URL]? + private let grokRoots: [URL]? private let fileURL: URL private let now: @Sendable () -> Date /// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다. @@ -101,6 +103,7 @@ actor LocalUsageCache { init(claudeRoot: URL? = nil, claudeRoots: [URL]? = nil, codexRoot: URL? = nil, codexRoots: [URL]? = nil, geminiRoot: URL? = nil, grokRoot: URL? = nil, + geminiRoots: [URL]? = nil, grokRoots: [URL]? = nil, fileURL: URL? = nil, now: @escaping @Sendable () -> Date = Date.init, codexProbe: @escaping @Sendable (URL) throws -> String? = { try LocalUsageReader.probeCodexRolloutSessionID(at: $0) @@ -113,7 +116,9 @@ actor LocalUsageCache { self.codexRoot = codexRoot self.codexRoots = codexRoots self.geminiRoot = geminiRoot + self.geminiRoots = geminiRoots self.grokRoot = grokRoot + self.grokRoots = grokRoots self.fileURL = fileURL ?? Self.defaultFileURL self.now = now self.codexProbe = codexProbe @@ -146,7 +151,8 @@ actor LocalUsageCache { func codexEntries(modifiedSince: Date) -> [LocalUsageReader.Entry] { ensureLoaded() let fmt = LocalUsageReader.localDayFormatter() - let roots = codexRoots ?? codexRoot.map { [$0] } ?? LocalUsageReader.codexScanRoots + let roots = codexRoots ?? codexRoot.map { [$0] } ?? LocalUsageReader.codexSessionRoots( + customRootsValue: CustomScanRoots.storedValue(for: "codex")) let (rollouts, includedPaths) = collectCodexRollouts( roots: roots, since: modifiedSince, @@ -166,12 +172,17 @@ actor LocalUsageCache { func geminiEntries(modifiedSince: Date) -> [LocalUsageReader.Entry] { ensureLoaded() let fmt = LocalUsageReader.localDayFormatter() - let r = collect(root: geminiRoot ?? LocalUsageReader.geminiTmpDir, since: modifiedSince, - cache: &geminiCache, allowJSON: true) { - LocalUsageReader.parseGeminiFile($0, fmt: fmt) + let roots = geminiRoots ?? geminiRoot.map { [$0] } ?? LocalUsageReader.geminiScanRoots( + customRootsValue: CustomScanRoots.storedValue(for: "gemini")) + var all: [LocalUsageReader.Entry] = [] + for root in roots { + all += collect(root: root, since: modifiedSince, + cache: &geminiCache, allowJSON: true) { + LocalUsageReader.parseGeminiFile($0, fmt: fmt) + } } saveIfNeeded() - return r + return LocalUsageReader.dedupKeepMax(all) } func grokEntries(modifiedSince: Date) -> [LocalUsageReader.Entry] { @@ -181,9 +192,14 @@ actor LocalUsageCache { // 파싱 캐시 **앞**에 있어야 한다 — blob 은 updates.jsonl 의 mtime·size 로만 무효화되는데 // 서브에이전트 판정 근거는 옆 파일(summary.json)이라, 파싱 안에서 걸러내면 늦게 쓰인 // session_kind 가 blob 에 굳어 이중집계가 영구화된다. - let all = collect(root: grokRoot ?? LocalUsageReader.grokSessionsDir, since: modifiedSince, - cache: &grokCache, include: LocalUsageReader.isGrokUsageFile) { - LocalUsageReader.parseGrokFile($0, fmt: fmt) + let roots = grokRoots ?? grokRoot.map { [$0] } ?? LocalUsageReader.grokSessionRoots( + customRootsValue: CustomScanRoots.storedValue(for: "grok")) + var all: [LocalUsageReader.Entry] = [] + for root in roots { + all += collect(root: root, since: modifiedSince, + cache: &grokCache, include: LocalUsageReader.isGrokUsageFile) { + LocalUsageReader.parseGrokFile($0, fmt: fmt) + } } saveIfNeeded() // fork 세션이 부모 updates 를 복사해도 같은 턴은 한 번만(전역 dedup). diff --git a/Sources/PokeTokenBar/Core/LocalUsageReader.swift b/Sources/PokeTokenBar/Core/LocalUsageReader.swift index 2493f30d5..72fa71385 100644 --- a/Sources/PokeTokenBar/Core/LocalUsageReader.swift +++ b/Sources/PokeTokenBar/Core/LocalUsageReader.swift @@ -59,6 +59,8 @@ enum LocalUsageReader { /// /// - `CLAUDE_CONFIG_DIR`: 사용자가 설정 위치를 옮긴 경우. 콤마로 여러 개를 줄 수 있고 각각 `<값>/projects`. /// - `~/.config/claude/projects`, `~/.claude/projects`: CLI 기본 위치(전자는 XDG 스타일 설치). + /// - 사용자 지정 스캔 폴더(설정): 기본 위치 밖의 로그. `CustomScanRoots.union` 으로 + /// 기본 루트에 *더하기만* 한다. 조상 경로는 기본 루트를 접어 없애지 못하게 버린다. /// - Claude Desktop 임베디드 세션: 세션 디렉터리마다 CLI 와 같은 모양의 `.claude/projects` 를 갖는다. /// Desktop 으로 일한 사용량이 여기에만 남으므로 빼면 조용히 누락된다. /// 계산에 파일시스템 탐색 + (GUI 앱에선) 로그인 셸 조회가 들어가는데 새로고침은 분 단위로 돈다. @@ -68,6 +70,7 @@ enum LocalUsageReader { /// 테스트·진단용 — 캐시를 무시하고 지금 상태로 계산한다. static func computeClaudeProjectRoots( configDirValue: String? = shellAwareClaudeConfigDir(), + customRootsValue: String? = nil, home: URL = FileManager.default.homeDirectoryForCurrentUser) -> [URL] { var roots: [URL] = [] @@ -86,7 +89,45 @@ enum LocalUsageReader { for store in ["local-agent-mode-sessions", "claude-code-sessions"] { roots.append(contentsOf: embeddedClaudeProjectRoots(under: desktop.appendingPathComponent(store))) } - return normalizedRoots(roots) + // Custom roots are unioned *after* curated defaults so an ancestor extra cannot + // evict `~/.claude/projects` (#162-B / #177). + return CustomScanRoots.union(defaults: roots, extraRaw: customRootsValue) + } + + /// Setting change must not wait for the 300s TTL — the next refresh should see the folder. + static func invalidateProjectRootsCache() { rootsCache.invalidate() } + + static func codexSessionRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + CustomScanRoots.union( + defaults: computeCodexScanRoots(home: home), + extraRaw: customRootsValue) + } + + static func geminiScanRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + CustomScanRoots.union( + defaults: [home.appendingPathComponent(".gemini/tmp")], + extraRaw: customRootsValue) + } + + static func grokSessionRoots( + customRootsValue: String? = nil, + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> [URL] { + let curated: URL + if home == FileManager.default.homeDirectoryForCurrentUser, + let env = UsageEnvironment.value("GROK_HOME")?.trimmingCharacters(in: .whitespacesAndNewlines), + !env.isEmpty { + curated = URL(fileURLWithPath: env).appendingPathComponent("sessions") + } else { + curated = home.appendingPathComponent(".grok/sessions") + } + return CustomScanRoots.union(defaults: [curated], extraRaw: customRootsValue) } /// `CLAUDE_CONFIG_DIR` 값. Finder/launchd 로 뜬 `.app` 은 셸 환경을 상속하지 않으므로 @@ -118,13 +159,21 @@ enum LocalUsageReader { lock.unlock() if let cached = hit.0, let at = hit.1, Date().timeIntervalSince(at) < ttl { return cached } - let fresh = computeClaudeProjectRoots() + let fresh = computeClaudeProjectRoots( + customRootsValue: CustomScanRoots.storedValue(for: "claude_code")) lock.lock() cached = fresh computedAt = Date() lock.unlock() return fresh } + + func invalidate() { + lock.lock() + cached = nil + computedAt = nil + lock.unlock() + } } /// Claude Desktop 임베디드 세션 스토어에서 `.claude/projects` 디렉터리를 찾는다. @@ -658,7 +707,8 @@ enum LocalUsageReader { static func codexEntries(modifiedSince: Date, root: URL? = nil) -> [Entry] { let fmt = localDayFormatter() - let roots = root.map { [$0] } ?? codexScanRoots + let roots = root.map { [$0] } ?? codexSessionRoots( + customRootsValue: CustomScanRoots.storedValue(for: "codex")) let allFiles = codexRolloutFiles(in: roots) // 테스트/캐시 미사용 경로 — 아는 세션 id 가 없으니 파일명 힌트와 probe 만으로 부모를 찾는다. let (rollouts, includedPaths) = expandCodexParentClosure( @@ -1041,11 +1091,15 @@ enum LocalUsageReader { static func geminiEntries(modifiedSince: Date, root: URL? = nil) -> [Entry] { let fmt = localDayFormatter() + let roots = root.map { [$0] } ?? geminiScanRoots( + customRootsValue: CustomScanRoots.storedValue(for: "gemini")) var entries: [Entry] = [] - for file in jsonlFiles(in: root ?? geminiTmpDir, modifiedSince: modifiedSince, allowJSON: true) { - entries.append(contentsOf: parseGeminiFile(file, fmt: fmt)) + for scanRoot in roots { + for file in jsonlFiles(in: scanRoot, modifiedSince: modifiedSince, allowJSON: true) { + entries.append(contentsOf: parseGeminiFile(file, fmt: fmt)) + } } - return entries + return dedupKeepMax(entries) } // MARK: Grok 파싱 @@ -1098,10 +1152,14 @@ enum LocalUsageReader { static func grokEntries(modifiedSince: Date, root: URL? = nil) -> [Entry] { let fmt = localDayFormatter() + let roots = root.map { [$0] } ?? grokSessionRoots( + customRootsValue: CustomScanRoots.storedValue(for: "grok")) var entries: [Entry] = [] - for file in jsonlFiles(in: root ?? grokSessionsDir, modifiedSince: modifiedSince) - where isGrokUsageFile(file) { - entries.append(contentsOf: parseGrokFile(file, fmt: fmt)) + for scanRoot in roots { + for file in jsonlFiles(in: scanRoot, modifiedSince: modifiedSince) + where isGrokUsageFile(file) { + entries.append(contentsOf: parseGrokFile(file, fmt: fmt)) + } } // fork 세션이 부모 updates 를 복사해도 턴 id 가 같아 한 번만 남는다(전역 dedup). return dedupKeepMax(entries) diff --git a/Sources/PokeTokenBar/Core/Localization.swift b/Sources/PokeTokenBar/Core/Localization.swift index 29d3a7d6e..e15aea93f 100644 --- a/Sources/PokeTokenBar/Core/Localization.swift +++ b/Sources/PokeTokenBar/Core/Localization.swift @@ -172,6 +172,18 @@ struct L { var warning: String { t("경고", "Warning", "警告", "Aviso") } var critical: String { t("임박", "Critical", "切迫", "Crítico") } var aggregationNote: String { t("토큰 집계 기준: totalTokens (input + output + cache, 로컬 날짜)", "Token basis: totalTokens (input + output + cache, local date)", "集計基準: totalTokens (input + output + cache, ローカル日付)", "Base de cálculo: totalTokens (input + output + cache, fecha local)") } + var customScanProviderLabel: String { t("프로바이더", "Provider", "プロバイダー", "Proveedor") } + var customScanRootsLabel: String { t("추가 스캔 폴더", "Additional scan folders", "追加スキャンフォルダ", "Carpetas de escaneo adicionales") } + var customScanRootsHint: String { + t("선택한 프로바이더의 로그가 기본 위치 밖에 있을 때만. 콤마·줄바꿈 구분, * 와일드카드. 다른 프로바이더 폴더를 넣지 마세요.", + "Only for this provider's logs outside the built-in locations. Comma/newline separated, * wildcards. Do not point at another provider's folder.", + "選択したプロバイダーのログが既定の場所にないときだけ。カンマ・改行区切り、*ワイルドカード。別プロバイダーのフォルダは指定しないでください。", + "Solo para los registros de este proveedor fuera de las ubicaciones integradas. Separados por coma o salto de línea; comodines *. No indiques la carpeta de otro proveedor.") + } + var customScanRootsPlaceholder: String { t("~/path/to/sessions", "~/path/to/sessions", "~/path/to/sessions", "~/path/to/sessions") } + func customScanRootsMatches(_ n: Int) -> String { + t("지금 \(n)개 추가 폴더를 스캔함", "Scans \(n) extra folder(s) now", "現在\(n)個の追加フォルダをスキャン", "Escanea \(n) carpeta(s) extra ahora") + } var close: String { t("닫기", "Close", "閉じる", "Cerrar") } // MARK: 세이브 이전 (설정 → 백업 & 이전) diff --git a/Sources/PokeTokenBar/Core/UsageStore.swift b/Sources/PokeTokenBar/Core/UsageStore.swift index 79fe09d18..dcf919720 100644 --- a/Sources/PokeTokenBar/Core/UsageStore.swift +++ b/Sources/PokeTokenBar/Core/UsageStore.swift @@ -119,8 +119,30 @@ final class UsageStore { private let providers: [any UsageProvider] + /// Registered usage sources — Settings lists these so extra scan folders + /// stay provider-tagged (#177). Do not grow one text field per provider. + var registeredProviders: [(id: String, displayName: String)] { + providers.map { (id: $0.id, displayName: $0.displayName) } + } + /// 등록된 프로바이더 id 목록 — 확장 규약 레지스트리 무결성 테스트용. - var registeredProviderIDs: [String] { providers.map(\.id) } + var registeredProviderIDs: [String] { registeredProviders.map(\.id) } + + func customScanRoots(for providerID: String) -> String { + defaults.string(forKey: CustomScanRoots.defaultsKey(for: providerID)) ?? "" + } + + func setCustomScanRoots(_ value: String, for providerID: String) { + let key = CustomScanRoots.defaultsKey(for: providerID) + let previous = defaults.string(forKey: key) ?? "" + guard value != previous else { return } + defaults.set(value, forKey: key) + LocalUsageReader.invalidateProjectRootsCache() + Task { + await LocalAdditionalUsageReader.invalidateScanCache() + await refresh() + } + } private let limitsProvider: any ClaudeLimitsProviding private let codexLimitsProvider: any CodexLimitsProviding private let statusProvider: any ProviderStatusProviding diff --git a/Sources/PokeTokenBar/UI/SettingsView.swift b/Sources/PokeTokenBar/UI/SettingsView.swift index 2e102bf02..aa53c64ed 100644 --- a/Sources/PokeTokenBar/UI/SettingsView.swift +++ b/Sources/PokeTokenBar/UI/SettingsView.swift @@ -15,6 +15,16 @@ struct SettingsView: View { @State private var advancedExpanded = false @State private var isCheckingUpdate = false @State private var didCheckUpdate = false + @State private var selectedScanProviderID = "claude_code" + /// Provider the draft currently describes. Picker change updates `selectedScanProviderID` + /// before the TextField blurs; committing against the selection would write Claude paths + /// into `customScanRoots.gemini`. + @State private var customScanDraftOwnerID = "claude_code" + @State private var customScanDraft = "" + @State private var customScanMatchCount = 0 + @State private var customScanMatchTask: Task? + @State private var customScanMatchGeneration = 0 + @FocusState private var customScanFocused: Bool private var l: L { companion.l } private var isBundledApp: Bool { AppEnv.isBundledApp } @@ -353,6 +363,12 @@ struct SettingsView: View { } .buttonStyle(.plain) .padding(.horizontal, 12).padding(.vertical, 9) + .onChange(of: advancedExpanded) { _, expanded in + if !expanded { + commitCustomScanDraft() + customScanMatchTask?.cancel() + } + } if advancedExpanded { Divider() @@ -389,6 +405,52 @@ struct SettingsView: View { .padding(.horizontal, 12).padding(.bottom, 6) } Divider() + groupRow { + VStack(alignment: .leading, spacing: 6) { + Text(l.customScanRootsLabel) + Text(l.customScanRootsHint).font(.caption2).foregroundStyle(.tertiary) + HStack { + Text(l.customScanProviderLabel).font(.caption) + Spacer() + Picker("", selection: $selectedScanProviderID) { + ForEach(store.registeredProviders, id: \.id) { provider in + Text(provider.displayName).tag(provider.id) + } + } + .labelsHidden().pickerStyle(.menu).fixedSize() + } + TextField(l.customScanRootsPlaceholder, text: $customScanDraft, axis: .vertical) + .textFieldStyle(.roundedBorder).font(.caption) + .focused($customScanFocused) + .onSubmit { commitCustomScanDraft() } + if !customScanDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(l.customScanRootsMatches(customScanMatchCount)) + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + .onAppear { + customScanDraftOwnerID = selectedScanProviderID + customScanDraft = store.customScanRoots(for: selectedScanProviderID) + scheduleCustomScanMatchCount() + } + .onDisappear { + commitCustomScanDraft() + customScanMatchTask?.cancel() + } + .onChange(of: selectedScanProviderID) { _, newID in + store.setCustomScanRoots(customScanDraft, for: customScanDraftOwnerID) + customScanDraftOwnerID = newID + customScanDraft = store.customScanRoots(for: newID) + scheduleCustomScanMatchCount() + } + .onChange(of: customScanDraft) { _, _ in + scheduleCustomScanMatchCount() + } + .onChange(of: customScanFocused) { _, focused in + if !focused { commitCustomScanDraft() } + } + Divider() Text(l.aggregationNote) .font(.caption2).foregroundStyle(.tertiary) .padding(.horizontal, 12).padding(.vertical, 8) @@ -468,6 +530,30 @@ struct SettingsView: View { .help(urlString) } + private func commitCustomScanDraft() { + store.setCustomScanRoots(customScanDraft, for: customScanDraftOwnerID) + } + + private func scheduleCustomScanMatchCount() { + customScanMatchTask?.cancel() + customScanMatchGeneration += 1 + let generation = customScanMatchGeneration + let raw = customScanDraft + let providerID = selectedScanProviderID + customScanMatchTask = Task.detached(priority: .utility) { + try? await Task.sleep(nanoseconds: 250_000_000) + guard !Task.isCancelled else { return } + let count = CustomScanRoots.survivingExtraCount( + defaults: CustomScanRoots.curatedRoots(for: providerID), + extraRaw: raw) + guard !Task.isCancelled else { return } + await MainActor.run { + guard generation == customScanMatchGeneration else { return } + customScanMatchCount = count + } + } + } + // MARK: 동작 /// 문제점 알리기 — 진단 정보(버전·macOS)가 채워진 리포트 메일을 기본 메일 앱으로 연다. diff --git a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift index a6314672f..f502edfa8 100644 --- a/Tests/PokeTokenBarTests/AntigravityUsageTests.swift +++ b/Tests/PokeTokenBarTests/AntigravityUsageTests.swift @@ -762,6 +762,53 @@ final class AntigravityUsageTests: XCTestCase { XCTAssertEqual(entries.count, 3, "duplicate r_shared must collapse to a single entry") } + /// #187 review: `resolvedRoots` hardcoded the CLI path (`defaultRoots[1]`). The no-root + /// `entries(modifiedSince:)` overload goes through that list, so 2.0/Core and IDE stores + /// silently dropped. Seed one turn in each default relative path under a fake home and + /// scan via `resolvedRoots` — the same list the no-root overload uses. + func testNoRootScanReadsEveryDefaultRoot() throws { + let home = temporaryDirectory.appendingPathComponent("home") + let stamp = try date("2026-03-04T10:00:00Z") + let relative = [ + ".gemini/antigravity/conversations", + ".gemini/antigravity-cli/conversations", + ".gemini/antigravity-ide/conversations", + ] + for (index, rel) in relative.enumerated() { + let root = home.appendingPathComponent(rel) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try writeConversation("c\(index)", at: root, records: [ + record(responseID: "r\(index)", model: "gemini-3.6-flash", createdAt: stamp, + input: UInt64(10 + index), output: 1, cacheRead: 0), + ]) + } + + let roots = LocalAntigravityUsageReader.resolvedRoots(customRootsValue: nil, home: home) + let entries = LocalAntigravityUsageReader.entries(modifiedSince: .distantPast, roots: roots) + XCTAssertEqual( + Set(entries.map(\.id)), + ["antigravity|r0", "antigravity|r1", "antigravity|r2"], + "resolvedRoots must union every defaultRoots path, not only antigravity-cli") + } + + /// The no-root `entries(modifiedSince:)` / cache path must call `resolvedRoots`, not + /// `defaultRoots` alone — otherwise custom extras never apply and the test above is + /// not load-bearing for production. + func testEntriesWithoutRootsGoesThroughResolvedRoots() throws { + let source = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/Core/LocalAntigravityUsageReader.swift") + let text = try String(contentsOf: source, encoding: .utf8) + XCTAssertTrue( + text.contains("roots ?? resolvedRoots"), + "entries(modifiedSince:) with no roots must scan resolvedRoots") + XCTAssertTrue( + text.contains("self.roots ?? LocalAntigravityUsageReader.resolvedRoots"), + "the cache actor must use the same list") + } + // MARK: - Fixtures private func readAll() -> [LocalUsageReader.Entry] { diff --git a/Tests/PokeTokenBarTests/CustomScanRootsTests.swift b/Tests/PokeTokenBarTests/CustomScanRootsTests.swift new file mode 100644 index 000000000..e1d71ef94 --- /dev/null +++ b/Tests/PokeTokenBarTests/CustomScanRootsTests.swift @@ -0,0 +1,455 @@ +import XCTest +@testable import PokeTokenBar + +/// Additional scan folders (#177). Storage is per provider. Custom roots only *add* +/// to curated defaults — an ancestor custom root must not evict a hidden default +/// (the #162-B failure: `normalizedRoots` + `skipsHiddenFiles` silently zeros usage). +final class CustomScanRootsTests: XCTestCase { + + private func tempDir() -> URL { + let d = FileManager.default.temporaryDirectory.appendingPathComponent("ptb-scan-\(UUID().uuidString)") + try? FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + return d + } + + // MARK: Expand + + /// Comma/newline split, `*` segment expansion, existing directories only, input order + /// with glob matches sorted. Relative paths and files drop out. + func testExpandGlobKeepsOnlyExistingDirs() { + let base = tempDir() + for name in ["b-inst", "a-inst"] { + try? FileManager.default.createDirectory( + at: base.appendingPathComponent("instances/\(name)/projects"), + withIntermediateDirectories: true) + } + try? FileManager.default.createDirectory( + at: base.appendingPathComponent("instances/empty"), + withIntermediateDirectories: true) + let literal = base.appendingPathComponent("literal") + try? FileManager.default.createDirectory(at: literal, withIntermediateDirectories: true) + try? "x".write(to: base.appendingPathComponent("file"), atomically: true, encoding: .utf8) + + let raw = "\(base.path)/instances/*/projects, \(literal.path)\n\(base.path)/nope/*, \(base.path)/file, relative/path" + let roots = CustomScanRoots.expand(raw).map(\.path) + + XCTAssertEqual(roots, [ + base.appendingPathComponent("instances/a-inst/projects").path, + base.appendingPathComponent("instances/b-inst/projects").path, + literal.path, + ], "glob expansion / existence filter / order drifted: \(roots)") + } + + func testExpandTildeAndBlankFragments() { + let home = FileManager.default.homeDirectoryForCurrentUser + let roots = CustomScanRoots.expand(" , ~/ , \n\t ").map(\.path) + XCTAssertEqual(roots, [home.path], "bare ~ should expand to the home directory") + } + + // MARK: Union — custom only adds + + func testUnionAddsCustomRootWithoutValueUnchanged() { + let home = URL(fileURLWithPath: "/Users/testhome") + let extra = tempDir() + let curated = [ + home.appendingPathComponent(".claude/projects"), + home.appendingPathComponent(".config/claude/projects"), + ] + let united = CustomScanRoots.union(defaults: curated, extraRaw: extra.path).map(\.path) + XCTAssertTrue(united.contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + XCTAssertTrue(united.contains("/Users/testhome/.claude/projects")) + + let none = CustomScanRoots.union(defaults: curated, extraRaw: nil).map(\.path) + XCTAssertEqual(none, ["/Users/testhome/.claude/projects", "/Users/testhome/.config/claude/projects"]) + XCTAssertFalse(none.contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + } + + /// #162-B: a custom root that is an *ancestor* of a curated root must not fold the + /// curated one away. `jsonlFiles` uses `skipsHiddenFiles`, so scanning `~` never + /// descends into `~/.claude` and the total goes to zero. + func testAncestorCustomRootDoesNotEvictCuratedDefault() { + let home = tempDir() + let projects = home.appendingPathComponent(".claude/projects/p") + try? FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + let line = """ + {"type":"assistant","requestId":"R","timestamp":"2026-06-30T10:00:00.000Z","message":{"id":"A","model":"claude-opus-4-8","usage":{"input_tokens":10,"output_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} + """ + try? line.write(to: projects.appendingPathComponent("s.jsonl"), atomically: true, encoding: .utf8) + + let curated = [home.appendingPathComponent(".claude/projects")] + let united = CustomScanRoots.union(defaults: curated, extraRaw: home.path) + XCTAssertEqual(united.map(\.path), curated.map(\.path), + "ancestor custom root evicted the curated default: \(united.map(\.path))") + + var found: [URL] = [] + for root in united { + found += LocalUsageReader.jsonlFiles(in: root, modifiedSince: .distantPast) + } + XCTAssertEqual(found.count, 1, "curated hidden .claude/projects must still be scanned") + } + + func testNestedCustomRootFoldsIntoDefaultAndCountsAsZeroExtras() { + let home = tempDir() + let curatedDir = home.appendingPathComponent(".claude/projects") + let nested = curatedDir.appendingPathComponent("extra") + try? FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + XCTAssertEqual(CustomScanRoots.survivingExtraCount(defaults: [curatedDir], extraRaw: nested.path), 0, + "a path already inside a curated root is not an extra folder") + } + + func testSurvivingExtraCountIgnoresEvictedAncestor() { + let home = tempDir() + let curated = [home.appendingPathComponent(".claude/projects")] + try? FileManager.default.createDirectory(at: curated[0], withIntermediateDirectories: true) + XCTAssertEqual(CustomScanRoots.survivingExtraCount(defaults: curated, extraRaw: home.path), 0) + } + + /// `/` is an ancestor of every curated path, but `extra + "/"` is `"//"` so a naive + /// prefix check misses it and the refresh would walk the whole disk. + func testRootFilesystemCustomDoesNotEvictCuratedDefault() { + let home = tempDir() + let curated = [home.appendingPathComponent(".claude/projects")] + try? FileManager.default.createDirectory(at: curated[0], withIntermediateDirectories: true) + XCTAssertEqual(CustomScanRoots.expand("/").map(\.path), ["/"]) + let united = CustomScanRoots.union(defaults: curated, extraRaw: "/") + XCTAssertEqual(united.map(\.path), curated.map(\.path), + "custom `/` must be dropped, not used as a scan root: \(united.map(\.path))") + XCTAssertEqual(CustomScanRoots.survivingExtraCount(defaults: curated, extraRaw: "/"), 0) + } + + func testSiblingCustomRootCountsAsExtra() { + let home = tempDir() + let curated = home.appendingPathComponent(".claude/projects") + let extra = home.appendingPathComponent("elsewhere") + try? FileManager.default.createDirectory(at: curated, withIntermediateDirectories: true) + try? FileManager.default.createDirectory(at: extra, withIntermediateDirectories: true) + XCTAssertEqual(CustomScanRoots.survivingExtraCount(defaults: [curated], extraRaw: extra.path), 1) + } + + // MARK: Storage keys + + func testDefaultsKeyIsProviderScoped() { + XCTAssertEqual(CustomScanRoots.defaultsKey(for: "claude_code"), "customScanRoots.claude_code") + XCTAssertEqual(CustomScanRoots.defaultsKey(for: "codex"), "customScanRoots.codex") + XCTAssertNotEqual(CustomScanRoots.defaultsKey(for: "claude_code"), "customScanRoots") + } + + func testStoredValueDoesNotLeakAcrossProviders() { + let suite = "ptb.scan.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set("~/claude-extra", forKey: CustomScanRoots.defaultsKey(for: "claude_code")) + XCTAssertEqual(CustomScanRoots.storedValue(for: "claude_code", defaults: defaults), "~/claude-extra") + XCTAssertNil(CustomScanRoots.storedValue(for: "codex", defaults: defaults)) + XCTAssertNil(CustomScanRoots.storedValue(for: "claude_code", defaults: UserDefaults(suiteName: "ptb.empty.\(UUID().uuidString)")!)) + } + + func testBlankStoredValueIsNil() { + let suite = "ptb.scan.blank.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + defaults.set(" \n ", forKey: CustomScanRoots.defaultsKey(for: "gemini")) + XCTAssertNil(CustomScanRoots.storedValue(for: "gemini", defaults: defaults)) + } + + // MARK: UsageStore persistence + + @MainActor + func testUsageStorePersistsPerProviderAndLeavesOthersAlone() { + let suite = "ptb.store.scan.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = UsageStore(autoRefresh: false, defaults: defaults) + store.setCustomScanRoots("~/codex-extra", for: "codex") + XCTAssertEqual(store.customScanRoots(for: "codex"), "~/codex-extra") + XCTAssertEqual(store.customScanRoots(for: "gemini"), "") + XCTAssertEqual(defaults.string(forKey: "customScanRoots.codex"), "~/codex-extra") + XCTAssertNil(defaults.string(forKey: "customScanRoots"), + "generic customScanRoots key must not be written (#162-C)") + } + + // MARK: Claude seam + + func testComputeClaudeProjectRootsUnionsCustomWhenInjected() { + let home = URL(fileURLWithPath: "/Users/testhome") + let extra = tempDir() + let roots = LocalUsageReader.computeClaudeProjectRoots( + configDirValue: nil, customRootsValue: extra.path, home: home).map(\.path) + XCTAssertTrue(roots.contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + XCTAssertTrue(roots.contains("/Users/testhome/.claude/projects")) + } + + func testComputeClaudeProjectRootsDefaultCustomIsNilNotUserDefaults() { + let home = URL(fileURLWithPath: "/Users/testhome") + let roots = LocalUsageReader.computeClaudeProjectRoots(configDirValue: nil, home: home).map(\.path) + XCTAssertEqual(Set(roots).count, roots.count) + XCTAssertTrue(roots.contains("/Users/testhome/.claude/projects")) + } + + func testClaudeAncestorCustomDoesNotZeroJsonlScan() { + let home = tempDir() + let projects = home.appendingPathComponent(".claude/projects/p") + try? FileManager.default.createDirectory(at: projects, withIntermediateDirectories: true) + let line = """ + {"type":"assistant","requestId":"R","timestamp":"2026-06-30T10:00:00.000Z","message":{"id":"A","model":"claude-opus-4-8","usage":{"input_tokens":10,"output_tokens":2,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}} + """ + try? line.write(to: projects.appendingPathComponent("s.jsonl"), atomically: true, encoding: .utf8) + + let roots = LocalUsageReader.computeClaudeProjectRoots( + configDirValue: nil, customRootsValue: home.path, home: home) + let defaultProjects = home.appendingPathComponent(LocalUsageReader.defaultRelativeProjectsPath) + .resolvingSymlinksInPath().standardizedFileURL.path + XCTAssertTrue(roots.map(\.path).contains(defaultProjects), + "default .claude/projects missing: \(roots.map(\.path))") + + var files: [URL] = [] + for root in roots { + files += LocalUsageReader.jsonlFiles(in: root, modifiedSince: .distantPast) + } + XCTAssertEqual(files.count, 1, "ancestor custom root zeroed Claude usage") + } + + // MARK: Other providers pick up an extra root + + func testCodexGeminiGrokScanRootsUnionCustom() { + let home = URL(fileURLWithPath: "/Users/testhome") + let extra = tempDir() + XCTAssertTrue(LocalUsageReader.codexSessionRoots(customRootsValue: extra.path, home: home) + .map(\.path).contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + XCTAssertTrue(LocalUsageReader.geminiScanRoots(customRootsValue: extra.path, home: home) + .map(\.path).contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + XCTAssertTrue(LocalUsageReader.grokSessionRoots(customRootsValue: extra.path, home: home) + .map(\.path).contains(extra.resolvingSymlinksInPath().standardizedFileURL.path)) + } + + func testAdditionalProvidersUnionCustomOntoCurated() { + let extra = tempDir() + let resolved = extra.resolvingSymlinksInPath().standardizedFileURL.path + XCTAssertTrue(LocalAdditionalUsageReader.openCodeRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + XCTAssertTrue(LocalAdditionalUsageReader.hermesRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + XCTAssertTrue(LocalAdditionalUsageReader.cursorRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + XCTAssertTrue(LocalAdditionalUsageReader.copilotRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + XCTAssertTrue(LocalAdditionalUsageReader.kiroRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + XCTAssertTrue(LocalAntigravityUsageReader.resolvedRoots(customRootsValue: extra.path) + .map(\.path).contains(resolved)) + } + + func testNilCustomLeavesAdditionalProviderDefaultsUnchanged() { + let extra = tempDir() + let resolved = extra.resolvingSymlinksInPath().standardizedFileURL.path + XCTAssertFalse(LocalAdditionalUsageReader.openCodeRoots(customRootsValue: nil) + .map(\.path).contains(resolved)) + XCTAssertFalse(LocalAntigravityUsageReader.resolvedRoots(customRootsValue: nil) + .map(\.path).contains(resolved)) + } + + func testGeminiCacheScansMultipleRootsAndDedups() async throws { + let payload = """ + {"type":"session_metadata","sessionId":"s1","startTime":"2026-07-03T01:00:00.000Z"} + {"type":"gemini","id":"m2","timestamp":"2026-07-03T01:00:10.000Z","model":"gemini-2.5-pro","tokens":{"input":1000,"output":50,"cached":600,"thoughts":30,"tool":20,"total":1100}} + """ + let a = tempDir(), b = tempDir() + try FileManager.default.createDirectory(at: a.appendingPathComponent("h/chats"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: b.appendingPathComponent("h/chats"), withIntermediateDirectories: true) + try payload.write(to: a.appendingPathComponent("h/chats/s.jsonl"), atomically: true, encoding: .utf8) + try payload.write(to: b.appendingPathComponent("h/chats/s.jsonl"), atomically: true, encoding: .utf8) + + let entries = await LocalUsageCache( + geminiRoots: [a, b], fileURL: tempDir().appendingPathComponent("cache.json") + ).geminiEntries(modifiedSince: .distantPast) + XCTAssertEqual(entries.count, 1, "the same Gemini turn copied into a second root must dedup") + let control = await LocalUsageCache( + geminiRoot: a, fileURL: tempDir().appendingPathComponent("c.json") + ).geminiEntries(modifiedSince: .distantPast) + XCTAssertEqual(control.count, entries.count) + } + + /// Per-root prune of `codexSessionIDs` would keep only the last root's files and + /// force a full re-probe of the others on the next refresh. + func testCodexCacheKeepsSessionIndexAcrossMultipleRoots() async throws { + func writeRollout(in dir: URL, id: String) throws { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let meta = """ + {"type":"session_meta","timestamp":"2026-07-29T01:00:00.000Z","payload":{"id":"\(id)","session_id":"\(id)"}} + """ + let state = """ + {"type":"event_msg","timestamp":"2026-07-29T01:00:01.000Z","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":11},"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":1,"reasoning_output_tokens":0,"total_tokens":11}}}} + """ + try (meta + "\n" + state).write( + to: dir.appendingPathComponent("rollout-\(id).jsonl"), atomically: true, encoding: .utf8) + } + let a = tempDir(), b = tempDir() + try writeRollout(in: a, id: "sess-a") + try writeRollout(in: b, id: "sess-b") + let cache = LocalUsageCache( + codexRoots: [a, b], fileURL: tempDir().appendingPathComponent("cache.json")) + _ = await cache.codexEntries(modifiedSince: .distantPast) + let indexed = await cache.codexSessionIndexCount() + XCTAssertEqual(indexed, 2, "session-id index after a two-root scan must keep both files, got \(indexed)") + } + + /// Production cache must expand the parent closure over the union of every root, + /// matching `LocalUsageReader.codexEntries`. A fork in a custom folder whose parent + /// still lives under the curated sessions dir would otherwise keep replayed turns. + func testCodexCacheFindsParentInASiblingRoot() async throws { + func write(_ dir: URL, name: String, lines: [String], mtime: Date) throws { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let url = dir.appendingPathComponent(name) + try lines.joined(separator: "\n").write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: mtime], ofItemAtPath: url.path) + } + func sessionMeta(id: String, ts: String) -> String { + """ + {"type":"session_meta","timestamp":"\(ts)","payload":{"id":"\(id)","session_id":"\(id)"}} + """ + } + func forkMeta(id: String, parentID: String, ts: String) -> String { + """ + {"type":"session_meta","timestamp":"\(ts)","payload":{"id":"\(id)","forked_from_id":"\(parentID)","parent_thread_id":"\(parentID)","thread_source":"user"}} + """ + } + func state(ts: String, cumulativeInput: Int, cumulativeOutput: Int, + lastInput: Int, lastOutput: Int) -> String { + """ + {"type":"event_msg","timestamp":"\(ts)","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":\(cumulativeInput),"cached_input_tokens":0,"output_tokens":\(cumulativeOutput),"reasoning_output_tokens":0,"total_tokens":\(cumulativeInput + cumulativeOutput)},"last_token_usage":{"input_tokens":\(lastInput),"cached_input_tokens":0,"output_tokens":\(lastOutput),"reasoning_output_tokens":0,"total_tokens":\(lastInput + lastOutput)}}}} + """ + } + + let parentRoot = tempDir() + let forkRoot = tempDir() + try write(parentRoot, name: "rollout-real.jsonl", lines: [ + sessionMeta(id: "P-123", ts: "2026-07-29T01:00:00.000Z"), + state(ts: "2026-07-29T01:00:01.000Z", cumulativeInput: 100, cumulativeOutput: 10, + lastInput: 100, lastOutput: 10), + state(ts: "2026-07-29T01:00:03.000Z", cumulativeInput: 300, cumulativeOutput: 30, + lastInput: 200, lastOutput: 20), + ], mtime: Date(timeIntervalSince1970: 1_000)) + try write(forkRoot, name: "rollout-fork.jsonl", lines: [ + forkMeta(id: "fork", parentID: "P-123", ts: "2026-07-30T01:00:00.000Z"), + state(ts: "2026-07-30T01:00:05.000Z", cumulativeInput: 100, cumulativeOutput: 10, + lastInput: 100, lastOutput: 10), + state(ts: "2026-07-30T01:00:10.000Z", cumulativeInput: 300, cumulativeOutput: 30, + lastInput: 200, lastOutput: 20), + state(ts: "2026-07-30T01:00:15.000Z", cumulativeInput: 600, cumulativeOutput: 60, + lastInput: 300, lastOutput: 30), + ], mtime: Date(timeIntervalSince1970: 3_000)) + + let cache = LocalUsageCache( + codexRoots: [forkRoot, parentRoot], + fileURL: tempDir().appendingPathComponent("cache.json")) + let entries = await cache.codexEntries(modifiedSince: Date(timeIntervalSince1970: 2_000)) + XCTAssertEqual(entries.map(\.total), [330], + "parent in another root was not loaded; replayed turns leaked: \(entries.map(\.total))") + } + + // MARK: Wiring sweep + + /// Every registered provider must consult its own key. A flat shared list is the + /// contamination hazard #177 forbids (Gemini `allowJSON` picking up Claude `.meta.json`). + @MainActor + func testEveryRegisteredProviderConsultsItsOwnCustomScanRootsKey() throws { + let suite = "ptb.scan.sweep.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let ids = UsageStore(autoRefresh: false, defaults: defaults).registeredProviderIDs + XCTAssertFalse(ids.isEmpty) + + let sources = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar") + var corpus = "" + let enumerator = try XCTUnwrap(FileManager.default.enumerator(at: sources, includingPropertiesForKeys: nil)) + for case let url as URL in enumerator where url.pathExtension == "swift" { + corpus += (try String(contentsOf: url, encoding: .utf8)) + "\n" + } + + var missing: [String] = [] + for id in ids { + let needle = "storedValue(for: \"\(id)\")" + if !corpus.contains(needle) { + missing.append(id) + } + } + XCTAssertTrue(missing.isEmpty, "provider(s) never read customScanRoots.: \(missing)") + XCTAssertFalse(corpus.contains("\"customScanRoots\""), + "generic customScanRoots key would force a migration (#162-C)") + + var missingCurated: [String] = [] + for id in ids { + if CustomScanRoots.curatedRoots(for: id).isEmpty { + missingCurated.append(id) + } + } + XCTAssertTrue( + missingCurated.isEmpty, + "curatedRoots(for:) default: [] — Settings match count goes silent for: \(missingCurated)") + } + + /// #181 archived sessions must survive the custom-root union. Hardcoding + /// `~/.codex/sessions` alone would drop kept usage the way the CLI-only Antigravity + /// literal dropped 2.0/IDE stores. + func testCodexSessionRootsIncludeArchivedSessions() { + let home = tempDir() + let roots = LocalUsageReader.codexSessionRoots(customRootsValue: nil, home: home).map(\.path) + XCTAssertTrue(roots.contains { $0.hasSuffix(".codex/sessions") }) + XCTAssertTrue( + roots.contains { $0.hasSuffix(".codex/archived_sessions") }, + "codexSessionRoots must union computeCodexScanRoots, not only the active sessions dir") + } + + func testSettingsUsesProviderPickerAndCommitsOnSubmit() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/UI/SettingsView.swift") + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains("registeredProviders"), "Settings must pick a provider, not grow one field per provider") + XCTAssertTrue(text.contains(".onSubmit"), "commit on submit, not per keystroke (#162 rec 1)") + XCTAssertFalse(text.contains("$store.customScanRoots)"), + "direct TextField binding to the store writes on every keystroke") + XCTAssertTrue(text.contains("Task.detached") || text.contains("Task.detached(priority:"), + "match count must not run contentsOfDirectory on the main thread in body") + XCTAssertTrue(text.contains("onDisappear"), + "collapsing Advanced without blur must still commit the draft") + XCTAssertTrue(text.contains("Task.sleep") || text.contains("sleep(for:"), + "match count must debounce so Claude desktop walks are not per-keystroke") + XCTAssertTrue(text.contains(".cancel()") || text.contains("isCancelled"), + "a stale glob must not overwrite a newer draft's match count") + XCTAssertTrue(text.contains("customScanDraftOwnerID"), + "focus-loss after a picker change would write provider A's draft into B's key") + XCTAssertFalse(text.contains("setCustomScanRoots(customScanDraft, for: selectedScanProviderID)"), + "commit must use the draft owner, not the picker selection") + } + + func testSetCustomScanRootsInvalidatesAdditionalProviderCache() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/Core/UsageStore.swift") + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains("invalidateScanCache"), + "OpenCode/Cursor/Copilot/Kiro 30s cache would hide a newly added folder") + XCTAssertTrue(text.contains("invalidateProjectRootsCache"), + "Claude's 300s root cache must still drop on save") + } + + func testComputeFunctionsDefaultCustomRootsValueToNil() throws { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .appendingPathComponent("Sources/PokeTokenBar/Core/LocalUsageReader.swift") + let text = try String(contentsOf: url, encoding: .utf8) + XCTAssertTrue(text.contains("customRootsValue: String? = nil"), + "a UserDefaults.standard default would leak the developer's settings into tests") + } +} diff --git a/docs/reference/defect-log.md b/docs/reference/defect-log.md index 8f103940e..e703620e8 100644 --- a/docs/reference/defect-log.md +++ b/docs/reference/defect-log.md @@ -92,6 +92,19 @@ read_when: `LocalUsageReader.claudeProjectRoots` 한 곳에서만 한다. 임베디드 루트 탐색은 `.claude` 가 **hidden** 이라 `skipsHiddenFiles` 를 켜면 조용히 0건이 된다 — 회귀 가드 `testEmbeddedRootsFindHiddenClaudeProjectsDirs` 가 그 브랜치를 밟는다. +- **커스텀 스캔 루트는 기본 루트에 더하기만 한다.** 사용자가 지정한 조상 경로(`~`, 홈 폴더)를 + `normalizedRoots` 에 넣으면 짧은 쪽이 이기고 기본 `~/.claude/projects` 가 접혀 사라진다. + `jsonlFiles` 는 `skipsHiddenFiles` 를 쓰므로 살아남은 `~` 은 `.claude` 아래로 내려가지 않고 + 합계가 조용히 0 이 된다(#162-B). 가드: `testAncestorCustomRootDoesNotEvictCuratedDefault`. + `skipsHiddenFiles` 자체는 건드리지 않는다. 저장 키는 `customScanRoots.` — + 공용 키에 Claude 전용 값을 넣으면 일반화할 때 마이그레이션이 생긴다(#162-C, #177). + 조상 판정은 `extra + "/"` 접두사만으로 부족하다 — 커스텀이 `/` 이면 접두사가 `"//"` 가 되어 + 기본 루트를 안 접는 대신 **디스크 전체를 스캔**한다. `/` 는 모든 경로의 조상으로 취급하고 버린다. + Codex 세션-id 인덱스는 **모든 루트를 모은 뒤에** 한 번만 prune 하고, parent-closure 도 + 루트 합집합 위에서 한 번만 확장한다. 루트마다 확장하면 커스텀 폴더의 fork 가 + `~/.codex/sessions` 의 부모를 못 보고 replay 를 이중 집계한다. + OpenCode/Hermes/Cursor/Copilot/Kiro 의 30초 엔트리 캐시는 저장 시 `invalidateScanCache` 로 + 비운다 — Claude 의 300초 루트 캐시만 비우면 추가 폴더가 TTL 동안 안 보인다. - **GUI 앱은 셸 환경을 상속하지 않는다 — 환경변수로 설정되는 경로는 셸에 물어봐야 한다.** Finder/launchd 로 뜬 `.app` 의 `ProcessInfo.processInfo.environment` 에는 `~/.zshrc` 의 export 가 없다. 그래서 `CLAUDE_CONFIG_DIR` 같은 값을 프로세스 환경에서만 읽으면 **CLI·`swift test` 에서는 통과하고 배포된 앱에서만 diff --git a/docs/reference/provider-extension.md b/docs/reference/provider-extension.md index 442a5838c..9cf3f4784 100644 --- a/docs/reference/provider-extension.md +++ b/docs/reference/provider-extension.md @@ -31,6 +31,14 @@ read_when: 디렉터리에 의존하지 않도록 한다. 루트가 겹쳐도 합계는 전역 dedup 이 바로잡지만, 중복 루트는 스캔 비용을 배로 늘리므로 `normalizedRoots` 로 접는다. +- **사용자 지정 스캔 폴더** = `customScanRoots.` (Settings → Advanced). 항목은 그 + 프로바이더 리더만 읽는다. 공통 헬퍼는 `CustomScanRoots.union` — 커스텀 루트는 기본 루트에 + *더하기만* 한다. 조상 경로(`~`)가 `normalizedRoots` 로 기본 루트를 접어 없애면 + `skipsHiddenFiles` 가 `.claude` 를 못 내려가 합계가 조용히 0 이 된다(#162-B, #177). + 새 프로바이더는 `storedValue(for: "")` 를 자기 루트 함수에 연결하고, + `CustomScanRoots.curatedRoots(for:)` 에도 그 기본 루트 목록을 넣는다 — Settings 매치 카운트가 + 이 두 번째 레지스트리를 본다. 빼먹으면 `default: []` 로 카운트만 0 이 되고 스캔은 리더 쪽 + 기본값으로 돌아간다. - **append-only SQLite 사용량 스토어** (Cursor `cursorDiskKV`, Copilot `assistant_usage_events`, 앞으로 같은 형태의 세 번째 소스) = `LocalAdditionalUsageReader.scanIncrementalStores`. URL 매핑· `MAX` SQL·row query·parse 만 넘긴다. watermark 루프를 프로바이더마다 복사하지 마라 (#157). diff --git a/scripts/test-gate.sh b/scripts/test-gate.sh index ddddd9722..1d3ef8739 100755 --- a/scripts/test-gate.sh +++ b/scripts/test-gate.sh @@ -28,6 +28,7 @@ LOGIC_CORE=( "Sources/PokeTokenBar/Core/LocalUsageReader.swift" "Sources/PokeTokenBar/Core/LocalUsageCache.swift" "Sources/PokeTokenBar/Core/ModelPricing.swift" + "Sources/PokeTokenBar/Core/CustomScanRoots.swift" ) echo "▶ swift test (--enable-code-coverage)"