mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
e58ec4f649
commit
3e3c2bc116
@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@ -26,6 +26,15 @@ struct ServerStatus {
|
||||
startup_error: Option<String>,
|
||||
}
|
||||
|
||||
/// 与 ServerState 平级的 adapter 子进程状态。
|
||||
///
|
||||
/// adapter sidecar(claude-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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user