程序员阿江(Relakkes) 3e3c2bc116 experiment(desktop): auto-spawn adapter sidecar on launch + restart on save
Wires the new claude-sidecar adapters mode into the actual desktop UX:

* On app launch, Tauri main process now spawns the adapter sidecar right
  after the server sidecar comes up. The sidecar reads ~/.claude/adapters.json
  and connects whichever of Feishu / Telegram has credentials configured;
  if neither does, it warns + skips + exits cleanly (treated as expected).
* When the user saves credentials in the existing AdapterSettings page,
  the frontend store invokes restart_adapters_sidecar after the PUT
  /api/adapters succeeds. Tauri kills the old child and spawns a new one,
  which picks up the fresh config and establishes the WebSocket connection
  to Feishu / Telegram immediately — no app restart needed.

End-to-end behavior is now: install app → open → configure credentials in
settings → click save → IM bot is live.

Implementation
==============

* desktop/src-tauri/capabilities/default.json: replace the stale
  binaries/claude-server allowlist with binaries/claude-sidecar across
  shell:allow-execute, shell:allow-spawn, plus a new shell:allow-kill
  entry needed for the restart path. (P2 changed externalBin to
  claude-sidecar but missed updating capabilities, which is why the prior
  bundle worked at all in dev mode but would have failed in production.)

* desktop/src-tauri/src/lib.rs:
  - new AdapterState that holds an Option<CommandChild>
  - start_adapters_sidecar() spawns `claude-sidecar adapters --feishu --telegram`
    with ADAPTER_SERVER_URL env var pointing at the dynamic server port
    (converted http://→ws:// since WsBridge does `new WebSocket(url)`
    directly without protocol translation)
  - spawn_and_track_adapters_sidecar() handles spawn + state insertion
  - stop_adapters_sidecar() kills + clears state
  - new #[tauri::command] restart_adapters_sidecar that calls stop+spawn
  - sidecar Terminated events are info-logged, not treated as errors,
    so the credential-missing path doesn't show up as a crash
  - setup() spawns the adapter sidecar after server startup completes
  - RunEvent::Exit cleanup also kills adapter sidecar

* desktop/src/stores/adapterStore.ts: after every successful PUT
  /api/adapters, dynamic-import @tauri-apps/api/core and call
  invoke('restart_adapters_sidecar'). Wrapped in try/catch so non-Tauri
  test environments fall through quietly. Triggers on every config
  change (including pairing code generation, paired-user removal) by
  design — keeps the rule simple and guarantees any save takes effect.

* desktop/src/pages/AdapterSettings.tsx: removed the stale "Server URL"
  text input. The field defaulted to ws://127.0.0.1:3456 but the actual
  server uses a dynamic port chosen at startup. Even when filled in
  correctly, loadConfig() in adapters/common/config.ts gives env var
  priority over file value, so this UI control had zero effect inside
  the desktop app. Standalone-mode adapter users can still edit the
  field directly in adapters.json if they need to.

Bundle size: unchanged at 88 MB .app / 37 MB DMG. The Rust changes
add only a few KB to the desktop main binary.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:55:44 +08:00

343 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ServerStatus>);
struct ServerRuntime {
url: String,
child: CommandChild,
}
#[derive(Default)]
struct ServerStatus {
runtime: Option<ServerRuntime>,
startup_error: Option<String>,
}
/// 与 ServerState 平级的 adapter 子进程状态。
///
/// adapter sidecarclaude-sidecar adapters --feishu --telegram的生命周期
/// 跟 server 不同:它没有 HTTP 端口可探活,没配凭据时会自己干净退出,
/// 而且需要支持运行时热重启 —— 用户在设置页保存飞书 / Telegram 凭据后,
/// 前端会通过 invoke('restart_adapters_sidecar') 来重启它,让新凭据生效。
#[derive(Default)]
struct AdapterState(Mutex<Option<CommandChild>>);
#[tauri::command]
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
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<u16, String> {
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<PathBuf, String> {
// 历史用途:此前 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/<profile>/ rust 跑出来的 binary 那一层)
// Prod: <App>.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<ServerRuntime, String> {
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::<ServerState>() 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<CommandChild, String> {
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::<ServerState>()
.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::<AdapterState>() {
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::<AdapterState>() 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::<ServerState>();
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);
}
});
}