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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ DerivedData/
*.xcodeproj
Config.local.xcconfig
.DS_Store
__pycache__/
*.pyc
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`。
21 changes: 21 additions & 0 deletions App/UsageApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,27 @@ struct ContentView: View {
.environment(\.palette, palette)
}

// 取数凭证失效时 Agent 会回吐缓存(stale),提示怎么恢复
if snapshot.claude.stale == true || snapshot.codex.stale == true {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Show re-login guidance even without cached data

When credentials are already invalid on a fresh installation or after last-good.json is removed, Snapshot returns relogin: true without stale, so this condition suppresses the recovery panel. The user then sees only the generic product error and not the required instruction to authenticate through the standalone CLI in Terminal.app; include relogin in the condition so this path cannot direct users toward the ineffective Claude Desktop login flow.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A credential failure with no last-good snapshot does not show the new menu-bar re-login guidance: Snapshot leaves stale unset when there is no cache, while this banner is entered only for stale. Including the relogin state in the no-cache UI path would preserve the distinction between login failure and a temporary fetch failure.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At App/UsageApp.swift, line 56:

<comment>A credential failure with no last-good snapshot does not show the new menu-bar re-login guidance: `Snapshot` leaves `stale` unset when there is no cache, while this banner is entered only for `stale`. Including the `relogin` state in the no-cache UI path would preserve the distinction between login failure and a temporary fetch failure.</comment>

<file context>
@@ -52,6 +52,27 @@ struct ContentView: View {
             }
 
+            // 取数凭证失效时 Agent 会回吐缓存(stale),提示怎么恢复
+            if snapshot.claude.stale == true || snapshot.codex.stale == true {
+                VStack(alignment: .leading, spacing: 4) {
+                    if snapshot.claude.stale == true {
</file context>

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))
Expand Down
4 changes: 3 additions & 1 deletion Shared/UsageModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
53 changes: 44 additions & 9 deletions Shared/UsageViews.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import SwiftUI
import WidgetKit // widgetRenderingMode

// ---- 配色(浅/深两套,可被「外观」偏好强制覆盖)----

Expand Down Expand Up @@ -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 ? "需重新登录" : "已过期" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Widget stale state only says “需重新登录”, so users viewing the widget are not told to run /login through the independent Terminal.app CLI; using the desktop app’s embedded Claude Code will not update the credentials read by the Agent. Add a visible recovery instruction or a widget-accessible path to that instruction for relogin.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Shared/UsageViews.swift, line 215:

<comment>Widget stale state only says “需重新登录”, so users viewing the widget are not told to run `/login` through the independent Terminal.app CLI; using the desktop app’s embedded Claude Code will not update the credentials read by the Agent. Add a visible recovery instruction or a widget-accessible path to that instruction for `relogin`.</comment>

<file context>
@@ -209,6 +210,9 @@ struct ProductHeader: View {
     var plan: String? = nil
+    var stale: Bool = false
+    var relogin: Bool = false
+    private var staleLabel: String { relogin ? "需重新登录" : "已过期" }
     var body: some View {
         HStack(spacing: 6) {
</file context>

var body: some View {
HStack(spacing: 6) {
// 产品色点在全部模式中保留,方便快速区分 Claude Code 与 Codex。
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -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 ?? "无数据")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ?? "无数据")
}
Expand Down
Binary file removed agent/__pycache__/usage-agent.cpython-314.pyc
Binary file not shown.
84 changes: 84 additions & 0 deletions agent/tests/test_refresh_race.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""并发刷新竞态回归测试:模拟 refreshToken 轮换的服务端语义。

服务端规则:只认当前 refreshToken,一旦被消费即轮换成新的,旧的立刻作废。
若 agent 并发刷新,第二个线程会拿着已作废的 token 去刷 → 400,
或两次都成功但写回顺序颠倒 → Keychain 留下已作废的 token(永久坏掉)。
"""
import importlib.util, threading, time, sys, json, pathlib

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)

# ---- 假 Keychain ----
KEYCHAIN = {"claudeAiOauth": {"accessToken": "at-0", "refreshToken": "rt-0",
"expiresAt": 0, "subscriptionType": "pro"}}
kc_lock = threading.Lock()

def fake_account():
return "tester"

def fake_read(acct):
with kc_lock:
return json.loads(json.dumps(KEYCHAIN)) # 深拷贝,模拟真实读取

def fake_write(acct, cred):
with kc_lock:
KEYCHAIN.clear()
KEYCHAIN.update(json.loads(json.dumps(cred)))

# ---- 假服务端:refreshToken 轮换 ----
server = {"valid_rt": "rt-0", "n": 0, "refresh_calls": 0, "rejected": 0}
srv_lock = threading.Lock()

def fake_refresh(opener, refresh_token):
with srv_lock:
server["refresh_calls"] += 1
if refresh_token != server["valid_rt"]:
server["rejected"] += 1
raise RuntimeError("Refresh token not found or invalid (拿到的是已作废的 %s)" % refresh_token)
server["n"] += 1
new_rt = "rt-%d" % server["n"]
server["valid_rt"] = new_rt
time.sleep(0.05) # 放大竞态窗口
return {"access_token": "at-%d" % server["n"], "refresh_token": new_rt, "expires_in": 3600}

def fake_open_json(opener, req, attempts=3):
return {"five_hour": {"utilization": 42, "resets_at": None},
"seven_day": {"utilization": 7, "resets_at": None}}

m._keychain_account = fake_account
m._keychain_read = fake_read
m._keychain_write = fake_write
m._refresh = fake_refresh
m._open_json = fake_open_json

# ---- 8 个线程同时取数(= reloadAllTimelines 唤醒小/中/大 + 菜单栏 App)----
results = []
def worker():
results.append(m.read_claude(None))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This regression test validates _REFRESH_LOCK, but not the Snapshot.get() serialization that is also part of the fix. Exercising concurrent Snapshot.get() calls (and counting the underlying fetch/read calls) would make the test fail if duplicate requests return.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agent/tests/test_refresh_race.py, line 60:

<comment>This regression test validates `_REFRESH_LOCK`, but not the `Snapshot.get()` serialization that is also part of the fix. Exercising concurrent `Snapshot.get()` calls (and counting the underlying fetch/read calls) would make the test fail if duplicate requests return.</comment>

<file context>
@@ -0,0 +1,84 @@
+# ---- 8 个线程同时取数(= reloadAllTimelines 唤醒小/中/大 + 菜单栏 App)----
+results = []
+def worker():
+    results.append(m.read_claude(None))
+
+threads = [threading.Thread(target=worker) for _ in range(8)]
</file context>


threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()

ok = sum(1 for r in results if r.get("ok"))
print("并发取数 8 次 → 成功 %d,刷新调用 %d 次,被服务端拒绝 %d 次"
% (ok, server["refresh_calls"], server["rejected"]))
print("Keychain 里最终 refreshToken:", KEYCHAIN["claudeAiOauth"]["refreshToken"],
"| 服务端当前有效:", server["valid_rt"])

fail = []
if server["refresh_calls"] != 1:
fail.append("刷新未串行(调用了 %d 次,应为 1 次)" % server["refresh_calls"])
if KEYCHAIN["claudeAiOauth"]["refreshToken"] != server["valid_rt"]:
fail.append("Keychain 存的 refreshToken 已作废 → 凭证永久坏掉")
if ok != 8:
fail.append("有 %d 个请求取数失败" % (8 - ok))

if fail:
print("\n❌ 失败:")
for f in fail: print(" -", f)
sys.exit(1)
print("\n✅ 通过:刷新已串行,Keychain 与服务端一致")
50 changes: 50 additions & 0 deletions agent/tests/test_refresh_selfheal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""自愈测试:Keychain 里的 refreshToken 已被 claude 自己轮换作废时,
agent 应重读 Keychain 捡起新 token,而不是直接报「请重新登录」。"""
import importlib.util, json, urllib.error, io, sys, pathlib

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)

state = {"reads": 0}
# 第一次读到的是已作废的 rt-dead;claude 随后写回了 rt-live
def fake_read(acct):
state["reads"] += 1
rt = "rt-dead" if state["reads"] == 1 else "rt-live"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The self-heal regression test does not actually test recovery from a rejected refresh: its lock-time reread replaces rt-dead with rt-live before the first refresh attempt. Keeping the dead token through the second read and returning rt-live only on the retry read would cover the behavior described by the test.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At agent/tests/test_refresh_selfheal.py, line 14:

<comment>The self-heal regression test does not actually test recovery from a rejected refresh: its lock-time reread replaces `rt-dead` with `rt-live` before the first refresh attempt. Keeping the dead token through the second read and returning `rt-live` only on the retry read would cover the behavior described by the test.</comment>

<file context>
@@ -0,0 +1,50 @@
+# 第一次读到的是已作废的 rt-dead;claude 随后写回了 rt-live
+def fake_read(acct):
+    state["reads"] += 1
+    rt = "rt-dead" if state["reads"] == 1 else "rt-live"
+    return {"claudeAiOauth": {"accessToken": "at-old", "refreshToken": rt,
+                              "expiresAt": 0, "subscriptionType": "pro"}}
</file context>

return {"claudeAiOauth": {"accessToken": "at-old", "refreshToken": rt,
"expiresAt": 0, "subscriptionType": "pro"}}

def fake_refresh(opener, refresh_token):
if refresh_token == "rt-dead":
raise urllib.error.HTTPError(
m.TOKEN_URL, 400, "Bad Request", {},
io.BytesIO(json.dumps({"error": "invalid_grant",
"error_description": "Refresh token not found or invalid"}).encode()))
return {"access_token": "at-new", "refresh_token": "rt-next", "expires_in": 3600}

m._keychain_account = lambda: "tester"
m._keychain_read = fake_read
m._keychain_write = lambda acct, cred: None
m._refresh = fake_refresh
m._open_json = lambda opener, req, attempts=3: {
"five_hour": {"utilization": 36, "resets_at": None},
"seven_day": {"utilization": 24, "resets_at": None}}

r = m.read_claude(None)
print("结果:", json.dumps(r, ensure_ascii=False))
if not r.get("ok"):
print("\n❌ 失败:没有自愈,仍然要求重新登录")
sys.exit(1)
print("\n✅ 通过:捡起了 claude 写回的新 token,无需重新登录")

# 反向用例:Keychain 里始终是死 token → 必须报「需重新登录」
state["reads"] = 99
m._keychain_read = lambda acct: {"claudeAiOauth": {
"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")
sys.exit(1)
print("✅ 通过:真失效时正确标记 relogin")
Loading