From ab3c282791becff47de63088d1c28aa643214acb Mon Sep 17 00:00:00 2001
From: Cmochance <3216202644@qq.com>
Date: Fri, 29 May 2026 11:23:05 +0800
Subject: [PATCH 1/4] feat(settings): add auto-detect button for Codex CLI path
The app must locate the real `codex` binary; auto-discovery sometimes
lands on a wrong/stale path and some users don't know how to set it by
hand. Add an "Auto-detect" button to Settings -> Codex CLI path that
force-rescans (ignoring the cached/override path) every common location
plus PATH and verifies each candidate is actually runnable via
`codex --version` (timeout-guarded, off the UI thread). A lone runnable
hit is applied immediately; several open the picker dialog with the
verified candidates; none falls back to manual entry -- and the empty
case distinguishes "no codex installed" from "codex present but won't run".
Backend: new `redetect_codex_cli_path` command (async/spawn_blocking) +
`CodexPathResolver::redetect_runnable_paths` + shared
`wait_child_with_timeout`; mac/win symmetric `probe_codex_runnable` +
`redetect_runnable_codex_cli_paths`. Frontend (shared, serves mac+win):
button, handler, en/zh strings. README (x2) + CHANGELOG updated.
Hardening from local review: diagnostic logs on probe/wait failure,
lone-hit set-rejection fallback to the dialog, candidate cap, and tests
for wait_child_with_timeout (shared) + probe_codex_runnable (win, runs on
the Linux CI job).
---
CHANGELOG.md | 4 +
README.md | 4 +-
README.zh-CN.md | 4 +-
src-tauri/mac/front/index.html | 1 +
src-tauri/mac/runtime/process.rs | 80 +++++++++
src-tauri/shared/commands/actions.rs | 28 ++-
src-tauri/shared/front/actions.ts | 90 +++++++++-
src-tauri/shared/front/base.css | 8 +
src-tauri/shared/front/i18n.ts | 18 ++
src-tauri/shared/front/render.ts | 2 +
src-tauri/shared/front/tauri.ts | 14 ++
src-tauri/shared/front/types.ts | 7 +
src-tauri/shared/runtime/codex_cli_path.rs | 188 ++++++++++++++++++++-
src-tauri/shared/runtime/models.rs | 15 ++
src-tauri/src/lib.rs | 1 +
src-tauri/win/front/index.html | 1 +
src-tauri/win/runtime/process.rs | 112 +++++++++++-
17 files changed, 558 insertions(+), 19 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 902c0b4..2ae238c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index c81808c..6f7b99f 100644
--- a/README.md
+++ b/README.md
@@ -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,方便单纯调样式。
@@ -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 / 历史吗
diff --git a/README.zh-CN.md b/README.zh-CN.md
index c81808c..6f7b99f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -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,方便单纯调样式。
@@ -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 / 历史吗
diff --git a/src-tauri/mac/front/index.html b/src-tauri/mac/front/index.html
index 371656b..4c8502f 100644
--- a/src-tauri/mac/front/index.html
+++ b/src-tauri/mac/front/index.html
@@ -151,6 +151,7 @@
Profiles
Codex CLI path
diff --git a/src-tauri/mac/runtime/process.rs b/src-tauri/mac/runtime/process.rs
index 2d28aa5..3b09354 100644
--- a/src-tauri/mac/runtime/process.rs
+++ b/src-tauri/mac/runtime/process.rs
@@ -252,6 +252,10 @@ impl CodexPathResolver for MacosCodexPathResolver {
fn suggested_paths(&self, codex_home: &Path) -> Vec {
suggested_codex_cli_paths(Some(codex_home))
}
+
+ fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec {
+ redetect_runnable_codex_cli_paths(Some(codex_home))
+ }
}
/// Soft cap on how long the PATH walk can spend stat'ing entries
@@ -301,6 +305,82 @@ pub fn suggested_codex_cli_paths(codex_home: Option<&Path>) -> Vec {
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;
+
+/// Whether `path` is an actually-runnable codex CLI: it must be a file
+/// and `codex --version` must exit successfully within the probe
+/// timeout. This is what lets auto-detect reject a stale or broken path
+/// that a mere existence check would accept. The "ran but failed" and
+/// "couldn't spawn" cases are logged so a broken install leaves a
+/// diagnostic trail instead of looking identical to "not found".
+fn probe_codex_runnable(path: &Path) -> bool {
+ if !path.is_file() {
+ return false;
+ }
+ let mut command = Command::new(path);
+ command
+ .arg("--version")
+ .stdin(Stdio::null())
+ .stdout(Stdio::null())
+ .stderr(Stdio::null());
+ match command.spawn() {
+ Ok(child) => match crate::shared::codex_cli_path::wait_child_with_timeout(
+ child,
+ RUNNABLE_PROBE_TIMEOUT,
+ ) {
+ Some(status) if status.success() => true,
+ Some(status) => {
+ eprintln!(
+ "codex probe: {} ran but `--version` exited unsuccessfully ({status})",
+ path.display()
+ );
+ false
+ }
+ None => false,
+ },
+ Err(error) => {
+ eprintln!("codex probe: failed to spawn {}: {error}", path.display());
+ false
+ }
+ }
+}
+
+/// Force a fresh scan for the Settings auto-detect button: gather every
+/// candidate the discovery + suggestion paths know about (login-shell
+/// resolver, fixed install locations, PATH), then keep only those that
+/// pass the runnable probe. Ignores the cached/override path entirely so
+/// a wrong saved path can be corrected.
+pub fn redetect_runnable_codex_cli_paths(codex_home: Option<&Path>) -> Vec {
+ let managed_shim = codex_home.map(managed_shim_path);
+ let mut candidates: Vec = Vec::new();
+
+ // The login-shell resolver catches version-manager installs (nvm /
+ // asdf) that aren't on the GUI app's PATH; suggestions cover the
+ // fixed locations plus a bounded PATH walk.
+ if let Some(shell_path) = discover_real_codex_cli_from_shell(managed_shim.as_deref()) {
+ 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(|path| probe_codex_runnable(path))
+ .collect()
+}
+
fn codex_app_candidates() -> Vec {
let mut candidates = vec![PathBuf::from("/Applications/Codex.app")];
if let Some(home) = env::var_os("HOME") {
diff --git a/src-tauri/shared/commands/actions.rs b/src-tauri/shared/commands/actions.rs
index 581a023..1b86cd6 100644
--- a/src-tauri/shared/commands/actions.rs
+++ b/src-tauri/shared/commands/actions.rs
@@ -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")]
@@ -222,6 +222,28 @@ pub fn clear_codex_cli_path() -> Result {
))
}
+/// 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 {
+ 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 {
Ok(crate::shared::login_cancel::cancel_login_in_progress())
diff --git a/src-tauri/shared/front/actions.ts b/src-tauri/shared/front/actions.ts
index eebba20..ea89041 100644
--- a/src-tauri/shared/front/actions.ts
+++ b/src-tauri/shared/front/actions.ts
@@ -34,12 +34,13 @@ import {
refreshActiveProfileQuotaSilent,
refreshAllOauthProfilePlansSilent,
refreshProfile,
+ redetectCodexCliPath,
renameProfile,
setCodexCliPath,
switchProfile,
updateProfileBaseUrl,
} from "@front-shared/tauri";
-import type { CodexCliStatus } from "@front-shared/types";
+import type { CodexCliRedetectResult, CodexCliStatus } from "@front-shared/types";
import {
applyLocale,
elements,
@@ -678,7 +679,7 @@ function codexCliSourceLabel(source: CodexCliStatus["source"]): string {
}
}
-function renderCodexCliStatus(status: CodexCliStatus): void {
+function renderCodexCliStatus(status: CodexCliStatus, detected?: string[]): void {
if (status.resolved_path) {
elements.codexCliCurrentValue.textContent = status.resolved_path;
elements.codexCliCurrentSource.textContent = ` (${codexCliSourceLabel(status.source)})`;
@@ -691,8 +692,17 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
elements.clearCodexCliButton.hidden = true;
}
+ // When auto-detect routes here with verified-runnable candidates, show
+ // those (labelled accordingly) instead of the raw common-location
+ // hints, so the user only picks from paths that actually ran.
+ const showingDetected = detected !== undefined && detected.length > 0;
+ const chips = showingDetected ? detected : status.suggested_paths;
+ elements.codexCliSuggestionsHeading.textContent = showingDetected
+ ? t(state.locale, "codexCliDetectedHeading")
+ : t(state.locale, "codexCliSuggestionsHeading");
+
elements.codexCliSuggestions.replaceChildren();
- if (status.suggested_paths.length === 0) {
+ if (chips.length === 0) {
const empty = document.createElement("p");
empty.className = "codex-cli-suggestions-empty";
empty.textContent = t(state.locale, "codexCliSuggestionsEmpty");
@@ -700,7 +710,7 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
return;
}
- for (const path of status.suggested_paths) {
+ for (const path of chips) {
const button = document.createElement("button");
button.type = "button";
button.className = "codex-cli-suggestion";
@@ -715,7 +725,10 @@ function renderCodexCliStatus(status: CodexCliStatus): void {
}
}
-async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise {
+async function openCodexCliDialog(
+ onSavedRetry?: () => Promise,
+ detectedCandidates?: string[],
+): Promise {
pendingLoginRetry = onSavedRetry ?? null;
clearDialogError(elements.codexCliDialogError);
elements.codexCliInput.value = "";
@@ -734,19 +747,79 @@ async function openCodexCliDialog(onSavedRetry?: () => Promise): Promise 0) {
+ elements.codexCliInput.value = detectedCandidates[0];
+ } else if (status.resolved_path) {
elements.codexCliInput.value = status.resolved_path;
}
elements.submitCodexCliButton.textContent = onSavedRetry
? t(state.locale, "codexCliRetryLogin")
: t(state.locale, "save");
- renderCodexCliStatus(status);
+ renderCodexCliStatus(status, detectedCandidates);
elements.codexCliDialog.showModal();
elements.codexCliInput.focus();
elements.codexCliInput.select();
}
+/// Settings "auto-detect" button: force a fresh runnable scan and act on
+/// the result — apply a lone hit, let the user pick when several survive,
+/// or fall back to the manual dialog when none do.
+async function handleDetectCodexCli(): Promise {
+ const button = elements.settingsCodexCliDetectButton;
+ if (button.disabled) {
+ return;
+ }
+ button.disabled = true;
+ button.textContent = t(state.locale, "settingsCodexCliDetecting");
+ try {
+ const result: CodexCliRedetectResult = await redetectCodexCliPath();
+ if (result.candidates.length === 1) {
+ // Lone runnable hit → apply it straight away. If the backend's
+ // set/validate then rejects it (managed shim, or the file vanished
+ // between probe and set), don't dump the raw error — fall back to
+ // the dialog prefilled with the candidate so the user can adjust.
+ const only = result.candidates[0];
+ try {
+ const status = await setCodexCliPath(only);
+ applyCodexCliSettingsDisplay(status);
+ showToast(t(state.locale, "codexCliDetectApplied", { path: only }));
+ } catch {
+ applyCodexCliSettingsDisplay(result.status);
+ void openCodexCliDialog(undefined, [only]);
+ }
+ } else if (result.candidates.length === 0) {
+ // Nothing runnable. Distinguish "no codex anywhere" from "codex
+ // exists on disk but none would run" (a broken install, not a
+ // missing one) via the on-disk suggestions in the refreshed status.
+ applyCodexCliSettingsDisplay(result.status);
+ const foundButBroken = result.status.suggested_paths.length > 0;
+ showToast(
+ t(state.locale, foundButBroken ? "codexCliDetectFoundButBroken" : "codexCliDetectNone"),
+ true,
+ );
+ void openCodexCliDialog();
+ } else {
+ // Several runnable hits → let the user choose in the dialog.
+ applyCodexCliSettingsDisplay(result.status);
+ showToast(
+ t(state.locale, "codexCliDetectMultiple", { count: String(result.candidates.length) }),
+ );
+ void openCodexCliDialog(undefined, result.candidates);
+ }
+ } catch (error) {
+ showToast(
+ error instanceof Error ? error.message : t(state.locale, "codexCliDetectFailed"),
+ true,
+ );
+ } finally {
+ button.disabled = false;
+ button.textContent = t(state.locale, "settingsCodexCliDetect");
+ }
+}
+
function closeCodexCliDialog(): void {
pendingLoginRetry = null;
elements.codexCliDialog.close();
@@ -933,6 +1006,9 @@ export function bootstrap(): void {
elements.codexCliForm.addEventListener("submit", (event) => {
void handleSubmitCodexCliPath(event as SubmitEvent);
});
+ elements.settingsCodexCliDetectButton.addEventListener("click", () => {
+ void handleDetectCodexCli();
+ });
elements.settingsCodexCliButton.addEventListener("click", () => {
void openCodexCliDialog();
});
diff --git a/src-tauri/shared/front/base.css b/src-tauri/shared/front/base.css
index ad69c09..e734a19 100644
--- a/src-tauri/shared/front/base.css
+++ b/src-tauri/shared/front/base.css
@@ -988,6 +988,14 @@ p {
min-width: 0;
}
+/* Two action buttons now share this row with the path value (auto-detect
+ + change). Drop the fixed min-width so they size to their label and
+ leave the flexible remainder to the (ellipsised) path value. */
+.settings-cli-inline .settings-action-button {
+ min-width: auto;
+ flex: none;
+}
+
.settings-value--inline {
flex: 1;
min-width: 0;
diff --git a/src-tauri/shared/front/i18n.ts b/src-tauri/shared/front/i18n.ts
index 48e8f70..0eaebc1 100644
--- a/src-tauri/shared/front/i18n.ts
+++ b/src-tauri/shared/front/i18n.ts
@@ -242,6 +242,15 @@ const enMessages = {
settingsCodexCli: "Codex CLI path",
settingsCodexCliChange: "Change",
settingsCodexCliEmpty: "Not detected",
+ settingsCodexCliDetect: "Auto-detect",
+ settingsCodexCliDetecting: "Detecting…",
+ codexCliDetectedHeading: "Detected (verified runnable)",
+ codexCliDetectApplied: "Detected and set: {path}",
+ codexCliDetectNone: "Couldn't auto-detect codex. Set the path manually below.",
+ codexCliDetectFoundButBroken:
+ "Found codex on disk, but none of them would run. Reinstall codex or set a working path below.",
+ codexCliDetectMultiple: "Found {count} runnable codex binaries — pick one.",
+ codexCliDetectFailed: "Auto-detect failed.",
} as const;
export type MessageKey = keyof typeof enMessages;
@@ -488,6 +497,15 @@ const messages: Record = {
settingsCodexCli: "Codex CLI 路径",
settingsCodexCliChange: "更改",
settingsCodexCliEmpty: "未检测到",
+ settingsCodexCliDetect: "自动检测",
+ settingsCodexCliDetecting: "检测中…",
+ codexCliDetectedHeading: "已检测到(已验证可运行)",
+ codexCliDetectApplied: "已检测并设置:{path}",
+ codexCliDetectNone: "未能自动检测到 codex,请在下方手动设置路径。",
+ codexCliDetectFoundButBroken:
+ "找到了 codex,但都无法运行。请重装 codex 或在下方指定一个可用路径。",
+ codexCliDetectMultiple: "检测到 {count} 个可运行的 codex,请选择一个。",
+ codexCliDetectFailed: "自动检测失败。",
},
};
diff --git a/src-tauri/shared/front/render.ts b/src-tauri/shared/front/render.ts
index 86dd902..6669e6e 100644
--- a/src-tauri/shared/front/render.ts
+++ b/src-tauri/shared/front/render.ts
@@ -116,6 +116,7 @@ export const elements = {
settingsCodexCliLabel: requiredElement("settings-codex-cli-label"),
settingsCodexCliValue: requiredElement("settings-codex-cli-value"),
settingsCodexCliButton: requiredElement("settings-codex-cli-button"),
+ settingsCodexCliDetectButton: requiredElement("settings-codex-cli-detect-button"),
codexCliDialog: requiredElement("codex-cli-dialog"),
codexCliForm: requiredElement("codex-cli-form"),
codexCliDialogTitle: requiredElement("codex-cli-dialog-title"),
@@ -758,6 +759,7 @@ export function applyLocale(): void {
elements.submitCodexCliButton.textContent = t(state.locale, "save");
elements.settingsCodexCliLabel.textContent = t(state.locale, "settingsCodexCli");
elements.settingsCodexCliButton.textContent = t(state.locale, "settingsCodexCliChange");
+ elements.settingsCodexCliDetectButton.textContent = t(state.locale, "settingsCodexCliDetect");
// Version label is locale-independent but lives next to the i18n
// settings rows; set it here so a single render pass paints both.
// `__CODEX_APP_VERSION__` is injected by Vite from `package.json` so
diff --git a/src-tauri/shared/front/tauri.ts b/src-tauri/shared/front/tauri.ts
index d62675e..0b05e36 100644
--- a/src-tauri/shared/front/tauri.ts
+++ b/src-tauri/shared/front/tauri.ts
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type {
ActionResponse,
+ CodexCliRedetectResult,
CodexCliStatus,
CommandError,
CurrentCard,
@@ -334,6 +335,15 @@ async function invokeCommand(command: string, args?: Record)
source: command === "set_codex_cli_path" ? "user_override" : "discovery",
suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"],
}) as Promise;
+ case "redetect_codex_cli_path":
+ return Promise.resolve({
+ candidates: ["/preview/codex"],
+ status: {
+ resolved_path: "/preview/codex",
+ source: "user_override",
+ suggested_paths: ["/preview/codex", "/preview/usr/local/bin/codex"],
+ },
+ }) as Promise;
case "cancel_codex_login":
return Promise.resolve(true) as Promise;
case "open_profile_folder":
@@ -464,6 +474,10 @@ export function clearCodexCliPath(): Promise {
return invokeCommand("clear_codex_cli_path");
}
+export function redetectCodexCliPath(): Promise {
+ return invokeCommand("redetect_codex_cli_path");
+}
+
export function cancelCodexLogin(): Promise {
return invokeCommand("cancel_codex_login");
}
diff --git a/src-tauri/shared/front/types.ts b/src-tauri/shared/front/types.ts
index 17c52e0..9187073 100644
--- a/src-tauri/shared/front/types.ts
+++ b/src-tauri/shared/front/types.ts
@@ -98,4 +98,11 @@ export interface CodexCliStatus {
suggested_paths: string[];
}
+export interface CodexCliRedetectResult {
+ /** Paths verified runnable by the forced scan, deduped, best-first. */
+ candidates: string[];
+ /** Refreshed status snapshot so the Settings row can update in step. */
+ status: CodexCliStatus;
+}
+
export type ShellRoute = "dashboard" | "profiles" | "settings" | "guide";
diff --git a/src-tauri/shared/runtime/codex_cli_path.rs b/src-tauri/shared/runtime/codex_cli_path.rs
index 91a697c..0d4538e 100644
--- a/src-tauri/shared/runtime/codex_cli_path.rs
+++ b/src-tauri/shared/runtime/codex_cli_path.rs
@@ -1,6 +1,7 @@
-//! Shared `InstallState` schema + `CodexPathResolver` trait + the four
+//! Shared `InstallState` schema + `CodexPathResolver` trait + the
//! Tauri-command helpers (`get_codex_cli_status` / `set_codex_cli_path`
-//! / `clear_codex_cli_path` / `build_codex_cli_status`).
+//! / `clear_codex_cli_path` / `redetect_codex_cli_path` /
+//! `build_codex_cli_status`).
//!
//! Before this module each platform (`mac/runtime/process.rs` +
//! `mac/runtime/profile_actions.rs` and the Windows mirrors) carried
@@ -13,11 +14,14 @@
//! the `CodexPathResolver` trait so this shared layer is OS-agnostic.
use std::path::{Path, PathBuf};
+use std::process::{Child, ExitStatus};
+use std::thread;
+use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use crate::errors::AppResult;
-use crate::models::CodexCliStatus;
+use crate::models::{CodexCliRedetectResult, CodexCliStatus};
/// Persistent install metadata. Both mac and Windows used to declare
/// this struct independently; consolidating here keeps the on-disk
@@ -77,6 +81,13 @@ pub trait CodexPathResolver {
/// Common install locations that exist on disk right now. Frontend
/// renders these as click-to-fill chips in the dialog.
fn suggested_paths(&self, codex_home: &Path) -> Vec;
+
+ /// Force a fresh scan that ignores the cached/override path and
+ /// returns only candidates verified runnable via `codex --version`
+ /// (deduped, best-first). Backs the Settings "auto-detect" button:
+ /// `resolve_with_source` trusts a previously-saved path, so when
+ /// that path is wrong the user needs this to rescan from scratch.
+ fn redetect_runnable_paths(&self, codex_home: &Path) -> Vec;
}
/// Build the snapshot the front-end consumes. Used by both
@@ -129,6 +140,57 @@ pub fn clear_codex_cli_path(
build_codex_cli_status(resolver, codex_home)
}
+/// Force a fresh detection scan and report which candidates are
+/// runnable, alongside a refreshed status snapshot. Backs the Settings
+/// "auto-detect" button — the front-end auto-applies a lone candidate
+/// and lets the user pick when several survive the probe.
+pub fn redetect_codex_cli_path(
+ resolver: &dyn CodexPathResolver,
+ codex_home: &Path,
+) -> CodexCliRedetectResult {
+ let candidates = resolver
+ .redetect_runnable_paths(codex_home)
+ .into_iter()
+ .map(|path| path.to_string_lossy().into_owned())
+ .collect();
+ CodexCliRedetectResult {
+ candidates,
+ status: build_codex_cli_status(resolver, codex_home),
+ }
+}
+
+/// Poll-wait for a child process, killing it if it outlives `timeout`.
+/// Shared by the per-platform `codex --version` runnable probes so a
+/// hung or input-waiting candidate can't wedge the re-detection scan.
+/// The platforms own `Command` construction (console hiding, Windows
+/// extension resolution); only this bounded wait is common.
+pub fn wait_child_with_timeout(mut child: Child, timeout: Duration) -> Option {
+ let deadline = Instant::now() + timeout;
+ loop {
+ match child.try_wait() {
+ Ok(Some(status)) => return Some(status),
+ Ok(None) => {
+ if Instant::now() >= deadline {
+ let _ = child.kill();
+ let _ = child.wait();
+ return None;
+ }
+ thread::sleep(Duration::from_millis(20));
+ }
+ Err(error) => {
+ // A real try_wait failure (EINTR, ECHILD, a Windows handle
+ // error) is indistinguishable from a clean timeout to the
+ // caller — both return None — so leave a diagnostic trail
+ // instead of silently demoting the candidate.
+ eprintln!("wait_child_with_timeout: try_wait failed: {error}");
+ let _ = child.kill();
+ let _ = child.wait();
+ return None;
+ }
+ }
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -148,6 +210,10 @@ mod tests {
// return Err(that AppError) to test ? propagation.
set_error: RefCell