From 748996036eed5b4cac84c8e9b09cd9d7852628d7 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Mon, 3 Aug 2026 16:34:31 +0800 Subject: [PATCH 1/2] feat: add remembered app close behavior settings --- src-tauri/src/main.rs | 65 ++++++++++++++++++++ src-tauri/src/usage.rs | 1 + src/App.tsx | 50 ++++++++++++++-- src/i18n/ja.ts | 10 ++++ src/i18n/resources.ts | 20 +++++++ src/pages/ConfigPanel.tsx | 122 +++++++++++++++++++++++++++++++++++++- src/styles.css | 52 ++++++++++++++++ 7 files changed, 314 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 5349aba..c49e4bf 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -479,6 +479,7 @@ struct GuiConfigFile { allow_lan: bool, host: String, run_on_startup: bool, + close_behavior: WindowsCloseBehavior, window_width: Option, window_height: Option, auth_dir: String, @@ -504,6 +505,30 @@ enum WindowsCloseAction { MinimizeToTray, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum WindowsCloseBehavior { + Ask, + Exit, + MinimizeToTray, +} + +impl Default for WindowsCloseBehavior { + fn default() -> Self { + Self::Ask + } +} + +impl WindowsCloseBehavior { + fn as_str(self) -> &'static str { + match self { + Self::Ask => "ask", + Self::Exit => "exit", + Self::MinimizeToTray => "minimize-to-tray", + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] struct GuiApiKeyEntry { key: String, @@ -567,6 +592,7 @@ impl Default for GuiConfigFile { allow_lan: false, host: "127.0.0.1".to_string(), run_on_startup: false, + close_behavior: WindowsCloseBehavior::Ask, window_width: Some(DEFAULT_MAIN_WINDOW_WIDTH), window_height: Some(DEFAULT_MAIN_WINDOW_HEIGHT), auth_dir: DEFAULT_AUTH_DIR.to_string(), @@ -599,6 +625,7 @@ struct GuiConfigPresence { api_keys: Option>, api_access_remarks: Option>, management_secret_key: Option, + close_behavior: Option, codex_session_repair_on_launch: Option, usage_statistics_enabled: Option, plugins_enabled: Option, @@ -616,6 +643,7 @@ struct GuiSettings { port: u16, allow_lan: bool, run_on_startup: bool, + close_behavior: WindowsCloseBehavior, } #[derive(Clone, Serialize)] @@ -1348,6 +1376,16 @@ impl GuiConfigState { }) } + fn set_close_behavior( + &self, + close_behavior: WindowsCloseBehavior, + ) -> Result { + self.update(|config| { + config.close_behavior = close_behavior; + Ok(()) + }) + } + fn set_codex_session_repair_on_launch(&self, enabled: bool) -> Result { self.update(|config| { config.codex_session_repair_on_launch = enabled; @@ -1450,6 +1488,7 @@ impl From<&GuiConfigFile> for GuiSettings { port: config.port, allow_lan: config.allow_lan, run_on_startup: config.run_on_startup, + close_behavior: config.close_behavior, } } } @@ -1632,8 +1671,18 @@ fn set_app_locale( #[tauri::command] fn resolve_windows_close_request( app: tauri::AppHandle, + gui_config_state: tauri::State<'_, GuiConfigState>, action: WindowsCloseAction, + remember: Option, ) -> Result<(), String> { + if remember.unwrap_or(false) { + let close_behavior = match action { + WindowsCloseAction::Exit => WindowsCloseBehavior::Exit, + WindowsCloseAction::MinimizeToTray => WindowsCloseBehavior::MinimizeToTray, + }; + gui_config_state.set_close_behavior(close_behavior)?; + } + match action { WindowsCloseAction::Exit => app.exit(0), WindowsCloseAction::MinimizeToTray => { @@ -1649,6 +1698,15 @@ fn resolve_windows_close_request( Ok(()) } +#[tauri::command] +fn save_app_close_behavior( + gui_config_state: tauri::State<'_, GuiConfigState>, + behavior: WindowsCloseBehavior, +) -> Result { + let config = gui_config_state.set_close_behavior(behavior)?; + Ok(GuiSettings::from(&config)) +} + fn inspect_agent_config_statuses( app: &tauri::AppHandle, config: &GuiConfigFile, @@ -15243,6 +15301,9 @@ fn load_or_create_gui_config() -> Result { if presence.locale.is_none() { changed = true; } + if presence.close_behavior.is_none() { + changed = true; + } if presence.codex_session_repair_on_launch.is_none() { changed = true; } @@ -15579,6 +15640,7 @@ fn write_gui_config_to_path(config: &GuiConfigFile, config_path: &Path) -> Resul ("allow-lan", value(config.allow_lan)), ("host", value(config.host.as_str())), ("run-on-startup", value(config.run_on_startup)), + ("close-behavior", value(config.close_behavior.as_str())), ("auth-dir", value(config.auth_dir.as_str())), ( "management-secret-key", @@ -18068,6 +18130,7 @@ fn main() { save_api_access_remark, set_app_locale, resolve_windows_close_request, + save_app_close_behavior, get_agent_config_statuses, refresh_agent_config_statuses, get_agent_models, @@ -19583,6 +19646,7 @@ model: assert!(content.contains("port = 8317")); assert!(content.contains("allow-lan = false")); assert!(content.contains("run-on-startup = false")); + assert!(content.contains("close-behavior = \"ask\"")); assert!(content.contains("window-width = 1531")); assert!(content.contains("window-height = 891")); assert!(content.contains("auth-dir = \"../oauth\"")); @@ -22260,6 +22324,7 @@ custom_option = "keep-original" allow_lan: true, host: "0.0.0.0".to_string(), run_on_startup: false, + close_behavior: WindowsCloseBehavior::Ask, window_width: None, window_height: None, auth_dir: path_to_string(&fixed_oauth_dir().unwrap()), diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 2681133..f345d0d 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -2193,6 +2193,7 @@ mod tests { allow_lan: false, host: "127.0.0.1".to_string(), run_on_startup: false, + close_behavior: crate::WindowsCloseBehavior::Ask, window_width: None, window_height: None, auth_dir: String::new(), diff --git a/src/App.tsx b/src/App.tsx index 504610d..7e6362a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -80,12 +80,18 @@ const pages = [ type PageId = (typeof pages)[number]['id']; type WindowsCloseAction = 'exit' | 'minimize-to-tray'; +type WindowsCloseBehavior = 'ask' | WindowsCloseAction; type WindowsClosePrompt = { resolvingAction: WindowsCloseAction | null; + rememberChoice: boolean; error: string | null; }; +type GuiSettings = { + closeBehavior: WindowsCloseBehavior; +}; + function HomePage() { return ; } @@ -155,13 +161,28 @@ function AppContent() { let disposed = false; let stopListening: (() => void) | undefined; - void listen('windows-close-requested', () => { + const handleWindowsCloseRequest = async () => { + try { + const settings = await invoke('get_gui_settings'); + if (settings.closeBehavior !== 'ask') { + await resolveWindowsCloseRequest(settings.closeBehavior, false); + return; + } + } catch (error) { + console.error('读取关闭行为设置失败', error); + } + setWindowsClosePrompt((current) => current ?? { resolvingAction: null, + rememberChoice: false, error: null, }, ); + }; + + void listen('windows-close-requested', () => { + void handleWindowsCloseRequest(); }) .then((stop) => { if (disposed) { @@ -206,7 +227,10 @@ function AppContent() { } }; - const resolveWindowsCloseRequest = async (action: WindowsCloseAction) => { + const resolveWindowsCloseRequest = async ( + action: WindowsCloseAction, + remember = windowsClosePrompt?.rememberChoice ?? false, + ) => { setWindowsClosePrompt((current) => current ? { @@ -218,7 +242,7 @@ function AppContent() { ); try { - await invoke('resolve_windows_close_request', { action }); + await invoke('resolve_windows_close_request', { action, remember }); setWindowsClosePrompt(null); } catch (error) { setWindowsClosePrompt((current) => @@ -228,7 +252,11 @@ function AppContent() { resolvingAction: null, error: error instanceof Error ? error.message : String(error), } - : current, + : { + resolvingAction: null, + rememberChoice: false, + error: error instanceof Error ? error.message : String(error), + }, ); } }; @@ -415,6 +443,20 @@ function AppContent() { {windowsClosePrompt.error} ) : null} +
+
+ ) : activeSubpage === 'software' ? ( +
+
+
+
+
+
+ + {softwareSettingsLoading + ? t('common.loading') + : softwareSettings === null + ? t('common.unavailable') + : softwareCloseBehaviorDirty + ? t('config.network.unsaved') + : t('config.network.saved')} + + +
+
+
+
+ {t('config.software.closeBehavior')} +

{t('config.software.description')}

+ {t('config.software.closeBehaviorHint')} +
+ +
+
+
) : (
Date: Mon, 3 Aug 2026 17:03:39 +0800 Subject: [PATCH 2/2] fix(agents): show Codex CLI and App versions separately --- src-tauri/src/main.rs | 202 ++++++++++++++++++++++++++++++++++++--- src/i18n/ja.ts | 2 + src/i18n/resources.ts | 4 + src/pages/AgentsPage.tsx | 25 ++++- src/styles.css | 8 ++ 5 files changed, 223 insertions(+), 18 deletions(-) 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)); }