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>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-12 00:55:44 +08:00
parent e58ec4f649
commit 3e3c2bc116
4 changed files with 195 additions and 22 deletions

View File

@ -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
}
]

View File

@ -26,6 +26,15 @@ struct ServerStatus {
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
@ -43,6 +52,22 @@ fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
.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}"))?;
@ -78,17 +103,13 @@ fn resolve_app_root(_app: &AppHandle) -> Result<PathBuf, String> {
// 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/<profile>/ rust 跑出来的 binary 那一层)
// Prod: <App>.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<ServerRuntime, String> {
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<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])
.invoke_handler(tauri::generate_handler![
get_server_url,
restart_adapters_sidecar
])
.setup(|app| {
let state = app.state::<ServerState>();
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);
}
});
}

View File

@ -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<string, unknown> = {}
if (serverUrl) patch.serverUrl = serverUrl
if (defaultProjectDir) patch.defaultProjectDir = defaultProjectDir
const tgUsers = tgAllowedUsers
@ -212,13 +210,11 @@ export function AdapterSettings() {
</div>
</section>
{/* Server URL */}
<Input
label={t('settings.adapters.serverUrl')}
value={serverUrl}
onChange={(e) => 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 */}
<div className="flex flex-col gap-1">

View File

@ -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<void> {
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<AdapterStore>((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 () => {