diff --git a/.agents b/.agents new file mode 120000 index 0000000..96b6c9e --- /dev/null +++ b/.agents @@ -0,0 +1 @@ +/Users/anle/.agents \ No newline at end of file diff --git a/.gitignore b/.gitignore index e6767cf..fdd9778 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,18 @@ /target +.tmp-ref/ +# Python virtual environments +.venv/ +.venv-system/ +**/__pycache__/ +*.pyc + +# macOS resource forks and metadata +._* +.DS_Store + +# Generated icon build artifacts +assets/icon.iconset/ # Planning files findings.md diff --git a/AGENTS.md b/AGENTS.md index 6157b87..9bb8a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,3 +6,21 @@ - `fix(TUI) - fixed typo in xyz` - `fix(tray) - aligned xyz` - Before finalizing, confirm the requested task and any follow-up work are complete. + +## Architecture + +- `src/main.rs` → `cli::run()` routes clap subcommands, or launches the menubar tray when no subcommand is given (macOS/Windows). +- `app/service.rs` holds core business logic; `App` is generic over storage. +- `repository/` persists snapshot metadata + gzip blobs; `secrets.rs` handles local/keyring migration. +- `codex.rs` / `cursor.rs` / `claude.rs` read/restore live auth for each provider. +- `usage.rs` fetches quota from OpenAI APIs; `app/auto_start.rs` runs background workers. +- Tray (`tray.rs`) is the primary macOS/Windows UX: left-click opens a CodexBar-style meter-card popover (`/menu`); right-click keeps the NSMenu; Overview is a secondary wry window (`/`). +- Linux/WSL: CLI subcommands only (no tray / no TUI). + +## Validation + +```bash +cargo test +cargo clippy --all-targets --all-features -- -D warnings +cargo fmt --check +``` diff --git a/Cargo.lock b/Cargo.lock index 5bd01a5..047999e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "android-activity" version = "0.6.1" @@ -17,13 +29,13 @@ dependencies = [ "android-properties", "bitflags 2.11.0", "cc", - "jni", + "jni 0.22.4", "libc", "log", - "ndk", + "ndk 0.9.0", "ndk-context", - "ndk-sys", - "num_enum", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.6", "thiserror 2.0.18", ] @@ -89,6 +101,21 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "atk" version = "0.18.2" @@ -145,6 +172,21 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -244,6 +286,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-expr" version = "0.15.8" @@ -266,6 +314,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clap" version = "4.6.0" @@ -306,6 +360,36 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types", + "libc", + "objc", +] + [[package]] name = "codex-account-switcher" version = "0.1.5" @@ -313,23 +397,27 @@ dependencies = [ "anyhow", "base64", "clap", - "console", - "dialoguer", + "core-foundation 0.9.4", "directories", "flate2", "image", "keyring", + "libc", + "rusqlite", "serde", "serde_json", "sysinfo", "tempfile", "time", + "tiny_http", "tray-icon", "ureq", "uuid", "windows-sys 0.61.2", "winit", "winresource", + "wry", + "zip", ] [[package]] @@ -358,16 +446,10 @@ dependencies = [ ] [[package]] -name = "console" -version = "0.16.3" +name = "convert_case" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" -dependencies = [ - "encode_unicode", - "libc", - "unicode-width", - "windows-sys 0.61.2", -] +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" [[package]] name = "cookie" @@ -389,7 +471,7 @@ dependencies = [ "cookie", "document-features", "idna", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -448,6 +530,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -472,6 +563,43 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754b69d351cdc2d8ee09ae203db831e005560fc6030da058f86ad60c92a9cb0a" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa 0.4.8", + "matches", + "phf 0.8.0", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -511,15 +639,37 @@ dependencies = [ ] [[package]] -name = "dialoguer" -version = "0.12.0" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ - "console", - "shell-words", - "tempfile", - "zeroize", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", ] [[package]] @@ -604,10 +754,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] -name = "encode_unicode" -version = "1.0.0" +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "equivalent" @@ -625,6 +790,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.4.1" @@ -723,6 +900,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -785,6 +972,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + [[package]] name = "gdk" version = "0.18.2" @@ -843,6 +1039,54 @@ dependencies = [ "system-deps", ] +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -851,7 +1095,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", ] [[package]] @@ -1021,6 +1265,21 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -1036,6 +1295,15 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.4.1" @@ -1054,6 +1322,20 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "http" version = "1.4.0" @@ -1061,7 +1343,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "itoa", + "itoa 1.0.18", ] [[package]] @@ -1070,6 +1352,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "icu_collections" version = "2.2.0" @@ -1192,6 +1480,16 @@ dependencies = [ "png 0.18.1", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", +] + [[package]] name = "indexmap" version = "2.14.0" @@ -1210,12 +1508,57 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + [[package]] name = "jni" version = "0.22.4" @@ -1323,6 +1666,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kuchikiki" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f29e4755b7b995046f510a7520c42b2fed58b77bd94d5a87a8eb43d2fd126da8" +dependencies = [ + "cssparser", + "html5ever", + "indexmap 1.9.3", + "matches", + "selectors", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -1402,12 +1758,23 @@ dependencies = [ ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "libsqlite3-sys" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - -[[package]] +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1425,12 +1792,56 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf 0.10.1", + "phf_codegen 0.10.0", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + [[package]] name = "memchr" version = "2.8.0" @@ -1486,6 +1897,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ndk" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451422b7e4718271c8b5b3aadf5adedba43dc76312454b387e98fae0fc951aa0" +dependencies = [ + "bitflags 1.3.2", + "jni-sys 0.3.1", + "ndk-sys 0.4.1+23.1.7779620", + "num_enum 0.5.11", + "raw-window-handle 0.5.2", + "thiserror 1.0.69", +] + [[package]] name = "ndk" version = "0.9.0" @@ -1495,8 +1920,9 @@ dependencies = [ "bitflags 2.11.0", "jni-sys 0.3.1", "log", - "ndk-sys", - "num_enum", + "ndk-sys 0.6.0+11769913", + "num_enum 0.7.6", + "raw-window-handle 0.6.2", "thiserror 1.0.69", ] @@ -1506,6 +1932,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" +[[package]] +name = "ndk-sys" +version = "0.4.1+23.1.7779620" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf2aae958bd232cac5069850591667ad422d263686d75b52a065f9badeee5a3" +dependencies = [ + "jni-sys 0.3.1", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -1515,6 +1950,18 @@ dependencies = [ "jni-sys 0.3.1", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + [[package]] name = "ntapi" version = "0.4.3" @@ -1539,16 +1986,37 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_enum" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f646caf906c20226733ed5b1374287eb97e3c2a5c227ce668c1f2ce20ae57c9" +dependencies = [ + "num_enum_derive 0.5.11", +] + [[package]] name = "num_enum" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ - "num_enum_derive", + "num_enum_derive 0.7.6", "rustversion", ] +[[package]] +name = "num_enum_derive" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcbff9bc912032c62bf65ef1d5aea88983b420f4f839db1e9b0c281a25c9c799" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "num_enum_derive" version = "0.7.6" @@ -1570,6 +2038,16 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -1837,6 +2315,24 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -1938,12 +2434,146 @@ dependencies = [ "system-deps", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_macros", + "phf_shared 0.8.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.7", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[package]] +name = "phf_macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6fde18ff429ffc8fe78e2bf7f8b7a5a5a6e2a8b58bc5a9ac69198bbda9189c" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.3", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -2037,6 +2667,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -2100,6 +2745,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2136,6 +2787,99 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ff9a1f06a88b01621b7ae906ef0211290d1c8a168a15542486a8f61c0833b9" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + [[package]] name = "redox_syscall" version = "0.4.1" @@ -2145,6 +2889,15 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + [[package]] name = "redox_syscall" version = "0.7.4" @@ -2179,6 +2932,20 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.11.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2264,6 +3031,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "2.11.1" @@ -2300,6 +3073,26 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + [[package]] name = "semver" version = "1.0.28" @@ -2342,7 +3135,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ - "itoa", + "itoa 1.0.18", "memchr", "serde", "serde_core", @@ -2368,10 +3161,25 @@ dependencies = [ ] [[package]] -name = "shell-words" -version = "1.1.1" +name = "servo_arc" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +checksum = "d98238b800e0d1576d8b6e3de32827c2d74bee68bb97748dcf5071fb53965432" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] [[package]] name = "shlex" @@ -2401,6 +3209,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2423,10 +3243,61 @@ dependencies = [ ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] [[package]] name = "strsim" @@ -2447,6 +3318,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", + "quote", "unicode-ident", ] @@ -2483,7 +3355,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.62.2", ] [[package]] @@ -2499,6 +3371,17 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -2518,6 +3401,23 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "thin-slice" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaa81235c7058867fa8c0e7314f33dcce9c215f535d1913822a2b3f5e289f3c" + [[package]] name = "thiserror" version = "1.0.69" @@ -2565,7 +3465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", - "itoa", + "itoa 1.0.18", "libc", "num-conv", "num_threads", @@ -2591,6 +3491,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2619,7 +3531,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", @@ -2652,7 +3564,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -2663,7 +3575,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -2676,7 +3588,7 @@ version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "winnow 1.0.2", @@ -2734,6 +3646,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -2746,12 +3664,6 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -2808,6 +3720,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -2866,6 +3784,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2962,7 +3886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2975,7 +3899,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags 2.11.0", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -2999,6 +3923,50 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webkit2gtk" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + [[package]] name = "webpki-roots" version = "1.0.6" @@ -3008,6 +3976,42 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webview2-com" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6516cfa64c6b3212686080eeec378e662c2af54bb2a5b2a22749673f5cb2226f" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows 0.57.0", + "windows-core 0.57.0", + "windows-implement 0.57.0", + "windows-interface 0.57.0", +] + +[[package]] +name = "webview2-com-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac1345798ecd8122468840bcdf1b95e5dc6d2206c5e4b0eafa078d061f59c9bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76d5b77320ff155660be1df3e6588bc85c75f1a9feef938cc4dc4dd60d1d7cf" +dependencies = [ + "thiserror 1.0.69", + "windows 0.57.0", + "windows-core 0.57.0", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3039,6 +4043,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.62.2" @@ -3046,7 +4060,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core", + "windows-core 0.62.2", "windows-future", "windows-numerics", ] @@ -3057,7 +4071,19 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-core", + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", ] [[package]] @@ -3066,10 +4092,10 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] @@ -3079,11 +4105,22 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3095,6 +4132,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3118,10 +4166,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core", + "windows-core 0.62.2", "windows-link", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -3140,6 +4197,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -3176,6 +4242,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -3218,6 +4299,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3230,6 +4326,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3242,6 +4344,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3266,6 +4374,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3278,6 +4392,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3290,6 +4410,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3302,6 +4428,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3333,13 +4465,14 @@ dependencies = [ "dpi", "js-sys", "libc", - "ndk", + "ndk 0.9.0", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit", "orbclient", "pin-project", + "raw-window-handle 0.6.2", "redox_syscall 0.4.1", "rustix 0.38.44", "smol_str", @@ -3409,7 +4542,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn 2.0.117", "wasm-metadata", @@ -3440,7 +4573,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags 2.11.0", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -3459,7 +4592,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -3475,6 +4608,69 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "wry" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b00c945786b02d7805d09a969fa36d0eee4e0bd4fb3ec2a79d2bf45a1b44cd" +dependencies = [ + "base64", + "block", + "cocoa", + "core-graphics", + "crossbeam-channel", + "dpi", + "dunce", + "gdkx11", + "gtk", + "html5ever", + "http", + "javascriptcore-rs", + "jni 0.21.1", + "kuchikiki", + "libc", + "ndk 0.7.0", + "ndk-context", + "ndk-sys 0.4.1+23.1.7779620", + "objc", + "objc_id", + "once_cell", + "percent-encoding", + "raw-window-handle 0.6.2", + "sha2", + "soup3", + "tao-macros", + "thiserror 1.0.69", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows 0.57.0", + "windows-core 0.57.0", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -3517,6 +4713,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.7" @@ -3591,8 +4807,37 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap 2.14.0", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index d9898ed..11f4d2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,29 +14,35 @@ categories = ["command-line-utilities"] anyhow = "1.0.102" base64 = "0.22.1" clap = { version = "4.6.0", features = ["derive"] } -console = "0.16.3" -dialoguer = "0.12.0" directories = "6.0.0" flate2 = "1.1.5" +libc = "0.2.180" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sysinfo = "0.38.4" time = { version = "0.3.47", features = ["formatting", "local-offset", "parsing", "serde"] } ureq = { version = "3.1.2", features = ["json"] } uuid = { version = "1.23.0", features = ["serde", "v4"] } +zip = { version = "2.2.3", default-features = false, features = ["deflate"] } +rusqlite = { version = "0.31.0", features = ["bundled"] } +tiny_http = "0.12.0" -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] image = { version = "0.25.10", default-features = false, features = ["png", "ico"] } -keyring = { version = "3.6.3", features = ["windows-native"] } tray-icon = { version = "0.22.1", default-features = false } +winit = { version = "0.30.13", default-features = false, features = ["rwh_06"] } +wry = { version = "0.41", features = ["transparent"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", features = ["windows-native"] } windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] } -winit = { version = "0.30.13", default-features = false } [target.'cfg(target_os = "windows")'.build-dependencies] winresource = "0.1.31" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3.6.3", features = ["apple-native"] } +core-foundation = "0.9.4" [target.'cfg(target_os = "linux")'.dependencies] keyring = { version = "3.6.3", features = ["sync-secret-service", "vendored"] } diff --git a/README.md b/README.md index 18e2a6c..2a3e1d0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ It does not modify other Codex config, history, sessions, or sqlite state. ## Commands -Run without arguments for interactive mode. +Run without arguments to launch the native menubar (macOS/Windows). On Linux/WSL, use clap subcommands instead — there is no terminal UI. + +Left-click the menu bar icon for CodexBar-style meter cards (ChatGPT Codex weekly-only, plus Cursor and Claude). Right-click for the classic menu. Overview is available from the popover or menu. ```text codex-account-switcher @@ -28,11 +30,15 @@ codex-account-switcher status [--json] codex-account-switcher list [--json] codex-account-switcher save [--json] codex-account-switcher usage [ACCOUNT_ID] [--json] -codex-account-switcher activate [ACCOUNT_ID] [--force] [--json] -codex-account-switcher delete [ACCOUNT_ID] [--json] +codex-account-switcher activate ACCOUNT_ID [--force] [--json] +codex-account-switcher delete ACCOUNT_ID [--json] codex-account-switcher auto-start-usage-windows [--enable|--disable] [--run] [--json] +codex-account-switcher auto-switch-on-limit [--enable|--disable] [--run] [--json] +codex-account-switcher exec ACCOUNT COMMAND [ARGS...] ``` +`ACCOUNT` for `exec` accepts a saved account UUID or email address. + ## Behavior - `save` snapshots the currently logged-in Codex account. @@ -41,7 +47,9 @@ codex-account-switcher auto-start-usage-windows [--enable|--disable] [--run] [-- - `usage` fetches current usage for the live account or for a saved account by id. - `activate` restores a saved snapshot. Reliable swaps require all Codex processes to be closed first; `--force` lets the command attempt activation anyway, but it still fails if the restored files do not stay stable. - `delete` removes the saved snapshot from the switcher store only. -- `auto-start-usage-windows` is opt-in from the CLI, TUI, or tray checkmark. When enabled, the interactive app refreshes saved weekly windows every 5 minutes and starts due windows with a minimal `codex exec` ping when Codex is on `PATH`. +- `auto-start-usage-windows` is opt-in from the CLI or menubar. When enabled, the menubar app refreshes saved weekly windows every 5 minutes and starts due windows with a minimal `codex exec` ping when Codex is on `PATH`. +- `auto-switch-on-limit` is opt-in from the CLI or menubar. When enabled, the app monitors the active account's quota and automatically switches to another saved account with remaining quota when the current account is exhausted. On macOS and Windows it can quit and relaunch Codex after switching. +- `exec` runs an arbitrary command under a temporary saved-account snapshot and restores the previous live auth afterward. Useful for scripting or isolated `codex exec` pings without permanently switching accounts. Saved snapshot data lives in the app-data directory for the current environment: @@ -63,6 +71,33 @@ Account labels come from the `id_token` payload: cargo build --release ``` +Regenerate all icon assets (logo source is procedural — do not hand-edit PNGs): + +```text +python3 scripts/generate_icons.py +``` + +This writes: + +- `assets/codex-account-switcher-transparent.png` — menu bar template logo (monochrome arcs + dot) +- `assets/codex-account-switcher-dock.png` — Dock/app icon with warm-black background and teal accent rim +- `assets/codex-account-switcher.ico` / `.icns` — platform bundles generated from the Dock asset + +Package a macOS menu-bar agent app (no Dock icon, tray-first): + +```text +./scripts/build_macos_app.sh +``` + +Or rebuild and relaunch the menubar agent from source: + +```text +cargo build --release +./contrib/macos-launch/open-menubar.command +``` + +**macOS UX:** left-click the menu bar icon for CodexBar-style meter cards (ChatGPT Codex weekly-only, Cursor, Claude). Right-click for the classic menu. Overview opens from the popover or menu item. + ## Install Tagged releases publish prebuilt archives plus installer scripts on GitHub Releases. @@ -116,3 +151,15 @@ cargo fmt --check - Plan metadata is best effort and may be blank if the token does not expose a recognizable value. - Usage enrichment depends on saved `auth.json` tokens still being present and accepted by the Codex/OpenAI usage endpoints. - Auto-starting usage windows can ping while Codex processes are running; active Codex sessions keep their auth cached, and the switcher restores the previous live account afterward. +- Auto-switch on limit requires at least one other saved account with remaining quota and valid auth. +- `exec` restores live auth even when the child command fails; check the child exit code separately when scripting. + +## Optional macOS Menu Bar Wrapper + +An alternate Python-based menubar UI lives in `contrib/macos-menubar/`. The Rust binary already includes a native tray on macOS; use the Python wrapper only if you prefer its UI or workflow. See `contrib/macos-menubar/README.md`. + +## Acknowledgments + +This repository is a fork of [Pimpmuckl/codex-account-switcher](https://github.com/Pimpmuckl/codex-account-switcher). + +Menu bar UX and usage/pace presentation are inspired by [CodexBar](https://github.com/steipete/CodexBar) by [steipete](https://github.com/steipete). This project does not redistribute CodexBar source; the credit is for reference and inspiration. diff --git a/assets/codex-account-switcher-dock.png b/assets/codex-account-switcher-dock.png new file mode 100644 index 0000000..9263f14 Binary files /dev/null and b/assets/codex-account-switcher-dock.png differ diff --git a/assets/codex-account-switcher-transparent.png b/assets/codex-account-switcher-transparent.png new file mode 100644 index 0000000..80ac0f5 Binary files /dev/null and b/assets/codex-account-switcher-transparent.png differ diff --git a/assets/codex-account-switcher.icns b/assets/codex-account-switcher.icns new file mode 100644 index 0000000..c5e7d47 Binary files /dev/null and b/assets/codex-account-switcher.icns differ diff --git a/assets/codex-account-switcher.ico b/assets/codex-account-switcher.ico index aa3d8bc..b611f28 100644 Binary files a/assets/codex-account-switcher.ico and b/assets/codex-account-switcher.ico differ diff --git a/build.rs b/build.rs index 96cfbe5..decc060 100644 --- a/build.rs +++ b/build.rs @@ -1,6 +1,8 @@ #[cfg(target_os = "windows")] fn main() { println!("cargo:rerun-if-changed=assets/codex-account-switcher.ico"); + println!("cargo:rerun-if-changed=assets/codex-account-switcher-transparent.png"); + println!("cargo:rerun-if-changed=assets/codex-account-switcher-dock.png"); winresource::WindowsResource::new() .set_icon("assets/codex-account-switcher.ico") .compile() diff --git a/contrib/macos-launch/open-menubar.command b/contrib/macos-launch/open-menubar.command new file mode 100755 index 0000000..9021abd --- /dev/null +++ b/contrib/macos-launch/open-menubar.command @@ -0,0 +1,59 @@ +#!/bin/bash +# Launch Codex Account Switcher as a menu-bar agent (no Dock icon). +# Double-click in Finder, or: open contrib/macos-launch/open-menubar.command + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +LOG_DIR="$HOME/Library/Application Support/com.nextide.codex-account-switcher" +LOG_FILE="$LOG_DIR/tray.log" +LOCK_FILE="$LOG_DIR/tray.lock" +INSTALL_BIN="$HOME/.local/bin/codex-account-switcher-menubar" +APP_BUNDLE="$PROJECT_DIR/target/release/Codex Account Switcher.app" +SOURCE_BIN="" + +mkdir -p "$LOG_DIR" "$(dirname "$INSTALL_BIN")" + +# Prefer a freshly built cargo binary over an older packaged .app bundle. +if [[ -x "$PROJECT_DIR/target/release/codex-account-switcher" ]]; then + SOURCE_BIN="$PROJECT_DIR/target/release/codex-account-switcher" +elif [[ -x "$APP_BUNDLE/Contents/MacOS/codex-account-switcher" ]]; then + SOURCE_BIN="$APP_BUNDLE/Contents/MacOS/codex-account-switcher" +else + echo "Build first: cargo build --release && ./scripts/build_macos_app.sh" >&2 + exit 1 +fi + +install -m 755 "$SOURCE_BIN" "$INSTALL_BIN" +echo "Installed menubar binary from: $SOURCE_BIN" + +OLD_PID="" +if [[ -f "$LOCK_FILE" ]]; then + OLD_PID="$(tr -d '[:space:]' <"$LOCK_FILE" 2>/dev/null || true)" +fi + +if [[ -n "$OLD_PID" ]] && kill -0 "$OLD_PID" 2>/dev/null; then + echo "Restarting tray (pid=$OLD_PID) to load updated binary..." + kill "$OLD_PID" 2>/dev/null || true + for _ in {1..10}; do + if ! kill -0 "$OLD_PID" 2>/dev/null; then + break + fi + sleep 0.2 + done +fi + +echo "Launching installed menubar binary: $INSTALL_BIN" +nohup "$INSTALL_BIN" >"$LOG_FILE" 2>&1 & + +sleep 2 +if pgrep -f 'codex-account-switcher-menubar' >/dev/null || pgrep -f 'codex-account-switcher' >/dev/null; then + echo "Running. Check menu bar (Control Center → Menu Bar if hidden)." + echo "Log: $LOG_FILE" + tail -n 5 "$LOG_FILE" 2>/dev/null || true +else + echo "Process did not stay running. See log: $LOG_FILE" >&2 + tail -n 20 "$LOG_FILE" 2>/dev/null || true + exit 1 +fi diff --git a/contrib/macos-menubar/README.md b/contrib/macos-menubar/README.md new file mode 100644 index 0000000..bc67653 --- /dev/null +++ b/contrib/macos-menubar/README.md @@ -0,0 +1,71 @@ +# macOS Menu Bar Wrapper + +> **Note:** The Rust binary already includes a native macOS menu bar tray. This Python wrapper is optional and maintained for users who prefer its workflow. For new installs, use the Rust app directly (`codex-account-switcher` with no args on a non-TTY session). + +A lightweight Python menu bar app that wraps the `codex-account-switcher` CLI, providing a native macOS menu bar experience for account switching. + +## Features + +- **⚡ Menu bar icon** — always visible, shows current account +- **One-click switching** — click any saved account to switch instantly +- **Auto Codex restart** — kills and relaunches Codex after switching +- **Add Account flow** — login with a new account without losing the current one +- **Delete accounts** — remove saved snapshots from the menu +- **Auto-start** — runs on login via LaunchAgent + +## Requirements + +- macOS 13+ +- Python 3.9+ +- `codex-account-switcher` CLI installed (`cargo install` or prebuilt binary) + +## Install + +```bash +# 1. Install the CLI first (if not already) +cargo install --git https://github.com/Pimpmuckl/codex-account-switcher + +# 2. Run setup (creates venv, installs deps, configures LaunchAgent) +chmod +x setup.sh +./setup.sh +``` + +The setup script will: +1. Create a Python virtual environment +2. Install `rumps` (macOS menu bar framework) +3. Register a LaunchAgent for auto-start on login +4. Launch the app immediately + +## Usage + +After setup, look for **⚡** in your macOS menu bar. + +| Menu Item | Description | +|---|---| +| ✅ email@example.com | Currently active account | +| ○ other@example.com | Click to switch to this account | +| 💾 Save Current Account | Snapshot the current Codex auth | +| 🔑 Add New Account… | Login with a new account (non-destructive) | +| 🗑 Delete Account | Remove a saved snapshot | +| 🔄 Refresh | Reload account list | + +### Adding a new account + +1. Click **🔑 Add New Account…** +2. Codex will relaunch with a login screen +3. Login with the new account +4. Click **⚡ → ✅ Finish Adding Account** +5. Original account is restored automatically + +## Uninstall + +```bash +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.codex-switcher.plist +rm ~/Library/LaunchAgents/com.codex-switcher.plist +rm -f ~/.local/bin/codex-switcher-launcher.sh +``` + +## Logs + +- stdout: `/tmp/codex-switcher.log` +- stderr: `/tmp/codex-switcher.err` diff --git a/contrib/macos-menubar/Toggle Codex Mode.command b/contrib/macos-menubar/Toggle Codex Mode.command new file mode 100755 index 0000000..2ad4f77 --- /dev/null +++ b/contrib/macos-menubar/Toggle Codex Mode.command @@ -0,0 +1,6 @@ +#!/bin/bash +echo "=== Toggle Codex Mode ===" +/Users/anle/.local/bin/codex-switch toggle +echo "" +echo "Press any key to close..." +read -n 1 diff --git a/contrib/macos-menubar/codex_switcher.py b/contrib/macos-menubar/codex_switcher.py new file mode 100644 index 0000000..f13adca --- /dev/null +++ b/contrib/macos-menubar/codex_switcher.py @@ -0,0 +1,717 @@ +#!/usr/bin/env python3 +""" +Codex Account Switcher — macOS Menu Bar App +Wraps codex-account-switcher CLI with a native menu bar UI. +One-click account switching, no TUI needed. +""" + +import rumps +import subprocess +import json +import os +import time +import datetime + +CLI_PATHS = [ + os.path.expanduser("~/.codex-account-switcher/bin/codex-account-switcher"), + os.path.expanduser("~/.cargo/bin/codex-account-switcher"), +] +CLI = next((p for p in CLI_PATHS if os.path.exists(p)), CLI_PATHS[0]) +ICON_PATHS = [ + os.path.join(os.path.dirname(__file__), "..", "..", "assets", "codex-account-switcher-transparent.png"), + os.path.join(os.path.dirname(__file__), "icon.png"), +] + + +def load_menu_bar_icon(): + """Load template-friendly menu bar icon from bundled assets.""" + try: + from AppKit import NSImage + + for path in ICON_PATHS: + resolved = os.path.abspath(path) + if not os.path.exists(resolved): + continue + image = NSImage.alloc().initByContentsOfFile_(resolved) + if image is None: + continue + image.setSize_((18, 18)) + image.setTemplate_(True) + return image + except Exception: + pass + return None + + +def format_reset_time(reset_at_arr, is_weekly=False): + """Parse 0-indexed day-of-year array to a readable time string.""" + if not reset_at_arr or len(reset_at_arr) < 5: + return "" + try: + year, yday, hour, minute, second = reset_at_arr[0], reset_at_arr[1], reset_at_arr[2], reset_at_arr[3], reset_at_arr[4] + dt = datetime.datetime(year, 1, 1) + datetime.timedelta(days=yday, hours=hour, minutes=minute, seconds=second) + if is_weekly: + # Show both date and clock time for weekly reset as well. + # Example: "Jul 7, 7:27 PM" + return dt.strftime("%b %-d, %-I:%M %p") + else: + return dt.strftime("%-I:%M %p") + except Exception: + return "" + + +def format_quota_bar(percent): + """Build a compact quota bar: [██████░░] 75%""" + bar_width = 6 + filled = round(percent * bar_width / 100) + empty = bar_width - filled + return f"[{'█' * filled}{'░' * empty}] {percent}%" + + +def _get_bottleneck_window(usage): + """Return the window with lowest remaining_percent that hasn't reset yet.""" + if not usage: + return None + import time as _time + now = _time.time() + candidates = [] + for key in ("five_hour", "weekly"): + w = usage.get(key) + if not w: + continue + rem = w.get("remaining_percent") + if rem is None: + continue + # reset_at is array [year, day_of_year, hour, minute, second] + reset_at = w.get("reset_at") + if reset_at and len(reset_at) >= 5: + try: + dt = datetime.datetime(reset_at[0], 1, 1) + datetime.timedelta( + days=reset_at[1], hours=reset_at[2], minutes=reset_at[3], seconds=reset_at[4] + ) + if dt.timestamp() <= now: + continue # past reset + except Exception: + pass + candidates.append((rem, w, key)) + if not candidates: + return None + candidates.sort(key=lambda x: x[0]) + return candidates[0] # (remaining_percent, window_dict, key) + + +def _get_nearest_reset_time(acc): + usage = acc.get("usage") + if not usage: + return None + import time as _time + now = _time.time() + nearest = None + for key in ("five_hour", "weekly"): + w = usage.get(key) + if w and w.get("remaining_percent") == 0: + reset_at = w.get("reset_at") + if reset_at and len(reset_at) >= 5: + try: + dt = datetime.datetime(reset_at[0], 1, 1) + datetime.timedelta( + days=reset_at[1], hours=reset_at[2], minutes=reset_at[3], seconds=reset_at[4] + ) + ts = dt.timestamp() + if ts > now: + if nearest is None or ts < nearest: + nearest = ts + except Exception: + pass + return nearest + + +def _format_account_details(plan_label, acc): + """Build compact details line: Plan • [████░░░░] 52% • ↻ 12/05""" + parts = [] + if plan_label: + parts.append(plan_label) + + if acc: + err = acc.get("usage_error", "") + if err and "login required" in err.lower(): + parts.append("Login required") + return " • ".join(parts) + + usage = acc.get("usage") + bottleneck = _get_bottleneck_window(usage) + if bottleneck: + rem, window, key = bottleneck + parts.append(format_quota_bar(rem)) + reset_str = format_reset_time(window.get("reset_at"), is_weekly=(key == "weekly")) + if reset_str: + parts.append(f"↻ {reset_str}") + + return " • ".join(parts) if parts else "" + + +def _status_tag(acc): + """Compact status tag: Ready / Low / Depleted / Login / Stale / —""" + err = acc.get("usage_error", "") + if err and "login required" in err.lower(): + return "Login" + usage = acc.get("usage") + if usage: + bottleneck = _get_bottleneck_window(usage) + if bottleneck: + rem = bottleneck[0] + if rem == 0: + return "Depleted" + if rem <= 10: + return "Low" + return "Ready" + return "Ready" + if err: + return "Stale" + return "—" + + +def notify(title, subtitle, message): + """Safe notification — falls back to osascript if rumps fails.""" + try: + rumps.notification(title, subtitle, message) + except Exception: + try: + subprocess.run( + ["osascript", "-e", + f'display notification "{message}" with title "{title}" subtitle "{subtitle}"'], + capture_output=True, timeout=5, + ) + except Exception: + pass + + +# ── Helpers ────────────────────────────────────────────────── + +def cli_json(*args): + """Run CLI with --json and return parsed output.""" + try: + r = subprocess.run( + [CLI, *args, "--json"], + capture_output=True, text=True, timeout=10, + ) + if r.returncode == 0 and r.stdout.strip(): + return json.loads(r.stdout) + except Exception: + pass + return None + + +def cli_run(*args): + """Run CLI and return (success, stdout).""" + try: + r = subprocess.run( + [CLI, *args], + capture_output=True, text=True, timeout=15, + ) + return r.returncode == 0, r.stdout.strip() + except Exception as e: + return False, str(e) + + +def ask_text(title, message, default=""): + """Show input dialog via osascript.""" + script = f''' + tell application "System Events" + activate + set userInput to display dialog "{message}" ¬ + default answer "{default}" ¬ + with title "{title}" ¬ + buttons {{"Cancel", "OK"}} default button "OK" + return text returned of userInput + end tell + ''' + try: + r = subprocess.run( + ["osascript", "-e", script], + capture_output=True, text=True, timeout=60, + ) + return r.stdout.strip() if r.returncode == 0 else None + except Exception: + return None + + +def ask_confirm(title, message): + """Show confirmation dialog via osascript.""" + script = f''' + tell application "System Events" + activate + display dialog "{message}" ¬ + with title "{title}" ¬ + buttons {{"Cancel", "OK"}} default button "OK" + end tell + ''' + try: + r = subprocess.run( + ["osascript", "-e", script], + capture_output=True, text=True, timeout=30, + ) + return r.returncode == 0 + except Exception: + return False + + +# ── App ────────────────────────────────────────────────────── + +class CodexSwitcher(rumps.App): + def __init__(self): + icon = load_menu_bar_icon() + title = "" if icon is not None else "⚡" + super().__init__(title, icon=icon, template=True, quit_button=None) + self._register_login_item() + self._refresh_menu() + + def _register_login_item(self): + try: + from pathlib import Path + py_path = os.path.abspath(__file__) + script_dir = os.path.dirname(py_path) + cmd_path = os.path.join(script_dir, "run.command") + venv_path = os.path.join(script_dir, ".venv/bin/python") + + if not os.path.exists(cmd_path): + return + + # Create local launcher script to wait for mount + local_bin = Path.home() / ".local" / "bin" + local_bin.mkdir(parents=True, exist_ok=True) + launcher_path = local_bin / "codex-switcher-launcher.sh" + + launcher_content = f"""#!/bin/bash +# Wait for external volume to mount (max 30 seconds) +PY_PATH="{py_path}" +VENV_PATH="{venv_path}" +for i in {{1..30}}; do + if [ -f "$PY_PATH" ]; then + pkill -f codex_switcher.py 2>/dev/null + sleep 0.5 + exec "$VENV_PATH" "$PY_PATH" + fi + sleep 1 +done +exit 1 +""" + if not launcher_path.exists() or launcher_path.read_text() != launcher_content: + launcher_path.write_text(launcher_content) + launcher_path.chmod(0o755) + + plist_dir = Path.home() / "Library" / "LaunchAgents" + plist_dir.mkdir(parents=True, exist_ok=True) + plist_path = plist_dir / "com.codex-switcher.plist" + plist_content = f""" + + + + Label + com.codex-switcher + ProgramArguments + + {launcher_path} + + RunAtLoad + + + +""" + if not plist_path.exists() or plist_path.read_text() != plist_content: + plist_path.write_text(plist_content) + # Load agent immediately + uid_out = subprocess.run(["id", "-u"], capture_output=True, text=True) + uid = uid_out.stdout.strip() + if uid: + subprocess.run(["launchctl", "bootout", f"gui/{uid}", str(plist_path)], capture_output=True) + subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)], capture_output=True) + except Exception: + pass + + + # ── Menu ───────────────────────────────────────────────── + + def _refresh_menu(self): + self.menu.clear() + status = cli_json("status") + account_list = cli_json("list") + + # Current account + current_email = None + plan = "" + if status and status.get("current_account"): + current_email = status["current_account"].get("email", "?") + plan = status["current_account"].get("plan_label", "") + if self.icon is None: + self.title = f"⚡ {current_email.split('@')[0]}" + else: + self.title = "" + else: + self.title = "" if self.icon is not None else "⚡ Codex" + + accounts = account_list.get("accounts", []) if account_list else [] + current_saved_id = status.get("current_account_saved_id") if status else None + + # Find active account and build other_accounts list + active_acc = None + other_accounts = [] + for acc in accounts: + if acc["email"] == current_email or acc.get("is_active", False) or (current_saved_id and acc["id"] == current_saved_id): + active_acc = acc + else: + other_accounts.append(acc) + + # ── Active account ────────────────────────────────── + if current_email: + not_saved = " [not saved]" if not current_saved_id else "" + active_label = f"\u2713 {current_email}{not_saved}" + active_item = rumps.MenuItem(active_label, callback=lambda _: None) + self.menu.add(active_item) + + # Details: plan + quota bar + reset + details = _format_account_details(plan, active_acc) + if details: + sub_item = rumps.MenuItem(f" {details}", callback=lambda _: None) + self.menu.add(sub_item) + else: + ni = rumps.MenuItem("Not logged in", callback=lambda _: None) + self.menu.add(ni) + + self.menu.add(None) + + # ── Saved accounts (switch targets) ───────────────── + if other_accounts: + active_group = [] + depleted_group = [] + login_group = [] + archived_group = [] + + for acc in other_accounts: + if acc.get("is_archived", False): + archived_group.append(acc) + else: + tag = _status_tag(acc) + if tag == "Login": + login_group.append(acc) + elif tag == "Depleted": + depleted_group.append(acc) + else: + active_group.append(acc) + + # Sort depleted group by nearest reset time + if depleted_group: + depleted_group.sort(key=lambda acc: _get_nearest_reset_time(acc) or float('inf')) + + first_group = True + + def add_acc(acc, target_menu=None): + if target_menu is None: + target_menu = self.menu + aid = acc["id"] + email = acc["email"] + short_email = email.removesuffix("@gmail.com") if email.endswith("@gmail.com") else email + label = f" {short_email}" + item = rumps.MenuItem( + label, callback=self._mk(self._switch, aid, email) + ) + target_menu.add(item) + + # Details line + acc_plan = acc.get("plan_label", "") + details = _format_account_details(acc_plan, acc) + if details: + sub_item = rumps.MenuItem(f" {details}", callback=lambda _: None) + target_menu.add(sub_item) + + if active_group: + if not first_group: + self.menu.add(None) + first_group = False + header = rumps.MenuItem("🟢 Active (Còn token)", callback=lambda _: None) + self.menu.add(header) + for acc in active_group: + add_acc(acc) + + if depleted_group: + if not first_group: + self.menu.add(None) + first_group = False + header = rumps.MenuItem("🔴 Depleted (Hết token)", callback=lambda _: None) + self.menu.add(header) + for acc in depleted_group: + add_acc(acc) + + if login_group or archived_group: + if not first_group: + self.menu.add(None) + first_group = False + hidden_submenu = rumps.MenuItem("📂 Show Hidden Accounts (Hiển thị tài khoản ẩn)") + first_hidden_group = True + + if login_group: + header = rumps.MenuItem("⚠️ Login Required (Cần login lại)", callback=lambda _: None) + hidden_submenu.add(header) + first_hidden_group = False + for acc in login_group: + add_acc(acc, target_menu=hidden_submenu) + + if archived_group: + if not first_hidden_group: + hidden_submenu.add(None) + header = rumps.MenuItem("📁 Archived (Đã lưu trữ)", callback=lambda _: None) + hidden_submenu.add(header) + for acc in archived_group: + add_acc(acc, target_menu=hidden_submenu) + + self.menu.add(hidden_submenu) + else: + empty = rumps.MenuItem(" (no saved accounts)", callback=lambda _: None) + self.menu.add(empty) + + self.menu.add(None) + + # ── Actions ───────────────────────────────────────── + self.menu.add(rumps.MenuItem( + "⚡ Best Quota", callback=self._on_pick_best + )) + self.menu.add(rumps.MenuItem( + "💾 Save Current", callback=self._on_save + )) + self.menu.add(rumps.MenuItem( + "➕ Add Account…", callback=self._on_add + )) + + if accounts: + del_sub = rumps.MenuItem("🗑 Delete Account") + for acc in accounts: + del_sub.add(rumps.MenuItem( + acc["email"], + callback=self._mk(self._delete, acc["id"], acc["email"]), + )) + self.menu.add(del_sub) + + # Archive submenu + archive_sub = rumps.MenuItem("📁 Archive Account") + has_archivable = False + for acc in accounts: + if not acc.get("is_archived", False): + has_archivable = True + archive_sub.add(rumps.MenuItem( + acc["email"], + callback=self._mk(self._archive, acc["id"], acc["email"]), + )) + if has_archivable: + self.menu.add(archive_sub) + + # Unarchive submenu + unarchive_sub = rumps.MenuItem("📂 Unarchive Account") + has_unarchivable = False + for acc in accounts: + if acc.get("is_archived", False): + has_unarchivable = True + unarchive_sub.add(rumps.MenuItem( + acc["email"], + callback=self._mk(self._unarchive, acc["id"], acc["email"]), + )) + if has_unarchivable: + self.menu.add(unarchive_sub) + + self.menu.add(None) + + # ── System ────────────────────────────────────────── + self.menu.add(rumps.MenuItem("🔄 Refresh", callback=self._on_refresh)) + self.menu.add(rumps.MenuItem("Quit", callback=self._on_quit)) + + def _mk(self, fn, *args): + return lambda _: fn(*args) + + # ── Actions ────────────────────────────────────────────── + + def _switch(self, account_id, email): + notify("Codex Switcher", "⏳ Switching…", f"→ {email}") + + # Kill Codex first for reliable swap + subprocess.run(["pkill", "-x", "Codex"], capture_output=True) + time.sleep(0.5) + + ok, msg = cli_run("activate", account_id, "--force") + if ok: + # Relaunch Codex + subprocess.Popen(["open", "-a", "Codex"]) + notify("Codex Switcher", "✅ Switched", f"Now: {email}") + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + + self._refresh_menu() + + def _on_save(self, _): + ok, msg = cli_run("save") + if ok: + notify("Codex Switcher", "💾 Saved", msg) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + self._refresh_menu() + + def _on_pick_best(self, _): + notify("Codex Switcher", "⏳ Finding best quota…", "") + ok, msg = cli_run("pick-best", "--relaunch") + if ok: + notify("Codex Switcher", "⚡ Best Quota", msg[:100]) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + self._refresh_menu() + + def _on_add(self, _): + """Temporarily remove auth → let user login → save → restore.""" + import shutil + from pathlib import Path + + codex_dir = Path.home() / ".codex" + auth = codex_dir / "auth.json" + cap_sid = codex_dir / "cap_sid" + backup_auth = codex_dir / "auth.json.switcher-bak" + backup_sid = codex_dir / "cap_sid.switcher-bak" + + if not auth.exists(): + notify("Codex Switcher", "Error", "Not logged in") + return + + # Backup + shutil.copy2(str(auth), str(backup_auth)) + if cap_sid.exists(): + shutil.copy2(str(cap_sid), str(backup_sid)) + + # Remove auth to trigger login screen + auth.unlink() + if cap_sid.exists(): + cap_sid.unlink() + + # Restart Codex + subprocess.run(["pkill", "-x", "Codex"], capture_output=True) + time.sleep(1) + subprocess.Popen(["open", "-a", "Codex"]) + + notify( + "Codex Switcher", + "🔑 Login with new account", + "Login in Codex, then click ⚡ → Finish Adding Account", + ) + + # Swap menu to show "Finish" button + self.menu.clear() + header = rumps.MenuItem("Codex Account Switcher") + header.set_callback(None) + self.menu.add(header) + self.menu.add(None) + self.menu.add(rumps.MenuItem( + "⏳ Waiting for login…" + )) + self.menu.add(None) + self.menu.add(rumps.MenuItem( + "✅ Finish Adding Account", + callback=lambda _: self._finish_add(backup_auth, backup_sid), + )) + self.menu.add(rumps.MenuItem( + "❌ Cancel", + callback=lambda _: self._cancel_add(backup_auth, backup_sid), + )) + self.menu.add(None) + self.menu.add(rumps.MenuItem("Quit", callback=self._on_quit)) + + def _finish_add(self, backup_auth, backup_sid): + from pathlib import Path + import shutil + + codex_dir = Path.home() / ".codex" + auth = codex_dir / "auth.json" + + if not auth.exists(): + notify("Codex Switcher", "Error", "Not logged in yet") + return + + # Ensure cap_sid exists + cap_sid = codex_dir / "cap_sid" + if not cap_sid.exists(): + cap_sid.touch() + + # Save new account + ok, msg = cli_run("save") + + # Kill Codex, restore original + subprocess.run(["pkill", "-x", "Codex"], capture_output=True) + time.sleep(0.5) + + if backup_auth.exists(): + shutil.copy2(str(backup_auth), str(auth)) + backup_auth.unlink() + if backup_sid.exists(): + shutil.copy2(str(backup_sid), str(cap_sid)) + backup_sid.unlink() + + # Relaunch with original + subprocess.Popen(["open", "-a", "Codex"]) + + if ok: + notify("Codex Switcher", "✅ Account Added", msg) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + + self._refresh_menu() + + def _cancel_add(self, backup_auth, backup_sid): + from pathlib import Path + import shutil + + codex_dir = Path.home() / ".codex" + auth = codex_dir / "auth.json" + cap_sid = codex_dir / "cap_sid" + + subprocess.run(["pkill", "-x", "Codex"], capture_output=True) + time.sleep(0.5) + + if backup_auth.exists(): + shutil.copy2(str(backup_auth), str(auth)) + backup_auth.unlink() + if backup_sid.exists(): + shutil.copy2(str(backup_sid), str(cap_sid)) + backup_sid.unlink() + + subprocess.Popen(["open", "-a", "Codex"]) + notify("Codex Switcher", "", "Cancelled. Original restored.") + self._refresh_menu() + + def _delete(self, account_id, email): + if not ask_confirm("Delete Account", f"Delete saved snapshot for {email}?"): + return + ok, msg = cli_run("delete", account_id) + if ok: + notify("Codex Switcher", "🗑 Deleted", email) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + self._refresh_menu() + + def _archive(self, account_id, email): + ok, msg = cli_run("archive", account_id) + if ok: + notify("Codex Switcher", "📁 Archived", email) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + self._refresh_menu() + + def _unarchive(self, account_id, email): + ok, msg = cli_run("archive", account_id, "--unarchive") + if ok: + notify("Codex Switcher", "📂 Unarchived", email) + else: + notify("Codex Switcher", "❌ Error", msg[:100]) + self._refresh_menu() + + def _on_refresh(self, _): + self._refresh_menu() + + def _on_quit(self, _): + rumps.quit_application() + + +if __name__ == "__main__": + CodexSwitcher().run() diff --git a/contrib/macos-menubar/requirements.txt b/contrib/macos-menubar/requirements.txt new file mode 100644 index 0000000..291e56a --- /dev/null +++ b/contrib/macos-menubar/requirements.txt @@ -0,0 +1 @@ +rumps>=0.4.0 diff --git a/contrib/macos-menubar/run.command b/contrib/macos-menubar/run.command new file mode 100755 index 0000000..540ad4d --- /dev/null +++ b/contrib/macos-menubar/run.command @@ -0,0 +1,8 @@ +#!/bin/bash +# Re-run Codex Switcher in background and detach it from the terminal +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +pkill -f codex_switcher.py 2>/dev/null +sleep 0.5 +osascript -e "do shell script \"${SCRIPT_DIR}/.venv/bin/python ${SCRIPT_DIR}/codex_switcher.py >/dev/null 2>&1 &\"" +osascript -e 'tell application "Terminal" to close first window' & +exit diff --git a/contrib/macos-menubar/setup.sh b/contrib/macos-menubar/setup.sh new file mode 100755 index 0000000..2db5ed7 --- /dev/null +++ b/contrib/macos-menubar/setup.sh @@ -0,0 +1,63 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +VENV="$SCRIPT_DIR/.venv" +PLIST=~/Library/LaunchAgents/com.codex-switcher.plist + +echo "=== Codex Account Switcher Setup ===" + +# 1. venv +if [ ! -d "$VENV" ]; then + echo "→ Creating venv..." + python3 -m venv "$VENV" +fi + +echo "→ Installing deps..." +"$VENV/bin/pip" install -q -r "$SCRIPT_DIR/requirements.txt" + +# 2. Create local launcher script to wait for mount +LAUNCHER_PATH=~/.local/bin/codex-switcher-launcher.sh +mkdir -p ~/.local/bin +cat > "$LAUNCHER_PATH" </dev/null + sleep 0.5 + exec "\$VENV_PATH" "\$PY_PATH" + fi + sleep 1 +done +exit 1 +EOF +chmod +x "$LAUNCHER_PATH" + +# 3. LaunchAgent (auto-start on login, run in menu bar) +cat > "$PLIST" < + + + + Label + com.codex-switcher + ProgramArguments + + ${LAUNCHER_PATH} + + RunAtLoad + + + +EOF + +# 4. Load agent +launchctl bootout gui/$(id -u) "$PLIST" 2>/dev/null || true +launchctl bootstrap gui/$(id -u) "$PLIST" + +echo "" +echo "✅ Done! Look for ⚡ in your menu bar." diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/scripts/build_macos_app.sh b/scripts/build_macos_app.sh new file mode 100755 index 0000000..d9f4065 --- /dev/null +++ b/scripts/build_macos_app.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ASSETS_DIR="$PROJECT_DIR/assets" +PNG_ICON="$ASSETS_DIR/codex-account-switcher-dock.png" +ICNS_ICON="$ASSETS_DIR/codex-account-switcher.icns" +APP_NAME="Codex Account Switcher" +APP_DIR="$PROJECT_DIR/target/release/$APP_NAME.app" +APP_VERSION="$(awk -F '\"' '/^version = / {print $2; exit}' "$PROJECT_DIR/Cargo.toml")" + +echo "=== Creating macOS Icon ===" +python3 "$SCRIPT_DIR/generate_icons.py" + +echo "=== Building Rust Application ===" +cargo build --release + +echo "=== Packaging App Bundle ===" +mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/Resources" + +# Copy binary +cp "$PROJECT_DIR/target/release/codex-account-switcher" "$APP_DIR/Contents/MacOS/" + +# Copy icon +cp "$ICNS_ICON" "$APP_DIR/Contents/Resources/icon.icns" + +# Create Info.plist +cat > "$APP_DIR/Contents/Info.plist" < + + + + CFBundleExecutable + codex-account-switcher + CFBundleIconFile + icon.icns + CFBundleIdentifier + com.pimpmuckl.codex-account-switcher + CFBundleName + Codex Account Switcher + CFBundlePackageType + APPL + CFBundleShortVersionString + ${APP_VERSION} + CFBundleVersion + ${APP_VERSION} + LSMinimumSystemVersion + 13.0 + LSUIElement + + + +EOF + +echo "✅ App bundle created at $APP_DIR" diff --git a/scripts/generate_icons.py b/scripts/generate_icons.py new file mode 100755 index 0000000..a664f72 --- /dev/null +++ b/scripts/generate_icons.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +"""Generate the Codex Account Switcher logo and platform icon assets.""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "assets" +PNG_TRANSPARENT = ASSETS / "codex-account-switcher-transparent.png" +DOCK_PNG = ASSETS / "codex-account-switcher-dock.png" +ICO = ASSETS / "codex-account-switcher.ico" +ICNS = ASSETS / "codex-account-switcher.icns" + +# ── Brand palette ───────────────────────────────────────────────────────────── +DOCK_BG = (12, 10, 9, 255) # #0C0A09 warm black +STROKE_PRIMARY = (250, 250, 249, 255) # #FAFAF9 +ACCENT = (20, 184, 166, 255) # #14B8A6 teal — active account / switch +ACCENT_RIM = (45, 212, 191, 220) # #2DD4BF softer teal for dock rim + +# ── Logo geometry (tune here) ───────────────────────────────────────────────── +LOGO_MARGIN_RATIO = 0.14 +STROKE_DIVISOR = 11 # lower = thicker strokes (was 18) +# Interlocking diagonal arcs (chain-link / ∞), NOT left-right parentheses. +ARC_PRIMARY_START = 208 +ARC_PRIMARY_END = 338 +ARC_ACCENT_START = 28 +ARC_ACCENT_END = 158 +DOT_X_RATIO = 0.68 # unused — kept for tuning reference +DOT_Y_RATIO = 0.66 +DOT_RADIUS_DIVISOR = 22 + +# Center swap arrow (↔) — account switch symbol +SWAP_ARROW_Y_RATIO = 0.50 +SWAP_ARROW_HALF_SPAN_RATIO = 0.24 +SWAP_ARROW_SHAFT_DIV = 16 +SWAP_ARROW_HEAD_RATIO = 0.62 + +# ── Dock frame ──────────────────────────────────────────────────────────────── +DOCK_OUTER_MARGIN_DIV = 10 +DOCK_CORNER_RADIUS_DIV = 5 +DOCK_LOGO_INSET_RATIO = 0.13 +DOCK_RIM_DIV = 140 # lower = thicker teal rim (was 170) + + +def draw_bidirectional_arrow(draw, size: int, color) -> None: + """Bold ↔ swap arrow at center — readable even at menu-bar size.""" + cx = size // 2 + cy = int(size * SWAP_ARROW_Y_RATIO) + half_span = int(size * SWAP_ARROW_HALF_SPAN_RATIO) + shaft = max(12, size // SWAP_ARROW_SHAFT_DIV) + head = max(shaft * 2, int(half_span * SWAP_ARROW_HEAD_RATIO)) + + x_left = cx - half_span + x_right = cx + half_span + + draw.line([(x_left + head, cy), (x_right - head, cy)], fill=color, width=shaft) + draw.polygon( + [(x_left, cy), (x_left + head, cy - head), (x_left + head, cy + head)], + fill=color, + ) + draw.polygon( + [(x_right, cy), (x_right - head, cy - head), (x_right - head, cy + head)], + fill=color, + ) + + +def render_logo(size: int = 1024, *, colored_accent: bool = True): + """Interlocking orbits + centered swap arrow.""" + from PIL import Image, ImageDraw + + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + + margin = int(size * LOGO_MARGIN_RATIO) + bbox = (margin, margin, size - margin, size - margin) + stroke = max(18, size // STROKE_DIVISOR) + accent_color = ACCENT if colored_accent else STROKE_PRIMARY + + # Diagonal interlock: arcs cross at center like linked orbits, not "(" ")". + draw.arc( + bbox, + start=ARC_PRIMARY_START, + end=ARC_PRIMARY_END, + fill=STROKE_PRIMARY, + width=stroke, + ) + draw.arc( + bbox, + start=ARC_ACCENT_START, + end=ARC_ACCENT_END, + fill=accent_color, + width=stroke, + ) + + arrow_color = ACCENT if colored_accent else STROKE_PRIMARY + draw_bidirectional_arrow(draw, size, arrow_color) + + return canvas + + +def compose_dock_icon(size: int = 1024): + from PIL import Image, ImageDraw + + logo = render_logo(size, colored_accent=True) + canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(canvas) + + outer_margin = size // DOCK_OUTER_MARGIN_DIV + corner_radius = size // DOCK_CORNER_RADIUS_DIV + draw.rounded_rectangle( + (outer_margin, outer_margin, size - outer_margin, size - outer_margin), + radius=corner_radius, + fill=DOCK_BG, + ) + + rim = max(4, size // DOCK_RIM_DIV) + draw.rounded_rectangle( + (outer_margin, outer_margin, size - outer_margin, size - outer_margin), + radius=corner_radius, + outline=ACCENT_RIM, + width=rim, + ) + + inset = int(size * DOCK_LOGO_INSET_RATIO) + logo_size = size - inset * 2 + logo_resized = logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) + canvas.paste(logo_resized, (inset, inset), logo_resized) + return canvas + + +def write_transparent_png() -> None: + # Monochrome for macOS menu-bar template rendering + render_logo(colored_accent=False).save(PNG_TRANSPARENT) + print(f"Wrote {PNG_TRANSPARENT}") + + +def write_dock_png() -> None: + compose_dock_icon().save(DOCK_PNG) + print(f"Wrote {DOCK_PNG}") + + +def generate_ico(source_path: Path) -> None: + from PIL import Image + + source = Image.open(source_path).convert("RGBA") + sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] + source.save(ICO, format="ICO", sizes=sizes) + print(f"Wrote {ICO}") + + +def generate_icns(source_path: Path) -> None: + with tempfile.TemporaryDirectory(prefix="cas-iconset-") as tmp: + iconset = Path(tmp) / "icon.iconset" + iconset.mkdir() + + mappings = { + "icon_16x16.png": (16, 16), + "icon_16x16@2x.png": (32, 32), + "icon_32x32.png": (32, 32), + "icon_32x32@2x.png": (64, 64), + "icon_128x128.png": (128, 128), + "icon_128x128@2x.png": (256, 256), + "icon_256x256.png": (256, 256), + "icon_256x256@2x.png": (512, 512), + "icon_512x512.png": (512, 512), + "icon_512x512@2x.png": (1024, 1024), + } + + for name, size in mappings.items(): + subprocess.run( + [ + "sips", + "-z", + str(size[1]), + str(size[0]), + str(source_path), + "--out", + str(iconset / name), + ], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + subprocess.run(["iconutil", "-c", "icns", str(iconset), "-o", str(ICNS)], check=True) + print(f"Wrote {ICNS}") + + +def remove_stale_iconset() -> None: + stale = ASSETS / "icon.iconset" + if stale.exists(): + shutil.rmtree(stale) + print(f"Removed stale {stale}") + + +def main() -> int: + ASSETS.mkdir(parents=True, exist_ok=True) + remove_stale_iconset() + write_transparent_png() + write_dock_png() + generate_ico(DOCK_PNG) + if shutil.which("sips") and shutil.which("iconutil"): + generate_icns(DOCK_PNG) + else: + print("Skipping .icns generation (sips/iconutil not available)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/activity.rs b/src/activity.rs new file mode 100644 index 0000000..de63bc7 --- /dev/null +++ b/src/activity.rs @@ -0,0 +1,264 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result}; +use serde::Serialize; +use time::format_description::well_known::Rfc3339; +use time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset}; +use uuid::Uuid; + +#[derive(Debug, Serialize)] +struct ActivityEntry { + #[serde(with = "crate::time_serde::offset_datetime")] + timestamp: OffsetDateTime, + action: &'static str, + account_id: Uuid, + email: String, + label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + detail: Option, +} + +pub fn log_account_activation( + app_data_dir: &Path, + account_id: Uuid, + email: &str, + label: Option<&str>, + detail: Option<&str>, +) -> Result<()> { + append_entry( + app_data_dir, + ActivityEntry { + timestamp: OffsetDateTime::now_utc(), + action: "activate", + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + detail: detail.map(str::to_owned), + }, + ) +} + +pub fn log_pick_best( + app_data_dir: &Path, + account_id: Uuid, + email: &str, + label: Option<&str>, + score: f64, +) -> Result<()> { + append_entry( + app_data_dir, + ActivityEntry { + timestamp: OffsetDateTime::now_utc(), + action: "pick_best", + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + detail: Some(format!("score={score:.1}")), + }, + ) +} + +fn append_entry(app_data_dir: &Path, entry: ActivityEntry) -> Result<()> { + std::fs::create_dir_all(app_data_dir) + .with_context(|| format!("failed to create {}", app_data_dir.display()))?; + let path = app_data_dir.join("activity.jsonl"); + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .with_context(|| format!("failed to open {}", path.display()))?; + let line = serde_json::to_string(&entry).context("failed to encode activity entry")?; + writeln!(file, "{line}").with_context(|| format!("failed to append {}", path.display()))?; + Ok(()) +} + +pub fn read_activity_log(app_data_dir: &Path) -> Result> { + let path = app_data_dir.join("activity.jsonl"); + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(&path)?; + let mut entries = Vec::new(); + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(entry) = serde_json::from_str::(line) { + let timestamp = parse_activity_timestamp(entry.get("timestamp")); + let action = entry + .get("action") + .and_then(|a| a.as_str()) + .unwrap_or("") + .to_string(); + let account_id = entry + .get("account_id") + .and_then(|id| id.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil); + let email = entry + .get("email") + .and_then(|e| e.as_str()) + .unwrap_or("") + .to_string(); + let label = entry + .get("label") + .and_then(|l| l.as_str()) + .map(String::from); + let detail = entry + .get("detail") + .and_then(|d| d.as_str()) + .map(String::from); + + let (message, subtext) = format_activity_copy(&action, &email, label.as_deref(), detail.as_deref()); + let timestamp_iso = timestamp + .map(|t| t.format(&Rfc3339).unwrap_or_default()) + .unwrap_or_default(); + let timestamp_unix = timestamp.map(|t| t.unix_timestamp()).unwrap_or(0); + + entries.push(ActivityEntryView { + timestamp: timestamp_iso, + timestamp_unix, + action, + account_id, + email, + label, + detail, + message, + subtext, + }); + } + } + entries.reverse(); + entries.truncate(50); + Ok(entries) +} + +fn format_activity_copy( + action: &str, + email: &str, + label: Option<&str>, + detail: Option<&str>, +) -> (String, String) { + let who = match label { + Some(l) if !l.is_empty() => format!("{email} ({l})"), + _ => email.to_owned(), + }; + let message = match action { + "activate" => { + if who.is_empty() { + "Switched account".to_owned() + } else { + format!("Switched to {who}") + } + } + "pick_best" => { + if who.is_empty() { + "Picked best quota account".to_owned() + } else { + format!("Best quota → {who}") + } + } + other if !other.is_empty() => { + if who.is_empty() { + other.to_owned() + } else { + format!("{other}: {who}") + } + } + _ => { + if who.is_empty() { + "Activity".to_owned() + } else { + who + } + } + }; + let subtext = detail.unwrap_or("").to_owned(); + (message, subtext) +} + +/// Accept RFC3339 string or legacy `time` serde tuple arrays from older logs. +fn parse_activity_timestamp(value: Option<&serde_json::Value>) -> Option { + let value = value?; + if let Some(s) = value.as_str() { + return OffsetDateTime::parse(s, &Rfc3339).ok(); + } + if let Some(items) = value.as_array() + && items.len() >= 6 + { + let year = items[0].as_i64()? as i32; + let ordinal = items[1].as_u64()? as u16; + let hour = items[2].as_u64().unwrap_or(0) as u8; + let minute = items[3].as_u64().unwrap_or(0) as u8; + let second = items[4].as_u64().unwrap_or(0) as u8; + let nanosecond = items[5].as_u64().unwrap_or(0) as u32; + let off_h = items.get(6).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + let off_m = items.get(7).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + let off_s = items.get(8).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + let date = Date::from_ordinal_date(year, ordinal).ok()?; + let time = Time::from_hms_nano(hour, minute, second, nanosecond).ok()?; + let offset = UtcOffset::from_hms(off_h, off_m, off_s).ok()?; + return Some(PrimitiveDateTime::new(date, time).assume_offset(offset)); + } + if let Some(secs) = value.as_i64() { + return OffsetDateTime::from_unix_timestamp(secs).ok(); + } + None +} + +#[derive(Debug, Serialize)] +pub struct ActivityEntryView { + /// RFC3339 string for display parsers. + pub timestamp: String, + /// Unix seconds for simple JS `new Date(ts * 1000)`. + pub timestamp_unix: i64, + pub action: String, + pub account_id: Uuid, + pub email: String, + pub label: Option, + pub detail: Option, + /// Preformatted primary line for the Overview UI. + pub message: String, + /// Secondary line (provider / score / etc.). + pub subtext: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn appends_activity_lines() -> Result<()> { + let temp = tempdir()?; + let id = Uuid::new_v4(); + log_account_activation(temp.path(), id, "person@example.com", Some("work"), None)?; + let contents = std::fs::read_to_string(temp.path().join("activity.jsonl"))?; + assert!(contents.contains("person@example.com")); + assert!(contents.contains("work")); + // New writes use RFC3339 timestamps, not tuple arrays. + assert!(contents.contains("T")); + + let read = read_activity_log(temp.path())?; + assert_eq!(read.len(), 1); + assert_eq!(read[0].email, "person@example.com"); + assert!(read[0].message.contains("person@example.com")); + assert!(read[0].message.contains("Switched")); + assert!(read[0].timestamp_unix > 0); + assert!(!read[0].timestamp.is_empty()); + Ok(()) + } + + #[test] + fn reads_legacy_tuple_timestamp() { + let raw = r#"{"timestamp":[2026,186,3,52,38,0,0,0,0],"action":"activate","account_id":"00000000-0000-0000-0000-000000000001","email":"a@b.com","label":null,"detail":"codex"}"#; + let value: serde_json::Value = serde_json::from_str(raw).unwrap(); + let ts = parse_activity_timestamp(value.get("timestamp")).expect("legacy ts"); + assert_eq!(ts.year(), 2026); + let (msg, sub) = format_activity_copy("activate", "a@b.com", None, Some("codex")); + assert_eq!(msg, "Switched to a@b.com"); + assert_eq!(sub, "codex"); + } +} diff --git a/src/app.rs b/src/app.rs index f080861..a25b968 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,10 @@ mod auto_start; mod service; -mod tui; use uuid::Uuid; pub use auto_start::spawn_auto_start_usage_windows_worker; -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] pub(crate) use auto_start::{ run_auto_start_usage_windows_check_now, subscribe_auto_start_usage_windows_checks, }; @@ -22,20 +21,6 @@ pub struct App { repository: SnapshotRepository, } -#[derive(Clone, Copy)] -pub enum InteractiveMode { - Persistent, - ActivateOnce, - DeleteOnce, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum InteractiveExit { - Quit, - #[cfg(windows)] - SendToTray, -} - fn account_view( account: SavedAccountMetadata, active_id: Option, @@ -57,6 +42,8 @@ fn account_view( subject: account.subject, name: account.name, plan_label: account.plan_label, + workspace_id: account.workspace_id, + workspace_name: account.workspace_name, environment: account.environment, is_active: active_id.is_some_and(|id| id == account.id), created_at: account.created_at, @@ -64,6 +51,9 @@ fn account_view( last_activated_at: account.last_activated_at, usage, usage_error, + label: account.label, + is_archived: account.is_archived, + target_app: account.target_app, } } @@ -71,19 +61,78 @@ fn match_saved_account<'a>( accounts: &'a [SavedAccountMetadata], identity: &DisplayIdentity, ) -> Option<&'a SavedAccountMetadata> { - accounts - .iter() - .find(|account| saved_identity(account).matches(identity)) + match_saved_account_with_app(accounts, identity, Some("codex")) } -fn account_view_matches_identity(account: &AccountView, identity: &DisplayIdentity) -> bool { - DisplayIdentity { - email: account.email.clone(), - subject: account.subject.clone(), - name: account.name.clone(), - plan_label: account.plan_label.clone(), +fn match_saved_account_with_app<'a>( + accounts: &'a [SavedAccountMetadata], + identity: &DisplayIdentity, + target_app: Option<&str>, +) -> Option<&'a SavedAccountMetadata> { + let app = target_app.unwrap_or("codex"); + if let Some(found) = accounts.iter().find(|account| { + account.target_app.as_deref().unwrap_or("codex") == app + && saved_identity(account).matches(identity) + }) { + return Some(found); + } + + // Claude Code often exposes tokens without email (keychain / credentials). + // Status uses a lightweight identity path that never blocks on CLI, so live + // email can be the placeholder even when a saved Claude snapshot exists. + // Bind to the sole / best-guess Claude account so tray quota still resolves. + if app == "claude" + && identity + .email + .eq_ignore_ascii_case(crate::claude::CLAUDE_UNKNOWN_EMAIL) + { + return fallback_unresolved_claude_account(accounts, identity); + } + None +} + +/// When live Claude identity has no email, pick the best saved Claude snapshot. +fn fallback_unresolved_claude_account<'a>( + accounts: &'a [SavedAccountMetadata], + identity: &DisplayIdentity, +) -> Option<&'a SavedAccountMetadata> { + let mut candidates: Vec<&'a SavedAccountMetadata> = accounts + .iter() + .filter(|account| { + account.target_app.as_deref() == Some("claude") && !account.is_archived + }) + .collect(); + if candidates.is_empty() { + return None; + } + // Prefer same subscription plan when multiple Claude accounts exist. + if let Some(plan) = identity.plan_label.as_deref() { + let plan_matches: Vec<_> = candidates + .iter() + .copied() + .filter(|account| { + account + .plan_label + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case(plan)) + }) + .collect(); + if !plan_matches.is_empty() { + candidates = plan_matches; + } + } + if candidates.len() == 1 { + return Some(candidates[0]); } - .matches(identity) + candidates.into_iter().max_by_key(|account| { + ( + account + .last_activated_at + .map(|t| t.unix_timestamp_nanos()) + .unwrap_or(0), + account.updated_at.unix_timestamp_nanos(), + ) + }) } fn saved_identity(account: &SavedAccountMetadata) -> DisplayIdentity { @@ -92,12 +141,14 @@ fn saved_identity(account: &SavedAccountMetadata) -> DisplayIdentity { subject: account.subject.clone(), name: account.name.clone(), plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), } } fn subject_bound_identity_matches(expected: &DisplayIdentity, snapshot: &DisplayIdentity) -> bool { match (&expected.subject, &snapshot.subject) { - (Some(left), Some(right)) => left == right, + (Some(left), Some(right)) => left == right && expected.workspace_matches(snapshot), _ => false, } } diff --git a/src/app/auto_start.rs b/src/app/auto_start.rs index 637f54a..69771b5 100644 --- a/src/app/auto_start.rs +++ b/src/app/auto_start.rs @@ -1,14 +1,14 @@ use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::process::{Command, ExitStatus, Stdio}; use std::sync::mpsc::Sender; -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] use std::sync::mpsc::{self, Receiver}; use std::sync::{Mutex, OnceLock}; use std::thread; use std::time::{Duration as StdDuration, Instant}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result, anyhow, bail}; use time::OffsetDateTime; use uuid::Uuid; @@ -16,15 +16,32 @@ use crate::codex; use crate::env::AppEnv; use crate::model::{ AUTH_FILES, AutoStartUsageWindowAccountResult, AutoStartUsageWindowsRunOutput, - AutoStartUsageWindowsStatusOutput, DisplayIdentity, SnapshotBlob, + AutoStartUsageWindowsStatusOutput, AutoSwitchOnLimitStatusOutput, + DisableBlockerWarningsStatusOutput, DisplayIdentity, LaunchAtStartupStatusOutput, + ShowQuotaInMenuBarStatusOutput, SnapshotBlob, }; use crate::repository::SnapshotRepository; use crate::secrets::{MigratingSecretStore, SecretStore}; -use crate::settings::{load_settings, save_settings}; +use crate::settings::{DEFAULT_USAGE_WINDOW_POLL_SECONDS, load_settings, save_settings}; use super::App; -pub const AUTO_START_USAGE_WINDOW_POLL_SECONDS: u64 = 300; +pub const NEAR_LIMIT_POLL_SECONDS: u64 = 30; +pub const URGENT_POLL_SECONDS: u64 = 15; +#[cfg(not(test))] +const AUTO_SWITCH_CODEX_WAIT_SECONDS: u64 = 120; + +#[allow(dead_code)] +fn auto_switch_codex_wait_timeout() -> StdDuration { + #[cfg(test)] + { + StdDuration::ZERO + } + #[cfg(not(test))] + { + StdDuration::from_secs(AUTO_SWITCH_CODEX_WAIT_SECONDS) + } +} const PING_INSTRUCTIONS: &str = "Reply only with ACK."; const PING_PROMPT: &str = "ACK"; @@ -35,7 +52,10 @@ where { pub fn auto_start_usage_windows_status(&self) -> Result { let settings = load_settings(&self.env.app_data_dir)?; - Ok(auto_start_status_output(settings.auto_start_usage_windows)) + Ok(auto_start_status_output( + settings.auto_start_usage_windows, + compute_poll_interval(&self.env), + )) } pub fn set_auto_start_usage_windows( @@ -45,7 +65,224 @@ where let mut settings = load_settings(&self.env.app_data_dir)?; settings.auto_start_usage_windows = enabled; save_settings(&self.env.app_data_dir, &settings)?; - Ok(auto_start_status_output(enabled)) + Ok(auto_start_status_output( + enabled, + compute_poll_interval(&self.env), + )) + } + + pub fn auto_switch_on_limit_status(&self) -> Result { + let settings = load_settings(&self.env.app_data_dir)?; + Ok(AutoSwitchOnLimitStatusOutput { + enabled: settings.auto_switch_on_limit, + }) + } + + pub fn set_auto_switch_on_limit(&self, enabled: bool) -> Result { + let mut settings = load_settings(&self.env.app_data_dir)?; + settings.auto_switch_on_limit = enabled; + save_settings(&self.env.app_data_dir, &settings)?; + Ok(AutoSwitchOnLimitStatusOutput { enabled }) + } + + pub fn launch_at_startup_status(&self) -> Result { + let settings = load_settings(&self.env.app_data_dir)?; + Ok(LaunchAtStartupStatusOutput { + enabled: settings.launch_at_startup, + }) + } + + pub fn show_quota_in_menu_bar_status(&self) -> Result { + let settings = load_settings(&self.env.app_data_dir)?; + Ok(ShowQuotaInMenuBarStatusOutput { + enabled: settings.show_quota_in_menu_bar, + }) + } + + pub fn set_show_quota_in_menu_bar( + &self, + enabled: bool, + ) -> Result { + let mut settings = load_settings(&self.env.app_data_dir)?; + settings.show_quota_in_menu_bar = enabled; + save_settings(&self.env.app_data_dir, &settings)?; + Ok(ShowQuotaInMenuBarStatusOutput { enabled }) + } + + pub fn disable_blocker_warnings_status(&self) -> Result { + let settings = load_settings(&self.env.app_data_dir)?; + Ok(DisableBlockerWarningsStatusOutput { + enabled: settings.disable_blocker_warnings, + }) + } + + pub fn set_disable_blocker_warnings( + &self, + enabled: bool, + ) -> Result { + let mut settings = load_settings(&self.env.app_data_dir)?; + settings.disable_blocker_warnings = enabled; + save_settings(&self.env.app_data_dir, &settings)?; + Ok(DisableBlockerWarningsStatusOutput { enabled }) + } + + pub fn ui_language_status(&self) -> Result { + let settings = load_settings(&self.env.app_data_dir)?; + let preference = crate::settings::normalize_ui_language(&settings.ui_language) + .unwrap_or("auto") + .to_owned(); + let resolved = crate::settings::resolve_ui_language(&preference) + .as_str() + .to_owned(); + Ok(crate::model::UiLanguageStatusOutput { + preference, + resolved, + }) + } + + pub fn set_ui_language( + &self, + preference: &str, + ) -> Result { + let normalized = crate::settings::normalize_ui_language(preference) + .ok_or_else(|| anyhow::anyhow!("unsupported ui language: {preference}"))?; + let mut settings = load_settings(&self.env.app_data_dir)?; + settings.ui_language = normalized.to_owned(); + save_settings(&self.env.app_data_dir, &settings)?; + self.ui_language_status() + } + + pub fn set_launch_at_startup(&self, enabled: bool) -> Result { + let mut settings = load_settings(&self.env.app_data_dir)?; + settings.launch_at_startup = enabled; + save_settings(&self.env.app_data_dir, &settings)?; + + #[cfg(target_os = "macos")] + { + if enabled { + if let Ok(exe) = std::env::current_exe() { + install_macos_launch_at_startup(&self.env.home_dir, &exe)?; + } + } else { + uninstall_macos_launch_at_startup(&self.env.home_dir)?; + } + } + + #[cfg(target_os = "windows")] + { + if enabled { + if let Ok(exe) = std::env::current_exe() { + let cmd = format!( + "Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name 'CodexAccountSwitcher' -Value '\"{}\"'", + exe.display() + ); + let _ = std::process::Command::new("powershell") + .args(["-Command", &cmd]) + .output(); + } + } else { + let cmd = "Remove-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name 'CodexAccountSwitcher' -ErrorAction SilentlyContinue"; + let _ = std::process::Command::new("powershell") + .args(["-Command", cmd]) + .output(); + } + } + + Ok(LaunchAtStartupStatusOutput { enabled }) + } + + pub fn update_launch_at_startup_path_if_enabled(&self) -> Result<()> { + let settings = load_settings(&self.env.app_data_dir)?; + if !settings.launch_at_startup { + return Ok(()); + } + + #[cfg(target_os = "macos")] + if let Ok(exe) = std::env::current_exe() { + update_macos_launch_at_startup_if_needed(&self.env.home_dir, &exe)?; + } + Ok(()) + } + + pub fn auto_switch_on_limit_once(&self) -> Result> { + let settings = load_settings(&self.env.app_data_dir)?; + if !settings.auto_switch_on_limit { + return Ok(None); + } + // Never quit/relaunch Codex while the user is in browser OAuth + // (redirect_uri=http://localhost:1455/auth/callback). + if codex::interactive_login_in_progress(&self.env) { + return Ok(None); + } + let threshold = settings.near_limit_threshold_percent; + self.refresh_saved_usage_cache()?; + let accounts = self.repository.list_accounts(&self.env.kind)?; + let status = self.status()?; + let Some(current_id) = status.current_account_saved_id else { + return Ok(None); + }; + let Some(current_account) = accounts.iter().find(|a| a.id == current_id) else { + return Ok(None); + }; + let now = OffsetDateTime::now_utc(); + let switch_reason = current_switch_reason(current_account, now); + if switch_reason.is_none() { + return Ok(None); + } + let switch_reason = switch_reason.expect("checked above"); + let scores = accounts + .iter() + .map(|account| { + crate::quota_scoring::score_saved_account_for_auto_switch( + account.id, + &account.email, + account.label.as_deref(), + account.cached_usage.as_ref(), + account.cached_usage_error.as_deref(), + now, + ) + }) + .collect::>(); + let Some(target_id) = crate::quota_scoring::pick_switch_target(&scores, current_id) else { + return Ok(None); + }; + let target_account = accounts + .iter() + .find(|account| account.id == target_id) + .context("switch target account missing after scoring")?; + if !switch_target_is_improvement( + switch_reason, + target_account.cached_usage.as_ref(), + now, + threshold, + ) { + return Ok(None); + } + if !self.activation_preflight_warnings().is_empty() { + if !auto_switch_should_quit_running_codex(switch_reason) { + return Ok(None); + } + #[cfg(not(test))] + { + crate::process::quit_running_codex_app(); + crate::process::force_quit_switch_blocking_codex_processes(); + } + if !crate::process::wait_for_codex_processes_to_exit_timeout( + auto_switch_codex_wait_timeout(), + ) { + return Ok(None); + } + } + + self.activate_with_running_policy(target_id, false)?; + crate::process::launch_codex_app(); + + let email = target_account.email.clone(); + let plan = target_account.plan_label.as_deref().unwrap_or("Free"); + let msg = format!("Auto-switched to {email} ({plan}): {switch_reason}."); + notify_auto_switch(&msg); + + Ok(Some(target_id)) } pub fn auto_start_usage_windows_once( @@ -53,7 +290,7 @@ where require_enabled: bool, ) -> Result { let settings = load_settings(&self.env.app_data_dir)?; - let enabled = settings.auto_start_usage_windows; + let enabled = settings.auto_start_usage_windows || settings.auto_switch_on_limit; let mut output = AutoStartUsageWindowsRunOutput { enabled, checked_accounts: 0, @@ -64,6 +301,11 @@ where return Ok(output); } + // Pings use `codex exec` and menu refresh; skip entirely during OAuth login. + if codex::interactive_login_in_progress(&self.env) { + return Ok(output); + } + let _run_guard = AUTO_START_RUN_LOCK .get_or_init(|| Mutex::new(())) .lock() @@ -89,21 +331,24 @@ where .push(format!("{}: usage unavailable: {error:#}", account.email)), } } - if due_accounts.is_empty() { - return Ok(output); + + if !due_accounts.is_empty() { + for (account_id, email) in due_accounts { + let result = match self.ping_usage_window_account(account_id, &email) { + Ok(result) => result, + Err(error) => AutoStartUsageWindowAccountResult { + account_id, + email, + status: "failed".to_owned(), + detail: Some(format!("{error:#}")), + }, + }; + output.pinged_accounts.push(result); + } } - for (account_id, email) in due_accounts { - let result = match self.ping_usage_window_account(account_id, &email) { - Ok(result) => result, - Err(error) => AutoStartUsageWindowAccountResult { - account_id, - email, - status: "failed".to_owned(), - detail: Some(format!("{error:#}")), - }, - }; - output.pinged_accounts.push(result); + if let Err(error) = self.auto_switch_on_limit_once() { + eprintln!("Auto-switch on limit check failed: {error:#}"); } Ok(output) @@ -151,7 +396,7 @@ where static AUTO_START_RUN_LOCK: OnceLock> = OnceLock::new(); static AUTO_START_CHECK_LISTENERS: OnceLock>>> = OnceLock::new(); -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] pub(crate) fn subscribe_auto_start_usage_windows_checks() -> Receiver<()> { let (sender, receiver) = mpsc::channel(); let mut listeners = AUTO_START_CHECK_LISTENERS @@ -173,12 +418,118 @@ pub fn spawn_auto_start_usage_windows_worker(env: AppEnv) { eprintln!("auto-start usage-window check failed: {error:#}"); } notify_auto_start_usage_windows_checked(); - thread::sleep(StdDuration::from_secs(AUTO_START_USAGE_WINDOW_POLL_SECONDS)); + let poll_seconds = compute_poll_interval(&env); + thread::sleep(StdDuration::from_secs(poll_seconds)); } }); }); } +fn switch_target_is_improvement( + reason: &str, + target: Option<&crate::model::AccountUsageView>, + now: OffsetDateTime, + near_limit_threshold: u8, +) -> bool { + if reason == "login expired" { + return true; + } + let Some(target) = target else { + return false; + }; + if target.is_fully_exhausted(now) || target.has_stale_quota_cache(now) { + return false; + } + match reason { + "rate limit detected" | "quota exhausted" => { + !target.should_switch_account(now, near_limit_threshold) + } + _ => false, + } +} + +fn current_switch_reason( + account: &crate::model::SavedAccountMetadata, + now: OffsetDateTime, +) -> Option<&'static str> { + if let Some(err) = account.cached_usage_error.as_deref() { + if crate::usage::usage_error_indicates_rate_limit(err) { + return Some("rate limit detected"); + } + if crate::usage::usage_error_requires_login(err) { + return Some("login expired"); + } + } + let usage = account.cached_usage.as_ref()?; + if usage.is_out_of_quota(now) { + return Some("quota exhausted"); + } + None +} + +fn auto_switch_should_quit_running_codex(reason: &str) -> bool { + matches!( + reason, + "quota exhausted" | "rate limit detected" | "login expired" + ) +} + +fn compute_poll_interval(env: &AppEnv) -> u64 { + let settings = load_settings(&env.app_data_dir).unwrap_or_default(); + let enabled = settings.auto_start_usage_windows || settings.auto_switch_on_limit; + if !enabled { + return DEFAULT_USAGE_WINDOW_POLL_SECONDS; + } + + let interval = if settings.auto_switch_on_limit { + settings.auto_switch_poll_seconds.max(15) + } else { + DEFAULT_USAGE_WINDOW_POLL_SECONDS + }; + + if !settings.auto_switch_on_limit { + return interval; + } + + let repository = SnapshotRepository::new( + &env.app_data_dir, + MigratingSecretStore::new(&env.app_data_dir.join("snapshots")), + ); + let Ok(accounts) = repository.list_accounts(&env.kind) else { + return interval; + }; + let now = OffsetDateTime::now_utc(); + let threshold = settings.near_limit_threshold_percent; + let mut urgent = false; + let mut near_limit = false; + + for account in &accounts { + if let Some(err) = account.cached_usage_error.as_deref() + && crate::usage::usage_error_indicates_rate_limit(err) + { + urgent = true; + break; + } + if let Some(usage) = account.cached_usage.as_ref() { + if usage.is_out_of_quota(now) { + urgent = true; + break; + } + if usage.is_near_limit(now, threshold) { + near_limit = true; + } + } + } + + if urgent { + URGENT_POLL_SECONDS + } else if near_limit { + interval.min(NEAR_LIMIT_POLL_SECONDS) + } else { + interval + } +} + fn notify_auto_start_usage_windows_checked() { let Some(listeners) = AUTO_START_CHECK_LISTENERS.get() else { return; @@ -190,7 +541,7 @@ fn notify_auto_start_usage_windows_checked() { listeners.retain(|listener| listener.send(()).is_ok()); } -#[cfg(windows)] +#[cfg(any(target_os = "windows", target_os = "macos"))] pub(crate) fn run_auto_start_usage_windows_check_now(env: AppEnv) -> Result<()> { run_auto_start_usage_windows_for_env(env) } @@ -205,17 +556,289 @@ fn run_auto_start_usage_windows_for_env(env: AppEnv) -> Result<()> { Ok(()) } -fn auto_start_status_output(enabled: bool) -> AutoStartUsageWindowsStatusOutput { +fn auto_start_status_output(enabled: bool, poll_seconds: u64) -> AutoStartUsageWindowsStatusOutput { AutoStartUsageWindowsStatusOutput { enabled, - poll_seconds: AUTO_START_USAGE_WINDOW_POLL_SECONDS, + poll_seconds, + } +} + +#[cfg(target_os = "macos")] +const MACOS_LAUNCH_AGENT_LABEL: &str = "com.anlvdt.codex-account-switcher"; + +#[cfg(target_os = "macos")] +const MACOS_LAUNCH_AGENT_WAIT_SECONDS: u32 = 60; + +#[cfg(target_os = "macos")] +fn macos_launch_agent_plist_path(home_dir: &Path) -> PathBuf { + home_dir + .join("Library") + .join("LaunchAgents") + .join(format!("{MACOS_LAUNCH_AGENT_LABEL}.plist")) +} + +#[cfg(target_os = "macos")] +fn macos_launcher_script_path(home_dir: &Path) -> PathBuf { + home_dir + .join(".local") + .join("bin") + .join("codex-account-switcher-launcher.sh") +} + +#[cfg(target_os = "macos")] +fn macos_launch_agent_domain() -> Result { + let output = Command::new("id") + .arg("-u") + .output() + .context("failed to resolve user id for launchctl")?; + if !output.status.success() { + bail!("id -u failed"); + } + let uid = String::from_utf8(output.stdout) + .context("id -u returned invalid utf-8")? + .trim() + .to_owned(); + Ok(format!("gui/{uid}")) +} + +#[cfg(target_os = "macos")] +fn macos_installed_menubar_binary_path(home_dir: &Path) -> PathBuf { + home_dir + .join(".local") + .join("bin") + .join("codex-account-switcher-menubar") +} + +#[cfg(target_os = "macos")] +fn install_macos_menubar_binary(home_dir: &Path, exe: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + + let installed = macos_installed_menubar_binary_path(home_dir); + if let Some(parent) = installed.parent() { + fs::create_dir_all(parent)?; } + let needs_copy = fs::read(exe) + .ok() + .zip(fs::read(&installed).ok()) + .is_none_or(|(source, existing)| source != existing); + if needs_copy { + fs::copy(exe, &installed).with_context(|| { + format!( + "failed to install menubar binary to {}", + installed.display() + ) + })?; + let mut permissions = fs::metadata(&installed)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&installed, permissions)?; + } + Ok(installed) +} + +#[cfg(target_os = "macos")] +fn macos_launcher_script_content(installed_exe: &Path, fallback_exe: &Path) -> String { + format!( + r#"#!/bin/bash +# Prefer the installed copy on the boot volume; fall back to the dev build path. +TARGET="{}" +FALLBACK="{}" +if [ -x "$TARGET" ]; then + exec "$TARGET" +fi +for i in $(seq 1 {MACOS_LAUNCH_AGENT_WAIT_SECONDS}); do + if [ -f "$FALLBACK" ]; then + exec "$FALLBACK" + fi + sleep 1 +done +exit 1 +"#, + installed_exe.display().to_string().replace('"', "\\\""), + fallback_exe.display().to_string().replace('"', "\\\"") + ) +} + +#[cfg(target_os = "macos")] +fn macos_launch_agent_log_dir(home_dir: &Path) -> PathBuf { + home_dir + .join("Library") + .join("Logs") + .join("com.anlvdt.codex-account-switcher") +} + +#[cfg(target_os = "macos")] +fn macos_launch_agent_plist_content(home_dir: &Path, program_path: &Path) -> String { + let log_dir = macos_launch_agent_log_dir(home_dir); + let stdout = log_dir.join("launchd.stdout.log"); + let stderr = log_dir.join("launchd.stderr.log"); + format!( + r#" + + + + Label + {MACOS_LAUNCH_AGENT_LABEL} + ProgramArguments + + {} + + RunAtLoad + + KeepAlive + + LimitLoadToSessionType + Aqua + StandardOutPath + {} + StandardErrorPath + {} + +"#, + program_path.display(), + stdout.display(), + stderr.display() + ) +} + +#[cfg(target_os = "macos")] +fn write_macos_launcher_script(home_dir: &Path, exe: &Path) -> Result { + use std::os::unix::fs::PermissionsExt; + + let installed_exe = install_macos_menubar_binary(home_dir, exe)?; + let launcher_path = macos_launcher_script_path(home_dir); + if let Some(parent) = launcher_path.parent() { + fs::create_dir_all(parent)?; + } + let content = macos_launcher_script_content(&installed_exe, exe); + fs::write(&launcher_path, content)?; + let mut permissions = fs::metadata(&launcher_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&launcher_path, permissions)?; + Ok(launcher_path) +} + +#[cfg(target_os = "macos")] +fn write_macos_launch_agent_plist(home_dir: &Path, program_path: &Path) -> Result { + let plist_path = macos_launch_agent_plist_path(home_dir); + if let Some(parent) = plist_path.parent() { + fs::create_dir_all(parent)?; + } + fs::create_dir_all(macos_launch_agent_log_dir(home_dir))?; + fs::write( + &plist_path, + macos_launch_agent_plist_content(home_dir, program_path), + )?; + Ok(plist_path) +} + +#[cfg(target_os = "macos")] +fn macos_launch_agent_is_loaded() -> Result { + let domain = macos_launch_agent_domain()?; + let service = format!("{domain}/{MACOS_LAUNCH_AGENT_LABEL}"); + let output = Command::new("launchctl") + .args(["print", &service]) + .output() + .context("failed to run launchctl print")?; + Ok(output.status.success()) +} + +#[cfg(target_os = "macos")] +fn ensure_macos_launch_agent_loaded(plist_path: &Path) -> Result<()> { + if macos_launch_agent_is_loaded()? { + return Ok(()); + } + if !plist_path.exists() { + return Ok(()); + } + reload_macos_launch_agent(plist_path) +} + +#[cfg(target_os = "macos")] +fn reload_macos_launch_agent(plist_path: &Path) -> Result<()> { + let domain = macos_launch_agent_domain()?; + let plist = plist_path + .to_str() + .context("launch agent plist path is not valid utf-8")?; + let _ = Command::new("launchctl") + .args(["bootout", &domain, plist]) + .output(); + let output = Command::new("launchctl") + .args(["bootstrap", &domain, plist]) + .output() + .context("failed to run launchctl bootstrap")?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!("launchctl bootstrap failed: {stderr}"); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn unload_macos_launch_agent(plist_path: &Path) -> Result<()> { + let domain = macos_launch_agent_domain()?; + let plist = plist_path + .to_str() + .context("launch agent plist path is not valid utf-8")?; + let _ = Command::new("launchctl") + .args(["bootout", &domain, plist]) + .output(); + Ok(()) +} + +#[cfg(target_os = "macos")] +fn install_macos_launch_at_startup(home_dir: &Path, exe: &Path) -> Result<()> { + let installed_exe = install_macos_menubar_binary(home_dir, exe)?; + write_macos_launcher_script(home_dir, exe)?; + let plist_path = write_macos_launch_agent_plist(home_dir, &installed_exe)?; + reload_macos_launch_agent(&plist_path) +} + +#[cfg(target_os = "macos")] +fn uninstall_macos_launch_at_startup(home_dir: &Path) -> Result<()> { + let plist_path = macos_launch_agent_plist_path(home_dir); + if plist_path.exists() { + unload_macos_launch_agent(&plist_path)?; + let _ = fs::remove_file(plist_path); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn update_macos_launch_at_startup_if_needed(home_dir: &Path, exe: &Path) -> Result<()> { + let installed_exe = install_macos_menubar_binary(home_dir, exe)?; + write_macos_launcher_script(home_dir, exe)?; + let plist_path = macos_launch_agent_plist_path(home_dir); + let plist_content = macos_launch_agent_plist_content(home_dir, &installed_exe); + let plist_changed = fs::read_to_string(&plist_path) + .map(|content| content != plist_content) + .unwrap_or(true); + + if plist_changed { + write_macos_launch_agent_plist(home_dir, &installed_exe)?; + reload_macos_launch_agent(&plist_path)?; + } else { + ensure_macos_launch_agent_loaded(&plist_path)?; + } + Ok(()) } fn usage_window_needs_ping(reset_at: OffsetDateTime, now: OffsetDateTime) -> bool { reset_at <= now } +fn notify_auto_switch(body: &str) { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(format!( + "display notification \"{}\" with title \"ChatGPT Codex\"", + body.replace('"', "\\\"") + )) + .spawn(); + } +} + struct CodexUsagePingResult { snapshot: SnapshotBlob, cleanup_warning: Option, @@ -460,7 +1083,76 @@ mod tests { assert!(!usage_window_needs_ping(now + Duration::minutes(1), now)); } - #[cfg(windows)] + #[test] + fn switch_target_is_improvement_rejects_exhausted_target() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let current = crate::model::AccountUsageView { + source: crate::model::UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(crate::model::UsageWindowView { + used_percent: 100, + remaining_percent: 0, + reset_at: reset, + }), + weekly: None, + credits: None, + }; + let target = current.clone(); + assert!(!switch_target_is_improvement( + "quota exhausted", + Some(&target), + now, + 5, + )); + } + + #[test] + fn current_switch_reason_ignores_near_limit_quota() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let account = crate::model::SavedAccountMetadata { + id: uuid::Uuid::new_v4(), + environment: crate::model::EnvironmentKind::Macos, + email: "active@example.com".to_owned(), + subject: Some("sub-active".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, + secret_key: "snapshot:test".to_owned(), + created_at: now, + updated_at: now, + last_activated_at: None, + cached_usage: Some(crate::model::AccountUsageView { + source: crate::model::UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(crate::model::UsageWindowView { + used_percent: 97, + remaining_percent: 3, + reset_at: reset, + }), + weekly: None, + credits: None, + }), + cached_usage_error: None, + label: None, + is_archived: false, + }; + + assert_eq!(current_switch_reason(&account, now), None); + } + + #[test] + fn auto_switch_quits_codex_for_urgent_reasons_only() { + assert!(auto_switch_should_quit_running_codex("quota exhausted")); + assert!(auto_switch_should_quit_running_codex("rate limit detected")); + assert!(auto_switch_should_quit_running_codex("login expired")); + assert!(!auto_switch_should_quit_running_codex("near limit")); + } + + #[cfg(any(target_os = "windows", target_os = "macos"))] #[test] fn auto_start_check_notification_reaches_listener() { let receiver = subscribe_auto_start_usage_windows_checks(); @@ -496,4 +1188,224 @@ mod tests { assert!(!codex_home.exists()); Ok(()) } + + #[test] + fn auto_switch_on_limit_once_switches_when_out_of_quota() -> Result<()> { + use crate::model::{DisplayIdentity, EnvironmentKind, SnapshotBlob}; + use base64::Engine; + + let temp = tempfile::tempdir()?; + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: temp.path().join(".codex"), + app_data_dir: temp.path().join("app"), + }; + std::fs::create_dir_all(&env.codex_root)?; + std::fs::write( + env.codex_root.join("auth.json"), + crate::codex::auth_json_fixture("active@example.com", "sub-active", Some("pro")), + )?; + std::fs::write(env.codex_root.join("cap_sid"), "sid-active")?; + + let active_snapshot = SnapshotBlob { + schema_version: 1, + files: vec![ + crate::model::SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode( + crate::codex::auth_json_fixture( + "active@example.com", + "sub-active", + Some("pro"), + ), + ), + }, + crate::model::SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode("sid-active"), + }, + ], + }; + + let candidate_snapshot = SnapshotBlob { + schema_version: 1, + files: vec![ + crate::model::SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode( + crate::codex::auth_json_fixture( + "candidate@example.com", + "sub-candidate", + Some("pro"), + ), + ), + }, + crate::model::SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode("sid-candidate"), + }, + ], + }; + + let repo = SnapshotRepository::new( + &env.app_data_dir, + crate::secrets::test_support::MemorySecretStore::default(), + ); + let saved_active = repo + .save_snapshot( + &env.kind, + &DisplayIdentity { + email: "active@example.com".to_owned(), + subject: Some("sub-active".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &active_snapshot, + )? + .0; + + let saved_candidate = repo + .save_snapshot( + &env.kind, + &DisplayIdentity { + email: "candidate@example.com".to_owned(), + subject: Some("sub-candidate".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &candidate_snapshot, + )? + .0; + + let now = OffsetDateTime::now_utc(); + repo.replace_snapshot( + &env.kind, + saved_active.id, + &DisplayIdentity { + email: "active@example.com".to_owned(), + subject: Some("sub-active".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &active_snapshot, + Some(crate::model::AccountUsageView { + source: crate::model::UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(crate::model::UsageWindowView { + used_percent: 100, + remaining_percent: 0, + reset_at: now + Duration::hours(1), + }), + weekly: None, + credits: None, + }), + )?; + + repo.replace_snapshot( + &env.kind, + saved_candidate.id, + &DisplayIdentity { + email: "candidate@example.com".to_owned(), + subject: Some("sub-candidate".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &candidate_snapshot, + Some(crate::model::AccountUsageView { + source: crate::model::UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(crate::model::UsageWindowView { + used_percent: 0, + remaining_percent: 100, + reset_at: now + Duration::hours(1), + }), + weekly: None, + credits: None, + }), + )?; + + let app = App::new(env.clone(), repo); + + let switched = app.auto_switch_on_limit_once()?; + assert!(switched.is_none()); + let current = crate::codex::read_live_auth_bundle(&env)?; + assert_eq!(current.identity.email, "active@example.com"); + + app.set_auto_switch_on_limit(true)?; + + let codex_running = !crate::process::detect_running_codex_processes().is_empty(); + let switched = app.auto_switch_on_limit_once()?; + if codex_running { + assert!( + switched.is_none(), + "auto-switch should defer while Codex processes are running" + ); + } else { + assert_eq!(switched, Some(saved_candidate.id)); + let current = crate::codex::read_live_auth_bundle(&env)?; + assert_eq!(current.identity.email, "candidate@example.com"); + } + + Ok(()) + } + + #[test] + #[cfg(target_os = "macos")] + fn launch_at_startup_files_are_written_without_loading_launch_agent() -> Result<()> { + let temp = tempfile::tempdir()?; + let home_dir = temp.path(); + let exe = std::env::current_exe()?; + + let installed = install_macos_menubar_binary(home_dir, &exe)?; + let launcher_path = write_macos_launcher_script(home_dir, &exe)?; + let plist_path = write_macos_launch_agent_plist(home_dir, &installed)?; + + assert_eq!( + launcher_path, + home_dir + .join(".local") + .join("bin") + .join("codex-account-switcher-launcher.sh") + ); + assert_eq!( + installed, + home_dir + .join(".local") + .join("bin") + .join("codex-account-switcher-menubar") + ); + assert_eq!( + plist_path, + home_dir + .join("Library") + .join("LaunchAgents") + .join("com.anlvdt.codex-account-switcher.plist") + ); + let launcher = fs::read_to_string(&launcher_path)?; + assert!(launcher.contains("codex-account-switcher-menubar")); + let plist = fs::read_to_string(&plist_path)?; + assert!(plist.contains("codex-account-switcher-menubar")); + assert!(!plist.contains("codex-account-switcher-launcher.sh")); + assert!(plist.contains("KeepAlive")); + assert!(plist.contains("launchd.stdout.log")); + assert!(plist.contains("launchd.stderr.log")); + assert!( + home_dir + .join(".local") + .join("bin") + .join("codex-account-switcher-menubar") + .exists() + ); + + Ok(()) + } } diff --git a/src/app/service.rs b/src/app/service.rs index 5c06b1c..9e8279f 100644 --- a/src/app/service.rs +++ b/src/app/service.rs @@ -1,22 +1,32 @@ use anyhow::{Context, Result}; +use base64::Engine; use std::time::Duration; +use time::OffsetDateTime; use uuid::Uuid; +use std::sync::{Mutex, OnceLock}; + use crate::codex; use crate::env::AppEnv; use crate::model::{ - ActivateOutput, DeleteOutput, DisplayIdentity, ListOutput, RunningCodexProcess, SaveAction, - SaveOutput, SnapshotBlob, StatusOutput, UsageOutput, UsageSource, + ActivateOutput, BatchRefreshFailure, BatchRefreshOutput, DeleteOutput, DisplayIdentity, + ExportBundle, ImportOutput, ListOutput, PickBestOutput, PickBestScoreView, RenameOutput, + RunningCodexProcess, SaveAction, SaveOutput, SnapshotBlob, StatusOutput, UsageOutput, + UsageSource, }; use crate::repository::SnapshotRepository; use crate::secrets::SecretStore; -use crate::usage::{fetch_usage, usage_error_message, usage_target_from_snapshot}; +use crate::usage::{ + fetch_usage, usage_error_message, usage_error_requires_login, usage_target_from_snapshot, +}; use super::{ - App, account_view, match_saved_account, saved_identity, should_verify_activation_stability, - subject_bound_identity_matches, + App, account_view, match_saved_account, match_saved_account_with_app, saved_identity, + should_verify_activation_stability, subject_bound_identity_matches, }; +static ACCOUNT_SWITCH_LOCK: OnceLock> = OnceLock::new(); + impl App where S: SecretStore, @@ -30,6 +40,11 @@ where } pub fn status(&self) -> Result { + self.status_with_processes(true) + } + + /// Like [`Self::status`], but process scanning is optional (UI polls skip it when unused). + pub fn status_with_processes(&self, include_processes: bool) -> Result { let saved_accounts = self.repository.list_accounts(&self.env.kind)?; let live = codex::try_read_live_auth_bundle(&self.env)?; let current_saved_id = live @@ -42,7 +57,11 @@ where current_account: live.map(|bundle| bundle.identity), current_account_saved_id: current_saved_id, saved_accounts: saved_accounts.len(), - process_warnings: crate::process::detect_running_codex_processes(), + process_warnings: if include_processes { + crate::process::detect_running_codex_processes() + } else { + Vec::new() + }, }) } @@ -62,6 +81,33 @@ where }) } + pub fn begin_add_account_session(&self) -> Result<()> { + codex::begin_add_account_session(&self.env) + } + + pub fn save_during_add_account_session(&self) -> Result { + if !codex::add_account_session_active(&self.env) { + anyhow::bail!("no add account session in progress"); + } + if !self.env.codex_root.join("auth.json").exists() { + anyhow::bail!("not logged in yet — complete Codex login first"); + } + codex::ensure_cap_sid_exists(&self.env)?; + // Marker stays until restore_add_account_backup / cancel clears the session. + self.save_current() + } + + pub fn cancel_add_account_session(&self) -> Result<()> { + codex::cancel_add_account_session(&self.env) + } + + /// Call after a successful interactive re-login + Save when not in add-account flow. + pub fn clear_interactive_login_if_saved(&self) { + if self.env.codex_root.join("auth.json").exists() { + codex::clear_interactive_login_session(&self.env); + } + } + pub fn save_current(&self) -> Result { let live = codex::read_live_auth_bundle(&self.env).with_context(|| { format!( @@ -72,6 +118,49 @@ where let (metadata, created) = self.repository .save_snapshot(&self.env.kind, &live.identity, &live.snapshot)?; + // OAuth finished for a normal save. Keep the guard during add-account until + // backup restore completes (see save_during_add_account_session / cancel). + if !codex::add_account_session_active(&self.env) { + codex::clear_interactive_login_session(&self.env); + } + Ok(SaveOutput { + account: account_view(metadata.clone(), Some(metadata.id), None, None), + action: if created { + SaveAction::Created + } else { + SaveAction::Refreshed + }, + }) + } + + pub fn save_cursor_current(&self) -> Result { + let live = crate::cursor::read_live_cursor_auth(&self.env) + .context("failed to read live Cursor session — open Cursor, sign in, then try again")?; + let (metadata, created) = self.repository.save_snapshot_with_app( + &self.env.kind, + &live.identity, + &live.snapshot, + Some("cursor".to_owned()), + )?; + Ok(SaveOutput { + account: account_view(metadata.clone(), Some(metadata.id), None, None), + action: if created { + SaveAction::Created + } else { + SaveAction::Refreshed + }, + }) + } + + pub fn save_claude_current(&self) -> Result { + let live = crate::claude::read_live_claude_auth(&self.env) + .context("no live Claude auth bundle found")?; + let (metadata, created) = self.repository.save_snapshot_with_app( + &self.env.kind, + &live.identity, + &live.snapshot, + Some("claude".to_owned()), + )?; Ok(SaveOutput { account: account_view(metadata.clone(), Some(metadata.id), None, None), action: if created { @@ -86,6 +175,21 @@ where self.activate_with_running_policy(account_id, false) } + pub fn start_login_for_saved_account(&self, account_id: Uuid) -> Result { + let metadata = self + .repository + .get_account(&self.env.kind, account_id)? + .ok_or_else(|| anyhow::anyhow!("saved account not found"))?; + // Do not restore then clear — that races Codex Desktop OAuth and can leave + // the browser stuck on auth.openai.com with nothing listening on :1455. + codex::begin_relogin_session(&self.env) + .context("failed to prepare live Codex auth for re-login")?; + Ok(ActivateOutput { + account: account_view(metadata, Some(account_id), None, None), + warnings: Vec::new(), + }) + } + pub fn validate_activation_target(&self, account_id: Uuid) -> Result<()> { let _ = self.load_activation_target(account_id)?; Ok(()) @@ -96,17 +200,62 @@ where account_id: Uuid, force_running: bool, ) -> Result { - let warnings = crate::process::detect_running_codex_processes(); - self.refresh_current_saved_account_before_activation(); + let _switch_guard = ACCOUNT_SWITCH_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| anyhow::anyhow!("account switch lock poisoned"))?; + + let account_metadata = self + .repository + .get_account(&self.env.kind, account_id)? + .ok_or_else(|| anyhow::anyhow!("saved account not found"))?; + let is_cursor = account_metadata.target_app.as_deref() == Some("cursor"); + let is_claude = account_metadata.target_app.as_deref() == Some("claude"); + + let warnings = if is_cursor { + crate::process::detect_switch_blocking_cursor_processes() + } else if is_claude { + crate::process::detect_switch_blocking_claude_processes() + } else { + crate::process::detect_switch_blocking_codex_processes() + }; + + if !is_cursor && !is_claude { + self.refresh_current_saved_account_before_activation(); + } + let (snapshot, snapshot_identity, restore_identity) = self.load_activation_target(account_id)?; let verify_stable = should_verify_activation_stability(force_running, &warnings); - codex::restore_snapshot(&self.env, &snapshot, &restore_identity, verify_stable) - .context("failed to restore the selected account snapshot")?; + + if is_cursor { + crate::cursor::restore_cursor_snapshot(&self.env, &snapshot) + .context("failed to restore the selected Cursor account snapshot")?; + } else if is_claude { + crate::claude::restore_claude_snapshot(&self.env, &snapshot) + .context("failed to restore the selected Claude account snapshot")?; + } else { + codex::restore_snapshot(&self.env, &snapshot, &restore_identity, verify_stable) + .context("failed to restore the selected account snapshot")?; + } + let metadata = self .repository .sync_activated_account(&self.env.kind, account_id, &snapshot_identity) .context("activated live auth but failed to update local metadata")?; + let _ = crate::activity::log_account_activation( + &self.env.app_data_dir, + account_id, + &metadata.email, + metadata.label.as_deref(), + Some(if is_cursor { + "cursor" + } else if is_claude { + "claude" + } else { + "codex" + }), + ); Ok(ActivateOutput { account: account_view(metadata, Some(account_id), None, None), warnings, @@ -135,7 +284,69 @@ where ) -> Result<(SnapshotBlob, DisplayIdentity, DisplayIdentity)> { let (metadata, snapshot) = self.repository.load_snapshot(&self.env.kind, account_id)?; let expected_identity = saved_identity(&metadata); - let snapshot_identity = codex::identity_from_snapshot(&snapshot)?; + + let snapshot_identity = if metadata.target_app.as_deref() == Some("cursor") { + let file = snapshot + .files + .iter() + .find(|f| f.name == "cursor_auth.json") + .context("snapshot missing cursor_auth.json")?; + let json_bytes = + base64::engine::general_purpose::STANDARD.decode(&file.bytes_base64)?; + let serialized_map: std::collections::HashMap = + serde_json::from_slice(&json_bytes)?; + + let email = serialized_map + .get("cursorAuth/cachedEmail") + .and_then(|val| base64::engine::general_purpose::STANDARD.decode(val).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .context("Cursor cached email not found in database")?; + + let cached_profile = serialized_map + .get("cursorAuth/cachedScopedProfile") + .and_then(|val| base64::engine::general_purpose::STANDARD.decode(val).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()); + let name = cached_profile + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|val| { + val.get("displayName") + .and_then(serde_json::Value::as_str) + .map(String::from) + }); + + let plan_label = serialized_map + .get("cursorAuth/stripeMembershipType") + .and_then(|val| base64::engine::general_purpose::STANDARD.decode(val).ok()) + .and_then(|bytes| String::from_utf8(bytes).ok()) + .map(|p| { + if p.eq_ignore_ascii_case("pro") { + "Pro".to_owned() + } else { + p + } + }); + + DisplayIdentity { + email, + subject: None, + name, + plan_label, + workspace_id: None, + workspace_name: None, + } + } else if metadata.target_app.as_deref() == Some("claude") { + DisplayIdentity { + email: metadata.email.clone(), + subject: metadata.subject.clone(), + name: metadata.name.clone(), + plan_label: metadata.plan_label.clone(), + workspace_id: metadata.workspace_id.clone(), + workspace_name: metadata.workspace_name.clone(), + } + } else { + codex::identity_from_snapshot(&snapshot)? + }; + let restore_identity = if expected_identity.subject.is_some() { if !subject_bound_identity_matches(&expected_identity, &snapshot_identity) { anyhow::bail!( @@ -152,87 +363,266 @@ where } pub fn activation_preflight_warnings(&self) -> Vec { - crate::process::detect_running_codex_processes() + crate::process::detect_switch_blocking_codex_processes() + } + + pub fn activation_preflight_warnings_for_account( + &self, + account_id: Uuid, + ) -> Vec { + let target_app = self + .repository + .get_account(&self.env.kind, account_id) + .ok() + .flatten() + .and_then(|meta| meta.target_app); + if target_app.as_deref() == Some("cursor") { + return crate::process::detect_switch_blocking_cursor_processes(); + } else if target_app.as_deref() == Some("claude") { + return crate::process::detect_switch_blocking_claude_processes(); + } + crate::process::detect_switch_blocking_codex_processes() } pub fn refresh_saved_usage_cache(&self) -> Result<()> { + let _ = self.refresh_all_usage()?; + Ok(()) + } + + pub fn refresh_all_usage(&self) -> Result { let accounts = self.repository.list_accounts(&self.env.kind)?; + let mut refreshed = Vec::new(); + let mut failed = Vec::new(); for account in accounts { - let _ = self.usage(Some(account.id)); + match self.usage(Some(account.id)) { + Ok(_) => refreshed.push(account.id), + Err(error) => failed.push(BatchRefreshFailure { + account_id: account.id, + email: account.email.clone(), + error: format!("{error:#}"), + }), + } } - Ok(()) + Ok(BatchRefreshOutput { + total: refreshed.len() + failed.len(), + refreshed, + failed, + }) } pub fn usage(&self, account_id: Option) -> Result { match account_id { Some(account_id) => { - let (snapshot, _, _) = self.load_activation_target(account_id)?; - let target = usage_target_from_snapshot( - self.env.kind.clone(), - snapshot, - UsageSource::SavedAccessToken, - true, - )?; - let (output, refreshed_snapshot) = match fetch_usage(target) { - Ok(result) => result, - Err(error) => { - let _ = self.repository.record_usage_error( - &self.env.kind, - account_id, - usage_error_message(&error), - ); - return Err(error); - } - }; - self.repository.replace_snapshot( + let metadata = self + .repository + .get_account(&self.env.kind, account_id)? + .ok_or_else(|| anyhow::anyhow!("saved account not found"))?; + let app = metadata.target_app.as_deref().unwrap_or("codex"); + match app { + "cursor" => self.usage_cursor_saved(account_id, &metadata), + "claude" => self.usage_claude_saved(account_id, &metadata), + _ => self.usage_codex_saved(account_id), + } + } + None => self.usage_live(true), + } + } + + fn usage_codex_saved(&self, account_id: Uuid) -> Result { + let (snapshot, _, _) = self.load_activation_target(account_id)?; + let original_snapshot = snapshot.clone(); + let target = usage_target_from_snapshot( + self.env.kind.clone(), + snapshot, + UsageSource::SavedAccessToken, + true, + )?; + let (output, refreshed_snapshot) = match fetch_usage(target) { + Ok(result) => result, + Err(error) => { + let _ = self.repository.record_usage_error( &self.env.kind, account_id, - &output.account, - &refreshed_snapshot, - Some(output.usage.clone()), - )?; - Ok(output) + usage_error_message(&error), + ); + return Err(error); } - None => { - let live = codex::read_live_auth_bundle(&self.env).with_context(|| { - format!( - "no live Codex auth bundle found at {}", - self.env.codex_root.display() - ) - })?; - let live_identity = live.identity.clone(); - let live_snapshot = live.snapshot.clone(); - let target = usage_target_from_snapshot( - self.env.kind.clone(), - live.snapshot, - UsageSource::LiveAccessToken, - true, - )?; - let (output, refreshed_snapshot) = match fetch_usage(target) { - Ok(result) => result, - Err(error) => { - self.record_usage_error_for_identity(&live_identity, &error); - return Err(error); - } - }; - if refreshed_snapshot != live_snapshot - && live_bundle_still_matches_snapshot(&self.env, &live_snapshot) + }; + if refreshed_snapshot != original_snapshot { + self.restore_refreshed_live_auth_if_still_current( + &original_snapshot, + &refreshed_snapshot, + &output.account, + ) + .context("refreshed saved auth but failed to update matching live auth files")?; + } + self.repository.replace_snapshot( + &self.env.kind, + account_id, + &output.account, + &refreshed_snapshot, + Some(output.usage.clone()), + )?; + Ok(output) + } + + fn usage_cursor_saved( + &self, + account_id: Uuid, + metadata: &crate::model::SavedAccountMetadata, + ) -> Result { + let (_, snapshot) = self.repository.load_snapshot(&self.env.kind, account_id)?; + let token = crate::cursor_usage::access_token_from_snapshot(&snapshot).map_err(|e| { + let _ = self.repository.record_usage_error( + &self.env.kind, + account_id, + usage_error_message(&e), + ); + e + })?; + let usage = crate::cursor_usage::fetch_cursor_usage(&token).map_err(|e| { + let _ = self.repository.record_usage_error( + &self.env.kind, + account_id, + usage_error_message(&e), + ); + e + })?; + let identity = crate::model::DisplayIdentity { + email: metadata.email.clone(), + subject: metadata.subject.clone(), + name: metadata.name.clone(), + plan_label: metadata.plan_label.clone(), + workspace_id: metadata.workspace_id.clone(), + workspace_name: metadata.workspace_name.clone(), + }; + self.repository.replace_snapshot( + &self.env.kind, + account_id, + &identity, + &snapshot, + Some(usage.clone()), + )?; + Ok(UsageOutput { + environment: self.env.kind.clone(), + account: identity, + usage, + }) + } + + fn usage_claude_saved( + &self, + account_id: Uuid, + metadata: &crate::model::SavedAccountMetadata, + ) -> Result { + let (_, snapshot) = self.repository.load_snapshot(&self.env.kind, account_id)?; + let token = crate::claude_usage::access_token_from_snapshot(&snapshot).map_err(|e| { + let _ = self.repository.record_usage_error( + &self.env.kind, + account_id, + usage_error_message(&e), + ); + e + })?; + let usage = crate::claude_usage::fetch_claude_usage(&token).map_err(|e| { + let _ = self.repository.record_usage_error( + &self.env.kind, + account_id, + usage_error_message(&e), + ); + e + })?; + let identity = crate::model::DisplayIdentity { + email: metadata.email.clone(), + subject: metadata.subject.clone(), + name: metadata.name.clone(), + plan_label: metadata.plan_label.clone(), + workspace_id: metadata.workspace_id.clone(), + workspace_name: metadata.workspace_name.clone(), + }; + self.repository.replace_snapshot( + &self.env.kind, + account_id, + &identity, + &snapshot, + Some(usage.clone()), + )?; + Ok(UsageOutput { + environment: self.env.kind.clone(), + account: identity, + usage, + }) + } + + fn usage_live(&self, retry_if_live_changed: bool) -> Result { + let live = codex::read_live_auth_bundle(&self.env).with_context(|| { + format!( + "no live Codex auth bundle found at {}", + self.env.codex_root.display() + ) + })?; + let live_identity = live.identity.clone(); + let live_snapshot = live.snapshot.clone(); + let target = usage_target_from_snapshot( + self.env.kind.clone(), + live.snapshot, + UsageSource::LiveAccessToken, + true, + )?; + let (output, refreshed_snapshot) = match fetch_usage(target) { + Ok(result) => result, + Err(error) => { + if retry_if_live_changed + && usage_error_requires_login(&format!("{error:#}")) + && self.live_snapshot_changed_since(&live_snapshot) { - codex::restore_snapshot(&self.env, &refreshed_snapshot, &output.account, false) - .context("refreshed live auth but failed to update local auth files")?; + return self.usage_live(false); } - if let Some(account_id) = self.saved_account_id_for_identity(&live_identity) { - self.repository.replace_snapshot( - &self.env.kind, - account_id, - &output.account, - &refreshed_snapshot, - Some(output.usage.clone()), - )?; - } - Ok(output) + self.record_usage_error_for_identity(&live_identity, &error); + return Err(error); } + }; + if refreshed_snapshot != live_snapshot { + self.restore_refreshed_live_auth_if_still_current( + &live_snapshot, + &refreshed_snapshot, + &output.account, + ) + .context("refreshed live auth but failed to update local auth files")?; } + if let Some(account_id) = self.saved_account_id_for_identity(&live_identity) { + self.repository.replace_snapshot( + &self.env.kind, + account_id, + &output.account, + &refreshed_snapshot, + Some(output.usage.clone()), + )?; + } + Ok(output) + } + + fn restore_refreshed_live_auth_if_still_current( + &self, + previous_snapshot: &SnapshotBlob, + refreshed_snapshot: &SnapshotBlob, + identity: &DisplayIdentity, + ) -> Result<()> { + let lock = codex::acquire_auth_write_lock(&self.env)?; + if codex::live_bundle_matches_snapshot(&self.env, previous_snapshot)? { + codex::restore_snapshot_with_lock( + &lock, + &self.env, + refreshed_snapshot, + identity, + false, + )?; + } + Ok(()) + } + + fn live_snapshot_changed_since(&self, snapshot: &SnapshotBlob) -> bool { + !live_bundle_still_matches_snapshot(&self.env, snapshot) } fn saved_account_id_for_identity(&self, identity: &DisplayIdentity) -> Option { @@ -263,6 +653,344 @@ where deleted_account_id: account_id, }) } + + pub fn find_account_by_id_or_email( + &self, + query: &str, + ) -> Result { + let accounts = self.repository.list_accounts(&self.env.kind)?; + if let Ok(id) = Uuid::parse_str(query) + && let Some(account) = accounts.iter().find(|a| a.id == id) + { + return Ok(account.clone()); + } + let query_lower = query.to_ascii_lowercase(); + let matched = accounts.into_iter().find(|account| { + account.email.to_ascii_lowercase() == query_lower + || account + .label + .as_ref() + .is_some_and(|label| label.to_ascii_lowercase() == query_lower) + }); + matched.ok_or_else(|| anyhow::anyhow!("no saved account found matching '{}'", query)) + } + + pub fn pick_best_account(&self, refresh: bool, activate: bool) -> Result { + if refresh { + self.refresh_saved_usage_cache()?; + } + let accounts = self.repository.list_accounts(&self.env.kind)?; + let status = self.status()?; + let active_id = status.current_account_saved_id; + let now = OffsetDateTime::now_utc(); + let scores = accounts + .iter() + .map(|account| { + crate::quota_scoring::score_saved_account( + account.id, + &account.email, + account.label.as_deref(), + account.cached_usage.as_ref(), + account.cached_usage_error.as_deref(), + now, + ) + }) + .collect::>(); + let score_views = scores + .iter() + .map(|entry| PickBestScoreView { + account_id: entry.account_id, + email: entry.email.clone(), + label: entry.label.clone(), + score: entry.eligible.then_some(entry.score), + eligible: entry.eligible, + weekly_used_percent: entry.weekly_used_percent, + five_hour_used_percent: entry.five_hour_used_percent, + detail: entry.detail.clone(), + }) + .collect::>(); + let Some(best_id) = crate::quota_scoring::pick_best_account_id(&scores) else { + anyhow::bail!("no eligible saved account with usable quota"); + }; + if active_id == Some(best_id) || !activate { + let metadata = self + .repository + .get_account(&self.env.kind, best_id)? + .context("best account metadata missing")?; + return Ok(PickBestOutput { + switched: false, + account: account_view(metadata, active_id, None, None), + scores: score_views, + }); + } + let output = self.activate_with_running_policy(best_id, true)?; + let best_score = scores + .iter() + .find(|entry| entry.account_id == best_id) + .map(|entry| entry.score) + .unwrap_or(0.0); + let _ = crate::activity::log_pick_best( + &self.env.app_data_dir, + best_id, + &output.account.email, + output.account.label.as_deref(), + best_score, + ); + Ok(PickBestOutput { + switched: true, + account: output.account, + scores: score_views, + }) + } + + pub fn login_and_save(&self) -> Result { + let status = std::process::Command::new(crate::process::codex_cli_path()) + .arg("login") + .status() + .context("failed to run `codex login`; make sure Codex is installed")?; + if !status.success() { + anyhow::bail!("`codex login` failed with {status}"); + } + self.save_current() + } + + pub fn export_accounts(&self, account_ids: Option>) -> Result { + crate::import_export::export_accounts( + &self.repository, + &self.env.kind, + account_ids.as_deref(), + ) + } + + pub fn import_auth_path( + &self, + auth_path: &std::path::Path, + label: Option, + ) -> Result { + crate::import_export::import_auth_file(&self.repository, &self.env.kind, auth_path, label) + } + + pub fn import_bundle(&self, bundle: &ExportBundle) -> Result> { + crate::import_export::import_export_bundle(&self.repository, &self.env.kind, bundle) + } + + pub fn rename_account(&self, account_id: Uuid, label: Option) -> Result { + let metadata = self + .repository + .set_account_label(&self.env.kind, account_id, label)?; + let active_id = self + .status()? + .current_account_saved_id + .filter(|id| *id == account_id); + Ok(RenameOutput { + account: account_view(metadata, active_id, None, None), + }) + } + + pub fn set_account_archived(&self, account_id: Uuid, archived: bool) -> Result<()> { + let _ = self + .repository + .set_account_archived(&self.env.kind, account_id, archived)?; + Ok(()) + } + + pub fn exec_with_temporary_account( + &self, + account_id: Uuid, + command: &[String], + ) -> Result { + if command.is_empty() { + anyhow::bail!("no command specified to execute"); + } + let auth_lock = codex::acquire_auth_write_lock(&self.env)?; + let original_bundle = codex::try_read_live_auth_bundle(&self.env)?; + let _guard = ActiveSnapshotGuard { + env: &self.env, + original_bundle, + active_now: true, + auth_lock, + }; + let (snapshot, _snapshot_identity, restore_identity) = + self.load_activation_target(account_id)?; + codex::restore_snapshot_with_lock( + &_guard.auth_lock, + &self.env, + &snapshot, + &restore_identity, + false, + ) + .context("failed to temporarily restore target account snapshot")?; + + let mut child = std::process::Command::new(&command[0]) + .args(&command[1..]) + .spawn() + .with_context(|| format!("failed to start command '{}'", command[0]))?; + let status = child.wait().context("failed to wait for child process")?; + Ok(status) + } + + pub fn cursor_status(&self) -> Result { + self.cursor_status_with_processes(true) + } + + pub fn cursor_status_with_processes(&self, include_processes: bool) -> Result { + let saved_accounts = self + .repository + .list_accounts(&self.env.kind)? + .into_iter() + .filter(|acc| acc.target_app.as_deref() == Some("cursor")) + .collect::>(); + // Identity-only read keeps status/popover polls cheap and avoids RW locks + // on Cursor's large state DB while Cursor itself is running. + let live = crate::cursor::try_read_live_cursor_identity(&self.env).unwrap_or(None); + let current_saved_id = live.as_ref().and_then(|identity| { + match_saved_account_with_app(&saved_accounts, identity, Some("cursor")) + .map(|account| account.id) + }); + Ok(StatusOutput { + environment: self.env.kind.clone(), + codex_root: crate::cursor::cursor_db_path(&self.env)? + .display() + .to_string(), + current_account: live, + current_account_saved_id: current_saved_id, + saved_accounts: saved_accounts.len(), + process_warnings: if include_processes { + crate::process::detect_running_cursor_processes() + } else { + Vec::new() + }, + }) + } + + pub fn claude_status(&self) -> Result { + self.claude_status_with_processes(true) + } + + pub fn claude_status_with_processes(&self, include_processes: bool) -> Result { + let saved_accounts = self + .repository + .list_accounts(&self.env.kind)? + .into_iter() + .filter(|acc| acc.target_app.as_deref() == Some("claude")) + .collect::>(); + let live = crate::claude::try_read_live_claude_identity(&self.env).unwrap_or(None); + let current_saved_id = live.as_ref().and_then(|identity| { + match_saved_account_with_app(&saved_accounts, identity, Some("claude")) + .map(|account| account.id) + }); + Ok(StatusOutput { + environment: self.env.kind.clone(), + codex_root: crate::claude::claude_dir(&self.env).display().to_string(), + current_account: live, + current_account_saved_id: current_saved_id, + saved_accounts: saved_accounts.len(), + process_warnings: if include_processes { + crate::process::detect_running_claude_processes() + } else { + Vec::new() + }, + }) + } + + pub fn import_cookies_json( + &self, + provider: &str, + json_text: &str, + label: Option, + ) -> Result { + let mut provider = provider.trim().to_ascii_lowercase(); + if provider.is_empty() || provider == "auto" { + provider = crate::cookie_import::detect_provider_from_json(json_text).to_owned(); + } + let (imported, target_app) = match provider.as_str() { + "codex" | "chatgpt" | "openai" => { + let imported = crate::cookie_import::import_codex_from_cookies_json(json_text)?; + crate::codex::validate_import_snapshot(&imported.snapshot)?; + (imported, None) + } + "cursor" => { + let imported = crate::cookie_import::import_cursor_from_cookies_json(json_text)?; + (imported, Some("cursor".to_owned())) + } + other => anyhow::bail!(crate::cookie_import::unsupported_provider_message(other)), + }; + + let (metadata, created) = if let Some(app) = target_app { + self.repository.save_snapshot_with_app( + &self.env.kind, + &imported.identity, + &imported.snapshot, + Some(app), + )? + } else { + self.repository.save_snapshot( + &self.env.kind, + &imported.identity, + &imported.snapshot, + )? + }; + let metadata = if label.is_some() { + self.repository + .set_account_label(&self.env.kind, metadata.id, label)? + } else { + metadata + }; + Ok(ImportOutput { + account_id: metadata.id, + email: metadata.email, + label: metadata.label, + created, + warnings: imported.warnings, + }) + } + + pub fn is_cursor_account(&self, account_id: Uuid) -> bool { + self.repository + .get_account(&self.env.kind, account_id) + .ok() + .flatten() + .and_then(|acc| acc.target_app) + .as_deref() + == Some("cursor") + } + + pub fn is_claude_account(&self, account_id: Uuid) -> bool { + self.repository + .get_account(&self.env.kind, account_id) + .ok() + .flatten() + .and_then(|acc| acc.target_app) + .as_deref() + == Some("claude") + } +} + +struct ActiveSnapshotGuard<'a> { + env: &'a AppEnv, + original_bundle: Option, + active_now: bool, + auth_lock: crate::codex::AuthWriteLock, +} + +impl<'a> Drop for ActiveSnapshotGuard<'a> { + fn drop(&mut self) { + if self.active_now { + if let Some(original) = &self.original_bundle { + let _ = crate::codex::restore_snapshot_with_lock( + &self.auth_lock, + self.env, + &original.snapshot, + &original.identity, + false, + ); + } else { + for file_name in crate::model::AUTH_FILES { + let _ = std::fs::remove_file(self.env.codex_root.join(file_name)); + } + } + } + } } fn live_bundle_still_matches_snapshot(env: &AppEnv, snapshot: &SnapshotBlob) -> bool { @@ -292,6 +1020,15 @@ mod tests { }; use crate::secrets::test_support::MemorySecretStore; + fn fixture_id_token(email: &str, subject: &str, plan: Option<&str>) -> String { + let auth: serde_json::Value = + serde_json::from_str(&auth_json_fixture(email, subject, plan)).expect("auth fixture"); + auth["tokens"]["id_token"] + .as_str() + .expect("id token") + .to_owned() + } + #[test] fn list_marks_active_account() { let temp = tempdir().expect("tempdir"); @@ -316,6 +1053,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -353,6 +1092,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -385,6 +1126,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -401,6 +1144,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -451,6 +1196,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let snapshot = SnapshotBlob { schema_version: 1, @@ -495,6 +1242,115 @@ mod tests { ); } + #[test] + fn saved_usage_refresh_updates_matching_live_auth() { + let temp = tempdir().expect("tempdir"); + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: temp.path().join(".codex"), + app_data_dir: temp.path().join("app"), + }; + std::fs::create_dir_all(&env.codex_root).expect("codex root"); + std::fs::write( + env.codex_root.join("auth.json"), + auth_json_fixture("active@example.com", "sub-1", Some("pro")), + ) + .expect("auth"); + std::fs::write(env.codex_root.join("cap_sid"), "sid-active").expect("cap"); + let repo = SnapshotRepository::new(&env.app_data_dir, MemorySecretStore::default()); + let app = App::new(env.clone(), repo); + let saved = app.save_current().expect("save current"); + + let (_, original_snapshot) = app + .repository + .load_snapshot(&env.kind, saved.account.id) + .expect("original snapshot"); + let refreshed_snapshot = refreshed_auth_snapshot( + &original_snapshot, + "access-new", + "refresh-new", + "active@example.com", + "sub-1", + Some("pro"), + ); + let refreshed_identity = DisplayIdentity { + email: "active@example.com".to_owned(), + subject: Some("sub-1".to_owned()), + name: Some("Tester".to_owned()), + plan_label: Some("Pro".to_owned()), + workspace_id: Some("acct".to_owned()), + workspace_name: None, + }; + + app.restore_refreshed_live_auth_if_still_current( + &original_snapshot, + &refreshed_snapshot, + &refreshed_identity, + ) + .expect("restore refreshed live auth"); + app.repository + .replace_snapshot( + &env.kind, + saved.account.id, + &refreshed_identity, + &refreshed_snapshot, + None, + ) + .expect("replace saved snapshot"); + + let live_auth = + std::fs::read_to_string(env.codex_root.join("auth.json")).expect("live auth"); + assert!(live_auth.contains("access-new")); + assert!(live_auth.contains("refresh-new")); + + let (_, saved_snapshot) = app + .repository + .load_snapshot(&env.kind, saved.account.id) + .expect("saved snapshot"); + let saved_auth = saved_snapshot + .files + .iter() + .find(|file| file.name == "auth.json") + .expect("saved auth"); + let saved_auth_json = String::from_utf8( + base64::engine::general_purpose::STANDARD + .decode(&saved_auth.bytes_base64) + .expect("decode saved auth"), + ) + .expect("saved auth utf8"); + assert!(saved_auth_json.contains("access-new")); + assert!(saved_auth_json.contains("refresh-new")); + } + + fn refreshed_auth_snapshot( + snapshot: &SnapshotBlob, + access_token: &str, + refresh_token: &str, + email: &str, + subject: &str, + plan: Option<&str>, + ) -> SnapshotBlob { + let mut refreshed = snapshot.clone(); + let auth_index = refreshed + .files + .iter() + .position(|file| file.name == "auth.json") + .expect("auth file"); + let auth_json = base64::engine::general_purpose::STANDARD + .decode(&refreshed.files[auth_index].bytes_base64) + .expect("decode auth"); + let mut auth: serde_json::Value = + serde_json::from_slice(&auth_json).expect("parse auth json"); + auth["tokens"]["access_token"] = serde_json::Value::String(access_token.to_owned()); + auth["tokens"]["refresh_token"] = serde_json::Value::String(refresh_token.to_owned()); + auth["tokens"]["id_token"] = + serde_json::Value::String(fixture_id_token(email, subject, plan)); + refreshed.files[auth_index].bytes_base64 = base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&auth).expect("encode auth json")); + refreshed + } + #[test] fn subject_bound_identity_requires_matching_subject() { let expected = DisplayIdentity { @@ -502,24 +1358,32 @@ mod tests { subject: Some("sub-1".to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let missing_subject = DisplayIdentity { email: "person@example.com".to_owned(), subject: None, name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let wrong_subject = DisplayIdentity { email: "person@example.com".to_owned(), subject: Some("sub-2".to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let matching_subject = DisplayIdentity { email: "other@example.com".to_owned(), subject: Some("sub-1".to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; assert!(!subject_bound_identity_matches(&expected, &missing_subject)); assert!(!subject_bound_identity_matches(&expected, &wrong_subject)); @@ -552,6 +1416,8 @@ mod tests { subject: Some("sub-1".to_owned()), name: Some("Before".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -607,6 +1473,8 @@ mod tests { subject: Some("sub-expected".to_owned()), name: Some("Expected".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -660,6 +1528,8 @@ mod tests { subject: None, name: Some("Old".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &SnapshotBlob { schema_version: 1, @@ -685,4 +1555,194 @@ mod tests { assert_eq!(output.account.email, "new@example.com"); assert_eq!(output.account.subject.as_deref(), Some("sub-new")); } + + #[test] + fn find_account_by_id_or_email_works() { + let temp = tempdir().expect("tempdir"); + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: temp.path().join(".codex"), + app_data_dir: temp.path().join("app"), + }; + let repo = SnapshotRepository::new(&env.app_data_dir, MemorySecretStore::default()); + let saved = repo + .save_snapshot( + &env.kind, + &DisplayIdentity { + email: "test@example.com".to_owned(), + subject: Some("sub-test".to_owned()), + name: Some("Test".to_owned()), + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &SnapshotBlob { + schema_version: 1, + files: vec![], + }, + ) + .expect("save") + .0; + + let app = App::new(env, repo); + + // Find by exact email + let found = app + .find_account_by_id_or_email("test@example.com") + .expect("found"); + assert_eq!(found.id, saved.id); + + // Find by case-insensitive email + let found = app + .find_account_by_id_or_email("TEST@EXAMPLE.COM") + .expect("found"); + assert_eq!(found.id, saved.id); + + // Find by UUID + let found = app + .find_account_by_id_or_email(&saved.id.to_string()) + .expect("found"); + assert_eq!(found.id, saved.id); + + // Not found + assert!( + app.find_account_by_id_or_email("other@example.com") + .is_err() + ); + } + + #[test] + fn exec_with_temporary_account_restores_original_bundle() { + let temp = tempdir().expect("tempdir"); + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: temp.path().join(".codex"), + app_data_dir: temp.path().join("app"), + }; + std::fs::create_dir_all(&env.codex_root).expect("codex root"); + std::fs::write( + env.codex_root.join("auth.json"), + auth_json_fixture("original@example.com", "sub-orig", Some("pro")), + ) + .expect("auth"); + std::fs::write(env.codex_root.join("cap_sid"), "sid-orig").expect("cap"); + + let repo = SnapshotRepository::new(&env.app_data_dir, MemorySecretStore::default()); + let saved = repo + .save_snapshot( + &env.kind, + &DisplayIdentity { + email: "temp@example.com".to_owned(), + subject: Some("sub-temp".to_owned()), + name: Some("Temp".to_owned()), + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &SnapshotBlob { + schema_version: 1, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode( + auth_json_fixture("temp@example.com", "sub-temp", Some("plus")), + ), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD + .encode("sid-temp"), + }, + ], + }, + ) + .expect("save") + .0; + + let app = App::new(env.clone(), repo); + + // Run a simple command (e.g. echo "hello") + #[cfg(windows)] + let command = vec!["cmd".to_owned(), "/c".to_owned(), "echo hello".to_owned()]; + #[cfg(not(windows))] + let command = vec!["echo".to_owned(), "hello".to_owned()]; + + let status = app + .exec_with_temporary_account(saved.id, &command) + .expect("exec"); + assert!(status.success()); + + // Verify the original account is restored + let live = crate::codex::read_live_auth_bundle(&env).expect("live bundle"); + assert_eq!(live.identity.email, "original@example.com"); + } + + #[test] + fn start_login_for_saved_account_clears_live_auth_and_marks_login_session() { + let temp = tempdir().expect("tempdir"); + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: temp.path().join(".codex"), + app_data_dir: temp.path().join("app"), + }; + + // Seed live auth so clear has something to remove. + std::fs::create_dir_all(&env.codex_root).expect("codex root"); + std::fs::write( + env.codex_root.join("auth.json"), + auth_json_fixture("expired@example.com", "sub-expired", Some("pro")), + ) + .expect("auth"); + std::fs::write(env.codex_root.join("cap_sid"), "sid-expired").expect("sid"); + + let repo = SnapshotRepository::new(&env.app_data_dir, MemorySecretStore::default()); + let saved = repo + .save_snapshot( + &env.kind, + &DisplayIdentity { + email: "expired@example.com".to_owned(), + subject: Some("sub-expired".to_owned()), + name: Some("Expired".to_owned()), + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + }, + &SnapshotBlob { + schema_version: 1, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode( + auth_json_fixture( + "expired@example.com", + "sub-expired", + Some("pro"), + ), + ), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD + .encode("sid-expired"), + }, + ], + }, + ) + .expect("save") + .0; + + let app = App::new(env.clone(), repo); + + let output = app + .start_login_for_saved_account(saved.id) + .expect("start login"); + + assert_eq!(output.account.email, "expired@example.com"); + assert!(!env.codex_root.join("auth.json").exists()); + assert!(!env.codex_root.join("cap_sid").exists()); + assert!(crate::codex::interactive_login_in_progress(&env)); + } } diff --git a/src/app/tui.rs b/src/app/tui.rs deleted file mode 100644 index 8b874b7..0000000 --- a/src/app/tui.rs +++ /dev/null @@ -1,1233 +0,0 @@ -use anyhow::{Context, Error, Result}; -use console::{Key, Term, style}; -use dialoguer::{Select, theme::ColorfulTheme}; -use time::OffsetDateTime; -use uuid::Uuid; - -use crate::model::{ - AccountView, AutoStartUsageWindowsRunOutput, ListOutput, RunningCodexProcess, SaveAction, - StatusOutput, -}; -use crate::process::format_process_table; -use crate::secrets::SecretStore; -use crate::time_display::format_local_reset_at; -use crate::usage::{usage_error_label, usage_error_requires_login}; - -use super::{App, InteractiveExit, InteractiveMode, account_view_matches_identity}; - -impl App -where - S: SecretStore, -{ - pub fn interactive( - &self, - mode: InteractiveMode, - force_running: bool, - ) -> Result { - let mut default_selection = 0usize; - if matches!(mode, InteractiveMode::Persistent) { - self.refresh_saved_usage_cache()?; - } - let mut feedback = Vec::new(); - loop { - let status = self.status()?; - let list = self.list()?; - let current_saved = status.current_account.as_ref().and_then(|identity| { - list.accounts - .iter() - .find(|account| account_view_matches_identity(account, identity)) - .map(|account| account.id) - }); - let auto_start_usage_windows_enabled = matches!(mode, InteractiveMode::Persistent) - && self.auto_start_usage_windows_status()?.enabled; - - let menu = build_menu( - mode, - &status, - &list, - current_saved, - auto_start_usage_windows_enabled, - ); - let selection = match mode { - InteractiveMode::Persistent => { - select_persistent_entry(&menu, default_selection, &feedback)? - } - InteractiveMode::ActivateOnce | InteractiveMode::DeleteOnce => { - let labels = menu.labels(); - let theme = ColorfulTheme::default(); - let mut select = Select::with_theme(&theme); - if !menu.prompt.is_empty() { - select = select.with_prompt(menu.prompt); - } - select - .items(&labels) - .default(default_selection.min(menu.len().saturating_sub(1))) - .interact()? - } - }; - default_selection = selection; - feedback.clear(); - - match menu.action(selection) { - InteractiveAction::SaveCurrent => { - let output = match self.save_current() { - Ok(output) => output, - Err(error) => { - if matches!(mode, InteractiveMode::Persistent) { - feedback = - error_feedback("Saving the current account failed.", error); - continue; - } - return Err(error); - } - }; - feedback.push(format!( - "{} {} ({})", - match output.action { - SaveAction::Created => "Saved", - SaveAction::Refreshed => "Refreshed", - }, - output.account.email, - output.account.id - )); - } - InteractiveAction::Activate(account_id) => { - let warnings = self.activation_preflight_warnings(); - let showed_preflight = !warnings.is_empty(); - if showed_preflight && !confirm_activation(&warnings)? { - continue; - } - let output = match self - .activate_with_running_policy(account_id, force_running || showed_preflight) - { - Ok(output) => output, - Err(error) => { - if matches!(mode, InteractiveMode::Persistent) { - let rendered_error = format!("{error:#}"); - feedback = error_feedback_rendered( - "Account activation failed.", - &rendered_error, - ); - if showed_preflight - && error_indicates_running_process_instability(&rendered_error) - { - feedback.push( - "Codex was still running during activation. Close those processes fully and retry." - .to_owned(), - ); - } - continue; - } - return Err(error); - } - }; - feedback.push(format!( - "Activated {} ({})", - output.account.email, output.account.id - )); - if showed_preflight { - feedback.push( - "Codex was still running during activation. If the account does not change in Codex, close those processes fully and retry." - .to_owned(), - ); - } - if !showed_preflight && !output.warnings.is_empty() { - feedback.extend(process_summary_lines("Codex processes", &output.warnings)); - } - if matches!(mode, InteractiveMode::ActivateOnce) { - break; - } - } - InteractiveAction::Delete(account_id) => { - let account = list - .accounts - .iter() - .find(|account| account.id == account_id) - .context("selected account no longer exists")?; - if !confirm_delete(account)? { - continue; - } - let output = match self.delete(account_id) { - Ok(output) => output, - Err(error) => { - if matches!(mode, InteractiveMode::Persistent) { - feedback = - error_feedback("Deleting the saved account failed.", error); - continue; - } - return Err(error); - } - }; - feedback.push(format!( - "Deleted saved snapshot {}", - output.deleted_account_id - )); - if matches!(mode, InteractiveMode::DeleteOnce) { - break; - } - } - InteractiveAction::DeletePrompt => { - let account_id = prompt_for_account_delete(&list.accounts)?; - let account = list - .accounts - .iter() - .find(|account| account.id == account_id) - .context("selected account no longer exists")?; - if !confirm_delete(account)? { - continue; - } - let output = match self.delete(account_id) { - Ok(output) => output, - Err(error) => { - if matches!(mode, InteractiveMode::Persistent) { - feedback = - error_feedback("Deleting the saved account failed.", error); - continue; - } - return Err(error); - } - }; - feedback.push(format!( - "Deleted saved snapshot {}", - output.deleted_account_id - )); - } - InteractiveAction::ShowStatus => { - feedback = interactive_status_lines(&status); - } - InteractiveAction::SetAutoStartUsageWindows(enabled) => { - match self.set_auto_start_usage_windows(enabled) { - Ok(output) => feedback.push(format!( - "Auto-start usage windows {}.", - if output.enabled { - "enabled" - } else { - "disabled" - } - )), - Err(error) => { - feedback = - error_feedback("Updating auto-start usage windows failed.", error); - continue; - } - } - if enabled { - match self.auto_start_usage_windows_once(true) { - Ok(output) => { - feedback.extend(auto_start_usage_window_feedback(&output)) - } - Err(error) => { - feedback = - error_feedback("Auto-start usage window check failed.", error); - } - } - } - } - #[cfg(windows)] - InteractiveAction::SendToTray => return Ok(InteractiveExit::SendToTray), - InteractiveAction::Quit => break, - } - } - Ok(InteractiveExit::Quit) - } -} - -#[derive(Clone, Copy)] -pub(crate) enum InteractiveAction { - SaveCurrent, - Activate(Uuid), - Delete(Uuid), - DeletePrompt, - ShowStatus, - SetAutoStartUsageWindows(bool), - #[cfg(windows)] - SendToTray, - Quit, -} - -pub(crate) struct InteractiveItem { - pub(crate) label: String, - pub(crate) action: InteractiveAction, -} - -pub(crate) struct InteractiveMenu { - pub(crate) prompt: &'static str, - pub(crate) current_status_label: Option, - pub(crate) accounts: Vec, - pub(crate) actions: Vec, -} - -struct PersistentRenderState { - total_lines: usize, - row_lines: Vec, -} - -#[derive(Clone, Copy, Default)] -struct AccountLabelWidths { - email: usize, - plan: usize, - remaining: usize, - reset: usize, -} - -impl InteractiveMenu { - pub(crate) fn len(&self) -> usize { - self.accounts.len() + self.actions.len() - } - - fn labels(&self) -> Vec<&str> { - self.accounts - .iter() - .chain(self.actions.iter()) - .map(|item| item.label.as_str()) - .collect() - } - - pub(crate) fn action(&self, index: usize) -> InteractiveAction { - if index < self.accounts.len() { - self.accounts[index].action - } else { - self.actions[index - self.accounts.len()].action - } - } - - fn label(&self, index: usize) -> &str { - if index < self.accounts.len() { - &self.accounts[index].label - } else { - &self.actions[index - self.accounts.len()].label - } - } - - fn first_action_index(&self) -> Option { - (!self.actions.is_empty()).then_some(self.accounts.len()) - } -} - -fn render_account_label(account: &AccountView, widths: AccountLabelWidths) -> String { - let email = format!("{:>() - .join(" ") -} - -fn account_label_widths(accounts: &[&AccountView]) -> AccountLabelWidths { - let mut widths = AccountLabelWidths::default(); - for account in accounts { - widths.email = widths.email.max(account.email.len()); - widths.plan = widths.plan.max( - account - .plan_label - .as_ref() - .map(|plan| format!("Plan: {plan}").len()) - .unwrap_or(0), - ); - let (remaining, reset) = if account - .usage_error - .as_deref() - .is_some_and(usage_error_requires_login) - { - ( - usage_error_label(account.usage_error.as_deref().unwrap_or_default()).len(), - 0, - ) - } else if let Some(usage) = &account.usage - && let Some(weekly) = &usage.weekly - { - if weekly.reset_at <= OffsetDateTime::now_utc() { - ("Weekly Remaining: passed".len(), 0) - } else { - ( - format!("Weekly Remaining: {}%", weekly.remaining_percent).len(), - format!("Reset: {}", format_local_reset_at(weekly.reset_at)).len(), - ) - } - } else if let Some(error) = &account.usage_error { - (usage_error_label(error).len(), 0) - } else { - (0, 0) - }; - widths.remaining = widths.remaining.max(remaining); - widths.reset = widths.reset.max(reset); - } - widths -} - -pub(crate) fn build_menu( - mode: InteractiveMode, - status: &StatusOutput, - list: &ListOutput, - current_saved: Option, - auto_start_usage_windows_enabled: bool, -) -> InteractiveMenu { - let active_account = current_saved - .and_then(|saved_id| list.accounts.iter().find(|account| account.id == saved_id)); - let mut label_accounts = list.accounts.iter().collect::>(); - if let Some(active_account) = active_account - && !label_accounts - .iter() - .any(|account| account.id == active_account.id) - { - label_accounts.push(active_account); - } - let widths = account_label_widths(&label_accounts); - - let mut accounts = Vec::new(); - for account in list.accounts.iter().filter(|account| { - !matches!( - mode, - InteractiveMode::Persistent | InteractiveMode::ActivateOnce - ) || !account.is_active - }) { - accounts.push(InteractiveItem { - label: render_account_label(account, widths), - action: match mode { - InteractiveMode::Persistent | InteractiveMode::ActivateOnce => { - InteractiveAction::Activate(account.id) - } - InteractiveMode::DeleteOnce => InteractiveAction::Delete(account.id), - }, - }); - } - - let mut actions = Vec::new(); - let current_status_label = active_account - .map(|account| render_account_label(account, widths)) - .or_else(|| { - status.current_account.as_ref().map(|current| { - format!( - "{}{}", - current.email, - if current_saved.is_some() { - " [saved]" - } else { - " [not saved]" - } - ) - }) - }); - - if matches!(mode, InteractiveMode::Persistent) { - if let Some(current) = status.current_account.as_ref() { - actions.push(InteractiveItem { - label: if current_saved.is_some() { - format!("Refresh saved snapshot for {}", current.email) - } else { - format!("Add current account {} to switcher", current.email) - }, - action: InteractiveAction::SaveCurrent, - }); - } - if !list.accounts.is_empty() { - actions.push(InteractiveItem { - label: "Delete saved account".to_owned(), - action: InteractiveAction::DeletePrompt, - }); - } - actions.push(InteractiveItem { - label: if auto_start_usage_windows_enabled { - "Disable auto-start usage windows".to_owned() - } else { - "Enable auto-start usage windows".to_owned() - }, - action: InteractiveAction::SetAutoStartUsageWindows(!auto_start_usage_windows_enabled), - }); - actions.push(InteractiveItem { - label: "Show status".to_owned(), - action: InteractiveAction::ShowStatus, - }); - #[cfg(windows)] - actions.push(InteractiveItem { - label: "Send to Tray".to_owned(), - action: InteractiveAction::SendToTray, - }); - } - actions.push(InteractiveItem { - label: "Quit".to_owned(), - action: InteractiveAction::Quit, - }); - - let prompt = match mode { - InteractiveMode::Persistent | InteractiveMode::ActivateOnce => "", - InteractiveMode::DeleteOnce => "Which saved account do you want to delete?", - }; - - InteractiveMenu { - prompt, - current_status_label, - accounts, - actions, - } -} - -fn select_persistent_entry( - menu: &InteractiveMenu, - default_selection: usize, - feedback: &[String], -) -> Result { - let term = Term::stderr(); - let mut selection = default_selection.min(menu.len().saturating_sub(1)); - term.hide_cursor()?; - let render_state = render_persistent_menu(&term, menu, selection, feedback)?; - loop { - match term.read_key()? { - Key::ArrowUp | Key::Char('k') => { - let next = if selection == 0 { - menu.len().saturating_sub(1) - } else { - selection - 1 - }; - update_persistent_selection(&term, menu, &render_state, selection, next)?; - selection = next; - } - Key::ArrowDown | Key::Char('j') => { - let next = (selection + 1) % menu.len().max(1); - update_persistent_selection(&term, menu, &render_state, selection, next)?; - selection = next; - } - Key::ArrowLeft | Key::ArrowRight | Key::Tab => { - let next = jump_section(menu, selection); - update_persistent_selection(&term, menu, &render_state, selection, next)?; - selection = next; - } - Key::Enter => { - if render_state.total_lines > 0 { - term.clear_last_lines(render_state.total_lines)?; - } - term.show_cursor()?; - return Ok(selection); - } - Key::Escape | Key::Char('q') => { - if render_state.total_lines > 0 { - term.clear_last_lines(render_state.total_lines)?; - } - term.show_cursor()?; - return Ok(menu.len().saturating_sub(1)); - } - _ => {} - } - } -} - -pub(crate) fn jump_section(menu: &InteractiveMenu, selection: usize) -> usize { - if selection < menu.accounts.len() { - menu.first_action_index().unwrap_or(selection) - } else if !menu.accounts.is_empty() { - 0 - } else { - selection - } -} - -fn render_persistent_menu( - term: &Term, - menu: &InteractiveMenu, - selection: usize, - feedback: &[String], -) -> Result { - let mut lines = 0usize; - let mut row_lines = Vec::with_capacity(menu.len()); - for line in feedback { - term.write_line(line)?; - lines += 1; - } - if !feedback.is_empty() { - term.write_line("")?; - lines += 1; - } - if !menu.prompt.is_empty() { - term.write_line(&style(menu.prompt).bold().to_string())?; - lines += 1; - } - term.write_line(&render_section_heading("Active Account"))?; - lines += 1; - if let Some(current_status_label) = &menu.current_status_label { - term.write_line( - &style(format!(" {current_status_label}")) - .white() - .to_string(), - )?; - } else { - term.write_line(&style(" (not logged in)").white().to_string())?; - } - lines += 1; - term.write_line(&render_section_heading("Saved Accounts"))?; - lines += 1; - if menu.accounts.is_empty() { - term.write_line(&style(" (no saved accounts)").dim().to_string())?; - lines += 1; - } else { - for index in 0..menu.accounts.len() { - row_lines.push(lines); - term.write_line(&render_menu_row(menu, selection, index))?; - lines += 1; - } - } - term.write_line(&render_section_heading("Actions"))?; - lines += 1; - for index in menu.accounts.len()..menu.len() { - row_lines.push(lines); - term.write_line(&render_menu_row(menu, selection, index))?; - lines += 1; - } - term.write_line(&render_divider())?; - lines += 1; - term.write_line( - &style("Arrows or j/k move. Tab or left/right jumps sections. Enter selects. q exits.") - .dim() - .to_string(), - )?; - lines += 1; - Ok(PersistentRenderState { - total_lines: lines, - row_lines, - }) -} - -fn render_menu_row(menu: &InteractiveMenu, selection: usize, index: usize) -> String { - render_menu_row_explicit(menu, index, selection == index) -} - -fn render_section_heading(title: &str) -> String { - style(title).blue().bold().to_string() -} - -fn render_divider() -> String { - style("--------------------------------------------------") - .dim() - .to_string() -} - -fn update_persistent_selection( - term: &Term, - menu: &InteractiveMenu, - render_state: &PersistentRenderState, - previous: usize, - next: usize, -) -> Result<()> { - if previous == next { - return Ok(()); - } - rewrite_menu_row(term, menu, render_state, previous, false)?; - rewrite_menu_row(term, menu, render_state, next, true)?; - Ok(()) -} - -fn rewrite_menu_row( - term: &Term, - menu: &InteractiveMenu, - render_state: &PersistentRenderState, - index: usize, - selected: bool, -) -> Result<()> { - let line_index = render_state.row_lines[index]; - let lines_up = render_state.total_lines.saturating_sub(line_index); - term.move_cursor_up(lines_up)?; - term.clear_line()?; - term.write_line(&render_menu_row_explicit(menu, index, selected))?; - term.move_cursor_down(lines_up.saturating_sub(1))?; - Ok(()) -} - -fn render_menu_row_explicit(menu: &InteractiveMenu, index: usize, selected: bool) -> String { - let label = menu.label(index); - if selected { - style(format!("> {label}")).cyan().bold().to_string() - } else { - format!(" {label}") - } -} - -fn prompt_for_account_delete(accounts: &[AccountView]) -> Result { - let widths = account_label_widths(&accounts.iter().collect::>()); - let labels = accounts - .iter() - .map(|account| render_account_label(account, widths)) - .collect::>(); - let selection = Select::with_theme(&ColorfulTheme::default()) - .with_prompt("Which saved account do you want to delete?") - .items(&labels) - .default(0) - .interact()?; - Ok(accounts[selection].id) -} - -fn confirm_delete(account: &AccountView) -> Result { - let widths = account_label_widths(&[account]); - confirm_with_menu( - "Delete saved snapshot?", - &[ - render_account_label(account, widths), - format!("Snapshot id: {}", account.id), - ], - &[], - "Yes, delete", - "No, keep it", - false, - ) -} - -fn confirm_activation(warnings: &[RunningCodexProcess]) -> Result { - let body = vec![ - "Codex appears to be running.".to_owned(), - "Close every listed process first for a reliable swap, or force activation anyway." - .to_owned(), - ]; - let details = process_summary_lines("Codex processes", warnings); - confirm_with_menu( - "Continue with account activation?", - &body, - &details, - "Yes, force activation", - "No, cancel", - false, - ) -} - -fn interactive_status_lines(status: &StatusOutput) -> Vec { - let mut lines = vec![ - "Status".to_owned(), - format!("Environment: {}", status.environment), - format!("Codex root: {}", status.codex_root), - ]; - match &status.current_account { - Some(account) => { - lines.push(format!("Current account: {}", account.email)); - if let Some(plan) = &account.plan_label { - lines.push(format!("Plan: {plan}")); - } - } - None => lines.push("Current account: not logged in".to_owned()), - } - lines.push(format!("Saved accounts: {}", status.saved_accounts)); - if !status.process_warnings.is_empty() { - lines.extend(process_summary_lines( - "Codex processes", - &status.process_warnings, - )); - } - lines -} - -fn process_summary_lines(title: &str, processes: &[RunningCodexProcess]) -> Vec { - let mut lines = vec![format!("{title}:")]; - lines.extend(format_process_table(processes)); - lines -} - -fn auto_start_usage_window_feedback(output: &AutoStartUsageWindowsRunOutput) -> Vec { - let mut lines = vec![format!( - "Checked {} saved accounts for due weekly windows.", - output.checked_accounts - )]; - if output.pinged_accounts.is_empty() && output.skipped.is_empty() { - lines.push("No due weekly windows found.".to_owned()); - } - for account in &output.pinged_accounts { - lines.push(match &account.detail { - Some(detail) => format!("{}: {} ({detail})", account.email, account.status), - None => format!("{}: {}", account.email, account.status), - }); - } - for skipped in &output.skipped { - lines.push(format!("Skipped: {skipped}")); - } - lines -} - -fn error_feedback(prefix: &str, error: Error) -> Vec { - error_feedback_rendered(prefix, &format!("{error:#}")) -} - -fn error_feedback_rendered(prefix: &str, rendered_error: &str) -> Vec { - let mut lines = vec![prefix.to_owned()]; - for (index, line) in rendered_error.lines().enumerate() { - lines.push(if index == 0 { - format!("Error: {line}") - } else { - format!(" {line}") - }); - } - lines -} - -fn error_indicates_running_process_instability(rendered_error: &str) -> bool { - rendered_error.contains("managed auth files no longer match") - || rendered_error.contains("changed again after activation") -} - -fn confirm_with_menu( - prompt: &str, - body_lines: &[String], - trailing_lines: &[String], - yes_label: &str, - no_label: &str, - default_yes: bool, -) -> Result { - let term = Term::stderr(); - let mut selection = usize::from(default_yes); - let options = [no_label, yes_label]; - let mut rendered_lines = 0usize; - term.hide_cursor()?; - loop { - if rendered_lines > 0 { - term.clear_last_lines(rendered_lines)?; - } - rendered_lines = 0; - term.write_line(&style(prompt).bold().to_string())?; - rendered_lines += 1; - for line in body_lines { - term.write_line(line)?; - rendered_lines += 1; - } - if !body_lines.is_empty() || !trailing_lines.is_empty() { - term.write_line("")?; - rendered_lines += 1; - } - for (index, option) in options.iter().enumerate() { - term.write_line(&render_confirm_option(option, selection == index))?; - rendered_lines += 1; - } - if !trailing_lines.is_empty() { - term.write_line("")?; - rendered_lines += 1; - for line in trailing_lines { - term.write_line(line)?; - rendered_lines += 1; - } - } - match term.read_key()? { - Key::ArrowUp - | Key::ArrowDown - | Key::ArrowLeft - | Key::ArrowRight - | Key::Tab - | Key::Char('j') - | Key::Char('k') => selection = 1 - selection, - Key::Enter => { - term.clear_last_lines(rendered_lines)?; - term.show_cursor()?; - return Ok(selection == 1); - } - Key::Escape | Key::Char('q') => { - term.clear_last_lines(rendered_lines)?; - term.show_cursor()?; - return Ok(false); - } - _ => {} - } - } -} - -fn render_confirm_option(label: &str, selected: bool) -> String { - if selected { - style(format!("> {label}")).cyan().bold().to_string() - } else { - format!(" {label}") - } -} - -#[cfg(test)] -mod tests { - use tempfile::tempdir; - use time::OffsetDateTime; - - use super::*; - use crate::app::should_verify_activation_stability; - use crate::codex::auth_json_fixture; - use crate::env::AppEnv; - use crate::model::{ - AccountUsageView, DisplayIdentity, EnvironmentKind, SnapshotBlob, UsageSource, - UsageWindowView, - }; - use crate::repository::SnapshotRepository; - use crate::secrets::test_support::MemorySecretStore; - - fn sample_status_with_email(email: &str, current_saved_id: Option) -> StatusOutput { - StatusOutput { - environment: EnvironmentKind::Windows, - codex_root: "C:\\Users\\tester\\.codex".to_owned(), - current_account: current_saved_id.map(|_| DisplayIdentity { - email: email.to_owned(), - subject: Some("sub-1".to_owned()), - name: Some("Tester".to_owned()), - plan_label: Some("Pro".to_owned()), - }), - current_account_saved_id: current_saved_id, - saved_accounts: usize::from(current_saved_id.is_some()), - process_warnings: Vec::new(), - } - } - - fn sample_status(current_saved_id: Option) -> StatusOutput { - sample_status_with_email("person@example.com", current_saved_id) - } - - fn sample_list(id: Uuid, is_active: bool) -> ListOutput { - ListOutput { - environment: EnvironmentKind::Windows, - accounts: vec![AccountView { - id, - email: "person@example.com".to_owned(), - subject: Some("sub-1".to_owned()), - name: Some("Tester".to_owned()), - plan_label: Some("Pro".to_owned()), - environment: EnvironmentKind::Windows, - is_active, - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - last_activated_at: is_active.then_some(OffsetDateTime::UNIX_EPOCH), - usage: None, - usage_error: None, - }], - } - } - - #[test] - fn account_label_includes_reset_time() { - let id = Uuid::new_v4(); - let mut list = sample_list(id, false); - let reset_at = OffsetDateTime::UNIX_EPOCH - .replace_date(time::Date::from_calendar_date(2099, time::Month::May, 12).unwrap()) - .replace_time(time::Time::from_hms(13, 56, 0).unwrap()); - list.accounts[0].usage = Some(AccountUsageView { - source: UsageSource::SavedAccessToken, - fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, - weekly: Some(UsageWindowView { - used_percent: 77, - remaining_percent: 23, - reset_at, - }), - credits: None, - }); - - let label = render_account_label( - &list.accounts[0], - account_label_widths(&[&list.accounts[0]]), - ); - - assert!(label.contains(&format!("Reset: {}", format_local_reset_at(reset_at)))); - } - - #[test] - fn account_label_marks_login_required_usage_error() { - let id = Uuid::new_v4(); - let mut list = sample_list(id, false); - list.accounts[0].usage = Some(AccountUsageView { - source: UsageSource::SavedAccessToken, - fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, - weekly: Some(UsageWindowView { - used_percent: 0, - remaining_percent: 100, - reset_at: OffsetDateTime::UNIX_EPOCH - .replace_date( - time::Date::from_calendar_date(2099, time::Month::May, 12).unwrap(), - ) - .replace_time(time::Time::from_hms(13, 56, 0).unwrap()), - }), - credits: None, - }); - list.accounts[0].usage_error = Some("Login required: Codex auth expired.".to_owned()); - - let label = render_account_label( - &list.accounts[0], - account_label_widths(&[&list.accounts[0]]), - ); - - assert!(label.contains("Login required")); - assert!(!label.contains("Usage unavailable")); - assert!(!label.contains("Weekly Remaining")); - } - - #[test] - fn account_label_keeps_cached_usage_for_transient_usage_error() { - let id = Uuid::new_v4(); - let mut list = sample_list(id, false); - list.accounts[0].usage = Some(AccountUsageView { - source: UsageSource::SavedAccessToken, - fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, - weekly: Some(UsageWindowView { - used_percent: 10, - remaining_percent: 90, - reset_at: OffsetDateTime::UNIX_EPOCH - .replace_date( - time::Date::from_calendar_date(2099, time::Month::May, 12).unwrap(), - ) - .replace_time(time::Time::from_hms(13, 56, 0).unwrap()), - }), - credits: None, - }); - list.accounts[0].usage_error = - Some("Usage unavailable: failed to query Codex usage".to_owned()); - - let label = render_account_label( - &list.accounts[0], - account_label_widths(&[&list.accounts[0]]), - ); - - assert!(label.contains("Weekly Remaining: 90%")); - assert!(!label.contains("Usage unavailable")); - } - - #[test] - fn activate_once_menu_hides_active_account() { - let id = Uuid::new_v4(); - let menu = build_menu( - InteractiveMode::ActivateOnce, - &sample_status(Some(id)), - &sample_list(id, true), - Some(id), - false, - ); - assert_eq!(menu.prompt, ""); - assert_eq!(menu.accounts.len(), 0); - assert_eq!(menu.len(), 1); - assert!(matches!(menu.action(0), InteractiveAction::Quit)); - } - - #[test] - fn delete_once_menu_only_lists_deletes_and_quit() { - let id = Uuid::new_v4(); - let menu = build_menu( - InteractiveMode::DeleteOnce, - &sample_status(Some(id)), - &sample_list(id, true), - Some(id), - false, - ); - assert_eq!(menu.prompt, "Which saved account do you want to delete?"); - assert_eq!(menu.len(), 2); - assert_eq!(menu.accounts.len(), 1); - assert!(matches!(menu.action(0), InteractiveAction::Delete(actual) if actual == id)); - assert!(matches!(menu.action(1), InteractiveAction::Quit)); - } - - #[test] - fn persistent_menu_keeps_refresh_in_actions() { - let id = Uuid::new_v4(); - let menu = build_menu( - InteractiveMode::Persistent, - &sample_status(Some(id)), - &sample_list(id, false), - Some(id), - false, - ); - assert_eq!(menu.accounts.len(), 1); - assert!(matches!(menu.action(1), InteractiveAction::SaveCurrent)); - assert_eq!( - menu.actions[0].label, - "Refresh saved snapshot for person@example.com" - ); - } - - #[test] - fn persistent_menu_shows_auto_start_usage_window_toggle() { - let id = Uuid::new_v4(); - let disabled_menu = build_menu( - InteractiveMode::Persistent, - &sample_status(Some(id)), - &sample_list(id, false), - Some(id), - false, - ); - let disabled_toggle = disabled_menu - .actions - .iter() - .find(|item| item.label == "Enable auto-start usage windows") - .expect("enable toggle"); - assert!(matches!( - disabled_toggle.action, - InteractiveAction::SetAutoStartUsageWindows(true) - )); - - let enabled_menu = build_menu( - InteractiveMode::Persistent, - &sample_status(Some(id)), - &sample_list(id, false), - Some(id), - true, - ); - let enabled_toggle = enabled_menu - .actions - .iter() - .find(|item| item.label == "Disable auto-start usage windows") - .expect("disable toggle"); - assert!(matches!( - enabled_toggle.action, - InteractiveAction::SetAutoStartUsageWindows(false) - )); - } - - #[test] - fn force_running_always_enables_stability_verification() { - let warnings = Vec::new(); - assert!(should_verify_activation_stability(true, &warnings)); - assert!(!should_verify_activation_stability(false, &warnings)); - - let warnings = vec![RunningCodexProcess { - pid: 1, - executable: "codex.exe".to_owned(), - role: "process".to_owned(), - summary: None, - }]; - assert!(should_verify_activation_stability(false, &warnings)); - } - - #[test] - fn jump_section_switches_between_accounts_and_actions() { - let id = Uuid::new_v4(); - let menu = build_menu( - InteractiveMode::Persistent, - &sample_status(Some(id)), - &sample_list(id, false), - Some(id), - false, - ); - assert_eq!(jump_section(&menu, 0), 1); - assert_eq!(jump_section(&menu, 1), 0); - } - - #[test] - fn persistent_menu_hides_active_account_from_switch_targets() { - let id = Uuid::new_v4(); - let menu = build_menu( - InteractiveMode::Persistent, - &sample_status(Some(id)), - &sample_list(id, true), - Some(id), - false, - ); - assert_eq!(menu.accounts.len(), 0); - let current = menu - .current_status_label - .as_deref() - .expect("current status label"); - assert!(current.contains("person@example.com")); - assert!(current.contains("Plan: Pro")); - assert!(!current.contains("Saved:")); - assert!(!current.contains("Last Used:")); - assert_eq!( - menu.actions[0].label, - "Refresh saved snapshot for person@example.com" - ); - } - - #[test] - fn persistent_menu_keeps_unsaved_current_account_out_of_saved_accounts() { - let id = Uuid::new_v4(); - let status = StatusOutput { - environment: EnvironmentKind::Windows, - codex_root: "C:\\Users\\tester\\.codex".to_owned(), - current_account: Some(DisplayIdentity { - email: "other@example.com".to_owned(), - subject: Some("sub-2".to_owned()), - name: Some("Other".to_owned()), - plan_label: Some("Plus".to_owned()), - }), - current_account_saved_id: None, - saved_accounts: 1, - process_warnings: Vec::new(), - }; - let menu = build_menu( - InteractiveMode::Persistent, - &status, - &sample_list(id, false), - None, - false, - ); - assert_eq!( - menu.current_status_label.as_deref(), - Some("other@example.com [not saved]") - ); - assert_eq!(menu.accounts.len(), 1); - assert!(menu.accounts[0].label.contains("person@example.com")); - assert!(!menu.accounts[0].label.contains("Saved:")); - assert!(!menu.accounts[0].label.contains("Last Used:")); - assert_eq!( - menu.actions[0].label, - "Add current account other@example.com to switcher" - ); - } - - #[test] - fn persistent_menu_reads_live_status_and_accounts() { - let temp = tempdir().expect("tempdir"); - let env = AppEnv { - kind: EnvironmentKind::Linux, - home_dir: temp.path().to_path_buf(), - codex_root: temp.path().join(".codex"), - app_data_dir: temp.path().join("app"), - }; - std::fs::create_dir_all(&env.codex_root).expect("codex root"); - std::fs::write( - env.codex_root.join("auth.json"), - auth_json_fixture("current@example.com", "sub-current", Some("pro")), - ) - .expect("auth"); - std::fs::write(env.codex_root.join("cap_sid"), "sid-current").expect("cap"); - let repo = SnapshotRepository::new(&env.app_data_dir, MemorySecretStore::default()); - repo.save_snapshot( - &env.kind, - &DisplayIdentity { - email: "saved@example.com".to_owned(), - subject: Some("sub-1".to_owned()), - name: None, - plan_label: Some("Pro".to_owned()), - }, - &SnapshotBlob { - schema_version: 1, - files: vec![], - }, - ) - .expect("save"); - let app = App::new(env, repo); - let status = app.status().expect("status"); - let list = app.list().expect("list"); - let menu = build_menu(InteractiveMode::Persistent, &status, &list, None, false); - assert_eq!(menu.accounts.len(), 1); - assert!(menu.actions.iter().any(|item| item.label == "Show status")); - } -} diff --git a/src/claude.rs b/src/claude.rs new file mode 100644 index 0000000..e4eb375 --- /dev/null +++ b/src/claude.rs @@ -0,0 +1,281 @@ +use anyhow::{Context, Result, bail}; +use base64::Engine; +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; + +use crate::codex::LiveAuthBundle; +use crate::env::AppEnv; +use crate::model::{DisplayIdentity, SNAPSHOT_SCHEMA_VERSION, SnapshotBlob, SnapshotFile}; + +/// Placeholder email when Claude is signed in but CLI identity lookup failed. +pub const CLAUDE_UNKNOWN_EMAIL: &str = "claude-user@unknown.com"; + +pub fn claude_dir(env: &AppEnv) -> PathBuf { + env.home_dir.join(".claude") +} + +pub fn get_local_username() -> Result { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .context("failed to determine local username from environment (USER/USERNAME)") +} + +pub fn try_read_live_claude_auth(env: &AppEnv) -> Result> { + if !claude_auth_present(env)? { + return Ok(None); + } + read_live_claude_auth(env).map(Some) +} + +/// Lightweight identity-only discovery for status/UI polls. +/// Prefer the credentials file; never block on Keychain prompts or `claude` CLI. +pub fn try_read_live_claude_identity(env: &AppEnv) -> Result> { + let credentials_path = claude_dir(env).join(".credentials.json"); + if credentials_path.exists() { + let mut identity = base_claude_identity(); + if let Ok(text) = fs::read_to_string(&credentials_path) { + enrich_identity_from_keychain_json(&mut identity, &text); + } + return Ok(Some(identity)); + } + // Keychain only as fallback, with a hard timeout so UI polls never freeze. + // 800ms matches full-auth path — 350ms was too aggressive and often returned + // no live Claude identity, blanking CL in the menu bar. + if let Some(password) = read_keychain_password_timeout(Duration::from_millis(800)) { + let mut identity = base_claude_identity(); + enrich_identity_from_keychain_json(&mut identity, &password); + return Ok(Some(identity)); + } + Ok(None) +} + +fn claude_auth_present(env: &AppEnv) -> Result { + let credentials_path = claude_dir(env).join(".credentials.json"); + if credentials_path.exists() { + return Ok(true); + } + Ok(read_keychain_password_timeout(Duration::from_millis(800)).is_some()) +} + +fn base_claude_identity() -> DisplayIdentity { + DisplayIdentity { + email: CLAUDE_UNKNOWN_EMAIL.to_string(), + subject: None, + name: None, + plan_label: None, + workspace_id: None, + workspace_name: None, + } +} + +pub fn read_live_claude_auth(env: &AppEnv) -> Result { + let identity = discover_claude_identity(env); + let mut files = Vec::new(); + + let credentials_path = claude_dir(env).join(".credentials.json"); + if credentials_path.exists() { + let content = fs::read(&credentials_path).with_context(|| { + format!( + "failed to read Claude credentials file at {}", + credentials_path.display() + ) + })?; + files.push(SnapshotFile { + name: "claude_credentials.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode(content), + }); + } + + if let Some(password) = read_keychain_password_timeout(Duration::from_millis(800)) { + files.push(SnapshotFile { + name: "claude_keychain.txt".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode(password.as_bytes()), + }); + } + + if files.is_empty() { + bail!( + "No Claude Code authentication state found (credentials file and Keychain are both empty)." + ); + } + + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files, + }; + + Ok(LiveAuthBundle { identity, snapshot }) +} + +fn discover_claude_identity(env: &AppEnv) -> DisplayIdentity { + let mut identity = base_claude_identity(); + + // File first (fast, no UI prompts). + let credentials_path = claude_dir(env).join(".credentials.json"); + if let Ok(text) = fs::read_to_string(&credentials_path) { + enrich_identity_from_keychain_json(&mut identity, &text); + } + + if let Some(password) = read_keychain_password_timeout(Duration::from_millis(800)) { + enrich_identity_from_keychain_json(&mut identity, &password); + } + + // CLI is optional enrichment only — never block save/status for long. + enrich_identity_from_claude_cli_timeout(&mut identity, Duration::from_millis(900)); + identity +} + +fn enrich_identity_from_keychain_json(identity: &mut DisplayIdentity, raw: &str) { + let Ok(value) = serde_json::from_str::(raw) else { + return; + }; + let oauth = value.get("claudeAiOauth").unwrap_or(&value); + if identity.plan_label.is_none() + && let Some(sub_type) = oauth + .get("subscriptionType") + .and_then(Value::as_str) + .or_else(|| value.get("subscriptionType").and_then(Value::as_str)) + { + identity.plan_label = Some(normalize_plan(sub_type)); + } + if identity.email == CLAUDE_UNKNOWN_EMAIL + && let Some(email) = oauth + .get("email") + .and_then(Value::as_str) + .or_else(|| value.get("email").and_then(Value::as_str)) + { + identity.email = email.to_owned(); + } +} + +fn enrich_identity_from_claude_cli_timeout(identity: &mut DisplayIdentity, timeout: Duration) { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(run_claude_auth_status()); + }); + if let Ok(Some(status_json)) = rx.recv_timeout(timeout) { + if let Some(email) = status_json.get("email").and_then(Value::as_str) { + identity.email = email.to_string(); + } + if let Some(sub_type) = status_json.get("subscriptionType").and_then(Value::as_str) { + identity.plan_label = Some(normalize_plan(sub_type)); + } + if let Some(org_name) = status_json.get("orgName").and_then(Value::as_str) { + identity.workspace_name = Some(org_name.to_string()); + } + if let Some(org_id) = status_json.get("orgId").and_then(Value::as_str) { + identity.workspace_id = Some(org_id.to_string()); + } + } +} + +fn run_claude_auth_status() -> Option { + let claude_bin = resolve_claude_cli()?; + let output = Command::new(claude_bin).arg("auth").arg("status").output().ok()?; + if !output.status.success() { + return None; + } + serde_json::from_slice(&output.stdout).ok() +} + +fn normalize_plan(sub_type: &str) -> String { + match sub_type.to_ascii_lowercase().as_str() { + "pro" => "Pro".to_string(), + "free" => "Free".to_string(), + other => other.to_string(), + } +} + +fn read_keychain_password_timeout(timeout: Duration) -> Option { + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let _ = tx.send(read_keychain_password_blocking()); + }); + rx.recv_timeout(timeout).ok().flatten() +} + +fn read_keychain_password_blocking() -> Option { + let username = get_local_username().ok()?; + keyring::Entry::new("Claude Code-credentials", &username) + .ok()? + .get_password() + .ok() +} + +/// Resolve `claude` even when LaunchAgents provide a minimal PATH. +fn resolve_claude_cli() -> Option { + if let Ok(path) = which_in_path("claude") { + return Some(path); + } + for candidate in [ + "/opt/homebrew/bin/claude", + "/usr/local/bin/claude", + &format!( + "{}/.local/bin/claude", + std::env::var("HOME").unwrap_or_default() + ), + ] { + let path = PathBuf::from(candidate); + if path.is_file() { + return Some(path); + } + } + None +} + +fn which_in_path(bin: &str) -> Result { + let path = std::env::var_os("PATH").context("PATH unset")?; + for dir in std::env::split_paths(&path) { + let candidate = dir.join(bin); + if candidate.is_file() { + return Ok(candidate); + } + } + bail!("not found in PATH"); +} + +pub fn restore_claude_snapshot(env: &AppEnv, snapshot: &SnapshotBlob) -> Result<()> { + if let Some(file) = snapshot + .files + .iter() + .find(|f| f.name == "claude_credentials.json") + { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&file.bytes_base64) + .context("failed to decode claude_credentials.json base64")?; + let dir = claude_dir(env); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create Claude directory at {}", dir.display()))?; + let path = dir.join(".credentials.json"); + fs::write(&path, bytes).with_context(|| { + format!( + "failed to write Claude credentials file to {}", + path.display() + ) + })?; + } + + if let Some(file) = snapshot + .files + .iter() + .find(|f| f.name == "claude_keychain.txt") + { + let bytes = base64::engine::general_purpose::STANDARD + .decode(&file.bytes_base64) + .context("failed to decode claude_keychain.txt base64")?; + let password = String::from_utf8(bytes) + .context("failed to parse claude_keychain.txt as UTF-8 string")?; + let username = get_local_username()?; + let entry = keyring::Entry::new("Claude Code-credentials", &username)?; + entry + .set_password(&password) + .context("failed to restore Claude credentials into Keychain")?; + } + + Ok(()) +} diff --git a/src/claude_usage.rs b/src/claude_usage.rs new file mode 100644 index 0000000..7896b26 --- /dev/null +++ b/src/claude_usage.rs @@ -0,0 +1,126 @@ +//! Claude Code OAuth usage (5-hour + 7-day windows). + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::Deserialize; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use crate::model::{AccountUsageView, SnapshotBlob, UsageSource, UsageWindowView}; + +const OAUTH_USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; + +#[derive(Debug, Deserialize)] +struct ClaudeUsageResponse { + #[serde(default)] + five_hour: Option, + #[serde(default)] + seven_day: Option, + #[serde(default)] + seven_day_opus: Option, +} + +#[derive(Debug, Deserialize)] +struct ClaudeWindow { + /// Percent used (0–100). + #[serde(default)] + utilization: Option, + #[serde(default)] + resets_at: Option, +} + +/// Pull Claude OAuth access token from a switcher snapshot. +pub fn access_token_from_snapshot(snapshot: &SnapshotBlob) -> Result { + for name in ["claude_credentials.json", "claude_keychain.txt"] { + if let Some(file) = snapshot.files.iter().find(|f| f.name == name) { + let bytes = STANDARD + .decode(&file.bytes_base64) + .with_context(|| format!("failed to decode {name}"))?; + let text = String::from_utf8(bytes).with_context(|| format!("{name} is not UTF-8"))?; + if let Some(token) = extract_access_token(&text) { + return Ok(token); + } + } + } + bail!("Claude access token not found in snapshot") +} + +fn extract_access_token(raw: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + let oauth = value.get("claudeAiOauth").unwrap_or(&value); + oauth + .get("accessToken") + .or_else(|| oauth.get("access_token")) + .and_then(|v| v.as_str()) + .map(str::to_owned) +} + +pub fn fetch_claude_usage(access_token: &str) -> Result { + let mut response = ureq::get(OAUTH_USAGE_URL) + .header("Authorization", &format!("Bearer {access_token}")) + .header("anthropic-beta", "oauth-2025-04-20") + .header("User-Agent", "claude-code/2.0.32") + .header("Accept", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .call() + .context("Claude usage request failed")?; + + let status = response.status().as_u16(); + let body = response + .body_mut() + .read_to_string() + .context("failed to read Claude usage body")?; + if !(200..300).contains(&status) { + if status == 401 || status == 403 { + bail!("usage authorization failed: Claude token rejected ({status})"); + } + bail!("Claude usage endpoint returned HTTP {status}: {body}"); + } + + let parsed: ClaudeUsageResponse = + serde_json::from_str(&body).context("failed to parse Claude usage JSON")?; + + Ok(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::now_utc(), + five_hour: parsed.five_hour.as_ref().and_then(window_from_claude), + weekly: parsed + .seven_day + .as_ref() + .or(parsed.seven_day_opus.as_ref()) + .and_then(window_from_claude), + credits: None, + }) +} + +fn window_from_claude(window: &ClaudeWindow) -> Option { + let used = window.utilization.unwrap_or(0.0).clamp(0.0, 100.0).round() as u8; + let reset_at = window + .resets_at + .as_deref() + .and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()) + .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::hours(5)); + Some(UsageWindowView { + used_percent: used, + remaining_percent: 100u8.saturating_sub(used), + reset_at, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extract_token_from_oauth_json() { + let raw = r#"{"claudeAiOauth":{"accessToken":"sk-ant-oat01-test","refreshToken":"r"}}"#; + assert_eq!( + extract_access_token(raw).as_deref(), + Some("sk-ant-oat01-test") + ); + } +} diff --git a/src/cli.rs b/src/cli.rs index 2f640b5..b6c96e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,17 +1,24 @@ +use std::path::PathBuf; +use std::sync::Arc; + use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use time::OffsetDateTime; use uuid::Uuid; -use crate::app::{App, InteractiveExit, InteractiveMode}; +use crate::app::App; use crate::env; +use crate::import_export::{read_export_file, write_export_file}; use crate::model::{ - AccountUsageView, AccountView, AutoStartUsageWindowsRunOutput, - AutoStartUsageWindowsStatusOutput, RunningCodexProcess, UsageOutput, + AUTO_REFRESH_QUOTA_ON_RESET_LABEL, AccountUsageView, AccountView, + AutoStartUsageWindowsRunOutput, AutoStartUsageWindowsStatusOutput, BatchRefreshOutput, + ImportOutput, PickBestOutput, QUOTA_PAST_RESET_LABEL, RunningCodexProcess, + ShowQuotaInMenuBarStatusOutput, UsageOutput, }; use crate::process::format_process_table; use crate::repository::SnapshotRepository; use crate::secrets::MigratingSecretStore; +use crate::time_display::format_local_reset_at; use crate::usage::{usage_error_label, usage_error_requires_login}; #[derive(Parser)] @@ -21,6 +28,14 @@ use crate::usage::{usage_error_label, usage_error_requires_login}; about = "Switch Codex accounts by snapshotting live auth state" )] struct Cli { + #[arg( + long, + global = true, + default_value = "codex", + help = "Target application: codex, cursor, or claude" + )] + pub app: String, + #[command(subcommand)] command: Option, } @@ -39,11 +54,62 @@ enum Command { #[arg(long)] json: bool, }, + Login { + #[arg(long)] + json: bool, + }, + Current { + #[arg(long)] + json: bool, + }, + PickBest { + #[arg(long)] + skip_refresh: bool, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, + Export { + #[arg(long)] + output: PathBuf, + account_id: Vec, + #[arg(long)] + json: bool, + }, + Import { + path: PathBuf, + #[arg(long)] + label: Option, + #[arg(long)] + json: bool, + }, + /// Import a ChatGPT Codex account from local cookies/token JSON (file path or `-` for stdin). + ImportCookies { + /// Path to JSON file, or `-` to read stdin. + path: PathBuf, + #[arg(long, default_value = "codex")] + provider: String, + #[arg(long)] + label: Option, + #[arg(long)] + json: bool, + }, + Rename { + account: String, + label: Option, + #[arg(long)] + json: bool, + }, Usage { account_id: Option, #[arg(long)] json: bool, }, + RefreshUsage { + #[arg(long)] + json: bool, + }, Activate { account_id: Option, #[arg(long)] @@ -56,6 +122,13 @@ enum Command { #[arg(long)] json: bool, }, + Archive { + account_id: Uuid, + #[arg(long)] + unarchive: bool, + #[arg(long)] + json: bool, + }, AutoStartUsageWindows { #[arg(long, conflicts_with = "disable")] enable: bool, @@ -66,42 +139,104 @@ enum Command { #[arg(long)] json: bool, }, + AutoSwitchOnLimit { + #[arg(long, conflicts_with = "disable")] + enable: bool, + #[arg(long)] + disable: bool, + #[arg(long)] + run: bool, + #[arg(long)] + json: bool, + }, + ShowQuotaInMenuBar { + #[arg(long, conflicts_with = "disable")] + enable: bool, + #[arg(long)] + disable: bool, + #[arg(long)] + json: bool, + }, + LaunchAtStartup { + #[arg(long, conflicts_with = "disable")] + enable: bool, + #[arg(long)] + disable: bool, + #[arg(long)] + json: bool, + }, + Dashboard, + Exec { + account: String, + #[arg(required = true, last = true)] + command: Vec, + }, } pub fn run() -> Result<()> { let cli = Cli::parse(); + let target_app = cli.app.to_ascii_lowercase(); + if target_app != "codex" && target_app != "cursor" && target_app != "claude" { + anyhow::bail!( + "invalid target application: '{}'. Supported: codex, cursor, claude", + target_app + ); + } let env = env::detect()?; let repository = SnapshotRepository::new( &env.app_data_dir, MigratingSecretStore::new(&env.app_data_dir.join("snapshots")), ); - let app = App::new(env, repository); + let app = Arc::new(App::new(env, repository)); + let _ = app.update_launch_at_startup_path_if_enabled(); match cli.command { - None => run_interactive_app(&app), + None => run_menubar_app(app, false), + Some(Command::Dashboard) => run_menubar_app(app, true), Some(Command::Status { json }) => { - let status = app.status()?; + let status = if target_app == "cursor" { + app.cursor_status()? + } else if target_app == "claude" { + app.claude_status()? + } else { + app.status()? + }; if json { print_json(&status)?; } else { println!("Environment: {}", status.environment); - println!("Codex root: {}", status.codex_root); + if target_app == "cursor" { + println!("Cursor database: {}", status.codex_root); + } else if target_app == "claude" { + println!("Claude config: {}", status.codex_root); + } else { + println!("Codex root: {}", status.codex_root); + } match status.current_account { Some(account) => println!("Current account: {}", account.email), None => println!("Current account: not logged in"), } println!("Saved accounts: {}", status.saved_accounts); if !status.process_warnings.is_empty() { - print_process_summary("Codex processes", &status.process_warnings); + let label = if target_app == "cursor" { + "Cursor processes" + } else if target_app == "claude" { + "Claude processes" + } else { + "Codex processes" + }; + print_process_summary(label, &status.process_warnings); } } Ok(()) } Some(Command::List { json }) => { - let list = app.list()?; + let mut list = app.list()?; + list.accounts + .retain(|account| account.target_app.as_deref().unwrap_or("codex") == target_app); if json { print_json(&list)?; } else if list.accounts.is_empty() { - println!("No saved accounts in {}.", list.environment); + println!("No saved {} accounts in {}.", target_app, list.environment); } else { for account in list.accounts { println!("{}", render_account_summary(&account)); @@ -110,7 +245,13 @@ pub fn run() -> Result<()> { Ok(()) } Some(Command::Save { json }) => { - let output = app.save_current()?; + let output = if target_app == "cursor" { + app.save_cursor_current()? + } else if target_app == "claude" { + app.save_claude_current()? + } else { + app.save_current()? + }; if json { print_json(&output)?; } else { @@ -118,6 +259,148 @@ pub fn run() -> Result<()> { } Ok(()) } + Some(Command::Login { json }) => { + if target_app == "cursor" || target_app == "claude" { + bail!( + "Login flow automation is not supported for {}. Please log in within the application first, then run 'save'.", + target_app + ); + } + let output = app.login_and_save()?; + if json { + print_json(&output)?; + } else { + println!( + "Logged in and saved {} ({})", + output.account.email, output.account.id + ); + } + Ok(()) + } + Some(Command::Current { json }) => { + let status = if target_app == "cursor" { + app.cursor_status()? + } else if target_app == "claude" { + app.claude_status()? + } else { + app.status()? + }; + if json { + print_json(&status)?; + } else { + match status.current_account { + Some(account) => { + println!("Current account: {}", account.email); + if let Some(id) = status.current_account_saved_id { + println!("Saved account ID: {id}"); + } else { + println!("Saved account: not saved in switcher"); + } + } + None => println!("Current account: not logged in"), + } + } + Ok(()) + } + Some(Command::PickBest { + skip_refresh, + dry_run, + json, + }) => { + let output = app.pick_best_account(!skip_refresh, !dry_run)?; + if json { + print_json(&output)?; + } else { + print_pick_best_output(&output); + } + Ok(()) + } + Some(Command::Export { + output, + account_id, + json, + }) => { + let ids = if account_id.is_empty() { + None + } else { + Some(account_id) + }; + let bundle = app.export_accounts(ids)?; + if json { + print_json(&bundle)?; + } else { + write_export_file(&output, &bundle)?; + println!( + "Exported {} account(s) to {}", + bundle.accounts.len(), + output.display() + ); + } + Ok(()) + } + Some(Command::Import { path, label, json }) => { + let outputs = match read_export_file(&path) { + Ok(bundle) => app.import_bundle(&bundle)?, + Err(bundle_error) => match app.import_auth_path(&path, label) { + Ok(output) => vec![output], + Err(auth_error) => { + return Err(bundle_error + .context(format!("failed to import as auth.json ({auth_error:#})"))); + } + }, + }; + if json { + print_json(&outputs)?; + } else { + for output in outputs { + print_import_output(&output); + } + } + Ok(()) + } + Some(Command::ImportCookies { + path, + provider, + label, + json, + }) => { + let text = if path.as_os_str() == "-" { + let mut buf = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut buf) + .context("failed to read cookie JSON from stdin")?; + buf + } else { + std::fs::read_to_string(&path) + .with_context(|| format!("failed to read {}", path.display()))? + }; + let output = app.import_cookies_json(&provider, &text, label)?; + if json { + print_json(&output)?; + } else { + print_import_output(&output); + for warning in &output.warnings { + eprintln!("warning: {warning}"); + } + } + Ok(()) + } + Some(Command::Rename { + account, + label, + json, + }) => { + let metadata = app.find_account_by_id_or_email(&account)?; + let output = app.rename_account(metadata.id, label)?; + if json { + print_json(&output)?; + } else { + match &output.account.label { + Some(name) => println!("Renamed {} to label \"{name}\"", output.account.email), + None => println!("Cleared label for {}", output.account.email), + } + } + Ok(()) + } Some(Command::Usage { account_id, json }) => { let output = app.usage(account_id)?; if json { @@ -127,6 +410,15 @@ pub fn run() -> Result<()> { } Ok(()) } + Some(Command::RefreshUsage { json }) => { + let output = app.refresh_all_usage()?; + if json { + print_json(&output)?; + } else { + print_batch_refresh_output(&output); + } + Ok(()) + } Some(Command::Activate { account_id, json, @@ -136,26 +428,35 @@ pub fn run() -> Result<()> { let output = match account_id { Some(account_id) => { app.validate_activation_target(account_id)?; - let warnings = app.activation_preflight_warnings(); + let warnings = app.activation_preflight_warnings_for_account(account_id); + let is_cursor = app.is_cursor_account(account_id); + let label = if is_cursor { + "Cursor processes" + } else { + "Codex processes" + }; + let app_name = if is_cursor { "Cursor" } else { "Codex" }; if !warnings.is_empty() { if !force { if !json { - print_process_summary("Codex processes", &warnings); + print_process_summary(label, &warnings); } bail!( - "Codex appears to be running. Close those processes first or rerun `activate` with `--force`." + "{} appears to be running. Close those processes first or rerun `activate` with `--force`.", + app_name ); } if !json { showed_preflight = true; - print_process_summary("Codex processes", &warnings); + print_process_summary(label, &warnings); } } app.activate_with_running_policy(account_id, force)? } None => { - let _ = app.interactive(InteractiveMode::ActivateOnce, force)?; - return Ok(()); + bail!( + "account id required. Run `list` to see saved accounts, then `activate `." + ); } }; if json { @@ -172,8 +473,9 @@ pub fn run() -> Result<()> { let output = match account_id { Some(account_id) => app.delete(account_id)?, None => { - let _ = app.interactive(InteractiveMode::DeleteOnce, false)?; - return Ok(()); + bail!( + "account id required. Run `list` to see saved accounts, then `delete `." + ); } }; if json { @@ -183,6 +485,20 @@ pub fn run() -> Result<()> { } Ok(()) } + Some(Command::Archive { + account_id, + unarchive, + json, + }) => { + app.set_account_archived(account_id, !unarchive)?; + if json { + print_json(&serde_json::json!({ "status": "success" }))?; + } else { + let verb = if unarchive { "Unarchived" } else { "Archived" }; + println!("{verb} account successfully."); + } + Ok(()) + } Some(Command::AutoStartUsageWindows { enable, disable, @@ -210,34 +526,120 @@ pub fn run() -> Result<()> { } Ok(()) } + Some(Command::AutoSwitchOnLimit { + enable, + disable, + run, + json, + }) => { + let status = if enable { + app.set_auto_switch_on_limit(true)? + } else if disable { + app.set_auto_switch_on_limit(false)? + } else { + app.auto_switch_on_limit_status()? + }; + if run && !disable { + let output = app.auto_switch_on_limit_once()?; + if json { + print_json(&output)?; + } else if let Some(target_id) = output { + println!("Auto-switched to account ID {}.", target_id); + } else { + println!("No switch performed."); + } + } else if json { + print_json(&status)?; + } else { + println!( + "Auto-switch on limit: {}", + if status.enabled { + "enabled" + } else { + "disabled" + } + ); + } + Ok(()) + } + Some(Command::ShowQuotaInMenuBar { + enable, + disable, + json, + }) => { + let status = if enable { + app.set_show_quota_in_menu_bar(true)? + } else if disable { + app.set_show_quota_in_menu_bar(false)? + } else { + app.show_quota_in_menu_bar_status()? + }; + if json { + print_json(&status)?; + } else { + print_show_quota_in_menu_bar_status(&status); + } + Ok(()) + } + Some(Command::LaunchAtStartup { + enable, + disable, + json, + }) => { + let status = if enable { + app.set_launch_at_startup(true)? + } else if disable { + app.set_launch_at_startup(false)? + } else { + app.launch_at_startup_status()? + }; + if json { + print_json(&status)?; + } else { + println!( + "Launch at startup: {}", + if status.enabled { + "enabled" + } else { + "disabled" + } + ); + } + Ok(()) + } + Some(Command::Exec { account, command }) => { + let target_account = app.find_account_by_id_or_email(&account)?; + let status = app.exec_with_temporary_account(target_account.id, &command)?; + if !status.success() { + if let Some(code) = status.code() { + std::process::exit(code); + } else { + std::process::exit(1); + } + } + Ok(()) + } } } -fn run_interactive_app(app: &App) -> Result<()> +fn run_menubar_app(app: Arc>, open_dashboard: bool) -> Result<()> where - S: crate::secrets::SecretStore, + S: crate::secrets::SecretStore + Send + Sync + 'static, { crate::app::spawn_auto_start_usage_windows_worker(app.env().clone()); - #[cfg(windows)] + #[cfg(any(target_os = "windows", target_os = "macos"))] { - loop { - match app.interactive(InteractiveMode::Persistent, false)? { - InteractiveExit::Quit => return Ok(()), - InteractiveExit::SendToTray => { - crate::tray::hide_console_window(); - match crate::tray::run(app)? { - crate::tray::TrayExit::ShowTui => crate::tray::show_console_window(), - crate::tray::TrayExit::Quit => return Ok(()), - } - } - } + crate::tray::hide_console_window(); + match crate::tray::run(app, open_dashboard)? { + crate::tray::TrayExit::Quit => Ok(()), } } - #[cfg(not(windows))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - match app.interactive(InteractiveMode::Persistent, false)? { - InteractiveExit::Quit => Ok(()), - } + let _ = open_dashboard; + bail!( + "No graphical tray on this platform. Use CLI subcommands: status, list, save, activate, usage, delete." + ); } } @@ -258,37 +660,95 @@ fn print_process_summary(title: &str, processes: &[RunningCodexProcess]) { } fn render_account_summary(account: &AccountView) -> String { - let mut line = format!( - "{} {}{}", - account.id, - account.email, - if account.is_active { " [active]" } else { "" } - ); + let label = account + .label + .as_deref() + .map(|name| format!(" [{name}]")) + .unwrap_or_default(); + let workspace = account + .workspace_label() + .map(|name| format!(" | workspace: {name}")) + .unwrap_or_default(); + let mut parts = vec![ + format!("{}{}{}", account.email, label, workspace), + account_status_summary(account), + ]; + if let Some(usage_summary) = account_usage_summary(account) { + parts.push(usage_summary); + } + parts.push(format!("id: {}", account.id)); + parts.join(" | ") +} + +fn account_status_summary(account: &AccountView) -> String { + let mut parts = Vec::new(); + if account.is_active { + parts.push("active"); + } if account .usage_error .as_deref() .is_some_and(usage_error_requires_login) { - line.push_str(&format!( - " [{}]", - usage_error_label(account.usage_error.as_deref().unwrap_or_default()).to_lowercase() - )); - } else if let Some(usage) = &account.usage - && let Some(weekly) = &usage.weekly - { - if weekly.reset_at <= OffsetDateTime::now_utc() { - line.push_str(" [weekly reset passed]"); + parts.push("login required"); + } else if let Some(usage) = &account.usage { + let now = OffsetDateTime::now_utc(); + if usage.is_out_of_quota(now) { + parts.push("quota depleted"); + } else if usage.is_near_limit(now, 10) { + parts.push("low quota"); } else { - line.push_str(&format!( - " [weekly remaining: {}%, reset {}]", - weekly.remaining_percent, - weekly.reset_at.date() - )); + parts.push("ready"); + } + } else if account.usage_error.is_some() { + parts.push("usage stale"); + } else { + parts.push("no usage"); + } + format!("status: {}", parts.join(", ")) +} + +fn account_usage_summary(account: &AccountView) -> Option { + if account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login) + { + return Some(usage_error_label(account.usage_error.as_deref()?).to_owned()); + } + if let Some(usage) = &account.usage { + let now = OffsetDateTime::now_utc(); + let weekly_only = account + .target_app + .as_deref() + .map(|app| app == "codex") + .unwrap_or(true); + let mut parts = Vec::new(); + if !weekly_only + && let Some(five_hour) = &usage.five_hour + && five_hour.reset_at > now + { + parts.push(format!("5h {}%", five_hour.remaining_percent)); + } + if let Some(weekly) = &usage.weekly { + if weekly.reset_at <= now { + parts.push(format!("weekly {QUOTA_PAST_RESET_LABEL}")); + } else { + parts.push(format!( + "weekly {}%, reset {}", + weekly.remaining_percent, + format_local_reset_at(weekly.reset_at) + )); + } + } + if !parts.is_empty() { + return Some(parts.join("; ")); } - } else if let Some(error) = &account.usage_error { - line.push_str(&format!(" [{}]", usage_error_label(error).to_lowercase())); } - line + account + .usage_error + .as_deref() + .map(|error| usage_error_label(error).to_owned()) } fn print_usage_output(output: &UsageOutput) { @@ -302,7 +762,8 @@ fn print_usage_output(output: &UsageOutput) { fn print_auto_start_usage_windows_status(output: &AutoStartUsageWindowsStatusOutput) { println!( - "Auto-start usage windows: {}", + "{}: {}", + AUTO_REFRESH_QUOTA_ON_RESET_LABEL, if output.enabled { "enabled" } else { @@ -312,9 +773,21 @@ fn print_auto_start_usage_windows_status(output: &AutoStartUsageWindowsStatusOut println!("Poll interval: {}s", output.poll_seconds); } +fn print_show_quota_in_menu_bar_status(output: &ShowQuotaInMenuBarStatusOutput) { + println!( + "Show quota in menu bar: {}", + if output.enabled { + "enabled" + } else { + "disabled" + } + ); +} + fn print_auto_start_usage_windows_run(output: &AutoStartUsageWindowsRunOutput) { println!( - "Auto-start usage windows: {}", + "{}: {}", + AUTO_REFRESH_QUOTA_ON_RESET_LABEL, if output.enabled { "enabled" } else { @@ -333,21 +806,94 @@ fn print_auto_start_usage_windows_run(output: &AutoStartUsageWindowsRunOutput) { } } -fn print_usage_summary(usage: &AccountUsageView) { - println!("Source: {}", format!("{:?}", usage.source).to_lowercase()); - println!("Fetched at: {}", usage.fetched_at); - if let Some(five_hour) = &usage.five_hour { +fn print_pick_best_output(output: &PickBestOutput) { + println!( + "{} {} ({})", + if output.switched { + "Switched to best quota account:" + } else { + "Best quota account already active:" + }, + output.account.email, + output.account.id + ); + for entry in &output.scores { + let score = entry + .score + .map(|value| format!("{value:+.1}")) + .unwrap_or_else(|| "n/a".to_owned()); + let marker = if entry.account_id == output.account.id { + " *" + } else { + "" + }; + let label = entry + .label + .as_deref() + .map(|name| format!(" [{name}]")) + .unwrap_or_default(); println!( - "5h remaining: {}% (reset {})", - five_hour.remaining_percent, five_hour.reset_at + " {}{label}: score {score}{marker}{}", + entry.email, + if entry.eligible { "" } else { " (ineligible)" }, ); } - if let Some(weekly) = &usage.weekly { +} + +fn print_import_output(output: &ImportOutput) { + let action = if output.created { + "Imported" + } else { + "Updated" + }; + match &output.label { + Some(label) => println!( + "{action} {} as \"{label}\" ({})", + output.email, output.account_id + ), + None => println!("{action} {} ({})", output.email, output.account_id), + } +} + +fn print_batch_refresh_output(output: &BatchRefreshOutput) { + println!( + "Refreshed usage for {}/{} saved accounts.", + output.refreshed.len(), + output.total + ); + for failure in &output.failed { println!( - "Weekly remaining: {}% (reset {})", - weekly.remaining_percent, weekly.reset_at + "Failed {} ({}): {}", + failure.email, failure.account_id, failure.error ); } +} + +fn print_usage_summary(usage: &AccountUsageView) { + println!("Source: {}", format!("{:?}", usage.source).to_lowercase()); + println!("Fetched at: {}", format_local_reset_at(usage.fetched_at)); + let now = OffsetDateTime::now_utc(); + // ChatGPT Codex is weekly-only; omit stale/legacy 5h windows from CLI output. + if let Some(weekly) = &usage.weekly { + if weekly.reset_at <= now { + println!("Weekly: past reset"); + } else { + print!( + "Weekly: {}% left · resets {}", + weekly.remaining_percent, + crate::time_display::format_countdown(weekly.reset_at, now) + ); + if let Some(pace) = + crate::usage_pace::pace_for_weekly(weekly.used_percent, weekly.reset_at, now) + { + print!(" · {}", pace.summary_label()); + if let Some(eta) = pace.eta_label(now) { + print!(" · {eta}"); + } + } + println!(); + } + } if let Some(credits) = &usage.credits { println!( "Credits: {} (has_credits={}, unlimited={})", @@ -355,3 +901,52 @@ fn print_usage_summary(usage: &AccountUsageView) { ); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{EnvironmentKind, UsageSource, UsageWindowView}; + + fn account() -> AccountView { + AccountView { + id: Uuid::new_v4(), + email: "person@example.com".to_owned(), + subject: Some("sub".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, + environment: EnvironmentKind::Macos, + is_active: true, + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + last_activated_at: None, + usage: Some(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour: None, + weekly: Some(UsageWindowView { + used_percent: 92, + remaining_percent: 8, + reset_at: OffsetDateTime::now_utc() + time::Duration::days(2), + }), + credits: None, + }), + usage_error: None, + label: Some("work".to_owned()), + is_archived: false, + } + } + + #[test] + fn account_summary_leads_with_human_status_before_id() { + let rendered = render_account_summary(&account()); + + assert!(rendered.starts_with("person@example.com [work]")); + assert!(rendered.contains("status: active, low quota")); + assert!(rendered.contains("weekly 8%")); + assert!(!rendered.contains("5h ")); + assert!(rendered.contains("id: ")); + } +} diff --git a/src/codex.rs b/src/codex.rs index 90a447f..3b9eed8 100644 --- a/src/codex.rs +++ b/src/codex.rs @@ -1,4 +1,5 @@ use std::fs; +use std::io::Write; use std::path::Path; use std::time::Duration; @@ -21,6 +22,43 @@ pub struct LiveAuthBundle { pub snapshot: SnapshotBlob, } +pub struct AuthWriteLock { + #[cfg(unix)] + _file: fs::File, + #[cfg(not(unix))] + _file: fs::File, +} + +pub fn acquire_auth_write_lock(env: &AppEnv) -> Result { + fs::create_dir_all(&env.codex_root) + .with_context(|| format!("failed to create {}", env.codex_root.display()))?; + let lock_path = env.codex_root.join(".cas-auth.lock"); + let mut file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&lock_path) + .with_context(|| format!("failed to open auth lock {}", lock_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::io::AsRawFd; + + let locked = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } == 0; + if !locked { + return Err(std::io::Error::last_os_error()) + .with_context(|| format!("failed to lock {}", lock_path.display())); + } + } + + file.set_len(0) + .with_context(|| format!("failed to clear auth lock {}", lock_path.display()))?; + writeln!(file, "{}", std::process::id()) + .with_context(|| format!("failed to write auth lock {}", lock_path.display()))?; + Ok(AuthWriteLock { _file: file }) +} + pub fn try_read_live_auth_bundle(env: &AppEnv) -> Result> { let auth_json_path = env.codex_root.join("auth.json"); if !auth_json_path.exists() { @@ -34,8 +72,17 @@ pub fn read_live_auth_bundle(env: &AppEnv) -> Result { let mut auth_json_bytes = None; for file_name in AUTH_FILES { let path = env.codex_root.join(file_name); - let bytes = - fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?; + let bytes = match fs::read(&path) { + Ok(bytes) => bytes, + Err(error) + if file_name == "cap_sid" && error.kind() == std::io::ErrorKind::NotFound => + { + Vec::new() + } + Err(error) => { + return Err(error).with_context(|| format!("failed to read {}", path.display())); + } + }; if file_name == "auth.json" { auth_json_bytes = Some(bytes.clone()); } @@ -72,6 +119,17 @@ pub fn restore_snapshot( snapshot: &SnapshotBlob, expected_identity: &DisplayIdentity, verify_stable: bool, +) -> Result<()> { + let lock = acquire_auth_write_lock(env)?; + restore_snapshot_with_lock(&lock, env, snapshot, expected_identity, verify_stable) +} + +pub fn restore_snapshot_with_lock( + _lock: &AuthWriteLock, + env: &AppEnv, + snapshot: &SnapshotBlob, + expected_identity: &DisplayIdentity, + verify_stable: bool, ) -> Result<()> { ensure_snapshot_complete(snapshot)?; fs::create_dir_all(&env.codex_root) @@ -87,15 +145,24 @@ pub fn restore_snapshot( fs::create_dir_all(&temp_dir) .with_context(|| format!("failed to create {}", temp_dir.display()))?; - if let Err(error) = stage_and_restore(&env.codex_root, &backup_dir, &temp_dir, snapshot) { - let _ = restore_from_backup(&env.codex_root, &backup_dir); + let backup_state = match backup_live_files(&env.codex_root, &backup_dir) { + Ok(backup_state) => backup_state, + Err(error) => { + let _ = fs::remove_dir_all(&temp_dir); + let _ = fs::remove_dir_all(&backup_dir); + return Err(error); + } + }; + + if let Err(error) = stage_and_restore(&env.codex_root, &temp_dir, snapshot) { + let _ = restore_from_backup(&env.codex_root, &backup_dir, &backup_state); let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&backup_dir); return Err(error); } if let Err(error) = verify_live_snapshot_once(env, snapshot, expected_identity) { - let _ = restore_from_backup(&env.codex_root, &backup_dir); + let _ = restore_from_backup(&env.codex_root, &backup_dir, &backup_state); let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&backup_dir); return Err(error); @@ -110,7 +177,7 @@ pub fn restore_snapshot( Duration::from_millis(250), ) { - let _ = restore_from_backup(&env.codex_root, &backup_dir); + let _ = restore_from_backup(&env.codex_root, &backup_dir, &backup_state); let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&backup_dir); return Err(error); @@ -143,20 +210,47 @@ pub fn verify_live_snapshot_stable( } fn ensure_snapshot_complete(snapshot: &SnapshotBlob) -> Result<()> { + for file in &snapshot.files { + if !AUTH_FILES.contains(&file.name.as_str()) { + return Err(anyhow!( + "snapshot contains unmanaged auth file {}", + file.name + )); + } + } for file_name in AUTH_FILES { - if !snapshot.files.iter().any(|file| file.name == file_name) { + let count = snapshot + .files + .iter() + .filter(|file| file.name == file_name) + .count(); + if count == 0 { return Err(anyhow!("snapshot missing managed auth file {file_name}")); } + if count > 1 { + return Err(anyhow!( + "snapshot contains duplicate managed auth file {file_name}" + )); + } } Ok(()) } -fn stage_and_restore( - codex_root: &Path, - backup_dir: &Path, - temp_dir: &Path, - snapshot: &SnapshotBlob, -) -> Result<()> { +pub fn validate_import_snapshot(snapshot: &SnapshotBlob) -> Result<()> { + ensure_snapshot_complete(snapshot) +} + +pub fn clear_live_auth_for_login(env: &AppEnv) -> Result<()> { + let _lock = acquire_auth_write_lock(env)?; + fs::create_dir_all(&env.codex_root) + .with_context(|| format!("failed to create {}", env.codex_root.display()))?; + for file_name in AUTH_FILES { + remove_live_file_if_exists(&env.codex_root.join(file_name))?; + } + Ok(()) +} + +fn stage_and_restore(codex_root: &Path, temp_dir: &Path, snapshot: &SnapshotBlob) -> Result<()> { for file in &snapshot.files { let decoded = STANDARD .decode(&file.bytes_base64) @@ -168,18 +262,7 @@ fn stage_and_restore( for file_name in AUTH_FILES { let live_path = codex_root.join(file_name); - if live_path.exists() { - let backup_path = backup_dir.join(file_name); - fs::copy(&live_path, &backup_path).with_context(|| { - format!( - "failed to back up {} to {}", - live_path.display(), - backup_path.display() - ) - })?; - fs::remove_file(&live_path) - .with_context(|| format!("failed to remove {}", live_path.display()))?; - } + remove_live_file_if_exists(&live_path)?; let staged_path = temp_dir.join(file_name); fs::copy(&staged_path, &live_path).with_context(|| { format!( @@ -192,15 +275,52 @@ fn stage_and_restore( Ok(()) } -fn restore_from_backup(codex_root: &Path, backup_dir: &Path) -> Result<()> { +#[derive(Clone, Debug)] +struct ManagedFileBackup { + file_name: &'static str, + existed: bool, +} + +fn backup_live_files(codex_root: &Path, backup_dir: &Path) -> Result> { + let mut backups = Vec::with_capacity(AUTH_FILES.len()); for file_name in AUTH_FILES { - let backup_path = backup_dir.join(file_name); let live_path = codex_root.join(file_name); - if backup_path.exists() { - if live_path.exists() { - fs::remove_file(&live_path) - .with_context(|| format!("failed to remove {}", live_path.display()))?; - } + let existed = live_path.exists(); + if existed { + let backup_path = backup_dir.join(file_name); + fs::copy(&live_path, &backup_path).with_context(|| { + format!( + "failed to back up {} to {}", + live_path.display(), + backup_path.display() + ) + })?; + } + backups.push(ManagedFileBackup { file_name, existed }); + } + Ok(backups) +} + +fn remove_live_file_if_exists(live_path: &Path) -> Result<()> { + match fs::remove_file(live_path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => { + Err(error).with_context(|| format!("failed to remove {}", live_path.display())) + } + } +} + +fn restore_from_backup( + codex_root: &Path, + backup_dir: &Path, + backups: &[ManagedFileBackup], +) -> Result<()> { + for backup in backups { + let backup_path = backup_dir.join(backup.file_name); + let live_path = codex_root.join(backup.file_name); + if backup.existed { + remove_live_file_if_exists(&live_path)?; fs::copy(&backup_path, &live_path).with_context(|| { format!( "failed to restore backup {} to {}", @@ -208,6 +328,8 @@ fn restore_from_backup(codex_root: &Path, backup_dir: &Path) -> Result<()> { live_path.display() ) })?; + } else { + remove_live_file_if_exists(&live_path)?; } } Ok(()) @@ -268,6 +390,156 @@ fn snapshot_files<'a>(snapshot: &'a SnapshotBlob, file_name: &str) -> Vec<&'a st .collect() } +const ADD_ACCOUNT_AUTH_BACKUP: &str = "auth.json.switcher-bak"; +const ADD_ACCOUNT_CAP_BACKUP: &str = "cap_sid.switcher-bak"; +/// Marks an interactive browser/desktop login so background workers do not +/// quit Codex or touch live auth while OAuth (`localhost:1455`) is in flight. +const LOGIN_SESSION_MARKER: &str = ".cas-login-session"; +const LOGIN_SESSION_MAX_AGE: Duration = Duration::from_secs(45 * 60); + +pub fn add_account_session_active(env: &AppEnv) -> bool { + env.codex_root.join(ADD_ACCOUNT_AUTH_BACKUP).exists() +} + +/// True while the user is expected to complete Codex Desktop / browser OAuth. +pub fn interactive_login_in_progress(env: &AppEnv) -> bool { + if add_account_session_active(env) { + return true; + } + let path = env.codex_root.join(LOGIN_SESSION_MARKER); + let Ok(meta) = fs::metadata(&path) else { + return false; + }; + let Ok(modified) = meta.modified() else { + return true; + }; + let age = modified.elapsed().unwrap_or(Duration::ZERO); + if age > LOGIN_SESSION_MAX_AGE { + let _ = fs::remove_file(&path); + return false; + } + true +} + +pub fn mark_interactive_login_session(env: &AppEnv) -> Result<()> { + fs::create_dir_all(&env.codex_root) + .with_context(|| format!("failed to create {}", env.codex_root.display()))?; + let path = env.codex_root.join(LOGIN_SESSION_MARKER); + fs::write( + &path, + format!( + "pid={}\nstarted_unix_ms={}\n", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) + ), + ) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +pub fn clear_interactive_login_session(env: &AppEnv) { + let _ = fs::remove_file(env.codex_root.join(LOGIN_SESSION_MARKER)); +} + +pub fn begin_add_account_session(env: &AppEnv) -> Result<()> { + if add_account_session_active(env) { + bail!("add account session already in progress"); + } + let _lock = acquire_auth_write_lock(env)?; + let auth = env.codex_root.join("auth.json"); + if !auth.exists() { + bail!("not logged in"); + } + fs::create_dir_all(&env.codex_root) + .with_context(|| format!("failed to create {}", env.codex_root.display()))?; + let cap_sid = env.codex_root.join("cap_sid"); + let backup_auth = env.codex_root.join(ADD_ACCOUNT_AUTH_BACKUP); + let backup_sid = env.codex_root.join(ADD_ACCOUNT_CAP_BACKUP); + fs::copy(&auth, &backup_auth).with_context(|| { + format!( + "failed to back up {} to {}", + auth.display(), + backup_auth.display() + ) + })?; + if cap_sid.exists() { + fs::copy(&cap_sid, &backup_sid).with_context(|| { + format!( + "failed to back up {} to {}", + cap_sid.display(), + backup_sid.display() + ) + })?; + fs::remove_file(&cap_sid) + .with_context(|| format!("failed to remove {}", cap_sid.display()))?; + } + fs::remove_file(&auth).with_context(|| format!("failed to remove {}", auth.display()))?; + // Keep marker even if backup already implies session — dual signal for workers. + drop(_lock); + mark_interactive_login_session(env)?; + Ok(()) +} + +pub fn ensure_cap_sid_exists(env: &AppEnv) -> Result<()> { + let _lock = acquire_auth_write_lock(env)?; + let cap_sid = env.codex_root.join("cap_sid"); + if !cap_sid.exists() { + fs::write(&cap_sid, b"") + .with_context(|| format!("failed to create empty {}", cap_sid.display()))?; + } + Ok(()) +} + +pub fn restore_add_account_backup(env: &AppEnv) -> Result<()> { + let _lock = acquire_auth_write_lock(env)?; + let auth = env.codex_root.join("auth.json"); + let cap_sid = env.codex_root.join("cap_sid"); + let backup_auth = env.codex_root.join(ADD_ACCOUNT_AUTH_BACKUP); + let backup_sid = env.codex_root.join(ADD_ACCOUNT_CAP_BACKUP); + if backup_auth.exists() { + fs::copy(&backup_auth, &auth).with_context(|| { + format!( + "failed to restore {} from {}", + auth.display(), + backup_auth.display() + ) + })?; + fs::remove_file(&backup_auth) + .with_context(|| format!("failed to remove {}", backup_auth.display()))?; + } + if backup_sid.exists() { + fs::copy(&backup_sid, &cap_sid).with_context(|| { + format!( + "failed to restore {} from {}", + cap_sid.display(), + backup_sid.display() + ) + })?; + fs::remove_file(&backup_sid) + .with_context(|| format!("failed to remove {}", backup_sid.display()))?; + } + clear_interactive_login_session(env); + Ok(()) +} + +pub fn cancel_add_account_session(env: &AppEnv) -> Result<()> { + if !add_account_session_active(env) { + clear_interactive_login_session(env); + return Ok(()); + } + restore_add_account_backup(env) +} + +/// Clear live auth and mark a re-login session (Sign in again…). +pub fn begin_relogin_session(env: &AppEnv) -> Result<()> { + clear_live_auth_for_login(env)?; + mark_interactive_login_session(env)?; + Ok(()) +} + #[cfg(test)] pub fn auth_json_fixture(email: &str, subject: &str, plan: Option<&str>) -> String { let payload = serde_json::json!({ @@ -350,11 +622,100 @@ mod tests { subject: None, name: bundle.identity.name.clone(), plan_label: bundle.identity.plan_label.clone(), + workspace_id: None, + workspace_name: None, }; restore_snapshot(&env, &bundle.snapshot, &expected, false)?; Ok(()) } + #[test] + fn failed_restore_removes_files_that_did_not_exist_beforehand() -> Result<()> { + let temp = tempdir()?; + let codex_root = temp.path().join(".codex"); + fs::create_dir_all(&codex_root)?; + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: codex_root.clone(), + app_data_dir: temp.path().join("data"), + }; + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode(auth_json_fixture( + "person@example.com", + "sub-1", + Some("pro"), + )), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode("sid-1"), + }, + ], + }; + let expected = DisplayIdentity { + email: "other@example.com".to_owned(), + subject: Some("sub-2".to_owned()), + name: None, + plan_label: None, + workspace_id: None, + workspace_name: None, + }; + + let error = restore_snapshot(&env, &snapshot, &expected, false) + .expect_err("identity mismatch should fail restore verification"); + + assert!(format!("{error:#}").contains("restore verification failed")); + assert!(!codex_root.join("auth.json").exists()); + assert!(!codex_root.join("cap_sid").exists()); + Ok(()) + } + + #[test] + fn import_validation_rejects_duplicate_or_unmanaged_files() { + let duplicate = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode("{}"), + }, + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode("{}"), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode("sid"), + }, + ], + }; + assert!(validate_import_snapshot(&duplicate).is_err()); + + let unmanaged = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode("{}"), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode("sid"), + }, + SnapshotFile { + name: "../outside".to_owned(), + bytes_base64: STANDARD.encode("nope"), + }, + ], + }; + assert!(validate_import_snapshot(&unmanaged).is_err()); + } + #[test] fn stable_verification_fails_when_auth_reverts() -> Result<()> { let temp = tempdir()?; @@ -376,6 +737,8 @@ mod tests { subject: Some("sub-2".to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, }; let expected_snapshot = read_live_auth_bundle(&env)?.snapshot; let auth_path = codex_root.join("auth.json"); @@ -421,6 +784,8 @@ mod tests { subject: Some("sub-2".to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, }; let expected_snapshot = read_live_auth_bundle(&env)?.snapshot; let cap_sid_path = codex_root.join("cap_sid"); @@ -477,4 +842,87 @@ mod tests { assert!(!snapshot_matches(&left, &right)); assert!(!snapshot_matches(&right, &left)); } + + #[test] + fn add_account_session_backs_up_and_restores_live_auth() -> Result<()> { + let temp = tempdir()?; + let codex_root = temp.path().join(".codex"); + fs::create_dir_all(&codex_root)?; + fs::write( + codex_root.join("auth.json"), + auth_json_fixture("person@example.com", "sub-1", Some("pro")), + )?; + fs::write(codex_root.join("cap_sid"), "sid-1")?; + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: codex_root.clone(), + app_data_dir: temp.path().join("data"), + }; + + begin_add_account_session(&env)?; + assert!(add_account_session_active(&env)); + assert!(!codex_root.join("auth.json").exists()); + + fs::write( + codex_root.join("auth.json"), + auth_json_fixture("new@example.com", "sub-2", Some("plus")), + )?; + ensure_cap_sid_exists(&env)?; + restore_add_account_backup(&env)?; + + assert!(!add_account_session_active(&env)); + let restored = read_live_auth_bundle(&env)?; + assert_eq!(restored.identity.email, "person@example.com"); + Ok(()) + } + + #[test] + fn clear_live_auth_for_login_removes_managed_auth_files() -> Result<()> { + let temp = tempdir()?; + let codex_root = temp.path().join(".codex"); + fs::create_dir_all(&codex_root)?; + fs::write( + codex_root.join("auth.json"), + auth_json_fixture("person@example.com", "sub-1", Some("pro")), + )?; + fs::write(codex_root.join("cap_sid"), "sid-1")?; + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root: codex_root.clone(), + app_data_dir: temp.path().join("data"), + }; + + clear_live_auth_for_login(&env)?; + + assert!(!codex_root.join("auth.json").exists()); + assert!(!codex_root.join("cap_sid").exists()); + Ok(()) + } + + #[test] + fn read_live_auth_bundle_treats_missing_cap_sid_as_empty() -> Result<()> { + let temp = tempdir()?; + let codex_root = temp.path().join(".codex"); + fs::create_dir_all(&codex_root)?; + fs::write( + codex_root.join("auth.json"), + auth_json_fixture("person@example.com", "sub-1", Some("pro")), + )?; + let env = AppEnv { + kind: EnvironmentKind::Linux, + home_dir: temp.path().to_path_buf(), + codex_root, + app_data_dir: temp.path().join("data"), + }; + + let bundle = read_live_auth_bundle(&env)?; + + assert_eq!(bundle.identity.email, "person@example.com"); + assert!(bundle.snapshot.files.iter().any(|file| { + file.name == "cap_sid" && file.bytes_base64 == STANDARD.encode(Vec::::new()) + })); + Ok(()) + } } diff --git a/src/cookie_import.rs b/src/cookie_import.rs new file mode 100644 index 0000000..30fb437 --- /dev/null +++ b/src/cookie_import.rs @@ -0,0 +1,841 @@ +//! Import accounts from local cookie / token JSON payloads. +//! +//! Codex / OpenAI shapes: +//! - Browser cookie export array: `[{ "name", "value", "domain"? }, ...]` +//! - Cookie header object: `{ "cookie": "name=value; ..." }` or `{ "cookies": "..." }` +//! - Name/value map: `{ "id_token": "...", "access_token": "...", ... }` +//! - Codex `auth.json`-like: `{ "tokens": { "id_token", "access_token", "refresh_token", "account_id"? } }` +//! - Session-style: `{ "accessToken": "...", "user": { "email": "..." } }` +//! +//! Cursor shapes: +//! - Browser export with `WorkosCursorSessionToken` (and optional `url` for cursor.com) +//! - Cookie jar object `{ "url", "cookies": [ ... ] }` as exported by extensions + +use std::collections::HashMap; +use std::time::Duration; + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use serde_json::{Map, Value}; +use time::OffsetDateTime; + +use crate::identity::{parse_identity_from_auth_json, parse_identity_from_id_token}; +use crate::model::{DisplayIdentity, SNAPSHOT_SCHEMA_VERSION, SnapshotBlob, SnapshotFile}; + +const SESSION_ENDPOINT: &str = "https://chatgpt.com/api/auth/session"; + +#[derive(Debug, Clone)] +pub struct CookieImportResult { + pub identity: DisplayIdentity, + pub snapshot: SnapshotBlob, + pub warnings: Vec, +} + +#[derive(Debug, Default)] +struct CookieJar { + by_name: HashMap, +} + +impl CookieJar { + fn insert(&mut self, name: &str, value: &str) { + let name = name.trim(); + let value = value.trim(); + if name.is_empty() || value.is_empty() { + return; + } + self.by_name.insert(name.to_owned(), value.to_owned()); + } + + fn get_ci(&self, name: &str) -> Option<&str> { + if let Some(value) = self.by_name.get(name) { + return Some(value.as_str()); + } + let needle = name.to_ascii_lowercase(); + self.by_name + .iter() + .find(|(key, _)| key.eq_ignore_ascii_case(&needle)) + .map(|(_, value)| value.as_str()) + } + + fn header_value(&self) -> String { + self.by_name + .iter() + .map(|(name, value)| format!("{name}={value}")) + .collect::>() + .join("; ") + } + + fn is_empty(&self) -> bool { + self.by_name.is_empty() + } +} + +/// Parse local JSON text into a Codex snapshot. Never logs cookie/token values. +pub fn import_codex_from_cookies_json(json_text: &str) -> Result { + let trimmed = json_text.trim(); + if trimmed.is_empty() { + bail!("cookie JSON is empty"); + } + let value: Value = serde_json::from_str(trimmed).context("cookie payload is not valid JSON")?; + + let mut warnings = Vec::new(); + let mut jar = CookieJar::default(); + collect_cookies_from_value(&value, &mut jar); + + // Direct auth.json / tokens object wins when present. + if let Some(auth_json) = try_build_auth_json_from_tokens(&value)? { + return finalize_auth_json(auth_json, warnings); + } + + // Tokens nested under common wrappers. + for key in ["auth", "auth_json", "codex", "session", "data", "account"] { + if let Some(child) = value.get(key) + && let Some(auth_json) = try_build_auth_json_from_tokens(child)? + { + return finalize_auth_json(auth_json, warnings); + } + } + + // Token-like cookie names / JWT cookie values. + if let Some(auth_json) = try_build_auth_json_from_cookie_jar(&jar)? { + return finalize_auth_json(auth_json, warnings); + } + + // Optional: exchange ChatGPT session cookie for access token. + if !jar.is_empty() + && (jar.get_ci("__Secure-next-auth.session-token").is_some() + || jar.get_ci("next-auth.session-token").is_some() + || jar.get_ci("__Secure-next-auth.session-token.0").is_some()) + { + match exchange_session_cookie_for_auth(&jar) { + Ok(auth_json) => { + warnings.push( + "Imported via ChatGPT session cookie exchange. Refresh token may be missing; re-login if the session expires." + .to_owned(), + ); + return finalize_auth_json(auth_json, warnings); + } + Err(error) => { + warnings.push(format!("Session cookie exchange failed: {error:#}")); + } + } + } + + bail!( + "could not build a Codex auth.json from this JSON. Expected OpenAI/Codex tokens \ +(id_token + access_token, preferably refresh_token), a Codex auth.json object, or ChatGPT \ +session cookies that can be exchanged. Cursor/Claude cookie import is not supported." + ); +} + +fn finalize_auth_json(auth_json: Value, warnings: Vec) -> Result { + let bytes = + serde_json::to_vec_pretty(&auth_json).context("failed to encode imported auth.json")?; + let identity = parse_identity_from_auth_json(&bytes) + .context("imported payload did not contain a usable id_token/email")?; + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode(bytes), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode(b""), + }, + ], + }; + Ok(CookieImportResult { + identity, + snapshot, + warnings, + }) +} + +fn collect_cookies_from_value(value: &Value, jar: &mut CookieJar) { + match value { + Value::Array(items) => { + for item in items { + collect_cookies_from_value(item, jar); + } + } + Value::Object(map) => { + if let (Some(name), Some(cookie_value)) = ( + string_field(map, &["name", "Name", "key", "Key"]), + string_field(map, &["value", "Value", "val", "Val"]), + ) { + jar.insert(&name, &cookie_value); + } + if let Some(header) = string_field( + map, + &["cookie", "Cookie", "cookies", "Cookies", "cookie_header"], + ) { + parse_cookie_header(&header, jar); + } + // Flat name→value map of cookie-like keys. + for (key, val) in map { + if let Some(text) = val.as_str() { + let lower = key.to_ascii_lowercase(); + if lower.contains("token") + || lower.contains("cookie") + || lower.starts_with("__secure-") + || lower.starts_with("oai-") + || lower == "session" + { + if text.contains('=') && text.contains(';') && !looks_like_jwt(text) { + parse_cookie_header(text, jar); + } else if !key.eq_ignore_ascii_case("cookie") + && !key.eq_ignore_ascii_case("cookies") + { + jar.insert(key, text); + } + } + } else if key.eq_ignore_ascii_case("cookies") || key.eq_ignore_ascii_case("cookie") + { + collect_cookies_from_value(val, jar); + } + } + } + Value::String(text) if text.contains('=') => { + parse_cookie_header(text, jar); + } + _ => {} + } +} + +fn parse_cookie_header(header: &str, jar: &mut CookieJar) { + for part in header.split(';') { + let part = part.trim(); + if part.is_empty() { + continue; + } + if let Some((name, value)) = part.split_once('=') { + jar.insert(name, value); + } + } +} + +fn try_build_auth_json_from_tokens(value: &Value) -> Result> { + let Some(map) = value.as_object() else { + return Ok(None); + }; + + // Already an auth.json with tokens object. + if let Some(tokens) = map.get("tokens").and_then(Value::as_object) { + let access = token_from_map(tokens, &["access_token", "accessToken"]); + let id = token_from_map(tokens, &["id_token", "idToken"]); + let refresh = token_from_map(tokens, &["refresh_token", "refreshToken"]); + let account_id = token_from_map(tokens, &["account_id", "accountId"]); + if let (Some(access), Some(id)) = (access, id) { + return Ok(Some(build_auth_json( + access, + id, + refresh, + account_id, + map.get("last_refresh").and_then(Value::as_str), + ))); + } + } + + let access = token_from_map( + map, + &[ + "access_token", + "accessToken", + "openai_access_token", + "chatgpt_access_token", + ], + ); + let id = token_from_map( + map, + &["id_token", "idToken", "openai_id_token", "chatgpt_id_token"], + ); + let refresh = token_from_map( + map, + &[ + "refresh_token", + "refreshToken", + "openai_refresh_token", + "chatgpt_refresh_token", + ], + ); + let account_id = token_from_map(map, &["account_id", "accountId", "chatgpt_account_id"]); + + if let (Some(access), Some(id)) = (access.clone(), id.clone()) { + return Ok(Some(build_auth_json( + access, + id, + refresh.clone(), + account_id.clone(), + None, + ))); + } + + // Session API style: accessToken only — synthesize id_token from user email when possible. + if let Some(access) = access { + if let Some(id) = id { + return Ok(Some(build_auth_json(access, id, refresh, account_id, None))); + } + if looks_like_jwt(&access) && parse_identity_from_id_token(&access).is_ok() { + return Ok(Some(build_auth_json( + access.clone(), + access, + refresh, + account_id, + None, + ))); + } + if let Some(email) = map + .get("user") + .and_then(Value::as_object) + .and_then(|user| string_field(user, &["email", "Email"])) + { + let synthetic = synthesize_id_token(&email, account_id.as_deref())?; + return Ok(Some(build_auth_json( + access, synthetic, refresh, account_id, None, + ))); + } + } + + Ok(None) +} + +fn try_build_auth_json_from_cookie_jar(jar: &CookieJar) -> Result> { + let access = first_cookie( + jar, + &[ + "access_token", + "accessToken", + "openai_access_token", + "chatgpt_access_token", + ], + ); + let id = first_cookie( + jar, + &["id_token", "idToken", "openai_id_token", "chatgpt_id_token"], + ); + let refresh = first_cookie( + jar, + &[ + "refresh_token", + "refreshToken", + "openai_refresh_token", + "chatgpt_refresh_token", + ], + ); + let account_id = first_cookie(jar, &["account_id", "accountId", "chatgpt_account_id"]); + + if let (Some(access), Some(id)) = (access.clone(), id) { + return Ok(Some(build_auth_json(access, id, refresh, account_id, None))); + } + + // Any JWT cookie with an email claim can seed id_token; access may be the same JWT. + for value in jar.by_name.values() { + if !looks_like_jwt(value) { + continue; + } + if parse_identity_from_id_token(value).is_ok() { + let access = access.unwrap_or_else(|| value.clone()); + return Ok(Some(build_auth_json( + access, + value.clone(), + refresh, + account_id, + None, + ))); + } + } + + Ok(None) +} + +fn exchange_session_cookie_for_auth(jar: &CookieJar) -> Result { + let cookie_header = jar.header_value(); + let mut response = ureq::get(SESSION_ENDPOINT) + .header("Cookie", &cookie_header) + .header("User-Agent", "codex-account-switcher") + .header("Accept", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(12))) + .build() + .call() + .context("failed to contact ChatGPT session endpoint")?; + let status = response.status(); + let body = response + .body_mut() + .read_to_string() + .context("failed to read ChatGPT session response")?; + if status.as_u16() < 200 || status.as_u16() >= 300 { + bail!("ChatGPT session endpoint returned HTTP {}", status.as_u16()); + } + let session: Value = + serde_json::from_str(&body).context("ChatGPT session response was not JSON")?; + try_build_auth_json_from_tokens(&session)? + .context("ChatGPT session response did not include usable tokens") +} + +fn build_auth_json( + access_token: String, + id_token: String, + refresh_token: Option, + account_id: Option, + last_refresh: Option<&str>, +) -> Value { + let mut tokens = Map::new(); + tokens.insert("access_token".to_owned(), Value::String(access_token)); + tokens.insert("id_token".to_owned(), Value::String(id_token)); + tokens.insert( + "refresh_token".to_owned(), + Value::String(refresh_token.unwrap_or_default()), + ); + if let Some(account_id) = account_id { + tokens.insert("account_id".to_owned(), Value::String(account_id)); + } + let mut root = Map::new(); + root.insert("tokens".to_owned(), Value::Object(tokens)); + root.insert( + "last_refresh".to_owned(), + Value::String( + last_refresh + .map(str::to_owned) + .unwrap_or_else(|| OffsetDateTime::now_utc().to_string()), + ), + ); + Value::Object(root) +} + +fn synthesize_id_token(email: &str, subject: Option<&str>) -> Result { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#); + let mut claims = Map::new(); + claims.insert("email".to_owned(), Value::String(email.to_owned())); + if let Some(sub) = subject { + claims.insert("sub".to_owned(), Value::String(sub.to_owned())); + } else { + claims.insert("sub".to_owned(), Value::String(format!("cookie:{email}"))); + } + let payload = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&Value::Object(claims)).context("failed to encode synthetic JWT")?, + ); + Ok(format!("{header}.{payload}.")) +} + +fn first_cookie(jar: &CookieJar, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| jar.get_ci(name).map(str::to_owned)) +} + +fn token_from_map(map: &Map, keys: &[&str]) -> Option { + string_field(map, keys).filter(|value| !value.is_empty()) +} + +fn string_field(map: &Map, keys: &[&str]) -> Option { + for key in keys { + if let Some(value) = map.get(*key).and_then(Value::as_str) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_owned()); + } + } + } + // Case-insensitive fallback. + for key in keys { + let needle = key.to_ascii_lowercase(); + if let Some((_, value)) = map.iter().find(|(k, _)| k.eq_ignore_ascii_case(&needle)) + && let Some(text) = value.as_str() + { + let trimmed = text.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_owned()); + } + } + } + None +} + +fn looks_like_jwt(value: &str) -> bool { + let mut parts = value.split('.'); + parts.next().is_some() && parts.next().is_some() && value.matches('.').count() >= 2 +} + +pub fn unsupported_provider_message(provider: &str) -> String { + match provider { + "claude" => { + "Claude accounts use Claude Code credentials/Keychain, not browser cookies. Use Backup/Save on a live Claude session instead.".to_owned() + } + other => format!("Cookie JSON import is not supported for provider '{other}'"), + } +} + +/// Detect provider from payload content when the UI does not specify one. +pub fn detect_provider_from_json(json_text: &str) -> &'static str { + let lower = json_text.to_ascii_lowercase(); + if lower.contains("workoscursorsessiontoken") + || lower.contains("\"cursor.com\"") + || lower.contains("cursorauth/") + { + return "cursor"; + } + "codex" +} + +/// Import a Cursor account from browser cookie export JSON. +/// +/// Uses `WorkosCursorSessionToken` (+ optional profile APIs) to build a +/// `cursor_auth.json` snapshot compatible with the switcher store. +pub fn import_cursor_from_cookies_json(json_text: &str) -> Result { + let trimmed = json_text.trim(); + if trimmed.is_empty() { + bail!("cookie JSON is empty"); + } + let value: Value = serde_json::from_str(trimmed).context("cookie payload is not valid JSON")?; + + let mut warnings = Vec::new(); + let mut jar = CookieJar::default(); + collect_cookies_from_value(&value, &mut jar); + + // Direct token map (IDE export style). + if let Some(result) = try_build_cursor_from_token_map(&value)? { + return Ok(result); + } + + let session = jar + .get_ci("WorkosCursorSessionToken") + .or_else(|| jar.get_ci("workos_cursor_session_token")) + .map(str::to_owned) + .context( + "no WorkosCursorSessionToken cookie found. Export cookies from cursor.com while signed in.", + )?; + + let session = urlencoding_decode(&session); + let (user_id, access_token) = split_cursor_session_token(&session)?; + + let profile = fetch_cursor_web_profile(&access_token, &session) + .map_err(|e| { + warnings.push(format!("Could not refresh Cursor profile online: {e:#}")); + e + }) + .ok(); + + let email = profile + .as_ref() + .and_then(|p| p.email.clone()) + .or_else(|| guess_email_from_payload(&value)) + .unwrap_or_else(|| format!("{user_id}@cursor.local")); + + let name = profile.as_ref().and_then(|p| p.name.clone()); + let plan = profile + .as_ref() + .and_then(|p| p.membership_type.clone()) + .unwrap_or_else(|| "pro".to_owned()); + + if profile.is_none() { + warnings.push( + "Imported from WorkosCursorSessionToken without live profile; email may be incomplete until Cursor validates the session." + .to_owned(), + ); + } + warnings.push( + "Cursor web session token imported. Activate this account while Cursor is closed for best results." + .to_owned(), + ); + + build_cursor_import_result(&email, name.as_deref(), &plan, &access_token, &session, warnings) +} + +#[derive(Debug, Default)] +struct CursorWebProfile { + email: Option, + name: Option, + membership_type: Option, +} + +fn urlencoding_decode(value: &str) -> String { + // Minimal decode for %3A%3A → :: and similar in cookie exports. + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' + && i + 2 < bytes.len() + && let (Some(hi), Some(lo)) = (from_hex(bytes[i + 1]), from_hex(bytes[i + 2])) + { + out.push((hi << 4) | lo); + i += 3; + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn from_hex(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +fn split_cursor_session_token(session: &str) -> Result<(String, String)> { + if let Some((user, jwt)) = session.split_once("::") { + if user.is_empty() || jwt.is_empty() { + bail!("WorkosCursorSessionToken is malformed"); + } + return Ok((user.to_owned(), jwt.to_owned())); + } + if looks_like_jwt(session) { + return Ok(("cursor-user".to_owned(), session.to_owned())); + } + bail!("WorkosCursorSessionToken is not a user::jwt session token"); +} + +fn fetch_cursor_web_profile(access_token: &str, full_session: &str) -> Result { + let mut profile = CursorWebProfile::default(); + + let mut me = ureq::get("https://cursor.com/api/auth/me") + .header("Authorization", &format!("Bearer {access_token}")) + .header( + "Cookie", + &format!("WorkosCursorSessionToken={full_session}"), + ) + .header("User-Agent", "codex-account-switcher") + .header("Accept", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(12))) + .build() + .call() + .context("Cursor /api/auth/me request failed")?; + let me_status = me.status().as_u16(); + let me_body = me + .body_mut() + .read_to_string() + .context("failed to read Cursor /api/auth/me body")?; + if !(200..300).contains(&me_status) { + bail!("Cursor /api/auth/me returned HTTP {me_status}"); + } + let me_json: Value = + serde_json::from_str(&me_body).context("failed to decode Cursor /api/auth/me JSON")?; + profile.email = me_json + .get("email") + .and_then(Value::as_str) + .map(str::to_owned); + profile.name = me_json + .get("name") + .and_then(Value::as_str) + .map(str::to_owned); + + if let Ok(mut stripe) = ureq::get("https://api2.cursor.sh/auth/full_stripe_profile") + .header("Authorization", &format!("Bearer {access_token}")) + .header("User-Agent", "codex-account-switcher") + .header("Accept", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(Duration::from_secs(12))) + .build() + .call() + { + let status = stripe.status().as_u16(); + if (200..300).contains(&status) + && let Ok(body) = stripe.body_mut().read_to_string() + && let Ok(stripe_json) = serde_json::from_str::(&body) + { + profile.membership_type = stripe_json + .get("membershipType") + .and_then(Value::as_str) + .map(str::to_owned); + } + } + + if profile.email.is_none() { + bail!("Cursor profile response missing email"); + } + Ok(profile) +} + +fn guess_email_from_payload(value: &Value) -> Option { + // Some exporters nest email at the top level. + value + .get("email") + .and_then(Value::as_str) + .map(str::to_owned) +} + +fn try_build_cursor_from_token_map(value: &Value) -> Result> { + let map = match value { + Value::Object(map) => map, + _ => return Ok(None), + }; + let access = map + .get("cursorAuth/accessToken") + .or_else(|| map.get("accessToken")) + .or_else(|| map.get("access_token")) + .and_then(Value::as_str); + let email = map + .get("cursorAuth/cachedEmail") + .or_else(|| map.get("email")) + .and_then(Value::as_str); + let (Some(access), Some(email)) = (access, email) else { + return Ok(None); + }; + let refresh = map + .get("cursorAuth/refreshToken") + .or_else(|| map.get("refreshToken")) + .or_else(|| map.get("refresh_token")) + .and_then(Value::as_str) + .unwrap_or(access); + let plan = map + .get("cursorAuth/stripeMembershipType") + .or_else(|| map.get("plan")) + .and_then(Value::as_str) + .unwrap_or("pro"); + let name = map.get("name").and_then(Value::as_str); + Ok(Some(build_cursor_import_result( + email, + name, + plan, + access, + refresh, + vec!["Imported Cursor tokens from JSON map.".to_owned()], + )?)) +} + +fn build_cursor_import_result( + email: &str, + name: Option<&str>, + plan: &str, + access_token: &str, + refresh_or_session: &str, + warnings: Vec, +) -> Result { + let mut auth_map: HashMap = HashMap::new(); + auth_map.insert( + "cursorAuth/cachedEmail".to_owned(), + STANDARD.encode(email.as_bytes()), + ); + auth_map.insert( + "cursorAuth/accessToken".to_owned(), + STANDARD.encode(access_token.as_bytes()), + ); + auth_map.insert( + "cursorAuth/refreshToken".to_owned(), + STANDARD.encode(refresh_or_session.as_bytes()), + ); + auth_map.insert( + "cursorAuth/stripeMembershipType".to_owned(), + STANDARD.encode(plan.as_bytes()), + ); + if let Some(name) = name { + let profile = serde_json::json!({ "displayName": name }).to_string(); + auth_map.insert( + "cursorAuth/cachedScopedProfile".to_owned(), + STANDARD.encode(profile.as_bytes()), + ); + } + + let json_bytes = + serde_json::to_vec_pretty(&auth_map).context("failed to encode cursor_auth.json")?; + let plan_label = if plan.eq_ignore_ascii_case("pro") { + Some("Pro".to_owned()) + } else if plan.eq_ignore_ascii_case("free") { + Some("Free".to_owned()) + } else { + Some(plan.to_owned()) + }; + + // Subject from JWT when possible. + let subject = access_token + .split('.') + .nth(1) + .and_then(|payload| { + let pad = "=".repeat((4 - payload.len() % 4) % 4); + URL_SAFE_NO_PAD + .decode(format!("{payload}{pad}")) + .ok() + .or_else(|| URL_SAFE_NO_PAD.decode(payload).ok()) + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) + .and_then(|claims| { + claims + .get("sub") + .and_then(Value::as_str) + .map(str::to_owned) + }); + + let identity = DisplayIdentity { + email: email.to_owned(), + subject, + name: name.map(str::to_owned), + plan_label, + workspace_id: None, + workspace_name: None, + }; + + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![SnapshotFile { + name: "cursor_auth.json".to_owned(), + bytes_base64: STANDARD.encode(json_bytes), + }], + }; + + Ok(CookieImportResult { + identity, + snapshot, + warnings, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + fn jwt(payload: &str) -> String { + let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#); + let payload = URL_SAFE_NO_PAD.encode(payload); + format!("{header}.{payload}.sig") + } + + #[test] + fn imports_tokens_object() { + let id = jwt(r#"{"email":"a@example.com","sub":"s1"}"#); + let json = format!( + r#"{{"tokens":{{"access_token":"access","id_token":"{id}","refresh_token":"refresh","account_id":"acct"}}}}"# + ); + let result = import_codex_from_cookies_json(&json).expect("import"); + assert_eq!(result.identity.email, "a@example.com"); + assert_eq!(result.snapshot.files.len(), 2); + } + + #[test] + fn imports_cookie_array_with_token_names() { + let id = jwt(r#"{"email":"b@example.com","sub":"s2"}"#); + let json = format!( + r#"[{{"name":"access_token","value":"access"}},{{"name":"id_token","value":"{id}"}},{{"name":"refresh_token","value":"rt"}}]"# + ); + let result = import_codex_from_cookies_json(&json).expect("import"); + assert_eq!(result.identity.email, "b@example.com"); + } + + #[test] + fn imports_cookie_header_object() { + let id = jwt(r#"{"email":"c@example.com","sub":"s3"}"#); + let json = + format!(r#"{{"cookie":"access_token=access; id_token={id}; refresh_token=rt"}}"#); + let result = import_codex_from_cookies_json(&json).expect("import"); + assert_eq!(result.identity.email, "c@example.com"); + } + + #[test] + fn rejects_empty_and_non_token_cookies() { + assert!(import_codex_from_cookies_json("").is_err()); + assert!(import_codex_from_cookies_json(r#"[{"name":"foo","value":"bar"}]"#).is_err()); + } +} diff --git a/src/cursor.rs b/src/cursor.rs new file mode 100644 index 0000000..4e6bba8 --- /dev/null +++ b/src/cursor.rs @@ -0,0 +1,490 @@ +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}; +use rusqlite::types::ValueRef; +use rusqlite::{Connection, OpenFlags}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::codex::LiveAuthBundle; +use crate::env::AppEnv; +use crate::model::{ + DisplayIdentity, EnvironmentKind, SNAPSHOT_SCHEMA_VERSION, SnapshotBlob, SnapshotFile, +}; + +const IDENTITY_KEYS: &[&str] = &[ + "cursorAuth/cachedEmail", + "cursorAuth/cachedScopedProfile", + "cursorAuth/stripeMembershipType", + "cursorAuth/accessToken", + "cursorAuth/refreshToken", +]; + +const AUTH_KEY_SQL: &str = "SELECT key, hex(value) FROM ItemTable WHERE key LIKE 'cursorAuth/%' OR key LIKE 'secret://cursorAuth/%'"; + +const IDENTITY_KEY_SQL: &str = "SELECT key, hex(value) FROM ItemTable WHERE key IN (\ + 'cursorAuth/cachedEmail',\ + 'cursorAuth/cachedScopedProfile',\ + 'cursorAuth/stripeMembershipType',\ + 'cursorAuth/accessToken',\ + 'cursorAuth/refreshToken'\ +);"; + +pub fn cursor_db_path(env: &AppEnv) -> Result { + let relative_path = match env.kind { + EnvironmentKind::Macos => { + "Library/Application Support/Cursor/User/globalStorage/state.vscdb" + } + EnvironmentKind::Windows => "AppData/Roaming/Cursor/User/globalStorage/state.vscdb", + EnvironmentKind::Linux | EnvironmentKind::Wsl => { + ".config/Cursor/User/globalStorage/state.vscdb" + } + }; + Ok(env.home_dir.join(relative_path)) +} + +pub fn try_read_live_cursor_auth(env: &AppEnv) -> Result> { + let db_path = cursor_db_path(env)?; + if !db_path.exists() { + return Ok(None); + } + match read_live_cursor_auth(env) { + Ok(bundle) => Ok(Some(bundle)), + Err(_) => Ok(None), + } +} + +/// Lightweight identity-only discovery for status/UI polls. +pub fn try_read_live_cursor_identity(env: &AppEnv) -> Result> { + let db_path = cursor_db_path(env)?; + if !db_path.exists() { + return Ok(None); + } + match read_cursor_kv_via_sqlite3(&db_path, IDENTITY_KEY_SQL) { + Ok(map) if map.contains_key("cursorAuth/cachedEmail") => { + return Ok(Some(identity_from_auth_map(&map)?)); + } + Ok(_) => {} + Err(_) => {} + } + match read_cursor_kv_rusqlite(&db_path, IDENTITY_KEYS) { + Ok(map) if map.contains_key("cursorAuth/cachedEmail") => { + Ok(Some(identity_from_auth_map(&map)?)) + } + Ok(_) => Ok(None), + Err(_) => Ok(None), + } +} + +fn resolve_sqlite3() -> PathBuf { + for candidate in [ + "/usr/bin/sqlite3", + "/opt/homebrew/bin/sqlite3", + "/usr/local/bin/sqlite3", + ] { + let path = PathBuf::from(candidate); + if path.is_file() { + return path; + } + } + PathBuf::from("sqlite3") +} + +fn decode_hex_bytes(hex: &str) -> Result> { + if !hex.len().is_multiple_of(2) { + bail!("invalid hex length {}", hex.len()); + } + let mut out = Vec::with_capacity(hex.len() / 2); + let bytes = hex.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let hi = hex_nibble(bytes[i])?; + let lo = hex_nibble(bytes[i + 1])?; + out.push((hi << 4) | lo); + i += 2; + } + Ok(out) +} + +fn hex_nibble(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => bail!("invalid hex digit {}", b as char), + } +} + +fn read_cursor_kv_via_sqlite3(path: &Path, sql: &str) -> Result>> { + // Avoid mapping Cursor's multi-GB state DB into this process. System sqlite3 + // can fetch auth keys cheaply even while Cursor holds the file. + let sqlite3 = resolve_sqlite3(); + let output = std::process::Command::new(&sqlite3) + .arg("-readonly") + .arg("-noheader") + .arg("-separator") + .arg("\t") + .arg(path) + .arg(sql) + .output() + .with_context(|| format!("failed to spawn {} for Cursor auth", sqlite3.display()))?; + if !output.status.success() { + bail!( + "sqlite3 failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let mut map = HashMap::new(); + for line in stdout.lines() { + let Some((key, hex_value)) = line.split_once('\t') else { + continue; + }; + if key.is_empty() || hex_value.is_empty() { + continue; + } + let value = decode_hex_bytes(hex_value) + .with_context(|| format!("failed to decode hex value for key {key}"))?; + map.insert(key.to_owned(), value); + } + Ok(map) +} + +pub fn read_live_cursor_auth(env: &AppEnv) -> Result { + let db_path = cursor_db_path(env)?; + if !db_path.exists() { + bail!("Cursor state database not found at {}", db_path.display()); + } + + let map = read_cursor_auth_map(&db_path)?; + if map.is_empty() { + bail!( + "No Cursor authentication tokens found in the database. Please log in to Cursor first." + ); + } + if !map.contains_key("cursorAuth/cachedEmail") { + bail!( + "Cursor session is incomplete (cached email missing). Please log in to Cursor first." + ); + } + if !map.contains_key("cursorAuth/accessToken") && !map.contains_key("cursorAuth/refreshToken") { + bail!("Cursor authentication tokens missing. Please log in to Cursor first."); + } + + let identity = identity_from_auth_map(&map)?; + + let mut serialized_map = HashMap::new(); + for (k, v) in &map { + serialized_map.insert(k.clone(), STANDARD.encode(v)); + } + + let json_bytes = serde_json::to_vec_pretty(&serialized_map)?; + + let files = vec![SnapshotFile { + name: "cursor_auth.json".to_owned(), + bytes_base64: STANDARD.encode(json_bytes), + }]; + + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files, + }; + + Ok(LiveAuthBundle { identity, snapshot }) +} + +fn open_cursor_db_readonly(path: &Path) -> Result { + // Cursor's state.vscdb can be >1GB. Open via immutable read-only URI with a + // tiny cache so status polls stay cheap and avoid OOM on LaunchAgents. + let encoded = path.to_string_lossy().replace(' ', "%20"); + let uri = format!("file:{encoded}?mode=ro&immutable=1"); + let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI; + let conn = Connection::open_with_flags(&uri, flags) + .or_else(|_| { + // Fallback without immutable (DB may have a hot WAL while Cursor runs). + let uri = format!("file:{encoded}?mode=ro"); + Connection::open_with_flags(uri, flags) + }) + .with_context(|| format!("failed to open Cursor database at {}", path.display()))?; + let _ = conn.busy_timeout(Duration::from_millis(400)); + let _ = conn.execute_batch( + "PRAGMA query_only = ON; + PRAGMA mmap_size = 0; + PRAGMA cache_size = -2000; + PRAGMA temp_store = MEMORY;", + ); + Ok(conn) +} + +fn row_value_bytes(value: ValueRef<'_>) -> Result> { + // Cursor/VS Code ItemTable stores auth values as TEXT (not BLOB). + match value { + ValueRef::Blob(bytes) => Ok(bytes.to_vec()), + ValueRef::Text(bytes) => Ok(bytes.to_vec()), + ValueRef::Null => Ok(Vec::new()), + ValueRef::Integer(v) => Ok(v.to_string().into_bytes()), + ValueRef::Real(v) => Ok(v.to_string().into_bytes()), + } +} + +fn read_cursor_kv_rusqlite(path: &Path, keys: &[&str]) -> Result>> { + let conn = open_cursor_db_readonly(path)?; + let placeholders = keys.iter().map(|_| "?").collect::>().join(","); + let sql = format!("SELECT key, value FROM ItemTable WHERE key IN ({placeholders})"); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(keys.iter()), |row| { + let key: String = row.get(0)?; + let value = row_value_bytes(row.get_ref(1)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, e.into()) + })?; + Ok((key, value)) + })?; + let mut map = HashMap::new(); + for row in rows { + let (key, value) = row?; + map.insert(key, value); + } + Ok(map) +} + +fn read_cursor_auth_map_rusqlite(path: &Path) -> Result>> { + let conn = open_cursor_db_readonly(path)?; + let mut stmt = conn.prepare( + "SELECT key, value FROM ItemTable WHERE key LIKE 'cursorAuth/%' OR key LIKE 'secret://cursorAuth/%'", + )?; + let rows = stmt.query_map([], |row| { + let key: String = row.get(0)?; + let value = row_value_bytes(row.get_ref(1)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, e.into()) + })?; + Ok((key, value)) + })?; + let mut map = HashMap::new(); + for row in rows { + let (key, value) = row?; + map.insert(key, value); + } + Ok(map) +} + +fn read_cursor_auth_map(path: &Path) -> Result>> { + match read_cursor_kv_via_sqlite3(path, AUTH_KEY_SQL) { + Ok(map) if !map.is_empty() => Ok(map), + Ok(_) => read_cursor_auth_map_rusqlite(path) + .context("sqlite3 returned no Cursor auth keys; rusqlite fallback also failed"), + Err(sqlite3_err) => read_cursor_auth_map_rusqlite(path).with_context(|| { + format!( + "sqlite3 Cursor auth read failed ({sqlite3_err:#}); rusqlite fallback also failed" + ) + }), + } +} + +fn identity_from_auth_map(map: &HashMap>) -> Result { + let email = map + .get("cursorAuth/cachedEmail") + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) + .context("Cursor cached email not found in database")?; + + let cached_profile = map + .get("cursorAuth/cachedScopedProfile") + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()); + + let name = cached_profile + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|val| { + val.get("displayName") + .and_then(serde_json::Value::as_str) + .map(String::from) + }); + + let plan_label = map + .get("cursorAuth/stripeMembershipType") + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()) + .map(|plan| { + if plan.eq_ignore_ascii_case("pro") { + "Pro".to_string() + } else if plan.eq_ignore_ascii_case("free") { + "Free".to_string() + } else { + plan + } + }); + + let token = map + .get("cursorAuth/accessToken") + .or_else(|| map.get("cursorAuth/refreshToken")) + .and_then(|bytes| String::from_utf8(bytes.clone()).ok()); + + let subject = token.and_then(|t| { + let mut parts = t.split('.'); + if parts.next().is_some() + && let Some(payload) = parts.next() + { + let payload_bytes = URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| { + let padding = "=".repeat((4 - payload.len() % 4) % 4); + URL_SAFE_NO_PAD.decode(format!("{payload}{padding}")) + }) + .ok()?; + let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + claims + .get("sub") + .and_then(serde_json::Value::as_str) + .map(String::from) + } else { + None + } + }); + + Ok(DisplayIdentity { + email, + subject, + name, + plan_label, + workspace_id: None, + workspace_name: None, + }) +} + +pub fn restore_cursor_snapshot(env: &AppEnv, snapshot: &SnapshotBlob) -> Result<()> { + let db_path = cursor_db_path(env)?; + if !db_path.exists() { + bail!("Cursor state database not found at {}", db_path.display()); + } + + let file = snapshot + .files + .iter() + .find(|f| f.name == "cursor_auth.json") + .context("snapshot missing cursor_auth.json")?; + + let json_bytes = STANDARD + .decode(&file.bytes_base64) + .context("failed to decode cursor_auth.json base64")?; + + let serialized_map: HashMap = + serde_json::from_slice(&json_bytes).context("failed to parse cursor_auth.json")?; + + let mut conn = Connection::open(&db_path) + .with_context(|| format!("failed to open Cursor database at {}", db_path.display()))?; + conn.busy_timeout(Duration::from_secs(2))?; + + let tx = conn.transaction()?; + + tx.execute( + "DELETE FROM ItemTable WHERE key LIKE 'cursorAuth/%' OR key LIKE 'secret://cursorAuth/%'", + [], + )?; + + let mut stmt = tx.prepare("INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?1, ?2);")?; + + for (key, val_b64) in serialized_map { + let value = STANDARD + .decode(&val_b64) + .with_context(|| format!("failed to decode value base64 for key {}", key))?; + // Cursor stores ItemTable values as TEXT; keep UTF-8 strings as TEXT. + match String::from_utf8(value) { + Ok(text) => { + stmt.execute(rusqlite::params![key, text])?; + } + Err(err) => { + stmt.execute(rusqlite::params![key, err.into_bytes()])?; + } + } + } + + stmt.finalize()?; + tx.commit()?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn create_text_auth_db(path: &Path) -> Result<()> { + let conn = Connection::open(path)?; + conn.execute_batch( + "CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT); + INSERT INTO ItemTable (key, value) VALUES + ('cursorAuth/cachedEmail', 'user@example.com'), + ('cursorAuth/cachedScopedProfile', '{\"displayName\":\"Ada\"}'), + ('cursorAuth/stripeMembershipType', 'pro'), + ('cursorAuth/accessToken', 'aaa.bbb.ccc'), + ('cursorAuth/refreshToken', 'refresh-token'), + ('secret://cursorAuth/openAIKey', 'sk-test');", + )?; + Ok(()) + } + + #[test] + fn reads_text_auth_values_via_sqlite3_hex() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("state.vscdb"); + create_text_auth_db(&db)?; + + let map = read_cursor_kv_via_sqlite3(&db, AUTH_KEY_SQL)?; + assert_eq!( + String::from_utf8(map["cursorAuth/cachedEmail"].clone())?, + "user@example.com" + ); + assert_eq!( + String::from_utf8(map["cursorAuth/accessToken"].clone())?, + "aaa.bbb.ccc" + ); + assert!(map.contains_key("secret://cursorAuth/openAIKey")); + Ok(()) + } + + #[test] + fn reads_text_auth_values_via_rusqlite_fallback() -> Result<()> { + let dir = tempdir()?; + let db = dir.path().join("state.vscdb"); + create_text_auth_db(&db)?; + + let map = read_cursor_auth_map_rusqlite(&db)?; + assert_eq!( + String::from_utf8(map["cursorAuth/cachedEmail"].clone())?, + "user@example.com" + ); + assert_eq!( + String::from_utf8(map["cursorAuth/stripeMembershipType"].clone())?, + "pro" + ); + Ok(()) + } + + #[test] + fn identity_from_text_auth_map() -> Result<()> { + let mut map = HashMap::new(); + map.insert( + "cursorAuth/cachedEmail".to_owned(), + b"user@example.com".to_vec(), + ); + map.insert( + "cursorAuth/cachedScopedProfile".to_owned(), + br#"{"displayName":"Ada"}"#.to_vec(), + ); + map.insert( + "cursorAuth/stripeMembershipType".to_owned(), + b"pro".to_vec(), + ); + let identity = identity_from_auth_map(&map)?; + assert_eq!(identity.email, "user@example.com"); + assert_eq!(identity.name.as_deref(), Some("Ada")); + assert_eq!(identity.plan_label.as_deref(), Some("Pro")); + Ok(()) + } + + #[test] + fn decode_hex_roundtrip() -> Result<()> { + assert_eq!(decode_hex_bytes("68656c6c6f")?, b"hello"); + assert_eq!(decode_hex_bytes("00FF")?, [0x00, 0xff]); + Ok(()) + } +} diff --git a/src/cursor_usage.rs b/src/cursor_usage.rs new file mode 100644 index 0000000..b8aa555 --- /dev/null +++ b/src/cursor_usage.rs @@ -0,0 +1,151 @@ +//! Cursor plan usage (monthly Auto + API pools). + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use serde::Deserialize; +use time::OffsetDateTime; + +use crate::model::{AccountUsageView, SnapshotBlob, UsageSource, UsageWindowView}; + +const PERIOD_USAGE_URL: &str = + "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage"; + +#[derive(Debug, Deserialize)] +struct PeriodUsageResponse { + #[serde(default, rename = "billingCycleEnd")] + billing_cycle_end: Option, + #[serde(default, rename = "planUsage")] + plan_usage: Option, +} + +#[derive(Debug, Deserialize)] +struct PlanUsage { + #[serde(default, rename = "autoPercentUsed")] + auto_percent_used: Option, + #[serde(default, rename = "apiPercentUsed")] + api_percent_used: Option, + #[serde(default, rename = "totalPercentUsed")] + total_percent_used: Option, +} + +/// Extract Cursor access token from a switcher snapshot (`cursor_auth.json`). +pub fn access_token_from_snapshot(snapshot: &SnapshotBlob) -> Result { + let file = snapshot + .files + .iter() + .find(|f| f.name == "cursor_auth.json") + .context("snapshot missing cursor_auth.json")?; + let json_bytes = STANDARD + .decode(&file.bytes_base64) + .context("failed to decode cursor_auth.json")?; + let map: std::collections::HashMap = + serde_json::from_slice(&json_bytes).context("failed to parse cursor_auth.json")?; + let encoded = map + .get("cursorAuth/accessToken") + .context("cursorAuth/accessToken missing from snapshot")?; + let bytes = STANDARD + .decode(encoded) + .context("failed to decode cursor access token")?; + String::from_utf8(bytes).context("cursor access token is not UTF-8") +} + +pub fn fetch_cursor_usage(access_token: &str) -> Result { + let mut response = ureq::post(PERIOD_USAGE_URL) + .header("Authorization", &format!("Bearer {access_token}")) + .header("Content-Type", "application/json") + .header("User-Agent", "codex-account-switcher") + .header("Accept", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(15))) + .build() + .send(&b"{}"[..]) + .context("Cursor usage request failed")?; + + let status = response.status().as_u16(); + let body = response + .body_mut() + .read_to_string() + .context("failed to read Cursor usage body")?; + if !(200..300).contains(&status) { + if status == 401 || status == 403 { + bail!("usage authorization failed: Cursor token rejected ({status})"); + } + bail!("Cursor usage endpoint returned HTTP {status}: {body}"); + } + + let parsed: PeriodUsageResponse = + serde_json::from_str(&body).context("failed to parse Cursor usage JSON")?; + let plan = parsed + .plan_usage + .context("Cursor usage response missing planUsage")?; + let reset_at = parse_billing_end(parsed.billing_cycle_end.as_ref()) + .unwrap_or_else(|| OffsetDateTime::now_utc() + time::Duration::days(30)); + + // Cursor has monthly pools (not Codex-style 5h/weekly). + // Map Auto/Composer pool → five_hour (session-style meter) + // Map total (or API) pool → weekly meter for the billing cycle. + let auto_used = plan.auto_percent_used.unwrap_or(0.0); + let total_used = plan + .total_percent_used + .or(plan.api_percent_used) + .unwrap_or(auto_used); + + Ok(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::now_utc(), + five_hour: Some(percent_window(auto_used, reset_at)), + weekly: Some(percent_window(total_used, reset_at)), + credits: None, + }) +} + +fn percent_window(used_percent: f64, reset_at: OffsetDateTime) -> UsageWindowView { + let used = used_percent.clamp(0.0, 100.0).round() as u8; + UsageWindowView { + used_percent: used, + remaining_percent: 100u8.saturating_sub(used), + reset_at, + } +} + +fn parse_billing_end(value: Option<&serde_json::Value>) -> Option { + let value = value?; + // API returns ms epoch as string: "1787112722000" + if let Some(s) = value.as_str() { + if let Ok(ms) = s.parse::() { + return OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000).ok(); + } + if let Ok(dt) = OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339) { + return Some(dt); + } + } + if let Some(ms) = value.as_i64() { + return OffsetDateTime::from_unix_timestamp_nanos(i128::from(ms) * 1_000_000).ok(); + } + if let Some(ms) = value.as_f64() { + return OffsetDateTime::from_unix_timestamp_nanos((ms as i128) * 1_000_000).ok(); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn percent_window_clamps() { + let w = percent_window(93.7, OffsetDateTime::UNIX_EPOCH); + assert_eq!(w.used_percent, 94); + assert_eq!(w.remaining_percent, 6); + } + + #[test] + fn parse_billing_end_ms_string() { + let v = json!("1787112722000"); + let dt = parse_billing_end(Some(&v)).expect("parse"); + assert!(dt.year() >= 2026); + } +} diff --git a/src/dashboard.html b/src/dashboard.html new file mode 100644 index 0000000..7b259c0 --- /dev/null +++ b/src/dashboard.html @@ -0,0 +1,2213 @@ + + + + + + ChatGPT Codex + + + +
+
+
CX
+
+
ChatGPT Codex
+
limits · accounts · resets
+
+
+
+ + + + + +
+
+ +
+
+
+
+
+
ChatGPT Codex
+
0
+
+
+
+
+
+
Cursor
+
0
+
+
+
+
+
+
Claude
+
0
+
+
+
+
+ +
+
+ +
+
+
+
+ + ChatGPT Codex +
+
+ + CX +
+
+ +
+ +
+
+
+ + Cursor IDE +
+
+ + CR +
+
+ +
+ +
+
+
+ + Claude Code +
+
+ + CL +
+
+ +
+
+
+ + +
+ +
+ + + +
+ +
+ + diff --git a/src/identity.rs b/src/identity.rs index 6402e71..8a5ea5d 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -13,7 +13,11 @@ pub fn parse_identity_from_auth_json(auth_json_bytes: &[u8]) -> Result Result { @@ -49,14 +53,129 @@ pub fn parse_identity_from_id_token(id_token: &str) -> Result { .and_then(|auth| auth.get("chatgpt_plan_type")) .and_then(Value::as_str) .and_then(normalize_plan_label); + let (workspace_id, workspace_name) = extract_workspace(&claims); Ok(DisplayIdentity { email, subject, name, plan_label, + workspace_id, + workspace_name, }) } +fn extract_workspace(value: &Value) -> (Option, Option) { + let mut workspace_id = workspace_id_from_object(value, false); + let mut workspace_name = workspace_name_from_object(value, false); + + for key in [ + "https://api.openai.com/auth", + "auth", + "workspace", + "organization", + "org", + "account", + "tokens", + ] { + let Some(child) = value.get(key) else { + continue; + }; + workspace_id = workspace_id.or_else(|| workspace_id_from_object(child, true)); + workspace_name = workspace_name.or_else(|| workspace_name_from_object(child, true)); + workspace_name = workspace_name + .or_else(|| workspace_name_from_collections(child, workspace_id.as_deref())); + } + + workspace_name = + workspace_name.or_else(|| workspace_name_from_collections(value, workspace_id.as_deref())); + + (workspace_id, workspace_name) +} + +fn workspace_id_from_object(value: &Value, allow_generic_id: bool) -> Option { + let keys = [ + "workspace_id", + "workspaceId", + "active_workspace_id", + "activeWorkspaceId", + "organization_id", + "organizationId", + "org_id", + "orgId", + "account_id", + "accountId", + "chatgpt_account_id", + "chatgptAccountId", + ]; + keys.iter() + .find_map(|key| string_field(value, key)) + .or_else(|| { + allow_generic_id + .then(|| string_field(value, "id")) + .flatten() + }) +} + +fn workspace_name_from_object(value: &Value, allow_generic_name: bool) -> Option { + let keys = [ + "workspace_name", + "workspaceName", + "active_workspace_name", + "activeWorkspaceName", + "organization_name", + "organizationName", + "org_name", + "orgName", + "account_name", + "accountName", + "chatgpt_account_name", + "chatgptAccountName", + "title", + "display_name", + "displayName", + ]; + keys.iter() + .find_map(|key| string_field(value, key)) + .or_else(|| { + allow_generic_name + .then(|| string_field(value, "name")) + .flatten() + }) +} + +fn workspace_name_from_collections(value: &Value, workspace_id: Option<&str>) -> Option { + for key in ["organizations", "workspaces", "accounts", "groups"] { + let Some(items) = value.get(key).and_then(Value::as_array) else { + continue; + }; + if let Some(workspace_id) = workspace_id + && let Some(name) = items.iter().find_map(|item| { + let item_id = workspace_id_from_object(item, true)?; + (item_id == workspace_id) + .then(|| workspace_name_from_object(item, true)) + .flatten() + }) + { + return Some(name); + } + if items.len() == 1 + && let Some(name) = workspace_name_from_object(&items[0], true) + { + return Some(name); + } + } + None +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) +} + fn normalize_plan_label(raw: &str) -> Option { let normalized = raw.replace(['-', '_'], " ").trim().to_ascii_lowercase(); if normalized.is_empty() { @@ -109,4 +228,35 @@ mod tests { assert_eq!(identity.subject.as_deref(), Some("abc")); assert_eq!(identity.plan_label.as_deref(), Some("Pro Lite")); } + + #[test] + fn parses_workspace_from_openai_auth_claims() { + let token = token( + r#"{"email":"person@example.com","sub":"abc","https://api.openai.com/auth":{"workspace_id":"ws_123","workspace_name":"Team Space"}}"#, + ); + let identity = parse_identity_from_id_token(&token).expect("identity"); + assert_eq!(identity.workspace_id.as_deref(), Some("ws_123")); + assert_eq!(identity.workspace_name.as_deref(), Some("Team Space")); + } + + #[test] + fn parses_workspace_name_from_organization_claims() { + let token = token( + r#"{"email":"person@example.com","sub":"abc","https://api.openai.com/auth":{"chatgpt_account_id":"acct_123","organizations":[{"id":"acct_123","title":"Team Org","role":"owner"}]}}"#, + ); + let identity = parse_identity_from_id_token(&token).expect("identity"); + assert_eq!(identity.workspace_id.as_deref(), Some("acct_123")); + assert_eq!(identity.workspace_name.as_deref(), Some("Team Org")); + } + + #[test] + fn auth_json_workspace_fills_missing_token_workspace() { + let token = token(r#"{"email":"person@example.com","sub":"abc"}"#); + let auth_json = format!( + r#"{{"workspace_id":"ws_456","workspace_name":"Other Space","tokens":{{"id_token":"{token}"}}}}"# + ); + let identity = parse_identity_from_auth_json(auth_json.as_bytes()).expect("identity"); + assert_eq!(identity.workspace_id.as_deref(), Some("ws_456")); + assert_eq!(identity.workspace_name.as_deref(), Some("Other Space")); + } } diff --git a/src/import_export.rs b/src/import_export.rs new file mode 100644 index 0000000..4d32086 --- /dev/null +++ b/src/import_export.rs @@ -0,0 +1,324 @@ +use std::fs; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use time::OffsetDateTime; +use uuid::Uuid; +use zip::write::SimpleFileOptions; +use zip::{CompressionMethod, ZipArchive, ZipWriter}; + +use crate::codex; +use crate::env::AppEnv; +use crate::identity::parse_identity_from_auth_json; +use crate::model::{ + DisplayIdentity, EnvironmentKind, ExportBundle, ExportBundleAccount, ImportOutput, + SNAPSHOT_SCHEMA_VERSION, SnapshotBlob, SnapshotFile, +}; +use crate::repository::SnapshotRepository; +use crate::secrets::SecretStore; + +pub const EXPORT_BUNDLE_SCHEMA_VERSION: u32 = 1; +const EXPORT_ZIP_ENTRY: &str = "bundle.json"; + +pub fn write_export_file(path: &Path, bundle: &ExportBundle) -> Result<()> { + if is_zip_path(path) { + write_export_zip(path, bundle) + } else { + write_export_bundle(path, bundle) + } +} + +pub fn read_export_file(path: &Path) -> Result { + if is_zip_path(path) { + read_export_zip(path) + } else { + read_export_bundle(path) + } +} + +fn is_zip_path(path: &Path) -> bool { + path.extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("zip")) +} + +pub fn write_export_zip(path: &Path, bundle: &ExportBundle) -> Result<()> { + let file = + fs::File::create(path).with_context(|| format!("failed to create {}", path.display()))?; + let mut zip = ZipWriter::new(file); + let options = SimpleFileOptions::default().compression_method(CompressionMethod::Deflated); + zip.start_file(EXPORT_ZIP_ENTRY, options) + .context("failed to start zip entry")?; + let json = serde_json::to_vec_pretty(bundle).context("failed to encode export bundle")?; + zip.write_all(&json) + .context("failed to write export bundle into zip")?; + zip.finish().context("failed to finalize export zip")?; + Ok(()) +} + +pub fn read_export_zip(path: &Path) -> Result { + let file = + fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; + let mut archive = + ZipArchive::new(file).with_context(|| format!("failed to read zip {}", path.display()))?; + for name in [ + EXPORT_ZIP_ENTRY, + "export.json", + "codex-account-switcher-export.json", + ] { + if let Ok(mut entry) = archive.by_name(name) { + return decode_bundle_reader(&mut entry) + .with_context(|| format!("failed to decode {name} from {}", path.display())); + } + } + for index in 0..archive.len() { + let mut entry = archive + .by_index(index) + .with_context(|| format!("failed to read zip entry {index}"))?; + if entry.name().ends_with(".json") { + return decode_bundle_reader(&mut entry).with_context(|| { + format!("failed to decode {} from {}", entry.name(), path.display()) + }); + } + } + bail!( + "zip archive {} does not contain an export bundle JSON file", + path.display() + ) +} + +fn decode_bundle_reader(reader: &mut dyn Read) -> Result { + let mut bytes = Vec::new(); + reader + .read_to_end(&mut bytes) + .context("failed to read export bundle bytes")?; + let bundle: ExportBundle = + serde_json::from_slice(&bytes).context("failed to decode export bundle")?; + if bundle.schema_version != EXPORT_BUNDLE_SCHEMA_VERSION { + bail!( + "unsupported export bundle schema version {}", + bundle.schema_version + ); + } + Ok(bundle) +} + +pub fn export_accounts( + repository: &SnapshotRepository, + environment: &EnvironmentKind, + account_ids: Option<&[Uuid]>, +) -> Result { + let accounts = repository.list_accounts(environment)?; + let selected = match account_ids { + Some(ids) => accounts + .into_iter() + .filter(|account| ids.contains(&account.id)) + .collect(), + None => accounts, + }; + if selected.is_empty() { + bail!("no saved accounts to export"); + } + + let mut exported = Vec::with_capacity(selected.len()); + for account in selected { + let (_, snapshot) = repository.load_snapshot(environment, account.id)?; + exported.push(ExportBundleAccount { + id: account.id, + email: account.email.clone(), + label: account.label.clone(), + subject: account.subject.clone(), + name: account.name.clone(), + plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), + target_app: account.target_app.clone(), + snapshot, + }); + } + + Ok(ExportBundle { + schema_version: EXPORT_BUNDLE_SCHEMA_VERSION, + environment: environment.clone(), + exported_at: OffsetDateTime::now_utc(), + accounts: exported, + }) +} + +pub fn write_export_bundle(path: &Path, bundle: &ExportBundle) -> Result<()> { + let json = serde_json::to_string_pretty(bundle).context("failed to encode export bundle")?; + fs::write(path, json).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +pub fn read_export_bundle(path: &Path) -> Result { + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let bundle: ExportBundle = + serde_json::from_slice(&bytes).context("failed to decode export bundle")?; + if bundle.schema_version != EXPORT_BUNDLE_SCHEMA_VERSION { + bail!( + "unsupported export bundle schema version {}", + bundle.schema_version + ); + } + Ok(bundle) +} + +pub fn import_auth_file( + repository: &SnapshotRepository, + environment: &EnvironmentKind, + auth_path: &Path, + label: Option, +) -> Result { + let auth_bytes = + fs::read(auth_path).with_context(|| format!("failed to read {}", auth_path.display()))?; + let identity = parse_identity_from_auth_json(&auth_bytes)?; + let mut files = vec![SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode(auth_bytes), + }]; + let cap_sid_path = auth_path + .parent() + .map(|dir| dir.join("cap_sid")) + .filter(|path| path.exists()); + if let Some(cap_sid_path) = cap_sid_path { + let cap_sid_bytes = fs::read(&cap_sid_path) + .with_context(|| format!("failed to read {}", cap_sid_path.display()))?; + files.push(SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode(cap_sid_bytes), + }); + } else { + files.push(SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode(b""), + }); + } + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files, + }; + import_snapshot(repository, environment, &identity, label, snapshot) +} + +pub fn import_export_bundle( + repository: &SnapshotRepository, + environment: &EnvironmentKind, + bundle: &ExportBundle, +) -> Result> { + if bundle.environment != *environment { + bail!( + "export bundle environment {:?} does not match current environment {:?}", + bundle.environment, + environment + ); + } + let mut outputs = Vec::with_capacity(bundle.accounts.len()); + for account in &bundle.accounts { + let identity = DisplayIdentity { + email: account.email.clone(), + subject: account.subject.clone(), + name: account.name.clone(), + plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), + }; + outputs.push(import_snapshot( + repository, + environment, + &identity, + account.label.clone(), + account.snapshot.clone(), + )?); + } + Ok(outputs) +} + +fn import_snapshot( + repository: &SnapshotRepository, + environment: &EnvironmentKind, + identity: &DisplayIdentity, + label: Option, + snapshot: SnapshotBlob, +) -> Result { + codex::validate_import_snapshot(&snapshot)?; + let (metadata, created) = repository.save_snapshot(environment, identity, &snapshot)?; + let metadata = if label.is_some() { + repository.set_account_label(environment, metadata.id, label)? + } else { + metadata + }; + Ok(ImportOutput { + account_id: metadata.id, + email: metadata.email, + label: metadata.label, + created, + warnings: Vec::new(), + }) +} + +pub fn import_live_auth( + repository: &SnapshotRepository, + env: &AppEnv, + label: Option, +) -> Result { + let live = codex::read_live_auth_bundle(env)?; + import_snapshot(repository, &env.kind, &live.identity, label, live.snapshot) +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + + use super::*; + use crate::codex::auth_json_fixture; + use crate::model::SnapshotFile; + + #[test] + fn zip_export_round_trips_bundle() -> Result<()> { + let temp = tempdir()?; + let snapshot = SnapshotBlob { + schema_version: SNAPSHOT_SCHEMA_VERSION, + files: vec![ + SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: STANDARD.encode(auth_json_fixture( + "person@example.com", + "sub-1", + Some("plus"), + )), + }, + SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: STANDARD.encode("sid"), + }, + ], + }; + let bundle = ExportBundle { + schema_version: EXPORT_BUNDLE_SCHEMA_VERSION, + environment: EnvironmentKind::Linux, + exported_at: OffsetDateTime::now_utc(), + accounts: vec![ExportBundleAccount { + id: Uuid::new_v4(), + email: "person@example.com".to_owned(), + label: Some("work".to_owned()), + subject: Some("sub-1".to_owned()), + name: None, + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, + snapshot, + }], + }; + let zip_path = temp.path().join("accounts.zip"); + write_export_zip(&zip_path, &bundle)?; + let restored = read_export_zip(&zip_path)?; + assert_eq!(restored.accounts.len(), 1); + assert_eq!(restored.accounts[0].email, "person@example.com"); + assert_eq!(restored.accounts[0].label.as_deref(), Some("work")); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index fba8f6b..efde884 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,31 @@ pub mod app; +pub mod claude; +pub mod claude_usage; pub mod cli; pub mod codex; +pub mod cursor; +pub mod cursor_usage; pub mod env; pub mod file_store; pub mod identity; pub mod model; +pub mod permissions; pub mod process; pub mod repository; pub mod secrets; +pub mod server; pub mod settings; mod time_display; -#[cfg(windows)] +pub mod time_serde; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub mod tray; pub mod usage; + +pub mod activity; +pub mod cookie_import; +pub mod import_export; +pub mod quota_scoring; +pub mod usage_pace; + +#[cfg(test)] +mod usage_http; diff --git a/src/menu.html b/src/menu.html new file mode 100644 index 0000000..fa1e082 --- /dev/null +++ b/src/menu.html @@ -0,0 +1,1951 @@ + + + + + +ChatGPT Codex + + + +
+
+
+
CX
+
+
ChatGPT Codex
+
limits · accounts · resets
+
+
+
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ + + diff --git a/src/model.rs b/src/model.rs index 36370b5..23d1611 100644 --- a/src/model.rs +++ b/src/model.rs @@ -5,6 +5,10 @@ use uuid::Uuid; pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; pub const METADATA_SCHEMA_VERSION: u32 = 1; pub const AUTH_FILES: [&str; 2] = ["auth.json", "cap_sid"]; +/// User-facing label for `settings.auto_start_usage_windows`. +pub const AUTO_REFRESH_QUOTA_ON_RESET_LABEL: &str = "Auto-refresh quota on reset"; +/// Weekly quota window reset time has passed (new window may be available). +pub const QUOTA_PAST_RESET_LABEL: &str = "Past reset"; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -45,15 +49,39 @@ pub struct DisplayIdentity { pub subject: Option, pub name: Option, pub plan_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, } impl DisplayIdentity { pub fn matches(&self, other: &Self) -> bool { - match (&self.subject, &other.subject) { + let same_user = match (&self.subject, &other.subject) { (Some(left), Some(right)) => left == right, _ => self.email.eq_ignore_ascii_case(&other.email), + }; + same_user && self.workspace_matches(other) + } + + pub fn workspace_matches(&self, other: &Self) -> bool { + match (&self.workspace_id, &other.workspace_id) { + (Some(left), Some(right)) => left == right, + (None, None) => match (&self.workspace_name, &other.workspace_name) { + (Some(left), Some(right)) => left.eq_ignore_ascii_case(right), + _ => true, + }, + _ => true, } } + + pub fn workspace_label(&self) -> Option<&str> { + self.workspace_name.as_deref().or_else(|| { + self.workspace_id + .as_deref() + .filter(|id| Uuid::parse_str(id).is_err()) + }) + } } #[cfg(test)] @@ -66,6 +94,8 @@ mod tests { subject: subject.map(str::to_owned), name: None, plan_label: None, + workspace_id: None, + workspace_name: None, } } @@ -97,6 +127,10 @@ pub struct SavedAccountMetadata { pub subject: Option, pub name: Option, pub plan_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, pub secret_key: String, pub created_at: OffsetDateTime, pub updated_at: OffsetDateTime, @@ -105,6 +139,12 @@ pub struct SavedAccountMetadata { pub cached_usage: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub cached_usage_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default)] + pub is_archived: bool, + #[serde(default)] + pub target_app: Option, } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -122,6 +162,10 @@ pub struct AccountView { pub subject: Option, pub name: Option, pub plan_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, pub environment: EnvironmentKind, pub is_active: bool, pub created_at: OffsetDateTime, @@ -129,6 +173,22 @@ pub struct AccountView { pub last_activated_at: Option, pub usage: Option, pub usage_error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(default)] + pub is_archived: bool, + #[serde(default)] + pub target_app: Option, +} + +impl AccountView { + pub fn workspace_label(&self) -> Option<&str> { + self.workspace_name.as_deref().or_else(|| { + self.workspace_id + .as_deref() + .filter(|id| Uuid::parse_str(id).is_err()) + }) + } } #[derive(Clone, Debug, Serialize)] @@ -160,6 +220,34 @@ pub struct AutoStartUsageWindowsStatusOutput { pub poll_seconds: u64, } +#[derive(Clone, Debug, Serialize)] +pub struct AutoSwitchOnLimitStatusOutput { + pub enabled: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct LaunchAtStartupStatusOutput { + pub enabled: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ShowQuotaInMenuBarStatusOutput { + pub enabled: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct DisableBlockerWarningsStatusOutput { + pub enabled: bool, +} + +#[derive(Clone, Debug, Serialize)] +pub struct UiLanguageStatusOutput { + /// Stored preference: `auto`, `en`, or `vi`. + pub preference: String, + /// Resolved language used by UI surfaces: `en` or `vi`. + pub resolved: String, +} + #[derive(Clone, Debug, Serialize)] pub struct AutoStartUsageWindowsRunOutput { pub enabled: bool, @@ -176,6 +264,80 @@ pub struct AutoStartUsageWindowAccountResult { pub detail: Option, } +#[derive(Clone, Debug, Serialize)] +pub struct BatchRefreshOutput { + pub total: usize, + pub refreshed: Vec, + pub failed: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct BatchRefreshFailure { + pub account_id: Uuid, + pub email: String, + pub error: String, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PickBestOutput { + pub switched: bool, + pub account: AccountView, + pub scores: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PickBestScoreView { + pub account_id: Uuid, + pub email: String, + pub label: Option, + pub score: Option, + pub eligible: bool, + pub weekly_used_percent: Option, + pub five_hour_used_percent: Option, + pub detail: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExportBundle { + pub schema_version: u32, + pub environment: EnvironmentKind, + pub exported_at: OffsetDateTime, + pub accounts: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ExportBundleAccount { + pub id: Uuid, + pub email: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub subject: Option, + pub name: Option, + pub plan_label: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_name: Option, + #[serde(default)] + pub target_app: Option, + pub snapshot: SnapshotBlob, +} + +#[derive(Clone, Debug, Serialize)] +pub struct ImportOutput { + pub account_id: Uuid, + pub email: String, + pub label: Option, + pub created: bool, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +#[derive(Clone, Debug, Serialize)] +pub struct RenameOutput { + pub account: AccountView, +} + #[derive(Clone, Debug, Serialize)] pub struct SaveOutput { pub account: AccountView, @@ -203,6 +365,14 @@ pub struct RunningCodexProcess { pub summary: Option, } +/// User choice when switching accounts while Codex processes are running. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SwitchWhenRunning { + Cancel, + WaitAndSwitch, + SwitchNow, +} + #[derive(Clone, Debug, Serialize)] pub struct DeleteOutput { pub deleted_account_id: Uuid, @@ -211,16 +381,99 @@ pub struct DeleteOutput { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct AccountUsageView { pub source: UsageSource, + /// RFC3339 in JSON (JS-friendly); still accepts legacy `time` tuples on load. + #[serde(with = "crate::time_serde::offset_datetime")] pub fetched_at: OffsetDateTime, pub five_hour: Option, pub weekly: Option, pub credits: Option, } +impl AccountUsageView { + pub fn is_out_of_quota(&self, now: OffsetDateTime) -> bool { + if let Some(five_hour) = &self.five_hour + && five_hour.remaining_percent == 0 + && five_hour.reset_at > now + { + return true; + } + if let Some(weekly) = &self.weekly + && weekly.remaining_percent == 0 + && weekly.reset_at > now + { + return true; + } + if self.five_hour.is_none() + && self.weekly.is_none() + && let Some(credits) = &self.credits + && !credits.unlimited + && !credits.has_credits + { + return true; + } + false + } + + /// Lowest remaining quota across active windows, or `None` when no window is active. + pub fn min_remaining_percent(&self, now: OffsetDateTime) -> Option { + [self.five_hour.as_ref(), self.weekly.as_ref()] + .into_iter() + .flatten() + .filter(|window| window.reset_at > now) + .map(|window| window.remaining_percent) + .min() + } + + /// True when any active window is nearly exhausted but not yet at 0%. + pub fn is_near_limit(&self, now: OffsetDateTime, threshold_percent: u8) -> bool { + if threshold_percent == 0 { + return false; + } + [self.five_hour.as_ref(), self.weekly.as_ref()] + .into_iter() + .flatten() + .any(|window| { + window.reset_at > now + && window.remaining_percent > 0 + && window.remaining_percent <= threshold_percent + }) + } + + /// True when every active quota window is depleted. An account with an exhausted + /// 5-hour window can still be switchable when weekly quota remains. + pub fn is_fully_exhausted(&self, now: OffsetDateTime) -> bool { + let active_windows: Vec<_> = [self.five_hour.as_ref(), self.weekly.as_ref()] + .into_iter() + .flatten() + .filter(|window| window.reset_at > now) + .collect(); + if !active_windows.is_empty() { + return active_windows + .iter() + .all(|window| window.remaining_percent == 0); + } + self.is_out_of_quota(now) + } + + /// Whether auto-switch should leave this account (exhausted or proactively near limit). + pub fn should_switch_account(&self, now: OffsetDateTime, near_limit_threshold: u8) -> bool { + self.is_out_of_quota(now) || self.is_near_limit(now, near_limit_threshold) + } + + /// True when any window's reset time has passed and cached usage may be stale. + pub fn has_stale_quota_cache(&self, now: OffsetDateTime) -> bool { + [self.five_hour.as_ref(), self.weekly.as_ref()] + .into_iter() + .flatten() + .any(|window| window.reset_at <= now) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UsageWindowView { pub used_percent: u8, pub remaining_percent: u8, + #[serde(with = "crate::time_serde::offset_datetime")] pub reset_at: OffsetDateTime, } @@ -231,7 +484,7 @@ pub struct CreditsView { pub balance: String, } -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum UsageSource { LiveAccessToken, @@ -239,3 +492,151 @@ pub enum UsageSource { SavedAccessToken, SavedRefreshToken, } + +#[cfg(test)] +mod usage_tests { + use super::{AccountUsageView, CreditsView, UsageSource, UsageWindowView}; + use time::OffsetDateTime; + + fn usage_view( + five_hour: Option, + weekly: Option, + credits: Option, + ) -> AccountUsageView { + AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour, + weekly, + credits, + } + } + + fn window(remaining_percent: u8, reset_at: OffsetDateTime) -> UsageWindowView { + UsageWindowView { + used_percent: 100 - remaining_percent, + remaining_percent, + reset_at, + } + } + + #[test] + fn is_out_of_quota_when_five_hour_window_is_exhausted() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let usage = usage_view(Some(window(0, reset)), None, None); + assert!(usage.is_out_of_quota(now)); + } + + #[test] + fn is_not_fully_exhausted_when_weekly_quota_remains() { + let now = OffsetDateTime::UNIX_EPOCH; + let five_hour_reset = now + time::Duration::hours(1); + let weekly_reset = now + time::Duration::days(7); + let usage = usage_view( + Some(window(0, five_hour_reset)), + Some(window(84, weekly_reset)), + None, + ); + assert!(usage.is_out_of_quota(now)); + assert!(!usage.is_fully_exhausted(now)); + } + + #[test] + fn is_fully_exhausted_when_all_active_windows_are_depleted() { + let now = OffsetDateTime::UNIX_EPOCH; + let five_hour_reset = now + time::Duration::hours(1); + let weekly_reset = now + time::Duration::days(7); + let usage = usage_view( + Some(window(0, five_hour_reset)), + Some(window(0, weekly_reset)), + None, + ); + assert!(usage.is_fully_exhausted(now)); + } + + #[test] + fn is_out_of_quota_when_weekly_window_is_exhausted() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::days(1); + let usage = usage_view(None, Some(window(0, reset)), None); + assert!(usage.is_out_of_quota(now)); + } + + #[test] + fn is_not_out_of_quota_after_reset_time_passes() { + let now = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(2); + let reset = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1); + let usage = usage_view(Some(window(0, reset)), None, None); + assert!(!usage.is_out_of_quota(now)); + } + + #[test] + fn is_out_of_quota_when_only_credits_remain_and_depleted() { + let usage = usage_view( + None, + None, + Some(CreditsView { + has_credits: false, + unlimited: false, + balance: "0".to_owned(), + }), + ); + assert!(usage.is_out_of_quota(OffsetDateTime::UNIX_EPOCH)); + } + + #[test] + fn unlimited_credits_do_not_count_as_out_of_quota() { + let usage = usage_view( + None, + None, + Some(CreditsView { + has_credits: false, + unlimited: true, + balance: "0".to_owned(), + }), + ); + assert!(!usage.is_out_of_quota(OffsetDateTime::UNIX_EPOCH)); + } + + #[test] + fn is_near_limit_when_remaining_is_at_threshold() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let usage = usage_view(Some(window(5, reset)), None, None); + assert!(usage.is_near_limit(now, 5)); + assert!(!usage.is_near_limit(now, 4)); + } + + #[test] + fn should_switch_account_when_near_limit_or_exhausted() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let near = usage_view(Some(window(3, reset)), None, None); + let exhausted = usage_view(Some(window(0, reset)), None, None); + let healthy = usage_view(Some(window(50, reset)), None, None); + assert!(near.should_switch_account(now, 5)); + assert!(exhausted.should_switch_account(now, 5)); + assert!(!healthy.should_switch_account(now, 5)); + } + + #[test] + fn min_remaining_percent_picks_lowest_active_window() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let usage = usage_view( + Some(window(20, reset)), + Some(window(60, reset + time::Duration::days(1))), + None, + ); + assert_eq!(usage.min_remaining_percent(now), Some(20)); + } + + #[test] + fn has_stale_quota_cache_when_reset_time_passed() { + let now = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(2); + let reset = now - time::Duration::hours(1); + let usage = usage_view(Some(window(0, reset)), None, None); + assert!(usage.has_stale_quota_cache(now)); + } +} diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 0000000..a2d95a1 --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,93 @@ +use std::path::Path; + +use anyhow::{Context, Result}; + +pub fn restrict_store_dir(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .with_context(|| format!("failed to protect {}", path.display()))?; + } + + #[cfg(windows)] + { + restrict_windows_path(path, true)?; + } + + #[cfg(not(any(unix, windows)))] + { + let _ = path; + } + + Ok(()) +} + +pub fn restrict_store_file(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("failed to protect {}", path.display()))?; + } + + #[cfg(windows)] + { + restrict_windows_path(path, false)?; + } + + #[cfg(not(any(unix, windows)))] + { + let _ = path; + } + + Ok(()) +} + +#[cfg(windows)] +fn restrict_windows_path(path: &Path, is_dir: bool) -> Result<()> { + let username = std::env::var("USERNAME").context("USERNAME is not set")?; + let grant = if is_dir { + format!("{username}:(OI)(CI)F") + } else { + format!("{username}:F") + }; + let output = std::process::Command::new("icacls") + .arg(path.as_os_str()) + .args(["/inheritance:r", "/grant:r", &grant]) + .output() + .with_context(|| format!("failed to run icacls for {}", path.display()))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(anyhow::anyhow!( + "icacls failed for {}: {}", + path.display(), + stderr.trim() + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn restrict_store_dir_and_file_round_trip_on_current_platform() -> Result<()> { + let temp = tempdir()?; + let store_dir = temp.path().join("snapshots"); + std::fs::create_dir_all(&store_dir)?; + restrict_store_dir(&store_dir)?; + + let file = store_dir.join("secret.snapshot"); + std::fs::write(&file, b"secret")?; + restrict_store_file(&file)?; + + let metadata = std::fs::metadata(&file)?; + assert!(metadata.is_file()); + Ok(()) + } +} diff --git a/src/process.rs b/src/process.rs index 8a5c98a..8c715fd 100644 --- a/src/process.rs +++ b/src/process.rs @@ -1,3 +1,6 @@ +use std::sync::Mutex; +use std::time::{Duration, Instant}; + use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System, UpdateKind}; use crate::model::RunningCodexProcess; @@ -5,8 +8,42 @@ use crate::model::RunningCodexProcess; const SUMMARY_LIMIT: usize = 72; const EXECUTABLE_WIDTH: usize = 12; const ROLE_WIDTH: usize = 14; +/// Short TTL so tray title/tooltip/menu + dashboard polls share one OS process walk. +const PROCESS_SCAN_CACHE_TTL: Duration = Duration::from_millis(2500); -pub fn detect_running_codex_processes() -> Vec { +#[derive(Clone, Default)] +pub struct ProcessScanSnapshot { + pub codex: Vec, + pub cursor: Vec, + pub claude: Vec, +} + +struct ProcessScanCache { + at: Instant, + snapshot: ProcessScanSnapshot, +} + +static PROCESS_SCAN_CACHE: Mutex> = Mutex::new(None); + +/// One sysinfo walk classifying Codex / Cursor / Claude processes. +pub fn detect_all_processes() -> ProcessScanSnapshot { + if let Ok(guard) = PROCESS_SCAN_CACHE.lock() + && let Some(cache) = guard.as_ref() + && cache.at.elapsed() < PROCESS_SCAN_CACHE_TTL + { + return cache.snapshot.clone(); + } + let snapshot = scan_all_processes_uncached(); + if let Ok(mut guard) = PROCESS_SCAN_CACHE.lock() { + *guard = Some(ProcessScanCache { + at: Instant::now(), + snapshot: snapshot.clone(), + }); + } + snapshot +} + +fn scan_all_processes_uncached() -> ProcessScanSnapshot { let mut system = System::new(); system.refresh_processes_specifics( ProcessesToUpdate::All, @@ -17,33 +54,120 @@ pub fn detect_running_codex_processes() -> Vec { .without_tasks(), ); let current_pid = std::process::id(); - let mut processes = system - .processes() - .iter() - .filter_map(|(pid, process)| { - if pid.as_u32() == current_pid { - return None; - } - let name = process.name().to_string_lossy().to_ascii_lowercase(); - let command = process - .cmd() + let mut codex = Vec::new(); + let mut cursor = Vec::new(); + let mut claude = Vec::new(); + + for (pid, process) in system.processes() { + if pid.as_u32() == current_pid { + continue; + } + let name = process.name().to_string_lossy().to_ascii_lowercase(); + let command = process + .cmd() + .iter() + .map(|item| item.to_string_lossy().into_owned()) + .collect::>(); + + if matches_codex_process(&name, &command) { + codex.push(format_process(*pid, &name, &command)); + continue; + } + if name.contains("cursor") + || command .iter() - .map(|item| item.to_string_lossy().into_owned()) - .collect::>(); - if matches_codex_process(&name, &command) { - Some(format_process(*pid, &name, &command)) - } else { - None - } - }) - .collect::>(); - processes.sort_by(|left, right| { + .any(|arg| arg.to_ascii_lowercase().contains("cursor")) + { + cursor.push(RunningCodexProcess { + pid: pid.as_u32(), + executable: name.clone(), + role: "editor".to_owned(), + summary: Some(command.join(" ")), + }); + continue; + } + if name.contains("claude") + || command + .iter() + .any(|arg| arg.to_ascii_lowercase().contains("claude")) + { + claude.push(RunningCodexProcess { + pid: pid.as_u32(), + executable: name, + role: "cli-agent".to_owned(), + summary: Some(command.join(" ")), + }); + } + } + + codex.sort_by(|left, right| { left.executable .cmp(&right.executable) .then_with(|| left.role.cmp(&right.role)) .then_with(|| left.pid.cmp(&right.pid)) }); - processes + cursor.sort_by_key(|p| p.pid); + claude.sort_by_key(|p| p.pid); + + ProcessScanSnapshot { + codex, + cursor, + claude, + } +} + +#[cfg(target_os = "macos")] +const MACOS_CHATGPT_CLI_PATH: &str = "/Applications/ChatGPT.app/Contents/Resources/codex"; +#[cfg(target_os = "macos")] +const MACOS_CODEX_CLI_PATH: &str = "/Applications/Codex.app/Contents/Resources/codex"; + +/// Resolve the Codex CLI binary. GUI-launched processes often lack shell aliases on PATH. +pub fn codex_cli_path() -> std::path::PathBuf { + #[cfg(target_os = "macos")] + { + let chatgpt_path = std::path::PathBuf::from(MACOS_CHATGPT_CLI_PATH); + if chatgpt_path.is_file() { + return chatgpt_path; + } + let path = std::path::PathBuf::from(MACOS_CODEX_CLI_PATH); + if path.is_file() { + return path; + } + } + std::path::PathBuf::from("codex") +} + +pub fn detect_running_codex_processes() -> Vec { + detect_all_processes().codex +} + +/// IDE/extension `codex app-server` processes do not hold live auth and should not +/// block account switching. +pub fn is_switch_blocking_process(process: &RunningCodexProcess) -> bool { + process.role != "app-server" +} + +pub fn detect_switch_blocking_codex_processes() -> Vec { + detect_running_codex_processes() + .into_iter() + .filter(is_switch_blocking_process) + .collect() +} + +pub fn detect_running_cursor_processes() -> Vec { + detect_all_processes().cursor +} + +pub fn detect_switch_blocking_cursor_processes() -> Vec { + detect_running_cursor_processes() +} + +pub fn detect_running_claude_processes() -> Vec { + detect_all_processes().claude +} + +pub fn detect_switch_blocking_claude_processes() -> Vec { + detect_running_claude_processes() } pub fn format_process_table(processes: &[RunningCodexProcess]) -> Vec { @@ -72,14 +196,19 @@ pub fn format_process_table(processes: &[RunningCodexProcess]) -> Vec { } fn matches_codex_process(name: &str, command: &[String]) -> bool { - if matches!(name, "codex" | "codex.exe") { + if matches!(name, "codex" | "codex.exe" | "chatgpt" | "chatgpt.exe") { return true; } command .iter() .filter_map(|token| path_file_name(token)) .map(|token| token.to_ascii_lowercase()) - .any(|token| matches!(token.as_str(), "codex" | "codex.exe" | "codex.js")) + .any(|token| { + matches!( + token.as_str(), + "codex" | "codex.exe" | "codex.js" | "chatgpt" | "chatgpt.exe" | "chatgpt.js" + ) + }) } fn format_process(pid: Pid, name: &str, command: &[String]) -> RunningCodexProcess { @@ -240,14 +369,393 @@ fn truncate_cell(value: &str, width: usize) -> String { } } +#[cfg(target_os = "macos")] +const MACOS_CHATGPT_MAIN_PROCESS_PATTERN: &str = "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT"; +#[cfg(target_os = "macos")] +const MACOS_CODEX_MAIN_PROCESS_PATTERN: &str = "/Applications/Codex.app/Contents/MacOS/Codex"; + +/// Codex Desktop OAuth callback port (browser redirects here after auth.openai.com). +pub const CODEX_OAUTH_CALLBACK_PORT: u16 = 1455; + +/// Gracefully stop the Codex desktop app before swapping auth snapshots. +pub fn quit_running_codex_app() { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("osascript") + .args(["-e", "quit app \"ChatGPT\""]) + .output(); + let _ = std::process::Command::new("osascript") + .args(["-e", "quit app \"Codex\""]) + .output(); + let _ = std::process::Command::new("pkill") + .args(["-f", MACOS_CHATGPT_MAIN_PROCESS_PATTERN]) + .output(); + let _ = std::process::Command::new("pkill") + .args(["-f", MACOS_CODEX_MAIN_PROCESS_PATTERN]) + .output(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("taskkill") + .args(["/f", "/im", "ChatGPT.exe"]) + .output(); + let _ = std::process::Command::new("taskkill") + .args(["/f", "/im", "Codex.exe"]) + .output(); + std::thread::sleep(std::time::Duration::from_millis(500)); + } +} + +/// Quit Codex/ChatGPT and wait until main processes are gone (OAuth needs a clean restart). +pub fn quit_and_wait_for_codex_app() { + quit_running_codex_app(); + let _ = wait_for_codex_processes_to_exit_timeout(std::time::Duration::from_secs(12)); + // Brief settle so the previous instance releases :1455 before relaunch. + std::thread::sleep(std::time::Duration::from_millis(400)); +} + +/// True when something is accepting TCP connections on the Codex OAuth callback port. +pub fn codex_oauth_callback_listening() -> bool { + std::net::TcpStream::connect_timeout( + &std::net::SocketAddr::from(([127, 0, 0, 1], CODEX_OAUTH_CALLBACK_PORT)), + std::time::Duration::from_millis(150), + ) + .is_ok() +} + +/// Wait until Codex Desktop binds `:1455` for the OAuth redirect (or timeout). +pub fn wait_for_codex_oauth_callback(timeout: std::time::Duration) -> bool { + let started = std::time::Instant::now(); + while started.elapsed() < timeout { + if codex_oauth_callback_listening() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(250)); + } + codex_oauth_callback_listening() +} + +/// Wait until nothing is listening on the OAuth callback port. +pub fn wait_for_codex_oauth_port_free(timeout: std::time::Duration) -> bool { + let started = std::time::Instant::now(); + while started.elapsed() < timeout { + if !codex_oauth_callback_listening() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + !codex_oauth_callback_listening() +} + +/// Best-effort free of `:1455` (Desktop/CLI leftovers hold this and blank OAuth pages). +pub fn free_codex_oauth_port() { + quit_and_wait_for_codex_app(); + force_quit_switch_blocking_codex_processes(); + #[cfg(unix)] + { + // Kill any remaining listener on the OAuth callback port. + if let Ok(output) = std::process::Command::new("lsof") + .args([ + "-nP", + &format!("-iTCP:{CODEX_OAUTH_CALLBACK_PORT}"), + "-sTCP:LISTEN", + "-t", + ]) + .output() + { + let pids = String::from_utf8_lossy(&output.stdout); + for pid in pids.split_whitespace() { + let _ = std::process::Command::new("kill") + .args(["-TERM", pid]) + .output(); + } + std::thread::sleep(std::time::Duration::from_millis(300)); + for pid in pids.split_whitespace() { + let _ = std::process::Command::new("kill") + .args(["-KILL", pid]) + .output(); + } + } + } + let _ = wait_for_codex_oauth_port_free(std::time::Duration::from_secs(5)); +} + +#[derive(Debug, Clone)] +pub struct InteractiveLoginLaunch { + pub oauth_port_ready: bool, + pub method: &'static str, + pub detail: String, +} + +/// Start interactive Codex login the reliable way: +/// 1) free `:1455` +/// 2) run `codex login` (binds OAuth server **then** opens the browser) +/// +/// Opening ChatGPT Desktop with wiped `auth.json` uses a streamlined web flow that +/// often lands on a blank / stuck `auth.openai.com/oauth/authorize` page. +pub fn start_codex_cli_interactive_login(codex_home: &std::path::Path) -> InteractiveLoginLaunch { + free_codex_oauth_port(); + + let cli = codex_cli_path(); + let spawn = std::process::Command::new(&cli) + .arg("login") + .env("CODEX_HOME", codex_home) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + + match spawn { + Ok(_child) => { + // CLI owns the browser tab; wait until its callback server is up. + let ready = wait_for_codex_oauth_callback(std::time::Duration::from_secs(20)); + InteractiveLoginLaunch { + oauth_port_ready: ready, + method: "codex-login", + detail: if ready { + "Browser login started via `codex login`. Close any blank tabs and finish in the new browser window, then return here." + .to_owned() + } else { + format!( + "Started `{} login`, but port {CODEX_OAUTH_CALLBACK_PORT} is not listening yet. Wait a few seconds; if the page is blank, close it and run Add account again.", + cli.display() + ) + }, + } + } + Err(error) => { + // Last resort: Desktop app (historically flaky blank OAuth pages). + launch_codex_app(); + let ready = wait_for_codex_oauth_callback(std::time::Duration::from_secs(12)); + InteractiveLoginLaunch { + oauth_port_ready: ready, + method: "desktop-fallback", + detail: format!( + "Could not run `codex login` ({error}). Opened ChatGPT/Codex Desktop instead — if the browser page is blank, install/fix Codex CLI and retry." + ), + } + } + } +} + +/// Prefers CLI login over Desktop relaunch. Pass the live Codex home (`~/.codex`). +pub fn relaunch_codex_for_interactive_login(codex_home: &std::path::Path) -> InteractiveLoginLaunch { + start_codex_cli_interactive_login(codex_home) +} + +/// Force-stop Codex processes that can hold live auth open during an urgent switch. +pub fn force_quit_switch_blocking_codex_processes() { + let processes = detect_switch_blocking_codex_processes(); + if processes.is_empty() { + return; + } + + #[cfg(unix)] + { + for process in &processes { + let _ = std::process::Command::new("kill") + .args(["-TERM", &process.pid.to_string()]) + .output(); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + for process in detect_switch_blocking_codex_processes() { + let _ = std::process::Command::new("kill") + .args(["-KILL", &process.pid.to_string()]) + .output(); + } + } + + #[cfg(windows)] + { + for process in &processes { + let _ = std::process::Command::new("taskkill") + .args(["/f", "/pid", &process.pid.to_string()]) + .output(); + } + } + + std::thread::sleep(std::time::Duration::from_millis(500)); +} + +pub fn force_quit_processes(processes: &[RunningCodexProcess]) { + if processes.is_empty() { + return; + } + #[cfg(unix)] + { + for process in processes { + let _ = std::process::Command::new("kill") + .args(["-TERM", &process.pid.to_string()]) + .output(); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + for process in processes { + let _ = std::process::Command::new("kill") + .args(["-KILL", &process.pid.to_string()]) + .output(); + } + } + #[cfg(windows)] + { + for process in processes { + let _ = std::process::Command::new("taskkill") + .args(["/f", "/pid", &process.pid.to_string()]) + .output(); + } + } + std::thread::sleep(std::time::Duration::from_millis(500)); +} + +pub fn force_quit_all_switch_blocking_processes() { + let mut processes = detect_switch_blocking_codex_processes(); + processes.extend(detect_switch_blocking_cursor_processes()); + processes.extend(detect_switch_blocking_claude_processes()); + processes.sort_by_key(|w| w.pid); + processes.dedup_by_key(|w| w.pid); + force_quit_processes(&processes); +} + +pub const SWITCH_WAIT_POLL_MS: u64 = 2_000; + +/// Block until no switch-blocking Codex processes remain (polls every [`SWITCH_WAIT_POLL_MS`]). +pub fn wait_for_codex_processes_to_exit() { + while !detect_switch_blocking_codex_processes().is_empty() { + std::thread::sleep(std::time::Duration::from_millis(SWITCH_WAIT_POLL_MS)); + } +} + +/// Wait up to `timeout` for switch-blocking Codex processes to exit. Returns `true` when none remain. +pub fn wait_for_codex_processes_to_exit_timeout(timeout: std::time::Duration) -> bool { + if timeout.is_zero() { + return detect_switch_blocking_codex_processes().is_empty(); + } + let started = std::time::Instant::now(); + while started.elapsed() < timeout { + if detect_switch_blocking_codex_processes().is_empty() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(SWITCH_WAIT_POLL_MS)); + } + detect_switch_blocking_codex_processes().is_empty() +} + +/// Ask how to proceed when Codex is running. Defaults to waiting when the dialog fails. +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub fn prompt_switch_when_running() -> crate::model::SwitchWhenRunning { + use crate::model::SwitchWhenRunning; + + #[cfg(target_os = "macos")] + { + let script = r#"tell application "System Events" to display dialog "Codex is running with active tasks. + +Switch now quits Codex and switches immediately. +Wait and switch defers until Codex finishes." buttons {"Cancel", "Wait and Switch", "Switch Now"} default button "Wait and Switch" with icon caution"#; + let output = std::process::Command::new("osascript") + .args(["-e", script]) + .output(); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + if stdout.contains("button returned:Switch Now") { + return SwitchWhenRunning::SwitchNow; + } + if stdout.contains("button returned:Wait and Switch") { + return SwitchWhenRunning::WaitAndSwitch; + } + return SwitchWhenRunning::Cancel; + } + SwitchWhenRunning::WaitAndSwitch + } + + #[cfg(target_os = "windows")] + { + let script = r#"Add-Type -AssemblyName Microsoft.VisualBasic +$result = [Microsoft.VisualBasic.Interaction]::MsgBox( + 'Codex is running with active tasks.' + [Environment]::NewLine + [Environment]::NewLine + + 'Yes = Switch now (quit Codex immediately)' + [Environment]::NewLine + + 'No = Wait and switch (defer until Codex finishes)' + [Environment]::NewLine + + 'Cancel = Cancel', + [System.Windows.Forms.MessageBoxButtons]::YesNoCancel, + 'Codex Account Switcher' +) +switch ($result) { + 'Yes' { 'SwitchNow' } + 'No' { 'WaitAndSwitch' } + default { 'Cancel' } +}"#; + let output = std::process::Command::new("powershell") + .args(["-Command", script]) + .output(); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned(); + return match stdout.as_str() { + "SwitchNow" => SwitchWhenRunning::SwitchNow, + "WaitAndSwitch" => SwitchWhenRunning::WaitAndSwitch, + _ => SwitchWhenRunning::Cancel, + }; + } + SwitchWhenRunning::WaitAndSwitch + } +} + +/// Relaunch the Codex/ChatGPT desktop app after auth has been restored. +/// +/// Launches **one** preferred app only. Starting both ChatGPT and Codex races +/// two OAuth listeners on `localhost:1455` and freezes sign-in in the browser. +pub fn launch_codex_app() { + #[cfg(target_os = "macos")] + { + // Prefer Codex Desktop when installed (matches originator=Codex Desktop OAuth). + // Fall back to ChatGPT.app (bundled Codex) when Codex.app is absent. + if std::path::Path::new("/Applications/Codex.app").exists() { + let _ = std::process::Command::new("open") + .args(["-a", "Codex"]) + .spawn(); + } else if std::path::Path::new("/Applications/ChatGPT.app").exists() { + let _ = std::process::Command::new("open") + .args(["-a", "ChatGPT"]) + .spawn(); + } else { + let _ = std::process::Command::new("open") + .args(["-a", "Codex"]) + .spawn(); + } + } + #[cfg(target_os = "windows")] + { + // Single app only — starting both races two OAuth listeners on :1455. + let _ = std::process::Command::new("cmd") + .args(["/c", "start", "", "ChatGPT"]) + .spawn(); + } +} + #[cfg(test)] mod tests { use super::{ classify_process, clean_token, detect_flag_value, format_process_table, - matches_codex_process, truncate_summary, + is_switch_blocking_process, matches_codex_process, truncate_summary, }; use crate::model::RunningCodexProcess; + #[test] + fn app_server_processes_do_not_block_account_switch() { + let app_server = RunningCodexProcess { + pid: 1, + executable: "codex".to_owned(), + role: "app-server".to_owned(), + summary: None, + }; + let renderer = RunningCodexProcess { + pid: 2, + executable: "codex (renderer)".to_owned(), + role: "renderer".to_owned(), + summary: None, + }; + assert!(!is_switch_blocking_process(&app_server)); + assert!(is_switch_blocking_process(&renderer)); + } + #[test] fn matches_codex_process_detects_wrapped_cli() { assert!(matches_codex_process( diff --git a/src/quota_scoring.rs b/src/quota_scoring.rs new file mode 100644 index 0000000..25d83a5 --- /dev/null +++ b/src/quota_scoring.rs @@ -0,0 +1,381 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::model::{AccountUsageView, UsageWindowView}; +use crate::usage::{usage_error_indicates_rate_limit, usage_error_requires_login}; + +const WEEKLY_WINDOW_SECONDS: f64 = 168.0 * 3600.0; + +#[derive(Clone, Debug, PartialEq)] +pub struct AccountQuotaScore { + pub account_id: Uuid, + pub email: String, + pub label: Option, + pub score: f64, + pub weekly_used_percent: Option, + pub five_hour_used_percent: Option, + pub eligible: bool, + pub detail: Option, +} + +pub fn score_usage(usage: &AccountUsageView, now: OffsetDateTime) -> f64 { + let weekly_used = usage + .weekly + .as_ref() + .map(|window| effective_used_percent(window, now)) + .unwrap_or(0); + let five_hour_used = usage + .five_hour + .as_ref() + .map(|window| effective_used_percent(window, now)); + + if weekly_used >= 100 { + return 500.0 + f64::from(weekly_used); + } + + let weekly_reset = usage + .weekly + .as_ref() + .map(|window| window.reset_at) + .unwrap_or(now); + let weekly_remaining_secs = (weekly_reset - now).whole_seconds().max(0) as f64; + let weekly_elapsed_secs = (WEEKLY_WINDOW_SECONDS - weekly_remaining_secs).max(0.0); + let weekly_budget = (weekly_elapsed_secs / WEEKLY_WINDOW_SECONDS) * 100.0; + let weekly_score = f64::from(weekly_used) - weekly_budget; + + let five_hour_penalty = match five_hour_used { + Some(used) if used >= 100 => 200.0, + Some(used) if used >= 90 => 50.0, + Some(used) if used >= 75 => 10.0, + _ => 0.0, + }; + + weekly_score + five_hour_penalty +} + +pub fn score_saved_account( + account_id: Uuid, + email: &str, + label: Option<&str>, + cached_usage: Option<&AccountUsageView>, + cached_usage_error: Option<&str>, + now: OffsetDateTime, +) -> AccountQuotaScore { + if cached_usage_error.is_some_and(usage_error_requires_login) { + return AccountQuotaScore { + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + score: f64::INFINITY, + weekly_used_percent: None, + five_hour_used_percent: None, + eligible: false, + detail: Some("login required".to_owned()), + }; + } + + if cached_usage_error.is_some_and(usage_error_indicates_rate_limit) { + return AccountQuotaScore { + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + score: f64::INFINITY, + weekly_used_percent: None, + five_hour_used_percent: None, + eligible: false, + detail: Some("rate limited".to_owned()), + }; + } + + let Some(usage) = cached_usage else { + return AccountQuotaScore { + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + score: 25.0, + weekly_used_percent: None, + five_hour_used_percent: None, + eligible: true, + detail: Some("no cached usage".to_owned()), + }; + }; + + if usage.is_fully_exhausted(now) { + return AccountQuotaScore { + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + score: f64::INFINITY, + weekly_used_percent: usage + .weekly + .as_ref() + .map(|w| effective_used_percent(w, now)), + five_hour_used_percent: usage + .five_hour + .as_ref() + .map(|w| effective_used_percent(w, now)), + eligible: false, + detail: Some("out of quota".to_owned()), + }; + } + + AccountQuotaScore { + account_id, + email: email.to_owned(), + label: label.map(str::to_owned), + score: score_usage(usage, now), + weekly_used_percent: usage + .weekly + .as_ref() + .map(|w| effective_used_percent(w, now)), + five_hour_used_percent: usage + .five_hour + .as_ref() + .map(|w| effective_used_percent(w, now)), + eligible: true, + detail: None, + } +} + +pub fn pick_best_account_id(scores: &[AccountQuotaScore]) -> Option { + scores + .iter() + .filter(|entry| entry.eligible) + .min_by(|left, right| { + left.score + .partial_cmp(&right.score) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|entry| entry.account_id) +} + +/// Pick the best switch target, excluding the current account. +pub fn pick_switch_target(scores: &[AccountQuotaScore], exclude_id: Uuid) -> Option { + scores + .iter() + .filter(|entry| entry.eligible && entry.account_id != exclude_id) + .min_by(|left, right| { + left.score + .partial_cmp(&right.score) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|entry| entry.account_id) +} + +pub fn score_saved_account_for_auto_switch( + account_id: Uuid, + email: &str, + label: Option<&str>, + cached_usage: Option<&AccountUsageView>, + cached_usage_error: Option<&str>, + now: OffsetDateTime, +) -> AccountQuotaScore { + let mut score = score_saved_account( + account_id, + email, + label, + cached_usage, + cached_usage_error, + now, + ); + if cached_usage.is_none() && score.eligible { + score.eligible = false; + score.score = f64::INFINITY; + score.detail = Some("quota unknown — refresh required".to_owned()); + } + if let Some(usage) = cached_usage + && usage.has_stale_quota_cache(now) + && score.eligible + { + score.eligible = false; + score.score = f64::INFINITY; + score.detail = Some("quota stale — refresh required".to_owned()); + } + score +} + +fn effective_used_percent(window: &UsageWindowView, now: OffsetDateTime) -> u8 { + if window.reset_at <= now { + 0 + } else { + window.used_percent + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{AccountUsageView, UsageSource, UsageWindowView}; + + fn window(used_percent: u8, reset_at: OffsetDateTime) -> UsageWindowView { + UsageWindowView { + used_percent, + remaining_percent: 100 - used_percent, + reset_at, + } + } + + fn usage( + weekly: Option, + five_hour: Option, + ) -> AccountUsageView { + AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour, + weekly, + credits: None, + } + } + + #[test] + fn lower_score_wins_when_under_weekly_budget() { + let now = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(84); + let reset = now + time::Duration::hours(84); + let ahead = score_usage(&usage(Some(window(80, reset)), None), now); + let behind = score_usage(&usage(Some(window(40, reset)), None), now); + assert!(behind < ahead); + } + + #[test] + fn five_hour_penalty_prefers_clear_short_window() { + let now = OffsetDateTime::UNIX_EPOCH + time::Duration::hours(24); + let weekly_reset = now + time::Duration::hours(120); + let five_hour_reset = now + time::Duration::hours(2); + let warm = score_usage( + &usage( + Some(window(30, weekly_reset)), + Some(window(80, five_hour_reset)), + ), + now, + ); + let clear = score_usage( + &usage( + Some(window(30, weekly_reset)), + Some(window(20, five_hour_reset)), + ), + now, + ); + assert!(clear < warm); + } + + #[test] + fn pick_best_skips_ineligible_accounts() { + let eligible = AccountQuotaScore { + account_id: Uuid::new_v4(), + email: "good@example.com".to_owned(), + label: None, + score: 5.0, + weekly_used_percent: Some(20), + five_hour_used_percent: Some(10), + eligible: true, + detail: None, + }; + let blocked = AccountQuotaScore { + account_id: Uuid::new_v4(), + email: "bad@example.com".to_owned(), + label: None, + score: 0.0, + weekly_used_percent: Some(100), + five_hour_used_percent: Some(100), + eligible: false, + detail: Some("out of quota".to_owned()), + }; + assert_eq!( + pick_best_account_id(&[blocked, eligible.clone()]), + Some(eligible.account_id) + ); + } + + #[test] + fn pick_switch_target_excludes_current_account() { + let current = AccountQuotaScore { + account_id: Uuid::new_v4(), + email: "current@example.com".to_owned(), + label: None, + score: 0.0, + weekly_used_percent: Some(10), + five_hour_used_percent: Some(5), + eligible: true, + detail: None, + }; + let better = AccountQuotaScore { + account_id: Uuid::new_v4(), + email: "better@example.com".to_owned(), + label: None, + score: 5.0, + weekly_used_percent: Some(20), + five_hour_used_percent: Some(10), + eligible: true, + detail: None, + }; + assert_eq!( + pick_switch_target(&[current.clone(), better.clone()], current.account_id), + Some(better.account_id) + ); + } + + #[test] + fn auto_switch_marks_unknown_quota_ineligible() { + let score = score_saved_account_for_auto_switch( + Uuid::new_v4(), + "user@example.com", + None, + None, + None, + OffsetDateTime::UNIX_EPOCH, + ); + assert!(!score.eligible); + assert!( + score + .detail + .as_deref() + .is_some_and(|d| d.contains("unknown")) + ); + } + + #[test] + fn auto_switch_marks_rate_limited_account_ineligible() { + let score = score_saved_account_for_auto_switch( + Uuid::new_v4(), + "user@example.com", + None, + Some(&usage( + Some(window( + 50, + OffsetDateTime::UNIX_EPOCH + time::Duration::hours(1), + )), + None, + )), + Some("rate limit exceeded"), + OffsetDateTime::UNIX_EPOCH, + ); + assert!(!score.eligible); + assert_eq!(score.detail.as_deref(), Some("rate limited")); + } + + #[test] + fn pick_switch_target_returns_none_when_all_accounts_exhausted() { + let now = OffsetDateTime::UNIX_EPOCH; + let reset = now + time::Duration::hours(1); + let accounts = [ + score_saved_account_for_auto_switch( + Uuid::new_v4(), + "a@example.com", + None, + Some(&usage(Some(window(100, reset)), None)), + None, + now, + ), + score_saved_account_for_auto_switch( + Uuid::new_v4(), + "b@example.com", + None, + Some(&usage(Some(window(100, reset)), None)), + None, + now, + ), + ]; + assert!(pick_switch_target(&accounts, accounts[0].account_id).is_none()); + } +} diff --git a/src/repository.rs b/src/repository.rs index 882953f..dc91043 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -62,17 +62,31 @@ where environment: &EnvironmentKind, identity: &DisplayIdentity, snapshot: &SnapshotBlob, + ) -> Result<(SavedAccountMetadata, bool)> { + self.save_snapshot_with_app(environment, identity, snapshot, None) + } + + pub fn save_snapshot_with_app( + &self, + environment: &EnvironmentKind, + identity: &DisplayIdentity, + snapshot: &SnapshotBlob, + target_app: Option, ) -> Result<(SavedAccountMetadata, bool)> { let mut index = self.index_store.load_index()?; let now = OffsetDateTime::now_utc(); let encoded_snapshot = encode_snapshot(snapshot)?; let existing_index = index.accounts.iter().position(|account| { &account.environment == environment + && account.target_app.as_deref().unwrap_or("codex") + == target_app.as_deref().unwrap_or("codex") && DisplayIdentity { email: account.email.clone(), subject: account.subject.clone(), name: account.name.clone(), plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), } .matches(identity) }); @@ -83,6 +97,9 @@ where account.subject = identity.subject.clone(); account.name = identity.name.clone(); account.plan_label = identity.plan_label.clone(); + account.workspace_id = identity.workspace_id.clone(); + account.workspace_name = identity.workspace_name.clone(); + account.target_app = target_app; account.cached_usage_error = None; account.updated_at = now; (account.clone(), false) @@ -95,12 +112,17 @@ where subject: identity.subject.clone(), name: identity.name.clone(), plan_label: identity.plan_label.clone(), + workspace_id: identity.workspace_id.clone(), + workspace_name: identity.workspace_name.clone(), secret_key: format!("snapshot:{id}"), created_at: now, updated_at: now, last_activated_at: None, cached_usage: None, cached_usage_error: None, + label: None, + is_archived: false, + target_app, }; index.accounts.push(metadata.clone()); (metadata, true) @@ -153,6 +175,8 @@ where account.subject = identity.subject.clone(); account.name = identity.name.clone(); account.plan_label = identity.plan_label.clone(); + account.workspace_id = identity.workspace_id.clone(); + account.workspace_name = identity.workspace_name.clone(); account.cached_usage = usage; account.cached_usage_error = None; let metadata = account.clone(); @@ -218,6 +242,47 @@ where Ok(()) } + pub fn set_account_label( + &self, + environment: &EnvironmentKind, + account_id: Uuid, + label: Option, + ) -> Result { + let mut index = self.index_store.load_index()?; + let normalized_label = label + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()); + let account = index + .accounts + .iter_mut() + .find(|account| account.id == account_id && &account.environment == environment) + .ok_or_else(|| anyhow!("saved account {account_id} not found"))?; + account.label = normalized_label; + account.updated_at = OffsetDateTime::now_utc(); + let metadata = account.clone(); + self.index_store.save_index(&index)?; + Ok(metadata) + } + + pub fn set_account_archived( + &self, + environment: &EnvironmentKind, + account_id: Uuid, + archived: bool, + ) -> Result { + let mut index = self.index_store.load_index()?; + let account = index + .accounts + .iter_mut() + .find(|account| account.id == account_id && &account.environment == environment) + .ok_or_else(|| anyhow!("saved account {account_id} not found"))?; + account.is_archived = archived; + account.updated_at = OffsetDateTime::now_utc(); + let metadata = account.clone(); + self.index_store.save_index(&index)?; + Ok(metadata) + } + pub fn sync_activated_account( &self, environment: &EnvironmentKind, @@ -245,6 +310,8 @@ where subject: account.subject.clone(), name: account.name.clone(), plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), } .matches(identity) }) @@ -265,6 +332,8 @@ where account.subject = identity.subject.clone(); account.name = identity.name.clone(); account.plan_label = identity.plan_label.clone(); + account.workspace_id = identity.workspace_id.clone(); + account.workspace_name = identity.workspace_name.clone(); account.last_activated_at = Some(now); account.updated_at = now; let updated = account.clone(); @@ -305,6 +374,16 @@ mod tests { subject: Some(subject.to_owned()), name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + } + } + + fn workspace_identity(email: &str, subject: &str, workspace_id: &str) -> DisplayIdentity { + DisplayIdentity { + workspace_id: Some(workspace_id.to_owned()), + workspace_name: Some(format!("Workspace {workspace_id}")), + ..identity(email, subject) } } @@ -343,6 +422,38 @@ mod tests { assert_eq!(second.email, "person2@example.com"); } + #[test] + fn keeps_same_user_accounts_separate_by_workspace() { + let temp = tempdir().expect("tempdir"); + let repo = SnapshotRepository::new(temp.path(), MemorySecretStore::default()); + let env = EnvironmentKind::Macos; + let snapshot = SnapshotBlob { + schema_version: 1, + files: vec![], + }; + + let (first, first_created) = repo + .save_snapshot( + &env, + &workspace_identity("person@example.com", "sub-1", "acct-a"), + &snapshot, + ) + .expect("save first"); + let (second, second_created) = repo + .save_snapshot( + &env, + &workspace_identity("person@example.com", "sub-1", "acct-b"), + &snapshot, + ) + .expect("save second"); + + assert!(first_created); + assert!(second_created); + assert_ne!(first.id, second.id); + let accounts = repo.list_accounts(&env).expect("list"); + assert_eq!(accounts.len(), 2); + } + #[derive(Clone, Default)] struct FailingDeleteSecretStore { inner: MemorySecretStore, @@ -674,6 +785,8 @@ mod tests { subject: None, name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &snapshot, ) @@ -718,6 +831,8 @@ mod tests { subject: None, name: Some("Tester".to_owned()), plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }, &snapshot, ) @@ -887,4 +1002,40 @@ mod tests { assert_eq!(loaded.files[0].bytes_base64, "legacy-auth"); assert_eq!(loaded.files[1].bytes_base64, "legacy-cap"); } + + #[test] + fn archive_status_is_persisted_and_updated() { + let temp = tempdir().expect("tempdir"); + let repo = SnapshotRepository::new(temp.path(), MemorySecretStore::default()); + let env = EnvironmentKind::Windows; + let snapshot = SnapshotBlob { + schema_version: 1, + files: vec![], + }; + let (saved, _) = repo + .save_snapshot(&env, &identity("person@example.com", "sub-1"), &snapshot) + .expect("save"); + + // Default should be false + assert!(!saved.is_archived); + + // Update to true + let updated = repo + .set_account_archived(&env, saved.id, true) + .expect("set archived"); + assert!(updated.is_archived); + + // Verify read back + let read = repo + .get_account(&env, saved.id) + .expect("get") + .expect("account"); + assert!(read.is_archived); + + // Update back to false + let updated_false = repo + .set_account_archived(&env, saved.id, false) + .expect("set unarchived"); + assert!(!updated_false.is_archived); + } } diff --git a/src/secrets.rs b/src/secrets.rs index bb76881..e8bdd89 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -4,6 +4,7 @@ use std::time::SystemTime; use crate::file_store::{RecoveryFileKind, list_recovery_files, replace_file_with_recovery}; use crate::model::SnapshotBlob; +use crate::permissions::{restrict_store_dir, restrict_store_file}; use anyhow::{Context, Result, anyhow}; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; @@ -268,29 +269,24 @@ fn write_private_file(path: &Path, value: &[u8]) -> Result<()> { .with_context(|| format!("failed to write {}", path.display()))?; file.write_all(value) .with_context(|| format!("failed to write {}", path.display()))?; + set_private_file_permissions(path)?; Ok(()) } #[cfg(not(unix))] { fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))?; + set_private_file_permissions(path)?; Ok(()) } } fn set_private_dir_permissions(path: &Path) -> Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - - fs::set_permissions(path, fs::Permissions::from_mode(0o700)) - .with_context(|| format!("failed to protect {}", path.display()))?; - } - - #[cfg(not(unix))] - let _ = path; + restrict_store_dir(path) +} - Ok(()) +fn set_private_file_permissions(path: &Path) -> Result<()> { + restrict_store_file(path) } fn snapshot_payload_is_valid(value: &[u8]) -> bool { diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..fcfd552 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,543 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::thread; +use tiny_http::{Header, Method, Request, Response, Server}; +use uuid::Uuid; + +use crate::app::App; +use crate::model::{AccountView, RunningCodexProcess}; +use crate::secrets::SecretStore; + +const HTML_CONTENT: &str = include_str!("dashboard.html"); +const MENU_HTML_CONTENT: &str = include_str!("menu.html"); + +/// Concurrent workers for the local dashboard HTTP server. +fn http_worker_count() -> usize { + std::thread::available_parallelism() + .map(|n| n.get().clamp(2, 8)) + .unwrap_or(4) +} + +#[derive(Serialize)] +struct SettingsView { + show_quota_in_menu_bar: bool, + auto_switch_on_limit: bool, + disable_blocker_warnings: bool, + ui_language: String, + ui_language_resolved: String, +} + +#[derive(Serialize)] +struct FullStatusPayload { + environment: crate::model::EnvironmentKind, + active_codex_id: Option, + active_cursor_id: Option, + active_claude_id: Option, + live_codex: Option, + live_cursor: Option, + live_claude: Option, + /// True while a Codex add-account / workspace login session is in progress. + codex_add_account_pending: bool, + accounts: Vec, + process_warnings: Vec, + settings: SettingsView, + logs: Vec, +} + +#[derive(Deserialize)] +struct ActivateRequest { + id: String, +} + +#[derive(Deserialize)] +struct SaveRequest { + app: String, +} + +#[derive(Deserialize)] +struct AddAccountRequest { + /// `begin` | `finish` | `cancel` + action: String, + /// `codex` (default), `cursor`, or `claude` + app: Option, +} + +#[derive(Deserialize)] +struct ImportCookiesRequest { + provider: Option, + json: String, + label: Option, +} + +#[derive(Deserialize)] +struct ArchiveRequest { + id: Uuid, + archived: bool, +} + +#[derive(Deserialize)] +struct DeleteRequest { + id: Uuid, +} + +#[derive(Deserialize)] +struct SettingsRequest { + key: String, + value: serde_json::Value, +} + +pub fn start_dashboard_server(app: Arc>) -> Result +where + S: SecretStore + Send + Sync + 'static, +{ + let port = 5032; + let server = match Server::http(format!("127.0.0.1:{port}")) { + Ok(s) => s, + Err(_) => { + // Fallback to random port + Server::http("127.0.0.1:0") + .map_err(|e| anyhow::anyhow!("failed to bind HTTP server to any port: {e}"))? + } + }; + + let bound_port = match server.server_addr() { + tiny_http::ListenAddr::IP(addr) => addr.port(), + _ => 5032, + }; + + thread::spawn(move || { + serve(server, app); + }); + + Ok(bound_port) +} + +fn serve(server: Server, app: Arc>) +where + S: SecretStore + Send + Sync + 'static, +{ + let server = Arc::new(server); + let workers = http_worker_count(); + let mut joins = Vec::with_capacity(workers); + for i in 0..workers { + let server = Arc::clone(&server); + let app = Arc::clone(&app); + let handle = thread::Builder::new() + .name(format!("dashboard-http-{i}")) + .spawn(move || { + for request in server.incoming_requests() { + handle_request(request, &app); + } + }) + .expect("failed to spawn dashboard HTTP worker"); + joins.push(handle); + } + for handle in joins { + let _ = handle.join(); + } +} + +fn path_and_query(url: &str) -> (&str, &str) { + url.split_once('?').unwrap_or((url, "")) +} + +fn query_flag(query: &str, names: &[&str]) -> bool { + for part in query.split('&') { + let (key, value) = part.split_once('=').unwrap_or((part, "1")); + if !names.iter().any(|n| key.eq_ignore_ascii_case(n)) { + continue; + } + let v = value.trim(); + if v.is_empty() + || v == "1" + || v.eq_ignore_ascii_case("true") + || v.eq_ignore_ascii_case("yes") + { + return true; + } + } + false +} + +fn json_response(body: String) -> Response>> { + Response::from_string(body) + .with_header(Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).unwrap()) + .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap()) +} + +fn html_response(body: &'static str) -> Response>> { + Response::from_string(body) + .with_header( + Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap(), + ) + .with_header(Header::from_bytes(&b"Cache-Control"[..], &b"no-store"[..]).unwrap()) +} + +fn handle_request(mut request: Request, app: &App) +where + S: SecretStore + Send + Sync + 'static, +{ + let url = request.url().to_owned(); + let (path, query) = path_and_query(&url); + let method = request.method().clone(); + + let response = match (&method, path) { + (&Method::Get, "/") => html_response(HTML_CONTENT), + (&Method::Get, "/menu") => html_response(MENU_HTML_CONTENT), + (&Method::Get, "/api/status") => { + // Process scan is expensive — only when Overview asks (`?processes=1`). + let include_processes = query_flag(query, &["processes", "include_processes"]); + match get_full_status(app, include_processes) { + Ok(payload) => { + let json = serde_json::to_string(&payload).unwrap_or_else(|_| { + r#"{"success":false,"error":"encode failed"}"#.to_owned() + }); + json_response(json) + } + Err(error) => { + let err_json = + serde_json::json!({ "success": false, "error": format!("{error:#}") }) + .to_string(); + json_response(err_json).with_status_code(500) + } + } + } + (&Method::Post, "/api/activate") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + + let res = if let Ok(req) = serde_json::from_str::(&body) { + if req.id == "best" { + match app.pick_best_account(true, true) { + Ok(output) => { + if output.switched { + serde_json::json!({ "success": true }) + } else { + serde_json::json!({ "success": false, "error": "No better account found" }) + } + } + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else if let Ok(uuid) = Uuid::parse_str(&req.id) { + match app.activate(uuid) { + Ok(_) => serde_json::json!({ "success": true }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid account ID" }) + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + (&Method::Post, "/api/save") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + + let res = if let Ok(req) = serde_json::from_str::(&body) { + let save_res = match req.app.to_lowercase().as_str() { + "cursor" => app.save_cursor_current().map(|_| ()), + "claude" => app.save_claude_current().map(|_| ()), + _ => app.save_current().map(|_| ()), + }; + match save_res { + Ok(_) => serde_json::json!({ "success": true }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + (&Method::Post, "/api/add-account") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + let res = match serde_json::from_str::(&body) { + Ok(req) => handle_add_account(app, &req), + Err(_) => { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + } + }; + json_response(res.to_string()) + } + (&Method::Post, "/api/import-cookies") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + // Never log request body — may contain cookies/tokens. + let res = if let Ok(req) = serde_json::from_str::(&body) { + let provider = req.provider.as_deref().unwrap_or("codex"); + match app.import_cookies_json(provider, &req.json, req.label) { + Ok(output) => serde_json::json!({ + "success": true, + "account_id": output.account_id, + "email": output.email, + "created": output.created, + "warnings": output.warnings, + }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + (&Method::Post, "/api/archive") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + + let res = if let Ok(req) = serde_json::from_str::(&body) { + match app.set_account_archived(req.id, req.archived) { + Ok(_) => serde_json::json!({ "success": true }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + (&Method::Post, "/api/delete") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + + let res = if let Ok(req) = serde_json::from_str::(&body) { + match app.delete(req.id) { + Ok(_) => serde_json::json!({ "success": true }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + (&Method::Post, "/api/kill") => { + crate::process::force_quit_all_switch_blocking_processes(); + json_response(serde_json::json!({ "success": true }).to_string()) + } + (&Method::Post, "/api/settings") => { + let mut body = String::new(); + let _ = request.as_reader().read_to_string(&mut body); + + let res = if let Ok(req) = serde_json::from_str::(&body) { + let set_res = match req.key.as_str() { + "menu-bar" => req + .value + .as_bool() + .ok_or_else(|| anyhow::anyhow!("menu-bar expects a boolean")) + .and_then(|value| app.set_show_quota_in_menu_bar(value).map(|_| ())), + "auto-switch" => req + .value + .as_bool() + .ok_or_else(|| anyhow::anyhow!("auto-switch expects a boolean")) + .and_then(|value| app.set_auto_switch_on_limit(value).map(|_| ())), + "disable-warnings" => req + .value + .as_bool() + .ok_or_else(|| anyhow::anyhow!("disable-warnings expects a boolean")) + .and_then(|value| app.set_disable_blocker_warnings(value).map(|_| ())), + "ui-language" => req + .value + .as_str() + .ok_or_else(|| anyhow::anyhow!("ui-language expects a string")) + .and_then(|value| app.set_ui_language(value).map(|_| ())), + _ => Err(anyhow::anyhow!("unknown setting key")), + }; + match set_res { + Ok(_) => serde_json::json!({ "success": true }), + Err(e) => { + serde_json::json!({ "success": false, "error": format!("{e:#}") }) + } + } + } else { + serde_json::json!({ "success": false, "error": "Invalid request body" }) + }; + + json_response(res.to_string()) + } + _ => { + json_response(serde_json::json!({ "success": false, "error": "not found" }).to_string()) + .with_status_code(404) + } + }; + + let _ = request.respond(response); +} + +fn get_full_status(app: &App, include_processes: bool) -> Result +where + S: SecretStore, +{ + let status = app + .status_with_processes(include_processes) + .context("failed to fetch Codex status")?; + let cursor_status = app.cursor_status_with_processes(include_processes).ok(); + let claude_status = app.claude_status_with_processes(include_processes).ok(); + let list = app.list().context("failed to fetch accounts list")?; + + let mut process_warnings = Vec::new(); + if include_processes { + // Warm shared cache once, then merge — nested status_* already hit the same cache. + let processes = crate::process::detect_all_processes(); + process_warnings.extend(processes.codex); + process_warnings.extend(processes.cursor); + process_warnings.extend(processes.claude); + if process_warnings.is_empty() { + process_warnings.extend(status.process_warnings.clone()); + if let Some(c_status) = &cursor_status { + process_warnings.extend(c_status.process_warnings.clone()); + } + if let Some(cl_status) = &claude_status { + process_warnings.extend(cl_status.process_warnings.clone()); + } + } + process_warnings.sort_by_key(|w| w.pid); + process_warnings.dedup_by_key(|w| w.pid); + } + + // One settings disk read for all toggles. + let settings = crate::settings::load_settings(&app.env().app_data_dir).unwrap_or_default(); + let resolved_lang = crate::settings::resolve_ui_language(&settings.ui_language); + + let logs = crate::activity::read_activity_log(&app.env().app_data_dir).unwrap_or_default(); + + Ok(FullStatusPayload { + environment: status.environment, + active_codex_id: status.current_account_saved_id, + active_cursor_id: cursor_status + .as_ref() + .and_then(|s| s.current_account_saved_id), + active_claude_id: claude_status + .as_ref() + .and_then(|s| s.current_account_saved_id), + live_codex: status.current_account.clone(), + live_cursor: cursor_status.and_then(|s| s.current_account), + live_claude: claude_status.and_then(|s| s.current_account), + codex_add_account_pending: crate::codex::add_account_session_active(app.env()), + accounts: list.accounts, + process_warnings, + settings: SettingsView { + show_quota_in_menu_bar: settings.show_quota_in_menu_bar, + auto_switch_on_limit: settings.auto_switch_on_limit, + disable_blocker_warnings: settings.disable_blocker_warnings, + ui_language: settings.ui_language, + ui_language_resolved: resolved_lang.as_str().to_owned(), + }, + logs, + }) +} + +fn handle_add_account(app: &App, req: &AddAccountRequest) -> serde_json::Value +where + S: SecretStore + Send + Sync + 'static, +{ + let provider = req + .app + .as_deref() + .unwrap_or("codex") + .trim() + .to_ascii_lowercase(); + let action = req.action.trim().to_ascii_lowercase(); + + match (provider.as_str(), action.as_str()) { + ("cursor", "begin") | ("claude", "begin") => serde_json::json!({ + "success": true, + "mode": "save_live", + "app": provider, + "message": if provider == "cursor" { + "Sign in to Cursor with the new account, then use Save signed-in session." + } else { + "Sign in to Claude with the new account, then use Save signed-in session." + }, + }), + ("cursor", _) | ("claude", _) => serde_json::json!({ + "success": false, + "error": "Use Save signed-in session for Cursor/Claude (cookie import is not supported)." + }), + ("codex", "begin") => { + let begin = if crate::codex::add_account_session_active(app.env()) { + // Resume stuck session instead of erroring. + Ok(()) + } else { + app.begin_add_account_session() + }; + match begin { + Ok(()) => { + let codex_home = app.env().codex_root.clone(); + thread::spawn(move || { + let launch = + crate::process::relaunch_codex_for_interactive_login(&codex_home); + eprintln!( + "add-account login method={} oauth_ready={} detail={}", + launch.method, launch.oauth_port_ready, launch.detail + ); + }); + serde_json::json!({ + "success": true, + "mode": "codex_login", + "pending": true, + "message": "Browser login is started via `codex login` (not blank Desktop OAuth). Close any empty auth.openai.com tabs, finish the new browser window, then choose Finish." + }) + } + Err(e) => serde_json::json!({ "success": false, "error": format!("{e:#}") }), + } + } + ("codex", "finish") => match app.save_during_add_account_session() { + Ok(output) => { + // Quit → restore → relaunch is slow (osascript/pkill). Do it off the + // HTTP worker so other workers can keep serving /api/status. + let env = app.env().clone(); + thread::spawn(move || { + crate::process::quit_running_codex_app(); + if let Err(error) = crate::codex::restore_add_account_backup(&env) { + eprintln!("add-account restore failed: {error:#}"); + } + crate::process::launch_codex_app(); + }); + serde_json::json!({ + "success": true, + "pending": false, + "email": output.account.email, + "account_id": output.account.id, + "message": format!("Added {}", output.account.email), + }) + } + Err(e) => serde_json::json!({ "success": false, "error": format!("{e:#}") }), + }, + ("codex", "cancel") => match app.cancel_add_account_session() { + Ok(()) => { + thread::spawn(|| { + crate::process::quit_running_codex_app(); + crate::process::launch_codex_app(); + }); + serde_json::json!({ + "success": true, + "pending": false, + "message": "Cancelled. Original account restored." + }) + } + Err(e) => serde_json::json!({ "success": false, "error": format!("{e:#}") }), + }, + _ => serde_json::json!({ + "success": false, + "error": format!("Unknown add-account action '{action}' for '{provider}'") + }), + } +} diff --git a/src/settings.rs b/src/settings.rs index 2b404db..0da4d68 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -6,10 +6,129 @@ use serde::{Deserialize, Serialize}; use crate::file_store::replace_file_with_recovery; -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub const DEFAULT_NEAR_LIMIT_THRESHOLD_PERCENT: u8 = 5; +pub const DEFAULT_AUTO_SWITCH_POLL_SECONDS: u64 = 60; +pub const DEFAULT_USAGE_WINDOW_POLL_SECONDS: u64 = 300; + +fn default_near_limit_threshold_percent() -> u8 { + DEFAULT_NEAR_LIMIT_THRESHOLD_PERCENT +} + +fn default_auto_switch_poll_seconds() -> u64 { + DEFAULT_AUTO_SWITCH_POLL_SECONDS +} + +fn default_show_quota_in_menu_bar() -> bool { + true +} + +fn default_ui_language() -> String { + "auto".to_owned() +} + +/// Preference stored in settings: `"auto"`, `"en"`, or `"vi"`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResolvedUiLanguage { + En, + Vi, +} + +impl ResolvedUiLanguage { + pub fn as_str(self) -> &'static str { + match self { + Self::En => "en", + Self::Vi => "vi", + } + } +} + +pub fn normalize_ui_language(value: &str) -> Option<&'static str> { + match value.trim().to_ascii_lowercase().as_str() { + "auto" => Some("auto"), + "en" | "en-us" | "en_gb" | "english" => Some("en"), + "vi" | "vi-vn" | "vi_vn" | "vietnamese" => Some("vi"), + _ => None, + } +} + +pub fn resolve_ui_language(preference: &str) -> ResolvedUiLanguage { + match normalize_ui_language(preference).unwrap_or("auto") { + "en" => ResolvedUiLanguage::En, + "vi" => ResolvedUiLanguage::Vi, + _ => { + if system_prefers_vietnamese() { + ResolvedUiLanguage::Vi + } else { + ResolvedUiLanguage::En + } + } + } +} + +pub fn system_prefers_vietnamese() -> bool { + for key in ["LC_ALL", "LC_MESSAGES", "LANG"] { + if let Ok(value) = std::env::var(key) { + let lower = value.to_ascii_lowercase(); + if lower.starts_with("vi") || lower.contains(".vi") { + return true; + } + } + } + + #[cfg(target_os = "macos")] + { + if let Ok(output) = std::process::Command::new("defaults") + .args(["read", "-g", "AppleLanguages"]) + .output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); + if text.contains("\"vi") || text.contains("vi-") || text.contains("vi_") { + return true; + } + } + } + + false +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct AppSettings { #[serde(default)] pub auto_start_usage_windows: bool, + #[serde(default)] + pub auto_switch_on_limit: bool, + #[serde(default)] + pub launch_at_startup: bool, + /// Switch proactively when any quota window drops to this remaining % or below. + #[serde(default = "default_near_limit_threshold_percent")] + pub near_limit_threshold_percent: u8, + /// How often to poll usage when auto-switch is enabled. + #[serde(default = "default_auto_switch_poll_seconds")] + pub auto_switch_poll_seconds: u64, + /// Show quota percentage/status directly in the menu bar / tray icon. + #[serde(default = "default_show_quota_in_menu_bar")] + pub show_quota_in_menu_bar: bool, + #[serde(default)] + pub disable_blocker_warnings: bool, + /// UI language preference: `auto` (system), `en`, or `vi`. + #[serde(default = "default_ui_language")] + pub ui_language: String, +} + +impl Default for AppSettings { + fn default() -> Self { + Self { + auto_start_usage_windows: false, + auto_switch_on_limit: false, + launch_at_startup: false, + near_limit_threshold_percent: DEFAULT_NEAR_LIMIT_THRESHOLD_PERCENT, + auto_switch_poll_seconds: DEFAULT_AUTO_SWITCH_POLL_SECONDS, + show_quota_in_menu_bar: true, + disable_blocker_warnings: false, + ui_language: default_ui_language(), + } + } } pub fn load_settings(app_data_dir: &Path) -> Result { @@ -50,6 +169,18 @@ mod tests { let settings = load_settings(temp.path()).expect("load settings"); assert!(!settings.auto_start_usage_windows); + assert!(!settings.auto_switch_on_limit); + assert!(!settings.launch_at_startup); + assert_eq!( + settings.near_limit_threshold_percent, + DEFAULT_NEAR_LIMIT_THRESHOLD_PERCENT + ); + assert_eq!( + settings.auto_switch_poll_seconds, + DEFAULT_AUTO_SWITCH_POLL_SECONDS + ); + assert!(settings.show_quota_in_menu_bar); + assert_eq!(settings.ui_language, "auto"); } #[test] @@ -59,6 +190,11 @@ mod tests { temp.path(), &AppSettings { auto_start_usage_windows: true, + auto_switch_on_limit: true, + launch_at_startup: true, + show_quota_in_menu_bar: false, + ui_language: "vi".to_owned(), + ..AppSettings::default() }, ) .expect("save settings"); @@ -66,6 +202,17 @@ mod tests { let settings = load_settings(temp.path()).expect("load settings"); assert!(settings.auto_start_usage_windows); + assert!(settings.auto_switch_on_limit); + assert!(settings.launch_at_startup); + assert!(!settings.show_quota_in_menu_bar); + assert_eq!(settings.ui_language, "vi"); + } + + #[test] + fn resolve_ui_language_honors_explicit_preference() { + assert_eq!(resolve_ui_language("en"), ResolvedUiLanguage::En); + assert_eq!(resolve_ui_language("vi"), ResolvedUiLanguage::Vi); + assert_eq!(normalize_ui_language("VI-vn"), Some("vi")); } #[test] @@ -77,5 +224,16 @@ mod tests { let settings = load_settings(temp.path()).expect("load settings"); assert!(!settings.auto_start_usage_windows); + assert!(!settings.auto_switch_on_limit); + assert!(!settings.launch_at_startup); + assert_eq!( + settings.near_limit_threshold_percent, + DEFAULT_NEAR_LIMIT_THRESHOLD_PERCENT + ); + assert_eq!( + settings.auto_switch_poll_seconds, + DEFAULT_AUTO_SWITCH_POLL_SECONDS + ); + assert!(settings.show_quota_in_menu_bar); } } diff --git a/src/time_display.rs b/src/time_display.rs index b93f259..82a9de7 100644 --- a/src/time_display.rs +++ b/src/time_display.rs @@ -1,35 +1,122 @@ use time::{OffsetDateTime, UtcOffset}; pub(crate) fn format_local_reset_at(reset_at: OffsetDateTime) -> String { - let reset_at = UtcOffset::local_offset_at(reset_at) - .map(|offset| reset_at.to_offset(offset)) - .unwrap_or(reset_at); + let reset_at = to_local(reset_at); + format_datetime_vn(reset_at) +} - format_reset_at(reset_at) +fn to_local(reset_at: OffsetDateTime) -> OffsetDateTime { + UtcOffset::local_offset_at(reset_at) + .map(|offset| reset_at.to_offset(offset)) + .unwrap_or(reset_at) } -fn format_reset_at(reset_at: OffsetDateTime) -> String { +fn format_datetime_vn(reset_at: OffsetDateTime) -> String { format!( - "{} {:02}:{:02}", - reset_at.date(), + "{:02}/{:02}/{} {:02}:{:02}", + reset_at.day(), + reset_at.month() as u8, + reset_at.year(), reset_at.hour(), reset_at.minute() ) } +pub(crate) fn format_countdown(reset_at: OffsetDateTime, now: OffsetDateTime) -> String { + if reset_at <= now { + return "now".to_owned(); + } + let diff = reset_at - now; + let days = diff.whole_days(); + let hours = diff.whole_hours() % 24; + let minutes = diff.whole_minutes() % 60; + + if days > 0 { + if hours > 0 { + format!("{}d {}h", days, hours) + } else { + format!("{}d", days) + } + } else if hours > 0 { + if minutes > 0 { + format!("{}h {}m", hours, minutes) + } else { + format!("{}h", hours) + } + } else { + let mins = minutes.max(1); + format!("{}m", mins) + } +} + #[cfg(test)] mod tests { use time::{Date, Month, OffsetDateTime, Time, UtcOffset}; - use super::format_reset_at; + use super::{format_countdown, format_datetime_vn}; #[test] - fn format_reset_at_uses_supplied_offset_time() { + fn format_datetime_vn_uses_day_month_year_order() { let reset_at = OffsetDateTime::UNIX_EPOCH .replace_date(Date::from_calendar_date(2099, Month::May, 12).unwrap()) .replace_time(Time::from_hms(23, 30, 0).unwrap()) .to_offset(UtcOffset::from_hms(2, 0, 0).unwrap()); - assert_eq!(format_reset_at(reset_at), "2099-05-13 01:30"); + assert_eq!(format_datetime_vn(reset_at), "13/05/2099 01:30"); + } + + #[test] + fn test_format_countdown() { + let now = OffsetDateTime::UNIX_EPOCH; + + // negative or zero diff + assert_eq!(format_countdown(now, now), "now"); + assert_eq!( + format_countdown(now - time::Duration::minutes(5), now), + "now" + ); + + // minutes + assert_eq!( + format_countdown(now + time::Duration::seconds(30), now), + "1m" + ); + assert_eq!( + format_countdown(now + time::Duration::minutes(5), now), + "5m" + ); + assert_eq!( + format_countdown(now + time::Duration::minutes(59), now), + "59m" + ); + + // hours + assert_eq!(format_countdown(now + time::Duration::hours(2), now), "2h"); + assert_eq!( + format_countdown( + now + time::Duration::hours(2) + time::Duration::minutes(15), + now + ), + "2h 15m" + ); + + // days + assert_eq!(format_countdown(now + time::Duration::days(3), now), "3d"); + assert_eq!( + format_countdown( + now + time::Duration::days(3) + time::Duration::hours(5), + now + ), + "3d 5h" + ); + assert_eq!( + format_countdown( + now + time::Duration::days(3) + + time::Duration::hours(5) + + time::Duration::minutes(12), + now + ), + "3d 5h" + ); } } diff --git a/src/time_serde.rs b/src/time_serde.rs new file mode 100644 index 0000000..69894ae --- /dev/null +++ b/src/time_serde.rs @@ -0,0 +1,123 @@ +//! Flexible OffsetDateTime serde: emit RFC3339 for JS clients, accept RFC3339 +//! or the legacy `time` crate tuple form used in older on-disk metadata. + +use serde::{Deserialize, Deserializer, Serializer}; +use time::format_description::well_known::Rfc3339; +use time::{Date, OffsetDateTime, PrimitiveDateTime, Time, UtcOffset}; + +pub mod offset_datetime { + use super::*; + + pub fn serialize(dt: &OffsetDateTime, serializer: S) -> Result + where + S: Serializer, + { + let s = dt.format(&Rfc3339).map_err(serde::ser::Error::custom)?; + serializer.serialize_str(&s) + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = serde_json::Value::deserialize(deserializer)?; + parse_offset_datetime(&value).map_err(serde::de::Error::custom) + } +} + +pub mod option_offset_datetime { + use super::*; + + pub fn serialize(dt: &Option, serializer: S) -> Result + where + S: Serializer, + { + match dt { + Some(dt) => offset_datetime::serialize(dt, serializer), + None => serializer.serialize_none(), + } + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(value) => parse_offset_datetime(&value) + .map(Some) + .map_err(serde::de::Error::custom), + } + } +} + +fn parse_offset_datetime(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::String(s) => OffsetDateTime::parse(s, &Rfc3339) + .or_else(|_| { + // Accept trailing Z / space variants via a quick normalize. + let trimmed = s.trim(); + OffsetDateTime::parse(trimmed, &Rfc3339) + }) + .map_err(|e| format!("invalid RFC3339 datetime {s:?}: {e}")), + serde_json::Value::Array(items) if items.len() >= 6 => { + // Legacy `time` serde shape: + // [year, ordinal, hour, minute, second, nanosecond, off_h, off_m, off_s] + let year = items[0].as_i64().ok_or("year")? as i32; + let ordinal = items[1].as_u64().ok_or("ordinal")? as u16; + let hour = items[2].as_u64().ok_or("hour")? as u8; + let minute = items[3].as_u64().ok_or("minute")? as u8; + let second = items[4].as_u64().ok_or("second")? as u8; + let nanosecond = items[5].as_u64().unwrap_or(0) as u32; + let off_h = items.get(6).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + let off_m = items.get(7).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + let off_s = items.get(8).and_then(|v| v.as_i64()).unwrap_or(0) as i8; + + let date = Date::from_ordinal_date(year, ordinal) + .map_err(|e| format!("invalid ordinal date: {e}"))?; + let time = Time::from_hms_nano(hour, minute, second, nanosecond) + .map_err(|e| format!("invalid time: {e}"))?; + let offset = UtcOffset::from_hms(off_h, off_m, off_s) + .map_err(|e| format!("invalid offset: {e}"))?; + Ok(PrimitiveDateTime::new(date, time).assume_offset(offset)) + } + other => Err(format!("unsupported datetime JSON shape: {other}")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::{Deserialize, Serialize}; + use time::Month; + + #[derive(Serialize, Deserialize)] + struct Sample { + #[serde(with = "offset_datetime")] + at: OffsetDateTime, + } + + #[test] + fn round_trips_rfc3339() { + let at = OffsetDateTime::UNIX_EPOCH + .replace_date(Date::from_calendar_date(2026, Month::July, 23).unwrap()) + .replace_time(Time::from_hms(14, 30, 0).unwrap()); + let json = serde_json::to_string(&Sample { at }).unwrap(); + assert!(json.contains("2026-07-23")); + let back: Sample = serde_json::from_str(&json).unwrap(); + assert_eq!(back.at.year(), 2026); + assert_eq!(back.at.month(), Month::July); + assert_eq!(back.at.day(), 23); + } + + #[test] + fn deserializes_legacy_tuple() { + // year=2026, ordinal=204 (~July 23), 07:51:00 UTC + let json = r#"{"at":[2026,204,7,51,0,0,0,0,0]}"#; + let sample: Sample = serde_json::from_str(json).unwrap(); + assert_eq!(sample.at.year(), 2026); + assert_eq!(sample.at.hour(), 7); + assert_eq!(sample.at.minute(), 51); + } +} diff --git a/src/tray.rs b/src/tray.rs index 45cc42e..d940420 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -1,63 +1,185 @@ use std::collections::HashMap; +use std::fs::{self, OpenOptions}; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::thread; use anyhow::{Context, Result}; use time::OffsetDateTime; -use tray_icon::menu::{CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem}; -use tray_icon::{Icon, TrayIcon, TrayIconBuilder}; +use tray_icon::menu::{ + CheckMenuItem, Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem, Submenu, +}; +use tray_icon::{Icon, MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; use uuid::Uuid; use winit::application::ApplicationHandler; -use winit::event::WindowEvent; +use winit::dpi::{LogicalSize, PhysicalPosition, PhysicalSize}; +use winit::event::{StartCause, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop, EventLoopProxy}; -use winit::window::WindowId; +use winit::window::{WindowId, WindowLevel}; use crate::app::App; -use crate::model::{AccountView, DisplayIdentity}; -use crate::secrets::SecretStore; -use crate::time_display::format_local_reset_at; -use crate::usage::{usage_error_label, usage_error_requires_login}; +use crate::codex; +use crate::env::AppEnv; +use crate::model::{ + AUTO_REFRESH_QUOTA_ON_RESET_LABEL, AccountView, DisplayIdentity, QUOTA_PAST_RESET_LABEL, + SwitchWhenRunning, +}; +use crate::repository::SnapshotRepository; +use crate::secrets::{MigratingSecretStore, SecretStore}; +use crate::settings::{ResolvedUiLanguage, load_settings, resolve_ui_language}; +use crate::usage::usage_error_requires_login; + +fn tray_ui_lang(app: &App) -> ResolvedUiLanguage { + load_settings(&app.env().app_data_dir) + .map(|settings| resolve_ui_language(&settings.ui_language)) + .unwrap_or(ResolvedUiLanguage::En) +} + +fn tt(lang: ResolvedUiLanguage, en: &'static str, vi: &'static str) -> &'static str { + match lang { + ResolvedUiLanguage::En => en, + ResolvedUiLanguage::Vi => vi, + } +} + +trait AppendableMenu { + fn append_item( + &self, + item: &dyn tray_icon::menu::IsMenuItem, + ) -> Result<(), tray_icon::menu::Error>; +} + +impl AppendableMenu for Menu { + fn append_item( + &self, + item: &dyn tray_icon::menu::IsMenuItem, + ) -> Result<(), tray_icon::menu::Error> { + self.append(item) + } +} + +impl AppendableMenu for Submenu { + fn append_item( + &self, + item: &dyn tray_icon::menu::IsMenuItem, + ) -> Result<(), tray_icon::menu::Error> { + self.append(item) + } +} #[derive(Debug)] enum UserEvent { Menu(MenuEvent), + TrayIcon(TrayIconEvent), AutoStartUsageWindowsChecked, + BackgroundTaskDone { + notification: Option<(String, String)>, + }, + UpdateMenu, + PopoverAction(PopoverAction), } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq)] +enum PopoverAction { + OpenOverview, + Quit, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] enum TrayCommand { Activate(Uuid), + Login(Uuid), + SaveCurrent, + SaveCursorCurrent, + SaveClaudeCurrent, + OpenDashboard, + StartAddAccount, + FinishAddAccount, + CancelAddAccount, + PickBestQuota, + Delete(Uuid, String), + SetArchived(Uuid, bool), SetAutoStartUsageWindows(bool), - ShowTui, + SetAutoSwitchOnLimit(bool), + SetLaunchAtStartup(bool), + SetShowQuotaInMenuBar(bool), + OpenLogsDir, + Refresh, Quit, } -#[derive(Clone, Copy, Default)] -struct TrayLabelWidths { - plan: usize, - remaining: usize, - reset: usize, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum TrayExit { - ShowTui, Quit, } -struct TrayState<'a, S> { - app: &'a App, +struct TrayState { + app: Arc>, tray_icon: Option, commands: HashMap, event_proxy: EventLoopProxy, exit: TrayExit, + dashboard_port: Option, + window: Option, + webview: Option, + popover: Option, + popover_webview: Option, + last_tray_rect: Option<(PhysicalPosition, PhysicalSize)>, + open_dashboard_on_start: bool, + /// Coalesce rapid UpdateMenu storms (auto-start + background tasks). + menu_update_pending: bool, + last_menu_rebuild_at: Option, } -pub(crate) fn run(app: &App) -> Result +pub(crate) fn run(app: Arc>, open_dashboard: bool) -> Result where - S: SecretStore, + S: SecretStore + Send + Sync + 'static, { - let event_loop = EventLoop::::with_user_event() + #[cfg(target_os = "macos")] + detach_from_controlling_terminal(); + + let _instance_lock = match TrayInstanceLock::acquire(&tray_lock_path(&app))? { + Some(lock) => lock, + None => { + log_tray_message( + &app, + &format!( + "tray already running (pid={}), exiting duplicate instance", + read_tray_lock_pid(&tray_lock_path(&app)).unwrap_or(0) + ), + ); + return Ok(TrayExit::Quit); + } + }; + + let bound_port = match crate::server::start_dashboard_server(app.clone()) { + Ok(port) => { + log_tray_message( + &app, + &format!("Dashboard server started on http://127.0.0.1:{}", port), + ); + Some(port) + } + Err(e) => { + log_tray_message(&app, &format!("Failed to start dashboard server: {e:#}")); + None + } + }; + + let mut builder = EventLoop::::with_user_event(); + #[cfg(target_os = "macos")] + { + use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS}; + builder + .with_activation_policy(ActivationPolicy::Accessory) + .with_activate_ignoring_other_apps(false) + .with_default_menu(false); + } + let event_loop = builder .build() .context("failed to create tray event loop")?; event_loop.set_control_flow(ControlFlow::Wait); @@ -67,18 +189,40 @@ where MenuEvent::set_event_handler(Some(move |event| { let _ = menu_proxy.send_event(UserEvent::Menu(event)); })); + let tray_proxy = proxy.clone(); + TrayIconEvent::set_event_handler(Some(move |event| { + let _ = tray_proxy.send_event(UserEvent::TrayIcon(event)); + })); spawn_auto_start_usage_windows_menu_refresh(proxy.clone()); + spawn_menu_bar_title_refresh(proxy.clone()); + let mut state = TrayState { - app, + app: app.clone(), tray_icon: None, commands: HashMap::new(), event_proxy: proxy, exit: TrayExit::Quit, + dashboard_port: bound_port, + window: None, + webview: None, + popover: None, + popover_webview: None, + last_tray_rect: None, + open_dashboard_on_start: open_dashboard, + menu_update_pending: false, + last_menu_rebuild_at: None, }; - event_loop - .run_app(&mut state) - .context("tray event loop failed")?; + let run_result = event_loop.run_app(&mut state); + log_tray_message( + &app, + &format!( + "tray event loop finished (exit={:?}, pid={})", + state.exit, + std::process::id() + ), + ); + run_result.context("tray event loop failed")?; Ok(state.exit) } @@ -98,52 +242,708 @@ fn spawn_auto_start_usage_windows_menu_refresh(proxy: EventLoopProxy) }); } -impl ApplicationHandler for TrayState<'_, S> +/// Keep menu-bar countdown / quota text fresh without waiting for a user action. +fn spawn_menu_bar_title_refresh(proxy: EventLoopProxy) { + let _ = thread::Builder::new() + .name("tray-menu-bar-title-refresh".to_owned()) + .spawn(move || { + loop { + thread::sleep(std::time::Duration::from_secs(45)); + if proxy.send_event(UserEvent::UpdateMenu).is_err() { + break; + } + } + }); +} + +impl ApplicationHandler for TrayState where - S: SecretStore, + S: SecretStore + Send + Sync + 'static, { - fn resumed(&mut self, _event_loop: &ActiveEventLoop) { - if self.tray_icon.is_some() { - return; + fn new_events(&mut self, _event_loop: &ActiveEventLoop, cause: StartCause) { + // tray-icon must be created once the event loop is running (tauri-apps/tray-icon#90). + if cause == StartCause::Init { + self.ensure_tray_icon(); } - match self.rebuild_menu() { - Ok(menu) => match TrayIconBuilder::new() - .with_tooltip("Codex account switcher") - .with_icon(load_codex_icon()) - .with_menu(Box::new(menu)) - .build() - { - Ok(tray_icon) => self.tray_icon = Some(tray_icon), - Err(error) => eprintln!("failed to create tray icon: {error:#}"), - }, - Err(error) => eprintln!("failed to build tray menu: {error:#}"), + } + + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + self.ensure_tray_icon(); + if self.open_dashboard_on_start { + self.open_dashboard_on_start = false; + self.open_dashboard_window(event_loop); } } fn window_event( &mut self, - _event_loop: &ActiveEventLoop, - _window_id: WindowId, - _event: WindowEvent, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, ) { + let is_overview = self.window.as_ref().is_some_and(|w| w.id() == window_id); + let is_popover = self.popover.as_ref().is_some_and(|w| w.id() == window_id); + + if is_overview { + match event { + WindowEvent::CloseRequested => { + self.webview = None; + self.window = None; + } + WindowEvent::Resized(size) => { + if let Some(webview) = &self.webview { + let _ = webview.set_bounds(wry_bounds_from_physical(size)); + } + } + _ => {} + } + return; + } + + if is_popover { + match event { + WindowEvent::CloseRequested => { + // Hide + reuse — destroying/recreating WKWebView on every click is the lag source. + self.hide_popover(); + } + // Click outside / app switch: auto-dismiss the menu panel. + WindowEvent::Focused(false) => { + self.hide_popover(); + } + WindowEvent::Destroyed => { + self.popover_webview = None; + self.popover = None; + } + _ => {} + } + return; + } + + let _ = event_loop; } fn user_event(&mut self, event_loop: &ActiveEventLoop, event: UserEvent) { - let UserEvent::Menu(event) = event else { + match event { + UserEvent::AutoStartUsageWindowsChecked + | UserEvent::UpdateMenu + | UserEvent::BackgroundTaskDone { .. } => { + if let UserEvent::BackgroundTaskDone { + notification: Some((title, body)), + } = &event + { + tray_notify(title, body); + } + self.request_menu_update(); + } + UserEvent::Menu(event) => self.handle_menu_event(event_loop, event), + UserEvent::TrayIcon(event) => self.handle_tray_icon_event(event_loop, event), + UserEvent::PopoverAction(action) => match action { + PopoverAction::OpenOverview => { + self.hide_popover(); + self.open_dashboard_window(event_loop); + } + PopoverAction::Quit => { + self.hide_popover(); + self.exit = TrayExit::Quit; + event_loop.exit(); + } + }, + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if self.menu_update_pending { + self.menu_update_pending = false; if let Err(error) = self.update_tray_menu() { eprintln!("failed to refresh tray menu: {error:#}"); } + } + } +} + +impl TrayState +where + S: SecretStore + Send + Sync + 'static, +{ + fn open_dashboard_window(&mut self, event_loop: &ActiveEventLoop) { + if let Some(port) = self.dashboard_port { + if self.window.is_none() { + // Native titled window — tray stays primary UX; overview is secondary. + // Keep a normal macOS title bar ("ChatGPT Codex") so this does not feel + // like a browser tab; localhost is never shown in UI chrome/copy. + let window_attrs = winit::window::Window::default_attributes() + .with_title("ChatGPT Codex") + .with_inner_size(LogicalSize::new(1120.0, 760.0)) + .with_resizable(true); + match event_loop.create_window(window_attrs) { + Ok(window) => { + let url = format!("http://127.0.0.1:{port}/"); + // new_as_child keeps winit's NSView as contentView. WebViewBuilder::new + // replaces it and later crashes in windowDidResignKey (objc weak / SIGSEGV). + match wry::WebViewBuilder::new_as_child(&window) + .with_bounds(wry_bounds_from_physical(window.inner_size())) + .with_url(url) + .build() + { + Ok(webview) => { + self.webview = Some(webview); + self.window = Some(window); + } + Err(e) => { + eprintln!("failed to create WebView: {e}"); + tray_notify("ChatGPT Codex", "Failed to open overview window."); + } + } + } + Err(e) => { + eprintln!("failed to create window: {e}"); + tray_notify("ChatGPT Codex", "Failed to create overview window."); + } + } + } else if let Some(window) = &self.window { + window.focus_window(); + } + } else { + tray_notify("ChatGPT Codex", "Overview is unavailable right now."); + } + } + + fn hide_popover(&mut self) { + if let Some(window) = &self.popover { + window.set_visible(false); + } + if let Some(webview) = &self.popover_webview { + // Cached WKWebView stays alive — stop JS polls while hidden. + let _ = webview + .evaluate_script("try{window.__popoverHidden&&window.__popoverHidden()}catch(e){}"); + } + } + + fn destroy_popover(&mut self) { + self.popover_webview = None; + self.popover = None; + } + + fn handle_tray_icon_event(&mut self, event_loop: &ActiveEventLoop, event: TrayIconEvent) { + #[cfg(target_os = "macos")] + { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + rect, + .. + } = event + { + self.last_tray_rect = Some((rect.position, rect.size)); + self.toggle_popover(event_loop); + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = (event_loop, event); + } + } + + #[cfg(target_os = "macos")] + fn popover_anchor(&self) -> (f64, f64) { + const WIDTH: f64 = 392.0; + if let Some((pos, size)) = self.last_tray_rect { + let x = pos.x + (f64::from(size.width) / 2.0) - (WIDTH / 2.0); + let y = pos.y + f64::from(size.height) + 6.0; + return (x, y); + } + if let Some(tray) = &self.tray_icon + && let Some(rect) = tray.rect() + { + let x = rect.position.x + (f64::from(rect.size.width) / 2.0) - (WIDTH / 2.0); + let y = rect.position.y + f64::from(rect.size.height) + 6.0; + return (x, y); + } + (40.0, 40.0) + } + + #[cfg(target_os = "macos")] + fn toggle_popover(&mut self, event_loop: &ActiveEventLoop) { + if let Some(window) = &self.popover + && window.is_visible() == Some(true) + { + self.hide_popover(); + return; + } + self.open_popover(event_loop); + } + + #[cfg(target_os = "macos")] + fn show_existing_popover(&mut self) { + let (x, y) = self.popover_anchor(); + if let Some(window) = &self.popover { + window.set_outer_position(PhysicalPosition::new(x, y)); + window.set_visible(true); + window.focus_window(); + } + if let Some(webview) = &self.popover_webview { + // Refresh data without reloading the document / recreating WKWebView. + let _ = webview + .evaluate_script("try{window.__popoverShown&&window.__popoverShown()}catch(e){}"); + } + } + + #[cfg(target_os = "macos")] + fn open_popover(&mut self, event_loop: &ActiveEventLoop) { + let Some(port) = self.dashboard_port else { + tray_notify("ChatGPT Codex", "Menu panel is unavailable right now."); return; }; - let command = self.commands.get(event.id.as_ref()).copied(); + + if self.popover.is_some() && self.popover_webview.is_some() { + self.show_existing_popover(); + return; + } + // Partial / failed prior create — drop and rebuild once. + self.destroy_popover(); + + const WIDTH: f64 = 392.0; + const HEIGHT: f64 = 580.0; + let (x, y) = self.popover_anchor(); + + use winit::platform::macos::WindowAttributesExtMacOS; + let window_attrs = winit::window::Window::default_attributes() + .with_title("ChatGPT Codex") + .with_inner_size(LogicalSize::new(WIDTH, HEIGHT)) + .with_max_inner_size(LogicalSize::new(WIDTH, HEIGHT)) + .with_min_inner_size(LogicalSize::new(WIDTH, HEIGHT)) + .with_decorations(false) + .with_resizable(false) + .with_transparent(true) + .with_window_level(WindowLevel::AlwaysOnTop) + .with_visible(false) + // Native square shadow bleeds past HTML border-radius; CSS shadow is enough. + .with_has_shadow(false) + .with_position(PhysicalPosition::new(x, y)); + + match event_loop.create_window(window_attrs) { + Ok(window) => { + // Reinforce transparency: NSWindow must be non-opaque + clearColor or + // rounded HTML leaves white corners over the default window fill. + window.set_transparent(true); + let proxy = self.event_proxy.clone(); + let url = format!("http://127.0.0.1:{port}/menu"); + // Child webview: must not replace winit's contentView (see open_dashboard_window). + match wry::WebViewBuilder::new_as_child(&window) + .with_bounds(wry::Rect { + position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), + size: wry::dpi::LogicalSize::new(WIDTH, HEIGHT).into(), + }) + .with_transparent(true) + .with_url(url) + .with_ipc_handler(move |request| { + let body = request.body().as_str(); + let action = match body { + "open-overview" => Some(PopoverAction::OpenOverview), + "quit" => Some(PopoverAction::Quit), + _ => None, + }; + if let Some(action) = action { + let _ = proxy.send_event(UserEvent::PopoverAction(action)); + } + }) + .build() + { + Ok(webview) => { + let _ = window.set_cursor_hittest(true); + window.set_visible(true); + window.focus_window(); + self.popover_webview = Some(webview); + self.popover = Some(window); + } + Err(e) => { + eprintln!("failed to create popover WebView: {e}"); + tray_notify("ChatGPT Codex", "Failed to open menu panel."); + } + } + } + Err(e) => { + eprintln!("failed to create popover window: {e}"); + tray_notify("ChatGPT Codex", "Failed to create menu panel."); + } + } + } + + fn ensure_tray_icon(&mut self) { + if self.tray_icon.is_some() { + return; + } + match self + .app + .status() + .and_then(|s| self.app.list().map(|l| (s, l))) + { + Ok((status, list)) => { + let cursor_status = self.app.cursor_status().ok(); + let claude_status = self.app.claude_status().ok(); + let tooltip = self.get_tooltip_text( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + ); + let title = self.get_menu_bar_title( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + ); + match self.rebuild_menu_with_status_and_list( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + ) { + Ok(menu) => { + let (icon, template) = load_tray_icon(); + let mut builder = TrayIconBuilder::new() + .with_tooltip(tooltip) + .with_icon(icon) + .with_title(title) + .with_menu(Box::new(menu)); + #[cfg(target_os = "macos")] + { + builder = builder + .with_icon_as_template(template) + .with_menu_on_left_click(false) + .with_menu_on_right_click(true); + } + match builder.build() { + Ok(tray_icon) => { + self.tray_icon = Some(tray_icon); + self.last_menu_rebuild_at = Some(std::time::Instant::now()); + self.spawn_startup_usage_refresh(); + #[cfg(target_os = "macos")] + wake_main_run_loop(); + log_tray_message( + &self.app, + &format!( + "tray icon created (template={template}, pid={})", + std::process::id() + ), + ); + } + Err(error) => log_tray_error( + &self.app, + &format!("failed to create tray icon: {error:#}"), + ), + } + } + Err(error) => { + log_tray_error(&self.app, &format!("failed to build tray menu: {error:#}")) + } + } + } + Err(error) => log_tray_error( + &self.app, + &format!("failed to query app status and list: {error:#}"), + ), + } + } + + fn handle_menu_event(&mut self, event_loop: &ActiveEventLoop, event: MenuEvent) { + let command = self.commands.get(event.id.as_ref()).cloned(); match command { Some(TrayCommand::Activate(account_id)) => { - if let Err(error) = self.app.activate_with_running_policy(account_id, false) { - eprintln!("failed to activate account from tray: {error:#}"); + let was_running = !self.app.activation_preflight_warnings().is_empty(); + let policy = if was_running { + crate::process::prompt_switch_when_running() + } else { + SwitchWhenRunning::WaitAndSwitch + }; + if was_running && policy == SwitchWhenRunning::Cancel { + return; + } + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + spawn_tray_background(proxy, move || { + let app = tray_app_for_env(&env); + if was_running && policy == SwitchWhenRunning::SwitchNow { + crate::process::quit_running_codex_app(); + } else if was_running { + crate::process::wait_for_codex_processes_to_exit(); + } + let force = was_running && policy == SwitchWhenRunning::SwitchNow; + let output = app.activate_with_running_policy(account_id, force)?; + crate::process::launch_codex_app(); + let account_name = account_display_name(&output.account); + let plan = format_plan_label_simple(output.account.plan_label.as_deref()); + let detail = if was_running && policy == SwitchWhenRunning::WaitAndSwitch { + " Codex restarted after tasks finished." + } else { + " Codex restarted." + }; + Ok(Some(( + "ChatGPT Codex".to_owned(), + format!("Switched to {account_name} ({plan}).{detail}"), + ))) + }); + } + Some(TrayCommand::Login(account_id)) => { + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + spawn_tray_background(proxy, move || { + let app = tray_app_for_env(&env); + let output = app.start_login_for_saved_account(account_id)?; + let launch = + crate::process::relaunch_codex_for_interactive_login(&env.codex_root); + let account_name = account_display_name(&output.account); + let mut msg = format!( + "Sign in for {account_name}: {}. After the browser finishes, choose Save signed-in session.", + launch.detail + ); + if !launch.oauth_port_ready { + msg.push_str(" (OAuth port not ready yet.)"); + } + Ok(Some(("ChatGPT Codex".to_owned(), msg))) + }); + } + Some(TrayCommand::SaveCurrent) => { + let msg = match self.app.save_current() { + Ok(output) => { + format!( + "Saved workspace {} successfully.", + account_display_name(&output.account) + ) + } + Err(error) => { + format!("Failed to save workspace: {error:#}") + } + }; + tray_notify("ChatGPT Codex", &msg); + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::SaveCursorCurrent) => { + let msg = match self.app.save_cursor_current() { + Ok(output) => { + format!( + "Saved Cursor workspace {} successfully.", + account_display_name(&output.account) + ) + } + Err(error) => { + format!("Failed to save Cursor workspace: {error:#}") + } + }; + tray_notify("ChatGPT Codex", &msg); + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::SaveClaudeCurrent) => { + let msg = match self.app.save_claude_current() { + Ok(output) => { + format!( + "Saved Claude workspace {} successfully.", + account_display_name(&output.account) + ) + } + Err(error) => { + format!("Failed to save Claude workspace: {error:#}") + } + }; + tray_notify("ChatGPT Codex", &msg); + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::OpenDashboard) => { + self.open_dashboard_window(event_loop); + } + Some(TrayCommand::StartAddAccount) => { + let env = self.app.env().clone(); + let started = if codex::add_account_session_active(self.app.env()) { + // Stuck / mid-session: re-run CLI login without failing "already in progress". + Ok(()) + } else { + self.app.begin_add_account_session() + }; + match started { + Ok(()) => { + let proxy = self.event_proxy.clone(); + spawn_tray_background(proxy, move || { + let launch = + crate::process::relaunch_codex_for_interactive_login(&env.codex_root); + let mut msg = format!( + "Step 1: {}. Step 2: return here → Finish Adding Workspace.", + launch.detail + ); + if !launch.oauth_port_ready { + msg.push_str(" (OAuth port not ready yet.)"); + } + Ok(Some(("ChatGPT Codex".to_owned(), msg))) + }); + } + Err(error) => { + tray_notify( + "ChatGPT Codex", + &format!("Failed to start add account: {error:#}"), + ); + } } - if let Err(error) = self.update_tray_menu() { - eprintln!("failed to refresh tray menu: {error:#}"); + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::FinishAddAccount) => { + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + spawn_tray_background(proxy, move || { + let app = tray_app_for_env(&env); + let msg = match app.save_during_add_account_session() { + Ok(output) => { + crate::process::quit_running_codex_app(); + if let Err(error) = codex::restore_add_account_backup(app.env()) { + format!( + "Saved {} but failed to restore original login: {error:#}", + account_display_name(&output.account) + ) + } else { + crate::process::launch_codex_app(); + format!( + "Added workspace {} successfully.", + account_display_name(&output.account) + ) + } + } + Err(error) => format!("Failed to finish adding account: {error:#}"), + }; + Ok(Some(("ChatGPT Codex".to_owned(), msg))) + }); + } + Some(TrayCommand::CancelAddAccount) => { + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + spawn_tray_background(proxy, move || { + let app = tray_app_for_env(&env); + crate::process::quit_running_codex_app(); + let msg = match app.cancel_add_account_session() { + Ok(()) => { + crate::process::launch_codex_app(); + "Cancelled. Original account restored.".to_owned() + } + Err(error) => format!("Failed to cancel add account: {error:#}"), + }; + Ok(Some(("ChatGPT Codex".to_owned(), msg))) + }); + } + Some(TrayCommand::PickBestQuota) => { + let was_running = !self.app.activation_preflight_warnings().is_empty(); + let policy = if was_running { + crate::process::prompt_switch_when_running() + } else { + SwitchWhenRunning::WaitAndSwitch + }; + if was_running && policy == SwitchWhenRunning::Cancel { + return; } + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + spawn_tray_background(proxy, move || { + let app = tray_app_for_env(&env); + if was_running && policy == SwitchWhenRunning::SwitchNow { + crate::process::quit_running_codex_app(); + } else if was_running { + crate::process::wait_for_codex_processes_to_exit(); + } + let msg = match app.pick_best_account(true, true) { + Ok(output) => { + let account_name = account_display_name(&output.account); + if output.switched { + if was_running && policy == SwitchWhenRunning::WaitAndSwitch { + format!( + "Switched to best available quota: {account_name} after Codex finished." + ) + } else { + format!("Switched to best available quota: {account_name}.") + } + } else { + format!("Already on best available quota: {account_name}.") + } + } + Err(error) => format!("Pick best quota failed: {error:#}"), + }; + if was_running && msg.starts_with("Switched to best") { + crate::process::launch_codex_app(); + } + Ok(Some(("ChatGPT Codex".to_owned(), msg))) + }); + } + Some(TrayCommand::Delete(account_id, account_name)) => { + let confirmed = { + #[cfg(target_os = "macos")] + { + let script = format!( + "tell application \"System Events\" to display dialog \"Are you sure you want to delete {}?\" buttons {{\"Cancel\", \"Delete\"}} default button \"Cancel\" with icon caution", + account_name.replace('"', "\\\"") + ); + let output = std::process::Command::new("osascript") + .args(["-e", &script]) + .output(); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout.contains("button returned:Delete") + } else { + false + } + } + #[cfg(target_os = "windows")] + { + let script = format!( + "Add-Type -AssemblyName PresentationFramework; [System.Windows.MessageBox]::Show('Are you sure you want to delete {}?', 'Confirm Delete', 'YesNo') -eq 'Yes'", + account_name + ); + let output = std::process::Command::new("powershell") + .args(["-Command", &script]) + .output(); + if let Ok(out) = output { + let stdout = String::from_utf8_lossy(&out.stdout); + stdout.trim().eq_ignore_ascii_case("True") + } else { + false + } + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + true + } + }; + + if confirmed { + match self.app.delete(account_id) { + Ok(_) => { + let msg = format!("Deleted workspace {} successfully.", account_name); + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(format!( + "display notification \"{}\" with title \"ChatGPT Codex\"", + msg.replace('"', "\\\"") + )) + .spawn(); + } + } + Err(error) => { + eprintln!("failed to delete account: {error:#}"); + } + } + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + } + Some(TrayCommand::SetArchived(account_id, archived)) => { + match self.app.set_account_archived(account_id, archived) { + Ok(_) => { + let verb = if archived { "Archived" } else { "Unarchived" }; + tray_notify("ChatGPT Codex", &format!("{verb} account successfully.")); + } + Err(error) => { + tray_notify( + "ChatGPT Codex", + &format!("Failed to archive/unarchive: {error:#}"), + ); + } + } + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); } Some(TrayCommand::SetAutoStartUsageWindows(enabled)) => { if let Err(error) = self.app.set_auto_start_usage_windows(enabled) { @@ -157,20 +957,55 @@ where if let Err(error) = crate::app::run_auto_start_usage_windows_check_now(env) { - eprintln!( - "failed to run auto-start usage window check from tray: {error:#}" - ); + eprintln!( + "failed to run auto-start usage window check from tray: {error:#}" + ); } let _ = proxy.send_event(UserEvent::AutoStartUsageWindowsChecked); }); } - if let Err(error) = self.update_tray_menu() { - eprintln!("failed to refresh tray menu: {error:#}"); + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::SetAutoSwitchOnLimit(enabled)) => { + if let Err(error) = self.app.set_auto_switch_on_limit(enabled) { + eprintln!("failed to update auto-switch on limit from tray: {error:#}"); + } else if enabled { + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + let _ = thread::Builder::new() + .name("tray-auto-switch-on-limit".to_owned()) + .spawn(move || { + if let Err(error) = + crate::app::run_auto_start_usage_windows_check_now(env) + { + eprintln!("failed to run auto-switch check from tray: {error:#}"); + } + let _ = proxy.send_event(UserEvent::AutoStartUsageWindowsChecked); + }); } + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); } - Some(TrayCommand::ShowTui) => { - self.exit = TrayExit::ShowTui; - event_loop.exit(); + Some(TrayCommand::SetLaunchAtStartup(enabled)) => { + if let Err(error) = self.app.set_launch_at_startup(enabled) { + eprintln!("failed to update launch at startup from tray: {error:#}"); + } + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::SetShowQuotaInMenuBar(enabled)) => { + if let Err(error) = self.app.set_show_quota_in_menu_bar(enabled) { + eprintln!("failed to update show quota in menu bar from tray: {error:#}"); + } + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); + } + Some(TrayCommand::OpenLogsDir) => { + let log_dir = self.app.env().app_data_dir.join("logs"); + #[cfg(target_os = "macos")] + let _ = std::process::Command::new("open").arg(&log_dir).spawn(); + #[cfg(target_os = "windows")] + let _ = std::process::Command::new("explorer").arg(&log_dir).spawn(); + } + Some(TrayCommand::Refresh) => { + let _ = self.event_proxy.send_event(UserEvent::UpdateMenu); } Some(TrayCommand::Quit) => { self.exit = TrayExit::Quit; @@ -179,79 +1014,782 @@ where None => {} } } -} -impl TrayState<'_, S> -where - S: SecretStore, -{ + fn request_menu_update(&mut self) { + // Coalesce bursts so one event-loop turn rebuilds once. + self.menu_update_pending = true; + // If the last rebuild was very recent, still mark pending — about_to_wait + // will run it once the event loop settles. + if self + .last_menu_rebuild_at + .is_some_and(|at| at.elapsed() < std::time::Duration::from_millis(80)) + { + return; + } + self.menu_update_pending = false; + if let Err(error) = self.update_tray_menu() { + eprintln!("failed to refresh tray menu: {error:#}"); + } + } + fn update_tray_menu(&mut self) -> Result<()> { - let menu = self.rebuild_menu()?; + let status = self.app.status()?; + let list = self.app.list()?; + // Single status pass — title/tooltip/menu previously each re-fetched Cursor/Claude. + let cursor_status = self.app.cursor_status().ok(); + let claude_status = self.app.claude_status().ok(); + let tooltip = self.get_tooltip_text( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + ); + let title = self.get_menu_bar_title( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + ); + let menu = self.rebuild_menu_with_status_and_list( + &status, + &list, + cursor_status.as_ref(), + claude_status.as_ref(), + )?; if let Some(tray_icon) = &self.tray_icon { + let _ = tray_icon.set_tooltip(Some(&tooltip)); + tray_icon.set_title(Some(title)); tray_icon.set_menu(Some(Box::new(menu))); } + self.last_menu_rebuild_at = Some(std::time::Instant::now()); Ok(()) } - fn rebuild_menu(&mut self) -> Result { - let status = self.app.status()?; - let list = self.app.list()?; - let menu = Menu::new(); - self.commands.clear(); + fn get_menu_bar_title( + &self, + status: &crate::model::StatusOutput, + list: &crate::model::ListOutput, + cursor_status: Option<&crate::model::StatusOutput>, + claude_status: Option<&crate::model::StatusOutput>, + ) -> String { + let Ok(settings) = self.app.show_quota_in_menu_bar_status() else { + return String::new(); + }; + if !settings.enabled { + return String::new(); + } - let active_account = find_active_tray_account( + let codex_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref().unwrap_or("codex") == "codex") + .cloned() + .collect(); + let active_codex = find_active_tray_account( status.current_account.as_ref(), status.current_account_saved_id, - &list.accounts, + &codex_accounts, ); - let active_account_id = active_account.map(|account| account.id); - let saved_accounts = tray_saved_accounts(&list.accounts, active_account_id); - let label_accounts = active_account - .into_iter() - .chain(saved_accounts.iter().copied()) - .collect::>(); - let mut widths = tray_label_widths(&label_accounts); - if let Some(current) = &status.current_account { - widths.include_plan(current.plan_label.as_deref()); - } - - menu.append(&MenuItem::new("Active Account", false, None))?; - let active_label = status - .current_account - .as_ref() - .map(|account| active_account_label(account, active_account, widths)) - .unwrap_or_else(|| "not logged in".to_owned()); - menu.append(&MenuItem::new(format!(" {active_label}"), false, None))?; - menu.append(&PredefinedMenuItem::separator())?; - menu.append(&MenuItem::new("Saved Accounts", false, None))?; - if saved_accounts.is_empty() { - menu.append(&MenuItem::new("No saved accounts", false, None))?; + let cursor_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("cursor")) + .cloned() + .collect(); + let active_cursor = cursor_status.and_then(|cur_status| { + find_active_tray_account( + cur_status.current_account.as_ref(), + cur_status.current_account_saved_id, + &cursor_accounts, + ) + }); + + let claude_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("claude")) + .cloned() + .collect(); + // Claude live identity often lacks email (or keychain times out on poll). + // Still resolve a saved Claude account so the menu bar can show CL %. + let active_claude = claude_status + .and_then(|c_status| { + find_active_tray_account( + c_status.current_account.as_ref(), + c_status.current_account_saved_id, + &claude_accounts, + ) + }) + .or_else(|| { + fallback_unresolved_claude_tray_account( + &claude_accounts, + &DisplayIdentity { + email: crate::claude::CLAUDE_UNKNOWN_EMAIL.to_owned(), + subject: None, + name: None, + plan_label: claude_status + .and_then(|s| s.current_account.as_ref()) + .and_then(|id| id.plan_label.clone()), + workspace_id: None, + workspace_name: None, + }, + ) + }); + // Prefer live identity; otherwise synthesize from the resolved saved account + // so format_menu_bar_part does not skip CL when keychain identity is empty. + let claude_identity_owned: Option = claude_status + .and_then(|s| s.current_account.clone()) + .or_else(|| { + active_claude.map(|acc| DisplayIdentity { + email: acc.email.clone(), + subject: acc.subject.clone(), + name: acc.name.clone(), + plan_label: acc.plan_label.clone(), + workspace_id: acc.workspace_id.clone(), + workspace_name: acc.workspace_name.clone(), + }) + }); + + let mut parts = Vec::new(); + + if let Some(part) = + self.format_menu_bar_part("CX", status.current_account.as_ref(), active_codex, true) + { + parts.push(part); + } + if let Some(part) = self.format_menu_bar_part( + "CR", + cursor_status.and_then(|s| s.current_account.as_ref()), + active_cursor, + false, + ) { + parts.push(part); + } + if let Some(part) = self.format_menu_bar_part( + "CL", + claude_identity_owned.as_ref(), + active_claude, + false, + ) { + parts.push(part); + } + + if parts.is_empty() { + String::new() } else { - for account in saved_accounts { - let id = format!("activate:{}", account.id); - let item = MenuItem::with_id( - MenuId::new(&id), - format!(" {}", tray_account_label(account, widths)), - true, - None, - ); - menu.append(&item)?; - self.commands.insert(id, TrayCommand::Activate(account.id)); + // Compact spacing keeps the macOS menu bar readable with 3 providers. + format!(" {}", parts.join(" ")) + } + } + + fn format_menu_bar_part( + &self, + label: &str, + current_identity: Option<&DisplayIdentity>, + active_account: Option<&AccountView>, + weekly_only: bool, + ) -> Option { + current_identity?; + + let Some(account) = active_account else { + // Live session detected but not matched to a saved snapshot with usage. + return Some(format!("{label} ·")); + }; + + if account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login) + { + return Some(format!("{label} auth")); + } + + if let Some(usage) = &account.usage { + let now = OffsetDateTime::now_utc(); + // Prefer provider-specific windows, then fall back to any available + // window so Codex still shows 5h when weekly is null (common API shape). + let mut windows: Vec<&crate::model::UsageWindowView> = if weekly_only { + usage.weekly.as_ref().into_iter().collect() + } else { + [usage.five_hour.as_ref(), usage.weekly.as_ref()] + .into_iter() + .flatten() + .collect() + }; + if windows.is_empty() { + windows = [usage.five_hour.as_ref(), usage.weekly.as_ref()] + .into_iter() + .flatten() + .collect(); } + + // When any active window is at 0%, show the soonest reset countdown. + let exhausted_resets: Vec<_> = windows + .iter() + .filter(|w| w.remaining_percent == 0 && w.reset_at > now) + .map(|w| w.reset_at) + .collect(); + if !exhausted_resets.is_empty() { + // Prefer showing remaining % of a non-zero window when available + // (e.g. Cursor Auto 0% but monthly still has quota). + let live = windows + .iter() + .filter(|w| w.remaining_percent > 0 && w.reset_at > now) + .min_by_key(|w| w.remaining_percent); + if let Some(window) = live { + return Some(format!("{label} {}%", window.remaining_percent)); + } + if let Some(reset_at) = exhausted_resets.into_iter().min() { + return Some(format!( + "{label} {}", + crate::time_display::format_countdown(reset_at, now) + )); + } + } + + let bottleneck = windows + .iter() + .filter(|w| w.reset_at > now) + .min_by_key(|w| w.remaining_percent); + if let Some(window) = bottleneck { + return Some(format!("{label} {}%", window.remaining_percent)); + } + + // Past-reset cache: still show last known remaining so the bar is not blank. + if let Some(window) = windows.iter().min_by_key(|w| w.remaining_percent) { + return Some(format!("{label} {}%", window.remaining_percent)); + } + if usage.has_stale_quota_cache(now) { + return Some(format!("{label} stale")); + } + } + + // No cached usage yet (refresh in progress or never fetched). + Some(format!("{label} …")) + } + + fn get_tooltip_text( + &self, + status: &crate::model::StatusOutput, + list: &crate::model::ListOutput, + cursor_status: Option<&crate::model::StatusOutput>, + claude_status: Option<&crate::model::StatusOutput>, + ) -> String { + let codex_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref().unwrap_or("codex") == "codex") + .cloned() + .collect(); + let active_codex = find_active_tray_account( + status.current_account.as_ref(), + status.current_account_saved_id, + &codex_accounts, + ); + + let cursor_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("cursor")) + .cloned() + .collect(); + let active_cursor = cursor_status.and_then(|cur_status| { + find_active_tray_account( + cur_status.current_account.as_ref(), + cur_status.current_account_saved_id, + &cursor_accounts, + ) + }); + + let claude_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("claude")) + .cloned() + .collect(); + let active_claude = claude_status.and_then(|c_status| { + find_active_tray_account( + c_status.current_account.as_ref(), + c_status.current_account_saved_id, + &claude_accounts, + ) + }); + + fn format_app_tooltip_line( + app_name: &str, + current: Option<&DisplayIdentity>, + active_account: Option<&AccountView>, + ) -> Option { + current.map(|identity| { + let plan = format_plan_label_simple(identity.plan_label.as_deref()); + let usage_info = + active_account + .map(|act| { + let (remaining, _) = account_usage_labels_simple(act); + let status = account_status_label_simple(act); + let pace = act.usage.as_ref().and_then(|u| u.weekly.as_ref()).and_then( + |weekly| { + let now = OffsetDateTime::now_utc(); + if weekly.reset_at > now { + crate::usage_pace::pace_for_weekly( + weekly.used_percent, + weekly.reset_at, + now, + ) + .map(|p| p.delta_text().to_owned()) + } else { + None + } + }, + ); + let mut parts = Vec::new(); + if !status.is_empty() { + parts.push(status); + } + if !remaining.is_empty() { + parts.push(remaining); + } + if let Some(pace) = pace { + parts.push(pace); + } + if parts.is_empty() { + String::new() + } else { + format!(" — {}", parts.join(", ")) + } + }) + .unwrap_or_default(); + format!( + "{app_name}: {} ({plan}){usage_info}", + identity_display_name(identity) + ) + }) + } + + let codex_line = format_app_tooltip_line( + "ChatGPT Codex", + status.current_account.as_ref(), + active_codex, + ); + let cursor_line = cursor_status.and_then(|cur_status| { + format_app_tooltip_line("Cursor", cur_status.current_account.as_ref(), active_cursor) + }); + let claude_line = claude_status.and_then(|c_status| { + format_app_tooltip_line("Claude", c_status.current_account.as_ref(), active_claude) + }); + + let mut lines = vec!["ChatGPT Codex".to_owned()]; + let lang = tray_ui_lang(self.app.as_ref()); + let not_logged = tt(lang, "Not logged in", "Chưa đăng nhập"); + if let Some(line) = codex_line { + lines.push(line); + } else { + lines.push(format!("ChatGPT Codex: {not_logged}")); } + if let Some(line) = cursor_line { + lines.push(line); + } else { + lines.push(format!("Cursor: {not_logged}")); + } + if let Some(line) = claude_line { + lines.push(line); + } else { + lines.push(format!("Claude: {not_logged}")); + } + + lines.join("\n") + } + + fn rebuild_menu_with_status_and_list( + &mut self, + status: &crate::model::StatusOutput, + list: &crate::model::ListOutput, + cursor_status: Option<&crate::model::StatusOutput>, + claude_status: Option<&crate::model::StatusOutput>, + ) -> Result { + if codex::add_account_session_active(self.app.env()) { + return self.rebuild_add_account_pending_menu(); + } + + let menu = Menu::new(); + self.commands.clear(); + + let codex_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref().unwrap_or("codex") == "codex") + .cloned() + .collect(); + let cursor_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("cursor")) + .cloned() + .collect(); + let claude_accounts: Vec = list + .accounts + .iter() + .filter(|acc| acc.target_app.as_deref() == Some("claude")) + .cloned() + .collect(); + let lang = tray_ui_lang(self.app.as_ref()); + + // ── Codex Section ─────────────────────────────────────── + self.rebuild_app_section( + &menu, + "ChatGPT Codex", + status.current_account.as_ref(), + status.current_account_saved_id, + &codex_accounts, + lang, + )?; menu.append(&PredefinedMenuItem::separator())?; - let auto_start_enabled = self.app.auto_start_usage_windows_status()?.enabled; - self.append_check_command( + + // ── Cursor Section (always shown) ─────────────────────── + self.rebuild_app_section( + &menu, + "Cursor", + cursor_status.and_then(|s| s.current_account.as_ref()), + cursor_status.and_then(|s| s.current_account_saved_id), + &cursor_accounts, + lang, + )?; + menu.append(&PredefinedMenuItem::separator())?; + + // ── Claude Section (always shown) ─────────────────────── + self.rebuild_app_section( + &menu, + "Claude", + claude_status.and_then(|s| s.current_account.as_ref()), + claude_status.and_then(|s| s.current_account_saved_id), + &claude_accounts, + lang, + )?; + menu.append(&PredefinedMenuItem::separator())?; + + // ── Actions (tray-first; overview is secondary) ────────── + self.append_command( + &menu, + "pick-best-quota", + tt(lang, "Switch to Best Quota", "Chuyển hạn mức tốt nhất"), + TrayCommand::PickBestQuota, + )?; + self.append_command( &menu, + "refresh", + tt(lang, "Refresh", "Làm mới"), + TrayCommand::Refresh, + )?; + menu.append(&PredefinedMenuItem::separator())?; + + let automation_submenu = Submenu::new(tt(lang, "Automation", "Tự động hóa"), true); + let auto_start_enabled = self.app.auto_start_usage_windows_status()?.enabled; + let auto_refresh_label = tt( + lang, + AUTO_REFRESH_QUOTA_ON_RESET_LABEL, + "Tự làm mới hạn mức khi reset", + ); + self.append_check_submenu_command( + &automation_submenu, "toggle-auto-start-usage-windows", - "Auto-start usage windows", + auto_refresh_label, auto_start_enabled, TrayCommand::SetAutoStartUsageWindows(!auto_start_enabled), )?; - self.append_command(&menu, "show-tui", "Show TUI", TrayCommand::ShowTui)?; - self.append_command(&menu, "quit", "Quit", TrayCommand::Quit)?; + let auto_switch_enabled = self.app.auto_switch_on_limit_status()?.enabled; + self.append_check_submenu_command( + &automation_submenu, + "toggle-auto-switch-on-limit", + tt( + lang, + "Auto-switch when exhausted", + "Tự chuyển khi hết hạn mức", + ), + auto_switch_enabled, + TrayCommand::SetAutoSwitchOnLimit(!auto_switch_enabled), + )?; + let launch_at_startup_enabled = self.app.launch_at_startup_status()?.enabled; + self.append_check_submenu_command( + &automation_submenu, + "toggle-launch-at-startup", + tt(lang, "Launch at Login", "Mở khi đăng nhập"), + launch_at_startup_enabled, + TrayCommand::SetLaunchAtStartup(!launch_at_startup_enabled), + )?; + let show_quota_enabled = self.app.show_quota_in_menu_bar_status()?.enabled; + self.append_check_submenu_command( + &automation_submenu, + "toggle-show-quota-in-menu-bar", + tt(lang, "Show Quota in Menu Bar", "Hiện hạn mức trên menu bar"), + show_quota_enabled, + TrayCommand::SetShowQuotaInMenuBar(!show_quota_enabled), + )?; + menu.append(&automation_submenu)?; + self.append_command( + &menu, + "open-dashboard", + tt(lang, "Open Overview…", "Mở Tổng quan…"), + TrayCommand::OpenDashboard, + )?; + self.append_command( + &menu, + "start-add-account", + tt(lang, "Add Codex Account…", "Thêm tài khoản Codex…"), + TrayCommand::StartAddAccount, + )?; + self.append_command( + &menu, + "open-logs", + tt(lang, "Open Logs Folder", "Mở thư mục nhật ký"), + TrayCommand::OpenLogsDir, + )?; + menu.append(&PredefinedMenuItem::separator())?; + menu.append(&MenuItem::new( + tt(lang, "Inspired by CodexBar", "Tham khảo CodexBar"), + false, + None, + ))?; + self.append_command( + &menu, + "quit", + tt(lang, "Quit ChatGPT Codex", "Thoát ChatGPT Codex"), + TrayCommand::Quit, + )?; + Ok(menu) + } + + fn rebuild_app_section( + &mut self, + menu: &dyn AppendableMenu, + app_label: &str, + current_account: Option<&crate::model::DisplayIdentity>, + current_saved_id: Option, + accounts: &[crate::model::AccountView], + lang: ResolvedUiLanguage, + ) -> Result<()> { + // CodexBar-style section header (disabled text item, not "--- dashes ---"). + menu.append_item(&MenuItem::new(app_label, false, None))?; + + let active_account = find_active_tray_account(current_account, current_saved_id, accounts); + let active_account_id = active_account.map(|account| account.id); + let weekly_only = app_label == "ChatGPT Codex"; + + if let Some(current) = current_account { + let not_saved = active_account.is_none(); + let name = identity_display_name(current); + let plan = format_plan_label_simple(current.plan_label.as_deref()); + let unsaved = tt(lang, "unsaved", "chưa lưu"); + let title = if not_saved { + format!("\u{2713} {name} · {plan} ({unsaved})") + } else { + format!("\u{2713} {name} · {plan}") + }; + menu.append_item(&MenuItem::new(title, false, None))?; + + let needs_login = active_account + .and_then(|a| a.usage_error.as_deref()) + .is_some_and(usage_error_requires_login); + if needs_login { + if let Some(act_acc) = active_account { + let login_id = format!("login_active_{}", act_acc.id); + let item = MenuItem::with_id( + MenuId::new(&login_id), + tt(lang, "Sign in again…", "Đăng nhập lại…"), + true, + None, + ); + menu.append_item(&item)?; + self.commands + .insert(login_id, TrayCommand::Login(act_acc.id)); + } else { + menu.append_item(&MenuItem::new( + tt(lang, "Login required", "Cần đăng nhập"), + false, + None, + ))?; + } + } else { + for line in format_usage_menu_lines( + active_account.and_then(|a| a.usage.as_ref()), + weekly_only, + lang, + ) { + menu.append_item(&MenuItem::new(line, false, None))?; + } + + if not_saved { + let save_id = + format!("save_active_{}", app_label.to_lowercase().replace(' ', "_")); + let item = MenuItem::with_id( + MenuId::new(&save_id), + tt(lang, "Save Active Account", "Lưu tài khoản đang dùng"), + true, + None, + ); + menu.append_item(&item)?; + self.commands.insert( + save_id, + match app_label { + "ChatGPT Codex" => TrayCommand::SaveCurrent, + "Cursor" => TrayCommand::SaveCursorCurrent, + "Claude" => TrayCommand::SaveClaudeCurrent, + _ => TrayCommand::Refresh, + }, + ); + } + } + } else { + menu.append_item(&MenuItem::new( + tt(lang, "Not logged in", "Chưa đăng nhập"), + false, + None, + ))?; + } + + // Flat Switch Account submenu (no nested "Hidden Accounts"). + let switch_submenu = Submenu::new(tt(lang, "Switch Account", "Đổi tài khoản"), true); + if accounts.is_empty() { + switch_submenu.append_item(&MenuItem::new( + tt(lang, "No saved accounts", "Chưa có tài khoản đã lưu"), + false, + None, + ))?; + } else { + let mut ready_group = Vec::new(); + let mut depleted_group = Vec::new(); + let mut login_group = Vec::new(); + let mut archived_group = Vec::new(); + + for account in accounts { + if account.is_archived { + archived_group.push(account); + } else { + let needs_login = account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login); + if needs_login { + login_group.push(account); + } else if let Some(usage) = &account.usage + && usage.is_out_of_quota(OffsetDateTime::now_utc()) + { + depleted_group.push(account); + } else { + ready_group.push(account); + } + } + } + + depleted_group.sort_by(|a, b| { + let ta = get_nearest_reset_time(a); + let tb = get_nearest_reset_time(b); + match (ta, tb) { + (Some(t1), Some(t2)) => t1.cmp(&t2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); + + let mut first_group = true; + append_switch_account_group( + &switch_submenu, + tt(lang, "Ready", "Sẵn sàng"), + &ready_group, + active_account_id, + &mut self.commands, + &mut first_group, + )?; + append_switch_account_group( + &switch_submenu, + tt(lang, "Depleted", "Đã hết"), + &depleted_group, + active_account_id, + &mut self.commands, + &mut first_group, + )?; + append_switch_account_group( + &switch_submenu, + tt(lang, "Login required", "Cần đăng nhập"), + &login_group, + active_account_id, + &mut self.commands, + &mut first_group, + )?; + append_switch_account_group( + &switch_submenu, + tt(lang, "Archived", "Đã lưu trữ"), + &archived_group, + active_account_id, + &mut self.commands, + &mut first_group, + )?; + } + menu.append_item(&switch_submenu)?; + + Ok(()) + } + + fn rebuild_add_account_pending_menu(&mut self) -> Result { + let lang = tray_ui_lang(self.app.as_ref()); + let menu = Menu::new(); + self.commands.clear(); + + menu.append(&MenuItem::new( + tt( + lang, + "Adding Account / Workspace", + "Đang thêm tài khoản / workspace", + ), + false, + None, + ))?; + menu.append(&MenuItem::new( + tt(lang, " 1. Log in with Codex", " 1. Đăng nhập Codex"), + false, + None, + ))?; + menu.append(&MenuItem::new( + tt(lang, " 2. Return to this menu", " 2. Quay lại menu này"), + false, + None, + ))?; + menu.append(&MenuItem::new( + tt( + lang, + " 3. Finish adding workspace", + " 3. Hoàn tất thêm workspace", + ), + false, + None, + ))?; + menu.append(&PredefinedMenuItem::separator())?; + self.append_command( + &menu, + "finish-add-account", + tt( + lang, + " Finish Adding Workspace", + " Hoàn tất thêm workspace", + ), + TrayCommand::FinishAddAccount, + )?; + self.append_command( + &menu, + "cancel-add-account", + tt(lang, " Cancel", " Hủy"), + TrayCommand::CancelAddAccount, + )?; + menu.append(&PredefinedMenuItem::separator())?; + self.append_command( + &menu, + "refresh", + tt(lang, "Refresh", "Làm mới"), + TrayCommand::Refresh, + )?; + self.append_command(&menu, "quit", tt(lang, "Quit", "Thoát"), TrayCommand::Quit)?; Ok(menu) } @@ -267,9 +1805,9 @@ where Ok(()) } - fn append_check_command( + fn append_check_submenu_command( &mut self, - menu: &Menu, + menu: &Submenu, id: &str, label: &str, checked: bool, @@ -285,38 +1823,173 @@ where self.commands.insert(id.to_owned(), command); Ok(()) } + + fn spawn_startup_usage_refresh(&self) { + let proxy = self.event_proxy.clone(); + let env = self.app.env().clone(); + let _ = thread::Builder::new() + .name("tray-startup-usage-refresh".to_owned()) + .spawn(move || { + let repository = SnapshotRepository::new( + &env.app_data_dir, + crate::secrets::MigratingSecretStore::new(&env.app_data_dir.join("snapshots")), + ); + let app = App::new(env, repository); + if let Err(error) = app.refresh_saved_usage_cache() { + eprintln!("failed to refresh usage cache on tray startup: {error:#}"); + } + let _ = proxy.send_event(UserEvent::AutoStartUsageWindowsChecked); + }); + } } -fn active_account_label( - account: &DisplayIdentity, - saved_account: Option<&AccountView>, - widths: TrayLabelWidths, -) -> String { - let plan = format_plan_label(account.plan_label.as_deref(), widths); - let (remaining, reset) = saved_account.map(account_usage_labels).unwrap_or_default(); - let remaining = format!("{: Option { + let usage = account.usage.as_ref()?; + let now = OffsetDateTime::now_utc(); + let mut nearest: Option = None; + if let Some(five_hour) = &usage.five_hour + && five_hour.remaining_percent == 0 + && five_hour.reset_at > now + { + nearest = Some(five_hour.reset_at); + } + if let Some(weekly) = &usage.weekly + && weekly.remaining_percent == 0 + && weekly.reset_at > now + { + if let Some(n) = nearest { + if weekly.reset_at < n { + nearest = Some(weekly.reset_at); + } + } else { + nearest = Some(weekly.reset_at); + } + } + nearest } -fn tray_account_label(account: &AccountView, widths: TrayLabelWidths) -> String { - let plan = format_plan_label(account.plan_label.as_deref(), widths); - let (remaining, reset) = account_usage_labels(account); - let remaining = format!("{: Vec<&AccountView> { + accounts.iter().collect() +} - tray_row_label(&account.email, [plan, remaining, reset], None) +fn append_switch_account_group( + menu: &dyn AppendableMenu, + heading: &str, + accounts: &[&AccountView], + active_account_id: Option, + commands: &mut HashMap, + first_group: &mut bool, +) -> Result<()> { + if accounts.is_empty() { + return Ok(()); + } + if !*first_group { + menu.append_item(&PredefinedMenuItem::separator())?; + } + *first_group = false; + menu.append_item(&MenuItem::new(heading, false, None))?; + for account in accounts { + append_tray_account_item(menu, account, active_account_id, commands)?; + } + Ok(()) } -fn tray_saved_accounts( - accounts: &[AccountView], +fn append_tray_account_item( + menu: &dyn AppendableMenu, + account: &AccountView, active_account_id: Option, -) -> Vec<&AccountView> { - accounts - .iter() - .filter(|account| Some(account.id) != active_account_id) - .collect() + commands: &mut HashMap, +) -> Result<()> { + let id = format!("activate:{}", account.id); + let is_active = Some(account.id) == active_account_id || account.is_active; + let weekly_only = account + .target_app + .as_deref() + .map(|app| app == "codex") + .unwrap_or(true); + let label = format_switch_account_label(account, is_active, weekly_only); + + let needs_login = account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login); + + if is_active { + menu.append_item(&MenuItem::new(label, false, None))?; + } else if needs_login { + let login_id = format!("login:{}", account.id); + let item = MenuItem::with_id(MenuId::new(&login_id), label, true, None); + menu.append_item(&item)?; + commands.insert(login_id, TrayCommand::Login(account.id)); + } else { + let item = MenuItem::with_id(MenuId::new(&id), label, true, None); + menu.append_item(&item)?; + commands.insert(id, TrayCommand::Activate(account.id)); + } + Ok(()) +} + +fn format_switch_account_label( + account: &AccountView, + is_active: bool, + weekly_only: bool, +) -> String { + let name = account_display_name(account); + let prefix = if is_active { "\u{2713} " } else { "" }; + let summary = format_account_quota_summary(account, weekly_only); + if summary.is_empty() { + format!("{prefix}{name}") + } else { + format!("{prefix}{name} · {summary}") + } +} + +fn format_account_quota_summary(account: &AccountView, weekly_only: bool) -> String { + if account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login) + { + return "Login required".to_owned(); + } + let Some(usage) = account.usage.as_ref() else { + return String::new(); + }; + let now = OffsetDateTime::now_utc(); + + if !weekly_only + && let Some(five_hour) = &usage.five_hour + && five_hour.reset_at > now + { + let at = crate::time_display::format_local_reset_at(five_hour.reset_at); + if five_hour.remaining_percent == 0 { + return format!( + "Session 0% · reset {at} ({})", + crate::time_display::format_countdown(five_hour.reset_at, now) + ); + } + return format!("Session {}% · reset {at}", five_hour.remaining_percent); + } + + if let Some(weekly) = &usage.weekly { + let at = crate::time_display::format_local_reset_at(weekly.reset_at); + if weekly.reset_at <= now { + return format!("Weekly {QUOTA_PAST_RESET_LABEL} · {at}"); + } + if weekly.remaining_percent == 0 { + return format!( + "Weekly 0% · reset {at} ({})", + crate::time_display::format_countdown(weekly.reset_at, now) + ); + } + return format!("Weekly {}% · reset {at}", weekly.remaining_percent); + } + + if usage.has_stale_quota_cache(now) { + return QUOTA_PAST_RESET_LABEL.to_owned(); + } + String::new() } fn find_active_tray_account<'a>( @@ -324,120 +1997,383 @@ fn find_active_tray_account<'a>( current_saved_id: Option, accounts: &'a [AccountView], ) -> Option<&'a AccountView> { - current_saved_id - .and_then(|id| accounts.iter().find(|account| account.id == id)) - .or_else(|| { - current_account.and_then(|current| { - accounts - .iter() - .find(|account| account.is_active && account_matches_identity(account, current)) - }) + if let Some(account) = + current_saved_id.and_then(|id| accounts.iter().find(|account| account.id == id)) + { + return Some(account); + } + if let Some(current) = current_account { + if let Some(account) = accounts + .iter() + .find(|account| account.is_active && account_matches_identity(account, current)) + { + return Some(account); + } + // Claude live identity is often the placeholder email — still show quota + // from the sole / most-recent Claude saved account with cached usage. + if current + .email + .eq_ignore_ascii_case(crate::claude::CLAUDE_UNKNOWN_EMAIL) + { + return fallback_unresolved_claude_tray_account(accounts, current); + } + } + None +} + +fn fallback_unresolved_claude_tray_account<'a>( + accounts: &'a [AccountView], + identity: &DisplayIdentity, +) -> Option<&'a AccountView> { + let mut candidates: Vec<&'a AccountView> = accounts + .iter() + .filter(|account| { + account.target_app.as_deref() == Some("claude") && !account.is_archived }) + .collect(); + if candidates.is_empty() { + return None; + } + if let Some(plan) = identity.plan_label.as_deref() { + let plan_matches: Vec<_> = candidates + .iter() + .copied() + .filter(|account| { + account + .plan_label + .as_deref() + .is_some_and(|p| p.eq_ignore_ascii_case(plan)) + }) + .collect(); + if !plan_matches.is_empty() { + candidates = plan_matches; + } + } + if candidates.len() == 1 { + return Some(candidates[0]); + } + candidates.into_iter().max_by_key(|account| { + ( + account + .last_activated_at + .map(|t| t.unix_timestamp_nanos()) + .unwrap_or(0), + account.updated_at.unix_timestamp_nanos(), + ) + }) } fn account_matches_identity(account: &AccountView, identity: &DisplayIdentity) -> bool { - match (&account.subject, &identity.subject) { - (Some(left), Some(right)) => left == right, - _ => account.email.eq_ignore_ascii_case(&identity.email), + DisplayIdentity { + email: account.email.clone(), + subject: account.subject.clone(), + name: account.name.clone(), + plan_label: account.plan_label.clone(), + workspace_id: account.workspace_id.clone(), + workspace_name: account.workspace_name.clone(), } + .matches(identity) } -fn tray_row_label( - email: &str, - details: [String; N], - marker: Option<&str>, -) -> String { - let details = details - .into_iter() - .chain(marker.map(str::to_owned)) - .filter(|part| !part.trim().is_empty()) - .collect::>() - .join(" "); - if details.is_empty() { - email.to_owned() - } else { - format!( - "{}{separator}{details}", - email, - separator = tray_detail_separator() - ) - } +fn account_display_name(account: &AccountView) -> String { + display_name_with_workspace(&account.email, account.workspace_label()) +} + +fn identity_display_name(identity: &DisplayIdentity) -> String { + display_name_with_workspace(&identity.email, identity.workspace_label()) } -fn tray_detail_separator() -> &'static str { - if cfg!(windows) { "\t" } else { " " } +/// Shorten email for compact display: strip common suffixes. +fn compact_email(email: &str) -> &str { + email + .strip_suffix("@gmail.com") + .or_else(|| email.strip_suffix("@googlemail.com")) + .unwrap_or(email) } -fn format_plan_label(plan: Option<&str>, widths: TrayLabelWidths) -> String { +fn display_name_with_workspace(email: &str, workspace: Option<&str>) -> String { + let short = compact_email(email); + workspace + .map(|workspace| format!("{short} ({workspace})")) + .unwrap_or_else(|| short.to_owned()) +} + +fn format_plan_label_simple(plan: Option<&str>) -> String { + plan.map(|p| match p.to_ascii_lowercase().as_str() { + "free" => "Free".to_owned(), + "plus" => "Plus".to_owned(), + "k12" => "K12".to_owned(), + other => other.to_owned(), + }) + .unwrap_or_else(|| "Free".to_owned()) +} + +/// CodexBar-style compact 3-glyph usage bar: `▮▮▯ 83%` +#[allow(dead_code)] +fn format_quota_bar(percent: u8) -> String { + const BAR_WIDTH: usize = 3; + let filled = ((f64::from(percent) / 100.0) * BAR_WIDTH as f64).round() as usize; + let filled = filled.min(BAR_WIDTH); + let empty = BAR_WIDTH.saturating_sub(filled); format!( - "{: TrayLabelWidths { - let mut widths = TrayLabelWidths::default(); - for account in accounts { - widths.include_plan(account.plan_label.as_deref()); - let (remaining, reset) = account_usage_labels(account); - widths.remaining = widths.remaining.max(visible_width(&remaining)); - widths.reset = widths.reset.max(visible_width(&reset)); +/// CodexBar-style multi-line usage card for the active account. +/// Codex (`weekly_only`): Weekly / Resets / Pace only — no session/5h. +fn format_usage_menu_lines( + usage: Option<&crate::model::AccountUsageView>, + weekly_only: bool, + lang: ResolvedUiLanguage, +) -> Vec { + let Some(usage) = usage else { + return Vec::new(); + }; + let now = OffsetDateTime::now_utc(); + let mut lines = Vec::new(); + let vi = matches!(lang, ResolvedUiLanguage::Vi); + + if !weekly_only + && let Some(five_hour) = &usage.five_hour + && five_hour.reset_at > now + { + let at = crate::time_display::format_local_reset_at(five_hour.reset_at); + let in_cd = crate::time_display::format_countdown(five_hour.reset_at, now); + if five_hour.remaining_percent == 0 { + lines.push(if vi { + format!("Phiên 0% · làm mới {at} ({in_cd})") + } else { + format!("Session 0% · reset {at} ({in_cd})") + }); + } else { + lines.push(if vi { + format!("Phiên còn {}%", five_hour.remaining_percent) + } else { + format!("Session {}% left", five_hour.remaining_percent) + }); + lines.push(if vi { + format!("Làm mới {at} · còn {in_cd}") + } else { + format!("Reset {at} · in {in_cd}") + }); + } } - widths -} -impl TrayLabelWidths { - fn include_plan(&mut self, plan: Option<&str>) { - self.plan = self.plan.max( - plan.map(|plan| visible_width(&format!("Plan: {plan}"))) - .unwrap_or_default(), - ); + if let Some(weekly) = &usage.weekly { + let at = crate::time_display::format_local_reset_at(weekly.reset_at); + if weekly.reset_at <= now { + lines.push(if vi { + format!("Tuần: đã qua mốc làm mới · {at}") + } else { + format!("Weekly {QUOTA_PAST_RESET_LABEL} · {at}") + }); + } else if weekly.remaining_percent == 0 { + let in_cd = crate::time_display::format_countdown(weekly.reset_at, now); + lines.push(if vi { + format!("Tuần 0% · làm mới {at} ({in_cd})") + } else { + format!("Weekly 0% · reset {at} ({in_cd})") + }); + } else { + let in_cd = crate::time_display::format_countdown(weekly.reset_at, now); + lines.push(if vi { + format!("Tuần còn {}%", weekly.remaining_percent) + } else { + format!("Weekly {}% left", weekly.remaining_percent) + }); + lines.push(if vi { + format!("Làm mới {at} · còn {in_cd}") + } else { + format!("Reset {at} · in {in_cd}") + }); + if let Some(pace) = + crate::usage_pace::pace_for_weekly(weekly.used_percent, weekly.reset_at, now) + { + lines.push(pace.summary_label_localized(vi)); + if let Some(eta) = pace.eta_label_localized(now, vi) { + lines.push(eta); + } + } + } + } else if usage.has_stale_quota_cache(now) && (weekly_only || usage.five_hour.is_none()) { + lines.push(if vi { + "Đã qua mốc làm mới".to_owned() + } else { + QUOTA_PAST_RESET_LABEL.to_owned() + }); } + + lines +} + +/// Compact one-line summary kept for tooltips / legacy helpers. +#[allow(dead_code)] +fn format_account_details_line( + plan: &str, + usage: Option<&crate::model::AccountUsageView>, + weekly_only: bool, + lang: ResolvedUiLanguage, +) -> String { + let mut parts = vec![plan.to_owned()]; + parts.extend(format_usage_menu_lines(usage, weekly_only, lang)); + parts.join(" · ") } -fn account_usage_labels(account: &AccountView) -> (String, String) { +fn account_usage_labels_simple(account: &AccountView) -> (String, String) { if account .usage_error .as_deref() .is_some_and(usage_error_requires_login) { - ( - usage_error_label(account.usage_error.as_deref().unwrap_or_default()).to_owned(), - String::new(), - ) + ("Login required".to_owned(), String::new()) } else if let Some(usage) = &account.usage && let Some(weekly) = &usage.weekly { - if weekly.reset_at <= OffsetDateTime::now_utc() { - ("Weekly Remaining: passed".to_owned(), String::new()) + let now = OffsetDateTime::now_utc(); + if weekly.reset_at <= now { + (QUOTA_PAST_RESET_LABEL.to_owned(), String::new()) } else { ( format!( - "Weekly Remaining: {}%", - format_remaining_percent(weekly.remaining_percent) + "{}% remaining", + format_remaining_percent(weekly.remaining_percent).trim() + ), + format!( + "Reset in: {}", + crate::time_display::format_countdown(weekly.reset_at, now) ), - format!("Reset: {}", format_local_reset_at(weekly.reset_at)), ) } } else if let Some(error) = &account.usage_error { - (usage_error_label(error).to_owned(), String::new()) + if error.to_lowercase().contains("login required") { + ("Login required".to_owned(), String::new()) + } else { + ("Error".to_owned(), String::new()) + } } else { (String::new(), String::new()) } } +/// Compact status tag for saved account list: Ready / Low / Depleted / Login / Stale +#[allow(dead_code)] +fn account_status_tag(account: &AccountView) -> String { + if account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login) + { + return "Login".to_owned(); + } + if let Some(usage) = &account.usage { + let now = OffsetDateTime::now_utc(); + if usage.is_out_of_quota(now) { + return "Depleted".to_owned(); + } + if usage.is_near_limit(now, 10) { + return "Low".to_owned(); + } + return "Ready".to_owned(); + } + if account.usage_error.is_some() { + "Stale".to_owned() + } else { + "—".to_owned() + } +} + +/// Verbose status label (used in tooltip and legacy paths). +fn account_status_label_simple(account: &AccountView) -> String { + let prefix = if account.is_active { "Active / " } else { "" }; + if account + .usage_error + .as_deref() + .is_some_and(usage_error_requires_login) + { + return format!("{prefix}Login required"); + } + if let Some(usage) = &account.usage { + let now = OffsetDateTime::now_utc(); + if usage.is_out_of_quota(now) { + return format!("{prefix}Quota depleted"); + } + if usage.is_near_limit(now, 10) { + return format!("{prefix}Low quota"); + } + return format!("{prefix}Ready"); + } + if account.usage_error.is_some() { + format!("{prefix}Usage stale") + } else { + format!("{prefix}No usage") + } +} + fn format_remaining_percent(percent: u8) -> String { format!("{percent:>3}").replace(' ', "\u{2007}") } -fn visible_width(text: &str) -> usize { - text.chars().count() +fn tray_app_for_env(env: &AppEnv) -> App { + let repository = SnapshotRepository::new( + &env.app_data_dir, + MigratingSecretStore::new(&env.app_data_dir.join("snapshots")), + ); + App::new(env.clone(), repository) +} + +fn spawn_tray_background( + proxy: EventLoopProxy, + job: impl FnOnce() -> Result> + Send + 'static, +) { + thread::spawn(move || { + let notification = match job() { + Ok(notification) => notification, + Err(error) => { + eprintln!("background tray task failed: {error:#}"); + Some(( + "ChatGPT Codex".to_owned(), + format!("Operation failed: {error:#}"), + )) + } + }; + let _ = proxy.send_event(UserEvent::BackgroundTaskDone { notification }); + }); +} + +fn tray_notify(title: &str, body: &str) { + #[cfg(target_os = "macos")] + { + let _ = std::process::Command::new("osascript") + .arg("-e") + .arg(format!( + "display notification \"{}\" with title \"{}\"", + body.replace('"', "\\\""), + title.replace('"', "\\\"") + )) + .spawn(); + } +} + +fn load_tray_icon() -> (Icon, bool) { + #[cfg(target_os = "macos")] + { + for bytes in macos_menubar_icon_candidates() { + if let Ok(icon) = decode_menubar_icon_bytes(bytes) { + return (icon, true); + } + } + } + (load_codex_icon(), false) } fn load_codex_icon() -> Icon { - if let Ok(icon) = decode_icon_bytes(include_bytes!("../assets/codex-account-switcher.ico")) { - return icon; + for bytes in embedded_icon_candidates() { + if let Ok(icon) = decode_icon_bytes(bytes) { + return icon; + } } candidate_icon_paths() .into_iter() @@ -445,37 +2381,153 @@ fn load_codex_icon() -> Icon { .unwrap_or_else(fallback_icon) } -pub(crate) fn hide_console_window() { - release_console(); +fn embedded_icon_candidates() -> &'static [&'static [u8]] { + &[ + include_bytes!("../assets/codex-account-switcher.ico"), + include_bytes!("../assets/codex-account-switcher-dock.png"), + include_bytes!("../assets/codex-account-switcher-transparent.png"), + ] } -pub(crate) fn show_console_window() { - allocate_console(); +#[cfg(target_os = "macos")] +fn macos_menubar_icon_candidates() -> &'static [&'static [u8]] { + &[ + include_bytes!("../assets/codex-account-switcher-transparent.png"), + include_bytes!("../assets/codex-account-switcher-dock.png"), + ] } -fn release_console() { - use windows_sys::Win32::System::Console::FreeConsole; +#[cfg(target_os = "macos")] +const MENUBAR_ICON_SIZE: u32 = 22; + +#[cfg(target_os = "macos")] +fn decode_menubar_icon_bytes(bytes: &[u8]) -> Result { + let image = image::load_from_memory(bytes) + .context("failed to decode menubar icon bytes")? + .into_rgba8(); + let resized = image::imageops::resize( + &image, + MENUBAR_ICON_SIZE, + MENUBAR_ICON_SIZE, + image::imageops::FilterType::Lanczos3, + ); + let (width, height) = resized.dimensions(); + Icon::from_rgba(resized.into_raw(), width, height).context("failed to create menubar tray icon") +} + +pub(crate) fn hide_console_window() { + #[cfg(target_os = "windows")] + release_console(); +} +#[cfg(target_os = "macos")] +fn wake_main_run_loop() { + // tray-icon may not paint until the main run loop is nudged (winit #3835). unsafe { - FreeConsole(); + use core_foundation::runloop::{CFRunLoopGetMain, CFRunLoopWakeUp}; + CFRunLoopWakeUp(CFRunLoopGetMain()); } } -fn allocate_console() { - use windows_sys::Win32::System::Console::{AllocConsole, GetConsoleWindow}; - use windows_sys::Win32::UI::WindowsAndMessaging::{SW_RESTORE, ShowWindow}; - +#[cfg(target_os = "macos")] +fn detach_from_controlling_terminal() { + // Detach from the launching terminal session so SIGHUP cannot stop the agent. + // SAFETY: setsid and SIG_IGN are async-signal-safe POSIX calls. unsafe { - AllocConsole(); + libc::setsid(); + libc::signal(libc::SIGHUP, libc::SIG_IGN); } - let window = unsafe { GetConsoleWindow() }; - if !window.is_null() { - unsafe { - ShowWindow(window, SW_RESTORE); +} + +struct TrayInstanceLock { + #[cfg(unix)] + _file: fs::File, +} + +impl TrayInstanceLock { + fn acquire(path: &Path) -> Result> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + #[cfg(unix)] + { + let mut file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(path) + .with_context(|| format!("failed to open tray lock {}", path.display()))?; + let fd = file.as_raw_fd(); + let locked = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0; + if !locked { + return Ok(None); + } + file.set_len(0)?; + writeln!(file, "{}", std::process::id())?; + Ok(Some(Self { _file: file })) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(Some(Self {})) } } } +fn tray_lock_path(app: &App) -> PathBuf { + app.env().app_data_dir.join("tray.lock") +} + +fn wry_bounds_from_physical(size: PhysicalSize) -> wry::Rect { + wry::Rect { + position: wry::dpi::LogicalPosition::new(0.0, 0.0).into(), + size: wry::dpi::PhysicalSize::new(size.width, size.height).into(), + } +} + +fn read_tray_lock_pid(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|content| content.trim().parse().ok()) +} + +fn log_tray_error(app: &App, message: &str) { + eprintln!("{message}"); + log_tray_message(app, message); +} + +fn log_tray_message(app: &App, message: &str) { + let log_path = tray_log_path(app); + if let Some(parent) = log_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + use std::io::Write; + if let Ok(mut file) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let timestamp = time::OffsetDateTime::now_utc() + .format(&time::format_description::well_known::Rfc3339) + .unwrap_or_else(|_| "unknown-time".to_owned()); + let _ = writeln!(file, "[{timestamp}] {message}"); + } +} + +fn tray_log_path(app: &App) -> PathBuf { + app.env().app_data_dir.join("tray.log") +} + +#[cfg(target_os = "windows")] +fn release_console() { + use windows_sys::Win32::System::Console::FreeConsole; + + unsafe { + FreeConsole(); + } +} + fn candidate_icon_paths() -> Vec { let mut paths = Vec::new(); if let Some(path) = std::env::var_os("CODEX_ACCOUNT_SWITCHER_ICON") { @@ -487,14 +2539,17 @@ fn candidate_icon_paths() -> Vec { paths.push(dir.join("icon.ico")); paths.push(dir.join("icon.png")); } - if let Some(program_files) = std::env::var_os("ProgramFiles") { - let windows_apps = PathBuf::from(program_files).join("WindowsApps"); - if let Ok(entries) = std::fs::read_dir(windows_apps) { - for entry in entries.flatten() { - let file_name = entry.file_name().to_string_lossy().to_string(); - if file_name.starts_with("OpenAI.Codex_") { - paths.push(entry.path().join("app").join("resources").join("icon.ico")); - paths.push(entry.path().join("app").join("assets").join("icon.png")); + #[cfg(target_os = "windows")] + { + if let Some(program_files) = std::env::var_os("ProgramFiles") { + let windows_apps = PathBuf::from(program_files).join("WindowsApps"); + if let Ok(entries) = std::fs::read_dir(windows_apps) { + for entry in entries.flatten() { + let file_name = entry.file_name().to_string_lossy().to_string(); + if file_name.starts_with("OpenAI.Codex_") { + paths.push(entry.path().join("app").join("resources").join("icon.ico")); + paths.push(entry.path().join("app").join("assets").join("icon.png")); + } } } } @@ -541,57 +2596,33 @@ fn fallback_icon() -> Icon { mod tests { use super::*; use crate::model::{AccountUsageView, EnvironmentKind, UsageSource, UsageWindowView}; - use time::{Date, Month, OffsetDateTime, Time}; + use time::OffsetDateTime; #[test] - fn tray_account_label_includes_usage_table_columns() { - let reset_at = OffsetDateTime::UNIX_EPOCH - .replace_date(Date::from_calendar_date(2099, Month::May, 12).unwrap()) - .replace_time(Time::from_hms(0, 52, 0).unwrap()); - let mut account = AccountView { - id: Uuid::new_v4(), - email: "person@example.com".to_owned(), - subject: Some("sub".to_owned()), - name: None, - plan_label: Some("Pro".to_owned()), - environment: EnvironmentKind::Windows, - is_active: true, - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - last_activated_at: None, - usage: None, - usage_error: None, - }; - account.usage = Some(AccountUsageView { - source: UsageSource::SavedAccessToken, - fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, - weekly: Some(UsageWindowView { - used_percent: 83, - remaining_percent: 17, - reset_at, - }), - credits: None, - }); + fn test_format_plan_label_simple() { + assert_eq!(format_plan_label_simple(Some("pro")), "pro"); + assert_eq!(format_plan_label_simple(Some("Free")), "Free"); + assert_eq!(format_plan_label_simple(None), "Free"); + } - assert_eq!( - tray_account_label(&account, tray_label_widths(&[&account])), - format!( - "person@example.com{}Plan: Pro Weekly Remaining: \u{2007}17% Reset: {}", - tray_detail_separator(), - format_local_reset_at(reset_at) - ) - ); + #[test] + fn remaining_percent_uses_fixed_width_visual_slot() { + assert_eq!(format_remaining_percent(2), "\u{2007}\u{2007}2"); + assert_eq!(format_remaining_percent(89), "\u{2007}89"); + assert_eq!(format_remaining_percent(100), "100"); } #[test] - fn tray_account_label_marks_login_required_usage_error() { + fn tray_status_label_surfaces_account_health() { let account = AccountView { id: Uuid::new_v4(), - email: "person@example.com".to_owned(), + email: "low@example.com".to_owned(), subject: Some("sub".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, environment: EnvironmentKind::Windows, is_active: false, created_at: OffsetDateTime::UNIX_EPOCH, @@ -600,31 +2631,20 @@ mod tests { usage: Some(AccountUsageView { source: UsageSource::SavedAccessToken, fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, - weekly: Some(UsageWindowView { - used_percent: 0, - remaining_percent: 100, - reset_at: OffsetDateTime::UNIX_EPOCH - .replace_date(Date::from_calendar_date(2099, Month::May, 12).unwrap()) - .replace_time(Time::from_hms(13, 56, 0).unwrap()), + five_hour: Some(UsageWindowView { + used_percent: 96, + remaining_percent: 4, + reset_at: OffsetDateTime::now_utc() + time::Duration::hours(1), }), + weekly: None, credits: None, }), - usage_error: Some("Login required: Codex auth expired.".to_owned()), + usage_error: None, + label: None, + is_archived: false, }; - let label = tray_account_label(&account, tray_label_widths(&[&account])); - - assert!(label.contains("Login required")); - assert!(!label.contains("Usage unavailable")); - assert!(!label.contains("Weekly Remaining")); - } - - #[test] - fn remaining_percent_uses_fixed_width_visual_slot() { - assert_eq!(format_remaining_percent(2), "\u{2007}\u{2007}2"); - assert_eq!(format_remaining_percent(89), "\u{2007}89"); - assert_eq!(format_remaining_percent(100), "100"); + assert_eq!(account_status_label_simple(&account), "Low quota"); } #[test] @@ -635,6 +2655,9 @@ mod tests { subject: Some("sub".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, environment: EnvironmentKind::Windows, is_active: true, created_at: OffsetDateTime::UNIX_EPOCH, @@ -642,6 +2665,8 @@ mod tests { last_activated_at: None, usage: None, usage_error: None, + label: None, + is_archived: false, }; let inactive = AccountView { id: Uuid::new_v4(), @@ -651,13 +2676,44 @@ mod tests { }; let accounts = vec![active, inactive]; - let saved_accounts = tray_saved_accounts(&accounts, None); + let saved_accounts = tray_saved_accounts(&accounts); assert_eq!(saved_accounts.len(), 2); assert_eq!(saved_accounts[0].email, "active@example.com"); assert_eq!(saved_accounts[1].email, "inactive@example.com"); } + #[test] + fn tray_status_label_marks_active_account() { + let account = AccountView { + id: Uuid::new_v4(), + email: "active@example.com".to_owned(), + subject: Some("sub".to_owned()), + name: None, + plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, + environment: EnvironmentKind::Windows, + is_active: true, + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + last_activated_at: None, + usage: Some(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour: None, + weekly: None, + credits: None, + }), + usage_error: None, + label: None, + is_archived: false, + }; + + assert_eq!(account_status_label_simple(&account), "Active / Ready"); + } + #[test] fn active_account_fallback_requires_live_identity_match() { let account = AccountView { @@ -666,6 +2722,9 @@ mod tests { subject: Some("sub".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: None, environment: EnvironmentKind::Windows, is_active: true, created_at: OffsetDateTime::UNIX_EPOCH, @@ -673,18 +2732,24 @@ mod tests { last_activated_at: None, usage: None, usage_error: None, + label: None, + is_archived: false, }; let matching_identity = DisplayIdentity { email: "active@example.com".to_owned(), subject: Some("sub".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let mismatched_identity = DisplayIdentity { email: "other@example.com".to_owned(), subject: Some("other-sub".to_owned()), name: None, plan_label: Some("Pro".to_owned()), + workspace_id: None, + workspace_name: None, }; let accounts = vec![account]; @@ -693,69 +2758,165 @@ mod tests { } #[test] - fn active_account_label_keeps_live_identity_and_saved_usage() { - let reset_at = OffsetDateTime::UNIX_EPOCH - .replace_date(Date::from_calendar_date(2099, Month::May, 12).unwrap()) - .replace_time(Time::from_hms(0, 52, 0).unwrap()); - let mut saved_account = AccountView { - id: Uuid::new_v4(), - email: "stale@example.com".to_owned(), - subject: Some("sub".to_owned()), - name: None, - plan_label: Some("Pro".to_owned()), - environment: EnvironmentKind::Windows, - is_active: true, - created_at: OffsetDateTime::UNIX_EPOCH, - updated_at: OffsetDateTime::UNIX_EPOCH, - last_activated_at: None, - usage: None, - usage_error: None, + fn quota_bar_renders_correct_fill() { + assert_eq!(format_quota_bar(100), "▮▮▮ 100%"); + assert_eq!(format_quota_bar(0), "▯▯▯ 0%"); + assert_eq!(format_quota_bar(50), "▮▮▯ 50%"); + assert_eq!(format_quota_bar(83), "▮▮▯ 83%"); + assert_eq!(format_quota_bar(17), "▮▯▯ 17%"); + } + + #[test] + fn usage_menu_lines_are_codexbar_style_weekly_only_for_codex() { + let now = OffsetDateTime::now_utc(); + let usage = AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(UsageWindowView { + used_percent: 40, + remaining_percent: 60, + reset_at: now + time::Duration::hours(2), + }), + weekly: Some(UsageWindowView { + used_percent: 16, + remaining_percent: 84, + reset_at: now + time::Duration::days(3), + }), + credits: None, }; - saved_account.usage = Some(AccountUsageView { + + let lines = format_usage_menu_lines(Some(&usage), true, ResolvedUiLanguage::En); + assert_eq!(lines[0], "Weekly 84% left"); + assert!(lines[1].starts_with("Reset ")); + assert!(lines[1].contains(" · in ")); + assert!(!lines.iter().any(|line| line.contains("Session"))); + } + + #[test] + fn usage_menu_lines_include_session_when_not_weekly_only() { + let now = OffsetDateTime::now_utc(); + let usage = AccountUsageView { source: UsageSource::SavedAccessToken, - fetched_at: OffsetDateTime::UNIX_EPOCH, - five_hour: None, + fetched_at: now, + five_hour: Some(UsageWindowView { + used_percent: 40, + remaining_percent: 60, + reset_at: now + time::Duration::hours(2), + }), weekly: Some(UsageWindowView { - used_percent: 83, - remaining_percent: 17, - reset_at, + used_percent: 16, + remaining_percent: 84, + reset_at: now + time::Duration::days(3), }), credits: None, - }); - let account = DisplayIdentity { - email: "person@example.com".to_owned(), + }; + + let lines = format_usage_menu_lines(Some(&usage), false, ResolvedUiLanguage::En); + assert_eq!(lines[0], "Session 60% left"); + assert!(lines.iter().any(|line| line == "Weekly 84% left")); + } + + #[test] + fn switch_account_label_is_single_compact_line() { + let now = OffsetDateTime::now_utc(); + let account = AccountView { + id: Uuid::new_v4(), + email: "person@gmail.com".to_owned(), subject: None, name: None, - plan_label: Some("ProLite".to_owned()), + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + target_app: Some("codex".to_owned()), + environment: EnvironmentKind::Macos, + is_active: false, + created_at: now, + updated_at: now, + last_activated_at: None, + usage: Some(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: now, + five_hour: Some(UsageWindowView { + used_percent: 90, + remaining_percent: 10, + reset_at: now + time::Duration::hours(1), + }), + weekly: Some(UsageWindowView { + used_percent: 20, + remaining_percent: 80, + reset_at: now + time::Duration::days(2), + }), + credits: None, + }), + usage_error: None, + label: None, + is_archived: false, }; - let mut widths = tray_label_widths(&[&saved_account]); - widths.include_plan(account.plan_label.as_deref()); - - assert_eq!( - active_account_label(&account, Some(&saved_account), widths), - format!( - "person@example.com{}Plan: ProLite Weekly Remaining: \u{2007}17% Reset: {}", - tray_detail_separator(), - format_local_reset_at(reset_at) - ) + + let label = format_switch_account_label(&account, false, true); + assert!( + label.starts_with("person · Weekly 80% · reset "), + "unexpected switch label: {label}" ); } #[test] - fn active_account_label_marks_unsaved_current_account() { - let account = DisplayIdentity { - email: "person@example.com".to_owned(), + fn status_tag_is_compact() { + let base = AccountView { + id: Uuid::new_v4(), + email: "a@b.com".to_owned(), subject: None, name: None, - plan_label: Some("Plus".to_owned()), + plan_label: None, + workspace_id: None, + workspace_name: None, + target_app: None, + environment: EnvironmentKind::Windows, + is_active: false, + created_at: OffsetDateTime::UNIX_EPOCH, + updated_at: OffsetDateTime::UNIX_EPOCH, + last_activated_at: None, + usage: None, + usage_error: None, + label: None, + is_archived: false, }; - assert_eq!( - active_account_label(&account, None, TrayLabelWidths::default()), - format!( - "person@example.com{}Plan: Plus [not saved]", - tray_detail_separator() - ) - ); + // No usage → dash + assert_eq!(account_status_tag(&base), "\u{2014}"); + + // With healthy usage → Ready + let ready = AccountView { + usage: Some(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour: None, + weekly: Some(UsageWindowView { + used_percent: 20, + remaining_percent: 80, + reset_at: OffsetDateTime::now_utc() + time::Duration::days(3), + }), + credits: None, + }), + ..base.clone() + }; + assert_eq!(account_status_tag(&ready), "Ready"); + + // Low quota → Low + let low = AccountView { + usage: Some(AccountUsageView { + source: UsageSource::SavedAccessToken, + fetched_at: OffsetDateTime::UNIX_EPOCH, + five_hour: Some(UsageWindowView { + used_percent: 95, + remaining_percent: 5, + reset_at: OffsetDateTime::now_utc() + time::Duration::hours(1), + }), + weekly: None, + credits: None, + }), + ..base.clone() + }; + assert_eq!(account_status_tag(&low), "Low"); } } diff --git a/src/usage.rs b/src/usage.rs index 4eb648b..27827a9 100644 --- a/src/usage.rs +++ b/src/usage.rs @@ -18,6 +18,44 @@ static USAGE_ENDPOINT: &str = "https://chatgpt.com/backend-api/wham/usage"; static REFRESH_ENDPOINT: &str = "https://auth.openai.com/oauth/token"; static TOKEN_REFRESH_INTERVAL: LazyLock = LazyLock::new(|| Duration::days(8)); +thread_local! { + static TEST_USAGE_ENDPOINT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static TEST_REFRESH_ENDPOINT: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; +} + +fn usage_endpoint() -> String { + TEST_USAGE_ENDPOINT + .with(|slot| slot.borrow().clone()) + .unwrap_or_else(|| USAGE_ENDPOINT.to_owned()) +} + +fn refresh_endpoint() -> String { + TEST_REFRESH_ENDPOINT + .with(|slot| slot.borrow().clone()) + .unwrap_or_else(|| REFRESH_ENDPOINT.to_owned()) +} + +#[cfg(test)] +pub(crate) fn with_test_http_endpoints( + usage_endpoint: &str, + refresh_endpoint: &str, + run: impl FnOnce() -> R, +) -> R { + struct TestHttpEndpointsGuard; + + impl Drop for TestHttpEndpointsGuard { + fn drop(&mut self) { + TEST_USAGE_ENDPOINT.with(|slot| *slot.borrow_mut() = None); + TEST_REFRESH_ENDPOINT.with(|slot| *slot.borrow_mut() = None); + } + } + + TEST_USAGE_ENDPOINT.with(|slot| *slot.borrow_mut() = Some(usage_endpoint.to_owned())); + TEST_REFRESH_ENDPOINT.with(|slot| *slot.borrow_mut() = Some(refresh_endpoint.to_owned())); + let _guard = TestHttpEndpointsGuard; + run() +} + #[derive(Clone, Debug)] pub struct UsageTarget { pub environment: EnvironmentKind, @@ -31,6 +69,17 @@ pub fn fetch_usage(target: UsageTarget) -> Result<(UsageOutput, SnapshotBlob)> { let mut auth = snapshot_auth(&target.snapshot)?; let mut source = target.source; + if target.allow_refresh && should_refresh_proactively(&auth) { + match refresh_auth(&auth) { + Ok(refreshed) => { + auth = refreshed; + source = refresh_source(source); + } + Err(error) if usage_error_requires_login(&format!("{error:#}")) => return Err(error), + Err(_) => {} + } + } + let response = match fetch_usage_response(&auth.access_token, auth.account_id.as_deref()) { Ok(response) => response, Err(error) if target.allow_refresh && should_refresh_after_error(&error, &auth) => { @@ -84,10 +133,26 @@ pub fn usage_error_requires_login(error: &str) -> bool { || (error.contains("token refresh failed") && (error.contains("invalid_grant") || error.contains("refresh token") + || error.contains("refresh_token") || error.contains("log out") + || error.contains("log in") + || error.contains("session has ended") || error.contains("sign in"))) } +/// True when the usage API or upstream signals an active rate/quota block. +pub fn usage_error_indicates_rate_limit(error: &str) -> bool { + let error = error.to_ascii_lowercase(); + error.contains("rate limit") + || error.contains("rate_limit") + || error.contains("too many requests") + || error.contains("quota exceeded") + || error.contains("usage limit") + || error.contains("limit reached") + || error.contains("429") + || error.contains("resource_exhausted") +} + #[derive(Clone, Debug, Deserialize, Serialize)] struct StoredAuth { tokens: StoredTokens, @@ -123,14 +188,30 @@ struct UsageResponse { #[derive(Debug, Deserialize)] struct UsageRateLimit { + /// 5-hour / session window (may be absent when OpenAI temporarily disables it). + #[serde(default, alias = "primary")] primary_window: Option, + /// Weekly window. + #[serde(default, alias = "secondary")] secondary_window: Option, + /// Newer payloads sometimes nest windows differently. + #[serde(default)] + rate_limit_window: Option, } #[derive(Debug, Deserialize)] struct UsageWindow { - used_percent: u8, - reset_at: i64, + /// Used percent 0–100 (API may send float). + #[serde(default)] + used_percent: Option, + /// Some payloads expose remaining directly. + #[serde(default)] + remaining_percent: Option, + /// Unix seconds (classic) or RFC3339 string. + #[serde(default)] + reset_at: Option, + #[serde(default, alias = "reset_time")] + reset_time: Option, } #[derive(Debug, Deserialize)] @@ -159,26 +240,28 @@ impl UsageResponse { subject: None, name: None, plan_label: normalize_plan_label(self.plan_type.as_deref()), + workspace_id: None, + workspace_name: None, })) } fn into_view(self, source: UsageSource) -> Result { let now = OffsetDateTime::now_utc(); + let limits = self.rate_limit.as_ref(); + let five_hour = limits + .and_then(|l| l.primary_window.as_ref()) + .or_else(|| limits.and_then(|l| l.rate_limit_window.as_ref())) + .map(window_view) + .transpose()?; + let weekly = limits + .and_then(|l| l.secondary_window.as_ref()) + .map(window_view) + .transpose()?; Ok(AccountUsageView { source, fetched_at: now, - five_hour: self - .rate_limit - .as_ref() - .and_then(|limits| limits.primary_window.as_ref()) - .map(window_view) - .transpose()?, - weekly: self - .rate_limit - .as_ref() - .and_then(|limits| limits.secondary_window.as_ref()) - .map(window_view) - .transpose()?, + five_hour, + weekly, credits: self.credits.map(credits_view), }) } @@ -228,8 +311,65 @@ fn update_snapshot_auth(snapshot: &SnapshotBlob, auth: &SnapshotAuth) -> Result< Ok(updated) } -fn fetch_usage_response(access_token: &str, account_id: Option<&str>) -> Result { - let mut request = ureq::get(USAGE_ENDPOINT) +#[cfg(test)] +fn parse_usage_response_json(value: &str) -> Result { + serde_json::from_str(value).context("failed to parse usage response json") +} + +#[cfg(test)] +fn using_test_http_endpoints() -> bool { + TEST_USAGE_ENDPOINT.with(|slot| slot.borrow().is_some()) +} + +#[cfg(test)] +fn isolated_http_agent() -> ureq::Agent { + ureq::Agent::config_builder() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(5))) + .max_idle_connections(1) + .max_idle_connections_per_host(1) + .build() + .into() +} + +#[cfg(test)] +fn usage_http_get( + url: &str, + access_token: &str, + account_id: Option<&str>, +) -> Result> { + if using_test_http_endpoints() { + let mut request = isolated_http_agent() + .get(url) + .header("Authorization", &format!("Bearer {access_token}")) + .header("Connection", "close") + .header("User-Agent", "codex-account-switcher"); + if let Some(account_id) = account_id { + request = request.header("ChatGPT-Account-Id", account_id); + } + request.call().context("failed to query Codex usage") + } else { + let mut request = ureq::get(url) + .header("Authorization", &format!("Bearer {access_token}")) + .header("User-Agent", "codex-account-switcher") + .config() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build(); + if let Some(account_id) = account_id { + request = request.header("ChatGPT-Account-Id", account_id); + } + request.call().context("failed to query Codex usage") + } +} + +#[cfg(not(test))] +fn usage_http_get( + url: &str, + access_token: &str, + account_id: Option<&str>, +) -> Result> { + let mut request = ureq::get(url) .header("Authorization", &format!("Bearer {access_token}")) .header("User-Agent", "codex-account-switcher") .config() @@ -239,11 +379,53 @@ fn fetch_usage_response(access_token: &str, account_id: Option<&str>) -> Result< if let Some(account_id) = account_id { request = request.header("ChatGPT-Account-Id", account_id); } - let mut response = request.call().context("failed to query Codex usage")?; + request.call().context("failed to query Codex usage") +} + +#[cfg(test)] +fn usage_http_post_json(url: &str, payload_json: &str) -> Result> { + if using_test_http_endpoints() { + isolated_http_agent() + .post(url) + .header("Content-Type", "application/json") + .header("Connection", "close") + .send(payload_json) + .context("failed to refresh Codex auth tokens") + } else { + ureq::post(url) + .header("Content-Type", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build() + .send(payload_json) + .context("failed to refresh Codex auth tokens") + } +} + +#[cfg(not(test))] +fn usage_http_post_json(url: &str, payload_json: &str) -> Result> { + ureq::post(url) + .header("Content-Type", "application/json") + .config() + .http_status_as_error(false) + .timeout_global(Some(std::time::Duration::from_secs(5))) + .build() + .send(payload_json) + .context("failed to refresh Codex auth tokens") +} + +fn fetch_usage_response(access_token: &str, account_id: Option<&str>) -> Result { + let url = usage_endpoint(); + let mut response = usage_http_get(&url, access_token, account_id)?; let status = response.status(); if status == 401 || status == 403 { bail!("usage authorization failed"); } + if status.as_u16() == 429 { + let body = response.body_mut().read_to_string().unwrap_or_default(); + bail!("usage rate limit exceeded (429): {body}"); + } if status.as_u16() >= 400 { let body = response.body_mut().read_to_string().unwrap_or_default(); bail!("usage request failed with {status}: {body}"); @@ -266,6 +448,15 @@ fn should_refresh_after_error(error: &anyhow::Error, auth: &SnapshotAuth) -> boo true } +fn should_refresh_proactively(auth: &SnapshotAuth) -> bool { + if auth.refresh_token.as_deref().is_none_or(str::is_empty) { + return false; + } + auth.last_refresh.is_none_or(|last_refresh| { + OffsetDateTime::now_utc() - last_refresh >= *TOKEN_REFRESH_INTERVAL + }) +} + fn refresh_auth(auth: &SnapshotAuth) -> Result { let refresh_token = auth .refresh_token @@ -280,14 +471,8 @@ fn refresh_auth(auth: &SnapshotAuth) -> Result { }); let payload_json = serde_json::to_string(&payload).context("failed to encode refresh payload")?; - let mut response = ureq::post(REFRESH_ENDPOINT) - .header("Content-Type", "application/json") - .config() - .http_status_as_error(false) - .timeout_global(Some(std::time::Duration::from_secs(5))) - .build() - .send(&payload_json) - .context("failed to refresh Codex auth tokens")?; + let url = refresh_endpoint(); + let mut response = usage_http_post_json(&url, &payload_json)?; let status = response.status(); if status.as_u16() >= 400 { let body = response.body_mut().read_to_string().unwrap_or_default(); @@ -321,15 +506,70 @@ fn refresh_source(source: UsageSource) -> UsageSource { } fn window_view(window: &UsageWindow) -> Result { - let reset_at = OffsetDateTime::from_unix_timestamp(window.reset_at) - .map_err(|error| anyhow!("invalid reset timestamp {}: {error}", window.reset_at))?; + let used = window + .used_percent + .map(|v| v.clamp(0.0, 100.0).round() as u8) + .or_else(|| { + window + .remaining_percent + .map(|r| 100u8.saturating_sub(r.clamp(0.0, 100.0).round() as u8)) + }) + .unwrap_or(0); + let remaining = window + .remaining_percent + .map(|v| v.clamp(0.0, 100.0).round() as u8) + .unwrap_or_else(|| 100u8.saturating_sub(used)); + + let reset_raw = window + .reset_at + .as_ref() + .or(window.reset_time.as_ref()) + .context("usage window missing reset_at")?; + let reset_at = parse_reset_value(reset_raw)?; + Ok(UsageWindowView { - used_percent: window.used_percent, - remaining_percent: 100u8.saturating_sub(window.used_percent), + used_percent: used, + remaining_percent: remaining, reset_at, }) } +fn parse_reset_value(value: &serde_json::Value) -> Result { + if let Some(secs) = value.as_i64() { + // Heuristic: ms vs seconds. + let secs = if secs > 10_000_000_000 { + secs / 1000 + } else { + secs + }; + return OffsetDateTime::from_unix_timestamp(secs) + .map_err(|error| anyhow!("invalid reset timestamp {secs}: {error}")); + } + if let Some(secs) = value.as_f64() { + let secs = if secs > 10_000_000_000.0 { + (secs / 1000.0) as i64 + } else { + secs as i64 + }; + return OffsetDateTime::from_unix_timestamp(secs) + .map_err(|error| anyhow!("invalid reset timestamp {secs}: {error}")); + } + if let Some(s) = value.as_str() { + if let Ok(secs) = s.parse::() { + let secs = if secs > 10_000_000_000 { + secs / 1000 + } else { + secs + }; + return OffsetDateTime::from_unix_timestamp(secs) + .map_err(|error| anyhow!("invalid reset timestamp {secs}: {error}")); + } + return OffsetDateTime::parse(s, &Rfc3339) + .map_err(|error| anyhow!("invalid reset timestamp {s:?}: {error}")); + } + bail!("unsupported reset timestamp shape: {value}") +} + fn credits_view(credits: UsageCredits) -> CreditsView { CreditsView { has_credits: credits.has_credits, @@ -361,6 +601,8 @@ fn merge_identity(base: &DisplayIdentity, fetched: Option) -> D subject: base.subject.clone(), name: base.name.clone(), plan_label: fetched.plan_label.or_else(|| base.plan_label.clone()), + workspace_id: base.workspace_id.clone(), + workspace_name: base.workspace_name.clone(), } } @@ -406,6 +648,10 @@ pub fn usage_target_from_snapshot( #[cfg(test)] mod tests { use anyhow::anyhow; + use base64::Engine; + + use crate::usage_http::{MockEndpoint, MockHttpServer, with_mock_http_test_lock}; + use crate::{codex::auth_json_fixture, model::EnvironmentKind}; use super::*; @@ -427,4 +673,331 @@ mod tests { assert_eq!(usage_error_label(&message), "Usage unavailable"); assert_eq!(message, "Usage unavailable: failed to query Codex usage"); } + + #[test] + fn usage_response_parses_windows_and_identity() { + let response = parse_usage_response_json( + r#"{ + "email": "user@example.com", + "plan_type": "plus", + "rate_limit": { + "primary_window": { "used_percent": 80, "reset_at": 1700000000 }, + "secondary_window": { "used_percent": 35, "reset_at": 1700100000 } + }, + "credits": { "has_credits": true, "unlimited": false, "balance": "12.50" } + }"#, + ) + .expect("parse usage response"); + + let identity = response + .identity() + .expect("identity") + .expect("usage identity"); + assert_eq!(identity.email, "user@example.com"); + assert_eq!(identity.plan_label.as_deref(), Some("Plus")); + let view = response + .into_view(UsageSource::SavedAccessToken) + .expect("usage view"); + assert_eq!(view.five_hour.as_ref().unwrap().remaining_percent, 20); + assert_eq!(view.weekly.as_ref().unwrap().remaining_percent, 65); + assert_eq!(view.credits.as_ref().unwrap().balance, "12.50"); + } + + #[test] + fn usage_response_rejects_invalid_reset_timestamp() { + let response = parse_usage_response_json( + r#"{ + "email": "user@example.com", + "rate_limit": { + "primary_window": { "used_percent": 10, "reset_at": "not-a-timestamp" } + } + }"#, + ) + .expect("parse usage response"); + + let error = response + .into_view(UsageSource::LiveAccessToken) + .expect_err("invalid reset timestamp"); + assert!(format!("{error:#}").contains("invalid reset timestamp")); + } + + #[test] + fn usage_error_requires_login_detects_authorization_failures() { + assert!(usage_error_requires_login( + "usage authorization failed for saved account" + )); + assert!(usage_error_requires_login("snapshot refresh token missing")); + assert!(!usage_error_requires_login("failed to query Codex usage")); + } + + #[test] + fn usage_error_indicates_rate_limit_detects_quota_signals() { + assert!(usage_error_indicates_rate_limit( + "usage rate limit exceeded (429): too many requests" + )); + assert!(usage_error_indicates_rate_limit( + "quota exceeded for this window" + )); + assert!(!usage_error_indicates_rate_limit( + "usage authorization failed" + )); + } + + #[test] + fn merge_identity_keeps_subject_from_saved_snapshot() { + let base = DisplayIdentity { + email: "saved@example.com".to_owned(), + subject: Some("sub-123".to_owned()), + name: Some("Saved User".to_owned()), + plan_label: Some("Pro".to_owned()), + workspace_id: Some("ws_saved".to_owned()), + workspace_name: Some("Saved Workspace".to_owned()), + }; + let fetched = DisplayIdentity { + email: "live@example.com".to_owned(), + subject: None, + name: None, + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + }; + let merged = merge_identity(&base, Some(fetched)); + assert_eq!(merged.email, "live@example.com"); + assert_eq!(merged.subject.as_deref(), Some("sub-123")); + assert_eq!(merged.plan_label.as_deref(), Some("Plus")); + assert_eq!(merged.workspace_id.as_deref(), Some("ws_saved")); + } + + fn usage_snapshot_fixture() -> SnapshotBlob { + usage_snapshot_fixture_with_last_refresh(Some(OffsetDateTime::now_utc())) + } + + fn stale_usage_snapshot_fixture() -> SnapshotBlob { + usage_snapshot_fixture_with_last_refresh(None) + } + + fn usage_snapshot_fixture_with_last_refresh( + last_refresh: Option, + ) -> SnapshotBlob { + let mut auth: serde_json::Value = serde_json::from_str(&auth_json_fixture( + "user@example.com", + "sub-user", + Some("plus"), + )) + .expect("auth fixture"); + if let Some(last_refresh) = last_refresh { + auth["last_refresh"] = serde_json::Value::String(format_last_refresh(last_refresh)); + } + SnapshotBlob { + schema_version: crate::model::SNAPSHOT_SCHEMA_VERSION, + files: vec![ + crate::model::SnapshotFile { + name: "auth.json".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD + .encode(serde_json::to_vec(&auth).expect("auth json")), + }, + crate::model::SnapshotFile { + name: "cap_sid".to_owned(), + bytes_base64: base64::engine::general_purpose::STANDARD.encode("sid-user"), + }, + ], + } + } + + #[test] + fn fetch_usage_proactively_refreshes_stale_snapshot() { + with_mock_http_test_lock(|| { + fetch_usage_proactively_refreshes_stale_snapshot_scenario(); + }); + } + + #[test] + fn fetch_usage_refreshes_after_authorization_failure() { + with_mock_http_test_lock(|| { + fetch_usage_refreshes_after_authorization_failure_scenario(); + }); + } + + #[test] + fn fetch_usage_does_not_refresh_when_disabled() { + with_mock_http_test_lock(|| { + fetch_usage_does_not_refresh_when_disabled_scenario(); + }); + } + + #[test] + fn fetch_usage_surfaces_refresh_invalid_grant_as_login_required() { + with_mock_http_test_lock(|| { + fetch_usage_surfaces_refresh_invalid_grant_as_login_required_scenario(); + }); + } + + fn fetch_usage_proactively_refreshes_stale_snapshot_scenario() { + let server = MockHttpServer::bind(); + server.enqueue( + MockEndpoint::Refresh, + 200, + r#"{ + "access_token": "access-new", + "refresh_token": "refresh-new", + "id_token": "id-new" + }"#, + ); + server.enqueue( + MockEndpoint::Usage, + 200, + r#"{ + "email": "user@example.com", + "plan_type": "plus", + "rate_limit": { + "primary_window": { "used_percent": 15, "reset_at": 1700000000 }, + "secondary_window": { "used_percent": 5, "reset_at": 1700100000 } + } + }"#, + ); + + let target = UsageTarget { + environment: EnvironmentKind::Macos, + identity: DisplayIdentity { + email: "user@example.com".to_owned(), + subject: Some("sub-user".to_owned()), + name: None, + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + }, + snapshot: stale_usage_snapshot_fixture(), + source: UsageSource::SavedAccessToken, + allow_refresh: true, + }; + + let (output, updated_snapshot) = + with_test_http_endpoints(&server.usage_url(), &server.refresh_url(), || { + fetch_usage(target).expect("fetch usage with proactive refresh") + }); + + assert_eq!(output.usage.source, UsageSource::SavedRefreshToken); + let updated_auth = snapshot_auth(&updated_snapshot).expect("updated auth"); + assert_eq!(updated_auth.access_token, "access-new"); + assert_eq!(updated_auth.refresh_token.as_deref(), Some("refresh-new")); + assert!(updated_auth.last_refresh.is_some()); + } + + fn fetch_usage_refreshes_after_authorization_failure_scenario() { + let server = MockHttpServer::bind(); + server.enqueue(MockEndpoint::Usage, 401, r#"{"error":"expired"}"#); + server.enqueue( + MockEndpoint::Refresh, + 200, + r#"{ + "access_token": "access-new", + "refresh_token": "refresh-new", + "id_token": "id-new" + }"#, + ); + server.enqueue( + MockEndpoint::Usage, + 200, + r#"{ + "email": "user@example.com", + "plan_type": "plus", + "rate_limit": { + "primary_window": { "used_percent": 25, "reset_at": 1700000000 }, + "secondary_window": { "used_percent": 10, "reset_at": 1700100000 } + } + }"#, + ); + + let snapshot = usage_snapshot_fixture(); + let target = UsageTarget { + environment: EnvironmentKind::Macos, + identity: DisplayIdentity { + email: "user@example.com".to_owned(), + subject: Some("sub-user".to_owned()), + name: None, + plan_label: Some("Plus".to_owned()), + workspace_id: None, + workspace_name: None, + }, + snapshot, + source: UsageSource::SavedAccessToken, + allow_refresh: true, + }; + + let (output, updated_snapshot) = + with_test_http_endpoints(&server.usage_url(), &server.refresh_url(), || { + fetch_usage(target).expect("fetch usage with refresh") + }); + + assert_eq!(output.account.email, "user@example.com"); + assert_eq!(output.usage.source, UsageSource::SavedRefreshToken); + assert_eq!(output.usage.weekly.as_ref().unwrap().remaining_percent, 90); + let updated_auth = snapshot_auth(&updated_snapshot).expect("updated auth"); + assert_eq!(updated_auth.access_token, "access-new"); + assert_eq!(updated_auth.refresh_token.as_deref(), Some("refresh-new")); + } + + fn fetch_usage_does_not_refresh_when_disabled_scenario() { + let server = MockHttpServer::bind(); + server.enqueue(MockEndpoint::Usage, 401, r#"{"error":"expired"}"#); + + let target = UsageTarget { + environment: EnvironmentKind::Linux, + identity: DisplayIdentity { + email: "user@example.com".to_owned(), + subject: Some("sub-user".to_owned()), + name: None, + plan_label: None, + workspace_id: None, + workspace_name: None, + }, + snapshot: usage_snapshot_fixture(), + source: UsageSource::SavedAccessToken, + allow_refresh: false, + }; + + let error = with_test_http_endpoints(&server.usage_url(), &server.refresh_url(), || { + fetch_usage(target).expect_err("usage should fail without refresh") + }); + let rendered = format!("{error:#}"); + assert!( + rendered.contains("usage authorization failed"), + "unexpected usage error: {rendered}" + ); + } + + fn fetch_usage_surfaces_refresh_invalid_grant_as_login_required_scenario() { + let server = MockHttpServer::bind(); + server.enqueue(MockEndpoint::Usage, 401, r#"{"error":"expired"}"#); + server.enqueue( + MockEndpoint::Refresh, + 400, + r#"{"error":"invalid_grant","error_description":"Please log out and sign in again."}"#, + ); + + let target = UsageTarget { + environment: EnvironmentKind::Windows, + identity: DisplayIdentity { + email: "user@example.com".to_owned(), + subject: Some("sub-user".to_owned()), + name: None, + plan_label: None, + workspace_id: None, + workspace_name: None, + }, + snapshot: usage_snapshot_fixture(), + source: UsageSource::SavedAccessToken, + allow_refresh: true, + }; + + let error = with_test_http_endpoints(&server.usage_url(), &server.refresh_url(), || { + fetch_usage(target).expect_err("refresh should fail") + }); + let message = usage_error_message(&error); + assert_eq!( + usage_error_label(&message), + "Login required", + "unexpected usage error: {error:#}" + ); + } } diff --git a/src/usage_http.rs b/src/usage_http.rs new file mode 100644 index 0000000..eb10daa --- /dev/null +++ b/src/usage_http.rs @@ -0,0 +1,210 @@ +#![cfg(test)] + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +static MOCK_HTTP_TEST_LOCK: Mutex<()> = Mutex::new(()); + +pub fn with_mock_http_test_lock(run: impl FnOnce() -> R) -> R { + let _guard = MOCK_HTTP_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + run() +} + +#[derive(Clone, Copy, Debug)] +pub enum MockEndpoint { + Usage, + Refresh, +} + +pub struct MockHttpServer { + pub base_url: String, + usage_responses: Arc>>, + refresh_responses: Arc>>, + shutdown: Arc, + handle: Option>, +} + +impl MockHttpServer { + pub fn bind() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind mock server"); + listener + .set_nonblocking(true) + .expect("mock server nonblocking"); + let addr = listener.local_addr().expect("mock server addr"); + let usage_responses = Arc::new(Mutex::new(Vec::new())); + let refresh_responses = Arc::new(Mutex::new(Vec::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let (ready_tx, ready_rx) = std::sync::mpsc::channel(); + let usage_for_thread = Arc::clone(&usage_responses); + let refresh_for_thread = Arc::clone(&refresh_responses); + let shutdown_for_thread = Arc::clone(&shutdown); + let handle = thread::spawn(move || { + ready_tx.send(()).ok(); + serve( + listener, + usage_for_thread, + refresh_for_thread, + shutdown_for_thread, + ); + }); + ready_rx + .recv() + .expect("mock server thread should start promptly"); + Self { + base_url: format!("http://{addr}"), + usage_responses, + refresh_responses, + shutdown, + handle: Some(handle), + } + } + + pub fn enqueue(&self, endpoint: MockEndpoint, status: u16, body: impl Into) { + let body = body.into(); + match endpoint { + MockEndpoint::Usage => self + .usage_responses + .lock() + .expect("mock usage responses lock") + .push((status, body)), + MockEndpoint::Refresh => self + .refresh_responses + .lock() + .expect("mock refresh responses lock") + .push((status, body)), + } + } + + pub fn usage_url(&self) -> String { + format!("{}/backend-api/wham/usage", self.base_url) + } + + pub fn refresh_url(&self) -> String { + format!("{}/oauth/token", self.base_url) + } +} + +impl Drop for MockHttpServer { + fn drop(&mut self) { + self.shutdown.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } +} + +fn serve( + listener: TcpListener, + usage_responses: Arc>>, + refresh_responses: Arc>>, + shutdown: Arc, +) { + while !shutdown.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + let _ = stream.set_nonblocking(false); + handle_connection(stream, &usage_responses, &refresh_responses); + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } +} + +fn handle_connection( + mut stream: TcpStream, + usage_responses: &Arc>>, + refresh_responses: &Arc>>, +) { + let mut reader = BufReader::new( + stream + .try_clone() + .expect("mock server stream clone for read"), + ); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).is_err() { + return; + } + let mut content_length = 0usize; + let mut chunked = false; + loop { + let mut line = String::new(); + if reader.read_line(&mut line).is_err() || line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("content-length") + { + content_length = value.trim().parse().unwrap_or(0); + } + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("transfer-encoding") + && value.trim().eq_ignore_ascii_case("chunked") + { + chunked = true; + } + } + if content_length > 0 { + let mut body = vec![0u8; content_length]; + if reader.read_exact(&mut body).is_err() { + return; + } + } else if chunked && read_chunked_body(&mut reader).is_err() { + return; + } + + let responses = if request_line.contains("/oauth/token") { + refresh_responses + } else { + usage_responses + }; + let (status, body) = { + let mut guard = responses.lock().expect("mock responses lock"); + guard + .first() + .cloned() + .map(|_| guard.remove(0)) + .unwrap_or((404, r#"{"error":"no mock response"}"#.to_owned())) + }; + let status_text = match status { + 200 => "OK", + 400 => "Bad Request", + 401 => "Unauthorized", + 403 => "Forbidden", + _ => "Error", + }; + let response = format!( + "HTTP/1.1 {status} {status_text}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len(), + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); +} + +fn read_chunked_body(reader: &mut BufReader) -> std::io::Result<()> { + loop { + let mut size_line = String::new(); + reader.read_line(&mut size_line)?; + let size_hex = size_line + .trim() + .split_once(';') + .map(|(size, _)| size) + .unwrap_or_else(|| size_line.trim()); + let size = usize::from_str_radix(size_hex, 16).unwrap_or(0); + if size == 0 { + let mut trailer = String::new(); + reader.read_line(&mut trailer)?; + return Ok(()); + } + let mut chunk = vec![0u8; size + 2]; + reader.read_exact(&mut chunk)?; + } +} diff --git a/src/usage_pace.rs b/src/usage_pace.rs new file mode 100644 index 0000000..0df2998 --- /dev/null +++ b/src/usage_pace.rs @@ -0,0 +1,283 @@ +//! Even-consumption pace tracking inspired by CodexBar's `UsagePace`. +//! +//! Compares actual used% against the expected burn rate for the remaining window. +//! Hidden when less than 3% of the window has elapsed (same gate as CodexBar). + +use time::OffsetDateTime; + +pub const FIVE_HOUR_WINDOW_SECS: f64 = 5.0 * 3600.0; +pub const WEEKLY_WINDOW_SECS: f64 = 168.0 * 3600.0; +const MIN_ELAPSED_FRACTION: f64 = 0.03; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PaceStage { + OnTrack, + SlightlyAhead, + Ahead, + FarAhead, + SlightlyBehind, + Behind, + FarBehind, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct UsagePace { + pub stage: PaceStage, + /// Actual used% minus expected used%. Positive = burning faster (deficit). + pub delta_percent: f64, + pub expected_used_percent: f64, + pub actual_used_percent: f64, + pub eta_secs: Option, + pub will_last_to_reset: bool, +} + +impl UsagePace { + /// Compact CodexBar-style left label: "On pace" / "N% in deficit" / "N% in reserve". + pub fn summary_label(&self) -> String { + self.summary_label_localized(false) + } + + pub fn summary_label_localized(&self, vietnamese: bool) -> String { + let delta = self.delta_percent.abs().round() as i64; + if delta == 0 || matches!(self.stage, PaceStage::OnTrack) { + return if vietnamese { + "Đúng nhịp".to_owned() + } else { + "On pace".to_owned() + }; + } + match self.stage { + PaceStage::SlightlyAhead | PaceStage::Ahead | PaceStage::FarAhead => { + if vietnamese { + format!("Thiếu {delta}%") + } else { + format!("{delta}% in deficit") + } + } + PaceStage::SlightlyBehind | PaceStage::Behind | PaceStage::FarBehind => { + if vietnamese { + format!("Dư {delta}%") + } else { + format!("{delta}% in reserve") + } + } + PaceStage::OnTrack => { + if vietnamese { + "Đúng nhịp".to_owned() + } else { + "On pace".to_owned() + } + } + } + } + + /// Optional right-hand ETA: "Lasts until reset" / "Runs out in 2h". + pub fn eta_label(&self, now: OffsetDateTime) -> Option { + self.eta_label_localized(now, false) + } + + pub fn eta_label_localized(&self, now: OffsetDateTime, vietnamese: bool) -> Option { + if self.will_last_to_reset { + return Some(if vietnamese { + "Đủ đến lúc làm mới".to_owned() + } else { + "Lasts until reset".to_owned() + }); + } + let eta_secs = self.eta_secs?; + if eta_secs <= 0.0 { + return Some(if vietnamese { + "Hết ngay".to_owned() + } else { + "Runs out now".to_owned() + }); + } + let eta_at = now + time::Duration::seconds(eta_secs.round() as i64); + let countdown = crate::time_display::format_countdown(eta_at, now); + Some(if vietnamese { + format!("Hết sau {countdown}") + } else { + format!("Runs out in {countdown}") + }) + } + + /// Signed delta for menu-bar display: `+14%` / `-5%` / `0%`. + pub fn delta_text(&self) -> String { + let delta = self.delta_percent.round() as i64; + if delta == 0 { + "0%".to_owned() + } else if delta > 0 { + format!("+{delta}%") + } else { + format!("{delta}%") + } + } +} + +/// Compute pace for a quota window. Returns `None` when data is insufficient. +pub fn compute_pace( + used_percent: u8, + reset_at: OffsetDateTime, + now: OffsetDateTime, + window_secs: f64, +) -> Option { + if window_secs <= 0.0 || reset_at <= now { + return None; + } + + let time_until_reset = (reset_at - now).whole_seconds().max(0) as f64; + if time_until_reset > window_secs { + return None; + } + + let elapsed = (window_secs - time_until_reset).clamp(0.0, window_secs); + if elapsed < window_secs * MIN_ELAPSED_FRACTION { + return None; + } + + let actual = f64::from(used_percent).clamp(0.0, 100.0); + if elapsed == 0.0 && actual > 0.0 { + return None; + } + + let expected = ((elapsed / window_secs) * 100.0).clamp(0.0, 100.0); + let delta = actual - expected; + let stage = stage_for(delta); + + let mut eta_secs = None; + let mut will_last_to_reset = false; + + if actual >= 100.0 { + eta_secs = Some(0.0); + } else if elapsed > 0.0 && actual > 0.0 { + let rate = actual / elapsed; + if rate > 0.0 { + let remaining = 100.0 - actual; + let candidate = remaining / rate; + if candidate >= time_until_reset { + will_last_to_reset = true; + } else { + eta_secs = Some(candidate); + } + } + } else if elapsed > 0.0 && actual == 0.0 { + will_last_to_reset = true; + } + + Some(UsagePace { + stage, + delta_percent: delta, + expected_used_percent: expected, + actual_used_percent: actual, + eta_secs, + will_last_to_reset, + }) +} + +pub fn pace_for_five_hour( + used_percent: u8, + reset_at: OffsetDateTime, + now: OffsetDateTime, +) -> Option { + compute_pace(used_percent, reset_at, now, FIVE_HOUR_WINDOW_SECS) +} + +pub fn pace_for_weekly( + used_percent: u8, + reset_at: OffsetDateTime, + now: OffsetDateTime, +) -> Option { + compute_pace(used_percent, reset_at, now, WEEKLY_WINDOW_SECS) +} + +fn stage_for(delta: f64) -> PaceStage { + let abs = delta.abs(); + if abs <= 2.0 { + PaceStage::OnTrack + } else if abs <= 6.0 { + if delta >= 0.0 { + PaceStage::SlightlyAhead + } else { + PaceStage::SlightlyBehind + } + } else if abs <= 12.0 { + if delta >= 0.0 { + PaceStage::Ahead + } else { + PaceStage::Behind + } + } else if delta >= 0.0 { + PaceStage::FarAhead + } else { + PaceStage::FarBehind + } +} + +#[cfg(test)] +mod tests { + use super::*; + use time::Duration; + + #[test] + fn on_pace_when_usage_matches_elapsed() { + let now = OffsetDateTime::UNIX_EPOCH + Duration::days(100); + // Half the weekly window elapsed → expect ~50% used + let reset = now + Duration::days(3) + Duration::hours(12); + let pace = pace_for_weekly(50, reset, now).expect("pace"); + assert!(pace.delta_percent.abs() < 1.0); + assert_eq!(pace.summary_label(), "On pace"); + assert!(pace.will_last_to_reset); + } + + #[test] + fn deficit_when_burning_faster() { + let now = OffsetDateTime::UNIX_EPOCH + Duration::days(100); + let reset = now + Duration::days(3) + Duration::hours(12); + let pace = pace_for_weekly(80, reset, now).expect("pace"); + assert!(pace.delta_percent > 20.0); + assert!(pace.summary_label().contains("deficit")); + assert!(!pace.will_last_to_reset); + assert!(pace.eta_secs.is_some()); + } + + #[test] + fn reserve_when_burning_slower() { + let now = OffsetDateTime::UNIX_EPOCH + Duration::days(100); + let reset = now + Duration::days(3) + Duration::hours(12); + let pace = pace_for_weekly(20, reset, now).expect("pace"); + assert!(pace.delta_percent < -20.0); + assert!(pace.summary_label().contains("reserve")); + assert!(pace.will_last_to_reset); + } + + #[test] + fn hidden_near_window_start() { + let now = OffsetDateTime::UNIX_EPOCH + Duration::days(100); + // Only ~1% of weekly elapsed + let reset = now + Duration::days(7) - Duration::hours(1); + assert!(pace_for_weekly(5, reset, now).is_none()); + } + + #[test] + fn delta_text_signs() { + let deficit = UsagePace { + stage: PaceStage::Ahead, + delta_percent: 14.2, + expected_used_percent: 50.0, + actual_used_percent: 64.2, + eta_secs: Some(3600.0), + will_last_to_reset: false, + }; + assert_eq!(deficit.delta_text(), "+14%"); + + let reserve = UsagePace { + stage: PaceStage::Behind, + delta_percent: -5.4, + expected_used_percent: 50.0, + actual_used_percent: 44.6, + eta_secs: None, + will_last_to_reset: true, + }; + assert_eq!(reserve.delta_text(), "-5%"); + } +}