mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
Settings needed a real shell for plugin, MCP, and skill setup without relying on a globally installed Claude CLI. Add an xterm.js terminal backed by portable-pty, wire it into the Tauri desktop runtime, and move shell restart handoff to the new session before old PTY teardown so the UI is less likely to stall behind child shutdown. Constraint: The desktop app must inject the bundled CLI into the shell environment instead of requiring a separate global install Constraint: Restart teardown cannot block the frontend-facing Tauri command path Rejected: Keep terminal setup inside installer chat only | that flow cannot replace an interactive shell Rejected: Wait for old PTY shutdown before adopting the new session | it keeps restart vulnerable to hung child teardown Confidence: medium Scope-risk: moderate Reversibility: clean Directive: Preserve new-session handoff before old-session cleanup when changing terminal lifecycle or restart logic Tested: bunx vitest run src/__tests__/terminalPanel.test.tsx src/components/settings/TerminalPanel.restart.test.tsx; bun run lint; cargo check Not-tested: Full packaged-app command echo and repeated manual restart behavior still need additional runtime verification
424 lines
14 KiB
Rust
424 lines
14 KiB
Rust
use std::{
|
||
io::{Error as IoError, ErrorKind},
|
||
net::{SocketAddr, TcpListener, TcpStream},
|
||
path::PathBuf,
|
||
sync::Mutex,
|
||
time::{Duration, Instant},
|
||
};
|
||
|
||
#[cfg(target_os = "macos")]
|
||
use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||
#[cfg(target_os = "macos")]
|
||
use tauri::Emitter;
|
||
use tauri::{AppHandle, Manager, RunEvent, State};
|
||
use tauri_plugin_shell::{
|
||
process::{CommandChild, CommandEvent},
|
||
ShellExt,
|
||
};
|
||
|
||
mod terminal;
|
||
|
||
use terminal::{
|
||
terminal_close, terminal_resize, terminal_start_session, terminal_write, stop_all_sessions,
|
||
TerminalState,
|
||
};
|
||
|
||
#[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 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
|
||
.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 builder = tauri::Builder::default()
|
||
.manage(ServerState::default())
|
||
.manage(AdapterState::default())
|
||
.manage(TerminalState::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,
|
||
terminal_start_session,
|
||
terminal_write,
|
||
terminal_resize,
|
||
terminal_close
|
||
]);
|
||
|
||
// macOS: native menu bar (traffic-light overlay style)
|
||
#[cfg(target_os = "macos")]
|
||
let builder = builder
|
||
.menu(|app| {
|
||
let about_item = MenuItemBuilder::with_id("nav_about", "关于 Claude Code Haha")
|
||
.build(app)?;
|
||
let settings_item = MenuItemBuilder::with_id("nav_settings", "设置...")
|
||
.accelerator("CmdOrCtrl+,")
|
||
.build(app)?;
|
||
|
||
let app_submenu = SubmenuBuilder::new(app, "Claude Code Haha")
|
||
.item(&about_item)
|
||
.separator()
|
||
.item(&settings_item)
|
||
.separator()
|
||
.services()
|
||
.separator()
|
||
.hide()
|
||
.hide_others()
|
||
.show_all()
|
||
.separator()
|
||
.quit()
|
||
.build()?;
|
||
|
||
let edit_submenu = SubmenuBuilder::new(app, "Edit")
|
||
.undo()
|
||
.redo()
|
||
.separator()
|
||
.cut()
|
||
.copy()
|
||
.paste()
|
||
.select_all()
|
||
.build()?;
|
||
|
||
let view_submenu = SubmenuBuilder::new(app, "View")
|
||
.fullscreen()
|
||
.build()?;
|
||
|
||
let window_submenu = SubmenuBuilder::new(app, "Window")
|
||
.minimize()
|
||
.maximize()
|
||
.close_window()
|
||
.build()?;
|
||
|
||
MenuBuilder::new(app)
|
||
.item(&app_submenu)
|
||
.item(&edit_submenu)
|
||
.item(&view_submenu)
|
||
.item(&window_submenu)
|
||
.build()
|
||
})
|
||
.on_menu_event(|app, event| match event.id().as_ref() {
|
||
"nav_about" => {
|
||
let _ = app.emit("native-menu-navigate", "about");
|
||
}
|
||
"nav_settings" => {
|
||
let _ = app.emit("native-menu-navigate", "settings");
|
||
}
|
||
_ => {}
|
||
});
|
||
|
||
let app = builder
|
||
.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());
|
||
|
||
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);
|
||
if let Some(state) = app_handle.try_state::<TerminalState>() {
|
||
stop_all_sessions(&state);
|
||
}
|
||
}
|
||
});
|
||
}
|