mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
- Windows 上隐藏原生菜单栏和窗口装饰,前端渲染自定义窗口控制按钮(最小化/最大化/关闭) - macOS 保持原生菜单栏不变(条件编译) - Sidebar 在 Windows 上不再添加 macOS 红绿灯的额外顶部间距 - 新增 Windows 构建脚本和 NSIS 安装钩子 - 服务端 CORS、会话服务、对话服务改进及测试补充 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
415 lines
14 KiB
Rust
415 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,
|
||
};
|
||
|
||
#[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())
|
||
.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
|
||
]);
|
||
|
||
// 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());
|
||
|
||
let _window = app.get_webview_window("main").unwrap();
|
||
|
||
// Windows: hide native decorations — frontend renders custom titlebar
|
||
#[cfg(target_os = "windows")]
|
||
let _ = _window.set_decorations(false);
|
||
|
||
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);
|
||
}
|
||
});
|
||
}
|