diff --git a/PRIVACY.md b/PRIVACY.md index d48a380..eea6a5d 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -18,4 +18,6 @@ Codex Desktop, its local app-server, Claude Code, and Anthropic's API remain gov Optional Codex account management reads local Codex `auth.json` for explicit registration, switching, and identity checks while refreshing registered-account usage. Replacement happens only on an explicit switch. Additional accounts use official Codex browser login in a restricted temporary home; temporary credentials are removed after import or cancellation. The separate account vault and recovery transaction are DPAPI encrypted and ACL restricted to the Windows user; the login staging directory also permits SYSTEM. They are never sent to this project's developers. DPAPI protects data at rest; it does not protect against other software already running as the same Windows user. The active Codex credential remains owned by the live authentication file: saved snapshots never override its newer tokens during recovery. Uninstall intentionally preserves the separate account vault to avoid losing saved logins. +An explicit **사용량 조회** request for an inactive account temporarily places that account's credential in a separate, user-only private home for the official local app-server. It never replaces the active login. The helper may refresh authentication while reading usage; after its confirmed exit, updated credentials are atomically saved back to the DPAPI vault even on request failure or cancellation. A DPAPI recovery journal retains the selected commit credentials before the vault write, including recovery interrupted by another crash. Temporary plaintext files are removed after verified persistence. If the process crashes or cleanup cannot finish, the restricted staging directory and journal remain until **중단된 조회 복구** completes after Codex writers stop. Each account retains only its latest successful percentages, reset times, and check time; the application does not attribute usage to people or retain a usage timeline. + When reporting a bug, do not attach Codex or Claude logs, credential/configuration files, tokens, or screenshots containing information you do not want to publish. diff --git a/README.md b/README.md index ad85269..2677ad6 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ Choose **+ 다른 계정 추가**, optionally name the account, then select ** Select the saved account and click **이 계정으로 전환**. The preparation dialog shows the source and target names and waits while you finish your work and close Codex Desktop and other Codex CLI/engine processes. **전환하고 Codex 열기** becomes available when they have stopped; cancellation keeps the current login. The widget stops its own usage helper, verifies once more that no Codex writers remain, saves the latest current login, and applies the selected login. It attempts to reopen the previously observed packaged desktop; if necessary, launch Codex from the Start menu and confirm the account there. File application and desktop login verification are separate outcomes. -Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account polling, or quota pooling. Inactive usage figures show the last observation and its time; values past their reset time are marked **갱신 필요**. A pending encrypted transaction blocks polling until **미완료 전환 복구** reconciles it with the actual live authentication; unknown third-party login changes are not overwritten. The management window supports display scaling, and its details scroll when the window is made smaller. +Select any registered account and click **사용량 조회** to fetch its latest weekly and 5-hour remaining usage, reset times, and last successful check time. **목록 갱신** only reloads saved values. Inactive accounts are queried only on an explicit click, using a short-lived official app-server in a private isolated home. Codex Desktop and the current account stay signed in; the current widget helper is not suspended. Active-account queries reuse that helper and require Codex Desktop to be open. **조회 취소**, a timeout, or a failed request preserves the last successful usage observation. Refreshed credentials are saved even if the usage request fails or is canceled. Expired logins can be renewed through **+ 다른 계정 추가** using the same inactive account; for the active account, sign in again in Codex. + +Only explicit selections cause a switch. There is no automatic quota rotation, proxy, inactive-account background polling, quota pooling, usage history, or per-person attribution. Values past their reset time are marked **갱신 필요**. A pending switch transaction blocks polling until **미완료 전환 복구** reconciles it with actual live authentication. A query interrupted before credential cleanup offers **중단된 조회 복구**; finish Codex work and close remaining Codex writers before this exceptional recovery. Its encrypted journal preserves refreshed credentials before staging is removed. Recovery never overwrites newer live authentication. The management window supports display scaling, and its details scroll when the window is made smaller. The first version supports local Windows file-based ChatGPT authentication. Unsupported keyring/managed configurations fail closed. The vault is stored separately at `%LOCALAPPDATA%\CodexWeeklyUsageIndicator.Accounts`; uninstall preserves it. Delete inactive accounts from the manager before removing the app if you no longer want their saved credentials. This convenience tool does not establish that any particular multi-account usage pattern is permitted by the service terms. diff --git a/docs/manual-accounts-design.md b/docs/manual-accounts-design.md index 5c45cf5..dee8a75 100644 --- a/docs/manual-accounts-design.md +++ b/docs/manual-accounts-design.md @@ -2,6 +2,16 @@ The existing WinForms widget owns the small usage surface and opens a separate account manager. The user selects accounts explicitly. Inactive usage is a dated observation, not a background login or synthetic combined quota. The current widget geometry and Claude source/cache contracts remain intact. +## On-demand usage (1.6) + +An explicit selected-account button reads weekly and optional 5-hour windows with the official `account/rateLimits/read` interface. List selection, list refresh, and the manager's five-second local refresh never query inactive accounts. The UI stores one successful snapshot/time, with no history, delta, or attribution. Failed and canceled requests retain that observation. The active account reuses the widget helper; an inactive account uses a separate private file-store `CODEX_HOME` without copying user configuration or passing tokens in arguments. + +Inactive requests hold the existing process-wide mutex and vault file lock on one synchronous worker thread across the async protocol operation. The UI remains responsive, and the active helper continues polling; its optional vault cache write is skipped while the query owns storage. Switching, import, rename, removal, duplicate query, and installer replacement cannot interleave with the transaction. + +Rate-limit reads can implicitly refresh authentication even with `account/read.refreshToken=false`. Query staging therefore has a separate encrypted journal, never the disposable `login-*` cleanup path. The owned helper uses a non-breakaway kill-on-close Job and must exit before credential read-back. Success, protocol failure, and cancellation all save its validated same-account credentials before removing staging. Shutdown uncertainty retains staging and journal. Before writing the vault, the chosen auth and expected prior digest are durably written to the encrypted journal; recovery can be interrupted repeatedly and the desktop can independently change accounts without reverting a committed refresh. Active live authentication remains authoritative and is never written by this path. + +Crash recovery is explicit and conservatively requires all potential Codex writers to exit, accounting for the narrow process-start/Job-assignment interval. A pending query is announced on startup and blocks account mutations, while active-account usage polling remains available. Missing/corrupt credentials or inconsistent vault revisions preserve recovery evidence and fail closed. + ## Authentication and recovery The local live authentication file is authoritative for the active account. Every switch stops the widget's own app-server, checks for remaining native Codex writers, reads the latest source credential, writes an encrypted recovery transaction, saves the source, and atomically applies the selected credential. File replacement is read back. Saved auth JSON is treated as opaque data so future fields survive. diff --git a/docs/manual-usage-delivery.md b/docs/manual-usage-delivery.md new file mode 100644 index 0000000..1ffc0a7 --- /dev/null +++ b/docs/manual-usage-delivery.md @@ -0,0 +1,21 @@ +# Manual account usage delivery — 1.6.0 + +The account manager can fetch a selected account's latest weekly and 5-hour remaining usage, reset times, and successful check time without switching the desktop login. List refresh remains local. No history, comparison, attribution, or inactive background polling is included. + +## Verification (2026-09-10) + +- Synthetic store tests cover inactive success, request failure and cancellation with rotated credentials; live auth byte preservation; selected-account observation binding; transaction exclusion; disk failure; staged identity mismatch; external activation; and interruption/recovery at durable boundaries. Repeated recovery is tested after another external login. +- App-server tests exercise initialized and canceled owned processes, account-read consistency, helper restart isolation, and parsing of a separate optional 5-hour window. +- UI tests run the actual WinForms message loop for one-click/one-request, busy actions, failure and cancellation, last-observation preservation, and zero helper suspension/resumption. Existing native focus, registration, rename, minimum-size scrolling, and scaled action-bound checks remain in the suite. Synthetic screenshots were inspected locally and are not published. +- An explicit opt-in live smoke invoked the real manager button with one inactive saved account while Codex Desktop remained running. It verified a new observation, byte-for-byte unchanged active authentication, unchanged active snapshot/account, zero suspend/resume callbacks, and removed query staging/journal after the official helper exited. No credentials, account identities, or usage values were printed or committed. +- Two inherited fork-team advisors checked authentication/persistence and UX/lifecycle/release. A separate fresh code reviewer inspected the candidate and its affected regression surfaces without implementing it. The review identified repeated-recovery durability and helper-start failure handling; fixes were adopted. Final code review recommended ready with no remaining confirmed blocker. The reviewer did not run live authentication or tests. + +## Operational boundaries + +Successful button-level live verification is engineering evidence, not a new user acceptance claim. The previous main-to-secondary switching acceptance remains separate. A new browser login was not required for this live query. + +If an inactive login has expired, use account addition to sign into that same account again. If a query is interrupted before credential persistence/cleanup, the manager offers recovery; finish work and close Codex writers before running it. No recovery or query silently replaces active authentication. + +## Release and rollback + +The tag workflow builds and packages a draft release. Publish only after checking its EXE hash against SHA256SUMS and the ZIP's EXE, lifecycle scripts, and public documents. Install through the existing per-user interactive scheduled-task path, retaining final-path validation. Rollback can reinstall 1.5.1 after any pending query is recovered in 1.6.0; resolve the new query journal before downgrading because older versions do not understand it. The account vault keeps its existing version and supports older snapshots without a 5-hour field. diff --git a/src/AccountDialogs.cs b/src/AccountDialogs.cs index 81d4ba6..1e34973 100644 --- a/src/AccountDialogs.cs +++ b/src/AccountDialogs.cs @@ -82,7 +82,7 @@ internal AccountSwitchDialog(string sourceName, string targetName, Action? asser _assertWritersStopped = assertWritersStopped ?? CodexAccountRuntime.AssertWritersStopped; Name = "AccountSwitchDialog"; AccountUiTheme.SetForm(this); - Text = recovery ? "미완료 전환 복구" : "계정 전환 준비"; + Text = recovery ? "중단된 계정 작업 복구" : "계정 전환 준비"; StartPosition = FormStartPosition.CenterParent; FormBorderStyle = FormBorderStyle.FixedDialog; MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs index dc8f232..aeb11c7 100644 --- a/src/AccountManagerForm.cs +++ b/src/AccountManagerForm.cs @@ -6,6 +6,7 @@ internal sealed class AccountManagerForm : Form private readonly Func _suspend; private readonly Action _resume; private readonly Action? _accountsChanged; + private readonly Func _queryUsage; private readonly AccountListBox _accounts = new() { Name = "AccountList", Dock = DockStyle.Fill, DisplayMember = nameof(SavedCodexAccount.Label) }; private readonly Label _count = AccountUiTheme.Label("저장된 계정"); private readonly Label _status = AccountUiTheme.Label("계정을 선택해 상태를 확인하세요."); @@ -16,6 +17,8 @@ internal sealed class AccountManagerForm : Form private readonly Label _usageTitle = AccountUiTheme.Label("주간 잔여 사용량", color: AccountUiTheme.Muted); private readonly Label _observed = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly Label _reset = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _shortRemaining = AccountUiTheme.Label("", color: AccountUiTheme.Text); + private readonly Label _shortReset = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly Label _switchHelp = AccountUiTheme.Label("", color: AccountUiTheme.Muted); private readonly AccountUsageBar _bar = new() { Dock = DockStyle.Fill }; private readonly Button _add = AccountUiTheme.Button("AddAccountButton", "+ 다른 계정 추가", true); @@ -24,7 +27,8 @@ internal sealed class AccountManagerForm : Form private readonly Button _rename = AccountUiTheme.Button("RenameAccountButton", "이름 변경"); private readonly Button _switch = AccountUiTheme.Button("SwitchAccountButton", "이 계정으로 전환", true); private readonly Button _delete = AccountUiTheme.Button("DeleteAccountButton", "저장된 로그인 삭제"); - private readonly Button _refresh = AccountUiTheme.Button("RefreshAccountsButton", "새로고침"); + private readonly Button _refresh = AccountUiTheme.Button("RefreshAccountsButton", "목록 갱신"); + private readonly Button _readUsage = AccountUiTheme.Button("ReadAccountUsageButton", "사용량 조회"); private readonly Button _recover = AccountUiTheme.Button("RecoverAccountsButton", "미완료 전환 복구"); private readonly Button _cancel = AccountUiTheme.Button("CancelLoginButton", "로그인 취소"); private readonly Panel _detail = new() { Name = "AccountDetailPanel", Dock = DockStyle.Fill, BackColor = AccountUiTheme.Surface, AutoScroll = true }; @@ -39,10 +43,12 @@ internal sealed class AccountManagerForm : Form internal bool IsOperationInProgress => _busy; private SavedCodexAccount? Selected => _accounts.SelectedItem as SavedCodexAccount; - public AccountManagerForm(CodexAccountStore store, Func suspend, Action resume, Action? accountsChanged = null) + public AccountManagerForm(CodexAccountStore store, Func suspend, Action resume, Action? accountsChanged = null, + Func? queryUsage = null) { SuspendLayout(); _store = store; _suspend = suspend; _resume = resume; _accountsChanged = accountsChanged; + _queryUsage = queryUsage ?? ((account, token) => CodexAccountUsageReader.ReadInactiveAsync(store, account.Id, token)); Name = "AccountManagerForm"; AccountUiTheme.SetForm(this); Text = "Codex 계정 관리"; @@ -92,13 +98,14 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re right.Controls.Add(_detail); right.Controls.Add(_empty); body.Controls.Add(right, 1, 0); root.Controls.Add(body, 0, 2); - var footer = AccountUiTheme.Label("저장된 계정의 사용량은 마지막 확인값입니다. 자동 전환하지 않습니다.", color: AccountUiTheme.Muted); + var footer = AccountUiTheme.Label("‘사용량 조회’로 선택한 계정의 최신 값을 확인하세요. 다른 계정은 자동 조회하지 않습니다.", color: AccountUiTheme.Muted); footer.Margin = new Padding(0, 8, 0, 0); root.Controls.Add(footer, 0, 3); Controls.Add(root); _accounts.SelectedIndexChanged += (_, _) => { if (!_reloading) ShowSelected(); }; _refresh.Click += (_, _) => { if (!_busy && Reload()) SetStatus("계정 목록을 새로 확인했습니다."); }; + _readUsage.Click += async (_, _) => await ReadSelectedUsageAsync(); _register.Click += async (_, _) => await RegisterCurrentAsync(); _registerActive.Click += async (_, _) => await RegisterCurrentAsync(); _rename.Click += (_, _) => RenameSelected(); @@ -106,7 +113,7 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re _switch.Click += async (_, _) => await SwitchSelectedAsync(); _delete.Click += (_, _) => DeleteSelected(); _recover.Click += async (_, _) => await RecoverAsync(); - _cancel.Click += (_, _) => { _loginCancellation?.Cancel(); _cancel.Enabled = false; SetStatus("로그인을 취소하고 임시 정보를 정리하고 있습니다…"); }; + _cancel.Click += (_, _) => { _loginCancellation?.Cancel(); _cancel.Enabled = false; SetStatus("요청을 취소하고 로그인 정보를 안전하게 정리하고 있습니다…"); }; KeyDown += (_, e) => { if (e.KeyCode == Keys.F2 && !_busy && Selected is not null) { e.Handled = true; RenameSelected(); } }; _refreshTimer.Tick += (_, _) => { if (!_busy && !OwnedForms.Any(f => f.Visible)) Reload(quiet: true); }; Shown += (_, _) => @@ -116,7 +123,7 @@ public AccountManagerForm(CodexAccountStore store, Func suspend, Action re Location = new Point(Math.Clamp(Left, area.Left, Math.Max(area.Left, area.Right - Width)), Math.Clamp(Top, area.Top, Math.Max(area.Top, area.Bottom - Height))); _desktopPath = CodexAccountRuntime.CaptureDesktopLaunchPath(); Reload(); _refreshTimer.Start(); }; - FormClosing += (_, e) => { if (_busy) { e.Cancel = true; SetStatus("진행 중인 작업이 있습니다. 로그인 중이라면 ‘로그인 취소’를 눌러주세요."); } }; + FormClosing += (_, e) => { if (_busy) { e.Cancel = true; SetStatus("진행 중인 작업이 있습니다. 취소 버튼으로 요청을 마친 뒤 닫아주세요."); } }; FormClosed += (_, _) => _refreshTimer.Stop(); ResumeLayout(performLayout: true); } @@ -134,12 +141,12 @@ private void BuildDetail() { var layout = AccountUiTheme.Stack(7); layout.Dock = DockStyle.Top; - layout.MinimumSize = new Size(0, 428); - layout.Height = 428; + layout.MinimumSize = new Size(0, 496); + layout.Height = 496; layout.Padding = new Padding(22); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 50)); - layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 166)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 234)); layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); @@ -154,13 +161,18 @@ private void BuildDetail() _state.Dock = DockStyle.Fill; _identity.Dock = DockStyle.Fill; identity.Controls.Add(_state, 0, 0); identity.Controls.Add(_identity, 0, 1); layout.Controls.Add(identity, 0, 1); - var usage = AccountUiTheme.Stack(5); + var usage = AccountUiTheme.Stack(7); usage.BackColor = AccountUiTheme.Raised; usage.Padding = new Padding(16, 10, 16, 10); - foreach (var height in new[] { 25, 47, 10, 28, 28 }) usage.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); - usage.Controls.Add(_usageTitle, 0, 0); + foreach (var height in new[] { 36, 47, 10, 28, 28, 30, 28 }) usage.RowStyles.Add(new RowStyle(SizeType.Absolute, height)); + var usageHeader = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; + _usageTitle.Dock = DockStyle.Fill; _usageTitle.AutoSize = false; _usageTitle.TextAlign = ContentAlignment.MiddleLeft; + _readUsage.Dock = DockStyle.Right; _readUsage.MinimumSize = new Size(98, 32); _readUsage.Padding = new Padding(8, 0, 8, 0); + usageHeader.Controls.Add(_usageTitle); usageHeader.Controls.Add(_readUsage); + usage.Controls.Add(usageHeader, 0, 0); usage.Controls.Add(_remaining, 0, 1); usage.Controls.Add(_bar, 0, 2); _observed.Dock = DockStyle.Fill; _observed.TextAlign = ContentAlignment.BottomLeft; usage.Controls.Add(_observed, 0, 3); usage.Controls.Add(_reset, 0, 4); + usage.Controls.Add(_shortRemaining, 0, 5); usage.Controls.Add(_shortReset, 0, 6); layout.Controls.Add(usage, 0, 2); _switchHelp.Dock = DockStyle.Fill; _switchHelp.AutoSize = false; layout.Controls.Add(_switchHelp, 0, 4); @@ -214,7 +226,7 @@ private async Task AddAccountAsync() await RunAsync(true, "브라우저에서 추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.", async () => { using var cancellation = new CancellationTokenSource(); - _loginCancellation = cancellation; _cancel.Visible = true; _cancel.Enabled = true; + _loginCancellation = cancellation; _cancel.Text = "로그인 취소"; _cancel.Visible = true; _cancel.Enabled = true; try { using var login = await CodexAccountRuntime.LoginAsync(_store.RootPath, cancellation.Token); @@ -226,6 +238,33 @@ await RunAsync(true, "브라우저에서 추가할 계정으로 로그인하세 }); } + private async Task ReadSelectedUsageAsync() + { + if (_busy || Selected is not { } account) return; + await RunAsync(false, $"‘{account.Label}’ 사용량을 조회하고 있습니다…", async () => + { + using var cancellation = new CancellationTokenSource(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellation.Token, timeout.Token); + _loginCancellation = cancellation; _cancel.Text = "조회 취소"; _cancel.Visible = true; _cancel.Enabled = true; + try + { + await _queryUsage(account, linked.Token); + Reload(account.Id); + SetStatus($"‘{account.Label}’ 사용량을 확인했습니다.", success: true); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested && !cancellation.IsCancellationRequested) + { throw new TimeoutException("조회 시간이 초과되었습니다. 마지막 확인값은 유지됩니다. 잠시 후 다시 시도하세요."); } + catch (UsageHelperShutdownException) { throw; } + catch (CodexUsageAuthenticationException) + { throw new InvalidOperationException(account.IsActive ? "현재 로그인을 갱신할 수 없습니다. Codex 앱에서 다시 로그인한 뒤 조회해 주세요." + : "저장된 로그인을 갱신할 수 없습니다. ‘다른 계정 추가’에서 같은 계정으로 다시 로그인해 주세요."); } + catch (IOException) + { throw new InvalidOperationException("사용량을 가져오지 못했습니다. 마지막 확인값은 유지됩니다. 연결을 확인한 뒤 다시 시도하세요."); } + finally { _loginCancellation = null; } + }, acquireGate: account.IsActive, canceledMessage: "사용량 조회를 취소했습니다. 마지막 확인값은 유지됩니다."); + } + private async Task SwitchSelectedAsync() { if (_busy || Selected is not { IsActive: false } target) return; @@ -262,12 +301,13 @@ await RunAsync(true, "복구 조건을 확인하고 있습니다…", async () = if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("복구를 취소했습니다. 미완료 기록은 보존됩니다."); return; } if (_store.IsEnabled) CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); _store.Recover(CodexAccountRuntime.AssertWritersStopped); - Reload(); _accountsChanged?.Invoke(); SetStatus("미완료 전환을 복구했습니다.", success: true); + Reload(); _accountsChanged?.Invoke(); SetStatus("중단된 계정 작업을 복구했습니다.", success: true); await Task.CompletedTask; }); } - private async Task RunAsync(bool suspend, string message, Func action) + private async Task RunAsync(bool suspend, string message, Func action, bool acquireGate = true, + string canceledMessage = "로그인을 취소했습니다. 현재 계정은 유지됩니다.") { if (_busy) return; _busy = true; UpdateActions(); _progress.Visible = true; SetStatus(message); @@ -277,11 +317,14 @@ private async Task RunAsync(bool suspend, string message, Func action) { _desktopPath ??= CodexAccountRuntime.CaptureDesktopLaunchPath(); if (suspend) await _suspend(); - try { ownsGate = gate.WaitOne(0); } catch (AbandonedMutexException) { ownsGate = true; } - if (!ownsGate) throw new InvalidOperationException("설치 또는 다른 계정 작업이 진행 중입니다. 완료 후 다시 시도하세요."); + if (acquireGate) + { + try { ownsGate = gate.WaitOne(0); } catch (AbandonedMutexException) { ownsGate = true; } + if (!ownsGate) throw new InvalidOperationException("설치 또는 다른 계정 작업이 진행 중입니다. 완료 후 다시 시도하세요."); + } await action(); } - catch (OperationCanceledException) { SetStatus("로그인을 취소했습니다. 현재 계정은 유지됩니다."); } + catch (OperationCanceledException) { SetStatus(canceledMessage); } catch (Exception ex) { SetStatus(ex.Message, error: true); } finally { @@ -317,9 +360,10 @@ private bool Reload(string? selectId = null, bool quiet = false) _empty.Visible = _items.Count == 0; _detail.Visible = _items.Count > 0; ShowSelected(); UpdateActions(); if (_store.HasPendingRecovery) SetStatus("미완료 전환이 있습니다. 복구를 완료하면 다시 사용할 수 있습니다.", error: true); + else if (_store.HasPendingUsageQuery) SetStatus("중단된 사용량 조회가 있습니다. 복구하여 로그인 정보를 보존해 주세요.", error: true); else if (_items.Count > 0 && !_items.Any(a => a.IsActive)) SetStatus("현재 로그인은 아직 등록되지 않았습니다. 전환하려면 현재 계정을 먼저 등록하세요."); else if (!quiet && _items.Count == 0) SetStatus("현재 계정을 먼저 등록하세요. 이름은 자동으로 지정됩니다."); - return !_store.HasPendingRecovery && (_items.Count == 0 || _items.Any(a => a.IsActive)); + return !_store.HasPendingRecovery && !_store.HasPendingUsageQuery && (_items.Count == 0 || _items.Any(a => a.IsActive)); } catch (Exception ex) { SetStatus(ex.Message, error: true); return false; } } @@ -330,12 +374,16 @@ private void ShowSelected() _title.Text = account.Label; _state.Text = account.IsActive ? "● 현재 사용 중" : "저장된 계정"; _identity.Text = account.IdentityHint; - _usageTitle.Text = account.IsActive ? "주간 잔여 사용량" : "주간 잔여 사용량 · 저장된 값"; + _usageTitle.Text = "주간 잔여 사용량"; var expired = account.Usage?.ResetsAt is { } resetAt && resetAt <= DateTimeOffset.Now; _remaining.Text = expired ? "갱신 필요" : account.Usage is { } usage ? $"{Math.Clamp(100 - usage.UsedPercent, 0, 100)}%" : "미확인"; _bar.Remaining = !expired && account.Usage is { } snapshot ? Math.Clamp(100 - snapshot.UsedPercent, 0, 100) : null; _observed.Text = account.ObservedAt is { } observed ? $"마지막 확인 {observed.ToLocalTime():MM-dd HH:mm}" : "아직 사용량을 확인하지 않았습니다."; - _reset.Text = expired ? "초기화 시점이 지났습니다. 이 계정 사용 시 다시 확인합니다." : account.Usage?.ResetsAt is { } reset ? $"초기화 예정 {reset.ToLocalTime():MM-dd HH:mm}" : "초기화 예정 —"; + _reset.Text = expired ? "초기화 시점이 지났습니다. ‘사용량 조회’로 확인하세요." : account.Usage?.ResetsAt is { } reset ? $"초기화 예정 {reset.ToLocalTime():MM-dd HH:mm}" : "초기화 예정 —"; + var shortWindow = account.Usage?.ShortWindow; + var shortExpired = shortWindow?.ResetsAt is { } shortReset && shortReset <= DateTimeOffset.Now; + _shortRemaining.Text = shortExpired ? "5시간 잔여 갱신 필요" : shortWindow is null ? "5시간 잔여 정보 없음" : $"5시간 잔여 {100 - shortWindow.UsedPercent}%"; + _shortReset.Text = shortWindow?.ResetsAt is { } shortAt ? $"5시간 초기화 {shortAt.ToLocalTime():MM-dd HH:mm}" : "5시간 초기화 —"; _switchHelp.Text = account.IsActive ? "이 계정을 사용하고 있습니다. 이름은 언제든 바꿀 수 있습니다." : "전환 준비 화면에서 Codex 종료 상태를 확인합니다."; _switch.Text = account.IsActive ? "현재 사용 중인 계정" : "이 계정으로 전환"; UpdateActions(); @@ -343,9 +391,10 @@ private void ShowSelected() private void UpdateActions() { - var pending = _store.HasPendingRecovery; + var pending = _store.HasPendingRecovery || _store.HasPendingUsageQuery; _accounts.Enabled = !_busy; _refresh.Enabled = !_busy; + _readUsage.Enabled = !_busy && !pending && Selected is not null; _add.Enabled = !_busy && !pending && _items.Count > 0; _register.Enabled = !_busy && !pending; _registerActive.Visible = _items.Count > 0 && !_items.Any(a => a.IsActive) && !pending; @@ -354,6 +403,7 @@ private void UpdateActions() _switch.Enabled = !_busy && !pending && _items.Any(a => a.IsActive) && Selected is { IsActive: false }; _delete.Enabled = !_busy && !pending && Selected is { IsActive: false }; _recover.Visible = pending; _recover.Enabled = !_busy; + _recover.Text = _store.HasPendingUsageQuery ? "중단된 조회 복구" : "미완료 전환 복구"; } private void SetStatus(string message, bool error = false, bool success = false) diff --git a/src/AppServerClient.cs b/src/AppServerClient.cs index bfe0219..401915b 100644 --- a/src/AppServerClient.cs +++ b/src/AppServerClient.cs @@ -6,6 +6,7 @@ namespace WeeklyUsageIndicator; // account/read exposes no workspace/user ID. Email corroborates a session; it is not a full identity proof. internal sealed record CodexAccountUsage(UsageSnapshot Usage, string? Email, string? PlanType, bool IsChatGpt); +internal sealed class CodexUsageAuthenticationException : IOException; /// Owns one serialized, cancellable app-server session. No session owns another session's state. internal sealed class AppServerClient : IDisposable @@ -13,6 +14,7 @@ internal sealed class AppServerClient : IDisposable private readonly object _stateLock = new(); private readonly SemaphoreSlim _operationGate = new(1, 1); private readonly Func _startInfoFactory; + private readonly bool _ownJob; private CancellationTokenSource _generation = new(); private Session? _session; private bool _suspended; @@ -21,8 +23,9 @@ internal sealed class AppServerClient : IDisposable public AppServerClient() : this(CreateStartInfo) { } - // Test seam: fake stdio server only; production always uses the official local executable. - internal AppServerClient(Func startInfoFactory) => _startInfoFactory = startInfoFactory; + // Explicit isolated home in production; fake stdio server in tests. + internal AppServerClient(Func startInfoFactory, bool ownJob = false) + { _startInfoFactory = startInfoFactory; _ownJob = ownJob; } public async Task GetWeeklyUsageAsync(CancellationToken cancellationToken) => (await ReadUsageAsync(includeAccount: false, cancellationToken).ConfigureAwait(false)).Usage; @@ -83,13 +86,31 @@ private async Task EnsureStartedAsync(CancellationToken token) ThrowIfUnavailable(); token.ThrowIfCancellationRequested(); var process = new Process { StartInfo = _startInfoFactory(), EnableRaisingEvents = true }; + CodexLoginJob? job = _ownJob ? new CodexLoginJob(allowChildBreakaway: false) : null; + var started = false; try { if (!process.Start()) throw new IOException("Codex app-server could not be started."); - session = new Session(process); + started = true; + job?.Attach(process); + session = new Session(process, job); _session = session; } - catch { process.Dispose(); throw; } + catch + { + job?.Dispose(); + try + { + if (started && !process.HasExited) + { + process.Kill(entireProcessTree: true); + if (!process.WaitForExit(8000)) throw new UsageHelperShutdownException(); + } + } + catch { throw new UsageHelperShutdownException(); } + finally { process.Dispose(); } + throw; + } } session.Reader = ReadLoopAsync(session); @@ -98,7 +119,7 @@ private async Task EnsureStartedAsync(CancellationToken token) { await CallCoreAsync(session, "initialize", new { - clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.5.1" }, + clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.6.0" }, capabilities = new { experimentalApi = true } }, token).ConfigureAwait(false); await SendLineAsync(session, JsonSerializer.Serialize(new { method = "initialized" }), token).ConfigureAwait(false); @@ -167,9 +188,16 @@ private static async Task ReadLoopAsync(Session session) if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("id", out var idElement) || idElement.ValueKind != JsonValueKind.Number || !idElement.TryGetInt32(out var id) || !session.Pending.TryGetValue(id, out var completion)) continue; - if (root.TryGetProperty("error", out _)) + if (root.TryGetProperty("error", out var error)) + { // Protocol errors can include sensitive server context. Never surface the raw payload. - completion.TrySetException(new IOException("Codex could not read account usage. Check the account login.")); + var message = error.ValueKind == JsonValueKind.Object && error.TryGetProperty("message", out var errorMessage) + && errorMessage.ValueKind == JsonValueKind.String ? errorMessage.GetString() ?? "" : ""; + var authFailure = new[] { "401", "unauthorized", "authentication required", "refresh_token", "token expired" } + .Any(term => message.Contains(term, StringComparison.OrdinalIgnoreCase)); + completion.TrySetException(authFailure ? new CodexUsageAuthenticationException() : + new IOException("Codex could not read account usage. Check the account login.")); + } else if (root.TryGetProperty("result", out var result)) completion.TrySetResult(result.Clone()); else completion.TrySetException(new InvalidDataException("Codex returned an incomplete response.")); @@ -235,6 +263,7 @@ private async Task StopSessionAsync() var session = _session; if (session is null) return; session.Lifetime.Cancel(); + session.Job?.Dispose(); try { session.Process.StandardInput.Close(); } catch { } try { @@ -275,9 +304,10 @@ public void Dispose() // Keep gates and generation valid for callers already unwinding their canceled operation. } - private sealed class Session(Process process) + private sealed class Session(Process process, CodexLoginJob? job) { internal readonly Process Process = process; + internal readonly CodexLoginJob? Job = job; internal readonly CancellationTokenSource Lifetime = new(); internal readonly ConcurrentDictionary> Pending = new(); internal Task Reader = Task.CompletedTask; @@ -313,7 +343,9 @@ private static UsageSnapshot ParseWeeklyUsage(JsonElement response) Math.Clamp(weekly.Used, 0, 100), resetsAt, weekly.Duration, - limitId); + limitId, + windows.Where(w => w.Duration == 300 && w.Used is >= 0 and <= 100).Select(w => new UsageWindow( + w.Used, w.ResetsAt is > 0 ? DateTimeOffset.FromUnixTimeSeconds(w.ResetsAt.Value) : null, w.Duration)).FirstOrDefault()); } private static JsonElement SelectCoreSnapshot(JsonElement response) diff --git a/src/CodexAccountRuntime.cs b/src/CodexAccountRuntime.cs index 58babfb..16ce5df 100644 --- a/src/CodexAccountRuntime.cs +++ b/src/CodexAccountRuntime.cs @@ -307,7 +307,7 @@ internal sealed class CodexLoginJob : IDisposable { private readonly SafeFileHandle _handle; - internal CodexLoginJob() + internal CodexLoginJob(bool allowChildBreakaway = true) { // Null security attributes make this private unnamed handle noninheritable. _handle = CreateJobObjectW(IntPtr.Zero, null); @@ -322,7 +322,7 @@ internal CodexLoginJob() { // KILL_ON_JOB_CLOSE | SILENT_BREAKAWAY_OK: own the login process, // while allowing its browser launcher/children to live independently. - LimitFlags = 0x00002000 | 0x00001000 + LimitFlags = 0x00002000 | (allowChildBreakaway ? 0x00001000u : 0u) } }; if (!SetInformationJobObject(_handle, 9, ref limits, (uint)Marshal.SizeOf())) diff --git a/src/CodexAccountStore.cs b/src/CodexAccountStore.cs index 37d138d..ad4d9fc 100644 --- a/src/CodexAccountStore.cs +++ b/src/CodexAccountStore.cs @@ -10,6 +10,10 @@ namespace WeeklyUsageIndicator; internal sealed record AccountIdentity(string Key, string Hint); +internal sealed class AccountStoreBusyException : InvalidOperationException +{ + internal AccountStoreBusyException() : base("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요.") { } +} internal sealed record SavedCodexAccount(string Id, string Label, string IdentityHint, bool IsActive, UsageSnapshot? Usage, DateTimeOffset? ObservedAt); @@ -17,7 +21,7 @@ internal sealed record SavedCodexAccount(string Id, string Label, string Identit /// Opt-in, local-only account storage. Live auth.json is authoritative for the active account. /// This class never refreshes a token, starts Codex, changes config, or reads Claude credentials. /// -internal sealed class CodexAccountStore +internal sealed partial class CodexAccountStore { internal const string TransactionMutexName = @"Local\CodexWeeklyUsageIndicator.AccountTransaction"; private const int MaxAuthBytes = 1024 * 1024; @@ -142,6 +146,7 @@ public void Recover(Action assertStopped) ArgumentNullException.ThrowIfNull(assertStopped); CheckSupportedStore(); using var gate = AcquireLock(); + if (HasPendingUsageQuery) RecoverUsageQueryCore(assertStopped); if (!HasPendingRecovery) return; assertStopped(); var journal = ReadEncrypted(_journalPath); @@ -196,8 +201,12 @@ public void Remove(string id) public void SaveUsage(string identityKey, UsageSnapshot snapshot) { - if (!IsEnabled || HasPendingRecovery) return; - using var gate = AcquireLock(); + // An isolated query owns the vault briefly; live polling may keep its in-memory value. + if (!IsEnabled || HasPendingRecovery || HasPendingUsageQuery) return; + IDisposable gate; + try { gate = AcquireLock(); } + catch (AccountStoreBusyException) { return; } + using var ownedGate = gate; RequireNoRecovery(); if (GetCurrentIdentity().Key != identityKey) return; var vault = LoadVault(); @@ -285,6 +294,7 @@ private static Vault Clone(Vault vault) => JsonSerializer.Deserialize( private void RequireNoRecovery() { if (HasPendingRecovery) throw new InvalidOperationException("미완료 계정 교체를 먼저 복구하세요."); + if (HasPendingUsageQuery) throw new InvalidOperationException("중단된 사용량 조회를 먼저 복구하세요."); } private void RequireSameAuth(byte[] expected) @@ -414,7 +424,7 @@ private IDisposable AcquireLock() if (!acquired) { mutex.Dispose(); - throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + throw new AccountStoreBusyException(); } try { @@ -435,7 +445,7 @@ private IDisposable AcquireLock() } catch (IOException) { - throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + throw new AccountStoreBusyException(); } } catch diff --git a/src/CodexAccountUsageQuery.cs b/src/CodexAccountUsageQuery.cs new file mode 100644 index 0000000..aec201e --- /dev/null +++ b/src/CodexAccountUsageQuery.cs @@ -0,0 +1,173 @@ +using System.Runtime.ExceptionServices; +using System.Security.AccessControl; +using System.Text; + +namespace WeeklyUsageIndicator; + +internal sealed partial class CodexAccountStore +{ + private string UsageJournalPath => Path.Combine(RootPath, "usage-query.dpapi"); + private string UsageHome => Path.Combine(RootPath, "usage-query"); + public bool HasPendingUsageQuery => File.Exists(UsageJournalPath); + + // The worker owns the thread-affine Windows mutex for the entire transaction. + // Never await inside this method. No API on this path writes the live auth file. + internal void QueryInactiveUsage(string id, Func> read, + CancellationToken cancellationToken) + { + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + cancellationToken.ThrowIfCancellationRequested(); + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == id) ?? throw CorruptStore(); + if (File.Exists(AuthPath) && GetCurrentIdentity().Key == entry.Key) + throw new InvalidOperationException("현재 계정은 활성 사용량 연결로 조회해야 합니다."); + RejectReparsePath(UsageHome); + if (Directory.Exists(UsageHome)) + throw new InvalidOperationException("이전 조회 폴더가 남아 있습니다. 계정 저장소를 확인하세요."); + var journal = new UsageQueryJournal(1, id, entry.Key, Digest(entry.Auth), false, false); + WriteEncrypted(UsageJournalPath, journal); + Checkpoint?.Invoke("usage-journal-written"); + FileSystemAclExtensions.Create(new DirectoryInfo(UsageHome), PrivateDirectorySecurity()); + WriteAtomic(Path.Combine(UsageHome, "config.toml"), Encoding.UTF8.GetBytes("cli_auth_credentials_store = \"file\"\n")); + WriteAtomic(Path.Combine(UsageHome, "auth.json"), entry.Auth); + journal = journal with { Prepared = true }; + WriteEncrypted(UsageJournalPath, journal); + Checkpoint?.Invoke("usage-staged"); + + CodexAccountUsage? result = null; + Exception? failure = null; + try { result = read(UsageHome, cancellationToken).GetAwaiter().GetResult(); } + catch (UsageHelperShutdownException) { throw; } // Keep staging until the owned writer is certainly gone. + catch (Exception ex) { failure = ex; } + Checkpoint?.Invoke("usage-helper-stopped"); + // The reader contract requires confirmed helper exit, even on failure/cancel. + // Commit refreshed auth before reporting an unsuccessful usage request. + CompleteUsageQuery(journal, result?.IsChatGpt == true ? result.Usage : null, recovery: false); + if (failure is not null) ExceptionDispatchInfo.Capture(failure).Throw(); + if (result?.IsChatGpt != true) + throw new InvalidOperationException("저장된 계정의 ChatGPT 로그인을 확인할 수 없습니다. 다시 로그인해 주세요."); + } + + private void RecoverUsageQueryCore(Action assertStopped) + { + // A parent crash can race the Job's process termination. In this exceptional + // path require all potential writers to be gone before reclaiming any auth. + assertStopped(); + var journal = ReadEncrypted(UsageJournalPath); + CompleteUsageQuery(journal, null, recovery: true); + } + + private void CompleteUsageQuery(UsageQueryJournal journal, UsageSnapshot? usage, bool recovery) + { + if (journal.Version != 1 || !Guid.TryParseExact(journal.AccountId, "N", out _) || + journal.BeforeDigest.Length != 64) throw CorruptStore(); + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == journal.AccountId && a.Key == journal.Key) + ?? throw CorruptStore(); + if (!journal.Committed) + { + var stageAuthPath = Path.Combine(UsageHome, "auth.json"); + byte[]? refreshed = journal.CommitAuth ?? (File.Exists(stageAuthPath) ? ReadBounded(stageAuthPath, MaxAuthBytes) : null); + if (journal.Prepared && refreshed is null) throw CorruptStore(); + if (refreshed is not null && ParseIdentity(refreshed).Key != entry.Key) throw CorruptStore(); + var current = File.Exists(AuthPath) ? GetCurrentIdentity() : null; + if (current?.Key == entry.Key) + { + if (!recovery) + throw new InvalidOperationException("조회 중 대상 계정이 현재 로그인으로 변경되었습니다. 현재 로그인은 보존했으며 조회 복구가 필요합니다."); + refreshed = ReadBounded(AuthPath, MaxAuthBytes); // Live auth always wins; never write it. + usage = null; + } + // A repeated recovery may observe the vault write that preceded a crash, + // including a newer live credential adopted by an earlier recovery. + else if (Digest(entry.Auth) != journal.BeforeDigest && + (refreshed is null || Digest(entry.Auth) != Digest(refreshed))) throw CorruptStore(); + var beforeCommitDigest = Digest(entry.Auth); + if (refreshed is not null) entry.Auth = refreshed; + if (usage is not null) + { + if (usage.UsedPercent is < 0 or > 100 || usage.LimitId.Length > 200 || + usage.ShortWindow?.UsedPercent is < 0 or > 100) throw CorruptStore(); + entry.Usage = usage; + entry.ObservedAt = DateTimeOffset.UtcNow; + } + // Preserve the chosen auth itself, not only a digest: an external login + // may move away again before a crashed recovery's vault write lands. + journal = journal with { BeforeDigest = beforeCommitDigest, CommitAuth = entry.Auth }; + WriteEncrypted(UsageJournalPath, journal); + Checkpoint?.Invoke("usage-commit-prepared"); + WriteEncrypted(_vaultPath, vault); + // Read back the encrypted vault before deleting the only rotated copy. + var saved = LoadVault().Accounts.Single(a => a.Id == entry.Id); + if (Digest(saved.Auth) != Digest(entry.Auth)) throw CorruptStore(); + Checkpoint?.Invoke("usage-vault-committed"); + journal = journal with { Committed = true }; + WriteEncrypted(UsageJournalPath, journal); + } + DeleteUsageHome(); + DeleteChecked(UsageJournalPath); + } + + private void DeleteUsageHome() + { + RejectReparsePath(UsageHome); + if (!Directory.Exists(UsageHome)) return; + var files = new List(); + var directories = new List(); + void Collect(string path) + { + foreach (var child in Directory.EnumerateFileSystemEntries(path)) + { + RejectReparsePath(child); + if (Directory.Exists(child)) { directories.Add(child); Collect(child); } + else files.Add(child); + if (files.Count + directories.Count > 2048) throw CorruptStore(); + } + } + // Fixed direct child of the validated vault. Inspect before deleting; never follow a link. + Collect(UsageHome); + foreach (var path in files) DeleteChecked(path); + foreach (var path in directories.AsEnumerable().Reverse()) Directory.Delete(path, false); + Directory.Delete(UsageHome, false); + } + + internal sealed record UsageQueryJournal(int Version, string AccountId, string Key, string BeforeDigest, bool Committed, bool Prepared, + byte[]? CommitAuth = null); +} + +internal sealed class UsageHelperShutdownException : IOException +{ + internal UsageHelperShutdownException() : base("조회 프로세스 종료를 확인하지 못했습니다. 조회 복구를 실행해 주세요.") { } +} + +internal static class CodexAccountUsageReader +{ + internal static Task ReadInactiveAsync(CodexAccountStore store, string id, CancellationToken token) => + Task.Run(() => store.QueryInactiveUsage(id, ReadIsolatedAsync, token), token); + + private static async Task ReadIsolatedAsync(string home, CancellationToken token) + { + using var client = new AppServerClient(() => CreateStartInfo(home), ownJob: true); + try { return await client.GetWeeklyUsageWithAccountAsync(token).ConfigureAwait(false); } + finally + { + try { await client.SuspendAsync().ConfigureAwait(false); } + catch { throw new UsageHelperShutdownException(); } + } + } + + internal static System.Diagnostics.ProcessStartInfo CreateStartInfo(string home) + { + var executable = AppServerClient.LocateCodexExecutable() + ?? throw new InvalidOperationException("Codex CLI를 찾지 못했습니다. Codex 앱을 먼저 실행해 주세요."); + var start = CodexAccountRuntime.CreateLoginStartInfo(executable, home); + start.ArgumentList.Clear(); + start.ArgumentList.Add("app-server"); + start.ArgumentList.Add("--stdio"); + start.ArgumentList.Add("-c"); + start.ArgumentList.Add("cli_auth_credentials_store=\"file\""); + return start; + } +} diff --git a/src/Program.cs b/src/Program.cs index b27e3e0..3048264 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -44,7 +44,10 @@ internal sealed record UsageSnapshot( int UsedPercent, DateTimeOffset? ResetsAt, long? WindowDurationMinutes, - string LimitId); + string LimitId, + UsageWindow? ShortWindow = null); + +internal sealed record UsageWindow(int UsedPercent, DateTimeOffset? ResetsAt, long? WindowDurationMinutes); internal sealed class UsageIndicatorForm : Form { @@ -137,6 +140,7 @@ public UsageIndicatorForm(bool previewMode, bool openAccounts = false) _accountBusy = true; ShowAccountManager(); } + else if (_accountStore.HasPendingUsageQuery) ShowAccountManager(); SyncCodexVisibility(); _codexStateTimer.Start(); if (openAccounts) ShowAccountManager(); @@ -441,11 +445,40 @@ private void ShowAccountManager() { if (_previewMode) return; if (_accountManager is null || _accountManager.IsDisposed) - _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts); + _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts, + queryUsage: QueryAccountUsageAsync); _accountManager.Show(); _accountManager.Activate(); } + private async Task QueryAccountUsageAsync(SavedCodexAccount account, CancellationToken token) + { + if (!account.IsActive) + { + await CodexAccountUsageReader.ReadInactiveAsync(_accountStore, account.Id, token); + return; + } + if (!_codexStateReader.IsRunning()) + throw new InvalidOperationException("현재 계정의 사용량을 조회하려면 Codex 앱을 열어 주세요."); + var generation = _accountGeneration; + var identity = _accountStore.GetCurrentIdentity().Key; + if (_accountStore.ListAccounts().SingleOrDefault(a => a.Id == account.Id)?.IsActive != true) + throw new InvalidOperationException("현재 계정이 변경되었습니다. 목록을 갱신한 뒤 다시 조회하세요."); + if (_helperIdentityKey != identity) + { + await _codexClient.SuspendAsync(); + _codexClient.Resume(); + _helperIdentityKey = identity; + } + var result = await _codexClient.GetWeeklyUsageWithAccountAsync(token); + if (!result.IsChatGpt || generation != _accountGeneration || _accountBusy || + _accountStore.GetCurrentIdentity().Key != identity) + throw new InvalidOperationException("조회 중 현재 계정이 변경되었습니다. 목록을 갱신한 뒤 다시 조회하세요."); + _accountStore.SaveUsage(identity, result.Usage); + _codexSnapshot = result.Usage; _codexError = null; + UpdateToolTip(); Invalidate(); + } + private async Task SuspendAccountsAsync() { _accountBusy = true; diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index 245dc62..feef669 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.5.1 + 1.6.0 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs index 3009021..f60ce34 100644 --- a/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs +++ b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs @@ -36,7 +36,7 @@ internal static async Task RunAsync() var testBase = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests"); - var root = Path.Combine(testBase, "runtime-" + Guid.NewGuid().ToString("N")); + var root = Path.GetFullPath(Path.Combine(testBase, "runtime-" + Guid.NewGuid().ToString("N"))); Directory.CreateDirectory(root); try { diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs new file mode 100644 index 0000000..764ed56 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageQueryTests.cs @@ -0,0 +1,191 @@ +using System.Reflection; +using WeeklyUsageIndicator; + +internal static class AccountUsageQueryTests +{ + internal static async Task RunAsync() + { + foreach (var outcome in new[] { "success", "failure", "cancel" }) await RotationAsync(outcome); + foreach (var point in new[] { "usage-journal-written", "usage-staged", "usage-helper-stopped", "usage-commit-prepared", "usage-vault-committed" }) + await RecoverAsync(point); + await SaveFailureAsync(); + await IdentityRaceAsync(); + await ExclusiveAsync(); + await UnconfirmedShutdownAsync(); + } + + private static async Task RotationAsync(string outcome) + { + using var f = new Fixture(); + var original = File.ReadAllBytes(f.LiveAuth); + var before = f.Store.ListAccounts().Single(a => a.Id == f.Target.Id); + try + { + await Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, async (home, token) => + { + await Task.Delay(15, token).ConfigureAwait(false); // Deliberately resume on another worker. + Check(home != f.Home, "query must use a separate home"); + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth("b", "rotated")); + if (outcome == "failure") throw new IOException("synthetic request failure"); + if (outcome == "cancel") throw new OperationCanceledException(); + return Result(); + }, CancellationToken.None)); + Check(outcome == "success", "failure/cancel must propagate"); + } + catch (Exception ex) when ((outcome == "failure" && ex is IOException) || (outcome == "cancel" && ex is OperationCanceledException)) { } + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(original), "query leaves active credentials byte-for-byte unchanged"); + var after = f.Store.ListAccounts().Single(a => a.Id == f.Target.Id); + Check(outcome == "success" ? after.Usage?.UsedPercent == 27 && after.Usage.ShortWindow?.UsedPercent == 13 && after.ObservedAt is not null + : after.Usage == before.Usage && after.ObservedAt == before.ObservedAt, "only success updates last observation"); + Check(!f.Store.HasPendingUsageQuery && !Directory.Exists(Path.Combine(f.Store.RootPath, "usage-query")), "committed query removes plaintext staging and journal"); + f.Store.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "rotated")), "success/failure/cancel all preserve rotated credentials for next switch"); + } + + private static async Task RecoverAsync(string point) + { + using var f = new Fixture(); + var original = File.ReadAllBytes(f.LiveAuth); + f.Store.Checkpoint = reached => { if (point == reached) throw new IOException("synthetic crash"); }; + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth("b", "rotated")); + return Task.FromResult(Result()); + }, CancellationToken.None))); + Check(f.Store.HasPendingUsageQuery, "interruption keeps durable query journal"); + f.Store.Checkpoint = null; + var reopened = new CodexAccountStore(f.Store.RootPath, f.Home); + var checks = 0; + reopened.Recover(() => checks++); + Check(checks > 0 && !reopened.HasPendingUsageQuery, "recovery requires stopped writers and closes transaction"); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(original), "recovery never writes live auth"); + reopened.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", point is "usage-helper-stopped" or "usage-commit-prepared" or "usage-vault-committed" ? "rotated" : "initial")), "crash recovery preserves latest staged credential"); + } + + private static async Task SaveFailureAsync() + { + using var f = new Fixture(); + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth("b", "rotated")); + f.Store.Checkpoint = point => { if (point == "accounts.dpapi-temp-flushed") throw new IOException("synthetic disk failure"); }; + return Task.FromResult(Result()); + }, CancellationToken.None))); + Check(f.Store.HasPendingUsageQuery && File.Exists(Path.Combine(f.Store.RootPath, "usage-query", "auth.json")), "save failure retains the only rotated copy"); + f.Store.Checkpoint = null; + f.Store.Recover(() => { }); + f.Store.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "rotated")), "retry recovery saves rotated token"); + } + + private static async Task IdentityRaceAsync() + { + foreach (var race in new[] { "stage", "active" }) + { + using var f = new Fixture(); + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth(race == "stage" ? "wrong" : "b", "rotated")); + if (race == "active") File.WriteAllBytes(f.LiveAuth, Auth("b", "external-newer")); + return Task.FromResult(Result()); + }, CancellationToken.None))); + Check(f.Store.HasPendingUsageQuery, "identity race fails closed and preserves recovery evidence"); + if (race == "active") + { + f.Store.Checkpoint = point => { if (point == "usage-vault-committed") throw new IOException("second crash during recovery"); }; + await ThrowsAsync(() => Task.Run(() => f.Store.Recover(() => { }))); + f.Store.Checkpoint = null; + File.WriteAllBytes(f.LiveAuth, Auth("a", "external-return")); + f.Store.Recover(() => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("a", "external-return")), "recovery preserves live auth after another external login"); + f.Store.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "external-newer")), "repeated recovery preserves the earlier committed live credential"); + var source = f.Store.ListAccounts().Single(a => !a.IsActive); + f.Store.SwitchTo(source.Id, () => { }); + f.Store.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "external-newer")), "live credential, not staged snapshot, survives later switches"); + } + } + using var active = new Fixture(); + var current = active.Store.ListAccounts().Single(a => a.IsActive); + await ThrowsAsync(() => Task.Run(() => active.Store.QueryInactiveUsage(current.Id, (_, _) => throw new Exception("must not launch"), CancellationToken.None))); + Check(!active.Store.HasPendingUsageQuery, "active target rejected before staging or helper launch"); + } + + private static async Task ExclusiveAsync() + { + using var f = new Fixture(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var query = Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, async (_, _) => + { entered.SetResult(); await release.Task.ConfigureAwait(false); return Result(); }, CancellationToken.None)); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + try + { + await ThrowsAsync(() => Task.Run(() => f.Store.SwitchTo(f.Target.Id, () => { }))); + await ThrowsAsync(() => Task.Run(() => f.Store.Remove(f.Target.Id))); + await ThrowsAsync(() => Task.Run(() => f.Store.Rename(f.Target.Id, "changed"))); + await ThrowsAsync(() => Task.Run(() => f.Store.RegisterCurrent("changed"))); + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (_, _) => Task.FromResult(Result()), CancellationToken.None))); + // A live poll can still display its new value without a vault-busy error. + f.Store.SaveUsage(f.Store.GetCurrentIdentity().Key, Result().Usage); + } + finally { release.SetResult(); await query; } + Check(!f.Store.HasPendingUsageQuery, "mutex is released on its owner thread after async reader completes"); + } + + private static async Task UnconfirmedShutdownAsync() + { + using var f = new Fixture(); + await ThrowsAsync(() => Task.Run(() => f.Store.QueryInactiveUsage(f.Target.Id, (home, _) => + { + File.WriteAllBytes(Path.Combine(home, "auth.json"), Auth("b", "rotated")); + throw new UsageHelperShutdownException(); + }, CancellationToken.None))); + Check(f.Store.HasPendingUsageQuery && File.Exists(Path.Combine(f.Store.RootPath, "usage-query", "auth.json")), "uncertain writer exit must retain staging"); + await ThrowsAsync(() => Task.Run(() => f.Store.Recover(() => throw new InvalidOperationException("writer still running")))); + Check(f.Store.HasPendingUsageQuery, "recovery cannot discard staging while a writer may live"); + f.Store.Recover(() => { }); + f.Store.SwitchTo(f.Target.Id, () => { }); + Check(File.ReadAllBytes(f.LiveAuth).SequenceEqual(Auth("b", "rotated")), "recovery after confirmed stop preserves rotated auth"); + } + + internal static CodexAccountUsage Result() => new(new UsageSnapshot(27, DateTimeOffset.UtcNow.AddDays(2), 10080, "codex", + new UsageWindow(13, DateTimeOffset.UtcNow.AddHours(2), 300)), "b@example.invalid", "pro", true); + internal static byte[] Auth(string user, string revision) => (byte[])typeof(AccountStoreTests) + .GetMethod("Auth", BindingFlags.NonPublic | BindingFlags.Static)!.Invoke(null, new object[] { user, "workspace", revision })!; + private static void Check(bool value, string message) { if (!value) throw new InvalidOperationException("Usage query: " + message); } + private static async Task ThrowsAsync(Func action) + { + try { await action(); } + catch (Exception ex) when (ex is IOException or InvalidOperationException or OperationCanceledException) { return; } + throw new Exception("Expected usage query rejection"); + } + private sealed class Fixture : IDisposable + { + private readonly string _path = Path.Combine(Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? + Path.Combine(Path.GetTempPath(), "gfs-agent", "260910_codex-account-usage", "tests"), "query-" + Guid.NewGuid().ToString("N")); + internal string Home { get; } + internal string LiveAuth => Path.Combine(Home, "auth.json"); + internal CodexAccountStore Store { get; } + internal SavedCodexAccount Target { get; } + internal Fixture() + { + Home = Directory.CreateDirectory(Path.Combine(_path, "home")).FullName; + File.WriteAllBytes(LiveAuth, Auth("a", "initial")); + Store = new CodexAccountStore(Path.Combine(_path, "vault"), Home); + Store.RegisterCurrent("Current"); + var imported = Path.Combine(_path, "import.json"); + File.WriteAllBytes(imported, Auth("b", "initial")); + Target = Store.ImportLoginFile(imported, "Saved"); + File.Delete(imported); + } + public void Dispose() + { + var path = Path.GetFullPath(_path); + if (!Path.GetFileName(path).StartsWith("query-", StringComparison.Ordinal)) throw new InvalidOperationException(); + Directory.Delete(path, true); + } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs new file mode 100644 index 0000000..c86b9b1 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountUsageUiSmoke.cs @@ -0,0 +1,101 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +internal static class AccountUsageUiSmoke +{ + internal static Task RunAsync() + { + var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var root = Path.Combine(Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? + Path.Combine(Path.GetTempPath(), "gfs-agent", "260910_codex-account-usage", "tests"), "usage-ui-" + Guid.NewGuid().ToString("N")); + try + { + Application.EnableVisualStyles(); + var home = Directory.CreateDirectory(Path.Combine(root, "home")).FullName; + File.WriteAllBytes(Path.Combine(home, "auth.json"), AccountUsageQueryTests.Auth("a", "ui")); + var store = new CodexAccountStore(Path.Combine(root, "vault"), home); + store.RegisterCurrent("Pro A · 현재 계정"); + store.SaveUsage(store.GetCurrentIdentity().Key, AccountUsageQueryTests.Result().Usage); + var import = Path.Combine(root, "import.json"); + File.WriteAllBytes(import, AccountUsageQueryTests.Auth("b", "ui")); + var target = store.ImportLoginFile(import, "Pro B · 저장된 계정"); + var current = store.ListAccounts().Single(a => a.IsActive); + var calls = 0; var restarts = 0; var mode = "success"; + using var form = new AccountManagerForm(store, () => { restarts++; return Task.CompletedTask; }, () => restarts++, + queryUsage: (account, token) => Task.Run(() => store.QueryInactiveUsage(account.Id, async (_, ct) => + { + Interlocked.Increment(ref calls); + await Task.Delay(mode == "cancel" ? 60000 : 100, ct).ConfigureAwait(false); + if (mode == "failure") throw new IOException("synthetic failure"); + return AccountUsageQueryTests.Result(); + }, token), token)); + T Find(string name) where T : Control => (T)form.Controls.Find(name, true).Single(); + form.Shown += async (_, _) => + { + try + { + var list = Find("AccountList"); + list.SelectedIndex = list.Items.Cast().ToList().FindIndex(a => a.Id == target.Id); + var button = Find