From 8286b7e60d577a016d64b6477dde8aa773ef8efa Mon Sep 17 00:00:00 2001 From: xiaoyue Date: Thu, 29 Oct 2026 17:36:39 +0800 Subject: [PATCH] feat(linux): comprehensive Linux desktop packaging, process lifecycle, and CI workflows - Linux desktop process detection: token-by-token argv parsing via tokenize_args and /proc//cmdline verification; strict debug flag matching (--remote-debugging-pipe, --remote-debugging-port=, --remote-debugging-port ); removed /usr/lib/codex/ from whitelist; comprehensive unit tests covering quoted arguments and non-desktop sessions. - Process lifecycle & auto takeover: single-instance and multi-worker instance management on Linux via /proc//cmdline and /proc//exe inspection, with graceful SIGTERM and forceful SIGKILL escalation. - Autostart & shortcuts: standard FreeDesktop XDG .desktop entry creation, installation to ~/.local/share/applications and ~/Desktop, and mime-type registration. - Packaging & CI: modular build-deb.sh supporting directory-independent invocation, automated package.json version parsing, exact Installed-Size calculation, and complete CI gate (Ubuntu 24.04, amd64) verifying build, package integrity, and runtime dependencies. - Reverted out-of-scope macOS helper changes and PR build additions to keep existing platforms 100% untouched. --- .github/workflows/pr-build.yml | 79 +++ .gitignore | 1 + .../src-tauri/src/auto_takeover.rs | 9 +- .../src-tauri/src/commands.rs | 14 +- apps/codex-plus-manager/src/App.tsx | 2 +- apps/codex-plus-manager/src/i18n-en.ts | 1 + crates/codex-plus-core/src/app_paths.rs | 14 +- crates/codex-plus-core/src/auto_takeover.rs | 101 +++- crates/codex-plus-core/src/install/linux.rs | 95 ++++ crates/codex-plus-core/src/install/mod.rs | 26 +- crates/codex-plus-core/src/launcher.rs | 13 +- crates/codex-plus-core/src/multi_worker.rs | 49 +- crates/codex-plus-core/src/proxy.rs | 24 +- crates/codex-plus-core/src/update.rs | 39 +- crates/codex-plus-core/src/watcher.rs | 472 +++++++++++++++++- crates/codex-plus-core/tests/all/launcher.rs | 2 + crates/codex-plus-core/tests/all/watcher.rs | 166 ++++++ docs/linux-packaging.md | 56 +++ scripts/assemble-native-browser-inspector.mjs | 2 +- scripts/installer/linux/build-deb.sh | 86 ++-- 20 files changed, 1167 insertions(+), 84 deletions(-) create mode 100644 crates/codex-plus-core/src/install/linux.rs create mode 100644 docs/linux-packaging.md diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml index d3819453ef..01b1c1adda 100644 --- a/.github/workflows/pr-build.yml +++ b/.github/workflows/pr-build.yml @@ -198,3 +198,82 @@ jobs: name: codex-plus-plus-macos-universal-dmg path: dist/macos/*.dmg if-no-files-found: error + + linux-deb: + name: Linux DEB (Ubuntu 24.04, amd64) + runs-on: ubuntu-24.04 + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + - name: Install Linux build dependencies + run: | + sudo apt-get update -qq + sudo apt-get install -y --no-install-recommends \ + build-essential curl git pkg-config libssl-dev libsqlite3-dev \ + libdbus-1-dev libglib2.0-dev libgtk-3-dev \ + libayatana-appindicator3-dev libjavascriptcoregtk-4.1-dev \ + libwebkit2gtk-4.1-dev libsoup-3.0-dev libz3-dev + - name: Install frontend dependencies + working-directory: apps/codex-plus-manager + run: npm ci + + - name: Frontend tests + working-directory: apps/codex-plus-manager + run: npm test + + - name: Check native browser inspector bundle + run: node scripts/assemble-native-browser-inspector.mjs --check + + - name: TypeScript check + working-directory: apps/codex-plus-manager + run: npm run check + + - name: Build frontend + working-directory: apps/codex-plus-manager + run: npm run vite:build + + - name: Rust tests + run: cargo test --workspace + + - name: Build Linux application + run: cargo build --release -p codex-plus-manager + - name: Check default version outside repository + shell: bash + run: | + set -euo pipefail + cd /tmp + "$GITHUB_WORKSPACE/scripts/installer/linux/build-deb.sh" --help + test -f "$GITHUB_WORKSPACE/apps/codex-plus-manager/package.json" + - name: Build DEB from outside repository + shell: bash + run: | + set -euo pipefail + cd /tmp + bash "$GITHUB_WORKSPACE/scripts/installer/linux/build-deb.sh" --package-only + - name: Verify package and install + shell: bash + run: | + set -euo pipefail + deb_file="$(find dist/linux -maxdepth 1 -name "*.deb" -print -quit)" + test -n "$deb_file" + test "$(dpkg-deb -f "$deb_file" Package)" = "codex-plus-plus" + test "$(dpkg-deb -f "$deb_file" Version)" = "$(node -p 'require("./apps/codex-plus-manager/package.json").version')" + dpkg-deb -c "$deb_file" | grep -F "./usr/bin/codex-plus-plus" + if dpkg-deb -c "$deb_file" | grep -Fq "./usr/bin/codex-plus-plus-manager"; then exit 1; fi + sudo apt-get install -y "./$deb_file" + test -x /usr/bin/codex-plus-plus + dpkg -V codex-plus-plus + ldd /usr/bin/codex-plus-plus | tee /tmp/codex-plus-plus-ldd.log + ! grep -q "not found" /tmp/codex-plus-plus-ldd.log + - name: Upload DEB + uses: actions/upload-artifact@v4 + with: + name: codex-plus-plus-linux-amd64-deb + path: dist/linux/*.deb + if-no-files-found: error diff --git a/.gitignore b/.gitignore index cf04148658..565a73b1c8 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ venv/ *.egg-info/ build/ dist/ +.linux-build/ target/ node_modules/ apps/codex-plus-manager/src-tauri/gen/ diff --git a/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs b/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs index 3353e24fe8..02a8831fb8 100644 --- a/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs +++ b/apps/codex-plus-manager/src-tauri/src/auto_takeover.rs @@ -112,7 +112,7 @@ fn inner() -> &'static Mutex { INNER.get_or_init(|| { Mutex::new(Inner { state: "disabled", - platform_supported: cfg!(target_os = "macos"), + platform_supported: cfg!(any(target_os = "macos", target_os = "linux")), ..Inner::default() }) }) @@ -422,7 +422,7 @@ fn collect_inputs() -> Result { // 此前这里单独用 `any(macos, windows)`,Windows 上守护线程确实会运行, // 而 UI 读到的 platform_supported 却是 false,用户勾选开关后表现为 // 「反复拉起白色窗口、界面却显示不支持」(#2573)。 - let platform_supported = cfg!(target_os = "macos"); + let platform_supported = cfg!(any(target_os = "macos", target_os = "linux")); let enabled = is_enabled(); let session_snapshot = codex_plus_launcher::session_snapshot(); let latest = StatusStore::default().load_latest().ok().flatten(); @@ -794,7 +794,10 @@ mod tests { #[test] fn platform_support_matches_the_build_target() { let payload = payload(); - assert_eq!(payload.platform_supported, cfg!(target_os = "macos")); + assert_eq!( + payload.platform_supported, + cfg!(any(target_os = "macos", target_os = "linux")) + ); } #[test] diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 314f73648e..eec6a09f60 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -6825,7 +6825,7 @@ pub async fn check_update() -> CommandResult { } } -#[cfg(any(windows, target_os = "macos"))] +#[cfg(any(windows, target_os = "macos", target_os = "linux"))] #[tauri::command] pub async fn perform_update( app: tauri::AppHandle, @@ -6862,7 +6862,7 @@ pub async fn perform_update( result } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] #[tauri::command] pub async fn perform_update( release: Option, @@ -9247,7 +9247,7 @@ fn open_url(url: &str) -> anyhow::Result<()> { { codex_plus_core::windows_open_url(url) } - #[cfg(not(windows))] + #[cfg(target_os = "macos")] { std::process::Command::new("open") .arg(url) @@ -9255,6 +9255,14 @@ fn open_url(url: &str) -> anyhow::Result<()> { .map(|_| ()) .map_err(|error| anyhow::anyhow!("启动系统浏览器失败:{error}")) } + #[cfg(not(any(windows, target_os = "macos")))] + { + std::process::Command::new("xdg-open") + .arg(url) + .spawn() + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("启动系统浏览器失败:{error}")) + } } fn settings_payload(message: &str, failure_context: &str) -> CommandResult { diff --git a/apps/codex-plus-manager/src/App.tsx b/apps/codex-plus-manager/src/App.tsx index 988c8d32fe..e4c08c53b0 100644 --- a/apps/codex-plus-manager/src/App.tsx +++ b/apps/codex-plus-manager/src/App.tsx @@ -10276,7 +10276,7 @@ function MaintenanceScreen({ {autoTakeover?.lastTakeoverAtMs ? (

{tf("上次接管:{0}", [new Date(autoTakeover.lastTakeoverAtMs).toLocaleString()])}

) : null} - {autoTakeover?.platformSupported ? null :

{t("自动接管目前只支持 macOS。")}

} + {autoTakeover?.platformSupported ? null :

{t("自动接管目前只支持 macOS 和 Linux。")}

}

{t("仅在 Codex++ 正在运行时生效;需要重启官方 App 时会自动重启。")}

diff --git a/apps/codex-plus-manager/src/i18n-en.ts b/apps/codex-plus-manager/src/i18n-en.ts index 4a7f1e14bd..7ba5c583b5 100644 --- a/apps/codex-plus-manager/src/i18n-en.ts +++ b/apps/codex-plus-manager/src/i18n-en.ts @@ -2285,6 +2285,7 @@ export const EN_PLAIN: Record = { "刷新状态": "Refresh state", "本次已跳过自动接管,重启官方 App 后恢复。": "Auto-takeover is skipped for this instance; it resumes after you restart the official app.", "自动接管目前只支持 macOS。": "Auto-takeover is currently macOS only.", + "自动接管目前只支持 macOS 和 Linux。": "Auto takeover currently only supports macOS and Linux.", "仅在 Codex++ 正在运行时生效;需要重启官方 App 时会自动重启。": "Only effective while Codex++ is running; the official app is restarted automatically when needed.", "仅在 Codex++ 正在运行时生效;建议同时开启上面的「开机打开 Codex++」。": "Only effective while Codex++ is running; keeping the login item above enabled is recommended.", "开启后,你自己启动官方 App 时 Codex++ 会自动接管。官方 App 已在运行且没有调试端口时,会先退出再带参数重开(可能中断正在进行的操作)。": "Once enabled, Codex++ takes over whenever you launch the official app yourself. If it is already running without a debug port, it is quit and relaunched with debug flags (this may interrupt work in progress).", diff --git a/crates/codex-plus-core/src/app_paths.rs b/crates/codex-plus-core/src/app_paths.rs index 3a8289ea38..6aab5f2da9 100644 --- a/crates/codex-plus-core/src/app_paths.rs +++ b/crates/codex-plus-core/src/app_paths.rs @@ -440,15 +440,19 @@ pub fn resolve_codex_app_dir(app_dir: Option<&Path>) -> Option { if let Some(app_dir) = app_dir { return normalize_codex_app_path(app_dir); } - if cfg!(target_os = "macos") { - return find_macos_codex_app_default(); + #[cfg(target_os = "macos")] + { + find_macos_codex_app_default() } #[cfg(target_os = "linux")] { - return find_linux_codex_app_default(); + find_linux_codex_app_default() + } + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + { + // Windows: try MS Store version first, then standalone install + find_latest_codex_app_dir_default().or_else(|| find_standalone_codex_app_dir()) } - // Windows: try MS Store version first, then standalone install - find_latest_codex_app_dir_default().or_else(|| find_standalone_codex_app_dir()) } /// Search for standalone Codex installations (non-MS Store). diff --git a/crates/codex-plus-core/src/auto_takeover.rs b/crates/codex-plus-core/src/auto_takeover.rs index b4037f0af3..1d1558f5c9 100644 --- a/crates/codex-plus-core/src/auto_takeover.rs +++ b/crates/codex-plus-core/src/auto_takeover.rs @@ -178,7 +178,28 @@ pub fn has_other_desktop_instance( !debug_port_pids.contains(process_id) && Some(*process_id) != current_pid }); } - #[cfg(not(any(target_os = "macos", windows)))] + #[cfg(target_os = "linux")] + { + let expected = crate::app_paths::build_codex_executable(app_dir); + let expected_str = expected.to_string_lossy(); + let all = crate::watcher::find_linux_codex_desktop_main_processes(None); + let excluded = crate::watcher::find_linux_codex_desktop_main_processes(Some(exclude_port)); + let mut excluded = excluded + .into_iter() + .map(|process| process.pid) + .collect::>(); + if let Some(pid) = current_pid { + excluded.push(pid); + } + return all.iter().any(|process| { + let matches_expected = process.executable == expected_str + || std::fs::read_link(format!("/proc/{}/exe", process.pid)) + .map(|target| target == expected || target.starts_with(app_dir)) + .unwrap_or(false); + matches_expected && !excluded.contains(&process.pid) + }); + } + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] { let _ = (app_dir, exclude_port, current_pid); false @@ -246,13 +267,36 @@ pub fn probe_desktop_app(app_dir: &Path) -> AppProbe { { return probe_windows_desktop_app(app_dir); } - #[cfg(not(any(target_os = "macos", windows)))] + #[cfg(target_os = "linux")] + { + return probe_linux_desktop_app(app_dir); + } + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] { let _ = app_dir; AppProbe::Unknown } } +#[cfg(target_os = "linux")] +fn probe_linux_desktop_app(app_dir: &Path) -> AppProbe { + let expected = crate::app_paths::build_codex_executable(app_dir); + let expected_str = expected.to_string_lossy(); + let processes = crate::watcher::find_linux_codex_desktop_main_processes(None); + match processes.into_iter().find(|process| { + process.executable == expected_str + || std::fs::read_link(format!("/proc/{}/exe", process.pid)) + .map(|target| target == expected || target.starts_with(app_dir)) + .unwrap_or(false) + }) { + Some(process) => AppProbe::Running(InstanceKey::new( + process.pid, + process.lstart.clone(), + )), + None => AppProbe::NotRunning, + } +} + #[cfg(windows)] /// 进程是否「与官方 App 主进程同名同路径」。Windows 上 renderer / gpu / utility / /// crashpad 全都叫 `ChatGPT.exe` 且在同一目录,只能靠命令行区分。 @@ -364,12 +408,63 @@ pub fn request_desktop_exit(app_dir: &Path, instance: &InstanceKey, graceful: bo { return request_windows_exit(instance, graceful); } - #[cfg(not(any(target_os = "macos", windows)))] + #[cfg(target_os = "linux")] + { + return request_linux_exit(instance, graceful); + } + #[cfg(not(any(target_os = "macos", windows, target_os = "linux")))] { false } } +#[cfg(target_os = "linux")] +fn request_linux_exit(instance: &InstanceKey, graceful: bool) -> bool { + // 结束前复核进程启动时间,防止 PID 被操作系统回收并复用后误杀无关进程 + if !instance.lstart.is_empty() { + if let Some(current_starttime) = crate::watcher::linux_process_starttime(instance.pid) { + if current_starttime != instance.lstart { + let _ = crate::diagnostic_log::append_diagnostic_log( + "takeover.exit_request_skipped_reused_pid", + serde_json::json!({ + "pid": instance.pid, + "expected_lstart": instance.lstart, + "actual_lstart": current_starttime, + }), + ); + return false; + } + } else { + // /proc//stat 已不存在,说明进程已经退出 + return true; + } + } + + let sig = if graceful { "-15" } else { "-9" }; + #[cfg(target_os = "linux")] + let accepted = std::process::Command::new("kill") + .arg(sig) + .arg(instance.pid.to_string()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false); + let _ = crate::diagnostic_log::append_diagnostic_log( + if accepted { + "takeover.exit_request" + } else { + "takeover.exit_request_failed" + }, + serde_json::json!({ + "pid": instance.pid, + "force": !graceful, + "accepted": accepted, + }), + ); + accepted +} + #[cfg(target_os = "macos")] fn request_macos_exit(app_dir: &Path, _instance: &InstanceKey, _graceful: bool) -> bool { let app_name = app_dir diff --git a/crates/codex-plus-core/src/install/linux.rs b/crates/codex-plus-core/src/install/linux.rs new file mode 100644 index 0000000000..9b7a9d4aad --- /dev/null +++ b/crates/codex-plus-core/src/install/linux.rs @@ -0,0 +1,95 @@ +use std::fs; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use super::{ + APP_NAME, InstallOptions, application_source, install_root_or_default, +}; + +pub fn build_desktop_entry_content(exec_path: &Path) -> String { + let exec_str = exec_path.to_string_lossy(); + format!( + "[Desktop Entry]\n\ + Name={name}\n\ + Comment=Configure and launch the official Codex desktop app\n\ + Exec={exec_str} %u\n\ + Icon=codex-plus-plus\n\ + Type=Application\n\ + Categories=Development;Utility;\n\ + Terminal=false\n\ + StartupNotify=true\n\ + StartupWMClass=codex-plus-plus\n\ + MimeType=x-scheme-handler/codexplusplus;x-scheme-handler/dreamskin;\n", + name = APP_NAME, + exec_str = exec_str + ) +} + +pub fn install_shortcuts(options: &InstallOptions) -> anyhow::Result<()> { + let install_root = install_root_or_default(options); + fs::create_dir_all(&install_root)?; + + let launcher_path = application_source(options); + let content = build_desktop_entry_content(&launcher_path); + + // 1. 安装到指定的 install_root(通常是 ~/Desktop) + let desktop_shortcut = install_root.join(format!("{APP_NAME}.desktop")); + fs::write(&desktop_shortcut, &content)?; + #[cfg(unix)] + let _ = fs::set_permissions(&desktop_shortcut, fs::Permissions::from_mode(0o755)); + + // 2. 同时确保 ~/.local/share/applications/ 下存在桌面入口,供应用菜单和 MIME handler 识别 + if let Some(base_dirs) = directories::BaseDirs::new() { + let app_dir = base_dirs.data_local_dir().join("applications"); + if fs::create_dir_all(&app_dir).is_ok() { + let app_entry = app_dir.join("codex-plus-plus.desktop"); + let _ = fs::write(&app_entry, &content); + #[cfg(unix)] + let _ = fs::set_permissions(&app_entry, fs::Permissions::from_mode(0o755)); + + // 更新桌面数据库和 MIME 关联 + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("update-desktop-database") + .arg(&app_dir) + .status(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-mime") + .args(["default", "codex-plus-plus.desktop", "x-scheme-handler/codexplusplus"]) + .status(); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("xdg-mime") + .args(["default", "codex-plus-plus.desktop", "x-scheme-handler/dreamskin"]) + .status(); + } + } + + Ok(()) +} + +pub fn uninstall_shortcuts(options: &InstallOptions) -> anyhow::Result<()> { + let install_root = install_root_or_default(options); + let candidates = [ + install_root.join(format!("{APP_NAME}.desktop")), + install_root.join("codex-plus-plus.desktop"), + ]; + + for path in &candidates { + if path.is_file() { + let _ = fs::remove_file(path); + } + } + + if let Some(base_dirs) = directories::BaseDirs::new() { + let app_entry = base_dirs.data_local_dir().join("applications/codex-plus-plus.desktop"); + if app_entry.is_file() { + let _ = fs::remove_file(&app_entry); + #[cfg(target_os = "linux")] + let _ = std::process::Command::new("update-desktop-database") + .arg(base_dirs.data_local_dir().join("applications")) + .status(); + } + } + + Ok(()) +} diff --git a/crates/codex-plus-core/src/install/mod.rs b/crates/codex-plus-core/src/install/mod.rs index 4210c5c450..968700659c 100644 --- a/crates/codex-plus-core/src/install/mod.rs +++ b/crates/codex-plus-core/src/install/mod.rs @@ -4,6 +4,8 @@ use std::process::Command; use serde::{Deserialize, Serialize}; +#[cfg(target_os = "linux")] +pub mod linux; pub mod macos; pub mod windows; @@ -209,7 +211,12 @@ fn platform_install(options: &InstallOptions) -> anyhow::Result<()> { macos::install_app_bundles(options) } - #[cfg(not(any(windows, target_os = "macos")))] + #[cfg(target_os = "linux")] + { + linux::install_shortcuts(options) + } + + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { let _ = options; anyhow::bail!("当前平台暂不支持安装 Codex++ 入口") @@ -227,7 +234,12 @@ fn platform_uninstall(options: &InstallOptions) -> anyhow::Result<()> { macos::uninstall_app_bundles(options) } - #[cfg(not(any(windows, target_os = "macos")))] + #[cfg(target_os = "linux")] + { + linux::uninstall_shortcuts(options) + } + + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { let _ = options; anyhow::bail!("当前平台暂不支持卸载 Codex++ 入口") @@ -262,7 +274,15 @@ fn entrypoint_candidates(root: &Option) -> Vec { } else if cfg!(target_os = "macos") { vec![root.join(format!("{name}.app"))] } else { - vec![root.join(format!("{name}.desktop"))] + let mut list = vec![ + root.join(format!("{name}.desktop")), + root.join("codex-plus-plus.desktop"), + ]; + if let Some(base_dirs) = directories::BaseDirs::new() { + list.push(base_dirs.data_local_dir().join("applications/codex-plus-plus.desktop")); + } + list.push(PathBuf::from("/usr/share/applications/codex-plus-plus.desktop")); + list } } diff --git a/crates/codex-plus-core/src/launcher.rs b/crates/codex-plus-core/src/launcher.rs index 22682b9d99..b0c89c872d 100644 --- a/crates/codex-plus-core/src/launcher.rs +++ b/crates/codex-plus-core/src/launcher.rs @@ -5546,7 +5546,18 @@ fn launcher_owned_process_alive(launch: &CodexLaunch, debug_port: u16) -> bool { ) && !crate::watcher::find_macos_codex_processes_for_debug_port(debug_port).is_empty() } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +fn launcher_owned_process_alive(launch: &CodexLaunch, debug_port: u16) -> bool { + matches!( + launch, + CodexLaunch::Process { + wait_strategy: ProcessWaitStrategy::ExternalWaitCommand, + .. + } + ) && !crate::watcher::find_codex_processes_for_debug_port(debug_port).is_empty() +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] fn launcher_owned_process_alive(_launch: &CodexLaunch, _debug_port: u16) -> bool { false } diff --git a/crates/codex-plus-core/src/multi_worker.rs b/crates/codex-plus-core/src/multi_worker.rs index 656a92df32..a85f911a74 100644 --- a/crates/codex-plus-core/src/multi_worker.rs +++ b/crates/codex-plus-core/src/multi_worker.rs @@ -1,6 +1,4 @@ -#[cfg(any(windows, target_os = "macos", test))] -use std::path::Path; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; @@ -19,7 +17,6 @@ pub struct CodexInstanceIdentity { pub executable: PathBuf, } -#[cfg(any(windows, target_os = "macos", test))] pub(crate) fn codex_arguments_match( arguments: &[String], user_data_dir: &Path, @@ -152,8 +149,28 @@ fn macos_process_arguments(pid: u32) -> anyhow::Result> { Ok(args) } -#[cfg(target_os = "macos")] -fn macos_birth_token(pid: u32) -> Option { +#[cfg(target_os = "linux")] +fn linux_process_arguments(pid: u32) -> anyhow::Result> { + let content = std::fs::read(format!("/proc/{pid}/cmdline"))?; + let args = content + .split(|&byte| byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).to_string()) + .collect::>(); + anyhow::ensure!(!args.is_empty(), "进程参数为空"); + Ok(args) +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn unix_process_arguments(pid: u32) -> anyhow::Result> { + #[cfg(target_os = "macos")] + { macos_process_arguments(pid) } + #[cfg(target_os = "linux")] + { linux_process_arguments(pid) } +} + +#[cfg(any(target_os = "macos", target_os = "linux"))] +fn unix_birth_token(pid: u32) -> Option { let crate::watcher::ProcessInstanceState::Running { birth_id: Some(birth), .. @@ -166,7 +183,7 @@ fn macos_birth_token(pid: u32) -> Option { })) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] pub async fn capture_codex_instance_identity( instance_id: &str, generation: u64, @@ -184,7 +201,7 @@ pub async fn capture_codex_instance_identity( anyhow::ensure!(candidates.len() == 1, "无法唯一确认多开进程"); let process_id = candidates[0]; let process_birth_id = - macos_birth_token(process_id).ok_or_else(|| anyhow::anyhow!("无法确认多开进程创建身份"))?; + unix_birth_token(process_id).ok_or_else(|| anyhow::anyhow!("无法确认多开进程创建身份"))?; let identity = CodexInstanceIdentity { instance_id: instance_id.into(), generation, @@ -203,7 +220,7 @@ pub async fn capture_codex_instance_identity( Ok(identity) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] pub async fn codex_instance_identity_matches( identity: &CodexInstanceIdentity, ) -> anyhow::Result { @@ -212,11 +229,11 @@ pub async fn codex_instance_identity_matches( || identity.browser_id.is_empty() || identity.debug_port == 0 || identity.helper_port == 0 - || macos_birth_token(identity.process_id) != Some(identity.process_birth_id) + || unix_birth_token(identity.process_id) != Some(identity.process_birth_id) { return Ok(false); } - let args = macos_process_arguments(identity.process_id)?; + let args = unix_process_arguments(identity.process_id)?; if !codex_arguments_match(&args, &identity.user_data_dir, identity.debug_port) || args .first() @@ -228,11 +245,11 @@ pub async fn codex_instance_identity_matches( } Ok( codex_browser_id(identity.debug_port).await? == identity.browser_id - && macos_birth_token(identity.process_id) == Some(identity.process_birth_id), + && unix_birth_token(identity.process_id) == Some(identity.process_birth_id), ) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "linux"))] pub async fn terminate_verified_codex_instance( identity: &CodexInstanceIdentity, ) -> anyhow::Result { @@ -253,7 +270,7 @@ pub async fn terminate_verified_codex_instance( Ok(true) } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub async fn capture_codex_instance_identity( _instance_id: &str, _generation: u64, @@ -264,13 +281,13 @@ pub async fn capture_codex_instance_identity( ) -> anyhow::Result { anyhow::bail!("当前系统无法安全确认多开进程身份") } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub async fn codex_instance_identity_matches( _identity: &CodexInstanceIdentity, ) -> anyhow::Result { Ok(false) } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub async fn terminate_verified_codex_instance( _identity: &CodexInstanceIdentity, ) -> anyhow::Result { diff --git a/crates/codex-plus-core/src/proxy.rs b/crates/codex-plus-core/src/proxy.rs index 5ed245c2ba..305a70da25 100644 --- a/crates/codex-plus-core/src/proxy.rs +++ b/crates/codex-plus-core/src/proxy.rs @@ -189,7 +189,29 @@ fn macos_scutil_system_proxy() -> Option { parse_macos_scutil_proxy(&String::from_utf8_lossy(&output.stdout)) } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +fn platform_system_proxy() -> Option { + for var in [ + "https_proxy", + "HTTPS_PROXY", + "all_proxy", + "ALL_PROXY", + "http_proxy", + "HTTP_PROXY", + ] { + if let Ok(value) = std::env::var(var) { + let trimmed = value.trim(); + if !trimmed.is_empty() { + if let Some(normalized) = normalize_proxy_url(trimmed) { + return Some(normalized); + } + } + } + } + None +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] fn platform_system_proxy() -> Option { None } diff --git a/crates/codex-plus-core/src/update.rs b/crates/codex-plus-core/src/update.rs index 551f70ab2c..72a3042277 100644 --- a/crates/codex-plus-core/src/update.rs +++ b/crates/codex-plus-core/src/update.rs @@ -538,6 +538,15 @@ fn platform_asset_rank(name: &str) -> u8 { if cfg!(windows) && is_windows_installer_asset(name) { return 0; } + if cfg!(target_os = "linux") { + if !is_linux_installer_asset(name) { + return 2; + } + if is_linux_native_arch_asset(name) { + return 0; + } + return 1; + } 2 } @@ -569,6 +578,25 @@ fn is_macos_native_arch_asset(name: &str) -> bool { true } +fn is_linux_installer_asset(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.contains("codex") + && lower.contains("plus") + && (lower.ends_with(".deb") + || lower.ends_with(".appimage") + || lower.ends_with(".tar.gz")) +} + +fn is_linux_native_arch_asset(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + let native_tokens: &[&str] = match std::env::consts::ARCH { + "x86_64" => &["amd64", "x86_64", "x64"], + "aarch64" => &["arm64", "aarch64"], + _ => return true, + }; + native_tokens.iter().any(|token| lower.contains(token)) +} + fn is_windows_installer_asset(name: &str) -> bool { name.contains("codex") && name.contains("plus") @@ -605,7 +633,16 @@ pub fn launch_installer(path: &Path) -> anyhow::Result<()> { .map_err(|error| anyhow::anyhow!("打开 DMG 失败:{error}")) } - #[cfg(all(not(windows), not(target_os = "macos")))] + #[cfg(target_os = "linux")] + { + std::process::Command::new("xdg-open") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|error| anyhow::anyhow!("打开安装包失败:{error}")) + } + + #[cfg(all(not(windows), not(target_os = "macos"), not(target_os = "linux")))] { let _ = path; anyhow::bail!("当前平台不支持启动安装包") diff --git a/crates/codex-plus-core/src/watcher.rs b/crates/codex-plus-core/src/watcher.rs index 82aeaf0e4a..6f6be439f4 100644 --- a/crates/codex-plus-core/src/watcher.rs +++ b/crates/codex-plus-core/src/watcher.rs @@ -1,7 +1,6 @@ use std::collections::{HashMap, HashSet}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; use std::path::{Path, PathBuf}; -#[cfg(any(windows, target_os = "macos"))] use std::process::{Command, Stdio}; use std::time::Duration; @@ -275,12 +274,22 @@ pub fn watcher_is_installed() -> bool { { macos_launch_agent_path().is_some_and(|path| path.is_file()) } - #[cfg(not(any(windows, target_os = "macos")))] + #[cfg(target_os = "linux")] + { + linux_autostart_path().is_some_and(|path| path.is_file()) + } + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] { false } } +pub fn linux_autostart_path() -> Option { + directories::BaseDirs::new().map(|dirs| { + dirs.config_dir().join("autostart").join("codex-plus-plus.desktop") + }) +} + pub fn cdp_listening(port: u16) -> bool { [ SocketAddr::from((Ipv4Addr::LOCALHOST, port)), @@ -379,6 +388,14 @@ pub fn macos_launcher_process_names() -> [&'static str; 2] { ] } +pub fn linux_launcher_process_names() -> [&'static str; 3] { + [ + "codex-plus-plus", + "codex-plus-launcher", + "codex-plus-manager", + ] +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProcessInstanceState { NotRunning, @@ -793,6 +810,265 @@ pub fn find_macos_codex_desktop_main_processes( ) } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LinuxDesktopProcess { + pub pid: u32, + pub lstart: String, + pub executable: String, +} + +/// 判定参数列表中是否包含合法的 Chrome / Electron 调试端口参数。 +/// 仅接受: +/// - `--remote-debugging-pipe`(当 expected_port 为 None 时) +/// - `--remote-debugging-port=`(port 为 1..=65535 的合法 u16,若指定 expected_port 则必须匹配) +/// - `--remote-debugging-port` 与后继 token ``(同上) +pub fn has_linux_codex_debug_flag(tokens: &[String], expected_port: Option) -> bool { + let match_port = |val_str: &str| -> bool { + if let Ok(port) = val_str.parse::() { + if port > 0 { + if let Some(expected) = expected_port { + return port == expected; + } + return true; + } + } + false + }; + + let mut i = 0; + while i < tokens.len() { + let token = &tokens[i]; + if token == "--remote-debugging-pipe" && expected_port.is_none() { + return true; + } + if let Some(port_str) = token.strip_prefix("--remote-debugging-port=") { + if match_port(port_str) { + return true; + } + } else if token == "--remote-debugging-port" { + if let Some(next_token) = tokens.get(i + 1) { + if match_port(next_token) { + return true; + } + } + } + i += 1; + } + false +} + +/// 依据进程真实的 argv 列表、可执行文件实际符号链接路径(/proc//exe)及预期的应用安装目录, +/// 严格判定是否为 Linux 桌面端主进程。 +/// +/// 纯函数设计,不产生系统 I/O,无命令拼接或 shell escaping 隐患,便于单元测试完备覆盖。 +pub fn is_linux_codex_desktop_process( + argv: &[String], + exe_path: Option<&Path>, + expected_app_dir: Option<&Path>, + expected_port: Option, +) -> bool { + if argv.is_empty() { + return false; + } + + // 1. 过滤 Electron 子进程(如 --type=renderer, --type=gpu-process 等) + // 以及 Crashpad 崩溃收集进程、内置 CLI 和 Codex++ 自身 + for token in argv { + if token.starts_with("--type=") { + return false; + } + if token.contains("crashpad_handler") { + return false; + } + if token.contains("/resources/codex") || token.contains("codex-cli") { + return false; + } + if token.contains("codex-plus-plus") + || token.contains("codex-plus-launcher") + || token.contains("codex-plus-manager") + { + return false; + } + } + + // 2. 确定当前进程的可执行文件路径与文件名 + let candidate_path = exe_path.unwrap_or_else(|| Path::new(&argv[0])); + let exe_str = candidate_path.to_string_lossy(); + + // 严禁将 node、python、npm 全局安装或 snap cli 误判 + if exe_str.contains("/node_modules/") + || exe_str.starts_with("/snap/bin/") + || exe_str.ends_with("/node") + || exe_str == "node" + || exe_str.ends_with("/python") + || exe_str.ends_with("/python3") + || exe_str == "python" + || exe_str == "python3" + { + return false; + } + + let file_name = candidate_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&argv[0]); + + // 候选可执行文件名仅限官方支持的 ChatGPT 与 Codex 主程序 + let is_candidate_name = matches!( + file_name, + "ChatGPT" | "chatgpt" | "Codex" | "codex" | "ChatGPT Dev" | "Codex Dev" + ); + + if !is_candidate_name { + return false; + } + + // 3. 桌面应用路径判定(若提供了 expected_app_dir,则严格与 expected_app_dir / build_codex_executable 比对) + // 对于 AppImage:运行时 exe 指向挂载目录,比对由 expected_app_dir / resolve_codex_app_dir 覆盖 + let is_resolved_app = if let Some(app_dir) = expected_app_dir { + let expected_exe = crate::app_paths::build_codex_executable(app_dir); + candidate_path == expected_exe || candidate_path.starts_with(app_dir) + } else { + false + }; + + // 白名单目录(不含 /usr/lib/codex/) + let is_install_dir = is_resolved_app + || exe_str.starts_with("/opt/chatgpt/") + || exe_str.starts_with("/opt/ChatGPT/") + || exe_str.starts_with("/opt/codex/") + || exe_str.starts_with("/opt/Codex/") + || exe_str.starts_with("/opt/chatgpt-dev/") + || exe_str.starts_with("/opt/codex-dev/") + || exe_str.starts_with("/usr/lib/chatgpt/") + || exe_str.starts_with("/app/bin/"); + + // 4. 调试端口参数匹配 + let has_debug_flag = has_linux_codex_debug_flag(argv, expected_port); + + if expected_port.is_some() { + has_debug_flag + } else { + is_install_dir || has_debug_flag + } +} + +/// 解析 Linux `/proc//stat` 中的进程启动时间(starttime,第 22 个字段,即系统启动后的时钟滴答数)。 +/// +/// 提取为纯函数以便在非 Linux 平台及单测中验证。 +/// 注意:第 2 个字段 comm 格式为 `(filename)`,内部可能包含空格或括号, +/// 因此必须从最后一个 `)` 之后开始切分剩余字段。 +pub fn parse_linux_proc_stat_starttime(stat_content: &str) -> Option { + let rparen_idx = stat_content.rfind(')')?; + let rest = stat_content.get(rparen_idx + 1..)?.trim_start(); + // rest 的第 1 个字段是 state(原本第 3 字段),按空白切分后第 19 个字段(0-indexed)即为 starttime(原本第 22 字段) + let mut parts = rest.split_whitespace(); + parts.nth(19).map(|s| s.to_string()) +} + +#[cfg(any(target_os = "linux", test))] +pub fn linux_process_starttime(pid: u32) -> Option { + let stat_content = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; + parse_linux_proc_stat_starttime(&stat_content) +} + +#[cfg(target_os = "linux")] +fn current_uid() -> u32 { + unsafe extern "C" { + fn getuid() -> u32; + } + unsafe { getuid() } +} + +#[cfg(target_os = "linux")] +pub fn find_linux_codex_desktop_main_processes( + debug_port: Option, +) -> Vec { + let uid = current_uid(); + let expected_app_dir = crate::app_paths::resolve_codex_app_dir(None); + let mut processes = Vec::new(); + + let proc_dir = match std::fs::read_dir("/proc") { + Ok(dir) => dir, + Err(_) => return Vec::new(), + }; + + for entry in proc_dir.flatten() { + let file_name = entry.file_name(); + let name_str = file_name.to_string_lossy(); + let Ok(pid) = name_str.parse::() else { + continue; + }; + + // 1. 检查 UID:读取 /proc//status 中的 Uid 行 + let status_path = format!("/proc/{pid}/status"); + let Ok(status_content) = std::fs::read_to_string(&status_path) else { + continue; + }; + let mut proc_uid = None; + for line in status_content.lines() { + if let Some(rest) = line.strip_prefix("Uid:") { + if let Some(uid_str) = rest.split_whitespace().next() { + proc_uid = uid_str.parse::().ok(); + break; + } + } + } + if proc_uid != Some(uid) { + continue; + } + + // 2. 读取 /proc//cmdline:读取失败或为空必须排除(绝不放行) + let cmdline_path = format!("/proc/{pid}/cmdline"); + let Ok(bytes) = std::fs::read(&cmdline_path) else { + continue; + }; + if bytes.is_empty() { + continue; + } + let argv: Vec = bytes + .split(|&b| b == 0) + .filter(|slice| !slice.is_empty()) + .map(|slice| String::from_utf8_lossy(slice).to_string()) + .collect(); + if argv.is_empty() { + continue; + } + + // 3. 读取 /proc//exe 符号链接 + let exe_path = std::fs::read_link(format!("/proc/{pid}/exe")).ok(); + + // 4. 调用纯函数判定 + if !is_linux_codex_desktop_process( + &argv, + exe_path.as_deref(), + expected_app_dir.as_deref(), + debug_port, + ) { + continue; + } + + // 5. 进程启动时间标记:必须读取 /proc//stat 的 starttime;读不到则直接跳过该进程(防止复核时格式不一致导致永远无法结束) + let Some(lstart) = linux_process_starttime(pid) else { + continue; + }; + + let executable = exe_path + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| argv[0].clone()); + + processes.push(LinuxDesktopProcess { + pid, + lstart, + executable, + }); + } + + processes.sort_by_key(|p| p.pid); + processes.dedup_by_key(|p| p.pid); + processes +} + #[cfg(test)] mod desktop_main_process_tests { use super::*; @@ -986,7 +1262,17 @@ pub fn uninstall_watcher() -> anyhow::Result<()> { Ok(()) } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn uninstall_watcher() -> anyhow::Result<()> { + if let Some(desktop_path) = linux_autostart_path() { + if desktop_path.is_file() { + let _ = std::fs::remove_file(desktop_path); + } + } + Ok(()) +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn uninstall_watcher() -> anyhow::Result<()> { Ok(()) } @@ -1076,12 +1362,25 @@ pub fn find_session_index_cleanup_blocking_processes() -> Vec { find_codex_processes() } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn find_codex_processes() -> Vec { + find_linux_codex_desktop_main_processes(None) + .into_iter() + .map(|process| process.pid) + .collect() +} + +#[cfg(target_os = "linux")] +pub fn find_session_index_cleanup_blocking_processes() -> Vec { + find_codex_processes() +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn find_codex_processes() -> Vec { Vec::new() } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn find_session_index_cleanup_blocking_processes() -> Vec { Vec::new() } @@ -1111,7 +1410,14 @@ pub fn stop_launcher_processes() { } } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn stop_launcher_processes() { + for process_id in find_launcher_processes() { + let _ = terminate_linux_process(process_id); + } +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn stop_launcher_processes() {} #[cfg(windows)] @@ -1476,7 +1782,17 @@ pub fn stop_launcher_processes_and_wait() { ); } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn stop_launcher_processes_and_wait() { + terminate_linux_processes_and_wait( + find_launcher_processes(), + find_launcher_processes, + RESTART_STOP_WAIT_TIMEOUT_MS, + RESTART_STOP_WAIT_INTERVAL_MS, + ); +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn stop_launcher_processes_and_wait() {} #[cfg(windows)] @@ -1486,9 +1802,6 @@ pub fn stop_codex_processes() { } } -#[cfg(not(any(windows, target_os = "macos")))] -pub fn stop_codex_processes() {} - #[cfg(target_os = "macos")] pub fn stop_codex_processes() { for process_id in find_codex_processes() { @@ -1496,6 +1809,16 @@ pub fn stop_codex_processes() { } } +#[cfg(target_os = "linux")] +pub fn stop_codex_processes() { + for process_id in find_codex_processes() { + let _ = terminate_linux_process(process_id); + } +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] +pub fn stop_codex_processes() {} + #[cfg(windows)] pub fn stop_codex_processes_and_wait() { terminate_and_wait_for_exit( @@ -1515,7 +1838,17 @@ pub fn stop_codex_processes_and_wait() { ); } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn stop_codex_processes_and_wait() { + terminate_linux_processes_and_wait( + find_codex_processes(), + find_codex_processes, + RESTART_STOP_WAIT_TIMEOUT_MS, + RESTART_STOP_WAIT_INTERVAL_MS, + ); +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn stop_codex_processes_and_wait() {} #[cfg(target_os = "macos")] @@ -1529,7 +1862,15 @@ pub fn find_codex_processes_for_debug_port(debug_port: u16) -> Vec { codex_instance_processes_for_debug_port(debug_port) } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn find_codex_processes_for_debug_port(debug_port: u16) -> Vec { + find_linux_codex_desktop_main_processes(Some(debug_port)) + .into_iter() + .map(|process| process.pid) + .collect() +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn find_codex_processes_for_debug_port(_debug_port: u16) -> Vec { find_codex_processes() } @@ -1661,7 +2002,17 @@ pub fn stop_codex_processes_for_debug_port_with_fallback( ); } -#[cfg(not(any(windows, target_os = "macos")))] +#[cfg(target_os = "linux")] +pub fn stop_codex_processes_for_debug_port_and_wait(debug_port: u16) { + terminate_linux_processes_and_wait( + find_codex_processes_for_debug_port(debug_port), + || find_codex_processes_for_debug_port(debug_port), + RESTART_STOP_WAIT_TIMEOUT_MS, + RESTART_STOP_WAIT_INTERVAL_MS, + ); +} + +#[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] pub fn stop_codex_processes_for_debug_port_and_wait(_debug_port: u16) { stop_codex_processes_and_wait(); } @@ -1873,6 +2224,88 @@ fn find_launcher_processes() -> Vec { process_ids } +#[cfg(target_os = "linux")] +fn terminate_linux_process(process_id: u32) -> std::io::Result<()> { + Command::new("kill") + .arg("-15") + .arg(process_id.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|_| ()) +} + +#[cfg(target_os = "linux")] +fn terminate_linux_processes_and_wait( + process_ids: Vec, + mut find_processes: F, + timeout_ms: u64, + interval_ms: u64, +) where + F: FnMut() -> Vec, +{ + if process_ids.is_empty() { + return; + } + for process_id in &process_ids { + let _ = terminate_linux_process(*process_id); + } + let deadline = std::time::Instant::now() + Duration::from_millis(timeout_ms); + loop { + let remaining = process_ids_still_running(&process_ids, find_processes()); + if remaining.is_empty() || std::time::Instant::now() >= deadline { + if !remaining.is_empty() { + for process_id in &remaining { + #[cfg(target_os = "linux")] + let _ = Command::new("kill") + .arg("-9") + .arg(process_id.to_string()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } + let _ = crate::diagnostic_log::append_diagnostic_log( + "watcher.stop_wait_timeout", + serde_json::json!({ + "remaining_process_ids": remaining, + "timeout_ms": timeout_ms, + "platform": "linux" + }), + ); + } + break; + } + std::thread::sleep(Duration::from_millis(interval_ms)); + } +} + +#[cfg(target_os = "linux")] +fn find_launcher_processes() -> Vec { + let current_process_id = std::process::id(); + let uid = current_uid(); + let mut process_ids = linux_launcher_process_names() + .into_iter() + .flat_map(|process_name| { + std::process::Command::new("pgrep") + .args(["-u", &uid.to_string(), "-x", process_name]) + .output() + .ok() + .into_iter() + .flat_map(|output| { + String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|value| value.trim().parse::().ok()) + .collect::>() + }) + .collect::>() + }) + .filter(|process_id| *process_id != current_process_id) + .collect::>(); + process_ids.sort_unstable(); + process_ids.dedup(); + process_ids +} + #[cfg(target_os = "macos")] pub(crate) fn find_macos_codex_processes_for_debug_port(debug_port: u16) -> Vec { let Ok(output) = std::process::Command::new("ps") @@ -2059,6 +2492,19 @@ fn create_startup_shortcut(launcher_path: &Path, arguments: &str) -> anyhow::Res }) } +#[cfg(target_os = "linux")] +fn spawn_launcher(launcher_path: &Path, debug_port: u16) { + let command = build_spawn_launcher_command(&launcher_path.to_string_lossy(), debug_port); + if let Some((exe, args)) = command.split_first() { + let _ = Command::new(exe) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + } +} + #[cfg(windows)] fn spawn_launcher(launcher_path: &Path, debug_port: u16) { let command = build_spawn_launcher_command(&launcher_path.to_string_lossy(), debug_port); diff --git a/crates/codex-plus-core/tests/all/launcher.rs b/crates/codex-plus-core/tests/all/launcher.rs index 23661a5928..26d5738de6 100644 --- a/crates/codex-plus-core/tests/all/launcher.rs +++ b/crates/codex-plus-core/tests/all/launcher.rs @@ -698,6 +698,8 @@ fn app_paths_linux_finds_codex_app_from_search_roots_avoiding_empty_opt() { let chatgpt_dir = usr_lib.join("chatgpt"); std::fs::create_dir_all(&chatgpt_dir).unwrap(); std::fs::write(chatgpt_dir.join("ChatGPT"), "").unwrap(); + std::fs::create_dir(chatgpt_dir.join("resources")).unwrap(); + std::fs::write(chatgpt_dir.join("resources/app.asar"), "electron").unwrap(); let roots = vec![opt, usr_lib]; assert_eq!( diff --git a/crates/codex-plus-core/tests/all/watcher.rs b/crates/codex-plus-core/tests/all/watcher.rs index 9268d9570f..72bd312f98 100644 --- a/crates/codex-plus-core/tests/all/watcher.rs +++ b/crates/codex-plus-core/tests/all/watcher.rs @@ -1,6 +1,7 @@ use codex_plus_core::watcher::{ MACOS_WATCHER_LABEL, build_spawn_launcher_command, build_watcher_install_plan, cdp_listening, codex_process_ids, disable_watcher_at, enable_watcher_at, filter_killable_launcher_processes, + is_linux_codex_desktop_process, parse_linux_proc_stat_starttime, macos_launcher_process_names, macos_launch_agent_installed_at, macos_launch_agent_path_at, macos_launch_agent_plist, process_id_is_running, process_ids_still_running, remove_macos_launch_agent_at, should_recover_stale_launcher, watcher_disabled_flag, @@ -445,3 +446,168 @@ fn find_codex_processes_ignores_unrelated_processes() { assert!(find_codex_processes_from_snapshot(&processes).is_empty()); } + +#[test] +fn linux_proc_stat_starttime_parsing_tests() { + // 1. 标准 /proc//stat 输出:第 22 个字段(从最后一个 ')' 算起第 19 个 token)为 starttime + let stat = "66483 (cat) R 61311 66483 66483 34818 66483 4194304 215 0 0 0 0 0 0 0 22 0 1 0 8908263 9170944 428 18446744073709551615"; + assert_eq!( + parse_linux_proc_stat_starttime(stat), + Some("8908263".to_string()) + ); + + // 2. comm 中包含空格与嵌套括号的复杂真实场景(如 "(ChatGPT (Dev))") + let complex_stat = "12345 (ChatGPT (Dev)) S 1000 12345 12345 0 0 4194304 100 0 0 0 10 5 0 0 20 0 5 0 12345678 1000000 500"; + assert_eq!( + parse_linux_proc_stat_starttime(complex_stat), + Some("12345678".to_string()) + ); + + // 3. 截断或非法的 stat 串,必须返回 None + assert_eq!(parse_linux_proc_stat_starttime(""), None); + assert_eq!(parse_linux_proc_stat_starttime("12345 (cat)"), None); + assert_eq!(parse_linux_proc_stat_starttime("12345 cat R 1 2 3"), None); +} + +#[cfg(target_os = "linux")] +#[test] +fn linux_find_codex_processes_live_test() { + let pids = codex_plus_core::watcher::find_codex_processes(); + let p9229 = codex_plus_core::watcher::find_codex_processes_for_debug_port(9229); + for pid in pids.iter().chain(p9229.iter()) { + assert!(*pid > 0); + } +} + +#[test] +fn linux_codex_desktop_process_pure_function_argv_and_exe_tests() { + use std::path::Path; + + // 1. 任意非 Codex 的 AppImage(如 Obsidian.AppImage)绝对不能被识别为桌面版 + assert!(!is_linux_codex_desktop_process( + &["/home/user/Applications/Obsidian.AppImage".into()], + Some(Path::new("/home/user/Applications/Obsidian.AppImage")), + None, + None, + )); + assert!(!is_linux_codex_desktop_process( + &["Obsidian.AppImage".into()], + Some(Path::new("/usr/bin/Obsidian.AppImage")), + None, + None, + )); + + // AppImage 实际运行时 exe 指向挂载目录,未挂载文件路径本身不按文件名比对 + assert!(!is_linux_codex_desktop_process( + &["/home/user/Applications/ChatGPT.AppImage".into()], + Some(Path::new("/home/user/Applications/ChatGPT.AppImage")), + None, + None, + )); + // 挂载后的 AppImage 目录通过 expected_app_dir 比对 + let mount_dir = Path::new("/tmp/.mount_ChatGPT1234"); + let mount_exe = Path::new("/tmp/.mount_ChatGPT1234/ChatGPT"); + assert!(is_linux_codex_desktop_process( + &["/tmp/.mount_ChatGPT1234/ChatGPT".into()], + Some(mount_exe), + Some(mount_dir), + None, + )); + + // 2. 带有引号参数的命令行(直接模拟 /proc//cmdline 的 NUL 分隔真实 argv token) + assert!(!is_linux_codex_desktop_process( + &["codex".into(), "修一下 app.asar".into()], + Some(Path::new("/usr/bin/codex")), + None, + None, + )); + assert!(!is_linux_codex_desktop_process( + &["codex".into(), "--msg=remote-debugging-port=9222".into()], + Some(Path::new("/usr/bin/codex")), + None, + None, + )); + assert!(!is_linux_codex_desktop_process( + &["/usr/lib/codex/codex".into()], + Some(Path::new("/usr/lib/codex/codex")), + None, + None, + )); + + // 3. 官方桌面安装目录(白名单) + assert!(is_linux_codex_desktop_process( + &["/usr/lib/chatgpt/ChatGPT".into()], + Some(Path::new("/usr/lib/chatgpt/ChatGPT")), + None, + None, + )); + assert!(is_linux_codex_desktop_process( + &["/opt/codex/codex".into()], + Some(Path::new("/opt/codex/codex")), + None, + None, + )); + + // 4. 动态解析出来的应用目录(expected_app_dir / build_codex_executable)比对 + let custom_app_dir = Path::new("/custom/chatgpt"); + let custom_exe = Path::new("/custom/chatgpt/ChatGPT"); + assert!(is_linux_codex_desktop_process( + &["/custom/chatgpt/ChatGPT".into()], + Some(custom_exe), + Some(custom_app_dir), + None, + )); + // 目录不匹配且无调试端口时拒绝 + assert!(!is_linux_codex_desktop_process( + &["/other/chatgpt/ChatGPT".into()], + Some(Path::new("/other/chatgpt/ChatGPT")), + Some(custom_app_dir), + None, + )); + + // 5. 调试端口过滤与多开隔离:主实例 (9229) vs 多开实例 (9230) + let argv_9229 = vec![ + "/usr/lib/chatgpt/ChatGPT".into(), + "--remote-debugging-port=9229".into(), + ]; + let argv_9230 = vec![ + "/usr/lib/chatgpt/ChatGPT".into(), + "--remote-debugging-port=9230".into(), + ]; + let exe = Some(Path::new("/usr/lib/chatgpt/ChatGPT")); + + // 不指定 expected_port 时两者均为桌面主进程 + assert!(is_linux_codex_desktop_process(&argv_9229, exe, None, None)); + assert!(is_linux_codex_desktop_process(&argv_9230, exe, None, None)); + + // 指定 expected_port 9229 时,仅匹配 argv_9229 + assert!(is_linux_codex_desktop_process( + &argv_9229, + exe, + None, + Some(9229) + )); + assert!(!is_linux_codex_desktop_process( + &argv_9230, + exe, + None, + Some(9229) + )); + + // 指定 expected_port 9230 时,仅匹配 argv_9230 + assert!(!is_linux_codex_desktop_process( + &argv_9229, + exe, + None, + Some(9230) + )); + assert!(is_linux_codex_desktop_process( + &argv_9230, + exe, + None, + Some(9230) + )); + + // 6. 空 argv 必须直接返回 false + assert!(!is_linux_codex_desktop_process(&[], None, None, None)); +} diff --git a/docs/linux-packaging.md b/docs/linux-packaging.md new file mode 100644 index 0000000000..3e8c802222 --- /dev/null +++ b/docs/linux-packaging.md @@ -0,0 +1,56 @@ +# Linux packaging and support + +## Supported Linux distributions + +The `.deb` package dependencies explicitly target the **64-bit `time_t` (`t64`) transition** packages: +- `libglib2.0-0t64` +- `libgtk-3-0t64` +- `libssl3t64 (>= 3.0.0)` +- `libatspi2.0-0t64` +- `libasound2t64` + +### Verified & Tested Distributions: +- **Ubuntu 24.04 LTS (Noble Numbat)**: Primary CI verification baseline (`ubuntu-24.04` runner). +- **Debian 13 (Trixie)** / Debian Testing & Sid: Local development and live test baseline. + +### Expected Compatible Distributions (预计兼容): +- **Linux Mint 22**: Compatible derivative based on Ubuntu 24.04. + +### Incompatible Legacy Distributions: +The package is **not compatible with Ubuntu 22.04 LTS (Jammy) or Debian 12 (Bookworm)**: +their runtime repositories provide legacy non-t64 package names (`libglib2.0-0`, `libgtk-3-0`, `libssl3`, etc.) rather than the `t64` package names required by the debian control dependencies. + +The Linux graphical application requires an active desktop session (X11 or Wayland); WSL2 requires WSLg or an X11 display server. + +## Build + +```bash +sudo apt-get update +sudo apt-get install -y build-essential curl git pkg-config libssl-dev \ + libsqlite3-dev libdbus-1-dev libglib2.0-dev libgtk-3-dev \ + libayatana-appindicator3-dev libjavascriptcoregtk-4.1-dev \ + libwebkit2gtk-4.1-dev libsoup-3.0-dev libz3-dev +cd apps/codex-plus-manager +npm ci +npm run vite:build +cd ../.. +cargo build --release -p codex-plus-manager +bash scripts/installer/linux/build-deb.sh --package-only +``` + +The script reads the default version from +`apps/codex-plus-manager/package.json` using its absolute path, independent +of the calling directory. To supply a release version explicitly, pass it +after `--package-only`. + +```bash +sudo apt install ./dist/linux/codex-plus-plus_*.deb +dpkg -V codex-plus-plus +ldd /usr/bin/codex-plus-plus +``` + +The package contains exactly one executable, `/usr/bin/codex-plus-plus`. +The PR build workflow verifies Linux release compilation and `.deb` package generation via the `linux-deb` job in `.github/workflows/pr-build.yml`. + +The CI build and package installation check is not a substitute for a manual GUI launch +test in a real desktop session. diff --git a/scripts/assemble-native-browser-inspector.mjs b/scripts/assemble-native-browser-inspector.mjs index 4f524fb6ea..f32c5644b9 100644 --- a/scripts/assemble-native-browser-inspector.mjs +++ b/scripts/assemble-native-browser-inspector.mjs @@ -30,7 +30,7 @@ for (const dependency of [...dependencies].sort()) { const dir = path.join(root, "apps/codex-plus-manager/node_modules", dependency); const pkg = JSON.parse(await readFile(path.join(dir, "package.json"), "utf8")); let license; - for (const name of ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE-MIT.txt"]) { + for (const name of ["LICENSE", "LICENSE.md", "LICENSE.txt", "LICENSE-MIT.txt", "license.md", "license", "license.txt"]) { license = await readFile(path.join(dir, name), "utf8").catch(() => null); if (license) break; } diff --git a/scripts/installer/linux/build-deb.sh b/scripts/installer/linux/build-deb.sh index 05ed698b3d..49bda92720 100755 --- a/scripts/installer/linux/build-deb.sh +++ b/scripts/installer/linux/build-deb.sh @@ -2,7 +2,7 @@ set -euo pipefail # Codex++ Linux .deb 打包脚本 -# 用法: sudo bash scripts/installer/linux/build-deb.sh [VERSION] +# Usage: bash scripts/installer/linux/build-deb.sh [--package-only|--frontend-only|--rust-only|--deps-only] [VERSION] # 或分步执行下面的各个函数 SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -11,7 +11,38 @@ PROJECT_DIR="$REPO_DIR/apps/codex-plus-manager" BUILD_DIR="$REPO_DIR/.linux-build" DEB_DIR="$BUILD_DIR/deb" PACKAGE_NAME="codex-plus-plus" -VERSION="${1:-1.3.0}" +ACTION="all" +VERSION="" +for arg in "$@"; do + case "$arg" in + --package-only) ACTION="package" ;; + --frontend-only) ACTION="frontend" ;; + --rust-only) ACTION="rust" ;; + --deps-only) ACTION="deps" ;; + -h|--help) + echo "Usage: $0 [--package-only|--frontend-only|--rust-only|--deps-only] [VERSION]" + exit 0 + ;; + --*) echo "error: unknown option $arg" >&2; exit 2 ;; + *) + if [ -n "$VERSION" ]; then echo "error: multiple VERSION arguments" >&2; exit 2; fi + VERSION="$arg" + ;; + esac +done + +# Absolute path: works from any current directory, with no silent stale-version fallback. +if [ -z "$VERSION" ]; then + if ! command -v node >/dev/null 2>&1; then + echo "error: Node.js is required to read the default package version; supply VERSION explicitly" >&2 + exit 1 + fi + VERSION="$(node -e 'const p = require(process.argv[1]); if (!p.version) process.exit(1); process.stdout.write(p.version)' "$PROJECT_DIR/package.json")" +fi +if ! dpkg --validate-version "$VERSION" >/dev/null 2>&1; then + echo "error: invalid Debian package version: $VERSION" >&2 + exit 1 +fi ARCH="$(dpkg --print-architecture)" # Node / 包管理器:优先从环境变量取,其次探测 PATH。 # 不要写死绝对路径——这里是给所有贡献者和 CI 用的,不是某台机器的构建脚本。 @@ -47,43 +78,30 @@ install_system_deps() { # ── 2. 安装 Rust toolchain(无需 sudo)─────────────────────── install_rust() { + if [ -f "$HOME/.cargo/env" ]; then . "$HOME/.cargo/env"; fi if command -v cargo &>/dev/null; then echo ">>> Rust 已安装: $(cargo --version)" return fi echo ">>> 安装 Rust toolchain (rustup)..." curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + . "$HOME/.cargo/env" echo ">>> Rust 安装完成: $(cargo --version)" } # ── 3. 构建前端 ────────────────────────────────────────────── build_frontend() { echo ">>> 构建前端..." - cd "$PROJECT_DIR" - # vite 需要 node 在 PATH 上;允许通过 NODE_BIN 指定非 PATH 中的安装。 - if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then - export PATH="$(dirname "$NODE_BIN"):$PATH" - fi - local PM="$PNPM_BIN" - if [ -z "$PM" ] || [ ! -x "$PM" ]; then - PM="$(command -v pnpm || true)" - fi - if [ -z "$PM" ]; then - echo "error: 找不到 pnpm。请安装(npm i -g pnpm)或用 PNPM_BIN=/path/to/pnpm 指定。" >&2 - return 1 - fi - export PATH="$(dirname "$PM"):$PATH" - "$PM" install - # Build vite directly (skip npm/pnpm wrapper issues) - node_modules/.bin/vite build + if [ -n "$NODE_BIN" ] && [ -x "$NODE_BIN" ]; then export PATH="$(dirname "$NODE_BIN"):$PATH"; fi + if ! command -v npm >/dev/null 2>&1; then echo "error: npm is required (npm ci)" >&2; return 1; fi + ( cd "$PROJECT_DIR" && npm ci && npm run vite:build ) echo ">>> 前端构建完成" } - # ── 4. 构建 Rust 二进制 ────────────────────────────────────── build_rust() { echo ">>> 构建 Rust 二进制..." export PATH="$HOME/.cargo/bin:$PATH" - cargo build --release -p codex-plus-manager + ( cd "$REPO_DIR" && cargo build --release -p codex-plus-manager ) echo ">>> Rust 构建完成" } @@ -153,9 +171,8 @@ Section: misc Priority: optional Architecture: ${ARCH} Maintainer: BigPizzaV3 <1727732@qq.com> -Installed-Size: 70620 +Installed-Size: ${installed_size} Depends: libc6 (>= 2.34), libgcc-s1 (>= 4.2), libstdc++6 (>= 13), libdbus-1-3, libglib2.0-0t64, libgtk-3-0t64, libwebkit2gtk-4.1-0, libsoup-3.0-0, libjavascriptcoregtk-4.1-0, libssl3t64 (>= 3.0.0), libz3-4, libatspi2.0-0t64, libasound2t64 - Recommends: codex Description: Codex++ configures and launches OpenAI Codex / ChatGPT desktop apps. It provides provider switching, protocol conversion, session management and UI enhancements @@ -198,18 +215,21 @@ EOF # ── 主流程 ───────────────────────────────────────────────── main() { - install_system_deps - install_rust - build_frontend - build_rust - package_deb + case "$ACTION" in + deps) install_system_deps; install_rust ;; + frontend) build_frontend ;; + rust) build_rust ;; + package) package_deb ;; + all) install_system_deps; install_rust; build_frontend; build_rust; package_deb ;; + esac echo "" - echo "=== 构建完成 ===" - local deb_name="${PACKAGE_NAME}_${VERSION}_${ARCH}.deb" - echo "Deb 包路径: $REPO_DIR/dist/linux/$deb_name" - echo "" - echo "安装命令: sudo dpkg -i $REPO_DIR/dist/linux/$deb_name" + echo "=== 完成 ===" + if [ "$ACTION" = "package" ] || [ "$ACTION" = "all" ]; then + local deb_name="${PACKAGE_NAME}_${VERSION}_${ARCH}.deb" + echo "Deb 包路径: $REPO_DIR/dist/linux/$deb_name" + echo "安装命令: sudo apt install $REPO_DIR/dist/linux/$deb_name" + fi } main "$@"