use std::{ io::{Error as IoError, ErrorKind}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, sync::Mutex, time::{Duration, Instant}, }; use tauri::{AppHandle, Manager, RunEvent, State}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, }; #[derive(Default)] struct ServerState(Mutex); struct ServerRuntime { url: String, child: CommandChild, } #[derive(Default)] struct ServerStatus { runtime: Option, startup_error: Option, } /// 与 ServerState 平级的 adapter 子进程状态。 /// /// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期 /// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出, /// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后, /// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。 #[derive(Default)] struct AdapterState(Mutex>); #[tauri::command] fn get_server_url(state: State<'_, ServerState>) -> Result { let guard = state .0 .lock() .map_err(|_| "desktop server state is unavailable".to_string())?; if let Some(runtime) = guard.runtime.as_ref() { return Ok(runtime.url.clone()); } Err(guard .startup_error .clone() .unwrap_or_else(|| "desktop server did not start".to_string())) } /// 前端在设置页保存飞书 / Telegram 凭据后调用,触发 adapter sidecar 热重启。 /// /// 流程: /// 1. kill 当前 adapter 子进程(如果在跑) /// 2. spawn 新的 adapter 子进程 /// 3. 新 sidecar 内部的 loadConfig() 会读到最新的 ~/.claude/adapters.json /// 并重新建立 WebSocket 连接到飞书 / Telegram /// /// 凭据缺失时 sidecar 自己会 warn + skip + 退出,所以这里不需要前置检查。 #[tauri::command] fn restart_adapters_sidecar(app: AppHandle) -> Result<(), String> { stop_adapters_sidecar(&app); spawn_and_track_adapters_sidecar(&app); Ok(()) } fn reserve_local_port() -> Result { let listener = TcpListener::bind("127.0.0.1:0").map_err(|err| format!("bind local port: {err}"))?; let port = listener .local_addr() .map_err(|err| format!("read local port: {err}"))? .port(); drop(listener); Ok(port) } fn wait_for_server(url_host: &str, port: u16) -> Result<(), String> { let addr: SocketAddr = format!("{url_host}:{port}") .parse() .map_err(|err| format!("parse server address: {err}"))?; let deadline = Instant::now() + Duration::from_secs(10); while Instant::now() < deadline { if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() { return Ok(()); } std::thread::sleep(Duration::from_millis(150)); } Err(format!( "desktop server did not start listening on {url_host}:{port} within 10 seconds" )) } fn resolve_app_root(_app: &AppHandle) -> Result { // 历史用途:此前 sidecar launcher 用 dynamic file:// import 加载磁盘上 // 的 src/server/index.ts 和 preload.ts,所以 Tauri 必须把整个 src/ + // node_modules/ 当 Resource 一起 ship 到 .app/Contents/Resources/app/。 // // 现在 launcher 改成静态 import + bun build --compile 整棵静态打进二进制, // sidecar 不再读磁盘上的 src/ 或 node_modules/。CLAUDE_APP_ROOT 现在 // 只剩一个名义上的"app 安装根目录"作用,给 conversationService 在 // spawn CLI 子进程时通过 --app-root 透传。 // // 我们直接用当前可执行文件所在目录作为 app_root: // Dev: desktop/src-tauri/target// (rust 跑出来的 binary 那一层) // Prod: .app/Contents/MacOS/ (sidecar 二进制的同级目录) let exe = std::env::current_exe() .map_err(|err| format!("resolve current exe path: {err}"))?; let dir = exe .parent() .ok_or_else(|| "current exe has no parent dir".to_string())? .to_path_buf(); Ok(dir) } fn start_server_sidecar(app: &AppHandle) -> Result { let host = "127.0.0.1"; let port = reserve_local_port()?; let url = format!("http://{host}:{port}"); let app_root = resolve_app_root(app)?; let app_root_arg = app_root.to_string_lossy().to_string(); // 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。 let sidecar = app .shell() .sidecar("claude-sidecar") .map_err(|err| format!("resolve sidecar: {err}"))? .args([ "server", "--app-root", &app_root_arg, "--host", host, "--port", &port.to_string(), ]); let (mut rx, child) = sidecar .spawn() .map_err(|err| format!("spawn server sidecar: {err}"))?; tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { let line = String::from_utf8_lossy(&line); println!("[claude-server] {}", line.trim_end()); } CommandEvent::Stderr(line) => { let line = String::from_utf8_lossy(&line); eprintln!("[claude-server] {}", line.trim_end()); } _ => {} } } }); wait_for_server(host, port)?; Ok(ServerRuntime { url, child }) } fn stop_server_sidecar(app: &AppHandle) { let Some(state) = app.try_state::() else { return; }; let Ok(mut guard) = state.0.lock() else { return; }; if let Some(runtime) = guard.runtime.take() { let _ = runtime.child.kill(); } } /// 启动 adapter sidecar。返回 Result 主要为了把"无法 spawn"和"spawn 后立刻 /// 退出(凭据缺失)"区分开 —— 后者不算错误,是正常 default 状态。 fn start_adapters_sidecar(app: &AppHandle) -> Result { let app_root = resolve_app_root(app)?; let app_root_arg = app_root.to_string_lossy().to_string(); // adapter 内部的 WsBridge 默认连 ws://127.0.0.1:3456,但桌面端的 server // 用的是 reserve_local_port() 拿到的动态端口。这里把实际端口通过 // ADAPTER_SERVER_URL env var 传过去 —— adapters/common/config.ts 的 // loadConfig() 会读它。 // // 如果 server 还没起来 / 没拿到 URL,回退到 3456 作为最后兜底(adapter // 自己有重连逻辑,等 server 上线就能连上)。 let server_http_url = app .try_state::() .and_then(|state| { state .0 .lock() .ok() .and_then(|guard| guard.runtime.as_ref().map(|r| r.url.clone())) }) .unwrap_or_else(|| "http://127.0.0.1:3456".to_string()); // WsBridge 直接 `new WebSocket('${serverUrl}/ws/...')`,必须传 ws://; // 不会自动从 http 转。 let server_ws_url = if let Some(rest) = server_http_url.strip_prefix("http://") { format!("ws://{rest}") } else if let Some(rest) = server_http_url.strip_prefix("https://") { format!("wss://{rest}") } else { server_http_url.clone() }; let sidecar = app .shell() .sidecar("claude-sidecar") .map_err(|err| format!("resolve sidecar: {err}"))? .env("ADAPTER_SERVER_URL", &server_ws_url) .args([ "adapters", "--app-root", &app_root_arg, "--feishu", "--telegram", ]); let (mut rx, child) = sidecar .spawn() .map_err(|err| format!("spawn adapter sidecar: {err}"))?; // 用一个 async task 把 sidecar 的 stdout/stderr 转发出来。它退出时 // 整个 task 也会自然结束。 tauri::async_runtime::spawn(async move { while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line) => { let line = String::from_utf8_lossy(&line); println!("[claude-adapters] {}", line.trim_end()); } CommandEvent::Stderr(line) => { let line = String::from_utf8_lossy(&line); eprintln!("[claude-adapters] {}", line.trim_end()); } CommandEvent::Terminated(payload) => { // exit code != 0 是常态:用户没配凭据时 sidecar 内部会 // warn + skip + process.exit(1)。这里只 info 一行, // 不要当错误冒泡。 println!( "[claude-adapters] sidecar exited (code={:?}, signal={:?})", payload.code, payload.signal ); } _ => {} } } }); Ok(child) } /// spawn adapter sidecar 并把 child handle 存进 AdapterState。 /// 在启动 + 重启路径里复用,集中处理"无法 spawn"的日志。 fn spawn_and_track_adapters_sidecar(app: &AppHandle) { match start_adapters_sidecar(app) { Ok(child) => { if let Some(state) = app.try_state::() { if let Ok(mut guard) = state.0.lock() { *guard = Some(child); } } } Err(err) => { eprintln!("[desktop] failed to start adapter sidecar: {err}"); } } } fn stop_adapters_sidecar(app: &AppHandle) { let Some(state) = app.try_state::() else { return; }; let Ok(mut guard) = state.0.lock() else { return; }; if let Some(child) = guard.take() { let _ = child.kill(); } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let app = tauri::Builder::default() .manage(ServerState::default()) .manage(AdapterState::default()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .invoke_handler(tauri::generate_handler![ get_server_url, restart_adapters_sidecar ]) .setup(|app| { let state = app.state::(); let mut guard = state .0 .lock() .map_err(|_| IoError::new(ErrorKind::Other, "server state lock poisoned"))?; match start_server_sidecar(&app.handle()) { Ok(runtime) => { guard.runtime = Some(runtime); guard.startup_error = None; } Err(err) => { eprintln!("[desktop] failed to start local server: {err}"); guard.runtime = None; guard.startup_error = Some(err); } } drop(guard); // server 起来之后再起 adapter sidecar —— start_adapters_sidecar // 内部会从 ServerState 读 server URL 注入 ADAPTER_SERVER_URL env, // 让 adapter 连上动态端口。 spawn_and_track_adapters_sidecar(&app.handle()); let _window = app.get_webview_window("main").unwrap(); Ok(()) }) .build(tauri::generate_context!()) .expect("error while building tauri application"); app.run(|app_handle, event| { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); } }); }