diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6f2fcdd..c3a0518 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: Copy-Item -LiteralPath 'scripts\install.ps1','scripts\uninstall.ps1','scripts\install-environment.ps1' -Destination 'package\scripts' Copy-Item -LiteralPath 'README.md','PRIVACY.md','SECURITY.md','LICENSE' -Destination 'package' Compress-Archive -Path 'package\*' -DestinationPath "CodexWeeklyUsageIndicator-$env:GITHUB_REF_NAME-win-x64.zip" - - name: Publish release + - name: Create draft release for artifact verification shell: pwsh env: GH_TOKEN: ${{ github.token }} @@ -40,4 +40,5 @@ jobs: 'dist\WeeklyUsageIndicator.exe' ` 'dist\SHA256SUMS.txt' ` --title "Codex + Claude Usage Indicator $env:GITHUB_REF_NAME" ` + --draft ` --generate-notes diff --git a/AGENTS.md b/AGENTS.md index a6c4a8a..29b7704 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ This repository contains a small Windows-only WinForms utility. Keep changes foc - Do not invoke Claude more often than every ten minutes, including explicit UI refreshes, and back off transient command failures. - Persist at most one credential-free Claude recovery snapshot containing only percentages, reset times, and update time. Use it only for transient cold-start failures, delete it after 24 hours, its Fable reset, or an authentication/schema failure, and never let it suppress the first live request in a new process. - Stop the app-server child process when the widget pauses or exits. -- Preserve the single-instance mutex and the always-on-top tool-window behavior. +- Preserve the single-instance mutex and the always-on-top tool-window behavior without taking keyboard focus or changing the foreground window. Widget show, hide/re-show, and timer maintenance must leave another window's focused input unchanged; do not restore topmost behavior through an activating WinForms `TopMost` assignment. - The installer must launch the `--supervise` mode through the per-user interactive, least-privilege scheduled task, never directly from Codex. The supervisor retries nonzero widget exits/start failures only; normal Quit must end supervision. Do not substitute Task Scheduler RestartOnFailure for this loop: it did not retry an exited action in live tests. Disable/stop the task before upgrade or uninstall; filter processes by the current user's exact installed EXE path. - Check the install directory's final handle path before stopping tasks or processes: packaged shells can redirect AppData even without reporting a package identity. Package `install-environment.ps1` with both lifecycle scripts. @@ -45,3 +45,4 @@ Then check that: 8. The context menu works, the widget hides, and its app-server child exits when Codex closes. 9. `install.ps1` and `uninstall.ps1` only modify the current user's dedicated install directory, legacy Startup shortcut, and SID-named scheduled task. 10. The task owns the supervisor and its widget child independently of Codex. Forced termination of the widget child recovers after about one minute; normal Quit ends both processes and does not recover. Reinstall leaves one supervisor and one widget; uninstall removes the task before stopping the app. +11. `AccountUiSmoke` verifies native keyboard focus and foreground-window preservation during widget show, hide/re-show, and repeated maintenance, while checking that the widget remains topmost. Preserve this regression coverage when changing window presentation. diff --git a/PRIVACY.md b/PRIVACY.md index c7a4c57..d48a380 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -4,16 +4,18 @@ Codex + Claude Usage Indicator reads Codex rate-limit windows from the locally i The application: -- does not store usage history; +- does not store a timeline of usage history; optional Codex account management retains each account's latest usage snapshot; - stores the last window coordinates and the Claude visibility preference in a local `settings.json` file; - stores one latest successful Claude snapshot in local `claude-usage-cache.json`, containing only usage percentages, reset times, and the update time; - does not read, print, log, copy, or persist Claude authentication tokens; -- does not collect account identifiers; +- reads Codex account identifiers only after optional account registration, and stores them with labels and login snapshots in a Windows CurrentUser DPAPI encrypted vault; - does not include telemetry; - registers a per-user Windows logon/recovery task containing the local executable path and Windows user SID, with no stored password or elevated privileges; -- makes no outbound request for Codex usage; +- makes no direct outbound request for Codex usage; the official local app-server manages service communication; - makes no direct Claude network request; it invokes `claude.exe` in safe mode without a shell or persistent session, caches successful `/usage` results in memory for ten minutes, and deletes the local recovery snapshot after 24 hours, its Fable reset, or an authentication/schema failure. Codex Desktop, its local app-server, Claude Code, and Anthropic's API remain governed by their own terms and privacy practices. +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. + 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 5dea2db..ad85269 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,19 @@ An unofficial Windows widget that stays on top while Codex Desktop is running an - Hides while another foreground app is fullscreen, then returns at the saved position. - Uses a per-user Windows scheduled task at sign-in, so the widget runs independently of Codex. A lightweight supervisor restarts the widget after an abnormal exit, waiting one minute (up to 999 retries per supervisor run). -The widget does not read or store login tokens, account details, or usage history. It stores only the latest successful Claude percentages, reset times, and update time for short-lived recovery. Claude Code itself owns authentication and token refresh. See [PRIVACY.md](PRIVACY.md). +Account management is optional. After you register a Codex account, the widget stores account labels, identities, each account's latest usage snapshot, and Codex login snapshots encrypted with Windows CurrentUser DPAPI. The live authentication file remains authoritative for the active account. Claude credentials are never accessed; Claude Code owns its authentication and token refresh. See [PRIVACY.md](PRIVACY.md). + +## Manual Codex accounts + +Open **Codex 계정 관리…** from the right-click menu, or double-click the tray icon. Choose **현재 계정 등록** to save the current login with a unique default name. Select an account in the left list to view its status and usage; use **이름 변경** or F2 to edit its name. Enter saves and Escape cancels. Account names appear only in the manager; the compact indicator shows percentages and bars. Renaming only changes local metadata and does not restart the usage helper. If you sign into an unregistered account directly in Codex, the manager offers registration above the existing list. + +Choose **+ 다른 계정 추가**, optionally name the account, then select **브라우저에서 로그인**. Complete the official browser login using the additional account. This login uses an isolated private `CODEX_HOME` and does not log the desktop out; **로그인 취소** stops only the login process owned by this tool. The widget maintains its topmost position without activating itself, including when it reappears, so typing in the manager or another app keeps focus. + +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. + +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. > [!IMPORTANT] > This is an unofficial community project. It relies on an experimental local Codex app-server method (`account/rateLimits/read`) and the text output of Claude Code's built-in `/usage` command. Either may change without notice. diff --git a/SECURITY.md b/SECURITY.md index 7cae26d..0367a8d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,5 @@ Please open a GitHub issue for ordinary bugs. Do not include credentials, authen For a security-sensitive report, use GitHub's private vulnerability reporting feature when it is available for this repository. Release binaries are not code-signed. Verify the accompanying `SHA256SUMS.txt` file or build the application from source before running it. + +Optional Codex account switching handles local authentication secrets. Do not attach account-vault files, encrypted recovery transactions, or temporary login folders to an issue. Windows CurrentUser DPAPI and restrictive ACLs protect the vault at rest; same-user malware is outside that boundary. The switcher refuses to replace authentication while Codex engines are running and preserves an encrypted transaction for interrupted writes. It does not revoke or log out saved accounts when deleting local entries. diff --git a/docs/manual-accounts-delivery.md b/docs/manual-accounts-delivery.md new file mode 100644 index 0000000..e1b0bf0 --- /dev/null +++ b/docs/manual-accounts-delivery.md @@ -0,0 +1,30 @@ +# Manual Codex accounts — delivery board + +Outcome: manually switch between the owner's paid Codex accounts from the existing Windows usage widget. Keep the 272 × 64 widget; account management uses a separate optional window. No automatic quota switching, proxy routing, inactive-account polling, or Claude credential handling. + +Release candidate: **1.5.1**, branch `codex/manual-account-switch`. Locally installed validation build SHA-256: `797F4309994B4C18753CC2E2FB24106A2BEC6020117D340EBD3B98048C05BBC0`, from code revision `1d28dcb6f111dc7a197647cfcbfa812ca9323067`. Installation verified one supervisor and one widget. On 2026-09-09 the user authorized recording the acceptance result and publishing a GitHub release. Public binaries are built by GitHub Actions; release checksums identify those artifacts separately from this local build. + +## UX outcome + +The user rejected the first manager's rough button layout and requested wireframe-led refinement, including name editing. The replacement uses a left account list and right details with status, usage and account-specific actions. A name editor supports Enter, Escape and inline validation without restarting the helper. Following the user's overlap report, version 1.5.1 removes account names from the compact indicator and keeps them in the manager. Additional login has a separate explanation/name step and cancellation. Switching has a source/target preparation dialog whose confirmation becomes available after Codex writers stop, then rechecks immediately before acceptance. External unregistered current accounts can register above an existing list. Expired usage is marked for refresh. Smaller windows scroll the details. + +The earlier blank-name registration and repeating TopMost assignment were corrected in 1.4.1. This revision also fixes WinForms focusing a topmost form when it reappears: native topmost styles and SWP_NOACTIVATE maintain z-order without setting the managed TopMost property. See the upstream [Form.SetVisibleCore behavior](https://github.com/dotnet/winforms/blob/v8.0.0/src/System.Windows.Forms/src/System/Windows/Forms/Form.cs). + +## Verification and review + +- All 23 regression groups passed and the release build succeeded. The binary scan found no checked personal username or absolute build path; whitespace validation passed. +- Synthetic UI tests click registration, rename current/saved accounts, cancel editing/addition, reject invalid names, check Enter/Escape wiring, follow switch readiness and recheck on confirmation, and register an externally changed current account. Minimum-size scrolling exposes lower actions. Native keyboard focus and foreground remain unchanged across widget show, hide/re-show and repeated maintenance; the widget remains topmost. +- Synthetic WinForms renders at 175% scaling were inspected. Clipped dialog columns/buttons were corrected. No real-account screenshots are committed. +- A fresh source-only reviewer identified two P1 issues: registration after external login, and clipped lower actions at minimum size. Both were fixed and covered by the UI harness. The reviewer accepted the fixes and found no new confirmed P1. A reported old capture discrepancy was absent in the chair's final capture readback. +- Earlier authentication/lifecycle review and tests cover private DPAPI storage, interrupted encrypted transactions, live credential rotation, third-account refusal, corrupt data, private ACLs, reparse rejection, helper generations, owned login cancellation and browser-child survival. These remain synthetic/source checks; the user acceptance below covers the actual account switch and subsequent use. +- On 2026-09-09 the user reported successfully switching from the main account to the secondary account and using Codex without issues, and explicitly approved the experience. This is user-reported acceptance of the installed 1.5.1 flow, not an agent-observed round trip. Secondary-to-main switching has not yet been reported. +- The nonactivating topmost requirement is now explicit in `AGENTS.md`, linked to the existing native-focus regression coverage. +- Installation completed through the existing least-privilege supervisor task with final-path safeguards. The separate temporary install task was removed afterward. Final installed-screen inspection was stopped by the user's physical Escape key before a fresh UI snapshot was obtained. + +## Wireframe evidence + +Mode: `standalone`. Canonical structural drafts: `wireframes/02_accounts.manager.yaml` and `wireframes/03_accounts.switch.yaml`. The actual WinForms implementation and its synthetic fixture renders are derived review surfaces. Exact reconstructable revisions are the Git blobs committed with these files; retrieve them with `git rev-parse HEAD:wireframes/02_accounts.manager.yaml` and the corresponding switch path. + +Wireframe assurance: **exploratory** for the structural YAML, which has not received a separate exact-revision structure review. The installed account-manager flow has user acceptance as recorded above; this does not imply full visual or accessibility certification. The layout prioritizes account identity and available actions, at the cost of extra selection compared with showing every account's full controls at once. Fixed user decisions are manual switching, widget integration, editable names and no Claude/Fable deliberation. Acceptance applies to the revised installed flow, not the rejected first manager. + +Carryover owner: user — confirm secondary-to-main switching when next needed. Second-account registration, main-to-secondary switching and subsequent Codex use are confirmed by the user. The implementation agent must not close the running desktop to test switching. Full screen-reader behavior, all monitor/DPI combinations and official service-policy acceptance are not certified. diff --git a/docs/manual-accounts-design.md b/docs/manual-accounts-design.md new file mode 100644 index 0000000..5c45cf5 --- /dev/null +++ b/docs/manual-accounts-design.md @@ -0,0 +1,35 @@ +# Manual account switching decisions + +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. + +## 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. + +Recovery examines the actual identity: a source identity reconciles the before-vault, a target identity reconciles the after-vault, and either keeps any newer live tokens. Missing, malformed, or unrelated third-account authentication stops recovery without replacing the live file. A flushed auth temporary file is scoped to this transaction and removed through recovery. Profile identity combines user and workspace information rather than equating an email or workspace alone with a person. + +The encrypted vault is separate from the installation folder. Windows CurrentUser DPAPI binds it to the Windows user. Private ACLs, non-reparse paths, bounded reads, atomic flushed writes, and a shared transaction mutex protect local operations. Same-user malware and unsupported external writers are outside this local mechanism's assurance. + +Additional registration uses official `codex login` with an isolated private home and explicit file credential storage. No token is passed in a command argument or UI field. Login output is discarded. A private Job closes the owned login process if the widget exits, with descendant breakaway so an OAuth browser survives. A narrow process-start/Job-attachment crash interval remains; owned staging is recoverable after writers exit. Managed requirements and unsupported credential modes fail closed. + +## Lifecycle and verification + +The prior helper's shared pending requests and reader finalizer could affect a replacement helper. The new client owns state per process, serializes requests, and suspends new starts before cancellation and confirmed exit. The widget rejects both late successes and errors from prior account generations. A changed live identity creates a fresh helper. `account/read` metadata is checked before/after usage; it does not expose full workspace identity and is not represented as proof of the desktop's account. + +The manager remains available from the tray when Desktop is closed. Version 1 requires the user to finish work and close Desktop/CLI engines before a switch; it does not force-close arbitrary processes or infer that a running task is idle. Applying the file and reopening Desktop are reported separately. A pending transaction prevents startup polling until explicit recovery. + +Installer and uninstaller share the account-operation mutex across process replacement. Login holds it through credential import and staging cleanup, so an upgrade cannot normally interrupt that sequence. The existing least-privilege supervisor and install-path validation remain in place. + +## Review disposition + +Inherited auth/lifecycle advisors identified stale-token and reader/start races; those were addressed in the store and session client. A fresh critic then inspected the actual implementation without the chair's recommendation. Its valid legacy `auth_mode` finding was adopted using the official fallback contract while rejecting competing credential types. Its parent-exit/login lifetime finding was addressed with the shared UI mutex, Job ownership, and explicit staging recovery. Final actual-source review found no confirmed remaining P0. + +The implementation has synthetic crash, process lifecycle, local ACL and UI checks. Those do not establish actual second-account OAuth or successful A→B→A Desktop use. User-assisted acceptance is tracked in the delivery board. + +## Implementation references + +- [OpenCodex native profile transaction](https://github.com/lidge-jun/opencodex/blob/2f3f736299dca38861f8fb9c4326a4b4d7c664bc/src/codex/native-profile-manager.ts): latest-source capture, durable journal, identity-based recovery. +- [Official Codex authentication storage at 0.153.4](https://github.com/openai/codex/blob/rust-v0.153.4/codex-rs/login/src/auth/storage.rs): file storage and optional mode contract. +- [Official Codex authentication](https://developers.openai.com/codex/auth/): official login and credential ownership. + +This tool does not determine whether a particular account usage pattern complies with service terms. It implements explicit local profile selection rather than automatic quota-driven routing. diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 3834cb1..04162e0 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -8,7 +8,7 @@ $pathMap = "$repositoryRoot=/_/" dotnet run --project $testProjectPath -c Release --nologo if ($LASTEXITCODE -ne 0) { - throw "Claude usage regression tests failed with exit code $LASTEXITCODE." + throw "Usage and account regression tests failed with exit code $LASTEXITCODE." } dotnet clean $projectPath -c Release | Out-Null diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 73485c0..c2c1fe5 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -17,6 +17,14 @@ if (-not (Test-Path -LiteralPath $sourceExecutable -PathType Leaf)) { New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null Assert-WidgetInstallPath -Path $installDirectory +# Share the account transaction gate before stopping any process. A pending +# encrypted journal remains recoverable after an abnormal widget exit. +$accountTransactionMutex = [Threading.Mutex]::new($false, 'Local\CodexWeeklyUsageIndicator.AccountTransaction') +$accountTransactionOwned = $false +try { + try { $accountTransactionOwned = $accountTransactionMutex.WaitOne(0) } + catch [Threading.AbandonedMutexException] { $accountTransactionOwned = $true } + if (-not $accountTransactionOwned) { throw 'An account operation is in progress. Finish it before installing or uninstalling.' } # Stop scheduler recovery before replacing the binary. Never stop another user's copy. $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue if ($existingTask) { @@ -65,3 +73,8 @@ if (Test-Path -LiteralPath $shortcutPath -PathType Leaf) { Write-Host "Installed: $installedExecutable" Write-Host "Logon and recovery task: $taskName" + +} finally { + if ($accountTransactionOwned) { $accountTransactionMutex.ReleaseMutex() } + $accountTransactionMutex.Dispose() +} \ No newline at end of file diff --git a/scripts/uninstall.ps1 b/scripts/uninstall.ps1 index ab47bd4..28cea6a 100644 --- a/scripts/uninstall.ps1 +++ b/scripts/uninstall.ps1 @@ -9,6 +9,14 @@ $userSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value $taskName = "CodexWeeklyUsageIndicator-$userSid" Assert-WidgetInstallPath -Path $installDirectory +# Share the account transaction gate before stopping any process. A pending +# encrypted journal remains recoverable after an abnormal widget exit. +$accountTransactionMutex = [Threading.Mutex]::new($false, 'Local\CodexWeeklyUsageIndicator.AccountTransaction') +$accountTransactionOwned = $false +try { + try { $accountTransactionOwned = $accountTransactionMutex.WaitOne(0) } + catch [Threading.AbandonedMutexException] { $accountTransactionOwned = $true } + if (-not $accountTransactionOwned) { throw 'An account operation is in progress. Finish it before installing or uninstalling.' } # Remove recovery before stopping the app or deleting its files. $existingTask = Get-ScheduledTask -TaskName $taskName -TaskPath '\' -ErrorAction SilentlyContinue if ($existingTask) { @@ -38,3 +46,8 @@ if ((Test-Path -LiteralPath $resolvedInstall -PathType Container) -and } Write-Host 'Codex + Claude Usage Indicator uninstalled.' + +} finally { + if ($accountTransactionOwned) { $accountTransactionMutex.ReleaseMutex() } + $accountTransactionMutex.Dispose() +} \ No newline at end of file diff --git a/src/AccountDialogs.cs b/src/AccountDialogs.cs new file mode 100644 index 0000000..81d4ba6 --- /dev/null +++ b/src/AccountDialogs.cs @@ -0,0 +1,151 @@ +namespace WeeklyUsageIndicator; + +internal sealed class AccountNameDialog : Form +{ + private readonly TextBox _input = new() { Name = "AccountNameTextBox", Dock = DockStyle.Fill, BorderStyle = BorderStyle.FixedSingle, MaxLength = 0 }; + private readonly Label _error = AccountUiTheme.Label("", color: AccountUiTheme.Error); + private readonly bool _allowEmpty; + internal string AccountName => _input.Text.Trim(); + + internal AccountNameDialog(string initialName, bool adding = false) + { + SuspendLayout(); + _allowEmpty = adding; + Name = "AccountNameDialog"; + AccountUiTheme.SetForm(this); + Text = adding ? "다른 계정 추가" : "계정 이름 변경"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; + ClientSize = new Size(500, 340); + var stack = AccountUiTheme.Stack(6); + stack.Padding = new Padding(28); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 64)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 27)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 36)); + stack.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + stack.Controls.Add(AccountUiTheme.Label(Text, 17, true), 0, 0); + var description = AccountUiTheme.Label(adding + ? "공식 로그인 화면이 브라우저에서 열립니다.\n추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다." + : "알아보기 쉬운 이름을 붙여주세요.\n로그인 정보와 사용량에는 영향을 주지 않습니다.", color: AccountUiTheme.Muted); + description.Dock = DockStyle.Fill; + description.AutoSize = false; + stack.Controls.Add(description, 0, 1); + stack.Controls.Add(AccountUiTheme.Label(adding ? "계정 이름 · 선택" : "계정 이름"), 0, 2); + _input.Text = initialName; + _input.BackColor = AccountUiTheme.Raised; _input.ForeColor = AccountUiTheme.Text; + _input.PlaceholderText = adding ? "비우면 계정 번호로 지정됩니다" : "1~40자"; + _input.TextChanged += (_, _) => _error.Text = ""; + stack.Controls.Add(_input, 0, 3); + _error.Name = "NameErrorLabel"; + _error.Dock = DockStyle.Fill; + _error.Margin = new Padding(0, 3, 0, 0); + stack.Controls.Add(_error, 0, 4); + var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; + var save = AccountUiTheme.Button("SaveNameButton", adding ? "브라우저에서 로그인" : "저장", true); + var cancel = AccountUiTheme.Button("CancelNameButton", "취소"); + cancel.Margin = new Padding(0, 0, 10, 0); + cancel.DialogResult = DialogResult.Cancel; + save.Click += (_, _) => + { + var name = AccountName; + if ((!_allowEmpty && name.Length == 0) || name.Length > 40 || name.Any(char.IsControl)) + { + _error.Text = "이름을 1~40자로 입력해 주세요. 줄바꿈은 사용할 수 없습니다."; + _input.Focus(); + return; + } + DialogResult = DialogResult.OK; + Close(); + }; + actions.Controls.Add(save); actions.Controls.Add(cancel); + stack.Controls.Add(actions, 0, 5); + Controls.Add(stack); + AcceptButton = save; CancelButton = cancel; + Shown += (_, _) => { _input.Focus(); _input.SelectAll(); }; + ResumeLayout(true); + } +} + +internal sealed class AccountSwitchDialog : Form +{ + private readonly Action _assertWritersStopped; + private readonly System.Windows.Forms.Timer _checkTimer = new() { Interval = 1000 }; + private readonly Label _readiness = AccountUiTheme.Label("실행 중인 앱을 확인하고 있습니다…"); + private readonly Button _confirm; + + internal AccountSwitchDialog(string sourceName, string targetName, Action? assertWritersStopped = null, bool recovery = false) + { + SuspendLayout(); + _assertWritersStopped = assertWritersStopped ?? CodexAccountRuntime.AssertWritersStopped; + Name = "AccountSwitchDialog"; + AccountUiTheme.SetForm(this); + Text = recovery ? "미완료 전환 복구" : "계정 전환 준비"; + StartPosition = FormStartPosition.CenterParent; + FormBorderStyle = FormBorderStyle.FixedDialog; + MinimizeBox = false; MaximizeBox = false; ShowInTaskbar = false; + ClientSize = new Size(580, 390); + var stack = AccountUiTheme.Stack(5); + stack.Padding = new Padding(28); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 62)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 88)); + stack.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + stack.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); + stack.Controls.Add(AccountUiTheme.Label(Text, 17, true), 0, 0); + var route = AccountUiTheme.Label(recovery ? "저장된 복구 정보를 확인합니다" : $"{sourceName} → {targetName}", 12, true, AccountUiTheme.Accent); + route.Dock = DockStyle.Fill; route.AutoSize = false; route.AutoEllipsis = true; + stack.Controls.Add(route, 0, 1); + var instructions = AccountUiTheme.Label("작업을 저장한 뒤 Codex 앱과 Codex 터미널·IDE 작업을\n직접 종료해 주세요. 앱이 닫히면 아래 버튼이 활성화됩니다.\n실행 중인 프로그램을 자동으로 종료하지 않습니다.", color: AccountUiTheme.Muted); + instructions.Dock = DockStyle.Fill; + instructions.AutoSize = false; + stack.Controls.Add(instructions, 0, 2); + _readiness.Name = "SwitchReadinessLabel"; + _readiness.Dock = DockStyle.Fill; + _readiness.BackColor = AccountUiTheme.Surface; + _readiness.Padding = new Padding(14); + _readiness.Margin = new Padding(0, 0, 0, 16); + stack.Controls.Add(_readiness, 0, 3); + var actions = new FlowLayoutPanel { Dock = DockStyle.Fill, FlowDirection = FlowDirection.RightToLeft, WrapContents = false, Margin = new Padding(0) }; + _confirm = AccountUiTheme.Button("ConfirmSwitchButton", recovery ? "복구 확인" : "전환하고 Codex 열기", true); + _confirm.Enabled = false; + _confirm.Click += (_, _) => { if (CheckReadiness()) { DialogResult = DialogResult.OK; Close(); } }; + var cancel = AccountUiTheme.Button("CancelSwitchButton", "취소"); + cancel.Margin = new Padding(0, 0, 10, 0); cancel.DialogResult = DialogResult.Cancel; + actions.Controls.Add(_confirm); actions.Controls.Add(cancel); + stack.Controls.Add(actions, 0, 4); + Controls.Add(stack); + AcceptButton = _confirm; CancelButton = cancel; + _checkTimer.Tick += (_, _) => CheckReadiness(); + Shown += (_, _) => { CheckReadiness(); _checkTimer.Start(); }; + FormClosed += (_, _) => _checkTimer.Stop(); + ResumeLayout(true); + } + + private bool CheckReadiness() + { + try + { + _assertWritersStopped(); + _readiness.Text = "● 준비 완료 · 모든 Codex 작업이 종료되었습니다."; + _readiness.ForeColor = AccountUiTheme.Accent; + _confirm.Enabled = true; + return true; + } + catch (Exception) + { + _readiness.Text = "● 종료 대기 · Codex 앱 또는 관련 작업이 실행 중입니다.\n앱을 닫으면 자동으로 다시 확인합니다."; + _readiness.ForeColor = AccountUiTheme.Warning; + _confirm.Enabled = false; + return false; + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) _checkTimer.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/AccountManagerForm.cs b/src/AccountManagerForm.cs new file mode 100644 index 0000000..dc8f232 --- /dev/null +++ b/src/AccountManagerForm.cs @@ -0,0 +1,370 @@ +namespace WeeklyUsageIndicator; + +internal sealed class AccountManagerForm : Form +{ + private readonly CodexAccountStore _store; + private readonly Func _suspend; + private readonly Action _resume; + private readonly Action? _accountsChanged; + 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("계정을 선택해 상태를 확인하세요."); + private readonly Label _title = AccountUiTheme.Label("", 18, true); + private readonly Label _identity = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly Label _state = AccountUiTheme.Label("", color: AccountUiTheme.Accent); + private readonly Label _remaining = AccountUiTheme.Label("미확인", 26, true); + 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 _switchHelp = AccountUiTheme.Label("", color: AccountUiTheme.Muted); + private readonly AccountUsageBar _bar = new() { Dock = DockStyle.Fill }; + private readonly Button _add = AccountUiTheme.Button("AddAccountButton", "+ 다른 계정 추가", true); + private readonly Button _register = AccountUiTheme.Button("RegisterCurrentButton", "현재 계정 등록", true); + private readonly Button _registerActive = AccountUiTheme.Button("RegisterActiveAccountButton", "현재 계정 등록"); + 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 _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 }; + private readonly Panel _empty = new() { Dock = DockStyle.Fill, BackColor = AccountUiTheme.Surface }; + private readonly ProgressBar _progress = new() { Dock = DockStyle.Bottom, Height = 3, Style = ProgressBarStyle.Marquee, Visible = false }; + private readonly System.Windows.Forms.Timer _refreshTimer = new() { Interval = 5000 }; + private IReadOnlyList _items = Array.Empty(); + private CancellationTokenSource? _loginCancellation; + private bool _busy; + private bool _reloading; + private string? _desktopPath; + internal bool IsOperationInProgress => _busy; + private SavedCodexAccount? Selected => _accounts.SelectedItem as SavedCodexAccount; + + public AccountManagerForm(CodexAccountStore store, Func suspend, Action resume, Action? accountsChanged = null) + { + SuspendLayout(); + _store = store; _suspend = suspend; _resume = resume; _accountsChanged = accountsChanged; + Name = "AccountManagerForm"; + AccountUiTheme.SetForm(this); + Text = "Codex 계정 관리"; + ClientSize = new Size(980, 660); + MinimumSize = new Size(850, 520); + StartPosition = FormStartPosition.CenterScreen; + KeyPreview = true; + var root = AccountUiTheme.Stack(4); + root.Padding = new Padding(24); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 76)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 78)); + root.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + root.RowStyles.Add(new RowStyle(SizeType.Absolute, 28)); + + var header = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; + header.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100)); header.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize)); + var headings = AccountUiTheme.Stack(2); + headings.RowStyles.Add(new RowStyle(SizeType.Absolute, 42)); headings.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + headings.Controls.Add(AccountUiTheme.Label("Codex 계정", 22, true), 0, 0); + headings.Controls.Add(AccountUiTheme.Label("직접 고르고, 필요할 때 전환하세요.", color: AccountUiTheme.Muted), 0, 1); + _add.Anchor = AnchorStyles.Right | AnchorStyles.Top; + header.Controls.Add(headings, 0, 0); header.Controls.Add(_add, 1, 0); + root.Controls.Add(header, 0, 0); + + var notice = new Panel { Dock = DockStyle.Fill, BackColor = AccountUiTheme.Raised, Padding = new Padding(14, 10, 14, 10), Margin = new Padding(0, 0, 0, 14) }; + _status.Name = "StatusLabel"; _status.Dock = DockStyle.Fill; _status.AutoSize = false; + var noticeActions = new FlowLayoutPanel { Dock = DockStyle.Right, AutoSize = true, WrapContents = false, FlowDirection = FlowDirection.RightToLeft }; + _cancel.Visible = false; _recover.Visible = false; + _registerActive.Visible = false; + noticeActions.Controls.Add(_cancel); noticeActions.Controls.Add(_recover); noticeActions.Controls.Add(_registerActive); + notice.Controls.Add(_status); notice.Controls.Add(noticeActions); notice.Controls.Add(_progress); + root.Controls.Add(notice, 0, 1); + + var body = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 2, RowCount = 1, Margin = new Padding(0) }; + body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30)); body.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 70)); + var sidebar = AccountUiTheme.Stack(2); + sidebar.BackColor = AccountUiTheme.Surface; sidebar.Margin = new Padding(0, 0, 16, 0); + sidebar.RowStyles.Add(new RowStyle(SizeType.Absolute, 52)); sidebar.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); + var listHeader = new Panel { Dock = DockStyle.Fill, Padding = new Padding(14, 8, 10, 6) }; + _count.Dock = DockStyle.Fill; _count.AutoSize = false; _count.TextAlign = ContentAlignment.MiddleLeft; + _refresh.Dock = DockStyle.Right; _refresh.MinimumSize = new Size(70, 32); _refresh.Padding = new Padding(6, 0, 6, 0); + listHeader.Controls.Add(_count); listHeader.Controls.Add(_refresh); + sidebar.Controls.Add(listHeader, 0, 0); sidebar.Controls.Add(_accounts, 0, 1); + body.Controls.Add(sidebar, 0, 0); + var right = new Panel { Dock = DockStyle.Fill, Margin = new Padding(0) }; + BuildDetail(); BuildEmpty(); + 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); + 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("계정 목록을 새로 확인했습니다."); }; + _register.Click += async (_, _) => await RegisterCurrentAsync(); + _registerActive.Click += async (_, _) => await RegisterCurrentAsync(); + _rename.Click += (_, _) => RenameSelected(); + _add.Click += async (_, _) => await AddAccountAsync(); + _switch.Click += async (_, _) => await SwitchSelectedAsync(); + _delete.Click += (_, _) => DeleteSelected(); + _recover.Click += async (_, _) => await RecoverAsync(); + _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 += (_, _) => + { + var area = Screen.FromControl(this).WorkingArea; + Size = new Size(Math.Min(Width, area.Width), Math.Min(Height, area.Height)); + 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("진행 중인 작업이 있습니다. 로그인 중이라면 ‘로그인 취소’를 눌러주세요."); } }; + FormClosed += (_, _) => _refreshTimer.Stop(); + ResumeLayout(performLayout: true); + } + + private Task RegisterCurrentAsync() => RunAsync(true, "현재 계정을 등록하고 있습니다…", async () => + { + var account = _store.RegisterCurrent(NextName()); + Reload(account.Id); + SetStatus($"‘{account.Label}’ 등록 완료. 이름은 오른쪽 ‘이름 변경’에서 바꿀 수 있습니다.", success: true); + _accountsChanged?.Invoke(); + await Task.CompletedTask; + }); + + private void BuildDetail() + { + var layout = AccountUiTheme.Stack(7); + layout.Dock = DockStyle.Top; + layout.MinimumSize = new Size(0, 428); + layout.Height = 428; + 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.Percent, 100)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 38)); + var titleRow = new Panel { Dock = DockStyle.Fill }; + _rename.Dock = DockStyle.Right; + _title.Dock = DockStyle.Fill; _title.AutoSize = false; _title.AutoEllipsis = true; + titleRow.Controls.Add(_title); titleRow.Controls.Add(_rename); + layout.Controls.Add(titleRow, 0, 0); + var identity = AccountUiTheme.Stack(2); + identity.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); identity.RowStyles.Add(new RowStyle(SizeType.Percent, 50)); + _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); + 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); + 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); + layout.Controls.Add(usage, 0, 2); + _switchHelp.Dock = DockStyle.Fill; _switchHelp.AutoSize = false; + layout.Controls.Add(_switchHelp, 0, 4); + _switch.Dock = DockStyle.Fill; + layout.Controls.Add(_switch, 0, 5); + _delete.Anchor = AnchorStyles.Left | AnchorStyles.Bottom; + _delete.MinimumSize = new Size(140, 28); _delete.Padding = new Padding(0); _delete.BackColor = AccountUiTheme.Surface; + _delete.ForeColor = AccountUiTheme.Muted; + layout.Controls.Add(_delete, 0, 6); + _detail.Controls.Add(layout); + _detail.Resize += (_, _) => layout.Height = Math.Max(layout.MinimumSize.Height, _detail.ClientSize.Height); + } + + private void BuildEmpty() + { + var layout = AccountUiTheme.Stack(5); layout.Padding = new Padding(32); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 35)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 48)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 80)); + layout.RowStyles.Add(new RowStyle(SizeType.Absolute, 44)); + layout.RowStyles.Add(new RowStyle(SizeType.Percent, 65)); + layout.Controls.Add(AccountUiTheme.Label("첫 계정을 등록하세요", 19, true), 0, 1); + var description = AccountUiTheme.Label("지금 Codex에서 사용하는 계정을 저장하면 시작할 수 있습니다.\n이름은 나중에 바꿀 수 있고, 현재 로그인은 유지됩니다.", color: AccountUiTheme.Muted); + description.Dock = DockStyle.Fill; description.AutoSize = false; + layout.Controls.Add(description, 0, 2); + _register.Dock = DockStyle.Fill; + layout.Controls.Add(_register, 0, 3); + _empty.Controls.Add(layout); + } + + private string NextName() + { + for (var n = 1; ; n++) if (!_items.Any(a => a.Label == $"계정 {n}")) return $"계정 {n}"; + } + + private void RenameSelected() + { + if (_busy || Selected is not { } account) return; + using var dialog = new AccountNameDialog(account.Label); + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("이름 변경을 취소했습니다."); return; } + try { _store.Rename(account.Id, dialog.AccountName); Reload(account.Id); _accountsChanged?.Invoke(); SetStatus("계정 이름을 변경했습니다.", success: true); } + catch (Exception ex) { SetStatus(ex.Message, error: true); } + } + + private async Task AddAccountAsync() + { + if (_busy || _items.Count == 0) return; + using var dialog = new AccountNameDialog("", adding: true); + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("계정 추가를 취소했습니다."); return; } + var name = string.IsNullOrWhiteSpace(dialog.AccountName) ? NextName() : dialog.AccountName; + await RunAsync(true, "브라우저에서 추가할 계정으로 로그인하세요. 현재 계정은 유지됩니다.", async () => + { + using var cancellation = new CancellationTokenSource(); + _loginCancellation = cancellation; _cancel.Visible = true; _cancel.Enabled = true; + try + { + using var login = await CodexAccountRuntime.LoginAsync(_store.RootPath, cancellation.Token); + var account = _store.ImportLoginFile(login.AuthPath, name); + Reload(account.Id); _accountsChanged?.Invoke(); + SetStatus($"‘{account.Label}’ 추가 완료. 목록에서 선택해 전환할 수 있습니다.", success: true); + } + finally { _loginCancellation = null; } + }); + } + + private async Task SwitchSelectedAsync() + { + if (_busy || Selected is not { IsActive: false } target) return; + await RunAsync(true, "전환 조건을 확인하고 있습니다…", async () => + { + var source = _items.FirstOrDefault(a => a.IsActive)?.Label ?? "현재 로그인"; + using var dialog = new AccountSwitchDialog(source, target.Label); + if (dialog.ShowDialog(this) != DialogResult.OK) { SetStatus("전환을 취소했습니다. 현재 계정은 유지됩니다."); return; } + CodexAccountRuntime.AssertWritersStopped(); + CodexAccountRuntime.ClearStaleLoginDirectories(_store.RootPath); + _store.SwitchTo(target.Id, CodexAccountRuntime.AssertWritersStopped); + Reload(target.Id); _accountsChanged?.Invoke(); + SetStatus($"‘{target.Label}’ 적용 완료. 열린 Codex에서 계정을 확인하세요.", success: true); + try { CodexAccountRuntime.LaunchDesktop(_desktopPath); } + catch { SetStatus($"‘{target.Label}’은 적용되었습니다. 시작 메뉴에서 Codex를 열어주세요."); } + await Task.CompletedTask; + }); + } + + private void DeleteSelected() + { + if (_busy || Selected is not { IsActive: false } account) return; + if (MessageBox.Show(this, $"‘{account.Label}’의 저장된 로그인을 삭제할까요?\n다시 사용하려면 해당 계정으로 로그인해야 합니다.", "저장된 로그인 삭제", + MessageBoxButtons.OKCancel, MessageBoxIcon.Warning) != DialogResult.OK) { SetStatus("삭제를 취소했습니다."); return; } + try { _store.Remove(account.Id); Reload(); _accountsChanged?.Invoke(); SetStatus("저장된 로그인을 삭제했습니다.", success: true); } + catch (Exception ex) { SetStatus(ex.Message, error: true); } + } + + private async Task RecoverAsync() + { + await RunAsync(true, "복구 조건을 확인하고 있습니다…", async () => + { + using var dialog = new AccountSwitchDialog("", "", recovery: true); + 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); + await Task.CompletedTask; + }); + } + + private async Task RunAsync(bool suspend, string message, Func action) + { + if (_busy) return; + _busy = true; UpdateActions(); _progress.Visible = true; SetStatus(message); + using var gate = new Mutex(false, CodexAccountStore.TransactionMutexName); + var ownsGate = false; + try + { + _desktopPath ??= CodexAccountRuntime.CaptureDesktopLaunchPath(); + if (suspend) await _suspend(); + try { ownsGate = gate.WaitOne(0); } catch (AbandonedMutexException) { ownsGate = true; } + if (!ownsGate) throw new InvalidOperationException("설치 또는 다른 계정 작업이 진행 중입니다. 완료 후 다시 시도하세요."); + await action(); + } + catch (OperationCanceledException) { SetStatus("로그인을 취소했습니다. 현재 계정은 유지됩니다."); } + catch (Exception ex) { SetStatus(ex.Message, error: true); } + finally + { + if (ownsGate) gate.ReleaseMutex(); + _busy = false; _cancel.Visible = false; _progress.Visible = false; + Reload(quiet: true); UpdateActions(); + if (suspend) + { + try { _resume(); } + catch { SetStatus("계정 작업은 끝났지만 사용량 조회를 재개하지 못했습니다. 위젯을 다시 열어주세요.", error: true); } + } + } + } + + private bool Reload(string? selectId = null, bool quiet = false) + { + try + { + var fresh = _store.IsEnabled ? _store.ListAccounts() : Array.Empty(); + var sorted = fresh.OrderByDescending(a => a.IsActive).ThenBy(a => a.Label, StringComparer.CurrentCulture).ToArray(); + var previousId = selectId ?? Selected?.Id; + if (!_items.SequenceEqual(sorted) || selectId is not null) + { + _reloading = true; + _accounts.BeginUpdate(); _accounts.Items.Clear(); + foreach (var account in sorted) _accounts.Items.Add(account); + _items = sorted; + var index = Array.FindIndex(sorted, a => a.Id == previousId); + _accounts.SelectedIndex = index >= 0 ? index : sorted.Length > 0 ? 0 : -1; + _accounts.EndUpdate(); _reloading = false; + } + _count.Text = $"계정 {_items.Count}개"; + _empty.Visible = _items.Count == 0; _detail.Visible = _items.Count > 0; + ShowSelected(); UpdateActions(); + if (_store.HasPendingRecovery) 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)); + } + catch (Exception ex) { SetStatus(ex.Message, error: true); return false; } + } + + private void ShowSelected() + { + if (Selected is not { } account) { UpdateActions(); return; } + _title.Text = account.Label; + _state.Text = account.IsActive ? "● 현재 사용 중" : "저장된 계정"; + _identity.Text = account.IdentityHint; + _usageTitle.Text = account.IsActive ? "주간 잔여 사용량" : "주간 잔여 사용량 · 저장된 값"; + 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}" : "초기화 예정 —"; + _switchHelp.Text = account.IsActive ? "이 계정을 사용하고 있습니다. 이름은 언제든 바꿀 수 있습니다." : "전환 준비 화면에서 Codex 종료 상태를 확인합니다."; + _switch.Text = account.IsActive ? "현재 사용 중인 계정" : "이 계정으로 전환"; + UpdateActions(); + } + + private void UpdateActions() + { + var pending = _store.HasPendingRecovery; + _accounts.Enabled = !_busy; + _refresh.Enabled = !_busy; + _add.Enabled = !_busy && !pending && _items.Count > 0; + _register.Enabled = !_busy && !pending; + _registerActive.Visible = _items.Count > 0 && !_items.Any(a => a.IsActive) && !pending; + _registerActive.Enabled = !_busy; + _rename.Enabled = !_busy && !pending && Selected is not null; + _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; + } + + private void SetStatus(string message, bool error = false, bool success = false) + { + _status.Text = message; + _status.ForeColor = error ? AccountUiTheme.Error : success ? AccountUiTheme.Accent : AccountUiTheme.Text; + } + + protected override void Dispose(bool disposing) + { + if (disposing) _refreshTimer.Dispose(); + base.Dispose(disposing); + } +} diff --git a/src/AccountUiTheme.cs b/src/AccountUiTheme.cs new file mode 100644 index 0000000..404fd63 --- /dev/null +++ b/src/AccountUiTheme.cs @@ -0,0 +1,149 @@ +using System.Drawing.Drawing2D; + +namespace WeeklyUsageIndicator; + +internal static class AccountUiTheme +{ + internal static readonly Color Background = Color.FromArgb(22, 24, 28); + internal static readonly Color Surface = Color.FromArgb(29, 32, 38); + internal static readonly Color Raised = Color.FromArgb(40, 44, 52); + internal static readonly Color Border = Color.FromArgb(58, 64, 74); + internal static readonly Color Text = Color.FromArgb(238, 241, 244); + internal static readonly Color Muted = Color.FromArgb(161, 172, 186); + internal static readonly Color Accent = Color.FromArgb(145, 218, 195); + internal static readonly Color Error = Color.FromArgb(255, 166, 158); + internal static readonly Color Warning = Color.FromArgb(243, 203, 137); + + internal static Label Label(string text, float size = 9.5f, bool bold = false, Color? color = null) => new() + { + Text = text, AutoSize = true, ForeColor = color ?? Text, + Font = new Font("맑은 고딕", size, bold ? FontStyle.Bold : FontStyle.Regular), + Margin = new Padding(0), UseMnemonic = false + }; + + internal static Button Button(string name, string text, bool primary = false) => new AccountButton() + { + Name = name, Text = text, AutoSize = true, MinimumSize = new Size(92, 38), + Padding = new Padding(12, 4, 12, 4), Margin = new Padding(0), + FlatStyle = FlatStyle.Flat, BackColor = primary ? Accent : Raised, + ForeColor = primary ? Background : Text, Cursor = Cursors.Hand, + UseVisualStyleBackColor = false, FlatAppearance = { BorderSize = 0, MouseOverBackColor = primary ? Color.FromArgb(175, 235, 216) : Color.FromArgb(56, 61, 72) } + }; + + internal static void SetForm(Form form) + { + form.AutoScaleMode = AutoScaleMode.Dpi; + form.Font = new Font("맑은 고딕", 9.5f); + form.AutoScaleDimensions = new SizeF(96, 96); + form.BackColor = Background; + form.ForeColor = Text; + form.ShowIcon = false; + } + + internal static TableLayoutPanel Stack(int rows) => new() + { + Dock = DockStyle.Fill, ColumnCount = 1, RowCount = rows, + ColumnStyles = { new ColumnStyle(SizeType.Percent, 100) }, + Margin = new Padding(0), Padding = new Padding(0) + }; +} + +internal sealed class AccountButton : Button +{ + public override Size GetPreferredSize(Size proposedSize) + { + var preferred = base.GetPreferredSize(proposedSize); + return new Size(preferred.Width, MinimumSize.Height); + } + + protected override void OnPaint(PaintEventArgs e) + { + if (Enabled) { base.OnPaint(e); return; } + e.Graphics.Clear(AccountUiTheme.Raised); + TextRenderer.DrawText(e.Graphics, Text, Font, ClientRectangle, AccountUiTheme.Muted, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.NoPrefix); + } +} + +internal sealed class AccountListBox : ListBox +{ + internal AccountListBox() + { + DrawMode = DrawMode.OwnerDrawFixed; + BorderStyle = BorderStyle.None; + BackColor = AccountUiTheme.Surface; + ForeColor = AccountUiTheme.Text; + IntegralHeight = false; + ItemHeight = 82; + DoubleBuffered = true; + } + + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + ItemHeight = (int)Math.Round(82 * DeviceDpi / 96.0); + } + + protected override void OnDpiChangedAfterParent(EventArgs e) + { + base.OnDpiChangedAfterParent(e); + ItemHeight = (int)Math.Round(82 * DeviceDpi / 96.0); + } + + protected override void OnDrawItem(DrawItemEventArgs e) + { + if (e.Index < 0 || e.Index >= Items.Count || Items[e.Index] is not SavedCodexAccount account) return; + var selected = (e.State & DrawItemState.Selected) != 0; + var scale = DeviceDpi / 96f; + int S(int value) => (int)Math.Round(value * scale); + using var background = new SolidBrush(selected ? Color.FromArgb(43, 57, 57) : AccountUiTheme.Surface); + e.Graphics.FillRectangle(background, e.Bounds); + if (selected) + { + using var accent = new SolidBrush(AccountUiTheme.Accent); + e.Graphics.FillRectangle(accent, e.Bounds.Left, e.Bounds.Top + S(8), S(3), e.Bounds.Height - S(16)); + } + var inset = S(16); + var badgeWidth = account.IsActive ? S(48) : 0; + var title = new Rectangle(e.Bounds.Left + inset, e.Bounds.Top + S(13), e.Bounds.Width - inset * 2 - badgeWidth, S(25)); + using var titleFont = new Font(Font, FontStyle.Bold); + TextRenderer.DrawText(e.Graphics, account.Label, titleFont, title, AccountUiTheme.Text, + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + if (account.IsActive) + { + var badge = new Rectangle(e.Bounds.Right - inset - badgeWidth, title.Top, badgeWidth, title.Height); + TextRenderer.DrawText(e.Graphics, "사용 중", Font, badge, AccountUiTheme.Accent, + TextFormatFlags.Right | TextFormatFlags.VerticalCenter | TextFormatFlags.SingleLine); + } + var hint = new Rectangle(title.Left, e.Bounds.Top + S(43), e.Bounds.Width - inset * 2, S(24)); + TextRenderer.DrawText(e.Graphics, account.IdentityHint, Font, hint, AccountUiTheme.Muted, + TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix | TextFormatFlags.SingleLine); + if ((e.State & DrawItemState.Focus) != 0 && Focused) e.DrawFocusRectangle(); + } +} + +internal sealed class AccountUsageBar : Control +{ + private int? _remaining; + internal int? Remaining { get => _remaining; set { _remaining = value; Invalidate(); } } + + internal AccountUsageBar() + { + SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true); + Height = 8; + MinimumSize = new Size(30, 8); + AccessibleName = "주간 잔여 사용량"; + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; + using var track = new SolidBrush(AccountUiTheme.Border); + e.Graphics.FillRectangle(track, ClientRectangle); + if (_remaining is { } remaining) + { + using var fill = new SolidBrush(remaining <= 15 ? AccountUiTheme.Warning : AccountUiTheme.Accent); + e.Graphics.FillRectangle(fill, 0, 0, Width * Math.Clamp(remaining, 0, 100) / 100f, Height); + } + } +} diff --git a/src/AppServerClient.cs b/src/AppServerClient.cs new file mode 100644 index 0000000..bfe0219 --- /dev/null +++ b/src/AppServerClient.cs @@ -0,0 +1,423 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Text.Json; + +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); + +/// Owns one serialized, cancellable app-server session. No session owns another session's state. +internal sealed class AppServerClient : IDisposable +{ + private readonly object _stateLock = new(); + private readonly SemaphoreSlim _operationGate = new(1, 1); + private readonly Func _startInfoFactory; + private CancellationTokenSource _generation = new(); + private Session? _session; + private bool _suspended; + private bool _disposed; + private int _shutdowns; + + public AppServerClient() : this(CreateStartInfo) { } + + // Test seam: fake stdio server only; production always uses the official local executable. + internal AppServerClient(Func startInfoFactory) => _startInfoFactory = startInfoFactory; + + public async Task GetWeeklyUsageAsync(CancellationToken cancellationToken) => + (await ReadUsageAsync(includeAccount: false, cancellationToken).ConfigureAwait(false)).Usage; + + public Task GetWeeklyUsageWithAccountAsync(CancellationToken cancellationToken) => + ReadUsageAsync(includeAccount: true, cancellationToken); + + private async Task ReadUsageAsync(bool includeAccount, CancellationToken cancellationToken) + { + CancellationToken generation; + lock (_stateLock) + { + ThrowIfUnavailable(); + generation = _generation.Token; + } + + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, generation); + await _operationGate.WaitAsync(linked.Token).ConfigureAwait(false); + try + { + linked.Token.ThrowIfCancellationRequested(); + var session = await EnsureStartedAsync(linked.Token).ConfigureAwait(false); + (string? Email, string? PlanType, bool IsChatGpt) account = (null, null, false); + if (includeAccount) + account = ParseAccount(await CallCoreAsync(session, "account/read", new { refreshToken = false }, linked.Token).ConfigureAwait(false)); + var result = await CallCoreAsync(session, "account/rateLimits/read", new { }, linked.Token).ConfigureAwait(false); + if (includeAccount) + { + var after = ParseAccount(await CallCoreAsync(session, "account/read", new { refreshToken = false }, linked.Token).ConfigureAwait(false)); + if (account != after) throw new IOException("The Codex helper account changed during the usage read. Refresh again."); + } + linked.Token.ThrowIfCancellationRequested(); + return new CodexAccountUsage(ParseWeeklyUsage(result), account.Email, account.PlanType, account.IsChatGpt); + } + finally { _operationGate.Release(); } + } + + private static (string? Email, string? PlanType, bool IsChatGpt) ParseAccount(JsonElement response) + { + if (response.ValueKind != JsonValueKind.Object || !response.TryGetProperty("account", out var account) || + account.ValueKind != JsonValueKind.Object) return (null, null, false); + static string? ReadString(JsonElement value, string key) => + value.TryGetProperty(key, out var field) && field.ValueKind == JsonValueKind.String && field.GetString() is { Length: <= 320 } text + ? text : null; + return (ReadString(account, "email"), ReadString(account, "planType"), ReadString(account, "type") == "chatgpt"); + } + + private async Task EnsureStartedAsync(CancellationToken token) + { + if (_session is { Initialized: true } existing && !existing.Process.HasExited && !existing.Disconnected) + return existing; + + await StopSessionAsync().ConfigureAwait(false); + Session session; + lock (_stateLock) + { + // Starting the child and publishing ownership are atomic with respect to suspension. + ThrowIfUnavailable(); + token.ThrowIfCancellationRequested(); + var process = new Process { StartInfo = _startInfoFactory(), EnableRaisingEvents = true }; + try + { + if (!process.Start()) throw new IOException("Codex app-server could not be started."); + session = new Session(process); + _session = session; + } + catch { process.Dispose(); throw; } + } + + session.Reader = ReadLoopAsync(session); + session.ErrorReader = DrainErrorAsync(session); + try + { + await CallCoreAsync(session, "initialize", new + { + clientInfo = new { name = "weekly-usage-indicator", title = "Weekly Usage Indicator", version = "1.5.1" }, + capabilities = new { experimentalApi = true } + }, token).ConfigureAwait(false); + await SendLineAsync(session, JsonSerializer.Serialize(new { method = "initialized" }), token).ConfigureAwait(false); + token.ThrowIfCancellationRequested(); + session.Initialized = true; + return session; + } + catch + { + await StopSessionAsync().ConfigureAwait(false); + throw; + } + } + + private static ProcessStartInfo CreateStartInfo() + { + var path = LocateCodexExecutable() + ?? throw new FileNotFoundException("codex.exe was not found. Install or open Codex Desktop first."); + return new ProcessStartInfo + { + FileName = path, + Arguments = "app-server --stdio", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + }; + } + + private static async Task CallCoreAsync(Session session, string method, object parameters, CancellationToken token) + { + using var linked = CancellationTokenSource.CreateLinkedTokenSource(token, session.Lifetime.Token); + var id = ++session.NextRequestId; + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!session.Pending.TryAdd(id, completion)) throw new IOException("Could not register a Codex request."); + try + { + await SendLineAsync(session, JsonSerializer.Serialize(new { id, method, @params = parameters }), linked.Token).ConfigureAwait(false); + return await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false); + } + finally { session.Pending.TryRemove(id, out _); } + } + + private static async Task SendLineAsync(Session session, string line, CancellationToken token) + { + if (session.Disconnected) throw new IOException("Codex app-server disconnected."); + await session.Process.StandardInput.WriteLineAsync(line.AsMemory(), token).ConfigureAwait(false); + await session.Process.StandardInput.FlushAsync(token).ConfigureAwait(false); + } + + private static async Task ReadLoopAsync(Session session) + { + try + { + while (!session.Lifetime.IsCancellationRequested) + { + var line = await session.Process.StandardOutput.ReadLineAsync(session.Lifetime.Token).ConfigureAwait(false); + if (line is null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + using var document = JsonDocument.Parse(line); + var root = document.RootElement; + 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 _)) + // 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.")); + else if (root.TryGetProperty("result", out var result)) + completion.TrySetResult(result.Clone()); + else completion.TrySetException(new InvalidDataException("Codex returned an incomplete response.")); + } + catch (JsonException) { /* Ignore non-protocol diagnostics. */ } + } + } + catch (OperationCanceledException) when (session.Lifetime.IsCancellationRequested) { } + catch { /* The sanitized disconnect below is sufficient for callers. */ } + finally + { + session.Disconnected = true; + foreach (var completion in session.Pending.Values) + completion.TrySetException(new IOException("Codex app-server disconnected.")); + } + } + + private static async Task DrainErrorAsync(Session session) + { + try + { + while (await session.Process.StandardError.ReadLineAsync(session.Lifetime.Token).ConfigureAwait(false) is not null) { } + } + catch { /* Never retain or display stderr, which may contain account context. */ } + } + + /// Blocks starts immediately and returns only once the owned writer has exited. + public async Task SuspendAsync() + { + CancellationTokenSource generation; + lock (_stateLock) + { + _suspended = true; + _shutdowns++; + generation = _generation; + } + generation.Cancel(); + await _operationGate.WaitAsync().ConfigureAwait(false); + try { await StopSessionAsync().ConfigureAwait(false); } + finally + { + lock (_stateLock) { _shutdowns--; } + _operationGate.Release(); + } + } + + public void Resume() + { + lock (_stateLock) + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_shutdowns != 0) throw new InvalidOperationException("Codex helper shutdown is still in progress."); + if (!_suspended) return; + if (_session is not null) throw new IOException("The previous Codex helper has not stopped."); + _generation.Dispose(); + _generation = new CancellationTokenSource(); + _suspended = false; + } + } + + private async Task StopSessionAsync() + { + var session = _session; + if (session is null) return; + session.Lifetime.Cancel(); + try { session.Process.StandardInput.Close(); } catch { } + try + { + if (!session.Process.HasExited) session.Process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) when (session.Process.HasExited) { } + + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(8)); + try { await session.Process.WaitForExitAsync(timeout.Token).ConfigureAwait(false); } + catch (OperationCanceledException) + { + // Keep ownership and remain suspended. The caller must not change credentials after this failure. + throw new IOException("Codex helper did not exit. Account switching is blocked."); + } + await Task.WhenAll(session.Reader, session.ErrorReader).WaitAsync(TimeSpan.FromSeconds(3)).ConfigureAwait(false); + session.Process.Dispose(); + session.Lifetime.Dispose(); + _session = null; + } + + private void ThrowIfUnavailable() + { + ObjectDisposedException.ThrowIf(_disposed, this); + if (_suspended) throw new OperationCanceledException("Codex usage is paused during account switching."); + } + + public void Pause() => SuspendAsync().GetAwaiter().GetResult(); + + public void Dispose() + { + lock (_stateLock) + { + if (_disposed) return; + _disposed = true; + } + try { SuspendAsync().GetAwaiter().GetResult(); } + catch { /* OS ownership checks also guard switching; Dispose must not tear down the UI with an exception. */ } + // Keep gates and generation valid for callers already unwinding their canceled operation. + } + + private sealed class Session(Process process) + { + internal readonly Process Process = process; + internal readonly CancellationTokenSource Lifetime = new(); + internal readonly ConcurrentDictionary> Pending = new(); + internal Task Reader = Task.CompletedTask; + internal Task ErrorReader = Task.CompletedTask; + internal int NextRequestId; + internal bool Initialized; + internal volatile bool Disconnected; + } + private static UsageSnapshot ParseWeeklyUsage(JsonElement response) + { + var snapshot = SelectCoreSnapshot(response); + var limitId = snapshot.TryGetProperty("limitId", out var idElement) && idElement.ValueKind == JsonValueKind.String + ? idElement.GetString() ?? "codex" + : "codex"; + + var windows = new List<(int Used, long? Duration, long? ResetsAt, string Name)>(); + AddWindow(snapshot, "primary", windows); + AddWindow(snapshot, "secondary", windows); + + if (windows.Count == 0) + throw new InvalidDataException("Codex did not return a usage window."); + + var weekly = windows + .OrderBy(window => WeeklyDistance(window.Duration)) + .ThenByDescending(window => window.Duration ?? 0) + .First(); + + DateTimeOffset? resetsAt = null; + if (weekly.ResetsAt is > 0) + resetsAt = DateTimeOffset.FromUnixTimeSeconds(weekly.ResetsAt.Value).ToLocalTime(); + + return new UsageSnapshot( + Math.Clamp(weekly.Used, 0, 100), + resetsAt, + weekly.Duration, + limitId); + } + + private static JsonElement SelectCoreSnapshot(JsonElement response) + { + if (response.TryGetProperty("rateLimitsByLimitId", out var byId) && byId.ValueKind == JsonValueKind.Object) + { + if (byId.TryGetProperty("codex", out var codex) && codex.ValueKind == JsonValueKind.Object) + return codex; + + foreach (var property in byId.EnumerateObject()) + { + if (property.Value.ValueKind != JsonValueKind.Object) continue; + if (!property.Value.TryGetProperty("limitName", out var name) || name.ValueKind == JsonValueKind.Null) + return property.Value; + } + } + + if (response.TryGetProperty("rateLimits", out var legacy) && legacy.ValueKind == JsonValueKind.Object) + return legacy; + + throw new InvalidDataException("Codex did not return rate-limit data."); + } + + private static void AddWindow( + JsonElement snapshot, + string propertyName, + ICollection<(int Used, long? Duration, long? ResetsAt, string Name)> windows) + { + if (!snapshot.TryGetProperty(propertyName, out var window) || window.ValueKind != JsonValueKind.Object) + return; + if (!window.TryGetProperty("usedPercent", out var usedElement) || !usedElement.TryGetInt32(out var used)) + return; + + long? duration = null; + if (window.TryGetProperty("windowDurationMins", out var durationElement) && + durationElement.ValueKind == JsonValueKind.Number && + durationElement.TryGetInt64(out var durationValue)) + { + duration = durationValue; + } + + long? resetsAt = null; + if (window.TryGetProperty("resetsAt", out var resetElement) && + resetElement.ValueKind == JsonValueKind.Number && + resetElement.TryGetInt64(out var resetValue)) + { + resetsAt = resetValue; + } + + windows.Add((used, duration, resetsAt, propertyName)); + } + + private static long WeeklyDistance(long? durationMinutes) + { + const long weekMinutes = 7 * 24 * 60; + return durationMinutes is null + ? long.MaxValue / 2 + : Math.Abs(durationMinutes.Value - weekMinutes); + } + + internal static string? LocateCodexExecutable() + { + var configured = Environment.GetEnvironmentVariable("CODEX_WEEKLY_INDICATOR_CODEX_PATH"); + if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) + return configured; + + var localBin = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "OpenAI", + "Codex", + "bin"); + + try + { + if (Directory.Exists(localBin)) + { + var localCodex = Directory + .EnumerateFiles(localBin, "codex.exe", SearchOption.AllDirectories) + .Select(path => new FileInfo(path)) + .OrderByDescending(file => file.LastWriteTimeUtc) + .FirstOrDefault(); + if (localCodex is not null) return localCodex.FullName; + } + } + catch + { + // Continue to PATH lookup. + } + + var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) + { + try + { + var candidate = Path.Combine(directory.Trim().Trim('"'), "codex.exe"); + if (File.Exists(candidate)) return candidate; + } + catch + { + // Ignore malformed PATH entries. + } + } + + return null; + } + +} diff --git a/src/CodexAccountRuntime.cs b/src/CodexAccountRuntime.cs new file mode 100644 index 0000000..58babfb --- /dev/null +++ b/src/CodexAccountRuntime.cs @@ -0,0 +1,455 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Principal; +using Microsoft.Win32.SafeHandles; + +namespace WeeklyUsageIndicator; + +internal static class CodexAccountRuntime +{ + private static readonly SemaphoreSlim LoginGate = new(1, 1); + internal const string LoginOwnerMarker = "weekly-usage-indicator-login-v1"; + + internal static void AssertWritersStopped() + { + // All Codex processes are potential writers, including CLI/IDE helpers whose + // CODEX_HOME cannot be verified from another process. Never terminate them. + foreach (var name in new[] { "codex", "ChatGPT" }) + { + var processes = Process.GetProcessesByName(name); + try + { + foreach (var process in processes) + { + try + { + if (process.HasExited) continue; + var path = name == "codex" ? null : process.MainModule?.FileName; + if (name == "codex" || path is null || IsPackagedDesktopPath(path)) + throw WritersRunning(); + } + catch (System.ComponentModel.Win32Exception) { throw WritersRunning(); } + catch (InvalidOperationException) + { + // A process that disappeared is harmless; an accessible live + // process or an uninspectable process must block the write. + try { if (process.HasExited) continue; } + catch { } + throw WritersRunning(); + } + } + } + finally { foreach (var process in processes) process.Dispose(); } + } + } + + private static InvalidOperationException WritersRunning() => new( + "Codex 앱과 Codex CLI·IDE 작업을 모두 종료한 뒤 다시 시도하세요. 실행 중인 프로세스는 자동 종료하지 않습니다."); + + internal static string? CaptureDesktopLaunchPath() + { + var processes = Process.GetProcessesByName("ChatGPT"); + try + { + foreach (var process in processes) + { + try + { + var path = process.MainModule?.FileName; + if (IsPackagedDesktopPath(path) && File.Exists(path)) return path; + } + catch (System.ComponentModel.Win32Exception) { } + catch (InvalidOperationException) { } + } + } + finally { foreach (var process in processes) process.Dispose(); } + return null; + } + + internal static bool IsPackagedDesktopPath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path)) return false; + try + { + var file = new FileInfo(Path.GetFullPath(path)); + var app = file.Directory; + var package = app?.Parent; + var windowsApps = package?.Parent; + return file.Name.Equals("ChatGPT.exe", StringComparison.OrdinalIgnoreCase) + && app?.Name.Equals("app", StringComparison.OrdinalIgnoreCase) == true + && package?.Name.StartsWith("OpenAI.Codex_", StringComparison.OrdinalIgnoreCase) == true + && package.Name.EndsWith("__2p2nqsd0c76g0", StringComparison.OrdinalIgnoreCase) + && windowsApps?.FullName.Equals( + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WindowsApps"), + StringComparison.OrdinalIgnoreCase) == true; + } + catch (ArgumentException) { return false; } + catch (NotSupportedException) { return false; } + } + + internal static void LaunchDesktop(string? verifiedPath) + { + if (!IsPackagedDesktopPath(verifiedPath) || !File.Exists(verifiedPath)) + throw new InvalidOperationException("Codex 실행 경로가 없거나 앱이 업데이트되었습니다. 시작 메뉴에서 Codex를 열어 주세요."); + try + { + using var process = Process.Start(new ProcessStartInfo(verifiedPath!) { UseShellExecute = true }); + } + catch { throw new InvalidOperationException("Codex를 자동으로 열지 못했습니다. 시작 메뉴에서 열어 주세요."); } + } + + internal static async Task LoginAsync(string vaultRoot, CancellationToken cancellationToken) + { + if (!await LoginGate.WaitAsync(0, cancellationToken)) + throw new InvalidOperationException("이미 계정 로그인이 진행 중입니다."); + CodexLoginResult? staging = null; + try + { + AssertUnmanagedLoginEnvironment(); + var executable = AppServerClient.LocateCodexExecutable(); + if (executable is null || !File.Exists(executable)) + throw new InvalidOperationException("설치된 Codex CLI를 찾지 못했습니다. Codex 앱을 먼저 실행해 주세요."); + staging = CreateLoginStaging(vaultRoot); + using var process = new Process { StartInfo = CreateLoginStartInfo(executable, staging.DirectoryPath) }; + using var loginJob = new CodexLoginJob(); + var started = false; + try + { + cancellationToken.ThrowIfCancellationRequested(); + if (!process.Start()) throw new InvalidOperationException(); + started = true; + // Assign immediately, before touching pipes or awaiting. The job's + // noninheritable handle closes on widget crash and kills this child. + loginJob.Attach(process); + } + catch (OperationCanceledException) { throw; } + catch + { + // An assignment failure must not leave an uncontained login alive. + if (started) + { + loginJob.Dispose(); + if (!process.HasExited) process.Kill(entireProcessTree: false); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + throw new InvalidOperationException("Codex 로그인 프로세스를 안전하게 시작하지 못했습니다."); + } + process.StandardInput.Close(); + // OAuth URLs and CLI diagnostics are not retained, logged, or shown. + var stdout = DiscardOutputAsync(process.StandardOutput); + var stderr = DiscardOutputAsync(process.StandardError); + try + { + await process.WaitForExitAsync(cancellationToken); + await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); + cancellationToken.ThrowIfCancellationRequested(); + if (process.ExitCode != 0 || !File.Exists(staging.AuthPath)) + throw new InvalidOperationException("로그인이 완료되지 않았습니다. 브라우저에서 계정을 확인한 뒤 다시 시도하세요."); + AssertNoReparsePoints(staging.AuthPath); + var length = new FileInfo(staging.AuthPath).Length; + if (length is <= 0 or > 1024 * 1024) + throw new InvalidOperationException("로그인 결과의 형식이 올바르지 않습니다."); + var result = staging; + staging = null; + return result; + } + finally + { + // Closing the job kills only the owned login process. Descendants + // break away so a browser opened by OAuth is never terminated. + // Always await login exit before deleting staging, including cancel. + loginJob.Dispose(); + if (!process.HasExited) + { + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + try { await Task.WhenAll(stdout, stderr).WaitAsync(TimeSpan.FromSeconds(5)); } + catch { /* No captured diagnostics are surfaced. */ } + } + } + catch (OperationCanceledException) { throw; } + catch (InvalidOperationException) { throw; } + catch { throw new InvalidOperationException("Codex 로그인 처리에 실패했습니다. 계정 상태를 확인한 뒤 다시 시도하세요."); } + finally + { + try { staging?.Dispose(); } + finally { LoginGate.Release(); } + } + } + + internal static ProcessStartInfo CreateLoginStartInfo(string executable, string stagingPath) + { + var start = new ProcessStartInfo(executable) + { + UseShellExecute = false, CreateNoWindow = true, + RedirectStandardInput = true, RedirectStandardOutput = true, RedirectStandardError = true, + WorkingDirectory = stagingPath + }; + start.ArgumentList.Add("login"); + start.ArgumentList.Add("-c"); + start.ArgumentList.Add("cli_auth_credentials_store=\"file\""); + foreach (var key in start.Environment.Keys.ToArray()) + { + if (key.StartsWith("CODEX_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("OPENAI_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("CHATGPT_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("SSH_", StringComparison.OrdinalIgnoreCase) + || key.Equals("RUST_LOG", StringComparison.OrdinalIgnoreCase)) start.Environment.Remove(key); + } + start.Environment["CODEX_HOME"] = stagingPath; + start.Environment["RUST_LOG"] = "off"; + return start; + } + + private static void AssertUnmanagedLoginEnvironment() + { + // Managed requirements can override -c and select a shared keyring. Login + // revokes existing auth before opening OAuth, so fail BEFORE launching it. + var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + foreach (var name in new[] { "requirements.toml", "config.toml" }) + { + var path = Path.Combine(commonData, "OpenAI", "Codex", name); + try + { + _ = File.GetAttributes(path); + throw new InvalidOperationException("관리형 Codex 설정이 감지되어 격리 로그인을 중단했습니다. 이 버전은 개인용 파일 인증 환경을 지원합니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (UnauthorizedAccessException) + { throw new InvalidOperationException("Codex 관리 설정을 확인할 수 없어 로그인을 시작하지 않았습니다."); } + } + } + + internal static CodexLoginResult CreateLoginStaging(string vaultRoot) + { + var root = Path.GetFullPath(vaultRoot); + AssertNoReparsePoints(root); + if (!Directory.Exists(root)) + throw new InvalidOperationException("계정 저장소를 먼저 만들어 주세요."); + var stage = Path.Combine(root, "login-" + Guid.NewGuid().ToString("N")); + var security = new DirectorySecurity(); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + var user = WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("현재 Windows 사용자를 확인할 수 없습니다."); + security.SetOwner(user); + foreach (var sid in new[] { user, new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null) }) + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + new DirectoryInfo(stage).Create(security); + var result = new CodexLoginResult(root, stage); + try + { + result.CreateOwnershipLease(); + File.WriteAllText(Path.Combine(stage, "config.toml"), "cli_auth_credentials_store = \"file\"\n", new System.Text.UTF8Encoding(false)); + return result; + } + catch { result.Dispose(); throw; } + } + + internal static void ClearStaleLoginDirectories(string vaultRoot) + { + // A crashed widget can leave its CLI login child alive. Do not clear any + // staging until every possible writer has exited, and skip held leases. + AssertWritersStopped(); + var root = Path.GetFullPath(vaultRoot); + AssertNoReparsePoints(root); + if (!Directory.Exists(root)) return; + foreach (var directory in Directory.EnumerateDirectories(root, "login-*", SearchOption.TopDirectoryOnly).Take(128)) + { + var name = Path.GetFileName(directory); + if (!Guid.TryParseExact(name[6..], "N", out _)) continue; + AssertNoReparsePoints(directory); + var marker = Path.Combine(directory, ".login-owner"); + AssertNoReparsePoints(marker); + if (!File.Exists(marker)) continue; + FileStream lease; + try { lease = new FileStream(marker, FileMode.Open, FileAccess.Read, FileShare.None); } + catch (IOException) { continue; } + bool owned; + using (lease) + { + if (lease.Length > 128) continue; + using var reader = new StreamReader(lease); + owned = reader.ReadToEnd() == LoginOwnerMarker; + } + if (!owned) continue; + AssertWritersStopped(); + using var stale = new CodexLoginResult(root, directory); + } + } + + internal static void AssertNoReparsePoints(string path) + { + for (var item = Path.GetFullPath(path); !string.IsNullOrEmpty(item); item = Path.GetDirectoryName(item)) + { + try + { + if ((File.GetAttributes(item) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("연결된 폴더나 파일에서는 계정 로그인을 수행할 수 없습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + } + } + + private static async Task DiscardOutputAsync(StreamReader reader) + { + var buffer = new char[2048]; + try { while (await reader.ReadAsync(buffer.AsMemory()) != 0) Array.Clear(buffer); } + finally { Array.Clear(buffer); } + } +} + +internal sealed class CodexLoginJob : IDisposable +{ + private readonly SafeFileHandle _handle; + + internal CodexLoginJob() + { + // Null security attributes make this private unnamed handle noninheritable. + _handle = CreateJobObjectW(IntPtr.Zero, null); + if (_handle.IsInvalid) + { + _handle.Dispose(); + throw new InvalidOperationException("로그인 프로세스 보호를 준비하지 못했습니다."); + } + var limits = new ExtendedLimitInformation + { + BasicLimitInformation = new BasicLimitInformation + { + // KILL_ON_JOB_CLOSE | SILENT_BREAKAWAY_OK: own the login process, + // while allowing its browser launcher/children to live independently. + LimitFlags = 0x00002000 | 0x00001000 + } + }; + if (!SetInformationJobObject(_handle, 9, ref limits, (uint)Marshal.SizeOf())) + { + _handle.Dispose(); + throw new InvalidOperationException("로그인 프로세스 보호를 설정하지 못했습니다."); + } + } + + internal void Attach(Process ownedProcess) + { + if (!AssignProcessToJobObject(_handle, ownedProcess.SafeHandle)) + throw new InvalidOperationException("로그인 프로세스 보호를 연결하지 못했습니다."); + } + + public void Dispose() => _handle.Dispose(); + + [StructLayout(LayoutKind.Sequential)] + private struct BasicLimitInformation + { + internal long PerProcessUserTimeLimit; + internal long PerJobUserTimeLimit; + internal uint LimitFlags; + internal UIntPtr MinimumWorkingSetSize; + internal UIntPtr MaximumWorkingSetSize; + internal uint ActiveProcessLimit; + internal UIntPtr Affinity; + internal uint PriorityClass; + internal uint SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + private struct IoCounters + { + internal ulong ReadOperationCount; + internal ulong WriteOperationCount; + internal ulong OtherOperationCount; + internal ulong ReadTransferCount; + internal ulong WriteTransferCount; + internal ulong OtherTransferCount; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ExtendedLimitInformation + { + internal BasicLimitInformation BasicLimitInformation; + internal IoCounters IoInfo; + internal UIntPtr ProcessMemoryLimit; + internal UIntPtr JobMemoryLimit; + internal UIntPtr PeakProcessMemoryUsed; + internal UIntPtr PeakJobMemoryUsed; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateJobObjectW(IntPtr attributes, string? name); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetInformationJobObject(SafeFileHandle job, int informationClass, + ref ExtendedLimitInformation information, uint length); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool AssignProcessToJobObject(SafeFileHandle job, SafeProcessHandle process); +} + +internal sealed class CodexLoginResult : IDisposable +{ + private readonly string _root; + private bool _disposed; + private FileStream? _ownershipLease; + internal string DirectoryPath { get; } + internal string AuthPath => Path.Combine(DirectoryPath, "auth.json"); + internal CodexLoginResult(string root, string directory) { _root = root; DirectoryPath = directory; } + + internal void CreateOwnershipLease() + { + _ownershipLease = new FileStream(Path.Combine(DirectoryPath, ".login-owner"), + FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None); + var marker = System.Text.Encoding.UTF8.GetBytes(CodexAccountRuntime.LoginOwnerMarker); + _ownershipLease.Write(marker); + _ownershipLease.Flush(flushToDisk: true); + } + + public void Dispose() + { + if (_disposed) return; + if (!Directory.Exists(DirectoryPath)) { _disposed = true; return; } + var full = Path.GetFullPath(DirectoryPath); + if (!string.Equals(Path.GetDirectoryName(full), _root, StringComparison.OrdinalIgnoreCase) + || !Path.GetFileName(full).StartsWith("login-", StringComparison.Ordinal) + || !Guid.TryParseExact(Path.GetFileName(full)[6..], "N", out _)) + throw new InvalidOperationException("로그인 임시 폴더의 범위를 확인하지 못했습니다."); + try + { + _ownershipLease?.Dispose(); + _ownershipLease = null; + CodexAccountRuntime.AssertNoReparsePoints(full); + // Inspect every node before any deletion; no recursive API traverses a link. + var files = new List(); + var directories = new List(); + Collect(full, files, directories); + foreach (var file in files) + { + CodexAccountRuntime.AssertNoReparsePoints(file); + File.Delete(file); + } + foreach (var directory in directories.AsEnumerable().Reverse()) Directory.Delete(directory, false); + Directory.Delete(full, false); + _disposed = true; + } + catch { throw new InvalidOperationException("로그인 임시 파일을 정리하지 못했습니다. 계정 저장소의 login- 임시 폴더를 확인해 주세요."); } + } + + private static void Collect(string path, List files, List directories) + { + foreach (var entry in Directory.EnumerateFileSystemEntries(path)) + { + var attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) throw new InvalidOperationException(); + if ((attributes & FileAttributes.Directory) != 0) + { + directories.Add(entry); + Collect(entry, files, directories); + } + else files.Add(entry); + if (files.Count + directories.Count > 2048) throw new InvalidOperationException(); + } + } +} diff --git a/src/CodexAccountStore.cs b/src/CodexAccountStore.cs new file mode 100644 index 0000000..37d138d --- /dev/null +++ b/src/CodexAccountStore.cs @@ -0,0 +1,648 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security.AccessControl; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; + +namespace WeeklyUsageIndicator; + +internal sealed record AccountIdentity(string Key, string Hint); +internal sealed record SavedCodexAccount(string Id, string Label, string IdentityHint, bool IsActive, + UsageSnapshot? Usage, DateTimeOffset? ObservedAt); + +/// +/// 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 const string TransactionMutexName = @"Local\CodexWeeklyUsageIndicator.AccountTransaction"; + private const int MaxAuthBytes = 1024 * 1024; + private const int MaxStoreBytes = 16 * 1024 * 1024; + private const int MaxAccounts = 20; + private static readonly byte[] Entropy = Encoding.UTF8.GetBytes("WeeklyUsageIndicator.Accounts.v1"); + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = false }; + private readonly string _vaultPath; + private readonly string _journalPath; + private string AuthPath => Path.Combine(CodexHome, "auth.json"); + private string AuthTempPath => Path.Combine(CodexHome, ".gfs-account-auth.tmp"); + public string RootPath { get; } + public string CodexHome { get; } + public bool IsEnabled => File.Exists(_vaultPath); + public bool HasPendingRecovery => File.Exists(_journalPath); + + // Internal deterministic crash seam. Production never supplies a callback. + internal Action? Checkpoint { get; set; } + + public CodexAccountStore(string? root = null, string? codexHome = null) + { + RootPath = Path.GetFullPath(root ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "CodexWeeklyUsageIndicator.Accounts")); + var configuredHome = Environment.GetEnvironmentVariable("CODEX_HOME"); + CodexHome = Path.GetFullPath(codexHome ?? (string.IsNullOrWhiteSpace(configuredHome) + ? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex") + : configuredHome)); + _vaultPath = Path.Combine(RootPath, "accounts.dpapi"); + _journalPath = Path.Combine(RootPath, "switch.dpapi"); + } + + public AccountIdentity GetCurrentIdentity() + { + CheckSupportedStore(); + return ParseIdentity(ReadBounded(AuthPath, MaxAuthBytes)); + } + + public IReadOnlyList ListAccounts() + { + if (!IsEnabled && !HasPendingRecovery) return Array.Empty(); + using var gate = AcquireLock(); + var vault = LoadVault(); + // Missing/logged-out auth is shown as no active account; invalid auth must remain visible as an error. + var key = File.Exists(AuthPath) ? GetCurrentIdentity().Key : null; + return vault.Accounts.Select(a => new SavedCodexAccount(a.Id, a.Label, a.Hint, + a.Key == key, a.Usage, a.ObservedAt)).ToArray(); + } + + public SavedCodexAccount RegisterCurrent(string label) + { + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + var bytes = ReadBounded(AuthPath, MaxAuthBytes); + var identity = ParseIdentity(bytes); + var vault = LoadVault(); + var entry = Upsert(vault, bytes, identity, label); + WriteEncrypted(_vaultPath, vault); + return PublicEntry(entry, true); + } + + public SavedCodexAccount ImportLoginFile(string path, string label) + { + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + var bytes = ReadBounded(Path.GetFullPath(path), MaxAuthBytes); + var identity = ParseIdentity(bytes); + var current = File.Exists(AuthPath) ? GetCurrentIdentity() : null; + // Never replace a live account's newer credentials with an isolated login's snapshot. + if (current?.Key == identity.Key) + throw new InvalidOperationException("현재 사용 중인 계정입니다. 현재 계정 등록을 사용하세요."); + var vault = LoadVault(); + var entry = Upsert(vault, bytes, identity, label); + WriteEncrypted(_vaultPath, vault); + return PublicEntry(entry, false); + } + + public void SwitchTo(string id, Action assertStopped) + { + ArgumentNullException.ThrowIfNull(assertStopped); + CheckSupportedStore(); + using var gate = AcquireLock(); + RequireNoRecovery(); + assertStopped(); + var beforeAuth = ReadBounded(AuthPath, MaxAuthBytes); + var source = ParseIdentity(beforeAuth); + var before = LoadVault(); + var target = before.Accounts.SingleOrDefault(a => a.Id == id) + ?? throw new InvalidOperationException("저장된 계정을 찾을 수 없습니다."); + if (source.Key == target.Key) return; + if (!before.Accounts.Any(a => a.Key == source.Key)) + throw new InvalidOperationException("현재 로그인한 계정을 먼저 등록하세요."); + var targetIdentity = ParseIdentity(target.Auth); + if (targetIdentity.Key != target.Key) throw CorruptStore(); + var after = Clone(before); + UpdateAuth(after, source.Key, beforeAuth); + var transaction = new SwitchJournal(1, source.Key, target.Key, beforeAuth, + target.Auth, Digest(beforeAuth), Digest(target.Auth), before, after); + // Journal is durable before either vault or active auth changes. No plaintext backups are made. + WriteEncrypted(_journalPath, transaction); + Checkpoint?.Invoke("journal-written"); + WriteEncrypted(_vaultPath, after); + Checkpoint?.Invoke("source-saved"); + assertStopped(); + CheckSupportedStore(); + RequireSameAuth(beforeAuth); + WriteAuth(target.Auth); + Checkpoint?.Invoke("auth-replaced"); + var actual = ReadBounded(AuthPath, MaxAuthBytes); + if (ParseIdentity(actual).Key != target.Key) + throw new InvalidOperationException("교체 중 다른 로그인이 발견되어 복구 기록을 보존했습니다."); + UpdateAuth(after, target.Key, actual); + WriteEncrypted(_vaultPath, after); + Checkpoint?.Invoke("vault-committed"); + DeleteChecked(_journalPath); + } + + public void Recover(Action assertStopped) + { + ArgumentNullException.ThrowIfNull(assertStopped); + CheckSupportedStore(); + using var gate = AcquireLock(); + if (!HasPendingRecovery) return; + assertStopped(); + var journal = ReadEncrypted(_journalPath); + ValidateJournal(journal); + if (!File.Exists(AuthPath)) + throw new InvalidOperationException("로그인 파일이 없어 자동 복구를 멈췄습니다. 원래 계정으로 로그인한 뒤 복구하세요."); + var live = ReadBounded(AuthPath, MaxAuthBytes); + var identity = ParseIdentity(live); + Vault result; + if (identity.Key == journal.SourceKey) + result = Clone(journal.Before); + else if (identity.Key == journal.TargetKey) + result = Clone(journal.After); + else + throw new InvalidOperationException("교체 대상과 다른 계정이 로그인되어 있습니다. 현재 로그인은 보존했고 자동 복구를 멈췄습니다."); + // Identity chooses rollback/complete; changed digest means the live token rotated and MUST win. + UpdateAuth(result, identity.Key, live); + assertStopped(); + RequireSameAuth(live); + WriteEncrypted(_vaultPath, result); + Checkpoint?.Invoke("recovery-saved"); + DeleteChecked(AuthTempPath); + DeleteChecked(_journalPath); + } + + public void Rename(string id, string label) + { + using var gate = AcquireLock(); + RequireNoRecovery(); + label = NormalizeLabel(label); + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == id) + ?? throw new InvalidOperationException("저장된 계정을 찾을 수 없습니다."); + // Metadata-only: do not inspect or replace live auth, even for the active account. + if (entry.Label == label) return; + entry.Label = label; + WriteEncrypted(_vaultPath, vault); + } + + public void Remove(string id) + { + using var gate = AcquireLock(); + RequireNoRecovery(); + var current = File.Exists(AuthPath) ? GetCurrentIdentity().Key : null; + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Id == id) + ?? throw new InvalidOperationException("저장된 계정을 찾을 수 없습니다."); + if (entry.Key == current) throw new InvalidOperationException("현재 사용 중인 계정은 삭제할 수 없습니다."); + vault.Accounts.Remove(entry); + WriteEncrypted(_vaultPath, vault); + } + + public void SaveUsage(string identityKey, UsageSnapshot snapshot) + { + if (!IsEnabled || HasPendingRecovery) return; + using var gate = AcquireLock(); + RequireNoRecovery(); + if (GetCurrentIdentity().Key != identityKey) return; + var vault = LoadVault(); + var entry = vault.Accounts.SingleOrDefault(a => a.Key == identityKey); + if (entry is null) return; + if (snapshot.UsedPercent is < 0 or > 100 || snapshot.LimitId.Length > 200) + throw new InvalidOperationException("사용량 값이 올바르지 않습니다."); + entry.Usage = snapshot; + entry.ObservedAt = DateTimeOffset.UtcNow; + WriteEncrypted(_vaultPath, vault); + } + + private static SavedCodexAccount PublicEntry(Entry entry, bool active) => + new(entry.Id, entry.Label, entry.Hint, active, entry.Usage, entry.ObservedAt); + + private static Entry Upsert(Vault vault, byte[] bytes, AccountIdentity identity, string label) + { + label = NormalizeLabel(label); + var entry = vault.Accounts.SingleOrDefault(a => a.Key == identity.Key); + if (entry is null) + { + if (vault.Accounts.Count >= MaxAccounts) throw new InvalidOperationException("최대 20개 계정까지 저장할 수 있습니다."); + entry = new Entry { Id = Guid.NewGuid().ToString("N"), Key = identity.Key }; + vault.Accounts.Add(entry); + } + entry.Label = label; + entry.Hint = identity.Hint; + entry.Auth = bytes; + return entry; + } + + private static string NormalizeLabel(string label) + { + label = label?.Trim() ?? ""; + if (label.Length is < 1 or > 40 || label.Any(char.IsControl)) + throw new InvalidOperationException("계정 이름은 제어 문자 없이 1~40자로 입력하세요."); + return label; + } + + private static void UpdateAuth(Vault vault, string key, byte[] bytes) + { + var entry = vault.Accounts.SingleOrDefault(a => a.Key == key) ?? throw CorruptStore(); + entry.Auth = bytes; + } + + private Vault LoadVault() + { + if (!File.Exists(_vaultPath)) return new Vault(); + var result = ReadEncrypted(_vaultPath); + ValidateVault(result); + return result; + } + + private static void ValidateVault(Vault vault) + { + if (vault.Version != 1 || vault.Accounts is null || vault.Accounts.Count > MaxAccounts || + vault.Accounts.Select(a => a.Id).Distinct().Count() != vault.Accounts.Count || + vault.Accounts.Select(a => a.Key).Distinct().Count() != vault.Accounts.Count) + throw CorruptStore(); + foreach (var entry in vault.Accounts) + { + if (!Guid.TryParseExact(entry.Id, "N", out _) || string.IsNullOrWhiteSpace(entry.Label) || + entry.Label.Length > 40 || entry.Label.Any(char.IsControl) || entry.Auth is null || + ParseIdentity(entry.Auth).Key != entry.Key) + throw CorruptStore(); + } + } + + private static void ValidateJournal(SwitchJournal journal) + { + if (journal.Version != 1 || journal.SourceKey == journal.TargetKey || + ParseIdentity(journal.BeforeAuth).Key != journal.SourceKey || + ParseIdentity(journal.AfterAuth).Key != journal.TargetKey || + Digest(journal.BeforeAuth) != journal.BeforeDigest || Digest(journal.AfterAuth) != journal.AfterDigest) + throw CorruptStore(); + ValidateVault(journal.Before); + ValidateVault(journal.After); + if (!journal.Before.Accounts.Any(a => a.Key == journal.SourceKey) || + !journal.After.Accounts.Any(a => a.Key == journal.TargetKey)) throw CorruptStore(); + } + + private static Vault Clone(Vault vault) => JsonSerializer.Deserialize( + JsonSerializer.SerializeToUtf8Bytes(vault, JsonOptions), JsonOptions) ?? throw CorruptStore(); + + private void RequireNoRecovery() + { + if (HasPendingRecovery) throw new InvalidOperationException("미완료 계정 교체를 먼저 복구하세요."); + } + + private void RequireSameAuth(byte[] expected) + { + if (!CryptographicOperations.FixedTimeEquals(expected, ReadBounded(AuthPath, MaxAuthBytes))) + throw new InvalidOperationException("로그인 정보가 작업 중 변경되어 교체를 멈췄습니다. 복구 후 다시 시도하세요."); + } + + private void CheckSupportedStore() + { + // Machine requirements can override the apparent user config and route to a shared keyring. + // Support only the personal unmanaged file-store case; never rewrite a managed setting. + var commonData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData); + foreach (var name in new[] { "requirements.toml", "config.toml" }) + { + var path = Path.Combine(commonData, "OpenAI", "Codex", name); + try + { + _ = File.GetAttributes(path); + throw new InvalidOperationException("관리형 Codex 설정이 있어 계정 교체를 지원하지 않습니다. 설정은 변경하지 않았습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + catch (UnauthorizedAccessException) + { throw new InvalidOperationException("Codex 관리 설정을 확인할 수 없어 계정 교체를 중단했습니다."); } + } + RejectReparsePath(CodexHome); + if (!Directory.Exists(CodexHome)) throw new InvalidOperationException("Codex 사용자 폴더를 찾을 수 없습니다."); + if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("CODEX_API_KEY"))) + throw new InvalidOperationException("CODEX_API_KEY가 설정된 환경에서는 계정 교체를 지원하지 않습니다."); + var config = Path.Combine(CodexHome, "config.toml"); + if (!File.Exists(config)) return; + var text = Encoding.UTF8.GetString(ReadBounded(config, MaxAuthBytes)); + // Be deliberately conservative about TOML syntax: only an unambiguous file store is supported. + foreach (var rawLine in text.Split('\n')) + { + var line = rawLine.Trim(); + if (line.StartsWith('#')) continue; + if (line.Contains("cli_auth_credentials_store", StringComparison.Ordinal) && + !Regex.IsMatch(line, "^cli_auth_credentials_store\\s*=\\s*([\\\"'])file\\1\\s*(#.*)?$")) + throw new InvalidOperationException("파일 방식 이외의 인증 저장 설정은 지원하지 않습니다. 설정은 변경하지 않았습니다."); + if (Regex.IsMatch(line, "^[\\\"']?(forced_chatgpt_workspace_id|forced_login_method)[\\\"']?\\s*=")) + throw new InvalidOperationException("로그인 또는 워크스페이스 제한이 있는 환경은 자동 교체를 지원하지 않습니다."); + } + } + + internal static AccountIdentity ParseIdentity(byte[] bytes) + { + if (bytes.Length is 0 or > MaxAuthBytes) throw InvalidAuth(); + try + { + using var document = JsonDocument.Parse(bytes, new JsonDocumentOptions { MaxDepth = 32 }); + var root = document.RootElement; + RejectDuplicateProperties(root); + if (root.TryGetProperty("auth_mode", out var mode) && mode.ValueKind != JsonValueKind.Null && + (mode.ValueKind != JsonValueKind.String || mode.GetString() != "chatgpt")) throw InvalidAuth(); + // Codex's managed ChatGPT legacy format omits auth_mode (or uses null). Accept it only + // when token data is complete and there is no competing credential mode to infer. + foreach (var field in new[] { "OPENAI_API_KEY", "personal_access_token", "agent_identity", "bedrock_api_key", "bedrock_access_keys" }) + if (root.TryGetProperty(field, out var credential) && credential.ValueKind != JsonValueKind.Null) + throw InvalidAuth(); + if (!root.TryGetProperty("tokens", out var tokens) || tokens.ValueKind != JsonValueKind.Object) + throw InvalidAuth(); + var idToken = Required(tokens, "id_token"); + _ = Required(tokens, "access_token"); + _ = Required(tokens, "refresh_token"); + var account = Required(tokens, "account_id"); + var pieces = idToken.Split('.'); + if (pieces.Length != 3 || pieces[1].Length > MaxAuthBytes) throw InvalidAuth(); + var payload = pieces[1].Replace('-', '+').Replace('_', '/'); + payload = payload.PadRight((payload.Length + 3) / 4 * 4, '='); + using var claimsDocument = JsonDocument.Parse(Convert.FromBase64String(payload)); + var claims = claimsDocument.RootElement; + RejectDuplicateProperties(claims); + var auth = claims.TryGetProperty("https://api.openai.com/auth", out var namespaced) + ? namespaced : default; + var claimAccount = StringValue(auth, "chatgpt_account_id"); + if (claimAccount is not null && claimAccount != account) throw InvalidAuth(); + var user = StringValue(auth, "chatgpt_user_id") ?? StringValue(auth, "user_id") ?? StringValue(claims, "sub"); + if (string.IsNullOrWhiteSpace(user) || user.Length > 512 || account.Length > 512) throw InvalidAuth(); + var key = Digest(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new[] { user, account }))); + var email = StringValue(claims, "email"); + var hint = string.IsNullOrEmpty(email) || email.Any(char.IsControl) + ? "계정 · " + key[..8] + : email[..1] + "*** · " + key[..8]; + return new AccountIdentity(key, hint); + } + catch (Exception ex) when (ex is JsonException or FormatException or InvalidOperationException or ArgumentException) + { + throw InvalidAuth(); + } + } + + private static void RejectDuplicateProperties(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) throw InvalidAuth(); + RejectDuplicateProperties(property.Value); + } + } + else if (element.ValueKind == JsonValueKind.Array) + foreach (var item in element.EnumerateArray()) RejectDuplicateProperties(item); + } + + private static string? StringValue(JsonElement element, string property) => + element.ValueKind == JsonValueKind.Object && element.TryGetProperty(property, out var value) && + value.ValueKind == JsonValueKind.String ? value.GetString() : null; + private static string Required(JsonElement element, string property) => + StringValue(element, property) is { Length: > 0 } value && !string.IsNullOrWhiteSpace(value) + ? value : throw InvalidAuth(); + private static string Digest(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes)); + private static InvalidOperationException InvalidAuth() => + new("지원되는 ChatGPT 로그인 파일이 아닙니다. 공식 Codex 로그인으로 다시 등록하세요."); + private static InvalidOperationException CorruptStore() => + new("계정 보관함 또는 복구 기록을 읽을 수 없습니다. 원본 파일을 보존한 채 중단했습니다."); + + private IDisposable AcquireLock() + { + var mutex = new Mutex(false, TransactionMutexName); + bool acquired; + try { acquired = mutex.WaitOne(0); } + catch (AbandonedMutexException) { acquired = true; } + if (!acquired) + { + mutex.Dispose(); + throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + } + try + { + RejectReparsePath(RootPath); + var directory = new DirectoryInfo(RootPath); + if (!directory.Exists) FileSystemAclExtensions.Create(directory, PrivateDirectorySecurity()); + FileSystemAclExtensions.SetAccessControl(directory, PrivateDirectorySecurity()); + var lockPath = Path.Combine(RootPath, "store.lock"); + RejectReparsePath(lockPath); + try + { + if (!File.Exists(lockPath)) + { + using var created = CreatePrivateFile(lockPath); + } + FileSystemAclExtensions.SetAccessControl(new FileInfo(lockPath), PrivateFileSecurity()); + return new StoreLock(mutex, new FileStream(lockPath, FileMode.Open, FileAccess.ReadWrite, FileShare.None)); + } + catch (IOException) + { + throw new InvalidOperationException("다른 계정 관리 작업이 진행 중입니다. 잠시 후 다시 시도하세요."); + } + } + catch + { + mutex.ReleaseMutex(); + mutex.Dispose(); + throw; + } + } + + private sealed class StoreLock(Mutex mutex, FileStream stream) : IDisposable + { + public void Dispose() + { + stream.Dispose(); + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } + + private static SecurityIdentifier CurrentSid => WindowsIdentity.GetCurrent().User + ?? throw new InvalidOperationException("Windows 사용자 SID를 확인할 수 없습니다."); + private static FileSecurity PrivateFileSecurity() + { + var security = new FileSecurity(); + security.SetOwner(CurrentSid); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(CurrentSid, FileSystemRights.FullControl, AccessControlType.Allow)); + return security; + } + private static DirectorySecurity PrivateDirectorySecurity() + { + var security = new DirectorySecurity(); + security.SetOwner(CurrentSid); + security.SetAccessRuleProtection(true, false); + security.AddAccessRule(new FileSystemAccessRule(CurrentSid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, PropagationFlags.None, AccessControlType.Allow)); + return security; + } + private static FileStream CreatePrivateFile(string path) => FileSystemAclExtensions.Create( + new FileInfo(path), FileMode.CreateNew, FileSystemRights.FullControl, FileShare.None, + 4096, FileOptions.WriteThrough, PrivateFileSecurity()); + + internal static void RejectReparsePath(string path) + { + var cursor = Path.GetFullPath(path); + while (!string.IsNullOrEmpty(cursor)) + { + try + { + if ((File.GetAttributes(cursor) & FileAttributes.ReparsePoint) != 0) + throw new InvalidOperationException("링크 또는 리파스 경로의 인증 파일은 지원하지 않습니다."); + } + catch (FileNotFoundException) { } + catch (DirectoryNotFoundException) { } + cursor = Path.GetDirectoryName(cursor); + } + } + + private static byte[] ReadBounded(string path, int maximum) + { + RejectReparsePath(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length is <= 0 || stream.Length > maximum) throw CorruptStore(); + var bytes = new byte[(int)stream.Length]; + stream.ReadExactly(bytes); + if (stream.ReadByte() != -1) throw CorruptStore(); + return bytes; + } + + private T ReadEncrypted(string path) + { + var encrypted = ReadBounded(path, MaxStoreBytes); + byte[]? plaintext = null; + try + { + plaintext = Dpapi(encrypted, false); + return JsonSerializer.Deserialize(plaintext, JsonOptions) ?? throw CorruptStore(); + } + catch (Exception ex) when (ex is JsonException or Win32Exception or CryptographicException) + { + throw CorruptStore(); + } + finally { if (plaintext is not null) CryptographicOperations.ZeroMemory(plaintext); } + } + + private void WriteEncrypted(string path, T value) + { + var plain = JsonSerializer.SerializeToUtf8Bytes(value, JsonOptions); + try + { + var encrypted = Dpapi(plain, true); + if (encrypted.Length > MaxStoreBytes) throw new InvalidOperationException("계정 보관함 크기 제한을 초과했습니다."); + WriteAtomic(path, encrypted); + } + finally { CryptographicOperations.ZeroMemory(plain); } + } + + private void WriteAuth(byte[] bytes) + { + _ = ParseIdentity(bytes); + WriteAtomic(AuthPath, bytes); + } + + private void WriteAtomic(string path, byte[] bytes) + { + RejectReparsePath(path); + var temporary = path.Equals(AuthPath, StringComparison.OrdinalIgnoreCase) ? AuthTempPath : + Path.Combine(Path.GetDirectoryName(path)!, ".gfs-account-" + Guid.NewGuid().ToString("N") + ".tmp"); + RejectReparsePath(temporary); + if (File.Exists(temporary)) + throw new InvalidOperationException("이전 인증 교체의 임시 파일이 있습니다. 복구를 먼저 실행하세요."); + try + { + using (var stream = CreatePrivateFile(temporary)) + { + stream.Write(bytes); + stream.Flush(true); + } + Checkpoint?.Invoke(Path.GetFileName(path) + "-temp-flushed"); + RejectReparsePath(path); + if (File.Exists(path)) + { + FileSystemAclExtensions.SetAccessControl(new FileInfo(path), PrivateFileSecurity()); + File.Replace(temporary, path, null, ignoreMetadataErrors: false); + } + else File.Move(temporary, path); + } + finally + { + // Auth replacement requires a private, short-lived plaintext temp on the same volume. + // It is never retained as a backup; encryption applies to all inactive/journal copies. + if (File.Exists(temporary)) DeleteChecked(temporary); + } + } + + private static void DeleteChecked(string path) + { + RejectReparsePath(path); + File.Delete(path); + } + + [StructLayout(LayoutKind.Sequential)] + private struct DataBlob { public int Length; public IntPtr Data; } + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptProtectData(ref DataBlob input, string? description, ref DataBlob entropy, + IntPtr reserved, IntPtr prompt, int flags, out DataBlob output); + [DllImport("crypt32.dll", SetLastError = true, CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CryptUnprotectData(ref DataBlob input, IntPtr description, ref DataBlob entropy, + IntPtr reserved, IntPtr prompt, int flags, out DataBlob output); + [DllImport("kernel32.dll")] + private static extern IntPtr LocalFree(IntPtr memory); + + private static byte[] Dpapi(byte[] bytes, bool protect) + { + var input = new DataBlob { Length = bytes.Length, Data = Marshal.AllocHGlobal(bytes.Length) }; + var entropy = new DataBlob { Length = Entropy.Length, Data = Marshal.AllocHGlobal(Entropy.Length) }; + var output = new DataBlob(); + try + { + Marshal.Copy(bytes, 0, input.Data, bytes.Length); + Marshal.Copy(Entropy, 0, entropy.Data, Entropy.Length); + // CRYPTPROTECT_UI_FORBIDDEN; omitting LOCAL_MACHINE binds to CurrentUser. + var success = protect + ? CryptProtectData(ref input, null, ref entropy, IntPtr.Zero, IntPtr.Zero, 1, out output) + : CryptUnprotectData(ref input, IntPtr.Zero, ref entropy, IntPtr.Zero, IntPtr.Zero, 1, out output); + if (!success) throw new Win32Exception(Marshal.GetLastWin32Error(), "Windows 계정 암호화 작업에 실패했습니다."); + var result = new byte[output.Length]; + Marshal.Copy(output.Data, result, 0, result.Length); + return result; + } + finally + { + ZeroUnmanaged(input.Data, input.Length); + Marshal.FreeHGlobal(input.Data); + Marshal.FreeHGlobal(entropy.Data); + if (output.Data != IntPtr.Zero) + { + ZeroUnmanaged(output.Data, output.Length); + LocalFree(output.Data); + } + } + } + private static void ZeroUnmanaged(IntPtr pointer, int length) + { + var zeros = new byte[Math.Min(length, 4096)]; + for (var offset = 0; offset < length; offset += zeros.Length) + Marshal.Copy(zeros, 0, pointer + offset, Math.Min(zeros.Length, length - offset)); + } + + internal sealed class Vault + { + public int Version { get; set; } = 1; + public List Accounts { get; set; } = new(); + } + internal sealed class Entry + { + public string Id { get; set; } = ""; + public string Key { get; set; } = ""; + public string Label { get; set; } = ""; + public string Hint { get; set; } = ""; + public byte[] Auth { get; set; } = Array.Empty(); + public UsageSnapshot? Usage { get; set; } + public DateTimeOffset? ObservedAt { get; set; } + } + internal sealed record SwitchJournal(int Version, string SourceKey, string TargetKey, byte[] BeforeAuth, + byte[] AfterAuth, string BeforeDigest, string AfterDigest, Vault Before, Vault After); +} diff --git a/src/Program.cs b/src/Program.cs index 3ebe9c6..b27e3e0 100644 --- a/src/Program.cs +++ b/src/Program.cs @@ -12,6 +12,7 @@ internal static class Program [STAThread] private static int Main(string[] args) { + var openAccounts = args.Any(argument => argument.Equals("--accounts", StringComparison.OrdinalIgnoreCase)); if (args.Any(argument => argument.Equals("--supervise", StringComparison.OrdinalIgnoreCase))) return WidgetSupervisor.Run(); @@ -19,13 +20,21 @@ private static int Main(string[] args) initiallyOwned: true, name: @"Local\CodexWeeklyUsageIndicator", createdNew: out var isFirstInstance); - if (!isFirstInstance) return 0; + if (!isFirstInstance) + { + if (openAccounts) + { + using var signal = new EventWaitHandle(false, EventResetMode.AutoReset, @"Local\CodexWeeklyUsageIndicator.OpenAccounts"); + signal.Set(); + } + return 0; + } var previewMode = args.Any(argument => argument.Equals("--preview", StringComparison.OrdinalIgnoreCase)); ApplicationConfiguration.Initialize(); - Application.Run(new UsageIndicatorForm(previewMode)); + Application.Run(new UsageIndicatorForm(previewMode, openAccounts)); GC.KeepAlive(singleInstance); return 0; } @@ -72,13 +81,20 @@ internal sealed class UsageIndicatorForm : Form private bool _keepOnTop = true; private bool _showClaude; private bool _positionInitialized; + private readonly CodexAccountStore _accountStore = new(); + private AccountManagerForm? _accountManager; + private NotifyIcon? _accountTray; + private bool _accountBusy; + private long _accountGeneration; + private string? _helperIdentityKey; + private readonly EventWaitHandle _openAccountsSignal = new(false, EventResetMode.AutoReset, @"Local\CodexWeeklyUsageIndicator.OpenAccounts"); private Point _dragCursorStart; private Point _dragFormStart; - public UsageIndicatorForm(bool previewMode) + public UsageIndicatorForm(bool previewMode, bool openAccounts = false) { _previewMode = previewMode; - _showClaude = IndicatorSettingsStore.LoadShowClaude(); + _showClaude = !previewMode && IndicatorSettingsStore.LoadShowClaude(); AutoScaleMode = AutoScaleMode.Dpi; BackColor = Color.FromArgb(24, 24, 28); ClientSize = new Size(WidgetWidth, WidgetHeight); @@ -91,16 +107,21 @@ public UsageIndicatorForm(bool previewMode) ShowInTaskbar = false; StartPosition = FormStartPosition.Manual; Text = "Codex 및 Claude 사용량"; - TopMost = true; AccessibleName = "Codex 및 Claude Fable 사용량 인디케이터"; Opacity = 0; BuildContextMenu(); + _accountTray = new NotifyIcon { Icon = SystemIcons.Application, Text = "Codex 사용량 · 계정 관리", ContextMenuStrip = _contextMenu, Visible = !previewMode }; + _accountTray.DoubleClick += (_, _) => ShowAccountManager(); ApplyRoundedRegion(); _toolTip.SetToolTip(this, "Codex 및 Claude 사용량을 불러오는 중…"); _pollTimer.Tick += async (_, _) => await RefreshUsageAsync(); - _codexStateTimer.Tick += (_, _) => SyncCodexVisibility(); + _codexStateTimer.Tick += (_, _) => + { + if (_openAccountsSignal.WaitOne(0)) ShowAccountManager(); + SyncCodexVisibility(); + }; Shown += (_, _) => { if (_previewMode) @@ -108,12 +129,17 @@ public UsageIndicatorForm(bool previewMode) EnsurePositionInitialized(); Opacity = 1; _ = RefreshUsageAsync(force: true); - _pollTimer.Start(); return; } + if (_accountStore.HasPendingRecovery) + { + _accountBusy = true; + ShowAccountManager(); + } SyncCodexVisibility(); _codexStateTimer.Start(); + if (openAccounts) ShowAccountManager(); }; MouseEnter += (_, _) => { _isHovered = true; Invalidate(); }; @@ -123,9 +149,15 @@ public UsageIndicatorForm(bool previewMode) MouseUp += HandleMouseUp; DoubleClick += async (_, _) => await RefreshUsageAsync(force: true); Resize += (_, _) => ApplyRoundedRegion(); - FormClosing += (_, _) => + FormClosing += (_, e) => { - if (_positionInitialized) + if (_accountManager is { IsOperationInProgress: true }) + { + e.Cancel = true; + _accountManager.Activate(); + return; + } + if (_positionInitialized && !_previewMode) IndicatorSettingsStore.SavePosition(Location); _pollTimer.Stop(); _codexStateTimer.Stop(); @@ -134,6 +166,9 @@ public UsageIndicatorForm(bool previewMode) _toolTip.Dispose(); _contextMenu.Dispose(); _valueFont.Dispose(); + _accountTray?.Dispose(); + _openAccountsSignal.Dispose(); + _accountManager?.Close(); }; } @@ -144,14 +179,21 @@ protected override CreateParams CreateParams get { const int WsExToolWindow = 0x00000080; + const int WsExNoActivate = 0x08000000; + const int WsExTopMost = 0x00000008; var parameters = base.CreateParams; - parameters.ExStyle |= WsExToolWindow; + parameters.ExStyle |= WsExToolWindow | WsExNoActivate; + // Form.TopMost makes WinForms focus the form when it becomes visible, + // even with ShowWithoutActivation. Keep z-order in native styles instead. + if (_keepOnTop) parameters.ExStyle |= WsExTopMost; return parameters; } } private void BuildContextMenu() { + var accountsItem = new ToolStripMenuItem("Codex 계정 관리…"); + accountsItem.Click += (_, _) => ShowAccountManager(); var refreshItem = new ToolStripMenuItem("새로고침"); refreshItem.Click += async (_, _) => await RefreshUsageAsync(force: true); @@ -171,8 +213,7 @@ private void BuildContextMenu() topMostItem.CheckedChanged += (_, _) => { _keepOnTop = topMostItem.Checked; - TopMost = _keepOnTop; - ReassertTopMost(); + if (IsHandleCreated) _ = NativeWindow.TrySetTopMost(Handle, _keepOnTop); }; var copyItem = new ToolStripMenuItem("현재 상태 복사"); @@ -194,6 +235,7 @@ private void BuildContextMenu() showClaudeItem, topMostItem, copyItem, + accountsItem, new ToolStripSeparator(), exitItem ]); @@ -258,6 +300,7 @@ private void ReassertTopMost() private void SyncCodexVisibility() { + if (_accountBusy || _accountStore.HasPendingRecovery) return; var codexIsRunning = _codexStateReader.IsRunning(); if (!codexIsRunning) { @@ -276,6 +319,7 @@ private void SyncCodexVisibility() if (!_codexWasRunning) { _codexWasRunning = true; + _codexClient.Resume(); EnsurePositionInitialized(); _ = RefreshUsageAsync(force: true); _pollTimer.Start(); @@ -288,22 +332,38 @@ private void SyncCodexVisibility() return; } + MaintainVisiblePresentation(); + } + + internal void MaintainVisiblePresentation() + { if (!Visible) Show(); - Opacity = 1; + if (Opacity != 1) Opacity = 1; if (_keepOnTop) { - TopMost = true; + // The WinForms TopMost setter can activate this form even when it + // is already topmost. Timer maintenance must use SWP_NOACTIVATE. ReassertTopMost(); } } private async Task RefreshUsageAsync(bool force = false) { + if (_previewMode) + { + _codexSnapshot = new UsageSnapshot(38, DateTimeOffset.Now.AddDays(3), 10080, "codex"); + _codexError = null; + UpdateToolTip(); + Invalidate(); + return; + } + if (_accountBusy || _accountStore.HasPendingRecovery) return; if (!_previewMode && !_codexWasRunning) return; if (_isRefreshing && !force) return; if (_isRefreshing) return; _isRefreshing = true; + var refreshGeneration = _accountGeneration; Invalidate(); try @@ -326,6 +386,8 @@ private async Task RefreshUsageAsync(bool force = false) { _isRefreshing = false; Invalidate(); + if (refreshGeneration != _accountGeneration && !_accountBusy && !IsDisposed) + _ = RefreshUsageAsync(force: true); } } @@ -341,18 +403,68 @@ private void UpdateToolTip() private async Task RefreshCodexAsync() { + var generation = _accountGeneration; try { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(15)); - _codexSnapshot = await _codexClient.GetWeeklyUsageAsync(timeout.Token); + var identity = _accountStore.IsEnabled ? _accountStore.GetCurrentIdentity().Key : null; + if (identity is not null && identity != _helperIdentityKey) + { + await _codexClient.SuspendAsync(); + if (generation != _accountGeneration || _accountBusy) return; + _codexClient.Resume(); + _helperIdentityKey = identity; + _codexSnapshot = null; + } + UsageSnapshot snapshot; + if (identity is null) snapshot = await _codexClient.GetWeeklyUsageAsync(timeout.Token); + else + { + var observed = await _codexClient.GetWeeklyUsageWithAccountAsync(timeout.Token); + if (!observed.IsChatGpt) throw new InvalidOperationException("Codex 조회 프로세스의 ChatGPT 로그인을 확인할 수 없습니다."); + snapshot = observed.Usage; + } + if (generation != _accountGeneration || _accountBusy) return; + if (identity is not null && identity != _accountStore.GetCurrentIdentity().Key) return; + _codexSnapshot = snapshot; _codexError = null; + if (identity is not null && snapshot is not null) _accountStore.SaveUsage(identity, snapshot); } catch (Exception ex) { + if (generation != _accountGeneration || _accountBusy) return; _codexError = FriendlyCodexError(ex); } } + private void ShowAccountManager() + { + if (_previewMode) return; + if (_accountManager is null || _accountManager.IsDisposed) + _accountManager = new AccountManagerForm(_accountStore, SuspendAccountsAsync, ResumeAccounts); + _accountManager.Show(); + _accountManager.Activate(); + } + + private async Task SuspendAccountsAsync() + { + _accountBusy = true; + _accountGeneration++; + _pollTimer.Stop(); + await _codexClient.SuspendAsync(); + } + + private void ResumeAccounts() + { + _accountBusy = _accountStore.HasPendingRecovery; + _codexSnapshot = null; + _helperIdentityKey = null; + _codexError = null; + _codexWasRunning = false; + if (!_accountBusy) SyncCodexVisibility(); + Invalidate(); + } + private async Task RefreshClaudeAsync() { try @@ -770,6 +882,7 @@ private static void SaveSettings(IndicatorSettings settings) internal static class NativeWindow { private static readonly IntPtr HwndTopMost = new(-1); + private static readonly IntPtr HwndNotTopMost = new(-2); private const int GwlStyle = -16; private const int SwShowMaximized = 3; private const int WsCaption = 0x00C00000; @@ -779,11 +892,11 @@ internal static class NativeWindow private const uint SwpNoActivate = 0x0010; private const uint SwpShowWindow = 0x0040; - public static bool TrySetTopMost(IntPtr windowHandle) + public static bool TrySetTopMost(IntPtr windowHandle, bool topMost = true) { return SetWindowPos( windowHandle, - HwndTopMost, + topMost ? HwndTopMost : HwndNotTopMost, 0, 0, 0, @@ -945,387 +1058,3 @@ public bool IsRunning() return false; } } - -internal sealed class AppServerClient : IDisposable -{ - private readonly ConcurrentDictionary> _pending = new(); - private readonly SemaphoreSlim _startGate = new(1, 1); - private readonly SemaphoreSlim _writeGate = new(1, 1); - private readonly CancellationTokenSource _lifetime = new(); - - private Process? _process; - private StreamWriter? _input; - private Task? _readerTask; - private int _nextRequestId; - private bool _initialized; - private bool _disposed; - - public async Task GetWeeklyUsageAsync(CancellationToken cancellationToken) - { - await EnsureStartedAsync(cancellationToken); - var result = await CallCoreAsync("account/rateLimits/read", new { }, cancellationToken); - return ParseWeeklyUsage(result); - } - - private async Task EnsureStartedAsync(CancellationToken cancellationToken) - { - if (_initialized && _process is { HasExited: false }) return; - - await _startGate.WaitAsync(cancellationToken); - try - { - if (_initialized && _process is { HasExited: false }) return; - StopProcess(); - - var codexPath = LocateCodexExecutable() - ?? throw new FileNotFoundException("codex.exe was not found. Install or open Codex Desktop first."); - - var startInfo = new ProcessStartInfo - { - FileName = codexPath, - Arguments = "app-server --stdio", - UseShellExecute = false, - CreateNoWindow = true, - RedirectStandardInput = true, - RedirectStandardOutput = true, - RedirectStandardError = true, - WorkingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) - }; - - _process = new Process { StartInfo = startInfo, EnableRaisingEvents = true }; - if (!_process.Start()) - throw new InvalidOperationException("Codex app-server could not be started."); - - _input = _process.StandardInput; - _input.AutoFlush = true; - _readerTask = ReadLoopAsync(_process, _lifetime.Token); - _ = DrainErrorAsync(_process, _lifetime.Token); - - await CallCoreAsync( - "initialize", - new - { - clientInfo = new - { - name = "weekly-usage-indicator", - title = "Weekly Usage Indicator", - version = "1.1.1" - }, - capabilities = new { experimentalApi = true } - }, - cancellationToken, - requireInitialized: false); - - await SendNotificationAsync("initialized", cancellationToken); - _initialized = true; - } - catch - { - StopProcess(); - throw; - } - finally - { - _startGate.Release(); - } - } - - private async Task CallCoreAsync( - string method, - object parameters, - CancellationToken cancellationToken, - bool requireInitialized = true) - { - if (requireInitialized && !_initialized) - throw new InvalidOperationException("Codex app-server is not initialized."); - - var id = Interlocked.Increment(ref _nextRequestId); - var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - if (!_pending.TryAdd(id, completion)) - throw new InvalidOperationException("Could not register a Codex request."); - - try - { - await SendLineAsync(JsonSerializer.Serialize(new { id, method, @params = parameters }), cancellationToken); - return await completion.Task.WaitAsync(cancellationToken); - } - finally - { - _pending.TryRemove(id, out _); - } - } - - private Task SendNotificationAsync(string method, CancellationToken cancellationToken) => - SendLineAsync(JsonSerializer.Serialize(new { method }), cancellationToken); - - private async Task SendLineAsync(string line, CancellationToken cancellationToken) - { - await _writeGate.WaitAsync(cancellationToken); - try - { - var writer = _input ?? throw new IOException("Codex app-server input is unavailable."); - await writer.WriteLineAsync(line.AsMemory(), cancellationToken); - await writer.FlushAsync(cancellationToken); - } - finally - { - _writeGate.Release(); - } - } - - private async Task ReadLoopAsync(Process process, CancellationToken cancellationToken) - { - Exception? terminalError = null; - try - { - while (!cancellationToken.IsCancellationRequested && !process.HasExited) - { - var line = await process.StandardOutput.ReadLineAsync(cancellationToken); - if (line is null) break; - if (string.IsNullOrWhiteSpace(line)) continue; - - try - { - using var document = JsonDocument.Parse(line); - var root = document.RootElement; - if (!root.TryGetProperty("id", out var idElement) || - idElement.ValueKind != JsonValueKind.Number || - !idElement.TryGetInt32(out var id) || - !_pending.TryGetValue(id, out var completion)) - { - continue; - } - - if (root.TryGetProperty("error", out var error)) - { - completion.TrySetException(new InvalidOperationException(error.ToString())); - } - else if (root.TryGetProperty("result", out var result)) - { - completion.TrySetResult(result.Clone()); - } - else - { - completion.TrySetException(new InvalidDataException("Codex returned an incomplete response.")); - } - } - catch (JsonException) - { - // App-server stdout is expected to be JSONL. Ignore unrelated diagnostic lines. - } - } - - if (!cancellationToken.IsCancellationRequested) - terminalError = new IOException("Codex app-server disconnected."); - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal shutdown. - } - catch (Exception ex) - { - terminalError = ex; - } - finally - { - if (terminalError is not null) - { - _initialized = false; - foreach (var completion in _pending.Values) - completion.TrySetException(terminalError); - } - } - } - - private static async Task DrainErrorAsync(Process process, CancellationToken cancellationToken) - { - try - { - while (!cancellationToken.IsCancellationRequested && !process.HasExited) - { - if (await process.StandardError.ReadLineAsync(cancellationToken) is null) break; - } - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - // Normal shutdown. - } - catch - { - // Diagnostics must never stop usage updates. - } - } - - private static UsageSnapshot ParseWeeklyUsage(JsonElement response) - { - var snapshot = SelectCoreSnapshot(response); - var limitId = snapshot.TryGetProperty("limitId", out var idElement) && idElement.ValueKind == JsonValueKind.String - ? idElement.GetString() ?? "codex" - : "codex"; - - var windows = new List<(int Used, long? Duration, long? ResetsAt, string Name)>(); - AddWindow(snapshot, "primary", windows); - AddWindow(snapshot, "secondary", windows); - - if (windows.Count == 0) - throw new InvalidDataException("Codex did not return a usage window."); - - var weekly = windows - .OrderBy(window => WeeklyDistance(window.Duration)) - .ThenByDescending(window => window.Duration ?? 0) - .First(); - - DateTimeOffset? resetsAt = null; - if (weekly.ResetsAt is > 0) - resetsAt = DateTimeOffset.FromUnixTimeSeconds(weekly.ResetsAt.Value).ToLocalTime(); - - return new UsageSnapshot( - Math.Clamp(weekly.Used, 0, 100), - resetsAt, - weekly.Duration, - limitId); - } - - private static JsonElement SelectCoreSnapshot(JsonElement response) - { - if (response.TryGetProperty("rateLimitsByLimitId", out var byId) && byId.ValueKind == JsonValueKind.Object) - { - if (byId.TryGetProperty("codex", out var codex) && codex.ValueKind == JsonValueKind.Object) - return codex; - - foreach (var property in byId.EnumerateObject()) - { - if (property.Value.ValueKind != JsonValueKind.Object) continue; - if (!property.Value.TryGetProperty("limitName", out var name) || name.ValueKind == JsonValueKind.Null) - return property.Value; - } - } - - if (response.TryGetProperty("rateLimits", out var legacy) && legacy.ValueKind == JsonValueKind.Object) - return legacy; - - throw new InvalidDataException("Codex did not return rate-limit data."); - } - - private static void AddWindow( - JsonElement snapshot, - string propertyName, - ICollection<(int Used, long? Duration, long? ResetsAt, string Name)> windows) - { - if (!snapshot.TryGetProperty(propertyName, out var window) || window.ValueKind != JsonValueKind.Object) - return; - if (!window.TryGetProperty("usedPercent", out var usedElement) || !usedElement.TryGetInt32(out var used)) - return; - - long? duration = null; - if (window.TryGetProperty("windowDurationMins", out var durationElement) && - durationElement.ValueKind == JsonValueKind.Number && - durationElement.TryGetInt64(out var durationValue)) - { - duration = durationValue; - } - - long? resetsAt = null; - if (window.TryGetProperty("resetsAt", out var resetElement) && - resetElement.ValueKind == JsonValueKind.Number && - resetElement.TryGetInt64(out var resetValue)) - { - resetsAt = resetValue; - } - - windows.Add((used, duration, resetsAt, propertyName)); - } - - private static long WeeklyDistance(long? durationMinutes) - { - const long weekMinutes = 7 * 24 * 60; - return durationMinutes is null - ? long.MaxValue / 2 - : Math.Abs(durationMinutes.Value - weekMinutes); - } - - private static string? LocateCodexExecutable() - { - var configured = Environment.GetEnvironmentVariable("CODEX_WEEKLY_INDICATOR_CODEX_PATH"); - if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured)) - return configured; - - var localBin = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - "OpenAI", - "Codex", - "bin"); - - try - { - if (Directory.Exists(localBin)) - { - var localCodex = Directory - .EnumerateFiles(localBin, "codex.exe", SearchOption.AllDirectories) - .Select(path => new FileInfo(path)) - .OrderByDescending(file => file.LastWriteTimeUtc) - .FirstOrDefault(); - if (localCodex is not null) return localCodex.FullName; - } - } - catch - { - // Continue to PATH lookup. - } - - var pathValue = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; - foreach (var directory in pathValue.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)) - { - try - { - var candidate = Path.Combine(directory.Trim().Trim('"'), "codex.exe"); - if (File.Exists(candidate)) return candidate; - } - catch - { - // Ignore malformed PATH entries. - } - } - - return null; - } - - private void StopProcess() - { - _initialized = false; - try { _input?.Close(); } catch { } - _input = null; - - if (_process is not null) - { - try - { - if (!_process.HasExited) _process.Kill(entireProcessTree: true); - } - catch { } - _process.Dispose(); - _process = null; - } - - foreach (var completion in _pending.Values) - completion.TrySetException(new IOException("Codex app-server was restarted.")); - _pending.Clear(); - } - - public void Pause() - { - if (_disposed) return; - StopProcess(); - } - - public void Dispose() - { - if (_disposed) return; - _disposed = true; - _lifetime.Cancel(); - StopProcess(); - _lifetime.Dispose(); - _startGate.Dispose(); - _writeGate.Dispose(); - } -} diff --git a/src/WeeklyUsageIndicator.csproj b/src/WeeklyUsageIndicator.csproj index f6aa3a2..245dc62 100644 --- a/src/WeeklyUsageIndicator.csproj +++ b/src/WeeklyUsageIndicator.csproj @@ -9,7 +9,7 @@ WeeklyUsageIndicator WeeklyUsageIndicator app.manifest - 1.3.4 + 1.5.1 true none false diff --git a/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs new file mode 100644 index 0000000..3009021 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountRuntimeTests.cs @@ -0,0 +1,171 @@ +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using WeeklyUsageIndicator; + +internal static class AccountRuntimeTests +{ + internal static async Task RunAsync() + { + var stage = Path.Combine(Path.GetTempPath(), "isolated login fixture"); + var start = CodexAccountRuntime.CreateLoginStartInfo("fixture-codex.exe", stage); + Check(!start.UseShellExecute && start.CreateNoWindow, "login must have no shell or visible console"); + Check(start.RedirectStandardInput && start.RedirectStandardOutput && start.RedirectStandardError, + "login diagnostics must not reach parent console"); + Check(start.WorkingDirectory == stage && start.Environment["CODEX_HOME"] == stage, + "login must use only the isolated home"); + Check(start.ArgumentList.SequenceEqual(new[] { "login", "-c", "cli_auth_credentials_store=\"file\"" }), + "login must use official browser login and file storage, never token arguments"); + Check(!start.Environment.Keys.Any(key => + (key.StartsWith("CODEX_", StringComparison.OrdinalIgnoreCase) && key != "CODEX_HOME") + || key.StartsWith("OPENAI_", StringComparison.OrdinalIgnoreCase) + || key.StartsWith("CHATGPT_", StringComparison.OrdinalIgnoreCase)), + "inherited auth and endpoint overrides must not reach isolated login"); + Check(start.Environment["RUST_LOG"] == "off", "verbose Rust tracing must be disabled"); + + var packagePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), + "WindowsApps", "OpenAI.Codex_1.2.3.0_x64__2p2nqsd0c76g0", "app", "ChatGPT.exe"); + Check(CodexAccountRuntime.IsPackagedDesktopPath(packagePath), "expected Windows package shape accepted"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath(packagePath.Replace("2p2nqsd0c76g0", "otherpublisher")), + "unverified package publisher rejected"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath(Path.Combine(stage, "WindowsApps", "OpenAI.Codex_1__2p2nqsd0c76g0", "app", "ChatGPT.exe")), + "lookalike directory outside Program Files rejected"); + Check(!CodexAccountRuntime.IsPackagedDesktopPath("ChatGPT.exe"), "relative desktop path rejected"); + try { CodexAccountRuntime.LaunchDesktop(null); throw new Exception("invalid launch unexpectedly succeeded"); } + catch (InvalidOperationException) { } + + 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")); + Directory.CreateDirectory(root); + try + { + string created; + using (var result = CodexAccountRuntime.CreateLoginStaging(root)) + { + created = result.DirectoryPath; + Check(Path.GetDirectoryName(created) == root, "staging remains inside vault root"); + Check(result.AuthPath == Path.Combine(created, "auth.json"), "import path is isolated"); + var acl = new DirectoryInfo(created).GetAccessControl(); + Check(acl.AreAccessRulesProtected, "staging must not inherit broad parent permissions"); + var user = WindowsIdentity.GetCurrent().User!; + var system = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null); + foreach (FileSystemAccessRule rule in acl.GetAccessRules(true, true, typeof(SecurityIdentifier))) + Check(rule.AccessControlType != AccessControlType.Allow + || rule.IdentityReference.Equals(user) || rule.IdentityReference.Equals(system), + "only current user and SYSTEM receive access"); + File.WriteAllText(result.AuthPath, "synthetic fixture, never a credential"); + var nested = Directory.CreateDirectory(Path.Combine(created, "log")); + File.WriteAllText(Path.Combine(nested.FullName, "test.log"), "synthetic"); + } + Check(!Directory.Exists(created), "dispose removes login output and nested diagnostics"); + var foreign = Directory.CreateDirectory(Path.Combine(root, "unrelated")); + using var invalid = new CodexLoginResult(root, foreign.FullName); + try { invalid.Dispose(); throw new Exception("unrelated cleanup unexpectedly succeeded"); } + catch (InvalidOperationException) { } + Check(Directory.Exists(foreign.FullName), "cleanup must preserve unrelated directories"); + // Avoid a second deliberate Dispose exception from the invalid fixture. + Directory.Delete(foreign.FullName); + } + finally { Directory.Delete(root, recursive: false); } + await TestOwnedJobAsync(); + await TestBrowserDescendantSurvivesAsync(testBase); + } + + private static async Task TestOwnedJobAsync() + { + var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true }; + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-Command", "Start-Sleep -Seconds 30" }) + start.ArgumentList.Add(argument); + using var owned = new Process { StartInfo = start }; + using var unrelated = new Process { StartInfo = start }; + using var job = new CodexLoginJob(); + try + { + Check(owned.Start() && unrelated.Start(), "fake sleepers must start"); + job.Attach(owned); + Check(!owned.HasExited && !unrelated.HasExited, "both fixture children are alive before disposal"); + job.Dispose(); + await owned.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Check(owned.HasExited, "closing job terminates owned login child"); + Check(!unrelated.HasExited, "closing job leaves an unrelated process running"); + } + finally + { + foreach (var process in new[] { owned, unrelated }) + { + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (InvalidOperationException) { } + } + } + } + + private static void Check(bool condition, string message) + { + if (!condition) throw new Exception(message); + } + + private static async Task TestBrowserDescendantSurvivesAsync(string testBase) + { + var root = Path.Combine(testBase, "job-descendant-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + var trigger = Path.Combine(root, "launch-child"); + var pidFile = Path.Combine(root, "child-pid"); + var script = Path.Combine(root, "parent.ps1"); + File.WriteAllText(script, """ + param([string] $TriggerPath, [string] $PidPath) + while (-not (Test-Path -LiteralPath $TriggerPath)) { Start-Sleep -Milliseconds 30 } + $child = Start-Process -FilePath (Join-Path $PSHOME 'powershell.exe') -ArgumentList '-NoProfile', '-NonInteractive', '-Command', 'Start-Sleep -Seconds 30' -WindowStyle Hidden -PassThru + [System.IO.File]::WriteAllText($PidPath, [string]$child.Id) + Start-Sleep -Seconds 30 + """); + var executable = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.System), + "WindowsPowerShell", "v1.0", "powershell.exe"); + var start = new ProcessStartInfo(executable) { UseShellExecute = false, CreateNoWindow = true }; + foreach (var argument in new[] { "-NoProfile", "-NonInteractive", "-File", script, trigger, pidFile }) + start.ArgumentList.Add(argument); + using var parent = new Process { StartInfo = start }; + using var job = new CodexLoginJob(); + Process? browserStandIn = null; + try + { + Check(parent.Start(), "fake login parent must start"); + job.Attach(parent); + // Parent cannot create its child until after it belongs to this job. + File.WriteAllText(trigger, "go"); + var deadline = Stopwatch.StartNew(); + while ((!File.Exists(pidFile) || new FileInfo(pidFile).Length == 0) + && deadline.Elapsed < TimeSpan.FromSeconds(10)) await Task.Delay(50); + Check(File.Exists(pidFile), "fake login must create a browser descendant"); + var childId = int.Parse(File.ReadAllText(pidFile)); + browserStandIn = Process.GetProcessById(childId); + Check(!browserStandIn.HasExited, "browser descendant is alive before job closes"); + job.Dispose(); + await parent.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + Check(parent.HasExited, "closing job terminates fake login parent"); + Check(!browserStandIn.HasExited, "browser descendant breaks away and survives login termination"); + } + finally + { + foreach (var process in new[] { parent, browserStandIn }) + { + if (process is null) continue; + try + { + if (!process.HasExited) process.Kill(entireProcessTree: true); + await process.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10)); + } + catch (InvalidOperationException) { } + } + browserStandIn?.Dispose(); + foreach (var path in new[] { trigger, pidFile, script }) File.Delete(path); + Directory.Delete(root, recursive: false); + } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs b/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs new file mode 100644 index 0000000..dbdd4e3 --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountStoreTests.cs @@ -0,0 +1,435 @@ +using System.Diagnostics; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using WeeklyUsageIndicator; + +internal static class AccountStoreTests +{ + public static async Task RunAsync() + { + using var fixture = new Fixture(); + TestOpaqueRoundTripAndRotation(fixture); + TestIdentityAndValidation(fixture); + TestUsageBindingAndRemoval(fixture); + TestMetadataOnlyRename(fixture); + TestCrashRecovery(fixture); + TestUnknownIdentityRecovery(fixture); + TestPreSwapRace(fixture); + TestDurabilityFailure(fixture); + TestCorruptionAndBounds(fixture); + TestPrivateAcl(fixture); + TestReparsePaths(fixture); + await TestConcurrentLockAsync(fixture); + } + + private static void TestOpaqueRoundTripAndRotation(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + Assert(!store.IsEnabled && !Directory.Exists(store.RootPath), "construction and empty list do not opt in"); + Assert(store.ListAccounts().Count == 0 && !Directory.Exists(store.RootPath), "empty listing has no side effects"); + var aOriginal = Auth("user-a", "workspace-a", "original-a"); + WriteAuth(home, aOriginal); + var a = store.RegisterCurrent("Pro A"); + Assert(store.IsEnabled && a.IsActive, "registration opts in and identifies current account"); + var bBytes = Auth("user-b", "workspace-b", "original-b"); + var b = fixture.Import(store, bBytes, "Pro B"); + var aRotated = Auth("user-a", "workspace-a", "rotated-a"); + WriteAuth(home, aRotated); + var stopChecks = 0; + store.SwitchTo(b.Id, () => stopChecks++); + Assert(stopChecks >= 2, "stopped writers are checked before capture and before swap"); + Equal(ReadAuth(home), bBytes, "target auth preserves every opaque byte"); + store.SwitchTo(a.Id, () => { }); + Equal(ReadAuth(home), aRotated, "switching back uses latest source rotation, not registration snapshot"); + Assert(!store.HasPendingRecovery, "successful round trip closes journal"); + Assert(store.ListAccounts().Count(a2 => a2.IsActive) == 1, "exactly one live identity is active"); + AssertThrows(() => fixture.Import(store, aOriginal, "stale active"), "isolated login cannot overwrite live account tokens"); + } + + private static void TestIdentityAndValidation(Fixture fixture) + { + var a = CodexAccountStore.ParseIdentity(Auth("same-user", "workspace-a", "a")); + var b = CodexAccountStore.ParseIdentity(Auth("same-user", "workspace-b", "b")); + var c = CodexAccountStore.ParseIdentity(Auth("other-user", "workspace-a", "c")); + Assert(a.Key != b.Key && a.Key != c.Key, "identity includes both user and workspace"); + Assert(a.Hint.StartsWith("s***") && !a.Hint.Contains("@"), "hint masks identity"); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes("{}")), "empty auth rejected"); + var external = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"chatgpt\"", "\"chatgptAuthTokens\""); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(external)), "external token mode rejected"); + var duplicate = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"auth_mode\":", "\"auth_mode\":\"apikey\",\"auth_mode\":"); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(duplicate)), "ambiguous duplicate keys rejected"); + var mismatch = Encoding.UTF8.GetString(Auth("a", "b", "x")).Replace("\"account_id\":\"b\"", "\"account_id\":\"other\""); + AssertThrows(() => CodexAccountStore.ParseIdentity(Encoding.UTF8.GetBytes(mismatch)), "workspace mismatch rejected"); + foreach (var legacyMode in new[] { "missing", "null" }) + { + var legacy = JsonNode.Parse(Auth("a", "b", "legacy"))!.AsObject(); + if (legacyMode == "missing") legacy.Remove("auth_mode"); + else legacy["auth_mode"] = null; + var legacyBytes = JsonSerializer.SerializeToUtf8Bytes(legacy); + Assert(CodexAccountStore.ParseIdentity(legacyBytes).Key == + CodexAccountStore.ParseIdentity(Auth("a", "b", "modern")).Key, + "official legacy managed ChatGPT mode accepted: " + legacyMode); + foreach (var conflict in new[] { "OPENAI_API_KEY", "personal_access_token", "agent_identity", "bedrock_api_key", "bedrock_access_keys" }) + { + var mixed = JsonNode.Parse(legacyBytes)!.AsObject(); + mixed[conflict] = "synthetic-conflicting-credential"; + AssertThrows(() => CodexAccountStore.ParseIdentity(JsonSerializer.SerializeToUtf8Bytes(mixed)), + "legacy mode with competing credentials rejected: " + conflict); + } + } + var badMode = JsonNode.Parse(Auth("a", "b", "bad-mode"))!.AsObject(); + badMode["auth_mode"] = 42; + AssertThrows(() => CodexAccountStore.ParseIdentity(JsonSerializer.SerializeToUtf8Bytes(badMode)), "wrong type mode rejected"); + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "b", "x")); + File.WriteAllText(Path.Combine(home, "config.toml"), "cli_auth_credentials_store = \"keyring\"\n"); + AssertThrows(() => store.RegisterCurrent("A"), "unsupported keyring config rejected without mutation"); + Assert(!store.IsEnabled, "failed capability check leaves feature disabled"); + File.WriteAllText(Path.Combine(home, "config.toml"), "cli_auth_credentials_store = 'file' # explicit\n"); + store.RegisterCurrent("A"); + File.WriteAllText(Path.Combine(home, "config.toml"), "forced_chatgpt_workspace_id = \"locked\"\n"); + AssertThrows(() => store.GetCurrentIdentity(), "workspace policy fails closed"); + } + + private static void TestUsageBindingAndRemoval(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("user-a", "workspace", "a")); + var a = store.RegisterCurrent("A"); + var keyA = store.GetCurrentIdentity().Key; + var b = fixture.Import(store, Auth("user-b", "workspace", "b"), "B"); + var usage = new UsageSnapshot(31, DateTimeOffset.UtcNow.AddDays(4), 10080, "codex"); + store.SaveUsage(keyA, usage); + Assert(store.ListAccounts().Single(e => e.Id == a.Id).Usage == usage, "usage saved against active identity"); + AssertThrows(() => store.Remove(a.Id), "active credential cannot be deleted"); + store.SwitchTo(b.Id, () => { }); + store.SaveUsage(keyA, usage with { UsedPercent = 99 }); + Assert(store.ListAccounts().Single(e => e.Id == a.Id).Usage?.UsedPercent == 31, + "late A usage response cannot become B or alter inactive observation"); + Assert(store.ListAccounts().Single(e => e.Id == b.Id).Usage is null, "target never inherits old account usage"); + store.Remove(a.Id); + Assert(store.ListAccounts().Count == 1, "inactive credential can be removed"); + } + + private static void TestMetadataOnlyRename(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + var authA = Auth("rename-a", "workspace", "a"); + var authB = Auth("rename-b", "workspace", "b"); + WriteAuth(home, authA); + var a = store.RegisterCurrent("A"); + var b = fixture.Import(store, authB, "B"); + store.SaveUsage(store.GetCurrentIdentity().Key, new UsageSnapshot(24, DateTimeOffset.UtcNow.AddDays(2), 10080, "codex")); + store.SwitchTo(b.Id, () => { }); + store.SaveUsage(store.GetCurrentIdentity().Key, new UsageSnapshot(67, DateTimeOffset.UtcNow.AddDays(3), 10080, "codex")); + store.SwitchTo(a.Id, () => { }); + var before = store.ListAccounts(); + var beforeA = before.Single(e => e.Id == a.Id); + var beforeB = before.Single(e => e.Id == b.Id); + // Exclusive auth handle proves renaming neither reads nor rewrites live credentials. + using (var authLease = new FileStream(Path.Combine(home, "auth.json"), FileMode.Open, FileAccess.Read, FileShare.None)) + { + store.Rename(a.Id, " 개인 작업 "); + store.Rename(b.Id, "두 번째 계정"); + } + var renamed = store.ListAccounts(); + Assert(renamed.Single(e => e.Id == a.Id) == (beforeA with { Label = "개인 작업" }), + "active rename trims label and preserves identity, usage, observation time, and active state"); + Assert(renamed.Single(e => e.Id == b.Id) == (beforeB with { Label = "두 번째 계정" }), + "inactive rename preserves every non-label field"); + Equal(ReadAuth(home), authA, "rename leaves live auth byte-for-byte unchanged"); + store.SwitchTo(b.Id, () => { }); + Equal(ReadAuth(home), authB, "renamed inactive credentials preserve every opaque byte"); + store.SwitchTo(a.Id, () => { }); + Equal(ReadAuth(home), authA, "renamed active credentials preserve every opaque byte"); + + var vaultPath = Path.Combine(store.RootPath, "accounts.dpapi"); + foreach (var invalid in new[] { "", " ", new string('x', 41), "name\ninside", "name\0inside" }) + { + var encryptedBefore = File.ReadAllBytes(vaultPath); + AssertThrows(() => store.Rename(a.Id, invalid), "invalid label rejected"); + Equal(File.ReadAllBytes(vaultPath), encryptedBefore, "invalid rename does not rewrite vault"); + Equal(ReadAuth(home), authA, "invalid rename does not change live auth"); + } + var beforeMissing = File.ReadAllBytes(vaultPath); + AssertThrows(() => store.Rename(Guid.NewGuid().ToString("N"), "missing"), "missing account ID rejected"); + Equal(File.ReadAllBytes(vaultPath), beforeMissing, "missing ID does not rewrite vault"); + + File.Delete(Path.Combine(home, "auth.json")); + store.Rename(a.Id, "로그아웃 중 변경"); + store.Rename(b.Id, "로그아웃 중 변경"); + Assert(!File.Exists(Path.Combine(home, "auth.json")), "logged-out rename never creates an auth file"); + Assert(store.ListAccounts().All(e => e.Label == "로그아웃 중 변경" && !e.IsActive), + "logged-out metadata rename works and duplicate labels remain permitted"); + WriteAuth(home, authA); + store.Checkpoint = phase => { if (phase == "journal-written") throw new SimulatedCrash(); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "synthetic pending recovery created"); + store.Checkpoint = null; + var beforeRecovery = File.ReadAllBytes(vaultPath); + AssertThrows(() => store.Rename(a.Id, "blocked"), "pending transaction blocks metadata changes"); + Equal(File.ReadAllBytes(vaultPath), beforeRecovery, "blocked rename preserves pending recovery metadata"); + store.Recover(() => { }); + } + + private static void TestCrashRecovery(Fixture fixture) + { + foreach (var phase in new[] { "journal-written", "source-saved", "auth.json-temp-flushed", "auth-replaced", "vault-committed" }) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a-original")); + var a = store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b-original"), "B"); + store.Checkpoint = point => { if (point == phase) throw new SimulatedCrash(); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "injected transaction crash propagates"); + Assert(store.HasPendingRecovery, "durable journal survives " + phase); + var landedOnTarget = phase is "auth-replaced" or "vault-committed"; + var refreshed = Auth(landedOnTarget ? "b" : "a", "workspace", "post-crash-rotation"); + WriteAuth(home, refreshed); + var reopened = new CodexAccountStore(store.RootPath, home); + // Simulate the only plaintext temp a process-kill could leave. Journal recovery must remove it. + File.WriteAllBytes(Path.Combine(home, ".gfs-account-auth.tmp"), Auth("b", "workspace", "orphan")); + reopened.Recover(() => { }); + Equal(ReadAuth(home), refreshed, "recovery never replaces newer live auth at " + phase); + Assert(!reopened.HasPendingRecovery && !File.Exists(Path.Combine(home, ".gfs-account-auth.tmp")), + "recovery closes journal and removes auth temp at " + phase); + reopened.SwitchTo(landedOnTarget ? a.Id : b.Id, () => { }); + reopened.SwitchTo(landedOnTarget ? b.Id : a.Id, () => { }); + Equal(ReadAuth(home), refreshed, "newer rotated credential was saved at " + phase); + } + } + + private static void TestUnknownIdentityRecovery(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => { if (point == "source-saved") throw new SimulatedCrash(); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "create pending journal"); + store.Checkpoint = null; + var third = Auth("third", "workspace", "independent-login"); + WriteAuth(home, third); + AssertThrows(() => store.Recover(() => { }), "third identity fails closed"); + Equal(ReadAuth(home), third, "third identity is untouched"); + Assert(store.HasPendingRecovery, "third identity keeps recoverable journal"); + AssertThrows(() => store.RegisterCurrent("third"), "registration cannot bypass pending recovery"); + File.Delete(Path.Combine(home, "auth.json")); + AssertThrows(() => store.Recover(() => { }), "missing auth fails closed rather than silently reinstating login"); + Assert(!File.Exists(Path.Combine(home, "auth.json")), "missing auth is not invented"); + } + + private static void TestPreSwapRace(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + var latest = Auth("a", "workspace", "raced-refresh"); + var checks = 0; + AssertThrows(() => store.SwitchTo(b.Id, () => + { + if (++checks == 2) WriteAuth(home, latest); + }), "concurrent refresh detected before replacement"); + Equal(ReadAuth(home), latest, "race does not clobber refreshed source"); + store.Recover(() => { }); + Assert(!store.HasPendingRecovery, "race recovers without swapping"); + } + + private static void TestDurabilityFailure(Fixture fixture) + { + foreach (var failure in new[] { "switch.dpapi-temp-flushed", "accounts.dpapi-temp-flushed" }) + { + var (store, home) = fixture.NewStore(); + var original = Auth("a", "workspace", "a"); + WriteAuth(home, original); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => { if (point == failure) throw new IOException("Synthetic disk failure"); }; + AssertThrows(() => store.SwitchTo(b.Id, () => { }), "durable backup failure must stop auth replacement"); + Equal(ReadAuth(home), original, "backup failure leaves auth byte-for-byte intact"); + store.Checkpoint = null; + if (store.HasPendingRecovery) store.Recover(() => { }); + Assert(!Directory.EnumerateFiles(store.RootPath, "*.tmp").Any(), "failed writes clean their own temp files"); + } + } + + private static void TestCorruptionAndBounds(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + var auth = Auth("a", "workspace", "a"); + WriteAuth(home, auth); + store.RegisterCurrent("A"); + var vaultPath = Path.Combine(store.RootPath, "accounts.dpapi"); + var encrypted = File.ReadAllBytes(vaultPath); + Assert(!Encoding.UTF8.GetString(encrypted).Contains("synthetic-refresh"), "inactive vault never contains plaintext tokens"); + encrypted[^1] ^= 0x55; + File.WriteAllBytes(vaultPath, encrypted); + AssertThrows(() => store.ListAccounts(), "DPAPI tampering is rejected"); + Equal(ReadAuth(home), auth, "corrupted vault never alters live auth"); + File.WriteAllBytes(Path.Combine(home, "auth.json"), new byte[1024 * 1024 + 1]); + AssertThrows(() => store.GetCurrentIdentity(), "oversized auth fails bounded read"); + } + + private static void TestPrivateAcl(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + var b = fixture.Import(store, Auth("b", "workspace", "b"), "B"); + store.Checkpoint = point => + { + if (point == "auth.json-temp-flushed") AssertPrivateFile(Path.Combine(home, ".gfs-account-auth.tmp")); + }; + store.SwitchTo(b.Id, () => { }); + AssertPrivateFile(Path.Combine(home, "auth.json")); + foreach (var file in Directory.EnumerateFiles(store.RootPath)) AssertPrivateFile(file); + var acl = FileSystemAclExtensions.GetAccessControl(new DirectoryInfo(store.RootPath)); + Assert(acl.AreAccessRulesProtected, "vault directory disables inherited broad ACLs"); + AssertOnlyCurrentSid(acl.GetAccessRules(true, true, typeof(SecurityIdentifier))); + } + + private static void AssertPrivateFile(string path) + { + var acl = FileSystemAclExtensions.GetAccessControl(new FileInfo(path)); + Assert(acl.AreAccessRulesProtected, "credential file disables inherited ACLs"); + AssertOnlyCurrentSid(acl.GetAccessRules(true, true, typeof(SecurityIdentifier))); + } + + private static void AssertOnlyCurrentSid(AuthorizationRuleCollection rules) + { + var sid = WindowsIdentity.GetCurrent().User!.Value; + Assert(rules.Count > 0 && rules.Cast().All(rule => + rule.IdentityReference.Value == sid && rule.AccessControlType == AccessControlType.Allow), + "only current Windows user is granted file access"); + } + + private static void TestReparsePaths(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + var linkedHome = Path.Combine(Path.GetDirectoryName(home)!, "linked-home"); + try { Directory.CreateSymbolicLink(linkedHome, home); } + catch (Exception ex) when (ex is UnauthorizedAccessException || ex is IOException && (ex.HResult & 0xFFFF) == 1314) + { + // Junction creation needs no developer-mode privilege. Inputs are this fixture's + // exact paths and are rejected if they contain any cmd metacharacters. + Assert(!linkedHome.Concat(home).Any(c => "&|<>^%!\"\r\n".Contains(c)), "junction fixture paths are shell-safe"); + using var process = Process.Start(new ProcessStartInfo + { + FileName = "cmd.exe", + Arguments = $"/d /c mklink /J \"{linkedHome}\" \"{home}\"", + UseShellExecute = false, + CreateNoWindow = true, + RedirectStandardOutput = true, + RedirectStandardError = true + }) ?? throw new InvalidOperationException("Could not create synthetic junction fixture"); + process.StandardOutput.ReadToEnd(); + process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert(process.ExitCode == 0, "synthetic junction creation succeeds"); + } + try + { + var linked = new CodexAccountStore(store.RootPath, linkedHome); + AssertThrows(() => linked.RegisterCurrent("A"), "ancestor reparse path rejected"); + Assert(!store.IsEnabled, "reparse rejection creates no vault"); + } + finally { Directory.Delete(linkedHome); } + } + + private static async Task TestConcurrentLockAsync(Fixture fixture) + { + var (store, home) = fixture.NewStore(); + WriteAuth(home, Auth("a", "workspace", "a")); + store.RegisterCurrent("A"); + using var entered = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var holder = Task.Run(() => + { + using var mutex = new Mutex(false, CodexAccountStore.TransactionMutexName); + mutex.WaitOne(); + entered.Set(); + release.Wait(); + mutex.ReleaseMutex(); + }); + entered.Wait(); + try { AssertThrows(() => store.RegisterCurrent("A"), "installer/second-process mutex prevents store mutation"); } + finally { release.Set(); await holder; } + } + + private static byte[] Auth(string user, string account, string revision) + { + var claims = JsonSerializer.SerializeToUtf8Bytes(new Dictionary + { + ["sub"] = "subject-" + user, + ["email"] = user + "@example.invalid", + ["https://api.openai.com/auth"] = new Dictionary + { + ["chatgpt_user_id"] = user, + ["chatgpt_account_id"] = account + } + }); + var token = "synthetic-header." + Convert.ToBase64String(claims).TrimEnd('=').Replace('+', '-').Replace('/', '_') + ".synthetic-signature"; + var data = new + { + auth_mode = "chatgpt", + OPENAI_API_KEY = (string?)null, + tokens = new { id_token = token, access_token = "synthetic-access-" + revision, + refresh_token = "synthetic-refresh-" + revision, account_id = account }, + last_refresh = "2026-09-09T01:00:00Z", + unknown_future_field = new { preserve_me = revision } + }; + return Encoding.UTF8.GetBytes(" \n" + JsonSerializer.Serialize(data) + "\n "); + } + + private static void WriteAuth(string home, byte[] bytes) => File.WriteAllBytes(Path.Combine(home, "auth.json"), bytes); + private static byte[] ReadAuth(string home) => File.ReadAllBytes(Path.Combine(home, "auth.json")); + private static void Equal(byte[] actual, byte[] expected, string message) => Assert(actual.SequenceEqual(expected), message); + private static void Assert(bool condition, string message) + { + if (!condition) throw new InvalidOperationException("Account store assertion failed: " + message); + } + private static void AssertThrows(Action action, string message) + { + try { action(); } + catch (Exception ex) when (ex is InvalidOperationException or IOException or SimulatedCrash) { return; } + throw new InvalidOperationException("Account store expected rejection: " + message); + } + private sealed class SimulatedCrash : Exception; + + private sealed class Fixture : IDisposable + { + private readonly string _path; + public Fixture() + { + var parent = Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? + Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests"); + _path = Path.GetFullPath(Path.Combine(parent, "account-store-" + Guid.NewGuid().ToString("N"))); + Directory.CreateDirectory(_path); + } + public (CodexAccountStore Store, string Home) NewStore() + { + var directory = Path.Combine(_path, Guid.NewGuid().ToString("N")); + var home = Path.Combine(directory, "synthetic-codex-home"); + Directory.CreateDirectory(home); + return (new CodexAccountStore(Path.Combine(directory, "vault"), home), home); + } + public SavedCodexAccount Import(CodexAccountStore store, byte[] bytes, string label) + { + var input = Path.Combine(_path, "synthetic-login-" + Guid.NewGuid().ToString("N") + ".json"); + File.WriteAllBytes(input, bytes); + try { return store.ImportLoginFile(input, label); } + finally { File.Delete(input); } + } + public void Dispose() + { + // Only this fixture's freshly generated, resolved subtree is ever recursively removed. + if (!Path.GetFileName(_path).StartsWith("account-store-", StringComparison.Ordinal)) + throw new InvalidOperationException("Invalid synthetic fixture root"); + if (Directory.Exists(_path)) Directory.Delete(_path, recursive: true); + } + } +} diff --git a/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs new file mode 100644 index 0000000..629795e --- /dev/null +++ b/tests/WeeklyUsageIndicator.Tests/AccountUiSmoke.cs @@ -0,0 +1,222 @@ +using System.Drawing; +using System.Drawing.Imaging; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Windows.Forms; +using WeeklyUsageIndicator; + +internal static class AccountUiSmoke +{ + internal static Task RunAsync() + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var thread = new Thread(() => + { + var output = Environment.GetEnvironmentVariable("GFS_ACCOUNT_UI_CAPTURE"); + var parent = string.IsNullOrWhiteSpace(output) + ? Environment.GetEnvironmentVariable("GFS_ACCOUNT_TEST_ROOT") ?? Path.Combine(Path.GetTempPath(), "gfs-agent", "260909_codex-account-switch", "tests") + : Path.GetDirectoryName(Path.GetFullPath(output))!; + var root = Path.Combine(parent, "ui-fixture-" + Guid.NewGuid().ToString("N")); + try + { + Application.SetHighDpiMode(HighDpiMode.PerMonitorV2); + Application.EnableVisualStyles(); + var home = Directory.CreateDirectory(Path.Combine(root, "home")).FullName; + var auth = typeof(AccountStoreTests).GetMethod("Auth", BindingFlags.Static | BindingFlags.NonPublic)!; + byte[] Fixture(string user) => (byte[])auth.Invoke(null, new object[] { user, "personal-workspace-" + user, "ui-only" })!; + File.WriteAllBytes(Path.Combine(home, "auth.json"), Fixture("a")); + var store = new CodexAccountStore(Path.Combine(root, "vault"), home); + var suspended = 0; var resumed = 0; + using var form = new AccountManagerForm(store, () => { suspended++; return Task.CompletedTask; }, () => resumed++); + form.Show(); + Application.DoEvents(); + Capture(form, output, "-empty"); + Button(form, "RegisterCurrentButton").PerformClick(); + Check(store.IsEnabled && store.ListAccounts().Single().Label == "계정 1", "registration needs no name entry"); + var list = Find(form, "AccountList"); + Check(list.Items.Count == 1 && list.SelectedIndex == 0, "registration immediately selects the account"); + Check(!form.IsOperationInProgress && suspended == 1 && resumed == 1, "registration restores operation state"); + Check(!Button(form, "SwitchAccountButton").Enabled && !Button(form, "DeleteAccountButton").Enabled, "current account cannot switch or delete"); + Check(Button(form, "RenameAccountButton").Enabled, "current account can rename"); + + RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + { + var input = Find(dialog, "AccountNameTextBox"); + Check(input.Text == "계정 1", "rename opens with current name"); + input.Text = "Pro A · 주 계정"; + input.Focus(); + using (var widget = new UsageIndicatorForm(previewMode: true)) + { + dialog.Activate(); + input.Focus(); + Application.DoEvents(); + Check(input.Focused && GetFocus() == input.Handle, "name editor has native keyboard focus before widget maintenance"); + var foreground = GetForegroundWindow(); + for (var tick = 0; tick < 5; tick++) + { + if (tick == 2) widget.Hide(); + widget.MaintainVisiblePresentation(); + Application.DoEvents(); + Check(GetForegroundWindow() == foreground && GetFocus() == input.Handle && input.Focused, "widget show and maintenance must preserve foreground and editor focus"); + Check((GetWindowLong(widget.Handle, -20) & 8) != 0, "widget remains topmost without managed activation"); + } + widget.Close(); + } + Capture(dialog, output, "-rename"); + Button(dialog, "SaveNameButton").PerformClick(); + }); + Check(store.ListAccounts().Single().Label == "Pro A · 주 계정", "save renames current account"); + Check(suspended == 1 && resumed == 1, "metadata rename must not restart helper"); + RespondToDialog(() => Button(form, "RenameAccountButton").PerformClick(), "AccountNameDialog", dialog => + { + Find(dialog, "AccountNameTextBox").Text = "discard this"; + Button(dialog, "CancelNameButton").PerformClick(); + }); + Check(store.ListAccounts().Single().Label == "Pro A · 주 계정", "cancel preserves original name"); + Check(!Find