diff --git a/apps/codex-plus-manager/src/styles.css b/apps/codex-plus-manager/src/styles.css index cc47ac0f6..2f797f1a6 100644 --- a/apps/codex-plus-manager/src/styles.css +++ b/apps/codex-plus-manager/src/styles.css @@ -146,6 +146,8 @@ body { } .sidebar { + display: flex; + flex-direction: column; min-height: 0; overflow: hidden; border-right: 1px solid hsl(var(--sidebar-border)); @@ -215,11 +217,13 @@ body { .nav { display: grid; + align-content: start; + flex: 1 1 auto; gap: 4px; margin-top: 12px; + min-height: 0; overflow-x: hidden; overflow-y: auto; - max-height: calc(100vh - 82px); padding-right: 2px; } @@ -334,11 +338,13 @@ body { display: grid; align-content: start; flex: 1 1 auto; + height: 0; gap: 12px; min-width: 0; min-height: 0; overflow-x: hidden; overflow-y: auto; + overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 16px 20px 24px; scrollbar-gutter: stable; @@ -3598,7 +3604,6 @@ select { .nav { gap: 2px; - max-height: calc(100vh - 94px); margin-top: 4px; padding-right: 3px; } @@ -4141,6 +4146,46 @@ select { } } +/* 小窗口优先保留内容区可用高度,页面其余内容通过自身滚动访问。 */ +@media (max-height: 800px) { + .sidebar { + padding-block: 12px 8px; + } + + .brand { + min-height: 0; + padding-bottom: 12px; + } + + .nav-item { + min-height: 36px; + } + + .topbar { + min-height: 56px; + padding-block: 8px; + } + + .screen { + gap: 12px; + padding-top: 12px; + } +} + +/* 右侧区域不足时让概览推广卡自然换行,避免标签和操作按钮被挤出。 */ +@media (max-width: 1180px) { + .jojocode-overview-layout { + grid-template-columns: 1fr; + } + + .jojocode-overview-side, + .jojocode-model-tags { + justify-items: start; + justify-content: flex-start; + max-width: none; + } +} + @media (max-width: 760px) { .topbar { align-items: flex-start; diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index f11acc155..f9b70ad61 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; -#[cfg(windows)] +#[cfg(any(windows, target_os = "macos"))] use std::process::{Command, Stdio}; use std::time::Duration; @@ -13,6 +13,15 @@ pub const CDP_PROBE_TIMEOUT_SECONDS: f64 = 0.5; pub const TAKEOVER_FAILURE_BACKOFF_SECONDS: f64 = 30.0; pub const RESTART_STOP_WAIT_TIMEOUT_MS: u64 = 5_000; const RESTART_STOP_WAIT_INTERVAL_MS: u64 = 100; +/// macOS 正常退出超时后,等待强制终止完成的最长时间。 +#[cfg(target_os = "macos")] +const MACOS_FORCE_STOP_WAIT_TIMEOUT_MS: u64 = 1_000; +/// macOS 上 Codex 桌面应用主进程可能使用的名称。 +#[cfg(target_os = "macos")] +const MACOS_CODEX_PROCESS_NAMES: &[&str] = &["Codex", "ChatGPT"]; +/// macOS 安装包与本地调试构建使用的静默启动器进程名。 +#[cfg(target_os = "macos")] +const MACOS_LAUNCHER_PROCESS_NAMES: &[&str] = &["CodexPlusPlus", "codex-plus-plus"]; pub const WATCHER_RUN_NAME: &str = "CodexPlusPlusWatcher"; pub const WATCHER_RUN_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Run"; pub const WATCHER_STARTUP_SHORTCUT_NAME: &str = "CodexPlusPlusWatcher.lnk"; @@ -256,28 +265,77 @@ pub fn find_session_index_cleanup_blocking_processes_from_snapshot( ids } +/// 查找 macOS 上正在运行的 Codex 桌面应用主进程。 #[cfg(target_os = "macos")] pub fn find_codex_processes() -> Vec { - let mut ids = ["Codex", "ChatGPT"] - .into_iter() - .flat_map(|name| { - std::process::Command::new("pgrep") - .args(["-x", name]) - .output() - .ok() - .into_iter() - .flat_map(|output| { - String::from_utf8_lossy(&output.stdout) - .lines() - .map(str::to_string) - .collect::>() - }) + find_macos_processes_by_names(MACOS_CODEX_PROCESS_NAMES) +} + +/// 从 macOS `ps -axo pid=,ucomm=` 输出中筛选指定名称的进程。 +/// +/// 使用 `ucomm` 而不是 `pgrep -x`,避免应用包内较长的可执行路径被系统截断后无法匹配。 +#[cfg(target_os = "macos")] +pub fn macos_process_ids_from_ps_output(output: &str, process_names: &[&str]) -> Vec { + let supported_names = process_names.iter().copied().collect::>(); + let mut process_ids = output + .lines() + .filter_map(|line| { + let line = line.trim(); + let split_at = line.find(char::is_whitespace)?; + let process_id = line[..split_at].parse::().ok()?; + let process_name = line[split_at..].trim(); + supported_names.contains(process_name).then_some(process_id) }) - .filter_map(|value| value.trim().parse::().ok()) .collect::>(); - ids.sort_unstable(); - ids.dedup(); - ids + process_ids.sort_unstable(); + process_ids.dedup(); + process_ids +} + +/// 查询 macOS 当前进程并按可执行文件名精确筛选。 +#[cfg(target_os = "macos")] +fn find_macos_processes_by_names(process_names: &[&str]) -> Vec { + match Command::new("ps").args(["-axo", "pid=,ucomm="]).output() { + Ok(output) if output.status.success() => macos_process_ids_from_ps_output( + &String::from_utf8_lossy(&output.stdout), + process_names, + ), + Ok(output) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_process_scan_failed", + serde_json::json!({ + "status": output.status.code(), + "stderr": String::from_utf8_lossy(&output.stderr).trim() + }), + ); + Vec::new() + } + Err(error) => { + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_process_scan_failed", + serde_json::json!({ "error": error.to_string() }), + ); + Vec::new() + } + } +} + +/// 查找可安全停止的 macOS 静默启动器,并保护当前正在执行清理的启动器实例。 +#[cfg(target_os = "macos")] +fn find_killable_macos_launcher_processes() -> Vec { + exclude_current_process_id( + find_macos_processes_by_names(MACOS_LAUNCHER_PROCESS_NAMES), + std::process::id(), + ) +} + +/// 从待停止列表中排除当前进程,避免启动器在恢复旧实例时结束自身。 +#[cfg(target_os = "macos")] +fn exclude_current_process_id(process_ids: Vec, current_process_id: u32) -> Vec { + process_ids + .into_iter() + .filter(|process_id| *process_id != current_process_id) + .collect() } #[cfg(target_os = "macos")] @@ -313,7 +371,13 @@ pub fn stop_launcher_processes() { } } -#[cfg(not(windows))] +/// 停止 macOS 上旧的静默启动器,避免新启动器因单实例锁而退化为“激活现有窗口”。 +#[cfg(target_os = "macos")] +pub fn stop_launcher_processes() { + terminate_macos_processes(find_killable_macos_launcher_processes()); +} + +#[cfg(not(any(windows, target_os = "macos")))] pub fn stop_launcher_processes() {} #[cfg(windows)] @@ -336,7 +400,18 @@ pub fn stop_launcher_processes_and_wait() { ); } -#[cfg(not(windows))] +/// 停止并等待 macOS 静默启动器退出,确保单实例锁已经释放。 +#[cfg(target_os = "macos")] +pub fn stop_launcher_processes_and_wait() { + terminate_macos_processes_and_wait( + find_killable_macos_launcher_processes(), + "launcher", + RESTART_STOP_WAIT_TIMEOUT_MS, + RESTART_STOP_WAIT_INTERVAL_MS, + ); +} + +#[cfg(not(any(windows, target_os = "macos")))] pub fn stop_launcher_processes_and_wait() {} #[cfg(windows)] @@ -346,7 +421,13 @@ pub fn stop_codex_processes() { } } -#[cfg(not(windows))] +/// 停止 macOS 上的 Codex 桌面应用主进程。 +#[cfg(target_os = "macos")] +pub fn stop_codex_processes() { + terminate_macos_processes(find_codex_processes()); +} + +#[cfg(not(any(windows, target_os = "macos")))] pub fn stop_codex_processes() {} #[cfg(windows)] @@ -358,9 +439,138 @@ pub fn stop_codex_processes_and_wait() { ); } -#[cfg(not(windows))] +/// 停止并等待 macOS Codex 桌面应用退出,避免后续 `open` 仅激活旧窗口。 +#[cfg(target_os = "macos")] +pub fn stop_codex_processes_and_wait() { + terminate_macos_processes_and_wait( + find_codex_processes(), + "codex", + RESTART_STOP_WAIT_TIMEOUT_MS, + RESTART_STOP_WAIT_INTERVAL_MS, + ); +} + +#[cfg(not(any(windows, target_os = "macos")))] pub fn stop_codex_processes_and_wait() {} +/// 向 macOS 目标进程发送正常终止信号。 +#[cfg(target_os = "macos")] +fn terminate_macos_processes(process_ids: Vec) { + send_macos_signal(&process_ids, "-TERM", "term"); +} + +/// 正常终止 macOS 进程并等待退出;超时后使用强制终止,保证重启可以继续。 +#[cfg(target_os = "macos")] +fn terminate_macos_processes_and_wait( + process_ids: Vec, + process_kind: &str, + timeout_ms: u64, + interval_ms: u64, +) { + if process_ids.is_empty() { + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_stop_skipped", + serde_json::json!({ "process_kind": process_kind, "reason": "not_running" }), + ); + return; + } + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_stop_requested", + serde_json::json!({ + "process_kind": process_kind, + "process_ids": process_ids, + "timeout_ms": timeout_ms + }), + ); + terminate_macos_processes(process_ids.clone()); + let remaining = wait_for_macos_processes_to_exit(&process_ids, timeout_ms, interval_ms); + if remaining.is_empty() { + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_stop_completed", + serde_json::json!({ "process_kind": process_kind, "forced": false }), + ); + return; + } + + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.stop_wait_timeout", + serde_json::json!({ + "process_kind": process_kind, + "remaining_process_ids": remaining, + "timeout_ms": timeout_ms + }), + ); + send_macos_signal(&remaining, "-KILL", "kill"); + let remaining_after_force = + wait_for_macos_processes_to_exit(&remaining, MACOS_FORCE_STOP_WAIT_TIMEOUT_MS, interval_ms); + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_stop_completed", + serde_json::json!({ + "process_kind": process_kind, + "forced": true, + "remaining_process_ids": remaining_after_force + }), + ); +} + +/// 调用系统 `kill` 命令向指定 macOS 进程发送信号,并记录失败详情。 +#[cfg(target_os = "macos")] +fn send_macos_signal(process_ids: &[u32], signal: &str, signal_name: &str) { + for process_id in process_ids { + let result = Command::new("kill") + .args([signal, &process_id.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + let succeeded = result.as_ref().is_ok_and(|status| status.success()); + if !succeeded { + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.macos_signal_failed", + serde_json::json!({ + "process_id": process_id, + "signal": signal_name, + "status": result.as_ref().ok().and_then(|status| status.code()), + "error": result.as_ref().err().map(|error| error.to_string()) + }), + ); + } + } +} + +/// 等待指定 macOS 进程退出,并返回超时后仍存活的进程 ID。 +#[cfg(target_os = "macos")] +fn wait_for_macos_processes_to_exit( + process_ids: &[u32], + timeout_ms: u64, + interval_ms: u64, +) -> Vec { + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + let remaining = process_ids + .iter() + .copied() + .filter(|process_id| macos_process_is_running(*process_id)) + .collect::>(); + if remaining.is_empty() || std::time::Instant::now() >= deadline { + return remaining; + } + std::thread::sleep(Duration::from_millis(interval_ms)); + } +} + +/// 使用无副作用的信号探测指定 macOS 进程是否仍然存活。 +#[cfg(target_os = "macos")] +fn macos_process_is_running(process_id: u32) -> bool { + Command::new("kill") + .args(["-0", &process_id.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + #[cfg(windows)] fn terminate_and_wait_for_exit(process_ids: Vec, timeout_ms: u64, interval_ms: u64) { if process_ids.is_empty() { @@ -435,3 +645,35 @@ fn startup_shortcut_path() -> Option { .join(WATCHER_STARTUP_SHORTCUT_NAME) }) } + +#[cfg(all(test, target_os = "macos"))] +mod macos_tests { + use super::{exclude_current_process_id, terminate_macos_processes_and_wait}; + + /// 验证旧启动器清理列表会保留当前正在执行恢复逻辑的进程。 + #[test] + fn launcher_cleanup_excludes_current_process() { + assert_eq!( + exclude_current_process_id(vec![10, 20, 30], 20), + vec![10, 30] + ); + } + + /// 验证 macOS 重启停止逻辑会真实结束目标进程,而不是只等待或激活窗口。 + #[test] + fn terminate_and_wait_stops_target_process() { + let mut child = std::process::Command::new("/bin/sleep") + .arg("30") + .spawn() + .expect("应能启动用于验证的临时进程"); + let process_id = child.id(); + let reaper = std::thread::spawn(move || child.wait().expect("应能回收临时进程")); + + terminate_macos_processes_and_wait(vec![process_id], "test", 1_000, 25); + + assert!( + !reaper.join().expect("临时进程回收线程不应失败").success(), + "目标进程应已退出" + ); + } +} diff --git a/crates/codex-plus-core/tests/watcher.rs b/crates/codex-plus-core/tests/watcher.rs index 03feecd24..052db50be 100644 --- a/crates/codex-plus-core/tests/watcher.rs +++ b/crates/codex-plus-core/tests/watcher.rs @@ -10,6 +10,9 @@ use codex_plus_core::watcher::{ find_session_index_cleanup_blocking_processes_from_snapshot, }; +#[cfg(target_os = "macos")] +use codex_plus_core::watcher::macos_process_ids_from_ps_output; + #[test] fn cdp_listening_returns_true_for_bound_loopback_port() { let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap(); @@ -142,6 +145,27 @@ fn stop_wait_tracks_only_expected_process_ids() { ); } +#[cfg(target_os = "macos")] +#[test] +fn macos_process_filter_matches_only_exact_main_process_names() { + let snapshot = r#" + 81224 ChatGPT + 81459 CodexPlusPlus + 81889 Codex (Renderer) + 83774 ChatGPT for Chrome + 90001 codex + "#; + + assert_eq!( + macos_process_ids_from_ps_output(snapshot, &["Codex", "ChatGPT"]), + vec![81224] + ); + assert_eq!( + macos_process_ids_from_ps_output(snapshot, &["CodexPlusPlus", "codex-plus-plus"]), + vec![81459] + ); +} + #[cfg(windows)] #[test] fn find_codex_processes_finds_local_install_with_capitial_c() {