Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "gswitch",
"private": true,
"version": "1.0.8",
"version": "1.0.9",
"type": "module",
"packageManager": "pnpm@10.17.1",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "gswitch"
version = "1.0.8"
version = "1.0.9"
description = "A focused local Codex account switcher"
authors = ["Ginbing"]
edition = "2021"
Expand Down
145 changes: 115 additions & 30 deletions src-tauri/src/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,44 +363,89 @@ fn app_server_command(codex_home: &Path) -> Command {
#[cfg(windows)]
fn windows_app_server_command() -> Command {
if let Some(path) = configured_codex_binary() {
if path
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("cmd"))
{
if let Some(npm_dir) = path.parent() {
let script = npm_dir
.join("node_modules")
.join("@openai")
.join("codex")
.join("bin")
.join("codex.js");
if script.is_file() {
// Calling a batch shim through cmd makes its child process
// lifecycle ambiguous and failed with redirected stdin in
// the installed Codex 0.144.5 runtime. Invoke its actual
// Node entrypoint instead.
let bundled_node = npm_dir.join("node.exe");
let mut command = if bundled_node.is_file() {
Command::new(bundled_node)
} else {
Command::new("node.exe")
};
command.args([script, PathBuf::from("app-server")]);
return command;
}
return windows_app_server_command_for(&path);
}

let mut command = Command::new("codex");
command.arg("app-server");
command
}

#[cfg(windows)]
fn windows_app_server_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");
return command;
}
if let Some(npm_dir) = path.parent() {
let script = npm_dir
.join("node_modules")
.join("@openai")
.join("codex")
.join("bin")
.join("codex.js");
if script.is_file() {
// The npm shim is a batch file and cannot reliably carry the
// App Server's redirected stdin. Use its Node entrypoint when
// the package's native executable is unavailable.
let bundled_node = npm_dir.join("node.exe");
let mut command = if bundled_node.is_file() {
Command::new(bundled_node)
} else {
Command::new("node.exe")
};
command.args([script, PathBuf::from("app-server")]);
return command;
}
}

let mut command = Command::new(path);
command.arg("app-server");
return command;
}

let mut command = Command::new("codex");
let mut command = Command::new(path);
command.arg("app-server");
command
}

#[cfg(windows)]
fn windows_npm_codex_executable(shim: &Path) -> Option<PathBuf> {
if !shim
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("codex.cmd"))
{
return None;
}
let npm_dir = shim.parent()?;
let (package, target) = match std::env::consts::ARCH {
"x86_64" => ("codex-win32-x64", "x86_64-pc-windows-msvc"),
"aarch64" => ("codex-win32-arm64", "aarch64-pc-windows-msvc"),
_ => return None,
};
let package_root = npm_dir.join("node_modules").join("@openai").join("codex");
// The official npm launcher resolves the platform package through Node's
// package lookup and falls back to its own vendor directory. Match those
// shipped locations so a desktop launch does not depend on node.exe in PATH.
[
package_root
.join("node_modules")
.join("@openai")
.join(package)
.join("vendor"),
npm_dir
.join("node_modules")
.join("@openai")
.join(package)
.join("vendor"),
package_root.join("vendor"),
]
.into_iter()
.map(|vendor| vendor.join(target).join("bin").join("codex.exe"))
.find(|executable| executable.is_file())
}

fn configured_codex_binary() -> Option<PathBuf> {
let explicit = env::var_os("GSWITCH_CODEX_BIN")
.map(PathBuf::from)
Expand Down Expand Up @@ -646,6 +691,40 @@ pub fn account_metadata(result: &Value) -> Result<AccountMetadata, String> {
mod tests {
use super::*;

#[cfg(windows)]
#[test]
fn npm_codex_uses_its_native_executable_without_node_in_path() {
use std::ffi::OsStr;

let root = test_profile_root();
let npm_dir = root.join("npm");
let shim = npm_dir.join("codex.cmd");
let (package, target) = match std::env::consts::ARCH {
"x86_64" => ("codex-win32-x64", "x86_64-pc-windows-msvc"),
"aarch64" => ("codex-win32-arm64", "aarch64-pc-windows-msvc"),
_ => return,
};
let native = npm_dir
.join("node_modules")
.join("@openai")
.join("codex")
.join("node_modules")
.join("@openai")
.join(package)
.join("vendor")
.join(target)
.join("bin")
.join("codex.exe");
fs::create_dir_all(native.parent().expect("native parent")).expect("package tree");
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);
assert_eq!(command.get_program(), native.as_os_str());
assert_eq!(command.get_args().next(), Some(OsStr::new("app-server")));
fs::remove_dir_all(root).expect("remove fixture");
}

#[cfg(not(windows))]
#[test]
fn derives_the_official_unix_installer_location() {
Expand Down Expand Up @@ -765,6 +844,7 @@ mod tests {
.join(format!("gswitch-app-server-test-{}", Uuid::new_v4()));
{
let profile = TempCodexHome::create(&root).expect("profile");
let profile_path = profile.path.clone();
let mut server = AppServer::start(&profile.path).expect("start app server");
let config = server.config_read(1).expect("read config");
let expected_version = config
Expand Down Expand Up @@ -792,6 +872,11 @@ mod tests {
.and_then(Value::as_str),
Some("file")
);
drop(server);
profile
.cleanup()
.expect("remove completed isolated profile");
assert!(!profile_path.exists());
}
let _ = fs::remove_dir_all(root);
}
Expand Down
6 changes: 3 additions & 3 deletions src-tauri/src/switching.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,8 @@ fn check_effective_file_store(
.map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?,
)
.map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?;
let mut server =
AppServer::start(&profile.path).map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?;
let mut server = AppServer::start(&profile.path)
.map_err(|_| SwitchFailureCode::CodexAppServerUnavailable)?;
runtime::ensure_no_external_codex(&[server.pid()]).map_err(|_| SwitchFailureCode::CodexOpen)?;
let config = server
.config_read(1)
Expand All @@ -491,7 +491,7 @@ fn check_effective_file_store(
drop(server);
profile
.cleanup()
.map_err(|_| SwitchFailureCode::CodexConfigUnavailable)?;
.map_err(|_| SwitchFailureCode::CodexConfigCleanupFailed)?;
Ok(())
}

Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,9 @@ pub enum SwitchFailureCode {
CredentialsChanged,
RecoveryRequired,
LocalVerificationFailed,
CodexAppServerUnavailable,
CodexConfigUnavailable,
CodexConfigCleanupFailed,
CurrentCredentialUnreadable,
CurrentAccountNotSaved,
TargetCheckUnavailable,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "GSwitch",
"version": "1.0.8",
"version": "1.0.9",
"identifier": "com.ginbing.gswitch",
"build": {
"beforeDevCommand": "pnpm dev",
Expand Down
4 changes: 3 additions & 1 deletion src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -813,7 +813,9 @@ describe("GSwitch account workspace", () => {
["file_store_required", /Enable file-backed Codex credentials/],
["credentials_changed", /Codex credentials changed during the switch/],
["recovery_required", /Complete the protected switch recovery/],
["codex_config_unavailable", /could not check Codex configuration/],
["codex_app_server_unavailable", /could not start the Codex CLI service/],
["codex_config_unavailable", /could not finish checking Codex settings/],
["codex_config_cleanup_failed", /could not finish the local Codex check/],
["current_credential_unreadable", /could not read the current Codex sign-in/],
["current_account_not_saved", /Save the current Codex account/],
["local_verification_failed", /could not read its local account state/],
Expand Down
4 changes: 4 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,9 @@ const switchFailureCodes = new Set<SwitchFailureCode>([
"credentials_changed",
"recovery_required",
"local_verification_failed",
"codex_app_server_unavailable",
"codex_config_unavailable",
"codex_config_cleanup_failed",
"current_credential_unreadable",
"current_account_not_saved",
"target_check_unavailable",
Expand Down Expand Up @@ -165,7 +167,9 @@ function switchFailureMessage(t: Translator, error: unknown, account: AccountVie
credentials_changed: "switch.error.credentialsChanged",
recovery_required: "switch.error.recoveryRequired",
local_verification_failed: "switch.error.localVerificationFailed",
codex_app_server_unavailable: "switch.error.codexAppServerUnavailable",
codex_config_unavailable: "switch.error.codexConfigUnavailable",
codex_config_cleanup_failed: "switch.error.codexConfigCleanupFailed",
current_credential_unreadable: "switch.error.currentCredentialUnreadable",
current_account_not_saved: "switch.error.currentAccountNotSaved",
target_check_unavailable: "switch.error.targetCheckUnavailable",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@ const en = {
"switch.error.credentialsChanged": "GSwitch stopped because Codex credentials changed during the switch. Review the current account, then retry {name}.",
"switch.error.recoveryRequired": "GSwitch did not switch {name}. Complete the protected switch recovery before trying again.",
"switch.error.localVerificationFailed": "GSwitch could not read its local account state before switching {name}. Your Codex account was not changed.",
"switch.error.codexConfigUnavailable": "GSwitch could not check Codex configuration before switching {name}. Make sure Codex is installed and try again. Your current account was not changed.",
"switch.error.codexAppServerUnavailable": "GSwitch could not start the Codex CLI service before switching {name}. Check or update your Codex CLI installation, then try again. Your current account was not changed.",
"switch.error.codexConfigUnavailable": "GSwitch could not finish checking Codex settings before switching {name}. Restart GSwitch and try again. Your current account was not changed.",
"switch.error.codexConfigCleanupFailed": "GSwitch could not finish the local Codex check before switching {name}. Restart GSwitch and try again. Your current account was not changed.",
"switch.error.currentCredentialUnreadable": "GSwitch could not read the current Codex sign-in. Check the current Codex account before switching to {name}; nothing was changed.",
"switch.error.currentAccountNotSaved": "Save the current Codex account in GSwitch before switching to {name}. Nothing was changed.",
"switch.error.targetCheckUnavailable": "GSwitch could not check {name} with ChatGPT. Your Codex account was not changed. Retry when the connection is available.",
Expand Down Expand Up @@ -437,7 +439,9 @@ const zhCN: Record<keyof typeof en, string> = {
"switch.error.credentialsChanged": "切换期间 Codex 凭据发生变化,GSwitch 已停止。请检查当前账户后重试 {name}。",
"switch.error.recoveryRequired": "GSwitch 未切换到 {name}。请先完成受保护的切换恢复步骤。",
"switch.error.localVerificationFailed": "切换到 {name} 前,GSwitch 无法读取本地账户状态。当前 Codex 账户未改变。",
"switch.error.codexConfigUnavailable": "切换到 {name} 前,GSwitch 无法检查 Codex 配置。请确认 Codex 已安装后重试;当前账户未改变。",
"switch.error.codexAppServerUnavailable": "切换到 {name} 前,GSwitch 无法启动 Codex CLI 的本地服务。请检查或更新 Codex CLI 后重试;当前账户未改变。",
"switch.error.codexConfigUnavailable": "切换到 {name} 前,GSwitch 未能完成 Codex 设置检查。请重启 GSwitch 后重试;当前账户未改变。",
"switch.error.codexConfigCleanupFailed": "切换到 {name} 前,GSwitch 未能结束本地 Codex 检查。请重启 GSwitch 后重试;当前账户未改变。",
"switch.error.currentCredentialUnreadable": "GSwitch 无法读取当前 Codex 登录状态。请检查当前账户后再切换到 {name};账户未改变。",
"switch.error.currentAccountNotSaved": "请先将当前 Codex 账户保存到 GSwitch,再切换到 {name}。当前账户未改变。",
"switch.error.targetCheckUnavailable": "GSwitch 暂时无法向 ChatGPT 验证 {name}。当前 Codex 账户未改变,请在连接恢复后重试。",
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ export type SwitchFailureCode =
| "credentials_changed"
| "recovery_required"
| "local_verification_failed"
| "codex_app_server_unavailable"
| "codex_config_unavailable"
| "codex_config_cleanup_failed"
| "current_credential_unreadable"
| "current_account_not_saved"
| "target_check_unavailable"
Expand Down
Loading