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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Changelog

## 1.5.12 - 2026-05-29

- Settings → Codex CLI path gains an **Auto-detect** button next to "Change". Unlike the existing path self-check (which trusts the cached / override path), it force-rescans every common install location plus PATH and verifies each candidate is actually runnable via `codex --version`. A lone runnable hit is applied immediately; several open the dialog with the verified candidates to pick from; none falls back to the manual dialog. Targets the two cases the self-check can't: auto-detection landed on a wrong / stale path, or the user doesn't know where to point it. Backed by a new `redetect_codex_cli_path` command that runs on the blocking pool (each candidate probe spawns a child) with a per-candidate timeout so a hung binary can't wedge the scan. macOS + Windows symmetric.

## 1.5.11 - 2026-05-16

- Added experimental Linux x86_64 build to the release pipeline. Tagged releases now publish `.deb` (Debian/Ubuntu) and `.AppImage` (generic portable) artifacts alongside the existing macOS / Windows ones. Built on `ubuntu-22.04` (glibc 2.35) so binaries run on Ubuntu 22.04+ / Debian 12+ and equivalent distros. UI, profile switching, and plan / quota readout work as on the other platforms; Linux-native paths for Codex CLI discovery and `codex login` spawning are not separately adapted yet (the non-macOS code branch is currently reused), so feedback issues are welcome.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
- **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。
- **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。
- **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。

Expand Down Expand Up @@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *.

### 没有 Codex CLI 怎么办

应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。

### 切换账号会丢失原账号的 sessions / 历史吗

Expand Down
4 changes: 2 additions & 2 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
- **登录可取消**:进行中的 `codex login` OAuth 流程支持点击同一按钮取消(向子进程 SIGTERM / taskkill),解决浏览器关闭后应用卡在等待回调的场景。
- **plan / quota 智能缓存**:bulk plan refresh 在 6 小时窗口内跳过已确认账号,per-card 刷新按钮也共享同一缓存;切换 / 登录 / 刷新后直接复用 backend 写回的 snapshot,不重复发 IPC。
- **Custom Base URL**:每个账号可独立配置 `OPENAI_BASE_URL`;配置后按钮变红警示(自定义 Base 与 ChatGPT OAuth 账号互斥)。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。
- **Codex CLI 路径自检**:自动定位 `codex` 可执行(PATH / `~/.codex/bin` / Homebrew / nvm),找不到或路径错误时设置页可手动指定,结果写入 `install_state.json` 优先生效。设置页还提供「自动检测」按钮:忽略可能出错的缓存重新扫描所有常见位置,并用 `codex --version` 验证候选确实可运行——唯一命中直接应用,多个命中时让你选。
- **跨平台原生 Tauri**:macOS arm64 / x64 与 Windows x64 提供原生窗口、原生标题栏 / 关闭按钮,配套 5 套浅色 / 深色主题与中英文界面。
- **本地预览模式**:没有 Tauri 运行时(直接 `vite` 跑前端)时自动使用 mock snapshot,方便单纯调样式。

Expand Down Expand Up @@ -143,7 +143,7 @@ npm run version:check # CI 用:拒绝把 semver 字面量写回 *.

### 没有 Codex CLI 怎么办

应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。
应用启动后会探测 `codex` 路径;探测失败时设置页会标红「Codex CLI 路径未找到」,可手动指定(写入 `install_state.json` 的 `user_codex_path` 优先级最高)。也可以点设置页「Codex CLI 路径」行的「自动检测」按钮强制重新扫描,并用 `codex --version` 验证候选——比启动时的自检更主动,适合自动定位出错或不清楚 `codex` 装在哪的情况。未装 Codex CLI 也能用 plan / quota 查看(走 ChatGPT OAuth token 直接拉 rate-limits),但「登录」按钮和切换后启动 Codex 等动作需要 CLI 存在。

### 切换账号会丢失原账号的 sessions / 历史吗

Expand Down
1 change: 1 addition & 0 deletions src-tauri/mac/front/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ <h2 id="profiles-heading" class="section-title">Profiles</h2>
<strong id="settings-codex-cli-label">Codex CLI path</strong>
<div class="settings-cli-inline">
<p id="settings-codex-cli-value" class="settings-value settings-value--inline">--</p>
<button id="settings-codex-cli-detect-button" class="settings-action-button" type="button">Auto-detect</button>
<button id="settings-codex-cli-button" class="settings-action-button" type="button">Change</button>
</div>
</div>
Expand Down
135 changes: 126 additions & 9 deletions src-tauri/mac/runtime/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use std::time::Duration;
use crate::errors::{AppError, AppResult};
use crate::platform::hooks::PlatformHooks;
use crate::shared::codex_app_server::{fetch_account_snapshot, AppServerSnapshot};
use crate::models::CodexCliCandidate;
use crate::shared::codex_cli_path::CodexPathResolver;
pub use crate::shared::codex_cli_path::{InstallState, RealCodexPathSource};
use crate::shared::login_cancel::wait_for_login_or_cancel;
Expand Down Expand Up @@ -81,15 +82,14 @@ fn discover_real_codex_cli_from_shell(managed_shim_path: Option<&Path>) -> Optio
let managed_shim_text = managed_shim_path
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_default();
let output = Command::new(&resolver_path)
.arg(managed_shim_text)
.output()
.ok()?;
if !output.status.success() {
return None;
}

let stdout = String::from_utf8_lossy(&output.stdout);
let mut command = Command::new(&resolver_path);
command.arg(managed_shim_text);
// Bounded like the other shell spawns: even this first-party resolver
// script sources the login environment, which can stall on a hung rc.
let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout(
command,
LOGIN_SHELL_PROBE_TIMEOUT,
)?;
let resolved = stdout
.lines()
.map(str::trim)
Expand Down Expand Up @@ -252,6 +252,10 @@ impl CodexPathResolver for MacosCodexPathResolver {
fn suggested_paths(&self, codex_home: &Path) -> Vec<PathBuf> {
suggested_codex_cli_paths(Some(codex_home))
}

fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec<CodexCliCandidate> {
redetect_runnable_codex_cli_paths(Some(codex_home))
}
}

/// Soft cap on how long the PATH walk can spend stat'ing entries
Expand Down Expand Up @@ -301,6 +305,119 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec<PathBuf> {
suggestions
}

/// How long a single `codex --version` probe may run before we kill it
/// and treat the candidate as unusable. Keeps a hung or input-waiting
/// binary from wedging the auto-detect scan; a healthy codex answers
/// well under this.
const RUNNABLE_PROBE_TIMEOUT: Duration = Duration::from_secs(3);

/// Upper bound on how many candidates the auto-detect scan will probe.
/// Each probe spawns a child (up to `RUNNABLE_PROBE_TIMEOUT`), so without
/// a cap a pathological PATH with many `codex` entries could stall the
/// scan. Realistic machines have 1-3 candidates.
const MAX_PROBE_CANDIDATES: usize = 12;

/// Login shells source the user's full profile chain (nvm / asdf / brew
/// shellenv / network-y rc files), which can be slower than a bare
/// `--version`, so give the login-shell resolve a more generous budget —
/// but still hard-bounded so a hung profile can't wedge the whole scan.
const LOGIN_SHELL_PROBE_TIMEOUT: Duration = Duration::from_secs(8);

/// Probe whether `path` is a runnable codex CLI and capture its version.
/// `Some(version)` (possibly empty) means it's a file that ran and exited
/// 0; `None` means not-a-file, couldn't spawn, exited non-zero, or timed
/// out. The failure is logged so a broken install leaves a diagnostic
/// trail instead of looking identical to "not found".
fn probe_codex_version(path: &Path) -> Option<String> {
if !path.is_file() {
return None;
}
let mut command = Command::new(path);
command.arg("--version");
let result =
crate::shared::codex_cli_path::probe_version_with_timeout(command, RUNNABLE_PROBE_TIMEOUT);
if result.is_none() {
eprintln!(
"codex probe: {} is not a runnable codex (spawn / non-zero exit / timeout)",
path.display()
);
}
result
}

/// Resolve codex through the user's login shell, so installs on the
/// shell's PATH (nvm / asdf / brew / fnm / any rc-managed location) are
/// found even when the app was launched from Finder with the narrow
/// launchd PATH. A non-interactive login shell + `command -v` avoids
/// loading the user's `codex` shell *function* (if any), so we resolve to
/// the real binary; the result is verified to be an absolute file.
fn discover_codex_via_login_shell(managed_shim_path: Option<&Path>) -> Option<PathBuf> {
let shell = env::var_os("SHELL")?;
let mut command = Command::new(&shell);
command.args(["-lc", "command -v codex"]);
// Bounded via the shared helper: a slow / hung login profile (nvm,
// asdf, network-y rc files) must NOT wedge the scan the way an
// unbounded `.output()` would — this runs first and synchronously.
let stdout = crate::shared::codex_cli_path::run_capturing_stdout_with_timeout(
command,
LOGIN_SHELL_PROBE_TIMEOUT,
)?;
// `command -v` prints the resolved path last — after any banner a noisy
// profile may have echoed to stdout — so take the last non-empty line.
let resolved = stdout
.lines()
.map(str::trim)
.rev()
.find(|value| !value.is_empty())?;
let candidate = PathBuf::from(resolved);
// `command -v` of a function/alias returns a bare name, not a path —
// require an absolute path to a real file so we never feed that back.
if !candidate.is_absolute() || !candidate.is_file() {
return None;
}
if managed_shim_path.is_some_and(|managed| managed == candidate.as_path()) {
return None;
}
Some(candidate)
}

/// Force a fresh scan for the Settings auto-detect button: gather every
/// candidate the discovery + suggestion paths know about (login-shell
/// resolution, managed-shim resolver, Codex.app bundle, fixed install
/// locations, PATH), then keep only those that pass the runnable probe,
/// capturing each one's version. Ignores the cached/override path so a
/// wrong saved path can be corrected.
pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec<CodexCliCandidate> {
let managed_shim = codex_home.map(managed_shim_path);
let mut candidates: Vec<PathBuf> = Vec::new();

// Login-shell resolution first — catches nvm / asdf / brew / fnm
// installs on the user's PATH even under Finder's narrow launchd PATH.
if let Some(path) = discover_codex_via_login_shell(managed_shim.as_deref()) {
push_candidate(&mut candidates, path);
}
// The managed-shim resolver (present when codex_switch installed its
// shim) and the suggestion list (Codex.app bundle, fixed locations,
// bounded PATH walk) fill in the rest.
if let Some(shell_path) = discover_real_codex_cli_from_shell(managed_shim.as_deref()) {
Comment thread
Cmochance marked this conversation as resolved.
push_candidate(&mut candidates, shell_path);
}
for path in suggested_codex_cli_paths(codex_home) {
push_candidate(&mut candidates, path);
}

candidates
.into_iter()
.take(MAX_PROBE_CANDIDATES)
.filter_map(|path| {
probe_codex_version(&path).map(|version| CodexCliCandidate {
path: path.to_string_lossy().into_owned(),
version: (!version.is_empty()).then_some(version),
})
})
.collect()
}

fn codex_app_candidates() -> Vec<PathBuf> {
let mut candidates = vec![PathBuf::from("/Applications/Codex.app")];
if let Some(home) = env::var_os("HOME") {
Expand Down
28 changes: 25 additions & 3 deletions src-tauri/shared/commands/actions.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
use crate::errors::CommandError;
use crate::models::{
ActionResponse, AddProfilePayload, CodexCliStatus, OpenUrlPayload, ProfilePayload,
RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload, UpdateCheckResponse,
UpdateProfileBaseUrlPayload,
ActionResponse, AddProfilePayload, CodexCliRedetectResult, CodexCliStatus, OpenUrlPayload,
ProfilePayload, RenameProfilePayload, SetCodexCliPathPayload, UpdateCheckPayload,
UpdateCheckResponse, UpdateProfileBaseUrlPayload,
};

#[cfg(target_os = "macos")]
Expand Down Expand Up @@ -222,6 +222,28 @@ pub fn clear_codex_cli_path() -> Result<CodexCliStatus, CommandError> {
))
}

/// Force a fresh codex CLI detection scan for the Settings auto-detect
/// button. Runs on the blocking pool because it probes each candidate
/// with `codex --version`, which can take a second or two per path and
/// would otherwise stall the UI thread.
#[tauri::command]
pub async fn redetect_codex_cli_path() -> Result<CodexCliRedetectResult, CommandError> {
tauri::async_runtime::spawn_blocking(|| {
let codex_home = platform_runtime::paths::get_codex_home();
crate::shared::codex_cli_path::redetect_codex_cli_path(
platform_runtime::codex_cli_resolver(),
&codex_home,
)
})
.await
.map_err(|error| {
CommandError::new(
"CODEX_CLI_REDETECT_FAILED",
format!("Redetect task failed: {error}"),
)
})
}

#[tauri::command]
pub fn cancel_codex_login() -> Result<bool, CommandError> {
Ok(crate::shared::login_cancel::cancel_login_in_progress())
Expand Down
Loading
Loading