From 318a0548de020afa8cf1990cf62815c4b456285e Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:39:18 +0800 Subject: [PATCH 1/2] feat: inspect and update installed Codex CLI --- PRODUCT.md | 2 + docs/architecture.md | 2 + docs/design.md | 7 ++ docs/security.md | 8 ++ src-tauri/src/accounts.rs | 9 ++ src-tauri/src/app_server.rs | 75 +++++++++---- src-tauri/src/cli_update.rs | 213 ++++++++++++++++++++++++++++++++++++ src-tauri/src/commands.rs | 38 ++++++- src-tauri/src/lib.rs | 4 + src-tauri/src/types.rs | 17 +++ src/App.test.tsx | 39 +++++++ src/App.tsx | 93 ++++++++++++++++ src/api.ts | 4 + src/i18n.ts | 42 +++++++ src/styles.css | 38 ++++++- src/types.ts | 13 +++ 16 files changed, 575 insertions(+), 29 deletions(-) create mode 100644 src-tauri/src/cli_update.rs diff --git a/PRODUCT.md b/PRODUCT.md index 06758cc..74a9cce 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -26,6 +26,8 @@ GSwitch may: - consume an eligible reset credit only after an explicit user action; - Wake one or more eligible accounts only after an explicit user action; - expose the minimum settings and recovery actions required by those jobs. +- show the Codex CLI that GSwitch uses and request its official update when a + local CLI problem prevents those jobs. Multiple intake paths still end in one local saved-account model. They do not create a general provider platform. diff --git a/docs/architecture.md b/docs/architecture.md index 661ff38..b82b2d5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,6 +65,8 @@ Keep the backend flat and organized by concrete responsibility: Server; - `chatgpt.rs`: read-only ChatGPT quota and account-metadata HTTP boundary; - `codex.rs`: `CODEX_HOME`, effective storage mode, and live auth-file access; +- `cli_update.rs`: version inspection and explicit handoff to the installed + Codex CLI's official update command; - `identity.rs`: credential classification, stable non-secret identity, and fingerprints used for comparisons; - `intake.rs`: OAuth, bounded auth-document batch import, versioned portable export serialization, and API-key intake; diff --git a/docs/design.md b/docs/design.md index a09d3bd..cc7ebc5 100644 --- a/docs/design.md +++ b/docs/design.md @@ -73,6 +73,13 @@ explicit reset preserves GSwitch's damaged file without touching Codex. ## Interaction states +The toolbar offers a small **Codex CLI** entry. Its focused dialog shows the +version of the CLI GSwitch actually launches and an explicit update action only +when that CLI exposes the official `update` command. Do not claim a version is +the latest without checking a source for that claim. A missing or unsupported +CLI points to the official install guide. Update progress and failures stay in +the dialog; the account grid remains the primary workspace. + - Do not optimistically display a switch or redemption as complete. - Opening a dialog moves keyboard focus inside it. Tab stays inside, and closing restores focus to the launching control. A dialog cannot be dismissed while diff --git a/docs/security.md b/docs/security.md index 2b5cd90..02cb4b4 100644 --- a/docs/security.md +++ b/docs/security.md @@ -70,6 +70,14 @@ GSwitch-owned isolated App Server children are scoped to their operation and excluded only from that operation's external-process check. They are terminated when the operation ends. +GSwitch reads the installed CLI version from the same executable it uses for +App Server. It updates the CLI only after a user selects **Update CLI**, with +the GSwitch operation lock held and no external Codex runtime active. The CLI +itself chooses its supported installation method; GSwitch does not download or +replace CLI files or change account credentials. A completed update is followed +by a fresh version read. Missing or unsupported update commands use official +manual guidance. Tests use a fake CLI and never update the user's installation. + Saving the current file-backed ChatGPT account uses the live access-token snapshot for a Rust-only read-only account check. It matches the returned workspace to the locally derived stable identity, rereads the live credential diff --git a/src-tauri/src/accounts.rs b/src-tauri/src/accounts.rs index 45fc562..6b0baf4 100644 --- a/src-tauri/src/accounts.rs +++ b/src-tauri/src/accounts.rs @@ -738,6 +738,15 @@ impl AppState { .map_err(|error| error.to_string()) } + /// Updating the installed CLI must not overlap a GSwitch operation that + /// may launch App Server. This remains available when account storage + /// needs recovery because it never reads or changes saved accounts. + pub(crate) fn acquire_cli_update_operation( + &self, + ) -> Result, OperationAcquireFailure> { + self.acquire_operation_lock() + } + pub(crate) fn acquire_operation_for_switch( &self, ) -> Result, OperationAcquireFailure> { diff --git a/src-tauri/src/app_server.rs b/src-tauri/src/app_server.rs index c75bb46..adca707 100644 --- a/src-tauri/src/app_server.rs +++ b/src-tauri/src/app_server.rs @@ -321,16 +321,8 @@ impl Drop for AppServer { } fn app_server_command(codex_home: &Path) -> Command { - #[cfg(windows)] - let mut command = windows_app_server_command(); - - #[cfg(not(windows))] - let mut command = { - let mut command = - Command::new(configured_codex_binary().unwrap_or_else(|| PathBuf::from("codex"))); - command.arg("app-server"); - command - }; + let mut command = codex_command(); + command.arg("app-server"); command .current_dir(codex_home) @@ -360,26 +352,57 @@ fn app_server_command(codex_home: &Path) -> Command { command } -#[cfg(windows)] -fn windows_app_server_command() -> Command { +/// Resolve the same installed Codex CLI for App Server and explicit CLI +/// maintenance. A desktop process does not necessarily inherit a shell PATH. +pub(crate) fn codex_command() -> Command { + #[cfg(windows)] + { + if let Some(path) = configured_codex_binary() { + return windows_codex_command_for(&path); + } + } + + #[cfg(not(windows))] if let Some(path) = configured_codex_binary() { - return windows_app_server_command_for(&path); + return Command::new(path); } - let mut command = Command::new("codex"); - command.arg("app-server"); - command + Command::new("codex") } #[cfg(windows)] -fn windows_app_server_command_for(path: &Path) -> Command { +fn windows_codex_command_for(path: &Path) -> Command { if path .extension() .is_some_and(|extension| extension.eq_ignore_ascii_case("cmd")) { if let Some(native) = windows_npm_codex_executable(path) { let mut command = Command::new(native); - command.arg("app-server"); + // The official codex.js launcher supplies this install context to + // the native process. Preserve it when launching directly so + // `codex update` chooses npm rather than an unknown install method. + for key in [ + "CODEX_MANAGED_BY_BUN", + "CODEX_MANAGED_BY_PNPM", + "CODEX_MANAGED_BY_VITE_PLUS", + ] { + command.env_remove(key); + } + command.env("CODEX_MANAGED_BY_NPM", "1"); + if let Some(npm_dir) = path.parent() { + command.env( + "CODEX_MANAGED_PACKAGE_ROOT", + npm_dir.join("node_modules").join("@openai").join("codex"), + ); + // A desktop process may not have the npm shim directory on PATH. + // The CLI's own updater starts npm from this installation. + let mut search_path = std::ffi::OsString::from(npm_dir.as_os_str()); + if let Some(existing) = env::var_os("PATH") { + search_path.push(";"); + search_path.push(existing); + } + command.env("PATH", search_path); + } return command; } if let Some(npm_dir) = path.parent() { @@ -399,15 +422,13 @@ fn windows_app_server_command_for(path: &Path) -> Command { } else { Command::new("node.exe") }; - command.args([script, PathBuf::from("app-server")]); + command.arg(script); return command; } } } - let mut command = Command::new(path); - command.arg("app-server"); - command + Command::new(path) } #[cfg(windows)] @@ -719,9 +740,17 @@ mod tests { fs::write(&shim, b"npm shim fixture").expect("shim"); fs::write(&native, b"native binary fixture").expect("native executable"); - let command = windows_app_server_command_for(&shim); + let mut command = windows_codex_command_for(&shim); + command.arg("app-server"); assert_eq!(command.get_program(), native.as_os_str()); assert_eq!(command.get_args().next(), Some(OsStr::new("app-server"))); + assert_eq!( + command + .get_envs() + .find(|(key, _)| *key == OsStr::new("CODEX_MANAGED_BY_NPM")) + .and_then(|(_, value)| value), + Some(OsStr::new("1")) + ); fs::remove_dir_all(root).expect("remove fixture"); } diff --git a/src-tauri/src/cli_update.rs b/src-tauri/src/cli_update.rs new file mode 100644 index 0000000..d9fb56a --- /dev/null +++ b/src-tauri/src/cli_update.rs @@ -0,0 +1,213 @@ +use std::{ + io::Read, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +use crate::{ + accounts::{AppState, OperationAcquireFailure}, + app_server, runtime, + types::{CodexCliInfo, CodexCliUpdateFailure}, +}; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_PROBE_OUTPUT: usize = 32 * 1024; + +/// Only the version and presence of the official update subcommand cross IPC. +/// Never return CLI diagnostics, environment values, paths, or command output. +pub fn inspect() -> CodexCliInfo { + inspect_with(app_server::codex_command) +} + +fn inspect_with(mut command: impl FnMut() -> Command) -> CodexCliInfo { + let version = probe(command(), "--version") + .as_deref() + .and_then(parse_version); + let supports_update = version.is_some() + && probe(command(), "--help") + .as_deref() + .is_some_and(help_lists_update); + CodexCliInfo { + version, + supports_update, + } +} + +pub fn update(state: &AppState) -> Result { + let _operation = state + .acquire_cli_update_operation() + .map_err(|error| match error { + OperationAcquireFailure::Busy => CodexCliUpdateFailure::Busy, + OperationAcquireFailure::Failed(_) => CodexCliUpdateFailure::UpdateFailed, + })?; + let before = inspect(); + if before.version.is_none() { + return Err(CodexCliUpdateFailure::NotInstalled); + } + if !before.supports_update { + return Err(CodexCliUpdateFailure::Unsupported); + } + runtime::ensure_no_external_codex(&[]).map_err(|_| CodexCliUpdateFailure::CodexOpen)?; + + run_official_update(app_server::codex_command())?; + + let after = inspect(); + if after.version.is_none() { + return Err(CodexCliUpdateFailure::VerificationFailed); + } + Ok(after) +} + +fn run_official_update(mut command: Command) -> Result<(), CodexCliUpdateFailure> { + command + .arg("update") + .current_dir(std::env::temp_dir()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + hide_console(&mut command); + let status = command + .status() + .map_err(|_| CodexCliUpdateFailure::UpdateFailed)?; + if !status.success() { + return Err(CodexCliUpdateFailure::UpdateFailed); + } + Ok(()) +} + +fn probe(mut command: Command, argument: &str) -> Option { + command + .arg(argument) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + hide_console(&mut command); + let mut child = command.spawn().ok()?; + let Some(mut stdout) = child.stdout.take() else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + let reader = thread::Builder::new() + .name("gswitch-cli-probe".to_string()) + .spawn(move || { + let mut captured = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let count = stdout.read(&mut chunk).ok()?; + if count == 0 { + break; + } + let keep = count.min(MAX_PROBE_OUTPUT.saturating_sub(captured.len())); + captured.extend_from_slice(&chunk[..keep]); + } + String::from_utf8(captured).ok() + }) + .ok(); + let Some(reader) = reader else { + let _ = child.kill(); + let _ = child.wait(); + return None; + }; + + let deadline = Instant::now() + PROBE_TIMEOUT; + let success = loop { + match child.try_wait() { + Ok(Some(status)) => break status.success(), + Ok(None) if Instant::now() < deadline => thread::sleep(Duration::from_millis(30)), + Ok(None) | Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + break false; + } + } + }; + if success { + reader.join().ok().flatten() + } else { + // A launcher may leave a grandchild holding stdout open. Do not make + // the five-second timeout wait indefinitely for that pipe to close. + None + } +} + +fn parse_version(output: &str) -> Option { + let mut words = output.split_whitespace(); + if words.next()? != "codex-cli" { + return None; + } + let version = words.next()?; + if version.len() > 48 + || !version + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".+-".contains(c)) + { + return None; + } + let core = version.split(['-', '+']).next()?; + if core.split('.').count() != 3 || !core.split('.').all(|part| part.parse::().is_ok()) { + return None; + } + Some(version.to_string()) +} + +fn help_lists_update(output: &str) -> bool { + output + .lines() + .any(|line| line.trim_start().split_whitespace().next() == Some("update")) +} + +#[cfg(windows)] +fn hide_console(command: &mut Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_console(_command: &mut Command) {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_only_codex_cli_versions_and_an_explicit_update_command() { + assert_eq!( + parse_version("codex-cli 0.156.1\n"), + Some("0.156.1".to_string()) + ); + assert_eq!( + parse_version("codex-cli 0.157.0-beta.1\n"), + Some("0.157.0-beta.1".to_string()) + ); + assert_eq!(parse_version("other-cli 0.156.1\n"), None); + assert_eq!(parse_version("codex-cli not-a-version\n"), None); + assert!(help_lists_update( + "Commands:\n update Update Codex to the latest version\n" + )); + assert!(!help_lists_update("Commands:\n exec Run Codex\n")); + } + + #[cfg(windows)] + #[test] + fn inspects_a_fake_windows_cli_without_running_its_update_action() { + let root = + std::env::temp_dir().join(format!("gswitch-cli-fixture-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).expect("fixture dir"); + let script = root.join("codex.cmd"); + std::fs::write( + &script, + "@echo off\r\nif \"%1\"==\"--version\" echo codex-cli 0.156.1\r\nif \"%1\"==\"--help\" echo update Update Codex\r\nif \"%1\"==\"update\" echo called>\"%~dp0updated.txt\"\r\n", + ) + .expect("fixture script"); + let info = inspect_with(|| Command::new(&script)); + assert_eq!(info.version.as_deref(), Some("0.156.1")); + assert!(info.supports_update); + assert!(!root.join("updated.txt").exists()); + run_official_update(Command::new(&script)).expect("fake CLI update"); + assert!(root.join("updated.txt").exists()); + std::fs::remove_dir_all(root).expect("remove fixture"); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dea5f43..ba8eede 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4,12 +4,13 @@ use tauri_plugin_opener::OpenerExt; use crate::{ accounts::AppState, - codex, intake, migration, quota, switching, + cli_update, codex, intake, migration, quota, switching, types::{ - AccountView, AppSnapshot, ExportResult, ImportResult, LiveAccountView, MigrationPreview, - OAuthLoginStart, OAuthLoginStatus, QuotaRefreshFailure, QuotaRefreshFailureCode, QuotaView, - ResetCreditOutcome, RuntimeInfo, StorageStatus, SwitchFailure, SwitchFailureCode, - SwitchOutcome, UpdateDelivery, WakeOperationView, WakeStart, + AccountView, AppSnapshot, CodexCliInfo, CodexCliUpdateFailure, ExportResult, ImportResult, + LiveAccountView, MigrationPreview, OAuthLoginStart, OAuthLoginStatus, QuotaRefreshFailure, + QuotaRefreshFailureCode, QuotaView, ResetCreditOutcome, RuntimeInfo, StorageStatus, + SwitchFailure, SwitchFailureCode, SwitchOutcome, UpdateDelivery, WakeOperationView, + WakeStart, }, wake, }; @@ -33,6 +34,33 @@ pub async fn get_runtime_info() -> Result { run_blocking(codex::runtime_info).await } +#[tauri::command] +pub async fn get_codex_cli_info() -> CodexCliInfo { + tauri::async_runtime::spawn_blocking(cli_update::inspect) + .await + .unwrap_or(CodexCliInfo { + version: None, + supports_update: false, + }) +} + +#[tauri::command] +pub async fn update_codex_cli( + state: State<'_, AppState>, +) -> Result { + let state = state.inner().clone(); + tauri::async_runtime::spawn_blocking(move || cli_update::update(&state)) + .await + .map_err(|_| CodexCliUpdateFailure::UpdateFailed)? +} + +#[tauri::command] +pub fn open_codex_cli_guide(app: AppHandle) -> Result<(), String> { + app.opener() + .open_url("https://developers.openai.com/codex/cli/", None::<&str>) + .map_err(|_| "Unable to open the official Codex CLI guide".to_string()) +} + /// Supplies one credential-free initial workspace projection. When GSwitch's /// own account store is damaged, this deliberately /// avoids touching Codex configuration or credentials so the recovery screen diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 90d5ee5..d86c2b0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod accounts; mod app_server; mod chatgpt; +mod cli_update; mod codex; mod commands; mod identity; @@ -31,6 +32,9 @@ pub fn run() { }) .invoke_handler(tauri::generate_handler![ commands::get_runtime_info, + commands::get_codex_cli_info, + commands::update_codex_cli, + commands::open_codex_cli_guide, commands::get_app_snapshot, commands::list_accounts, commands::reset_damaged_account_store, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 119bab2..a0530b2 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -1,6 +1,23 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CodexCliInfo { + pub version: Option, + pub supports_update: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexCliUpdateFailure { + NotInstalled, + Unsupported, + Busy, + CodexOpen, + UpdateFailed, + VerificationFailed, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum AccountKind { diff --git a/src/App.test.tsx b/src/App.test.tsx index ac69828..496d77f 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -8,6 +8,9 @@ import type { AccountView, QuotaView, SwitchFailureCode } from "./types"; const mocks = vi.hoisted(() => ({ runtimeInfo: vi.fn(), + codexCliInfo: vi.fn(), + updateCodexCli: vi.fn(), + openCodexCliGuide: vi.fn(), appSnapshot: vi.fn(), listAccounts: vi.fn(), resetDamagedAccountStore: vi.fn(), @@ -109,6 +112,9 @@ const staleQuota: QuotaView = { }; function prepareDefaults() { + mocks.codexCliInfo.mockResolvedValue({ version: "0.156.1", supports_update: true }); + mocks.updateCodexCli.mockResolvedValue({ version: "0.157.0", supports_update: true }); + mocks.openCodexCliGuide.mockResolvedValue(undefined); mocks.runtimeInfo.mockResolvedValue({ codex_home: "C:\\Codex", auth_file_exists: false, @@ -201,6 +207,39 @@ describe("GSwitch account workspace", () => { delete (window as Window & { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; }); + it("shows and updates the same Codex CLI without touching accounts", async () => { + render(); + await userEvent.click(screen.getByRole("button", { name: "Codex CLI" })); + const dialog = await screen.findByRole("dialog", { name: "Codex CLI" }); + expect(await within(dialog).findByText("Installed version: 0.156.1")).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "Update CLI" })); + expect(await within(dialog).findByText("Update finished. Installed version: 0.157.0.")).toBeInTheDocument(); + expect(mocks.updateCodexCli).toHaveBeenCalledOnce(); + expect(mocks.switchAccount).not.toHaveBeenCalled(); + }); + + it("keeps CLI update failures in the Chinese dialog with a next step", async () => { + Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" }); + mocks.updateCodexCli.mockRejectedValue("codex_open"); + render(); + await userEvent.click(screen.getByRole("button", { name: "Codex CLI" })); + const dialog = await screen.findByRole("dialog", { name: "Codex CLI" }); + expect(await within(dialog).findByText("已安装版本:0.156.1")).toBeInTheDocument(); + await userEvent.click(within(dialog).getByRole("button", { name: "更新 CLI" })); + expect(await within(dialog).findByRole("alert")).toHaveTextContent("请退出 Codex 后再更新"); + expect(within(dialog).getByText("已安装版本:0.156.1")).toBeInTheDocument(); + }); + + it("does not offer an update to a CLI without the official command", async () => { + mocks.codexCliInfo.mockResolvedValue({ version: "0.100.0", supports_update: false }); + render(); + await userEvent.click(screen.getByRole("button", { name: "Codex CLI" })); + const dialog = await screen.findByRole("dialog", { name: "Codex CLI" }); + expect(await within(dialog).findByText("Installed version: 0.100.0")).toBeInTheDocument(); + expect(within(dialog).queryByRole("button", { name: "Update CLI" })).not.toBeInTheDocument(); + expect(within(dialog).getByRole("button", { name: "Official install guide" })).toBeInTheDocument(); + }); + it("uses the Simplified Chinese system locale and keeps the main account flow localized", async () => { Object.defineProperty(window.navigator, "language", { configurable: true, value: "zh-CN" }); render(); diff --git a/src/App.tsx b/src/App.tsx index d8a6adc..b523789 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,6 +18,7 @@ import { Plus, RefreshCw, ShieldAlert, + Terminal, Trash2, Upload, X, @@ -48,6 +49,8 @@ import { } from "./i18n"; import type { AccountView, + CodexCliInfo, + CodexCliUpdateFailure, LiveAccountView, MigrationCandidate, MigrationPreview, @@ -74,6 +77,7 @@ type Dialog = | "remove" | "export" | "wake" + | "codex-cli" | null; type AddMethod = "start" | "oauth" | "json" | "api-key" | "migration"; @@ -249,9 +253,18 @@ function accountGridHasGlobalMutation(busy: string | null) { "recover-switch", "recover-reset-credit", "reset-damaged-store", + "codex-cli-update", ].includes(busy) || busy.startsWith("switch:") || busy.startsWith("reset:") || busy.startsWith("remove:"); } +function cliUpdateFailureCode(error: unknown): CodexCliUpdateFailure { + const code = String(error); + if (["not_installed", "unsupported", "busy", "codex_open", "update_failed", "verification_failed"].includes(code)) { + return code as CodexCliUpdateFailure; + } + return "update_failed"; +} + function codexQuotaWindows(quota: QuotaView | undefined): QuotaWindow[] { return quota?.snapshot?.buckets .filter((bucket) => bucket.kind === "codex") @@ -804,6 +817,10 @@ export default function App() { const [resetConfirmation, setResetConfirmation] = useState(false); const [storageResetConfirmation, setStorageResetConfirmation] = useState(false); const [pendingUpdate, setPendingUpdate] = useState(null); + const [cliInfo, setCliInfo] = useState(null); + const [cliChecking, setCliChecking] = useState(false); + const [cliResult, setCliResult] = useState<{ kind: "updated" | "sameVersion"; version: string } | null>(null); + const [cliError, setCliError] = useState(null); const [updatePhase, setUpdatePhase] = useState("available"); const [updateProgress, setUpdateProgress] = useState(null); const jsonRef = useRef(null); @@ -822,6 +839,43 @@ export default function App() { saveLanguagePreference(preference); }; + const checkCodexCli = async () => { + setCliChecking(true); + setCliError(null); + try { + setCliInfo(await api.codexCliInfo()); + } catch { + setCliInfo(null); + setCliError("inspect_failed"); + } finally { + setCliChecking(false); + } + }; + + const openCodexCli = () => { + setCliInfo(null); + setCliResult(null); + setCliError(null); + setDialog("codex-cli"); + void checkCodexCli(); + }; + + const updateCodexCli = async () => { + const previousVersion = cliInfo?.version; + setCliResult(null); + setCliError(null); + setBusy("codex-cli-update"); + try { + const updated = await api.updateCodexCli(); + setCliInfo(updated); + setCliResult({ kind: previousVersion === updated.version ? "sameVersion" : "updated", version: updated.version || "—" }); + } catch (error) { + setCliError(cliUpdateFailureCode(error)); + } finally { + setBusy(null); + } + }; + const requestQuotaRefresh = useCallback((accountId: string): Promise => { const inFlight = quotaRefreshes.current.get(accountId); if (inFlight) { @@ -1643,6 +1697,10 @@ export default function App() { {t("common.addAccount")} +
@@ -1853,6 +1911,41 @@ export default function App() {
+ {dialog === "codex-cli" ? ( + setDialog(null)} t={t} title={t("toolbar.codexCli")}> +
+ {cliChecking ?

{t("cli.checking")}

: ( +

{cliInfo?.version ? t("cli.installed", { version: cliInfo.version }) : t("cli.missing")}

+ )} + {cliInfo?.version ?

{t("cli.context")}

: null} + {cliInfo?.version && !cliInfo.supports_update ?

{t("cli.unsupported")}

: null} + {cliInfo?.supports_update ?

{t("cli.closeCodex")}

: null} + {busy === "codex-cli-update" ?

{t("cli.updating")}

: null} + {cliResult ?

{t(cliResult.kind === "updated" ? "cli.updated" : "cli.sameVersion", { version: cliResult.version })}

: null} + {cliError ?

{t(({ + not_installed: "cli.error.notInstalled", + unsupported: "cli.error.unsupported", + busy: "cli.error.busy", + codex_open: "cli.error.codexOpen", + update_failed: "cli.error.updateFailed", + verification_failed: "cli.error.verificationFailed", + inspect_failed: "cli.error.inspectFailed", + open_guide: "cli.error.openGuide", + } as const)[cliError])}

: null} +
+ + + {cliInfo?.supports_update ? ( + + ) : null} +
+
+
+ ) : null} + {dialog === "add" ? ( {addMethod === "start" ? ( diff --git a/src/api.ts b/src/api.ts index 6db31d9..d5b0512 100644 --- a/src/api.ts +++ b/src/api.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { AccountView, AppSnapshot, + CodexCliInfo, ExportResult, ImportResult, LiveAccountView, @@ -22,6 +23,9 @@ export type UpdateDelivery = "installer_exits" | "relaunch_required" | "release_ // small, display-oriented capacity view. export const api = { runtimeInfo: () => invoke("get_runtime_info"), + codexCliInfo: () => invoke("get_codex_cli_info"), + updateCodexCli: () => invoke("update_codex_cli"), + openCodexCliGuide: () => invoke("open_codex_cli_guide"), appSnapshot: () => invoke("get_app_snapshot"), listAccounts: () => invoke("list_accounts"), resetDamagedAccountStore: () => diff --git a/src/i18n.ts b/src/i18n.ts index e1c4932..2f574d1 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -83,6 +83,7 @@ const en = { "firstRun.browserSignIn": "Browser sign-in", "firstRun.authJson": "Cockpit Tools export, Codex auth.json, or another supported account export", "toolbar.language": "Language", + "toolbar.codexCli": "Codex CLI", "toolbar.wakeAll": "Wake all", "toolbar.currentAccount": "Current Codex account", "toolbar.statusChecking": "Checking", @@ -91,6 +92,26 @@ const en = { "toolbar.statusUnknown": "Unknown account", "toolbar.statusFileStore": "Setup required", "toolbar.statusRecovery": "Recovery required", + "cli.checking": "Checking the Codex CLI used by GSwitch…", + "cli.installed": "Installed version: {version}", + "cli.missing": "GSwitch could not find a working Codex CLI.", + "cli.context": "GSwitch uses this CLI to check Codex settings before switching accounts.", + "cli.closeCodex": "Quit Codex before updating. Your saved accounts will not change.", + "cli.unsupported": "This CLI has no built-in update command. Update it with its installer or package manager.", + "cli.update": "Update CLI", + "cli.updating": "Updating Codex CLI… This may take a few minutes.", + "cli.updated": "Update finished. Installed version: {version}.", + "cli.sameVersion": "Update finished. Version remains {version}.", + "cli.checkAgain": "Check again", + "cli.guide": "Official install guide", + "cli.error.notInstalled": "The Codex CLI is unavailable. Install it, then check again.", + "cli.error.unsupported": "This CLI cannot update itself. Use its installer or package manager.", + "cli.error.busy": "Another GSwitch operation is running. Try again when it finishes.", + "cli.error.codexOpen": "Quit Codex, then try the update again.", + "cli.error.updateFailed": "The official Codex update did not finish. Try updating from a terminal or use the install guide.", + "cli.error.verificationFailed": "The update command finished, but GSwitch could not read the installed CLI version. Check the installation.", + "cli.error.inspectFailed": "GSwitch could not check the Codex CLI. Try again.", + "cli.error.openGuide": "GSwitch could not open the guide. Visit developers.openai.com/codex/cli/ in your browser.", "update.available": "GSwitch {version} is ready", "update.safe": "The signed update never touches your Codex accounts.", "update.install": "Update", @@ -398,6 +419,7 @@ const zhCN: Record = { "firstRun.browserSignIn": "浏览器登录", "firstRun.authJson": "Cockpit Tools 导出、Codex auth.json 或其他受支持的账户导出文件", "toolbar.language": "语言", + "toolbar.codexCli": "Codex CLI", "toolbar.wakeAll": "全部唤醒", "toolbar.currentAccount": "当前 Codex 账户", "toolbar.statusChecking": "检查中", @@ -406,6 +428,26 @@ const zhCN: Record = { "toolbar.statusUnknown": "未知账户", "toolbar.statusFileStore": "需要设置", "toolbar.statusRecovery": "需要恢复", + "cli.checking": "正在检查 GSwitch 使用的 Codex CLI…", + "cli.installed": "已安装版本:{version}", + "cli.missing": "GSwitch 找不到可用的 Codex CLI。", + "cli.context": "GSwitch 在切换账户前使用它检查 Codex 设置。", + "cli.closeCodex": "更新前请退出 Codex。已保存的账户不会改变。", + "cli.unsupported": "此 CLI 没有内置更新命令。请通过原安装程序或包管理器更新。", + "cli.update": "更新 CLI", + "cli.updating": "正在更新 Codex CLI…可能需要几分钟。", + "cli.updated": "更新已完成。已安装版本:{version}。", + "cli.sameVersion": "更新已完成。版本仍为 {version}。", + "cli.checkAgain": "重新检查", + "cli.guide": "官方安装指南", + "cli.error.notInstalled": "Codex CLI 不可用。请安装后重新检查。", + "cli.error.unsupported": "此 CLI 不支持自行更新。请通过原安装程序或包管理器更新。", + "cli.error.busy": "另一项 GSwitch 操作正在进行,请完成后重试。", + "cli.error.codexOpen": "请退出 Codex 后再更新。", + "cli.error.updateFailed": "Codex 官方更新未完成。请在终端中更新,或查看安装指南。", + "cli.error.verificationFailed": "更新命令已结束,但 GSwitch 无法读取已安装的 CLI 版本。请检查安装。", + "cli.error.inspectFailed": "GSwitch 无法检查 Codex CLI。请重试。", + "cli.error.openGuide": "GSwitch 无法打开指南。请在浏览器访问 developers.openai.com/codex/cli/。", "update.available": "GSwitch {version} 已可更新", "update.safe": "已签名更新不会触及你的 Codex 账户。", "update.install": "更新", diff --git a/src/styles.css b/src/styles.css index eeb7c7e..076e387 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1144,10 +1144,46 @@ summary:focus-visible { .migration-flow, .confirm-panel, .reset-details, -.wake-panel { +.wake-panel, +.cli-panel { padding: 20px; } +.cli-panel { + display: grid; + gap: 12px; +} + +.cli-panel p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.45; +} + +.cli-panel .cli-status { + display: flex; + align-items: center; + gap: 9px; + color: var(--text); + font-size: 15px; + font-weight: 700; +} + +.cli-panel .cli-result, +.cli-panel .cli-error { + padding: 10px 12px; + border-radius: 8px; +} + +.cli-panel .cli-result { color: var(--success); background: var(--success-soft); } +.cli-panel .cli-error { color: var(--danger); background: var(--danger-soft); } + +.cli-panel .modal-actions { + flex-wrap: wrap; + margin-top: 4px; +} + .add-methods { display: grid; gap: 14px; diff --git a/src/types.ts b/src/types.ts index 17c18cb..08d2783 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,19 @@ export interface RuntimeInfo { credential_store: CredentialStoreMode; } +export interface CodexCliInfo { + version?: string; + supports_update: boolean; +} + +export type CodexCliUpdateFailure = + | "not_installed" + | "unsupported" + | "busy" + | "codex_open" + | "update_failed" + | "verification_failed"; + export type StorageStatus = "ready" | "recovery_required"; export interface StorageView { From 27194f8eb5c1402ecc0777d4433796b8d61f7002 Mon Sep 17 00:00:00 2001 From: SquarePots <46488165+squarepots@users.noreply.github.com> Date: Fri, 25 Sep 2026 03:45:01 +0800 Subject: [PATCH 2/2] fix: satisfy strict CLI help lint --- src-tauri/src/cli_update.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/cli_update.rs b/src-tauri/src/cli_update.rs index d9fb56a..f8e11d7 100644 --- a/src-tauri/src/cli_update.rs +++ b/src-tauri/src/cli_update.rs @@ -155,7 +155,7 @@ fn parse_version(output: &str) -> Option { fn help_lists_update(output: &str) -> bool { output .lines() - .any(|line| line.trim_start().split_whitespace().next() == Some("update")) + .any(|line| line.split_whitespace().next() == Some("update")) } #[cfg(windows)]