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) && (