diff --git a/apps/console/src-tauri/Cargo.lock b/apps/console/src-tauri/Cargo.lock index 08a2af6..64f2390 100644 --- a/apps/console/src-tauri/Cargo.lock +++ b/apps/console/src-tauri/Cargo.lock @@ -2108,6 +2108,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-single-instance", "tokio", ] @@ -3519,6 +3520,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c142ed88deee222bd2d979269d35c73b6c1c0f6ebd5b79b4ff80066fcad6af1" +dependencies = [ + "serde", + "serde_json", + "tauri", + "thiserror 2.0.21", + "tokio", + "tracing", + "windows-sys 0.61.2", + "zbus", +] + [[package]] name = "tauri-runtime" version = "2.12.1" diff --git a/apps/console/src-tauri/Cargo.toml b/apps/console/src-tauri/Cargo.toml index 82784b0..c368fcb 100644 --- a/apps/console/src-tauri/Cargo.toml +++ b/apps/console/src-tauri/Cargo.toml @@ -22,6 +22,9 @@ tauri-build = { version = "2", features = [] } # 不给这个 feature,`TrayIconBuilder` 根本不存在(编译期就找不到符号)。 tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" +# ⚠️ 单实例保护(v2.0.3 修):缺了它,「开机自启的隐藏实例 + 用户手开的实例」会并存, +# 各自托管一个被控端 → 同机两个被控端抢同一个 8765。详见 src/lib.rs 顶部「单实例」一节。 +tauri-plugin-single-instance = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" # ⚠️ 2026-10:不再依赖 crates/nodeagent-client —— 协议实现改由 Node sidecar 承担 diff --git a/apps/console/src-tauri/src/agent.rs b/apps/console/src-tauri/src/agent.rs index 783b8d8..bce915a 100644 --- a/apps/console/src-tauri/src/agent.rs +++ b/apps/console/src-tauri/src/agent.rs @@ -394,12 +394,25 @@ impl AgentManager { // 端口占用检查:既有的(非本控制台托管的)实例在跑时,给出可执行的说明, // 而不是让用户看到一个 `EADDRINUSE` 堆栈。 + // + // ⚠️ 这里是症状 B(「应用端无法启动」)的**现场**:真机上曾有陈旧计划任务拉起的 + // 被控端占着 8765,控制台自己的被控端起不来。端口检查本身是对的, + // 但只给一句「已被占用」用户无从下手 —— 故补上「怎么找出占用者」。 let cfg = read_agent_config(data_dir); let port = cfg.get("port").and_then(Value::as_u64).unwrap_or(8765) as u16; if port_in_use(port) { + let hint = if cfg!(windows) { + format!( + "netstat -ano | findstr :{port} 找到 PID,再 tasklist /FI \"PID eq \" 看是谁" + ) + } else { + format!("lsof -nP -iTCP:{port} -sTCP:LISTEN") + }; return Err(format!( - "端口 {port} 已被占用 —— 可能已有一个被控端在运行(手工安装的或另一个实例)。\ - 请先停止它,或修改 {} 里的 port。", + "端口 {port} 已被占用 —— 已有另一个被控端在监听(手工安装的、计划任务拉起的,\ + 或另一个控制台实例)。\n\ + 排查占用者:{hint}\n\ + 处理:先停掉它;或把 {} 里的 port 改成别的端口后重启本机服务。", data_dir.join("agent.json").display() )); } @@ -730,6 +743,37 @@ mod tests { m.stop().await.unwrap(); } + #[tokio::test] + async fn 端口被外部占用时应给出可执行的错误说明() { + let dir = tmp_dir("port-busy"); + let script = fake_agent(&dir); + // 占住一个空闲端口,扮演「手工安装的 / 计划任务拉起的另一个被控端」 + // (真机症状 B:陈旧计划任务 na-gui 占着 8765,控制台自己的被控端起不来) + let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("应能绑一个空闲端口"); + let port = listener.local_addr().unwrap().port(); + std::fs::write( + dir.join("agent.json"), + format!("{{\"node_id\":\"t\",\"port\":{port},\"key\":\"k\",\"tls\":true}}"), + ) + .unwrap(); + + let m = AgentManager::new(Arc::new(|_| {})); + let err = m + .start(&node_bin(), &script, &dir, false) + .await + .expect_err("端口被占时应拒绝启动"); + assert!(err.contains("已被占用"), "应说明端口被占用,实际:{err}"); + assert!(err.contains(&port.to_string()), "错误里应带上具体端口号,实际:{err}"); + // 关键:必须给出「怎么找出占用者」的可执行线索,否则用户无从下手 + assert!( + err.contains("lsof") || err.contains("netstat"), + "应给出排查占用者的命令,实际:{err}" + ); + // 端口检查失败不该改动状态、更不该留下进程 + assert_eq!(m.snapshot().await["state"], json!(state::STOPPED)); + drop(listener); + } + #[tokio::test] async fn 进程意外退出应转为crashed() { let dir = tmp_dir("crash"); diff --git a/apps/console/src-tauri/src/lib.rs b/apps/console/src-tauri/src/lib.rs index 7940939..15c7305 100644 --- a/apps/console/src-tauri/src/lib.rs +++ b/apps/console/src-tauri/src/lib.rs @@ -26,6 +26,9 @@ //! - 默认:显示主窗口 //! - `--agent-only`:**不显示窗口**,直接进托盘并托管本机被控端(开机自启用它, //! 这样登录自启也不会有任何窗口弹出来) +//! +//! ## 单实例(v2.0.3) +//! 见下方「单实例」一节 —— 真机缺陷:无保护时同机会跑起两个被控端。 mod agent; mod autostart; @@ -603,11 +606,51 @@ fn build_tray(app: &AppHandle) -> tauri::Result<()> { Ok(()) } +// --------------------------------------------------------------------------- +// 单实例 +// --------------------------------------------------------------------------- +// +// ## 为什么必须有(真机缺陷,2026-10-13) +// 控制台此前**完全没有单实例保护**。开机自启会拉起一个 `--agent-only` 实例(无窗口、驻托盘), +// 用户再手动双击图标 → **第二个进程**。而「本机被控端」是**进程内状态** +// (`AgentManager` 各持自己的 `child: Option` / `pid`),两个进程互相看不见 → +// 同一台机器上跑起**两个被控端**,抢同一个 8765 端口。 +// +// 后果按先后顺序分两种,正是用户报的两个症状: +// - **A(两个被控端)**:两边都抢到了不同时机 → 同机并存两个被控端进程; +// - **B(后开的起不来)**:先占住端口的那一侧赢了,后启动的一侧在 `AgentManager::start` +// 的端口检查处失败,界面表现为「应用端无法启动」(本机服务起不来)。 +// +// ## 为什么不能简单「已有实例就退出」 +// 自启实例是**无窗口**的(`--agent-only`)。若第二次启动只是静默退出,用户双击图标会 +// **什么都不发生** —— 比缺陷本身更难排查。正确语义是: +// 第二次启动 → 把「用户想看到界面」这个意图**交给已有实例**(让它显示并聚焦窗口)→ 新进程再退出。 +// +// `tauri-plugin-single-instance` 正好是这个语义:回调在**已有实例**里执行,新进程由插件负责收尾退出。 +// 它必须注册为**第一个**插件,才能在其他插件与本项目的 `setup`(会拉起被控端)之前完成抢占判定。 + +/// 第二次启动时执行(在**已有实例**的进程里)。 +fn on_second_instance(app: &AppHandle, argv: Vec) { + // 第二次启动若自己就带 `--agent-only`(例如自启被重复触发),**不要弹窗** —— + // 那正是「仅服务模式」要避免的行为。只提示已有实例,不做别的。 + if argv.iter().any(|a| a == autostart::AGENT_ONLY_FLAG) { + return; + } + // 已有实例可能是「自启的隐藏实例」或「窗口已关到托盘」→ 两种都要能唤出来。 + show_main(app); + // 顺带通知前端刷一次状态(第二次启动往往发生在「用户发现没反应、想确认一下」的时刻)。 + // 前端没监听也无害。 + let _ = app.emit("na-second-instance", json!({ "argv": argv })); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let agent_only = std::env::args().any(|a| a == autostart::AGENT_ONLY_FLAG); tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + on_second_instance(app, argv); + })) .plugin(tauri_plugin_opener::init()) .setup(move |app| { let handle = app.handle().clone(); diff --git a/apps/console/src/stores/agent.ts b/apps/console/src/stores/agent.ts index 70a59d8..7e99511 100644 --- a/apps/console/src/stores/agent.ts +++ b/apps/console/src/stores/agent.ts @@ -88,6 +88,11 @@ export const useAgentStore = defineStore("agent", () => { await listen("na-agent-state", () => { void refresh(); }); + // v2.0.3 单实例:用户再次双击图标时,由**已存在实例**接管(把窗口唤出来)。 + // 顺手刷新一次 —— 触发它的场景往往正是「点了没反应,想看看怎么回事」。 + await listen<{ argv?: string[] }>("na-second-instance", () => { + void refresh(); + }); await refresh(); void loadSystemInfo(); }