diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index f70e126..115404a 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -1,7 +1,8 @@ -import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import { createServer } from "node:net"; -import { homedir } from "node:os"; +import { homedir, userInfo } from "node:os"; import { join } from "node:path"; import type { LimitProviderId, @@ -23,6 +24,7 @@ const MAX_LINE_BYTES = 1_048_576; const MAX_BUFFER_BYTES = MAX_LINE_BYTES * 2; const MAX_WINDOWS = 12; const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage"; const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"; @@ -287,6 +289,11 @@ export class LimitsService { export interface ClaudeUsageReadOptions { configRoot?: string; + environment?: Readonly>; + platform?: NodeJS.Platform; + homeDirectory?: string; + account?: string; + keychainRequest?: (service: string, account: string) => Promise; request?: ( url: string, accessToken: string, @@ -298,8 +305,10 @@ export async function readClaudeUsage( clientVersion: string, options: ClaudeUsageReadOptions = {} ): Promise { - const configRoot = options.configRoot ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"); - const credentials = await readCredentialFile(join(configRoot, ".credentials.json"), "not-authenticated"); + const environment = options.environment ?? process.env; + const defaultConfigRoot = join(options.homeDirectory ?? homedir(), ".claude"); + const configRoot = (options.configRoot ?? environment.CLAUDE_CONFIG_DIR) || defaultConfigRoot; + const credentials = await readClaudeCredentials(configRoot, defaultConfigRoot, environment, options); const oauth = isRecord(credentials.claudeAiOauth) ? credentials.claudeAiOauth : null; const accessToken = cleanSecret(oauth?.accessToken); // Missing credentials describe only this runtime user; they do not prove anything about the user's plan. @@ -311,6 +320,59 @@ export async function readClaudeUsage( }); } +async function readClaudeCredentials( + configRoot: string, + defaultConfigRoot: string, + environment: Readonly>, + options: ClaudeUsageReadOptions +): Promise> { + if ((options.platform ?? process.platform) !== "darwin") { + return readCredentialFile(join(configRoot, ".credentials.json"), "not-authenticated"); + } + + const secureStorageRoot = Object.hasOwn(environment, "CLAUDE_SECURESTORAGE_CONFIG_DIR") + ? environment.CLAUDE_SECURESTORAGE_CONFIG_DIR || defaultConfigRoot + : configRoot; + const service = secureStorageRoot === defaultConfigRoot + ? CLAUDE_KEYCHAIN_SERVICE + : `${CLAUDE_KEYCHAIN_SERVICE}-${createHash("sha256") + .update(secureStorageRoot.normalize("NFC")) + .digest("hex") + .slice(0, 8)}`; + const account = options.account ?? userInfo().username; + const keychainRequest = options.keychainRequest ?? readMacOSClaudeCredentials; + + try { + const parsed: unknown = JSON.parse(await keychainRequest(service, account)); + if (!isRecord(parsed)) throw new LimitsAdapterError("protocol-error"); + return parsed; + } catch (error) { + if (error instanceof LimitsAdapterError) throw error; + throw new LimitsAdapterError(keychainErrorReason(error)); + } +} + +function keychainErrorReason(error: unknown): LimitUnavailableReason { + if (!isRecord(error)) return "protocol-error"; + if (error.killed === true || error.code === "ETIMEDOUT") return "timeout"; + if (error.code === 44 || error.code === "44") return "not-authenticated"; + return "protocol-error"; +} + +function readMacOSClaudeCredentials(service: string, account: string): Promise { + return new Promise((resolve, reject) => { + execFile( + "/usr/bin/security", + ["find-generic-password", "-s", service, "-a", account, "-w"], + { encoding: "utf8", maxBuffer: MAX_LINE_BYTES, timeout: REQUEST_TIMEOUT_MS }, + (error, stdout) => { + if (error) reject(error); + else resolve(stdout); + } + ); + }); +} + async function readOpenCodeGoUsage(clientVersion: string): Promise { const credentials = await readFirstCredentialFile(openCodeAuthPaths(), "subscription-required"); const go = isRecord(credentials["opencode-go"]) ? credentials["opencode-go"] : null; @@ -924,6 +986,7 @@ export function normalizeClaudeLimits(raw: unknown): LimitWindow[] { resetsAt }]; }); + } export function normalizeKimiLimits(raw: unknown): LimitWindow[] { diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index 3a3092a..b5796d0 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -678,7 +678,10 @@ function LimitRow({ row, locale, now }: { row: HomeLimitRow; locale: LocaleId; n {row.window ? ( - {formatResetCountdown(row.window.resetsAt, now, locale)} + + {formatPercent(row.window.usedPercent, locale)} + {formatResetCountdown(row.window.resetsAt, now, locale)} + diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index bd65b33..e897866 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -106,7 +106,9 @@ button { border: 0; } .limit-row { position: relative; min-width: 0; min-height: 0; padding: 3px 14px; overflow: hidden; display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: center; column-gap: 14px; border-radius: 12px; color: var(--text); background: var(--surface-soft); container: limit-row / size; } .limit-row > .provider-icon { justify-self: center; } .limit-row__metric { min-width: 0; display: grid; grid-template-rows: minmax(0, 1fr) 6px; gap: 7px; } -.limit-row__metric strong { overflow: hidden; color: var(--text); font: 900 27px/1 var(--font-mono); letter-spacing: -.045em; text-overflow: ellipsis; white-space: nowrap; } +.limit-row__summary { min-width: 0; display: flex; align-items: baseline; justify-content: space-between; gap: 10px; } +.limit-row__summary strong { flex: 0 0 auto; overflow: hidden; color: var(--text); font: 900 27px/1 var(--font-mono); letter-spacing: -.045em; text-overflow: ellipsis; white-space: nowrap; } +.limit-row__summary > span { min-width: 0; overflow: hidden; color: var(--text-muted-on-dark); font: 800 14px/1 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; } .limit-row__track { position: relative; width: 100%; height: 6px; overflow: hidden; border-radius: 999px; background: rgba(255,255,255,.12); } .limit-row__fill { position: absolute; inset: 0 auto 0 0; max-width: 100%; border-radius: inherit; background: rgba(248,247,241,.78); } .limit-row__empty { overflow: hidden; color: var(--text-muted-on-dark); font-size: 13px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } @@ -117,7 +119,9 @@ button { border: 0; } @container limit-row (max-height: 48px) { .limit-row > .provider-icon--medium { --provider-icon-art-size: 24px; width: 32px; height: 32px; } .limit-row__metric { grid-template-rows: minmax(0, 1fr) 4px; gap: 3px; } - .limit-row__metric strong { font-size: 22px; } + .limit-row__summary { gap: 7px; } + .limit-row__summary strong { font-size: 22px; } + .limit-row__summary > span { font-size: 11px; } .limit-row__track { height: 4px; } .limit-row__empty { font-size: 11px; } } diff --git a/tests/appearance-settings.test.mjs b/tests/appearance-settings.test.mjs index 75d3996..99c53c7 100644 --- a/tests/appearance-settings.test.mjs +++ b/tests/appearance-settings.test.mjs @@ -88,6 +88,8 @@ test("four or more HOME limit rows switch to bounded compact geometry", () => { assert.match(styles, /\.limit-row\s*\{[^}]*min-height:\s*0;[^}]*overflow:\s*hidden;[^}]*container:\s*limit-row\s*\/\s*size;/u); assert.match(styles, /@container\s+limit-row\s*\(max-height:\s*48px\)/u); assert.match(styles, /\.limit-row\s*>\s*\.provider-icon--medium\s*\{[^}]*--provider-icon-art-size:\s*24px;[^}]*height:\s*32px;/u); - assert.match(styles, /\.limit-row__metric strong\s*\{[^}]*font-size:\s*22px;/u); + assert.match(source, /formatPercent\(row\.window\.usedPercent,\s*locale\)[\s\S]*formatResetCountdown\(row\.window\.resetsAt,\s*now,\s*locale\)/u); + assert.match(styles, /\.limit-row__summary strong\s*\{[^}]*font-size:\s*22px;/u); + assert.match(styles, /\.limit-row__summary\s*>\s*span\s*\{[^}]*font-size:\s*11px;/u); assert.match(styles, /\.limit-row__track\s*\{[^}]*height:\s*4px;/u); }); diff --git a/tests/claude-limits-adapter.test.mjs b/tests/claude-limits-adapter.test.mjs index bb341aa..bb5268a 100644 --- a/tests/claude-limits-adapter.test.mjs +++ b/tests/claude-limits-adapter.test.mjs @@ -20,6 +20,7 @@ test("Claude limits use the current user's OAuth token and perform the usage req const result = await readClaudeUsage("1.3.0", { configRoot, + platform: "linux", request: async (url, accessToken, headers) => { request = { url, accessToken, headers }; return payload; @@ -49,11 +50,11 @@ test("missing Claude credentials mean sign-in is unavailable, not that a subscri }), "utf8"); await assert.rejects( - readClaudeUsage("1.3.0", { configRoot: missingRoot }), + readClaudeUsage("1.3.0", { configRoot: missingRoot, platform: "linux" }), (error) => error instanceof Error && error.message === "not-authenticated" ); await assert.rejects( - readClaudeUsage("1.3.0", { configRoot: emptyRoot }), + readClaudeUsage("1.3.0", { configRoot: emptyRoot, platform: "linux" }), (error) => error instanceof Error && error.message === "not-authenticated" ); } finally { @@ -63,3 +64,161 @@ test("missing Claude credentials mean sign-in is unavailable, not that a subscri ]); } }); + +test("default macOS Claude profile reads the default Keychain service for the current account", async () => { + const profileToken = ["default", "profile", "oauth"].join("-"); + let keychainQuery = null; + let requestedToken = null; + + await readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: {}, + account: "tester", + keychainRequest: async (service, account) => { + keychainQuery = { service, account }; + return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } }); + }, + request: async (_url, accessToken) => { + requestedToken = accessToken; + return { five_hour: { utilization: 7 } }; + } + }); + + assert.deepEqual(keychainQuery, { service: "Claude Code-credentials", account: "tester" }); + assert.equal(requestedToken, profileToken); +}); + +test("custom CLAUDE_CONFIG_DIR selects its normalized hashed macOS Keychain service", async () => { + const configRoot = "/test-home/Claude-Profiles/Cafe\u0301"; + const profileToken = ["custom", "profile", "oauth"].join("-"); + let keychainQuery = null; + + await readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: { CLAUDE_CONFIG_DIR: configRoot }, + account: "profile-user", + keychainRequest: async (service, account) => { + keychainQuery = { service, account }; + return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } }); + }, + request: async () => ({ five_hour: { utilization: 7 } }) + }); + + assert.deepEqual(keychainQuery, { + service: "Claude Code-credentials-49260767", + account: "profile-user" + }); +}); + +test("CLAUDE_SECURESTORAGE_CONFIG_DIR overrides the config-root Keychain service", async () => { + const secureStorageRoot = "/Volumes/Claude Credentials/profile-a"; + const profileToken = ["secure", "storage", "oauth"].join("-"); + let requestedService = null; + + await readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: { + CLAUDE_CONFIG_DIR: "/test-home/.claude-work", + CLAUDE_SECURESTORAGE_CONFIG_DIR: secureStorageRoot + }, + account: "tester", + keychainRequest: async (service) => { + requestedService = service; + return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } }); + }, + request: async () => ({ five_hour: { utilization: 7 } }) + }); + + assert.equal(requestedService, "Claude Code-credentials-6aefe1a7"); +}); + +test("empty CLAUDE_SECURESTORAGE_CONFIG_DIR explicitly selects the default Keychain service", async () => { + const profileToken = ["default", "store", "oauth"].join("-"); + let requestedService = null; + + await readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: { + CLAUDE_CONFIG_DIR: "/test-home/.claude-work", + CLAUDE_SECURESTORAGE_CONFIG_DIR: "" + }, + account: "tester", + keychainRequest: async (service) => { + requestedService = service; + return JSON.stringify({ claudeAiOauth: { accessToken: profileToken } }); + }, + request: async () => ({ five_hour: { utilization: 7 } }) + }); + + assert.equal(requestedService, "Claude Code-credentials"); +}); + +for (const platform of ["linux", "win32"]) { + test(`${platform} Claude limits keep using config-root credential files`, async () => { + const configRoot = await mkdtemp(join(tmpdir(), `canvastty-claude-${platform}-`)); + const fileToken = [platform, "file", "oauth"].join("-"); + try { + await writeFile(join(configRoot, ".credentials.json"), JSON.stringify({ + claudeAiOauth: { accessToken: fileToken } + }), "utf8"); + let requestedToken = null; + + await readClaudeUsage("1.5.0", { + platform, + environment: { + CLAUDE_CONFIG_DIR: configRoot, + CLAUDE_SECURESTORAGE_CONFIG_DIR: "/ignored/on/non-macos" + }, + keychainRequest: async () => { + throw new Error("Keychain must not be queried"); + }, + request: async (_url, accessToken) => { + requestedToken = accessToken; + return { five_hour: { utilization: 7 } }; + } + }); + + assert.equal(requestedToken, fileToken); + } finally { + await rm(configRoot, { recursive: true, force: true }); + } + }); +} + +test("macOS Keychain failures preserve missing-item, timeout, and protocol semantics", async () => { + const cases = [ + { failure: Object.assign(new Error("missing"), { code: 44 }), reason: "not-authenticated" }, + { failure: Object.assign(new Error("timed out"), { killed: true, signal: "SIGTERM" }), reason: "timeout" }, + { failure: Object.assign(new Error("security failed"), { code: 1 }), reason: "protocol-error" } + ]; + + for (const { failure, reason } of cases) { + await assert.rejects( + readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: {}, + account: "tester", + keychainRequest: async () => { throw failure; } + }), + (error) => error instanceof Error && error.message === reason + ); + } +}); + +test("malformed macOS Keychain credential JSON is a protocol error", async () => { + await assert.rejects( + readClaudeUsage("1.5.0", { + platform: "darwin", + homeDirectory: "/test-home", + environment: {}, + account: "tester", + keychainRequest: async () => "not-json" + }), + (error) => error instanceof Error && error.message === "protocol-error" + ); +});