Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e76a8e0
fix(claude): keep forked usage with its original account (#344)
chattymin Sep 25, 2026
4bfae97
fix: let collected Pokemon appearances be selected separately (#345)
chattymin Sep 25, 2026
13e3bf2
feat(save): add local auto-backup snapshots, corruption recovery, and…
KillianHzr Sep 25, 2026
78595ed
feat: add a usage recap for any week, month or year (#332)
DyRize Sep 25, 2026
b4ff5de
fix: protect legendary companions from accidental egg discard (#314) …
justinjeong5 Sep 25, 2026
f0ef5ff
feat(dex): color dex numbers by rarity and add hover feedback (#343)
IdoZ14 Sep 25, 2026
eed5b73
feat(limits): let each Claude account use its own session key (#346)
KillianHzr Sep 25, 2026
68deb09
feat(popover): color quota bars by pace (6 tiers) (#349)
LuceteYang Sep 25, 2026
fe51577
fix: show the real shiny odds in hatch notifications (#351)
heli-os Sep 25, 2026
014fcb2
fix: follow growth difficulty in the first-run egg hint (#352)
heli-os Sep 25, 2026
d44329b
fix(ui): give the Home tab a fixed height and reserve a scroller lane…
cte13 Sep 25, 2026
832154a
fix: price Claude Opus 5.5 so its usage cost is not Unavailable (#354)
cte13 Sep 25, 2026
671c1ec
fix: stop the test suite from calling live limits endpoints with the …
cte13 Sep 25, 2026
746d838
feat: pick Korean particles to match the name (#356)
heli-os Sep 25, 2026
ae7bd9d
feat(dex): add search, sorting, and filters to Pokédex and Catch Log …
KillianHzr Sep 25, 2026
20c5a38
chore: sync fork with upstream (15 PRs, incl. the fork's #353/#354/#355)
cte13 Sep 25, 2026
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
36 changes: 36 additions & 0 deletions PULL_REQUEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# feat(save): add local auto-backup snapshots, corruption recovery, and restore UI

## Summary

This PR introduces an automated local snapshot backup and recovery mechanism for `CompanionState` without modifying the core state schema or depending on external cloud providers.

Key enhancements:
- **Periodic automatic snapshots with rotation**: Takes point-in-time snapshots of the companion state into `<stateDir>/snapshots/` at an interval of at least 12 hours during regular play and saves. Retains the newest 10 snapshots automatically and prunes older ones.
- **Automatic corruption self-healing**: If `companion-state.json` is corrupted or fails to decode at startup, the app preserves the corrupted file as `.corrupt` and automatically restores from the latest valid snapshot instead of starting from a blank state.
- **Settings UI & 1-click rollback**: Adds an "Automatic Backups (Snapshots)" section within the Settings `Backup & Transfer` group:
- Instant "Create snapshot" button for manual restore points.
- Snapshot list displaying companion sprite, creation timestamp, Pokédex count, and lifetime tokens.
- "Restore" button triggering a safety confirmation dialog comparing target vs current progress, taking a pre-restore backup snapshot, and seamlessly applying the rollback.
- **Full 7-language localization & safety**: All strings are routed through `Localization.swift` across all 7 supported languages (`ko`, `en`, `ja`, `es`, `fr`, `pt`, `de`), without Hangul string literals in UI sources. `CompanionState` stored properties remain unmodified, strictly preserving `SaveTransferTests` field classification.

## Type of change

- [ ] Bug fix
- [x] New feature
- [ ] Refactor / cleanup
- [ ] Documentation
- [ ] Other:

## UI changes

| Before | After |
| ------ | ----- |
| The Settings `Backup & Transfer` section only provided manual "Export save" and "Import save" buttons requiring manual JSON file management via system file dialogs. | The Settings `Backup & Transfer` section now includes an "Automatic Backups (Snapshots)" panel with a "Create snapshot" button and a scrollable list of recent restore points showing companion thumbnails, timestamp, Pokédex count, token count, and a 1-click "Restore" button with safety confirmation. |

## Checklist

- [x] `swift build` and `swift test` pass locally
- [x] PR title and description are written in English
- [x] UI changes are described above (before/after — images optional)
- [x] No copyrighted assets, secrets, or private tooling references are committed (see [CONTRIBUTING](../CONTRIBUTING.md))
- [x] Tests were added or updated for this change
3 changes: 2 additions & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ Pi は複数のモデルを一つのセッションログに流すことがあ
- **サービス別タブ** — Claude Code・Codex・Gemini CLI・Antigravity・OpenCode・Hermes Agent・Cursor・Grok CLI・Copilot CLI・Kiro CLI・Pi Agent・omp・Aside のうち2つ以上が検出されると、小さなタブでサービス別の詳細を切替(今日の合計は合算のまま)。
- **公式の上限** — Claude・Codex・Antigravity の5時間/週間使用率とリセットのカウントダウンを、今日の数字のすぐ下に。
- **追加スキャンフォルダ** — 既定のパスの外にあるログのために、プロバイダーごとにスキャンルートを追加(設定 → 詳細)。
- **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。
- **複数の Claude アカウント** — 設定フォルダ(`CLAUDE_CONFIG_DIR`)ごとにログインした Claude Code アカウントには公式上限の下にそれぞれのタブが付き、ゲージ・ふしぎなアメ・通知・今日/今月のトークンを個別に表示します。`~/.claude-*` フォルダとエクスポートされた `CLAUDE_CONFIG_DIR` は自動で見つけ、それ以外の場所は設定 → 詳細で追加します。メニューバーの上限 %・コンパニオンの様子・予測は最後に使ったアカウントに従います(設定 → 一般 → 追跡する Claude アカウント)。アカウントごとに claude.ai セッションキーを設定でき(設定 → 詳細)、Keychain なしでもそのアカウントの上限が更新され続けます。
- **消費予測** — 現在の5時間ウィンドウが100%に達する時刻を予測。
- **アプリ内アップデート** — ワンクリックの更新確認、設定に現在のバージョンを表示。
- **使用量のふりかえり** — 週・月・年ごとに過去までさかのぼれるポケモン図鑑風のまとめ(合計・ゲージ・前の期間との比較(進行中は同じ日数どうし)・最高の日・最長連続・卒業)。記録は今年と昨年分をローカルに保存。

## 対応ツール

Expand Down
3 changes: 2 additions & 1 deletion README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,9 +208,10 @@ Pi는 모델 여러 개를 세션 로그 하나로 흘려보낼 수 있습니다
- **서비스별 탭** — Claude Code·Codex·Gemini CLI·Antigravity·OpenCode·Hermes Agent·Cursor·Grok CLI·Copilot CLI·Kiro CLI·Pi Agent·omp·Aside 중 2개 이상 감지되면 작은 탭으로 상세를 서비스별 전환(오늘 합계는 통합 유지).
- **공식 한도** — Claude·Codex·Antigravity 5시간/주간 사용률 + 리셋 카운트다운을 오늘 숫자 바로 아래에.
- **추가 스캔 폴더** — 기본 경로 밖에 있는 로그를 위해 프로바이더별로 스캔 루트를 더 지정 (설정 → 고급).
- **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정).
- **여러 Claude 계정** — 설정 폴더(`CLAUDE_CONFIG_DIR`)마다 따로 로그인한 Claude Code 계정은 공식 한도 아래에 각자의 탭이 생기고, 게이지·이상한 사탕·알림·오늘/이번 달 토큰을 따로 보여 줍니다. `~/.claude-*` 폴더와 export 된 `CLAUDE_CONFIG_DIR` 은 자동으로 찾고, 다른 위치는 설정 → 고급에서 추가합니다. 메뉴바 한도 %·컴패니언 기분·예측은 마지막으로 사용한 계정을 따릅니다(설정 → 일반 → 추적할 Claude 계정). 계정마다 claude.ai 세션 키를 따로 넣을 수 있어(설정 → 고급), Keychain 없이도 그 계정의 한도가 계속 갱신됩니다.
- **소진 예측** — 현재 5시간 창이 100%에 도달할 시각 예측.
- **인앱 업데이트** — 원클릭 업데이트 확인, 설정에 현재 버전 표시.
- **사용량 돌아보기** — 주·월·연 단위로 지난 기간까지 넘겨 보는 포켓몬 도감 스타일 요약(합계·게이지·이전 기간과 비교(진행 중이면 같은 일수끼리)·최고의 날·최장 연속·졸업). 기록은 올해와 작년분을 로컬에 보관.

## 지원 도구

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,11 +207,12 @@ Reset countdowns include the clock time, and the colored percentage stays aligne
- **Per-service tabs** — when two or more of Claude Code, Codex, Gemini CLI, Antigravity, OpenCode, Hermes Agent, Cursor, Grok CLI, Copilot CLI, Kiro CLI, Pi Agent, omp, and Aside are detected, compact tabs switch between them; today's total stays combined.
- **Official limits** — Claude, Codex & Antigravity 5-hour / weekly utilization with reset countdowns, right under today's numbers.
- **Additional scan folders** — per-provider extra scan roots for logs kept outside the built-in paths (Settings → Advanced).
- **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account).
- **Several Claude accounts** — Claude Code logins kept in their own config folder (`CLAUDE_CONFIG_DIR`) each get a tab under the official limits, with their own gauges, Rare Candy, alerts, and today/month tokens. `~/.claude-*` folders and an exported `CLAUDE_CONFIG_DIR` are found automatically; add others in Settings → Advanced. The menu bar percentage, companion mood, and forecast follow the account you used last (Settings → General → Tracked Claude account). Each account can take its own claude.ai session key (Settings → Advanced), so its limits keep refreshing without the Keychain.
- **Burn-rate forecast** — projects when the current 5h window hits 100%.
- **In-app updates** — one-click update check; current version shown in Settings.
- **iOS companion app** *(this fork)* — syncs from the Mac app via iCloud (or your local network) and shows token usage and cost (today/week/month), burn-rate forecast, rate limits per provider (Claude, Codex, OpenCode Go, Antigravity) with the Mac's own warning thresholds, your companion with its status line and evolution line, plus read-only Shop, Bag, and Collection tabs. UI localized in English, Korean, Japanese, and Spanish.
- **WidgetKit extension** *(this fork)* — a medium home-screen widget with your companion sprite, today/week usage, cost, and a utilization bar per rate-limit window (with reset countdowns) — no network required once data is synced.
- **Usage recap** — a Pokédex-style recap of any week, month or year you step back to (total, meters, comparison with the previous period, day for day while it is still running, best day, best streak, graduations). History is kept locally for this year and the last.

## Works with

Expand Down
7 changes: 3 additions & 4 deletions Sources/PokeTokenBar/Core/AntigravityRateLimitsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,7 @@ public struct AntigravityRateLimitsProvider: AntigravityLimitsProviding, Sendabl
}

private func fetchStatus(accessToken: String) async throws -> AntigravityRateLimitStatus {
// OAuthLimitsProvider.fetchStatus 와 동일 규약 — 네트워크 경계에서만 막는다(자격증명 읽기는 통과).
guard AppEnv.isBundledApp || AppEnv.isParityRun else { throw LimitsError.liveFetchNotPermitted }
guard AppEnv.allowsLiveLimitsFetch else { throw LimitsError.liveFetchNotPermitted }
var endpoints: [URL] = []
if let envURLString = UsageEnvironment.value("CLOUD_CODE_URL"),
let envURL = URL(string: envURLString + "/v1internal:retrieveUserQuotaSummary") {
Expand Down Expand Up @@ -296,7 +295,7 @@ actor AntigravityTokenCache {

static func revokeAtGoogle(refreshToken: String) async -> AntigravityRevokeResult {
// 다른 나가는 호출과 같은 경계 — 스위트가 사용자 토큰을 실제로 폐기하면 되돌릴 수 없다.
guard AppEnv.isBundledApp || AppEnv.isParityRun else {
guard AppEnv.allowsLiveLimitsFetch else {
return .failed("live call not permitted")
}
guard let (_, response) = try? await URLSession.shared.data(
Expand Down Expand Up @@ -516,7 +515,7 @@ actor AntigravityTokenCache {
guard let clientSecret, !clientSecret.isEmpty else { return .clientRejected }
// fetchStatus 와 별개의 네트워크 경계다 — 여기를 빼면 스위트가 사용자 refresh_token 을 실제로
// 소비해 새 토큰을 발급받는다(조회보다 부작용이 크다).
guard AppEnv.isBundledApp || AppEnv.isParityRun else { return .transient }
guard AppEnv.allowsLiveLimitsFetch else { return .transient }

let request = makeRefreshRequest(refreshToken: refreshToken, clientSecret: clientSecret)

Expand Down
20 changes: 15 additions & 5 deletions Sources/PokeTokenBar/Core/AppEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,25 @@ enum AppEnv {
Bundle.main.bundleIdentifier != nil && Bundle.main.bundlePath.hasSuffix(".app")
}

/// `PTB_PARITY=1` 로 지정된 QA/파리티 실행 — 라이브 엔드포인트 검증(LocalUsageParityTests)처럼
/// 실 IO 가 *허용*되는 모드. isBundledApp 게이트를 여는 방향으로만 쓴다(닫는 방향 아님).
/// CompanionStore 의 PTB_STATE_DIR 과 같은 개발/QA 전용 플래그 부류라 여기서 직독한다.
/// `PTB_PARITY=1` — a QA/parity run where live endpoints are allowed (the same flag the parity
/// smoke tests already use). Only ever opens a gate that `isBundledApp` keeps closed.
static var isParityRun: Bool {
ProcessInfo.processInfo.environment["PTB_PARITY"] == "1"
}

/// 테스트에서 URLProtocol 등 가짜 네트워크를 등록하고 fetch 를 돌릴 때 게이트 바이패스
nonisolated(unsafe) static var allowLiveFetchForTesting: Bool = false
/// Lets a test drive a provider's real network code against a stubbed transport (URLProtocol, an
/// injected URLSession). Set it in the test and reset it in `defer`.
nonisolated(unsafe) static var allowLiveFetchForTesting = false

/// Whether a limits provider may send the user's credentials to a live endpoint. Closed under
/// `swift test` and for a raw `swift build` binary: a test that does not inject a stub would
/// otherwise call Anthropic, Google or Cursor with the real login found on the machine
/// (`~/.claude/.credentials.json`, the Antigravity token file, Cursor's `state.vscdb`).
/// Put the check at the network boundary, not before the credential read, so the Keychain path
/// tests keep observing real reads. `LiveCredentialCallGateTests` requires it in every provider.
static var allowsLiveLimitsFetch: Bool {
isBundledApp || isParityRun || allowLiveFetchForTesting
}

/// 사용자 상태 파일(Application Support 등)을 실제로 읽고 쓸 것인가.
///
Expand Down
20 changes: 17 additions & 3 deletions Sources/PokeTokenBar/Core/ClaudeAccountRoots.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,14 @@ enum ClaudeAccountRoots {
savedLogins.login(file: file)?.identity
}

/// Organization of this folder's login, to pick the same one among a session key's organizations.
static func savedOrganizationID(in root: URL) -> String? {
savedLogins.login(file: root.appendingPathComponent(".claude.json"))?.organizationID
}

private struct SavedLogin {
let identity: AccountIdentity?
var organizationID: String? = nil
}

private static let savedLogins = SavedLoginCache()
Expand Down Expand Up @@ -123,7 +129,8 @@ enum ClaudeAccountRoots {
return SavedLogin(identity: nil)
}
let org = (account["organizationName"] as? String).flatMap { $0.isEmpty ? nil : $0 }
return SavedLogin(identity: AccountIdentity(email: email, organizationName: org))
let orgID = (account["organizationUuid"] as? String).flatMap { $0.isEmpty ? nil : $0 }
return SavedLogin(identity: AccountIdentity(email: email, organizationName: org), organizationID: orgID)
}
}

Expand Down Expand Up @@ -257,12 +264,17 @@ struct AdditionalClaudeLimits: Sendable {
/// The folder's token was rejected. Only Claude Code running on that folder renews it, so a
/// refresh alone cannot help: the tab shows the last values dimmed, with what to do.
var isExpired: Bool
/// The rejected credential is the folder's own session key: the fix is a fresh key in Settings,
/// not Claude Code, and a retry would only read the Keychain the key is there to avoid.
var sessionKeyExpired: Bool

init(rootPath: String, status: LimitStatus, isExpired: Bool = false, updatedAt: Date? = nil) {
init(rootPath: String, status: LimitStatus, isExpired: Bool = false, sessionKeyExpired: Bool = false,
updatedAt: Date? = nil) {
self.rootPath = rootPath
self.key = ClaudeAccountRoots.pathKey(for: URL(fileURLWithPath: rootPath))
self.status = status
self.isExpired = isExpired
self.sessionKeyExpired = sessionKeyExpired
self.updatedAt = updatedAt
}
}
Expand All @@ -282,6 +294,8 @@ struct ClaudeAccountLimits: Sendable, Identifiable {
let isDefault: Bool
let isExpired: Bool
var updatedAt: Date? = nil
/// See `AdditionalClaudeLimits.sessionKeyExpired`. The default account keeps `UsageStore.limitsAuthExpiry`.
var sessionKeyExpired = false

static func defaultAccount(_ status: LimitStatus, isExpired: Bool = false,
updatedAt: Date? = nil) -> ClaudeAccountLimits {
Expand All @@ -293,7 +307,7 @@ struct ClaudeAccountLimits: Sendable, Identifiable {
ClaudeAccountLimits(id: account.key, windowKeyPrefix: "claude.\(account.key)",
fallbackTitle: (account.rootPath as NSString).abbreviatingWithTildeInPath,
status: account.status, isDefault: false, isExpired: account.isExpired,
updatedAt: account.updatedAt)
updatedAt: account.updatedAt, sessionKeyExpired: account.sessionKeyExpired)
}

/// The account has at least one official window to show (a placeholder tab has none).
Expand Down
22 changes: 15 additions & 7 deletions Sources/PokeTokenBar/Core/ClaudeAccountUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,23 @@ enum ClaudeAccountUsageAttribution {

/// Accounts are checked in order; on equal times the first one wins.
static func owner(session: String, at date: Date, accounts: [Account]) -> String? {
owner(sessions: [session], at: date, accounts: accounts)
}

/// A copied turn can appear in both its original and fork sessions. Prefer the latest prompt
/// at or before the turn across all candidates; a fork prompt created later cannot claim it.
static func owner(sessions: [String], at date: Date, accounts: [Account]) -> String? {
var latest: (id: String, date: Date)?
var earliest: (id: String, date: Date)?
for account in accounts {
guard let times = account.prompts[session], let first = times.first else { continue }
if let before = times.last(where: { $0 <= date }), latest.map({ before > $0.date }) ?? true {
latest = (account.id, before)
}
if earliest.map({ first < $0.date }) ?? true {
earliest = (account.id, first)
for session in Set(sessions).sorted() {
guard let times = account.prompts[session], let first = times.first else { continue }
if let before = times.last(where: { $0 <= date }), latest.map({ before > $0.date }) ?? true {
latest = (account.id, before)
}
if earliest.map({ first < $0.date }) ?? true {
earliest = (account.id, first)
}
}
}
// A turn logged before any prompt of its session (clock skew) goes to the login that started it.
Expand All @@ -75,7 +83,7 @@ enum ClaudeAccountUsageAttribution {
// A block started before the month began still counts.
let inBlock = entry.date >= blockStart
guard inMonth || inBlock else { continue }
let key = entry.sessionID.flatMap { owner(session: $0, at: entry.date, accounts: accounts) } ?? unattributed
let key = owner(sessions: entry.claudeSessionIDs, at: entry.date, accounts: accounts) ?? unattributed
if inBlock, key != unattributed { recent[key, default: []].append(entry) }
guard inMonth else { continue }
month[key, default: LocalUsageReader.Bucket()].add(entry)
Expand Down
Loading