From a25c14123ffe101fce73c75ad1f8cd19af29bc95 Mon Sep 17 00:00:00 2001 From: charleshan7 <59214270+charleshan7@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:33:25 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E4=B8=B2=E8=A1=8C=E5=8C=96=20token?= =?UTF-8?q?=20=E5=88=B7=E6=96=B0=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=B9=B6?= =?UTF-8?q?=E5=8F=91=E7=AB=9E=E6=80=81=E6=90=9E=E5=9D=8F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 小组件 Claude 数据反复卡在旧值不更新,根因是 Agent 的并发刷新竞态。 服务是 ThreadingHTTPServer,而 reloadAllTimelines() 会同时唤醒小/中/大 三个组件来取数,Snapshot.get() 和 read_claude() 都没有锁。多个线程各自拿 同一个 refreshToken 去刷新,而每次刷新都会轮换 refreshToken——后发的那次 让先写回的那份当场作废,Keychain 里留下一个死 token,用户只能重新登录。 (claude 自己也在刷同一份凭证,是第二个对撞源。) 修复: - _REFRESH_LOCK 串行化刷新,并在锁内重读 Keychain,捡起别人刚写回的新 token,避免重复消耗 - Snapshot.get() 加双检锁,从源头掐掉并发取数 - 刷新被拒时重读 Keychain 自愈:claude 若已写回新 token 就直接用,免去重登 - refreshToken 为空时提前拦截(claude 刷新失败后会清空它),不再发那个只会 换来 "Invalid request format" 的请求 - 报错带上失败阶段和响应体,凭证真失效时下发 relogin 标记 回归测试 agent/tests/:修复前跑 test_refresh_race 是 8 次刷新 7 次被拒, 修复后 1 次刷新全部成功。 UI:stale 时卡片显示「已过期」角标(relogin 时显示「需重新登录」)+ 数据 变灰,菜单栏面板区分「登录失效去 /login」和「临时取不到」。此前过期数据 和正常数据长得一模一样,只能靠肉眼比对重置时间才能发现。 另:UsageViews.swift 补上漏掉的 import WidgetKit(原先靠 Release 全模块 优化才编得过,Debug 会炸);__pycache__ 移出版本控制;AGENTS.md 记录本次 及相关踩坑。 Co-Authored-By: Claude Fable 5 --- .gitignore | 2 + AGENTS.md | 45 ++++++++ App/UsageApp.swift | 21 ++++ Shared/UsageModel.swift | 4 +- Shared/UsageViews.swift | 53 +++++++-- agent/__pycache__/usage-agent.cpython-314.pyc | Bin 24001 -> 0 bytes agent/tests/test_refresh_race.py | 84 ++++++++++++++ agent/tests/test_refresh_selfheal.py | 50 +++++++++ agent/usage-agent.py | 106 +++++++++++++++--- 9 files changed, 340 insertions(+), 25 deletions(-) create mode 100644 AGENTS.md delete mode 100644 agent/__pycache__/usage-agent.cpython-314.pyc create mode 100644 agent/tests/test_refresh_race.py create mode 100644 agent/tests/test_refresh_selfheal.py diff --git a/.gitignore b/.gitignore index 26df3f1..3f2a179 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ DerivedData/ *.xcodeproj Config.local.xcconfig .DS_Store +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b57c9bf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# AGENTS.md — AI 用量小组件开发指南 + +给接手本项目的 AI 编程助手(Claude Code / Codex / Cursor 等)的说明。用户可读文档见 README.md;本文件只写开发者需要、但代码里看不出来的东西。 + +## 项目一句话 + +macOS 菜单栏 App + WidgetKit 桌面小组件,显示 Claude Code 与 Codex 的 5 小时 / 一周用量、Token 消耗统计和订阅性价比(「本月羊毛」)。已开源(MIT)。 + +## 架构(为什么是这个形状) + +**沙盒小组件读不到本地文件和 Keychain**——这是整个架构的成因。取数全部由非沙盒本地 Agent(`agent/usage-agent.py`,LaunchAgent `com.charles.aiusage.agent` 登录自启)完成,通过 `http://127.0.0.1:47615/usage` 供给组件和 App。改任何取数逻辑都改 Agent,不要试图让组件直接读文件。 + +- **Codex 取数**:主路是实时接口 `GET chatgpt.com/backend-api/wham/usage`(Bearer token 取自 `~/.codex/auth.json`,`ChatGPT-Account-Id` 从 id_token JWT 解出);失败回退扫 `~/.codex/sessions/**/*.jsonl` 的 `rate_limits` 快照。快照模式下"不用 Codex 数字就不动"是固有限制。 +- **Claude Code 取数**:`GET api.anthropic.com/api/oauth/usage`(header `anthropic-beta: oauth-2025-04-20`),凭证在 Keychain `Claude Code-credentials`。access token 过期用 refreshToken 刷新。**刷新会轮换 refreshToken,必须按原结构写回 Keychain,否则用户的 `claude` CLI 登录直接失效**——这是本仓库最容易造成实际损害的坑。 +- **外观/配置下发**:沙盒组件读不到本地偏好,所以主题等设置由 App GET 到 Agent 的 `/config?theme=`,Agent 随 `/usage` 下发。WidgetKit 不认 `preferredColorScheme`,Palette 是 light/dark 两套 struct 显式选色。 + +## 构建与运行 + +```bash +xcodegen generate +xcodebuild -scheme AIUsageWidget -configuration Release build +bash agent/install.sh # 装/更新 LaunchAgent;卸载: bash agent/install.sh remove +``` + +App 运行的是 `build/DerivedData/Build/Products/Release/` 下的产物;改完重建 + 重启 App 即生效。ad-hoc 签名,无需开发者账号。 + +## 关键决策与踩过的坑(勿重蹈) + +1. **Token 统计必须按 `message.id` 去重(Claude 侧)**:流式分块会把同一消息写多行,续接/compact 会把历史复制进新文件,直接累加会虚高 61%。synthetic 消息要跳过。校准基准:与 ccusage 的 message 级去重结果吻合。 +2. **Codex 的 `cached_input` 是 `input_tokens` 的子集**(OpenAI 语义),不是独立计费项。ccusage 把它当独立项加成了约 2 倍——别学它。 +3. **Codex 现役模型是 gpt-5.5 / gpt-5.6-sol**,官方牌价 $5/$0.5/$30(入/缓存/出),比老 GPT-5 档贵 4 倍,价格表别用错。 +4. **Claude refreshToken 是绝对 30 天有效期**(从 `/login` 那天起算,刷 access token 续不了它的命)。过期后 Agent 返回 last-good 数据并带 `stale: true`,UI 显示红色「已过期」角标 + 数据变灰 + 面板重登提示。诊断:`curl localhost:47615/usage` 看 claude 是否 `"stale": true`;修复只能让用户在终端 `claude` 里 `/login`。 +5. **刷新 refreshToken 必须串行——这个坑真的把用户凭证搞坏过两次。** 服务是 `ThreadingHTTPServer`,而 `reloadAllTimelines()` 会同时唤醒小/中/大三个组件来取数。早期版本 `Snapshot.get()` 和 `read_claude()` 都没锁,多个线程各拿同一个 refreshToken 去刷新,而**每次刷新都会轮换 refreshToken**,后发的那次让先写回的那份当场作废,Keychain 里就留下一个死 token,用户只能重新登录。现在靠 `_REFRESH_LOCK`(串行 + 锁内重读 Keychain)和 `Snapshot` 的双检锁兜住,回归测试见 `agent/tests/`。**动取数并发相关的代码前先跑那两个测试。** +6. **凭证坏掉的两种形态要分清**:`Refresh token expired` = 30 天到期(见第 4 条);`Refresh token not found or invalid` = 被轮换作废(第 5 条的后果)。另有一种是 `refreshToken` 变成空字符串——`claude` 自己刷新失败后会清空它,此时拿空串去刷只会换来一句莫名其妙的 `Invalid request format`,所以 `_ensure_token` 里提前拦掉并直接报 `relogin`。报错信息务必带上失败阶段(keychain/refresh/usage)和响应体,否则诊断只能靠猜。 +7. **让用户重登时,必须说明「在 Terminal.app 里用独立 CLI 登录」。** Claude 桌面版内嵌的 Claude Code(`CLAUDE_CODE_ENTRYPOINT=claude-desktop`)认证由桌面版托管,token 存进 `Claude Safe Storage`,在里面 `/login` 只更新 `~/.claude.json` 的账号信息,**一个字节都不写 `Claude Code-credentials`**——而 Agent 读的正是后者。曾因为这个让用户白登了两次。验证登录是否落到位:`security find-generic-password -s "Claude Code-credentials" | grep mdat`,时间戳是刚才即成功。 +8. 代理默认直连;用户如需代理,写 `~/.config/ai-usage-widget/config.json` 的 `proxy` 字段,不要硬编码。 + +## 数据文件 + +- Token 统计增量缓存:`~/.config/ai-usage-widget/token-cache.json`(文件级 (mtime,size) 缓存,5 分钟重算) +- UI 偏好:`~/.config/ai-usage-widget/ui.json` + +## 提交约定 + +main 分支直推,commit 信息中文为主。发布打包用 `release/package-dmg.sh`。 diff --git a/App/UsageApp.swift b/App/UsageApp.swift index 2ae90b3..6a3cee3 100644 --- a/App/UsageApp.swift +++ b/App/UsageApp.swift @@ -52,6 +52,27 @@ struct ContentView: View { .environment(\.palette, palette) } + // 取数凭证失效时 Agent 会回吐缓存(stale),提示怎么恢复 + if snapshot.claude.stale == true || snapshot.codex.stale == true { + VStack(alignment: .leading, spacing: 4) { + if snapshot.claude.stale == true { + Text(snapshot.claude.relogin == true + ? "Claude 登录已失效,正在显示缓存。终端运行 claude 后输入 /login 重新登录即可恢复。" + : "Claude 数据暂时取不到(\(snapshot.claude.error ?? "未知原因")),正在显示缓存。") + } + if snapshot.codex.stale == true { + Text(snapshot.codex.relogin == true + ? "Codex 登录已失效,正在显示缓存。重新登录 codex 即可恢复。" + : "Codex 数据暂时取不到,正在显示缓存。") + } + } + .font(.system(size: 12)).foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color.primary.opacity(0.06)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + VStack(spacing: 10) { HStack { Text("小组件外观").font(.system(size: 12)) diff --git a/Shared/UsageModel.swift b/Shared/UsageModel.swift index 41e8628..f6cc98b 100644 --- a/Shared/UsageModel.swift +++ b/Shared/UsageModel.swift @@ -19,9 +19,11 @@ struct ProductUsage: Codable, Hashable { let sevenDay: UsageWindow? let stale: Bool? let error: String? + /// 登录凭证失效,只能重新登录才能恢复(与临时取数失败区分)。 + var relogin: Bool? = nil enum CodingKeys: String, CodingKey { - case ok, plan, stale, error + case ok, plan, stale, error, relogin case fiveHour = "five_hour" case sevenDay = "seven_day" } diff --git a/Shared/UsageViews.swift b/Shared/UsageViews.swift index 1f68d6c..fce0c59 100644 --- a/Shared/UsageViews.swift +++ b/Shared/UsageViews.swift @@ -1,4 +1,5 @@ import SwiftUI +import WidgetKit // widgetRenderingMode // ---- 配色(浅/深两套,可被「外观」偏好强制覆盖)---- @@ -209,6 +210,9 @@ struct ProductHeader: View { let name: String let color: Color var plan: String? = nil + var stale: Bool = false + var relogin: Bool = false + private var staleLabel: String { relogin ? "需重新登录" : "已过期" } var body: some View { HStack(spacing: 6) { // 产品色点在全部模式中保留,方便快速区分 Claude Code 与 Codex。 @@ -230,6 +234,20 @@ struct ProductHeader: View { .opacity(0.88) } } + if stale { + if renderingMode == .fullColor { + Text(staleLabel) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 5).padding(.vertical, 1) + .background(Capsule().fill(severityColor(100))) + } else { + Text(staleLabel) + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.primary) + .opacity(0.88) + } + } Spacer(minLength: 0) } } @@ -293,11 +311,17 @@ struct SmallProduct: View { let color: Color let product: ProductUsage var body: some View { + let stale = product.stale == true + let relogin = product.relogin == true VStack(alignment: .leading, spacing: 3) { - ProductHeader(name: name, color: color, plan: product.plan) + // 小卡宽度紧张:过期时用角标顶掉套餐徽标 + ProductHeader(name: name, color: color, plan: stale ? nil : product.plan, stale: stale, relogin: relogin) if product.ok { - SmallMetric(label: "5h", window: product.fiveHour) - SmallMetric(label: "周", window: product.sevenDay) + Group { + SmallMetric(label: "5h", window: product.fiveHour) + SmallMetric(label: "周", window: product.sevenDay) + } + .opacity(stale ? 0.5 : 1) } else { ProductError(message: product.error ?? "无数据") } @@ -325,11 +349,17 @@ struct MediumColumn: View { let color: Color let product: ProductUsage var body: some View { + let stale = product.stale == true + let relogin = product.relogin == true VStack(alignment: .leading, spacing: 8) { - ProductHeader(name: name, color: color, plan: product.plan) + // 中卡列宽有限:过期时用角标顶掉套餐徽标,避免名字换行 + ProductHeader(name: name, color: color, plan: stale ? nil : product.plan, stale: stale, relogin: relogin) if product.ok { - MetricRow(label: "5 小时", window: product.fiveHour) - MetricRow(label: "一周", window: product.sevenDay) + Group { + MetricRow(label: "5 小时", window: product.fiveHour) + MetricRow(label: "一周", window: product.sevenDay) + } + .opacity(stale ? 0.5 : 1) } else { ProductError(message: product.error ?? "无数据") Spacer(minLength: 0) @@ -359,11 +389,16 @@ struct LargeSection: View { let color: Color let product: ProductUsage var body: some View { + let stale = product.stale == true + let relogin = product.relogin == true VStack(alignment: .leading, spacing: 8) { - ProductHeader(name: name, color: color, plan: product.plan) + ProductHeader(name: name, color: color, plan: product.plan, stale: stale, relogin: relogin) if product.ok { - MetricRow(label: "5 小时窗口", window: product.fiveHour, showAbsolute: true, barHeight: 8) - MetricRow(label: "一周窗口", window: product.sevenDay, showAbsolute: true, barHeight: 8) + Group { + MetricRow(label: "5 小时窗口", window: product.fiveHour, showAbsolute: true, barHeight: 8) + MetricRow(label: "一周窗口", window: product.sevenDay, showAbsolute: true, barHeight: 8) + } + .opacity(stale ? 0.5 : 1) } else { ProductError(message: product.error ?? "无数据") } diff --git a/agent/__pycache__/usage-agent.cpython-314.pyc b/agent/__pycache__/usage-agent.cpython-314.pyc deleted file mode 100644 index cc5ce4c5c23e95a8033d0e01b363c5b70ff2d7c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24001 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 Date: Thu, 30 Jul 2026 22:47:10 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E5=88=B7=E6=96=B0?= =?UTF-8?q?=E8=AF=8A=E6=96=AD=E5=B9=B6=E8=A1=A5=E5=85=A8=E9=87=8D=E7=99=BB?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/UsageApp.swift | 35 +++++++++---- agent/tests/test_refresh_selfheal.py | 6 ++- agent/tests/test_stale_failure_metadata.py | 57 ++++++++++++++++++++++ agent/usage-agent.py | 15 +++--- 4 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 agent/tests/test_stale_failure_metadata.py diff --git a/App/UsageApp.swift b/App/UsageApp.swift index 6a3cee3..7c1337e 100644 --- a/App/UsageApp.swift +++ b/App/UsageApp.swift @@ -28,6 +28,25 @@ struct ContentView: View { private var connected: Bool { snapshot.claude.ok || snapshot.codex.ok } private var palette: Palette { Palette.of(AppTheme.scheme(theme) ?? systemScheme) } + private var showRecoveryNotice: Bool { + snapshot.claude.stale == true || snapshot.claude.relogin == true || + snapshot.codex.stale == true || snapshot.codex.relogin == true + } + + private func recoveryNotice(for product: ProductUsage, name: String) -> String? { + let stale = product.stale == true + let relogin = product.relogin == true + guard stale || relogin else { return nil } + + if relogin { + let cacheStatus = stale ? "正在显示缓存。" : "当前没有可用缓存。" + if name == "Claude" { + return "Claude 登录已失效,\(cacheStatus)请在 Terminal.app 运行独立 claude CLI 并输入 /login 重新登录。" + } + return "Codex 登录已失效,\(cacheStatus)请在 Terminal.app 重新登录 codex。" + } + return "\(name) 数据暂时取不到(\(product.error ?? "未知原因")),正在显示缓存。" + } private var updatedText: String { let fmt = DateFormatter() @@ -52,18 +71,14 @@ struct ContentView: View { .environment(\.palette, palette) } - // 取数凭证失效时 Agent 会回吐缓存(stale),提示怎么恢复 - if snapshot.claude.stale == true || snapshot.codex.stale == true { + // 有缓存时说明数据已过期;无缓存但需重登时也必须给出正确恢复路径。 + if showRecoveryNotice { VStack(alignment: .leading, spacing: 4) { - if snapshot.claude.stale == true { - Text(snapshot.claude.relogin == true - ? "Claude 登录已失效,正在显示缓存。终端运行 claude 后输入 /login 重新登录即可恢复。" - : "Claude 数据暂时取不到(\(snapshot.claude.error ?? "未知原因")),正在显示缓存。") + if let notice = recoveryNotice(for: snapshot.claude, name: "Claude") { + Text(notice) } - if snapshot.codex.stale == true { - Text(snapshot.codex.relogin == true - ? "Codex 登录已失效,正在显示缓存。重新登录 codex 即可恢复。" - : "Codex 数据暂时取不到,正在显示缓存。") + if let notice = recoveryNotice(for: snapshot.codex, name: "Codex") { + Text(notice) } } .font(.system(size: 12)).foregroundColor(.secondary) diff --git a/agent/tests/test_refresh_selfheal.py b/agent/tests/test_refresh_selfheal.py index 9572389..6f8a5e0 100644 --- a/agent/tests/test_refresh_selfheal.py +++ b/agent/tests/test_refresh_selfheal.py @@ -44,7 +44,9 @@ def fake_refresh(opener, refresh_token): "accessToken": "at-old", "refreshToken": "rt-dead", "expiresAt": 0, "subscriptionType": "pro"}} r2 = m.read_claude(None) print("\n死 token 用例:", json.dumps(r2, ensure_ascii=False)) -if r2.get("ok") or not r2.get("relogin"): - print("❌ 失败:真的失效时应标记 relogin") +if (r2.get("ok") or not r2.get("relogin") + or r2.get("stage") != "refresh" + or r2.get("detail") != "Refresh token not found or invalid"): + print("❌ 失败:真的失效时应标记 relogin,并保留 refresh 阶段与响应详情") sys.exit(1) print("✅ 通过:真失效时正确标记 relogin") diff --git a/agent/tests/test_stale_failure_metadata.py b/agent/tests/test_stale_failure_metadata.py new file mode 100644 index 0000000..026801d --- /dev/null +++ b/agent/tests/test_stale_failure_metadata.py @@ -0,0 +1,57 @@ +"""缓存兜底必须保留原始失败阶段和响应详情。""" +import importlib.util +import pathlib +import sys + +AGENT = str(pathlib.Path(__file__).resolve().parent.parent / "usage-agent.py") +spec = importlib.util.spec_from_file_location("agent", AGENT) +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + +m.read_claude = lambda opener: { + "ok": False, + "error": "登录已失效,请重新登录", + "relogin": True, + "stage": "refresh", + "detail": "Refresh token not found or invalid", +} +m.read_codex = lambda opener: { + "ok": True, + "plan": "plus", + "five_hour": None, + "seven_day": None, +} + +snapshot = m.Snapshot(cache_seconds=0) +snapshot.last_good = { + "claude": { + "ok": True, + "plan": "pro", + "five_hour": None, + "seven_day": None, + }, + "codex": None, +} +snapshot._save = lambda: None + +claude = snapshot._fetch(None)["claude"] +expected = { + "stale": True, + "error": "登录已失效,请重新登录", + "relogin": True, + "stage": "refresh", + "detail": "Refresh token not found or invalid", +} +failures = [ + "%s=%r,应为 %r" % (key, claude.get(key), value) + for key, value in expected.items() + if claude.get(key) != value +] + +if failures: + print("❌ stale 失败元数据丢失:") + for failure in failures: + print(" -", failure) + sys.exit(1) + +print("✅ stale payload 保留了 error/relogin/stage/detail") diff --git a/agent/usage-agent.py b/agent/usage-agent.py index 4bbd7b7..8656f25 100644 --- a/agent/usage-agent.py +++ b/agent/usage-agent.py @@ -350,7 +350,7 @@ def read_claude(opener): acct = _keychain_account() cred = _keychain_read(acct) if not cred.get("claudeAiOauth"): - return {"ok": False, "error": "凭证格式异常"} + return {"ok": False, "error": "凭证格式异常", "stage": stage} stage = "refresh" oauth = _ensure_token(opener, acct, cred) # 调 /usage @@ -374,14 +374,17 @@ def read_claude(opener): # 刷新被拒(过期 / 已被轮换作废)只能重新登录,与临时故障区分开 if stage == "refresh" and e.code in (400, 401): return {"ok": False, "error": "登录已失效,请重新登录", "relogin": True, + "stage": stage, "detail": str(detail)[:80]} return {"ok": False, + "stage": stage, "error": "%s HTTP %s%s" % (stage, e.code, (":%s" % detail) if detail else "")} except ReloginRequired as e: return {"ok": False, "error": "登录已失效,请重新登录", "relogin": True, + "stage": stage, "detail": str(e)[:80]} except Exception as e: - return {"ok": False, "error": str(e)[:80]} + return {"ok": False, "error": str(e)[:80], "stage": stage} def _claude_win(w): # Claude 是实时接口,resets_at 始终是当前窗口(未来),无需过点归零。 @@ -691,10 +694,10 @@ def _fetch(self, opener): fail = val val = dict(self.last_good[key]) val["stale"] = True - # 兜底展示旧数据,但保留失败原因:需重新登录要能提示到用户 - val["error"] = fail.get("error") - if fail.get("relogin"): - val["relogin"] = True + # 兜底展示旧数据,但完整保留失败原因,便于区分过期与 token 轮换损坏。 + for field in ("error", "relogin", "stage", "detail"): + if field in fail: + val[field] = fail[field] if key == "claude": claude = val else: