Relakkes Yang 5d243b657a feat(desktop): Windows 自定义标题栏 & 服务端改进
- Windows 上隐藏原生菜单栏和窗口装饰,前端渲染自定义窗口控制按钮(最小化/最大化/关闭)
- macOS 保持原生菜单栏不变(条件编译)
- Sidebar 在 Windows 上不再添加 macOS 红绿灯的额外顶部间距
- 新增 Windows 构建脚本和 NSIS 安装钩子
- 服务端 CORS、会话服务、对话服务改进及测试补充

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:29:35 +08:00

415 lines
14 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},
};
#[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 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 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);
}
});
}