From b1421e6e1e820686a5a42e80ef2d1fe1154d2622 Mon Sep 17 00:00:00 2001 From: "Matt.Li" Date: Sun, 13 Sep 2026 17:07:21 +0800 Subject: [PATCH] fix(codex): activate Windows packages through their registered application Resolve the exact registered manifest application for all Desktop launch paths and preserve CDP arguments. Keep standalone launches unchanged and report package lookup failures before closing processes. Represent MSIX timezone support as renderer-only, including after CCSM restarts, without prompting an ineffective refresh. Validation: 7 Windows source-harness tests, 3 UI tests, TypeScript, rustfmt, Prettier and diff checks. User cold-start reproduction confirmed direct launch error 5 versus successful package activation and CDP. Full Tauri build remains for CI. --- src-tauri/src/codex_desktop.rs | 45 +++++- src-tauri/src/codex_egress_timezone.rs | 67 ++++++-- src-tauri/src/codex_egress_timezone_tests.rs | 52 ++++++ src-tauri/src/codex_runtime_refresh.rs | 88 +++------- src-tauri/src/codex_windows_launch.rs | 151 ++++++++++++++++++ src-tauri/src/settings.rs | 3 +- .../CodexEgressTimezoneStatusCard.test.tsx | 14 ++ .../codex/CodexEgressTimezoneStatusCard.tsx | 8 + src/lib/api/codexEgressTimezone.ts | 1 + 9 files changed, 343 insertions(+), 86 deletions(-) create mode 100644 src-tauri/src/codex_windows_launch.rs diff --git a/src-tauri/src/codex_desktop.rs b/src-tauri/src/codex_desktop.rs index 8487235d..67e29904 100644 --- a/src-tauri/src/codex_desktop.rs +++ b/src-tauri/src/codex_desktop.rs @@ -10,6 +10,10 @@ use sha2::{Digest, Sha256}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; +#[cfg(target_os = "windows")] +#[path = "codex_windows_launch.rs"] +pub(crate) mod windows_launch; + pub(crate) const DEFAULT_CODEX_DEBUG_PORT: u16 = 9229; pub(crate) const CDP_HTTP_TIMEOUT: Duration = Duration::from_secs(2); const CDP_CONNECT_TIMEOUT: Duration = Duration::from_secs(4); @@ -1776,6 +1780,10 @@ pub(crate) fn launch_codex_with_debug_port( ); } } + #[cfg(target_os = "windows")] + if let Some(app_id) = windows_launch::resolve_app_id(executable)? { + return launch_windows_app(&app_id, debug_port); + } let mut command = Command::new(executable); append_codex_debug_args(&mut command, debug_port); apply_codex_launch_timezone(&mut command, launch_timezone.as_deref()); @@ -1802,13 +1810,25 @@ fn apply_codex_launch_timezone(command: &mut Command, timezone: Option<&str>) { } } +#[cfg(target_os = "windows")] +pub(crate) fn launch_windows_app(app_id: &str, debug_port: u16) -> Result<(), String> { + windows_launch::activate(app_id, debug_port)?; + // The activation broker does not inherit our per-child environment. Renderer + // timezone emulation is applied separately by install_script over CDP. + crate::codex_egress_timezone::mark_codex_timezone_not_inherited(); + Ok(()) +} + +fn codex_debug_args(debug_port: u16) -> [String; 2] { + [ + format!("--remote-debugging-port={debug_port}"), + format!("--remote-allow-origins=http://127.0.0.1:{debug_port}"), + ] +} + /// 为 Desktop 启动命令追加 Chromium remote-debugging 参数。 fn append_codex_debug_args(command: &mut Command, debug_port: u16) { - command - .arg(format!("--remote-debugging-port={debug_port}")) - .arg(format!( - "--remote-allow-origins=http://127.0.0.1:{debug_port}" - )); + command.args(codex_debug_args(debug_port)); } /// Windows 下查找 Codex App 主进程的脚本。 @@ -2665,6 +2685,21 @@ fn version_tuple_from_package_name(name: &str) -> Vec { mod tests { use super::*; + #[test] + fn desktop_launch_transports_share_the_requested_debug_port() { + let mut command = Command::new("codex-desktop-placeholder"); + append_codex_debug_args(&mut command, 9231); + let arguments: Vec<_> = command + .get_args() + .map(|arg| arg.to_string_lossy()) + .collect(); + assert_eq!(arguments, codex_debug_args(9231)); + assert_eq!( + codex_debug_args(9231).join(" "), + "--remote-debugging-port=9231 --remote-allow-origins=http://127.0.0.1:9231" + ); + } + /// 返回当前测试平台的 Desktop 主程序文件名。 fn desktop_test_executable_name() -> &'static str { if cfg!(target_os = "windows") { diff --git a/src-tauri/src/codex_egress_timezone.rs b/src-tauri/src/codex_egress_timezone.rs index c40d912e..2352fd43 100644 --- a/src-tauri/src/codex_egress_timezone.rs +++ b/src-tauri/src/codex_egress_timezone.rs @@ -57,6 +57,7 @@ pub enum CodexEgressMonitorState { Checking, Ready, RestartRequired, + RendererOnly, Error, } @@ -67,6 +68,7 @@ pub(crate) struct CodexEgressMonitorRuntime { pub last_trigger: Option, pub last_error: Option, pub restart_required: bool, + pub process_timezone_unavailable: bool, pub consecutive_failures: u32, } @@ -98,11 +100,16 @@ pub(crate) fn monitor_status_from_parts( runtime: &CodexEgressMonitorRuntime, _now: i64, ) -> CodexEgressMonitorStatus { - let state = if settings.mode != CodexEgressTimezoneMode::Auto { + let process_timezone_unavailable = + runtime.process_timezone_unavailable && settings.mode != CodexEgressTimezoneMode::Off; + let restart_required = runtime.restart_required && !process_timezone_unavailable; + let state = if process_timezone_unavailable { + CodexEgressMonitorState::RendererOnly + } else if settings.mode != CodexEgressTimezoneMode::Auto { CodexEgressMonitorState::Disabled } else if runtime.running { CodexEgressMonitorState::Checking - } else if runtime.restart_required { + } else if restart_required { CodexEgressMonitorState::RestartRequired } else if runtime.last_error.is_some() { CodexEgressMonitorState::Error @@ -133,7 +140,7 @@ pub(crate) fn monitor_status_from_parts( .map(|at| at.saturating_add(i64::from(interval) * 60)) }, monitor_interval_minutes: interval, - restart_required: runtime.restart_required, + restart_required, } } @@ -816,6 +823,22 @@ pub(crate) fn notify_proxy_failure(app_type: &str, error: &ProxyError) { pub(crate) fn mark_codex_timezone_applied() { let applied_timezone = resolve_launch_timezone(&crate::settings::get_settings()); + record_codex_launch_timezone(applied_timezone, false); +} + +#[cfg(target_os = "windows")] +pub(crate) fn mark_codex_timezone_not_inherited() { + let configured = resolve_launch_timezone(&crate::settings::get_settings()); + if configured.is_some() { + log::warn!("Codex MSIX activation cannot inherit TZ; process timezone remains unapplied. Renderer timezone emulation will be attempted over CDP."); + } + record_codex_launch_timezone(None, true); +} + +fn record_codex_launch_timezone( + applied_timezone: Option, + process_timezone_unavailable: bool, +) { if let Err(error) = crate::settings::mutate_codex_egress_timezone(|settings| { settings.last_applied_timezone = applied_timezone; settings.last_applied_at = Some(Utc::now().timestamp()); @@ -826,6 +849,7 @@ pub(crate) fn mark_codex_timezone_applied() { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); runtime.restart_required = false; + runtime.process_timezone_unavailable = process_timezone_unavailable; drop(runtime); emit_monitor_status(None); } @@ -836,16 +860,21 @@ pub(crate) fn start_automatic_monitor(app_handle: AppHandle) { return; } let settings = crate::settings::get_settings().codex_egress_timezone; - if settings.mode == CodexEgressTimezoneMode::Auto - && settings.detected_timezone.is_some() - && settings.detected_timezone != settings.last_applied_timezone - && crate::codex_desktop::is_codex_desktop_running() - { - monitor_runtime() + let running = crate::codex_desktop::detect_running_codex_main_process(); + #[cfg(target_os = "windows")] + let packaged = running + .as_deref() + .is_some_and(crate::codex_desktop::windows_launch::is_packaged_codex); + #[cfg(not(target_os = "windows"))] + let packaged = false; + initialize_monitor_launch_state( + &mut monitor_runtime() .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .restart_required = true; - } + .unwrap_or_else(|poisoned| poisoned.into_inner()), + &settings, + running.is_some(), + packaged, + ); tauri::async_runtime::spawn(async move { let mut last_tick = Instant::now(); loop { @@ -864,6 +893,20 @@ pub(crate) fn start_automatic_monitor(app_handle: AppHandle) { }); } +pub(crate) fn initialize_monitor_launch_state( + runtime: &mut CodexEgressMonitorRuntime, + settings: &CodexEgressTimezoneSettings, + codex_running: bool, + packaged: bool, +) { + runtime.process_timezone_unavailable = codex_running && packaged; + runtime.restart_required = !runtime.process_timezone_unavailable + && codex_running + && settings.mode == CodexEgressTimezoneMode::Auto + && settings.detected_timezone.is_some() + && settings.detected_timezone != settings.last_applied_timezone; +} + #[tauri::command] pub fn get_codex_egress_timezone_monitor_status() -> CodexEgressMonitorStatus { current_monitor_status() diff --git a/src-tauri/src/codex_egress_timezone_tests.rs b/src-tauri/src/codex_egress_timezone_tests.rs index fbb56517..2104c329 100644 --- a/src-tauri/src/codex_egress_timezone_tests.rs +++ b/src-tauri/src/codex_egress_timezone_tests.rs @@ -368,6 +368,58 @@ fn monitor_status_exposes_failure_backoff_as_the_next_check() { assert_eq!(status.state, CodexEgressMonitorState::Error); assert_eq!(status.next_check_at, Some(2_180)); } + +#[test] +fn package_activation_reports_renderer_only_without_a_refresh_loop() { + let runtime = CodexEgressMonitorRuntime { + process_timezone_unavailable: true, + restart_required: true, + ..CodexEgressMonitorRuntime::default() + }; + for mode in [ + CodexEgressTimezoneMode::Auto, + CodexEgressTimezoneMode::Manual, + ] { + let settings = CodexEgressTimezoneSettings { + mode, + ..CodexEgressTimezoneSettings::default() + }; + let status = monitor_status_from_parts(&settings, &runtime, 2_010); + assert_eq!(status.state, CodexEgressMonitorState::RendererOnly); + assert!(!status.restart_required); + } + let status = + monitor_status_from_parts(&CodexEgressTimezoneSettings::default(), &runtime, 2_010); + assert_eq!(status.state, CodexEgressMonitorState::Disabled); +} + +#[test] +fn restarting_ccsm_preserves_the_running_package_timezone_limitation() { + let settings = CodexEgressTimezoneSettings { + mode: CodexEgressTimezoneMode::Auto, + detected_timezone: Some("Asia/Taipei".into()), + ..CodexEgressTimezoneSettings::default() + }; + let mut runtime = CodexEgressMonitorRuntime::default(); + super::codex_egress_timezone::initialize_monitor_launch_state( + &mut runtime, + &settings, + true, + true, + ); + assert_eq!( + monitor_status_from_parts(&settings, &runtime, 0).state, + CodexEgressMonitorState::RendererOnly + ); + assert!(!runtime.restart_required); + super::codex_egress_timezone::initialize_monitor_launch_state( + &mut runtime, + &settings, + true, + false, + ); + assert!(runtime.restart_required); +} #[tokio::test] #[ignore = "Explicit network diagnostic; never run as a regular test gate"] async fn live_trace_transport_diagnostic() { diff --git a/src-tauri/src/codex_runtime_refresh.rs b/src-tauri/src/codex_runtime_refresh.rs index 83a4f40c..16343a36 100644 --- a/src-tauri/src/codex_runtime_refresh.rs +++ b/src-tauri/src/codex_runtime_refresh.rs @@ -851,31 +851,10 @@ async fn query_refresh_targets() -> Result { Ok(classify_refresh_targets(&processes)) } -#[cfg(target_os = "windows")] -fn resolve_windows_codex_aumid() -> Option { - let output = powershell_utf8_output( - r#" -[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) -Get-StartApps | - Where-Object { $_.AppID -match '^OpenAI\.Codex(?:\.Preview)?_.*!App$' } | - Select-Object -First 1 -ExpandProperty AppID -"#, - ) - .ok()?; - let aumid = output.lines().next()?.trim(); - (!aumid.is_empty()).then(|| aumid.to_string()) -} - fn select_launch_target( aumid: Option, executable: Option, - timezone_injection_enabled: bool, ) -> Option { - if timezone_injection_enabled { - if let Some(executable) = executable.clone() { - return Some(CodexRuntimeLaunchTarget::DesktopExecutable(executable)); - } - } #[cfg(target_os = "windows")] if let Some(aumid) = aumid { return Some(CodexRuntimeLaunchTarget::WindowsAumid(aumid)); @@ -885,16 +864,16 @@ fn select_launch_target( executable.map(CodexRuntimeLaunchTarget::DesktopExecutable) } -fn resolve_launch_target() -> Option { +fn resolve_launch_target() -> Result, String> { let executable = crate::codex_desktop::resolve_codex_executable(); - let timezone_injection_enabled = - crate::codex_egress_timezone::resolve_launch_timezone(&crate::settings::get_settings()) - .is_some(); #[cfg(target_os = "windows")] - let aumid = resolve_windows_codex_aumid(); + let aumid = match executable.as_deref() { + Some(path) => crate::codex_desktop::windows_launch::resolve_app_id(path)?, + None => None, + }; #[cfg(not(target_os = "windows"))] let aumid = None; - select_launch_target(aumid, executable, timezone_injection_enabled) + Ok(select_launch_target(aumid, executable)) } #[cfg(not(target_os = "windows"))] @@ -915,7 +894,7 @@ async fn build_preflight() -> Result { #[cfg(target_os = "windows")] async fn build_preflight() -> Result { let targets = query_refresh_targets().await?; - let launch_target = resolve_launch_target(); + let launch_target = resolve_launch_target()?; let paginated_history = tokio::task::spawn_blocking(paginated_history::inspect_paginated_history_repair) .await @@ -1061,41 +1040,13 @@ fn force_terminate_process(_pid: u32) -> Result<(), String> { Err("codex_runtime_refresh_windows_only".to_string()) } -#[cfg(target_os = "windows")] -fn launch_windows_aumid(aumid: &str) -> Result<(), String> { - use windows::core::HSTRING; - use windows::Win32::System::Com::{ - CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_LOCAL_SERVER, - COINIT_APARTMENTTHREADED, - }; - use windows::Win32::UI::Shell::{ - ApplicationActivationManager, IApplicationActivationManager, AO_NONE, - }; - - let initialized = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED).is_ok() }; - let result = (|| -> Result<(), String> { - let manager: IApplicationActivationManager = - unsafe { CoCreateInstance(&ApplicationActivationManager, None, CLSCTX_LOCAL_SERVER) } - .map_err(|error| format!("codex_aumid_activation_manager_failed: {error}"))?; - let arguments = HSTRING::from(format!( - "--remote-debugging-port={} --remote-allow-origins=http://127.0.0.1:{}", - crate::codex_desktop::DEFAULT_CODEX_DEBUG_PORT, - crate::codex_desktop::DEFAULT_CODEX_DEBUG_PORT - )); - unsafe { manager.ActivateApplication(&HSTRING::from(aumid), &arguments, AO_NONE) } - .map(|_| ()) - .map_err(|error| format!("codex_aumid_activation_failed: {error}")) - })(); - if initialized { - unsafe { CoUninitialize() }; - } - result -} - fn launch_codex_target(target: &CodexRuntimeLaunchTarget) -> Result<(), String> { match target { #[cfg(target_os = "windows")] - CodexRuntimeLaunchTarget::WindowsAumid(aumid) => launch_windows_aumid(aumid), + CodexRuntimeLaunchTarget::WindowsAumid(aumid) => crate::codex_desktop::launch_windows_app( + aumid, + crate::codex_desktop::DEFAULT_CODEX_DEBUG_PORT, + ), CodexRuntimeLaunchTarget::DesktopExecutable(path) => { crate::codex_desktop::launch_codex_with_debug_port( path, @@ -1397,7 +1348,7 @@ pub async fn refresh_codex_runtime_state( let _refresh_guard = CODEX_RUNTIME_REFRESH_LOCK .try_lock() .map_err(|_| "codex_runtime_refresh_already_running".to_string())?; - let launch_target = resolve_launch_target() + let launch_target = resolve_launch_target()? .ok_or_else(|| "codex_desktop_launch_target_not_found".to_string())?; let progress = RuntimeRefreshProgressEmitter::new(app); let mut operations = SystemCodexRuntimeRefreshOperations { @@ -1437,19 +1388,20 @@ mod tests { })); } + #[cfg(target_os = "windows")] #[test] - fn timezone_injection_prefers_executable_over_aumid_launch() { + fn registered_package_uses_activation_independently_of_timezone_settings() { let executable = PathBuf::from(r"C:\Program Files\WindowsApps\OpenAI.Codex\ChatGPT.exe"); - let target = select_launch_target( - Some("OpenAI.Codex_123!App".to_string()), - Some(executable.clone()), - true, + let aumid = "OpenAI.Codex_publisher!UnifiedApp".to_string(); + assert_eq!( + select_launch_target(Some(aumid.clone()), Some(executable.clone())), + Some(CodexRuntimeLaunchTarget::WindowsAumid(aumid)) ); - assert_eq!( - target, + select_launch_target(None, Some(executable.clone())), Some(CodexRuntimeLaunchTarget::DesktopExecutable(executable)) ); + assert_eq!(select_launch_target(None, None), None); } use std::collections::VecDeque; diff --git a/src-tauri/src/codex_windows_launch.rs b/src-tauri/src/codex_windows_launch.rs new file mode 100644 index 00000000..f5f1309a --- /dev/null +++ b/src-tauri/src/codex_windows_launch.rs @@ -0,0 +1,151 @@ +use std::path::Path; + +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "PascalCase")] +struct RegisteredApplication { + executable: String, + app_id: String, +} + +fn normalized_path(path: &str) -> String { + path.replace('/', "\\") + .trim_start_matches(r"\\?\") + .to_lowercase() +} + +fn matching_app_id(applications: &[RegisteredApplication], executable: &Path) -> Option { + let executable = normalized_path(&executable.to_string_lossy()); + applications + .iter() + .find(|app| normalized_path(&app.executable) == executable) + .map(|app| app.app_id.clone()) +} + +pub(crate) fn is_packaged_codex(executable: &Path) -> bool { + normalized_path(&executable.to_string_lossy()) + .split('\\') + .any(|part| part.starts_with("openai.codex_") || part.starts_with("openai.codex.preview_")) +} + +pub(crate) fn resolve_app_id(executable: &Path) -> Result, String> { + if !is_packaged_codex(executable) { + return Ok(None); + } + // Match the registered manifest entry, not the first Start-menu shortcut: + // stable and Preview can coexist, and Application Id is not always "App". + let script = r#" +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding($false) +$items = @(Get-AppxPackage -Name 'OpenAI.Codex*' | + Where-Object { $_.Name -eq 'OpenAI.Codex' -or $_.Name -eq 'OpenAI.Codex.Preview' } | + ForEach-Object { + $package = $_ + $manifest = Get-AppxPackageManifest -Package $package.PackageFullName + foreach ($app in $manifest.Package.Applications.Application) { + if ($app.Executable -and $app.Id) { + [PSCustomObject]@{ + Executable = Join-Path $package.InstallLocation $app.Executable + AppId = $package.PackageFamilyName + '!' + $app.Id + } + } + } + }) +ConvertTo-Json -InputObject $items -Compress +"#; + let value = super::powershell_json_value(script) + .ok_or_else(|| "codex_package_registration_query_failed".to_string())?; + let applications: Vec = serde_json::from_value(value) + .map_err(|error| format!("codex_package_registration_invalid: {error}"))?; + matching_app_id(&applications, executable) + .map(Some) + .ok_or_else(|| { + format!( + "codex_package_application_not_registered: {}", + executable.display() + ) + }) +} + +pub(crate) fn activate(app_id: &str, debug_port: u16) -> Result<(), String> { + use windows::core::HSTRING; + use windows::Win32::System::Com::{ + CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_LOCAL_SERVER, + COINIT_APARTMENTTHREADED, + }; + use windows::Win32::UI::Shell::{ + ApplicationActivationManager, IApplicationActivationManager, AO_NONE, + }; + + let initialization = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }; + // A Tauri worker may already be in the MTA; it still has a valid COM apartment. + const RPC_E_CHANGED_MODE: windows::core::HRESULT = + windows::core::HRESULT(0x80010106_u32 as i32); + if initialization.is_err() && initialization != RPC_E_CHANGED_MODE { + return Err(format!( + "codex_aumid_com_initialization_failed: {initialization}" + )); + } + let result = (|| -> Result<(), String> { + let manager: IApplicationActivationManager = + unsafe { CoCreateInstance(&ApplicationActivationManager, None, CLSCTX_LOCAL_SERVER) } + .map_err(|error| format!("codex_aumid_activation_manager_failed: {error}"))?; + let arguments = HSTRING::from(super::codex_debug_args(debug_port).join(" ")); + unsafe { manager.ActivateApplication(&HSTRING::from(app_id), &arguments, AO_NONE) } + .map(|_| ()) + .map_err(|error| format!("codex_aumid_activation_failed ({app_id}): {error}")) + })(); + if initialization.is_ok() { + unsafe { CoUninitialize() }; + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn activation_matches_the_selected_package_and_manifest_application() { + let applications = vec![ + RegisteredApplication { + executable: r"C:\Program Files\WindowsApps\OpenAI.Codex.Preview_2_x64__publisher\app\ChatGPT.exe".into(), + app_id: "OpenAI.Codex.Preview_publisher!App".into(), + }, + RegisteredApplication { + executable: r"C:\Program Files\WindowsApps\OpenAI.Codex_1_x64__publisher\app\ChatGPT.exe".into(), + app_id: "OpenAI.Codex_publisher!UnifiedApp".into(), + }, + ]; + let selected = Path::new( + r"\\?\c:\program files\windowsapps\OpenAI.Codex_1_x64__publisher\app\ChatGPT.exe", + ); + assert_eq!( + matching_app_id(&applications, selected).as_deref(), + Some("OpenAI.Codex_publisher!UnifiedApp") + ); + assert!(matching_app_id(&applications, Path::new(r"C:\Other\ChatGPT.exe")).is_none()); + assert_eq!( + matching_app_id(&applications, Path::new(&applications[0].executable)).as_deref(), + Some("OpenAI.Codex.Preview_publisher!App") + ); + } + + #[test] + fn standalone_installations_do_not_require_package_registration() { + assert_eq!( + resolve_app_id(Path::new(r"C:\Users\Test\Apps\Codex.exe")), + Ok(None) + ); + assert!(!is_packaged_codex(Path::new( + r"C:\OpenAI.Codex.Tools\Codex.exe" + ))); + assert!(is_packaged_codex(Path::new( + r"D:/WindowsApps/OpenAI.Codex_1_x64__publisher/app/ChatGPT.exe" + ))); + assert!(is_packaged_codex(Path::new( + r"C:\WindowsApps\OpenAI.Codex.Preview_1_x64__publisher\app\Codex.exe" + ))); + } +} diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 86ababc7..9b3ae8a8 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -426,7 +426,8 @@ pub enum CodexEgressTimezoneMode { /// Codex Desktop 进程级出口时区设置。 /// -/// 仅在 CCSwitchMulti 启动 Codex 时通过子进程 `TZ` 环境变量生效,不会修改 +/// 直接启动时通过子进程 `TZ` 环境变量生效;MSIX 包激活无法继承该变量, +/// 仅通过 CDP 尝试覆盖 renderer 时区,不标记进程时区已应用。不会修改 /// Windows 系统时区。`auto` 使用后台监测到的最新 IANA 时区;监测失败不会 /// 阻塞 Codex 请求或启动。 #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/components/codex/CodexEgressTimezoneStatusCard.test.tsx b/src/components/codex/CodexEgressTimezoneStatusCard.test.tsx index bd1534fb..81508386 100644 --- a/src/components/codex/CodexEgressTimezoneStatusCard.test.tsx +++ b/src/components/codex/CodexEgressTimezoneStatusCard.test.tsx @@ -12,6 +12,20 @@ vi.mock("@/lib/api/codexEgressTimezone", () => ({ })); describe("CodexEgressTimezoneStatusCard", () => { + it("explains package activation without offering an ineffective refresh", async () => { + vi.mocked(codexEgressTimezoneApi.monitorStatus).mockResolvedValue({ + state: "renderer_only", + monitorIntervalMinutes: 15, + restartRequired: false, + }); + render(); + expect(await screen.findByText("仅支持页面时区同步")).toBeInTheDocument(); + expect(screen.getByText(/重复刷新无法解除此限制/)).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "打开安全刷新" }), + ).not.toBeInTheDocument(); + }); + beforeEach(() => { vi.mocked(codexEgressTimezoneApi.monitorStatus).mockReset(); vi.mocked(codexEgressTimezoneApi.triggerAutomaticProbe).mockReset(); diff --git a/src/components/codex/CodexEgressTimezoneStatusCard.tsx b/src/components/codex/CodexEgressTimezoneStatusCard.tsx index 9e984967..78693e2b 100644 --- a/src/components/codex/CodexEgressTimezoneStatusCard.tsx +++ b/src/components/codex/CodexEgressTimezoneStatusCard.tsx @@ -25,6 +25,8 @@ function stateLabel(status: CodexEgressMonitorStatus) { return "监测正常"; case "restart_required": return "出口已变化,需要刷新 Codex"; + case "renderer_only": + return "仅支持页面时区同步"; case "error": return "自动检测失败"; } @@ -129,6 +131,12 @@ export function CodexEgressTimezoneStatusCard({ 不会强制结束任务,请在没有运行任务时使用 Codex 状态页的安全刷新。

)} + {status?.state === "renderer_only" && ( +

+ Windows 应用包启动无法传入进程时区,只会尝试同步页面时区; + app-server 时区未覆盖,重复刷新无法解除此限制。 +

+ )} {(requestError || status?.lastError) && (