diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index 517e8f52..b445af1e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -12,7 +12,7 @@ "allow": [ { "args": true, - "name": "binaries/claude-server", + "name": "binaries/claude-sidecar", "sidecar": true } ] @@ -22,7 +22,17 @@ "allow": [ { "args": true, - "name": "binaries/claude-server", + "name": "binaries/claude-sidecar", + "sidecar": true + } + ] + }, + { + "identifier": "shell:allow-kill", + "allow": [ + { + "args": true, + "name": "binaries/claude-sidecar", "sidecar": true } ] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index da5803c7..e774dabe 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -26,6 +26,15 @@ struct ServerStatus { 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 @@ -43,6 +52,22 @@ fn get_server_url(state: State<'_, ServerState>) -> Result { .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}"))?; @@ -78,17 +103,13 @@ fn resolve_app_root(_app: &AppHandle) -> Result { // node_modules/ 当 Resource 一起 ship 到 .app/Contents/Resources/app/。 // // 现在 launcher 改成静态 import + bun build --compile 整棵静态打进二进制, - // server / cli sidecar 不再读磁盘上的 src/ 或 node_modules/。CLAUDE_APP_ROOT - // 现在只剩一个名义上的"app 安装根目录"作用,给 conversationService - // 在 spawn CLI 子进程时通过 --app-root 透传。 + // 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 二进制的同级目录) - // - // 这样 P0 不再依赖 BaseDirectory::Resource 解出的 app/ 目录,可以从 - // tauri.conf.json 的 resources 里把 src/、node_modules/、preload.ts、stubs/ - // 全部干掉,直接砍掉 ~300MB 包体。 let exe = std::env::current_exe() .map_err(|err| format!("resolve current exe path: {err}"))?; let dir = exe @@ -105,7 +126,7 @@ fn start_server_sidecar(app: &AppHandle) -> Result { let app_root = resolve_app_root(app)?; let app_root_arg = app_root.to_string_lossy().to_string(); - // 单一合并 sidecar:第一个参数选 server / cli 模式。 + // 单一合并 sidecar:第一个参数选 server / cli / adapters 模式。 let sidecar = app .shell() .sidecar("claude-sidecar") @@ -159,15 +180,128 @@ fn stop_server_sidecar(app: &AppHandle) { } } +/// 启动 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]) + .invoke_handler(tauri::generate_handler![ + get_server_url, + restart_adapters_sidecar + ]) .setup(|app| { let state = app.state::(); let mut guard = state @@ -186,6 +320,12 @@ pub fn run() { 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(()) @@ -196,6 +336,7 @@ pub fn run() { app.run(|app_handle, event| { if matches!(event, RunEvent::Exit | RunEvent::ExitRequested { .. }) { stop_server_sidecar(app_handle); + stop_adapters_sidecar(app_handle); } }); } diff --git a/desktop/src/pages/AdapterSettings.tsx b/desktop/src/pages/AdapterSettings.tsx index cfa15b62..cec92833 100644 --- a/desktop/src/pages/AdapterSettings.tsx +++ b/desktop/src/pages/AdapterSettings.tsx @@ -9,8 +9,8 @@ export function AdapterSettings() { const t = useTranslation() const { config, isLoading, fetchConfig, updateConfig, generatePairingCode, removePairedUser } = useAdapterStore() - // Server - const [serverUrl, setServerUrl] = useState('') + // Server —— serverUrl 不再暴露在 UI 里(见下方 Server URL 注释), + // 桌面端用 Tauri env var 注入动态端口。 const [defaultProjectDir, setDefaultProjectDir] = useState('') // Telegram @@ -39,7 +39,6 @@ export function AdapterSettings() { // Sync form state when config is loaded useEffect(() => { - setServerUrl(config.serverUrl ?? '') setDefaultProjectDir(config.defaultProjectDir ?? '') setTgBotToken(config.telegram?.botToken ?? '') setTgAllowedUsers(config.telegram?.allowedUsers?.join(', ') ?? '') @@ -58,7 +57,6 @@ export function AdapterSettings() { try { const patch: Record = {} - if (serverUrl) patch.serverUrl = serverUrl if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir const tgUsers = tgAllowedUsers @@ -212,13 +210,11 @@ export function AdapterSettings() { - {/* Server URL */} - setServerUrl(e.target.value)} - placeholder={t('settings.adapters.serverUrlPlaceholder')} - /> + {/* Server URL —— 之前是个手填字段,但桌面端 Tauri 启动 adapter sidecar + 时已经把 server 的动态端口通过 ADAPTER_SERVER_URL env var 注进去了, + loadConfig() 里 env 优先级高于这里的 file value,所以这个字段在桌面 + 运行时完全不会被读到。用户也根本不知道该填什么端口(每次启动随机)。 + Standalone 模式(直接 bun run adapters/...)保留 file 字段兜底就够了。 */} {/* Default Project */}
diff --git a/desktop/src/stores/adapterStore.ts b/desktop/src/stores/adapterStore.ts index 14a6fa27..41c9ae05 100644 --- a/desktop/src/stores/adapterStore.ts +++ b/desktop/src/stores/adapterStore.ts @@ -2,6 +2,27 @@ import { create } from 'zustand' import { adaptersApi } from '../api/adapters' import type { AdapterFileConfig } from '../types/adapter' +/** + * Tauri command 触发器:让主进程 kill + respawn adapter sidecar, + * 让 ~/.claude/adapters.json 里的最新凭据被新进程读到,建立飞书 / Telegram + * 的 WebSocket 连接。 + * + * 在非 Tauri 环境(纯浏览器调试 / 单元测试)这会安静失败 —— 那种场景下 + * 本来也没有 sidecar 可重启。 + */ +async function notifyTauriRestartAdapters(): Promise { + try { + // 用 dynamic import 避开 SSR / non-tauri 测试环境的硬依赖 + const { invoke } = await import('@tauri-apps/api/core') + await invoke('restart_adapters_sidecar') + } catch (err) { + // 不阻塞保存流程 —— 配置文件已经写入,下次启动 App 也会生效 + if (typeof console !== 'undefined') { + console.warn('[adapterStore] restart_adapters_sidecar failed:', err) + } + } +} + const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' const CODE_LENGTH = 6 const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes @@ -49,6 +70,11 @@ export const useAdapterStore = create((set, get) => ({ updateConfig: async (patch) => { const config = await adaptersApi.updateConfig(patch) set({ config }) + // 配置文件已写入磁盘,让 Tauri 主进程 kill + respawn adapter sidecar, + // 触发飞书 / Telegram WebSocket 用新凭据重连。pairing code / paired users + // 这种轻量更新也会触发重启 —— 这是个有意为之的简化:保证"任何配置变更 + // 都立刻生效",比起精细判断哪些字段值得重启更可靠。 + void notifyTauriRestartAdapters() }, generatePairingCode: async () => {