diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index c49e4bf..6be4b6f 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -657,6 +657,8 @@ struct AgentConfigStatus { executable_path: Option, launch_targets: Vec, version: Option, + cli_version: Option, + app_version: Option, config_paths: Vec, config_exists: bool, config_valid: bool, @@ -3308,6 +3310,7 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon "unconfigured" }; let error = errors.into_iter().next(); + let cli_version = executable.as_deref().and_then(read_agent_version); AgentConfigStatus { id: PI_AGENT_ID.to_string(), name: PI_AGENT_NAME.to_string(), @@ -3316,7 +3319,9 @@ fn inspect_pi_provider_status(home: &Path, port: u16, api_key: &str) -> AgentCon plugin_installed, executable_path: launch_targets.first().map(|target| target.detail.clone()), launch_targets, - version: executable.as_deref().and_then(read_agent_version), + version: cli_version.clone(), + cli_version, + app_version: None, config_paths: vec![path_to_string(&config_path), path_to_string(&settings_path)], config_exists, config_valid, @@ -3692,18 +3697,20 @@ fn inspect_agent_config( }; let launch_targets = agent_launch_targets(client, home); let executable = find_agent_executable(client, home); - let version = if client == AgentClient::ClaudeDesktop { - read_claude_desktop_version(home) - } else { - executable.as_deref().and_then(read_agent_version) + let cli_version = executable.as_deref().and_then(read_agent_version); + let app_version = match client { + AgentClient::ClaudeDesktop => read_claude_desktop_version(home), + AgentClient::Codex => read_codex_app_version(home), + _ => None, }; - let installed = version.is_some(); + let version = cli_version.clone().or_else(|| app_version.clone()); + let installed = agent_client_is_installed(client, &launch_targets, version.as_deref()); let mut warnings = Vec::new(); if !client.supported_platform() { warnings.push("当前平台不支持 Claude Desktop 3P 配置".to_string()); } else if launch_targets.is_empty() && config_exists { warnings.push("只检测到配置文件,未在 PATH 中找到客户端命令".to_string()); - } else if !launch_targets.is_empty() && version.is_none() { + } else if !installed && !launch_targets.is_empty() && version.is_none() { warnings.push("检测到客户端入口,但无法读取有效版本,未标记为已安装".to_string()); } if let Some(message) = error.as_ref() { @@ -3721,6 +3728,8 @@ fn inspect_agent_config( executable_path: launch_targets.first().map(|target| target.detail.clone()), launch_targets, version, + cli_version, + app_version, config_paths: paths.iter().map(|path| path_to_string(path)).collect(), config_exists, config_valid, @@ -3954,6 +3963,22 @@ fn is_managed_agent_base_url(value: &str) -> bool { value.starts_with("http://127.0.0.1:") || value.starts_with("http://localhost:") } +fn agent_client_is_installed( + client: AgentClient, + launch_targets: &[AgentLaunchTarget], + version: Option<&str>, +) -> bool { + if client == AgentClient::Codex { + // Codex can be installed as either a CLI or a desktop app. Discovery + // of either real entry is enough; version lookup is independent. + launch_targets + .iter() + .any(|target| target.id == "cli" || target.id == "app") + } else { + version.is_some() + } +} + fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec { let mut targets = Vec::new(); if client == AgentClient::ClaudeDesktop { @@ -3974,12 +3999,6 @@ fn agent_launch_targets(client: AgentClient, home: &Path) -> Vec Option { find_claude_desktop_executable(home).and_then(|path| read_agent_version(&path)) } +fn read_codex_app_version(home: &Path) -> Option { + #[cfg(target_os = "windows")] + { + match find_codex_app_target(home)? { + CodexAppTarget::WindowsAppId(_) => read_windows_codex_store_version(), + CodexAppTarget::Application(path) => { + read_windows_executable_version(&path).or_else(|| read_agent_version(&path)) + } + } + } + #[cfg(target_os = "macos")] + { + let CodexAppTarget::Application(path) = find_codex_app_target(home)?; + read_macos_app_version(&path) + } + #[cfg(not(any(target_os = "windows", target_os = "macos")))] + { + let _ = home; + None + } +} + +#[cfg(target_os = "macos")] +fn read_macos_app_version(application: &Path) -> Option { + let info_plist = application.join("Contents/Info.plist"); + for key in ["CFBundleShortVersionString", "CFBundleVersion"] { + let output = Command::new("/usr/bin/plutil") + .args(["-extract", key, "raw", "-o", "-"]) + .arg(&info_plist) + .output() + .ok()?; + if !output.status.success() { + continue; + } + if let Some(version) = + normalize_detected_agent_version(&String::from_utf8_lossy(&output.stdout)) + { + return Some(version); + } + } + None +} + #[cfg(target_os = "windows")] fn windows_system_root() -> PathBuf { env::var_os("SystemRoot") @@ -4239,6 +4301,42 @@ if ($package -and $package.Version) { parse_windows_claude_desktop_version_output(&String::from_utf8_lossy(&output.stdout)) } +#[cfg(target_os = "windows")] +fn read_windows_codex_store_version() -> Option { + const VERSION_SCRIPT: &str = r#" +$ErrorActionPreference = 'SilentlyContinue' +$ProgressPreference = 'SilentlyContinue' +$package = @(Get-AppxPackage) | + Where-Object { + $_.Name -in @('OpenAI.Codex', 'OpenAI.CodexBeta', 'OpenAI.ChatGPT') -or + $_.PackageFamilyName -match '^OpenAI\.(Codex|CodexBeta|ChatGPT)_' + } | + Sort-Object { [version]$_.Version } -Descending | + Select-Object -First 1 +if ($package -and $package.Version) { + Write-Output "VERSION:$($package.Version)" +} +"#; + + let encoded_command = windows_powershell_encoded_command(VERSION_SCRIPT); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + &encoded_command, + ]); + configure_background_command(&mut command); + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + parse_windows_codex_version_output(&String::from_utf8_lossy(&output.stdout)) +} + #[cfg(target_os = "windows")] fn find_windows_registered_codex_app_target() -> Option { const DISCOVERY_SCRIPT: &str = r#" @@ -4342,6 +4440,43 @@ fn windows_powershell_encoded_command(script: &str) -> String { STANDARD.encode(bytes) } +#[cfg(target_os = "windows")] +fn windows_powershell_single_quoted_literal(value: &str) -> String { + format!("'{}'", value.replace('\'', "''")) +} + +#[cfg(target_os = "windows")] +fn read_windows_executable_version(path: &Path) -> Option { + let path = windows_powershell_single_quoted_literal(&path_to_string(path)); + let script = format!( + r#" +$ErrorActionPreference = 'SilentlyContinue' +$item = Get-Item -LiteralPath {path} +$version = $item.VersionInfo.ProductVersion +if ($version) {{ + Write-Output "VERSION:$version" +}} +"# + ); + let encoded_command = windows_powershell_encoded_command(&script); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + &encoded_command, + ]); + configure_background_command(&mut command); + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + parse_windows_codex_version_output(&String::from_utf8_lossy(&output.stdout)) +} + #[cfg(target_os = "windows")] fn parse_windows_codex_app_discovery_output(output: &str) -> Option { output.lines().find_map(|line| { @@ -4373,6 +4508,15 @@ fn parse_windows_claude_desktop_version_output(output: &str) -> Option { }) } +#[cfg(target_os = "windows")] +fn parse_windows_codex_version_output(output: &str) -> Option { + output.lines().find_map(|line| { + line.trim() + .strip_prefix("VERSION:") + .and_then(normalize_detected_agent_version) + }) +} + #[cfg(target_os = "windows")] fn find_windows_codex_app_id_via_registry() -> Option { const PACKAGES_KEY: &str = r"HKCU\Software\Classes\Local Settings\Software\Microsoft\Windows\CurrentVersion\AppModel\Repository\Packages"; @@ -20826,6 +20970,32 @@ oauth-model-alias: assert_eq!(GuiConfigFile::default().locale, "zh-CN"); } + #[test] + fn codex_installation_is_detected_from_either_cli_or_app_entry() { + let cli_target = vec![AgentLaunchTarget { + id: "cli".to_string(), + label: "Codex CLI".to_string(), + detail: "codex".to_string(), + }]; + let app_target = vec![AgentLaunchTarget { + id: "app".to_string(), + label: "ChatGPT App".to_string(), + detail: "Microsoft Store · OpenAI.Codex_2p2nqsd0c76g0!App".to_string(), + }]; + + assert!(agent_client_is_installed( + AgentClient::Codex, + &cli_target, + None + )); + assert!(agent_client_is_installed( + AgentClient::Codex, + &app_target, + None + )); + assert!(!agent_client_is_installed(AgentClient::Codex, &[], None)); + } + #[cfg(target_os = "windows")] #[test] fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() { @@ -20893,6 +21063,12 @@ oauth-model-alias: ); assert!(parse_windows_claude_desktop_version_output("VERSION:\r\n").is_none()); assert!(parse_windows_claude_desktop_version_output("VERSION:unknown\r\n").is_none()); + assert_eq!( + parse_windows_codex_version_output("VERSION:26.727.6591.0\r\n").as_deref(), + Some("26.727.6591.0") + ); + assert!(parse_windows_codex_version_output("VERSION:\r\n").is_none()); + assert!(parse_windows_codex_version_output("VERSION:unknown\r\n").is_none()); } #[test] diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 7dd9c5c..e02fc0a 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -556,6 +556,8 @@ export const jaOverrides = { 'agents.clientDetected': 'クライアントを検出しました', 'agents.clientNotDetected': 'クライアントを検出できません', 'agents.clientVersion': 'クライアントバージョン', + 'agents.cliVersion': 'CLI バージョン', + 'agents.appVersion': 'App バージョン', 'agents.notFetched': '未取得', 'agents.useModel': '使用モデル', 'agents.pending': '未適用', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 98f2a8d..9cacc78 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -559,6 +559,8 @@ export const zhCN = { 'agents.clientDetected': '已检测到客户端', 'agents.clientNotDetected': '未检测到客户端', 'agents.clientVersion': '客户端版本', + 'agents.cliVersion': 'CLI 版本', + 'agents.appVersion': 'App 版本', 'agents.notFetched': '未获取', 'agents.useModel': '使用模型', 'agents.pending': '尚未应用', @@ -1510,6 +1512,8 @@ export const en: Record = { 'agents.clientDetected': 'Client detected', 'agents.clientNotDetected': 'Client not detected', 'agents.clientVersion': 'Client Version', + 'agents.cliVersion': 'CLI Version', + 'agents.appVersion': 'App Version', 'agents.notFetched': 'Not available', 'agents.useModel': 'Model', 'agents.pending': 'Not Applied', diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index 3941282..724ae45 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -66,6 +66,8 @@ type AgentConfigStatus = { executablePath: string | null; launchTargets: AgentLaunchTarget[]; version: string | null; + cliVersion: string | null; + appVersion: string | null; configValid: boolean; configured: boolean; currentModel: string | null; @@ -1259,15 +1261,28 @@ export function AgentsPage() { role="tabpanel" aria-labelledby="agent-subpage-tab-core" > -
+
{t('agents.installStatus')} {activeStatus?.installed ? t('agents.clientDetected') : t('agents.clientNotDetected')}
-
- {t('agents.clientVersion')} - {activeStatus?.version ?? t('agents.notFetched')} -
+ {selected === 'codex' ? ( + <> +
+ {t('agents.cliVersion')} + {activeStatus?.cliVersion ?? t('agents.notFetched')} +
+
+ {t('agents.appVersion')} + {activeStatus?.appVersion ?? t('agents.notFetched')} +
+ + ) : ( +
+ {t('agents.clientVersion')} + {activeStatus?.version ?? t('agents.notFetched')} +
+ )}
{activeStatus?.error || (!isPiClient && activeStatus?.warnings.length) ? ( diff --git a/src/styles.css b/src/styles.css index b8179a9..0e29ae8 100644 --- a/src/styles.css +++ b/src/styles.css @@ -5305,6 +5305,10 @@ textarea:focus-visible { min-height: 76px; } +.agent-status-grid.codex-status-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + .agent-status-grid > div { display: grid; min-width: 0; @@ -7282,6 +7286,10 @@ textarea:focus-visible { min-height: 0; } + .agent-status-grid.codex-status-grid { + grid-template-columns: 1fr; + } + .codex-session-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }