diff --git a/README.en.md b/README.en.md index 3d02b6ef..21319330 100644 --- a/README.en.md +++ b/README.en.md @@ -283,6 +283,20 @@ Thanks to the following open-source projects and community practices for referen --- +## ⭐ Star History + +If this project helps you, please support it with a ⭐ Star so more people can discover Claude Code Haha. + + + + + + Star History Chart + + + +--- + ## Disclaimer This repository is based on the Claude Code source leaked from the Anthropic npm registry on 2026-03-31. All original source code copyrights belong to [Anthropic](https://www.anthropic.com). It is provided for learning and research purposes only. diff --git a/README.md b/README.md index aefef042..f136251a 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,20 @@ http://127.0.0.1:2024 --- +## ⭐ Star 趋势图 + +如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha! + + + + + + Star History Chart + + + +--- + ## Disclaimer 本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。 diff --git a/desktop/src-tauri/app-icon-macos.svg b/desktop/src-tauri/app-icon-macos.svg index af11a9e2..1fd4682d 100644 --- a/desktop/src-tauri/app-icon-macos.svg +++ b/desktop/src-tauri/app-icon-macos.svg @@ -27,6 +27,7 @@ + @@ -39,4 +40,5 @@ + diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png index 9c55c9b0..0c3f4c33 100644 Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png index 3993a862..3ed6ca7f 100644 Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png index 9e7fee4e..eb20b1ba 100644 Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png index 1af7f418..b687e89d 100644 Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ diff --git a/desktop/src-tauri/icons/Square107x107Logo.png b/desktop/src-tauri/icons/Square107x107Logo.png index 93827f1b..981c6993 100644 Binary files a/desktop/src-tauri/icons/Square107x107Logo.png and b/desktop/src-tauri/icons/Square107x107Logo.png differ diff --git a/desktop/src-tauri/icons/Square142x142Logo.png b/desktop/src-tauri/icons/Square142x142Logo.png index 2cb58edb..2ef24d23 100644 Binary files a/desktop/src-tauri/icons/Square142x142Logo.png and b/desktop/src-tauri/icons/Square142x142Logo.png differ diff --git a/desktop/src-tauri/icons/Square150x150Logo.png b/desktop/src-tauri/icons/Square150x150Logo.png index 296ecbfb..6e00e9d8 100644 Binary files a/desktop/src-tauri/icons/Square150x150Logo.png and b/desktop/src-tauri/icons/Square150x150Logo.png differ diff --git a/desktop/src-tauri/icons/Square284x284Logo.png b/desktop/src-tauri/icons/Square284x284Logo.png index 983a93a2..ff785d17 100644 Binary files a/desktop/src-tauri/icons/Square284x284Logo.png and b/desktop/src-tauri/icons/Square284x284Logo.png differ diff --git a/desktop/src-tauri/icons/Square30x30Logo.png b/desktop/src-tauri/icons/Square30x30Logo.png index 330ee213..26e58bbb 100644 Binary files a/desktop/src-tauri/icons/Square30x30Logo.png and b/desktop/src-tauri/icons/Square30x30Logo.png differ diff --git a/desktop/src-tauri/icons/Square310x310Logo.png b/desktop/src-tauri/icons/Square310x310Logo.png index c371943a..57cf5a6e 100644 Binary files a/desktop/src-tauri/icons/Square310x310Logo.png and b/desktop/src-tauri/icons/Square310x310Logo.png differ diff --git a/desktop/src-tauri/icons/Square44x44Logo.png b/desktop/src-tauri/icons/Square44x44Logo.png index 266ea978..1e7efbb9 100644 Binary files a/desktop/src-tauri/icons/Square44x44Logo.png and b/desktop/src-tauri/icons/Square44x44Logo.png differ diff --git a/desktop/src-tauri/icons/Square71x71Logo.png b/desktop/src-tauri/icons/Square71x71Logo.png index 4e5e1a81..2b6ea0c7 100644 Binary files a/desktop/src-tauri/icons/Square71x71Logo.png and b/desktop/src-tauri/icons/Square71x71Logo.png differ diff --git a/desktop/src-tauri/icons/Square89x89Logo.png b/desktop/src-tauri/icons/Square89x89Logo.png index cac6b571..432fe0d6 100644 Binary files a/desktop/src-tauri/icons/Square89x89Logo.png and b/desktop/src-tauri/icons/Square89x89Logo.png differ diff --git a/desktop/src-tauri/icons/StoreLogo.png b/desktop/src-tauri/icons/StoreLogo.png index dc2cc91d..6eef0c0d 100644 Binary files a/desktop/src-tauri/icons/StoreLogo.png and b/desktop/src-tauri/icons/StoreLogo.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index c37e2cda..f4693643 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 274bb8df..dce478fb 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index c5a1d511..d89378a4 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index 806a5227..0a1917d5 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index ffa26a92..69b16963 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 671e6925..6b76d53d 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 2b73cacd..090793b7 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 6f8a6591..49f55cb2 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index d14ea701..01ffd5c8 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index ec497f3f..e9769d5a 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index 4cd2cce0..3f3aeb26 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 686cba35..9577fc19 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index 8b79823a..0297b63e 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index cc36933d..ed9c9114 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index d991e6f6..e8d4a138 100644 Binary files a/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/desktop/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns index 398851de..cd21aa2e 100644 Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico index f951ee90..b3e4fddb 100644 Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png index bf3255e0..5cd4c0ea 100644 Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png index a166f1f6..84b19f85 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png index f01debfa..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png index f01debfa..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png index 6d48bc00..0c71f6fd 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-20x20@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png index 437dce50..cd4e0f45 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png index 9bfa4244..dd67f86a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png index 9bfa4244..dd67f86a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png index a43989b0..d2ff8a80 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-29x29@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png index f01debfa..f9f8bb3a 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png index f63dac44..86a22651 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x-1.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png index f63dac44..86a22651 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png index 8f04e2de..67e98dfc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-40x40@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png index aaf24f03..bd440a4f 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-512@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-512@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png index 8f04e2de..67e98dfc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png index 8a2c4614..1a03a61e 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png and b/desktop/src-tauri/icons/ios/AppIcon-60x60@3x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png index caf57fd2..a73c4417 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@1x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png index 633238a4..28c440fc 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-76x76@2x.png differ diff --git a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png index 3c01a7b1..a5bc775b 100644 Binary files a/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png and b/desktop/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png differ diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c6a33d0f..5b0312ce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use std::{ collections::{HashMap, VecDeque}, + fs, io::{Error as IoError, ErrorKind, Read, Write}, net::{SocketAddr, TcpListener, TcpStream}, path::PathBuf, @@ -14,11 +15,11 @@ use std::{ }; use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize}; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder}; use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}; use tauri::Emitter; -use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent}; +use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent}; use tauri_plugin_shell::{ process::{CommandChild, CommandEvent}, ShellExt, @@ -28,6 +29,10 @@ const SERVER_STARTUP_LOG_LIMIT: usize = 80; const MAIN_WINDOW_LABEL: &str = "main"; const TRAY_SHOW_ID: &str = "tray_show"; const TRAY_QUIT_ID: &str = "tray_quit"; +const WINDOW_STATE_FILE: &str = "window-state.json"; +const MIN_WINDOW_WIDTH: u32 = 960; +const MIN_WINDOW_HEIGHT: u32 = 640; +const MIN_VISIBLE_PIXELS: i64 = 64; #[derive(Default)] struct ServerState(Mutex); @@ -48,6 +53,15 @@ struct AppExitState { is_quitting: Mutex, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +struct StoredWindowState { + x: i32, + y: i32, + width: u32, + height: u32, + maximized: bool, +} + /// 与 ServerState 平级的 adapter 子进程状态。 /// /// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期 @@ -157,10 +171,187 @@ fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool { .unwrap_or(true) } +fn is_persistable_window_state(state: &StoredWindowState) -> bool { + state.width >= MIN_WINDOW_WIDTH && state.height >= MIN_WINDOW_HEIGHT +} + +fn has_meaningful_intersection( + state: &StoredWindowState, + monitor_x: i32, + monitor_y: i32, + monitor_width: u32, + monitor_height: u32, +) -> bool { + let left = state.x as i64; + let top = state.y as i64; + let right = left + state.width as i64; + let bottom = top + state.height as i64; + + let monitor_left = monitor_x as i64; + let monitor_top = monitor_y as i64; + let monitor_right = monitor_left + monitor_width as i64; + let monitor_bottom = monitor_top + monitor_height as i64; + + right > monitor_left + MIN_VISIBLE_PIXELS + && bottom > monitor_top + MIN_VISIBLE_PIXELS + && left < monitor_right - MIN_VISIBLE_PIXELS + && top < monitor_bottom - MIN_VISIBLE_PIXELS +} + +fn is_window_state_visible_on_any_monitor( + state: &StoredWindowState, + monitors: &[tauri::Monitor], +) -> bool { + if monitors.is_empty() { + return true; + } + + monitors.iter().any(|monitor| { + let position = monitor.position(); + let size = monitor.size(); + has_meaningful_intersection(state, position.x, position.y, size.width, size.height) + }) +} + +fn window_state_path(app: &AppHandle) -> Option { + match app.path().app_config_dir() { + Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)), + Err(err) => { + eprintln!("[desktop] failed to resolve app config dir: {err}"); + None + } + } +} + +fn read_stored_window_state(app: &AppHandle) -> Option { + let path = window_state_path(app)?; + let data = match fs::read_to_string(&path) { + Ok(data) => data, + Err(err) if err.kind() == ErrorKind::NotFound => return None, + Err(err) => { + eprintln!( + "[desktop] failed to read window state {}: {err}", + path.display() + ); + return None; + } + }; + + match serde_json::from_str::(&data) { + Ok(state) if is_persistable_window_state(&state) => Some(state), + Ok(_) => None, + Err(err) => { + eprintln!( + "[desktop] failed to parse window state {}: {err}", + path.display() + ); + None + } + } +} + +fn write_stored_window_state(app: &AppHandle, state: &StoredWindowState) { + if !is_persistable_window_state(state) { + return; + } + + let Some(path) = window_state_path(app) else { + return; + }; + + if let Some(parent) = path.parent() { + if let Err(err) = fs::create_dir_all(parent) { + eprintln!( + "[desktop] failed to create window state directory {}: {err}", + parent.display() + ); + return; + } + } + + let data = match serde_json::to_string_pretty(state) { + Ok(data) => data, + Err(err) => { + eprintln!("[desktop] failed to serialize window state: {err}"); + return; + } + }; + + if let Err(err) = fs::write(&path, data) { + eprintln!( + "[desktop] failed to write window state {}: {err}", + path.display() + ); + } +} + +fn capture_window_state(window: &tauri::WebviewWindow) -> Option { + if window.is_minimized().unwrap_or(false) { + return None; + } + + let position = match window.outer_position() { + Ok(position) => position, + Err(err) => { + eprintln!("[desktop] failed to read window position: {err}"); + return None; + } + }; + let size = match window.outer_size() { + Ok(size) => size, + Err(err) => { + eprintln!("[desktop] failed to read window size: {err}"); + return None; + } + }; + + let state = StoredWindowState { + x: position.x, + y: position.y, + width: size.width, + height: size.height, + maximized: window.is_maximized().unwrap_or(false), + }; + + is_persistable_window_state(&state).then_some(state) +} + +fn save_main_window_state(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + let Some(state) = capture_window_state(&window) else { + return; + }; + + write_stored_window_state(app, &state); +} + +fn restore_main_window_state(app: &AppHandle) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + return; + }; + let Some(state) = read_stored_window_state(app) else { + return; + }; + + let monitors = window.available_monitors().unwrap_or_default(); + if !is_window_state_visible_on_any_monitor(&state, &monitors) { + return; + } + + let _ = window.unmaximize(); + let _ = window.set_size(PhysicalSize::new(state.width, state.height)); + let _ = window.set_position(PhysicalPosition::new(state.x, state.y)); + if state.maximized { + let _ = window.maximize(); + } +} + fn show_main_window(app: &AppHandle) { if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) { - let _ = window.unminimize(); let _ = window.show(); + let _ = window.unminimize(); let _ = window.set_focus(); } } @@ -879,9 +1070,71 @@ fn kill_windows_sidecars() { #[cfg(test)] mod tests { - use super::{decode_terminal_output, default_utf8_locale, ensure_utf8_locale, parse_env_block}; + use super::{ + decode_terminal_output, default_utf8_locale, ensure_utf8_locale, + has_meaningful_intersection, is_persistable_window_state, parse_env_block, + StoredWindowState, + }; use std::collections::HashMap; + #[test] + fn window_state_rejects_too_small_sizes() { + let valid = StoredWindowState { + x: 100, + y: 100, + width: 1200, + height: 800, + maximized: false, + }; + let too_narrow = StoredWindowState { + width: 959, + ..valid.clone() + }; + let too_short = StoredWindowState { + height: 639, + ..valid.clone() + }; + + assert!(is_persistable_window_state(&valid)); + assert!(!is_persistable_window_state(&too_narrow)); + assert!(!is_persistable_window_state(&too_short)); + } + + #[test] + fn window_state_requires_visible_monitor_intersection() { + let state = StoredWindowState { + x: 100, + y: 100, + width: 1200, + height: 800, + maximized: false, + }; + + assert!(has_meaningful_intersection(&state, 0, 0, 1920, 1080)); + assert!(!has_meaningful_intersection( + &StoredWindowState { + x: -1200, + y: 100, + ..state.clone() + }, + 0, + 0, + 1920, + 1080, + )); + assert!(!has_meaningful_intersection( + &StoredWindowState { + x: 1900, + y: 100, + ..state + }, + 0, + 0, + 1920, + 1080, + )); + } + #[test] fn terminal_output_decoder_preserves_split_chinese_characters() { let mut pending = Vec::new(); @@ -1043,6 +1296,7 @@ pub fn run() { let app = builder .setup(|app| { setup_system_tray(app)?; + restore_main_window_state(&app.handle()); let state = app.state::(); let mut guard = state @@ -1080,10 +1334,18 @@ pub fn run() { .. } if should_hide_to_tray(app_handle, &label) => { api.prevent_close(); + save_main_window_state(app_handle); if let Some(window) = app_handle.get_webview_window(&label) { let _ = window.hide(); } } + RunEvent::WindowEvent { + label, + event: WindowEvent::Moved(_) | WindowEvent::Resized(_), + .. + } if label == MAIN_WINDOW_LABEL => { + save_main_window_state(app_handle); + } #[cfg(target_os = "macos")] RunEvent::Reopen { has_visible_windows: false, @@ -1093,11 +1355,13 @@ pub fn run() { } RunEvent::ExitRequested { .. } => { mark_app_quitting(app_handle); + save_main_window_state(app_handle); stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); } RunEvent::Exit => { mark_app_quitting(app_handle); + save_main_window_state(app_handle); stop_server_sidecar(app_handle); stop_adapters_sidecar(app_handle); } diff --git a/src/server/__tests__/conversations.test.ts b/src/server/__tests__/conversations.test.ts index 48505ba6..af22e877 100644 --- a/src/server/__tests__/conversations.test.ts +++ b/src/server/__tests__/conversations.test.ts @@ -859,6 +859,32 @@ describe('WebSocket Chat Integration', () => { expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true) }) + it('should include desktop service diagnostics when CLI startup fails', async () => { + const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-startup-missing-workdir-')) + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + await fs.rm(workDir, { recursive: true, force: true }) + + const messages = await runTurn(sessionId, 'trigger startup diagnostics', true) + const error = messages.find((msg) => msg.type === 'error') + + expect(error).toMatchObject({ + code: 'WORKDIR_INVALID', + }) + expect(error?.message).toContain('Desktop service diagnostics:') + expect(error?.message).toContain(`sessionId: ${sessionId}`) + expect(error?.message).toContain(`workDir: ${workDir}`) + expect(error?.message).toContain('runtimeOverride: (none)') + expect(error?.message).toContain('activeProviderId:') + expect(error?.message).toContain('configuredProviders:') + }) + it('should prewarm the CLI before the first user turn and reuse that process', async () => { const createRes = await fetch(`${baseUrl}/api/sessions`, { method: 'POST', @@ -1143,6 +1169,108 @@ describe('WebSocket Chat Integration', () => { } }, 20_000) + it('should ignore stale persisted runtime provider ids when resuming old sessions', async () => { + const providerService = new ProviderService() + const activeProvider = await providerService.addProvider({ + presetId: 'custom', + name: 'Current Valid Provider', + apiKey: 'key-current-valid', + baseUrl: 'http://127.0.0.1:1/anthropic', + apiFormat: 'anthropic', + models: { + main: 'current-main', + haiku: 'current-haiku', + sonnet: 'current-sonnet', + opus: 'current-opus', + }, + }) + await providerService.activateProvider(activeProvider.id) + + const createRes = await fetch(`${baseUrl}/api/sessions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workDir: process.cwd() }), + }) + expect(createRes.status).toBe(201) + const { sessionId } = await createRes.json() as { sessionId: string } + + const staleProviderId = crypto.randomUUID() + const originalStartSession = conversationService.startSession.bind(conversationService) + const startCalls: Array<{ + sessionId: string + options: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null } | undefined + }> = [] + + conversationService.startSession = (async function patchedStartSession( + sid: string, + workDir: string, + sdkUrl: string, + options?: { permissionMode?: string; model?: string; effort?: string; providerId?: string | null }, + ) { + startCalls.push({ sessionId: sid, options }) + return originalStartSession(sid, workDir, sdkUrl, options) + }) as typeof conversationService.startSession + + const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + const messages: any[] = [] + try { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close() + reject(new Error(`Timed out waiting for stale runtime resume for session ${sessionId}`)) + }, 10_000) + + ws.onmessage = (event) => { + const msg = JSON.parse(event.data as string) + messages.push(msg) + + if (msg.type === 'connected') { + ws.send(JSON.stringify({ + type: 'set_runtime_config', + providerId: staleProviderId, + modelId: 'stale-model', + })) + ws.send(JSON.stringify({ type: 'user_message', content: 'resume old session' })) + return + } + + if (msg.type === 'error') { + clearTimeout(timeout) + ws.close() + reject(new Error(msg.message)) + return + } + + if (msg.type === 'message_complete') { + clearTimeout(timeout) + ws.close() + resolve() + } + } + + ws.onerror = () => { + clearTimeout(timeout) + reject(new Error(`WebSocket error for stale runtime resume session ${sessionId}`)) + } + }) + + expect(startCalls).toHaveLength(1) + expect(startCalls[0]).toMatchObject({ + sessionId, + options: { + providerId: activeProvider.id, + }, + }) + expect(startCalls[0]?.options?.model).not.toBe('stale-model') + expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true) + } finally { + ws.close() + conversationService.startSession = originalStartSession + conversationService.stopSession(sessionId) + await providerService.activateOfficial() + } + }, 20_000) + it('should resume streaming to a reconnected client during an active turn', async () => { await withMockStreamDelay(150, async () => { const sessionId = `chat-reconnect-${crypto.randomUUID()}` diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 5d35c744..1c5ce276 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -61,6 +61,7 @@ const runtimeOverrides = new Map>() const sessionStartupPromises = new Map>() +const lastResolvedStartupWorkDirs = new Map() const prewarmPendingSessions = new Set() const prewarmedSessions = new Set() const prewarmIdleTimers = new Map>() @@ -265,7 +266,7 @@ async function handleUserMessage( console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`) sendMessage(ws, { type: 'error', - message: errMsg, + message: await buildSessionStartupDiagnosticMessage(sessionId, errMsg), code, retryable: err instanceof ConversationStartupError ? err.retryable : false, @@ -511,7 +512,10 @@ async function restartSessionWithPermissionMode( console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`) sendMessage(ws, { type: 'error', - message: `Failed to restart session with new permission mode: ${errMsg}`, + message: await buildSessionStartupDiagnosticMessage( + sessionId, + `Failed to restart session with new permission mode: ${errMsg}`, + ), code: 'CLI_RESTART_FAILED', }) sendMessage(ws, { type: 'status', state: 'idle' }) @@ -545,7 +549,10 @@ async function restartSessionWithRuntimeConfig( console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`) sendMessage(ws, { type: 'error', - message: `Failed to switch provider/model: ${errMsg}`, + message: await buildSessionStartupDiagnosticMessage( + sessionId, + `Failed to switch provider/model: ${errMsg}`, + ), code: 'CLI_RESTART_FAILED', }) sendMessage(ws, { type: 'status', state: 'idle' }) @@ -661,6 +668,7 @@ function cleanupSessionRuntimeState(sessionId: string) { runtimeOverrides.delete(sessionId) runtimeTransitionPromises.delete(sessionId) sessionStartupPromises.delete(sessionId) + lastResolvedStartupWorkDirs.delete(sessionId) clearPrewarmState(sessionId) } @@ -758,6 +766,7 @@ async function ensureCliSessionStarted( const startup = (async () => { const workDir = await resolveSessionWorkDir(sessionId) + lastResolvedStartupWorkDirs.set(sessionId, workDir) const runtimeSettings = await getRuntimeSettings(sessionId) const sdkUrl = `ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` + @@ -1223,14 +1232,28 @@ function rebindSessionOutput( }) } -async function getRuntimeSettings(sessionId?: string): Promise<{ +type RuntimeSettings = { permissionMode?: string model?: string effort?: string providerId?: string | null -}> { +} + +async function getRuntimeSettings(sessionId?: string): Promise { const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined if (runtimeOverride) { + if (typeof runtimeOverride.providerId === 'string') { + const { providers } = await providerService.listProviders() + const providerExists = providers.some((provider) => provider.id === runtimeOverride.providerId) + if (!providerExists) { + console.warn( + `[WS] Ignoring stale runtime provider id for ${sessionId}: ${runtimeOverride.providerId}`, + ) + runtimeOverrides.delete(sessionId!) + return getDefaultRuntimeSettings() + } + } + const userSettings = await settingsService.getUserSettings() const effort = typeof userSettings.effort === 'string' && userSettings.effort.trim() @@ -1245,10 +1268,21 @@ async function getRuntimeSettings(sessionId?: string): Promise<{ } } + return getDefaultRuntimeSettings() +} + +async function getDefaultRuntimeSettings(): Promise { // Check if a custom provider is active - const { activeId } = await providerService.listProviders() + const { providers, activeId } = await providerService.listProviders() + let resolvedActiveId = activeId + if (activeId && !providers.some((provider) => provider.id === activeId)) { + console.warn(`[WS] Active provider id is stale, falling back to official provider: ${activeId}`) + resolvedActiveId = null + await providerService.activateOfficial() + } + const userSettings = await settingsService.getUserSettings() - const providerSettings = activeId + const providerSettings = resolvedActiveId ? await providerService.getManagedSettings() : undefined const modelSettings = providerSettings ?? userSettings @@ -1262,7 +1296,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<{ : undefined let model: string | undefined - if (activeId) { + if (resolvedActiveId) { // Provider is active — only consult provider-managed cc-haha settings. // Global ~/.claude/settings.json model values must not bleed into provider mode. const baseModel = @@ -1286,10 +1320,58 @@ async function getRuntimeSettings(sessionId?: string): Promise<{ permissionMode: await settingsService.getPermissionMode().catch(() => undefined), model, effort, - providerId: activeId, + providerId: resolvedActiveId, } } +async function buildSessionStartupDiagnosticMessage( + sessionId: string, + cause: string, +): Promise { + const lines = [ + cause, + '', + 'Desktop service diagnostics:', + `- sessionId: ${sessionId}`, + ] + + try { + const recentWorkDir = lastResolvedStartupWorkDirs.get(sessionId) + const workDir = + recentWorkDir || + conversationService.getSessionWorkDir(sessionId) || + await sessionService.getSessionWorkDir(sessionId) + lines.push(`- workDir: ${workDir ?? '(unknown)'}`) + } catch (err) { + lines.push(`- workDir: failed to resolve (${err instanceof Error ? err.message : String(err)})`) + } + + const runtimeOverride = runtimeOverrides.get(sessionId) + if (runtimeOverride) { + lines.push(`- runtimeOverride.providerId: ${runtimeOverride.providerId ?? '(official)'}`) + lines.push(`- runtimeOverride.modelId: ${runtimeOverride.modelId}`) + } else { + lines.push('- runtimeOverride: (none)') + } + + try { + const { providers, activeId } = await providerService.listProviders() + lines.push(`- activeProviderId: ${activeId ?? '(official)'}`) + lines.push(`- configuredProviders: ${providers.length}`) + if (providers.length > 0) { + lines.push( + `- providerIndex: ${providers + .map((provider) => `${provider.name} (${provider.id})`) + .join(', ')}`, + ) + } + } catch (err) { + lines.push(`- providers: failed to read (${err instanceof Error ? err.message : String(err)})`) + } + + return lines.join('\n') +} + function enqueueRuntimeTransition( sessionId: string, transition: () => Promise,