Merge branch 'main' into HEAD
14
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.
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## 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.
|
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.
|
||||||
|
|||||||
14
README.md
@ -283,6 +283,20 @@ http://127.0.0.1:2024
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⭐ Star 趋势图
|
||||||
|
|
||||||
|
如果这个项目对您有帮助,请给个 ⭐ Star 支持一下,让更多的人看到 Claude Code Haha!
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/#NanmiCoder/cc-haha&Date">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date&theme=dark" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=NanmiCoder/cc-haha&type=Date" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Disclaimer
|
## Disclaimer
|
||||||
|
|
||||||
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
|
本仓库基于 2026-03-31 从 Anthropic npm registry 泄露的 Claude Code 源码。所有原始源码版权归 [Anthropic](https://www.anthropic.com) 所有。仅供学习和研究用途。
|
||||||
|
|||||||
@ -27,6 +27,7 @@
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
|
<g transform="translate(512 512) scale(1.0612244898) translate(-512 -512)">
|
||||||
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
|
<rect x="120" y="120" width="784" height="784" rx="180" fill="url(#panel)"/>
|
||||||
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
|
<rect x="124" y="124" width="776" height="776" rx="176" stroke="url(#panelGlow)" stroke-width="8"/>
|
||||||
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
<path d="M236 260C324 190 464 170 612 206" stroke="#5C2E1D" stroke-width="18" stroke-linecap="round" opacity="0.58"/>
|
||||||
@ -39,4 +40,5 @@
|
|||||||
<g filter="url(#sparkGlow)">
|
<g filter="url(#sparkGlow)">
|
||||||
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
|
<path d="M592 334L608.8 385.2L660 402L608.8 418.8L592 470L575.2 418.8L524 402L575.2 385.2L592 334Z" fill="url(#spark)"/>
|
||||||
</g>
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 809 B After Width: | Height: | Size: 838 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.5 KiB |
@ -1,5 +1,6 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::{HashMap, VecDeque},
|
collections::{HashMap, VecDeque},
|
||||||
|
fs,
|
||||||
io::{Error as IoError, ErrorKind, Read, Write},
|
io::{Error as IoError, ErrorKind, Read, Write},
|
||||||
net::{SocketAddr, TcpListener, TcpStream},
|
net::{SocketAddr, TcpListener, TcpStream},
|
||||||
path::PathBuf,
|
path::PathBuf,
|
||||||
@ -14,11 +15,11 @@ use std::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
|
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::menu::{MenuBuilder, MenuItemBuilder, SubmenuBuilder};
|
||||||
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent};
|
||||||
use tauri::Emitter;
|
use tauri::Emitter;
|
||||||
use tauri::{AppHandle, Manager, RunEvent, State, WindowEvent};
|
use tauri::{AppHandle, Manager, PhysicalPosition, PhysicalSize, RunEvent, State, WindowEvent};
|
||||||
use tauri_plugin_shell::{
|
use tauri_plugin_shell::{
|
||||||
process::{CommandChild, CommandEvent},
|
process::{CommandChild, CommandEvent},
|
||||||
ShellExt,
|
ShellExt,
|
||||||
@ -28,6 +29,10 @@ const SERVER_STARTUP_LOG_LIMIT: usize = 80;
|
|||||||
const MAIN_WINDOW_LABEL: &str = "main";
|
const MAIN_WINDOW_LABEL: &str = "main";
|
||||||
const TRAY_SHOW_ID: &str = "tray_show";
|
const TRAY_SHOW_ID: &str = "tray_show";
|
||||||
const TRAY_QUIT_ID: &str = "tray_quit";
|
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)]
|
#[derive(Default)]
|
||||||
struct ServerState(Mutex<ServerStatus>);
|
struct ServerState(Mutex<ServerStatus>);
|
||||||
@ -48,6 +53,15 @@ struct AppExitState {
|
|||||||
is_quitting: Mutex<bool>,
|
is_quitting: Mutex<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
struct StoredWindowState {
|
||||||
|
x: i32,
|
||||||
|
y: i32,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
maximized: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// 与 ServerState 平级的 adapter 子进程状态。
|
/// 与 ServerState 平级的 adapter 子进程状态。
|
||||||
///
|
///
|
||||||
/// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期
|
/// adapter sidecar(claude-sidecar adapters --feishu --telegram)的生命周期
|
||||||
@ -157,10 +171,187 @@ fn should_hide_to_tray(app: &AppHandle, label: &str) -> bool {
|
|||||||
.unwrap_or(true)
|
.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<PathBuf> {
|
||||||
|
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<StoredWindowState> {
|
||||||
|
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::<StoredWindowState>(&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<StoredWindowState> {
|
||||||
|
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) {
|
fn show_main_window(app: &AppHandle) {
|
||||||
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) {
|
||||||
let _ = window.unminimize();
|
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
|
let _ = window.unminimize();
|
||||||
let _ = window.set_focus();
|
let _ = window.set_focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -879,9 +1070,71 @@ fn kill_windows_sidecars() {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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;
|
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]
|
#[test]
|
||||||
fn terminal_output_decoder_preserves_split_chinese_characters() {
|
fn terminal_output_decoder_preserves_split_chinese_characters() {
|
||||||
let mut pending = Vec::new();
|
let mut pending = Vec::new();
|
||||||
@ -1043,6 +1296,7 @@ pub fn run() {
|
|||||||
let app = builder
|
let app = builder
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
setup_system_tray(app)?;
|
setup_system_tray(app)?;
|
||||||
|
restore_main_window_state(&app.handle());
|
||||||
|
|
||||||
let state = app.state::<ServerState>();
|
let state = app.state::<ServerState>();
|
||||||
let mut guard = state
|
let mut guard = state
|
||||||
@ -1080,10 +1334,18 @@ pub fn run() {
|
|||||||
..
|
..
|
||||||
} if should_hide_to_tray(app_handle, &label) => {
|
} if should_hide_to_tray(app_handle, &label) => {
|
||||||
api.prevent_close();
|
api.prevent_close();
|
||||||
|
save_main_window_state(app_handle);
|
||||||
if let Some(window) = app_handle.get_webview_window(&label) {
|
if let Some(window) = app_handle.get_webview_window(&label) {
|
||||||
let _ = window.hide();
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
RunEvent::Reopen {
|
RunEvent::Reopen {
|
||||||
has_visible_windows: false,
|
has_visible_windows: false,
|
||||||
@ -1093,11 +1355,13 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
RunEvent::ExitRequested { .. } => {
|
RunEvent::ExitRequested { .. } => {
|
||||||
mark_app_quitting(app_handle);
|
mark_app_quitting(app_handle);
|
||||||
|
save_main_window_state(app_handle);
|
||||||
stop_server_sidecar(app_handle);
|
stop_server_sidecar(app_handle);
|
||||||
stop_adapters_sidecar(app_handle);
|
stop_adapters_sidecar(app_handle);
|
||||||
}
|
}
|
||||||
RunEvent::Exit => {
|
RunEvent::Exit => {
|
||||||
mark_app_quitting(app_handle);
|
mark_app_quitting(app_handle);
|
||||||
|
save_main_window_state(app_handle);
|
||||||
stop_server_sidecar(app_handle);
|
stop_server_sidecar(app_handle);
|
||||||
stop_adapters_sidecar(app_handle);
|
stop_adapters_sidecar(app_handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -859,6 +859,32 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
expect(nextTurn.some((m) => m.type === 'message_complete')).toBe(true)
|
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 () => {
|
it('should prewarm the CLI before the first user turn and reuse that process', async () => {
|
||||||
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
const createRes = await fetch(`${baseUrl}/api/sessions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@ -1143,6 +1169,108 @@ describe('WebSocket Chat Integration', () => {
|
|||||||
}
|
}
|
||||||
}, 20_000)
|
}, 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<void>((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 () => {
|
it('should resume streaming to a reconnected client during an active turn', async () => {
|
||||||
await withMockStreamDelay(150, async () => {
|
await withMockStreamDelay(150, async () => {
|
||||||
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
|
const sessionId = `chat-reconnect-${crypto.randomUUID()}`
|
||||||
|
|||||||
@ -61,6 +61,7 @@ const runtimeOverrides = new Map<string, {
|
|||||||
|
|
||||||
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
const runtimeTransitionPromises = new Map<string, Promise<void>>()
|
||||||
const sessionStartupPromises = new Map<string, Promise<void>>()
|
const sessionStartupPromises = new Map<string, Promise<void>>()
|
||||||
|
const lastResolvedStartupWorkDirs = new Map<string, string>()
|
||||||
const prewarmPendingSessions = new Set<string>()
|
const prewarmPendingSessions = new Set<string>()
|
||||||
const prewarmedSessions = new Set<string>()
|
const prewarmedSessions = new Set<string>()
|
||||||
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
const prewarmIdleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||||
@ -265,7 +266,7 @@ async function handleUserMessage(
|
|||||||
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
|
console.error(`[WS] CLI start failed for ${sessionId}: ${errMsg}`)
|
||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: errMsg,
|
message: await buildSessionStartupDiagnosticMessage(sessionId, errMsg),
|
||||||
code,
|
code,
|
||||||
retryable:
|
retryable:
|
||||||
err instanceof ConversationStartupError ? err.retryable : false,
|
err instanceof ConversationStartupError ? err.retryable : false,
|
||||||
@ -511,7 +512,10 @@ async function restartSessionWithPermissionMode(
|
|||||||
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
|
console.error(`[WS] Failed to restart CLI for ${sessionId}: ${errMsg}`)
|
||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
type: 'error',
|
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',
|
code: 'CLI_RESTART_FAILED',
|
||||||
})
|
})
|
||||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
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}`)
|
console.error(`[WS] Failed to restart CLI for ${sessionId} after runtime override: ${errMsg}`)
|
||||||
sendMessage(ws, {
|
sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
message: `Failed to switch provider/model: ${errMsg}`,
|
message: await buildSessionStartupDiagnosticMessage(
|
||||||
|
sessionId,
|
||||||
|
`Failed to switch provider/model: ${errMsg}`,
|
||||||
|
),
|
||||||
code: 'CLI_RESTART_FAILED',
|
code: 'CLI_RESTART_FAILED',
|
||||||
})
|
})
|
||||||
sendMessage(ws, { type: 'status', state: 'idle' })
|
sendMessage(ws, { type: 'status', state: 'idle' })
|
||||||
@ -661,6 +668,7 @@ function cleanupSessionRuntimeState(sessionId: string) {
|
|||||||
runtimeOverrides.delete(sessionId)
|
runtimeOverrides.delete(sessionId)
|
||||||
runtimeTransitionPromises.delete(sessionId)
|
runtimeTransitionPromises.delete(sessionId)
|
||||||
sessionStartupPromises.delete(sessionId)
|
sessionStartupPromises.delete(sessionId)
|
||||||
|
lastResolvedStartupWorkDirs.delete(sessionId)
|
||||||
clearPrewarmState(sessionId)
|
clearPrewarmState(sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -758,6 +766,7 @@ async function ensureCliSessionStarted(
|
|||||||
|
|
||||||
const startup = (async () => {
|
const startup = (async () => {
|
||||||
const workDir = await resolveSessionWorkDir(sessionId)
|
const workDir = await resolveSessionWorkDir(sessionId)
|
||||||
|
lastResolvedStartupWorkDirs.set(sessionId, workDir)
|
||||||
const runtimeSettings = await getRuntimeSettings(sessionId)
|
const runtimeSettings = await getRuntimeSettings(sessionId)
|
||||||
const sdkUrl =
|
const sdkUrl =
|
||||||
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
|
`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
|
permissionMode?: string
|
||||||
model?: string
|
model?: string
|
||||||
effort?: string
|
effort?: string
|
||||||
providerId?: string | null
|
providerId?: string | null
|
||||||
}> {
|
}
|
||||||
|
|
||||||
|
async function getRuntimeSettings(sessionId?: string): Promise<RuntimeSettings> {
|
||||||
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
|
const runtimeOverride = sessionId ? runtimeOverrides.get(sessionId) : undefined
|
||||||
if (runtimeOverride) {
|
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 userSettings = await settingsService.getUserSettings()
|
||||||
const effort =
|
const effort =
|
||||||
typeof userSettings.effort === 'string' && userSettings.effort.trim()
|
typeof userSettings.effort === 'string' && userSettings.effort.trim()
|
||||||
@ -1245,10 +1268,21 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return getDefaultRuntimeSettings()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDefaultRuntimeSettings(): Promise<RuntimeSettings> {
|
||||||
// Check if a custom provider is active
|
// 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 userSettings = await settingsService.getUserSettings()
|
||||||
const providerSettings = activeId
|
const providerSettings = resolvedActiveId
|
||||||
? await providerService.getManagedSettings()
|
? await providerService.getManagedSettings()
|
||||||
: undefined
|
: undefined
|
||||||
const modelSettings = providerSettings ?? userSettings
|
const modelSettings = providerSettings ?? userSettings
|
||||||
@ -1262,7 +1296,7 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
|||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
let model: string | undefined
|
let model: string | undefined
|
||||||
if (activeId) {
|
if (resolvedActiveId) {
|
||||||
// Provider is active — only consult provider-managed cc-haha settings.
|
// Provider is active — only consult provider-managed cc-haha settings.
|
||||||
// Global ~/.claude/settings.json model values must not bleed into provider mode.
|
// Global ~/.claude/settings.json model values must not bleed into provider mode.
|
||||||
const baseModel =
|
const baseModel =
|
||||||
@ -1286,10 +1320,58 @@ async function getRuntimeSettings(sessionId?: string): Promise<{
|
|||||||
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
|
permissionMode: await settingsService.getPermissionMode().catch(() => undefined),
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
providerId: activeId,
|
providerId: resolvedActiveId,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildSessionStartupDiagnosticMessage(
|
||||||
|
sessionId: string,
|
||||||
|
cause: string,
|
||||||
|
): Promise<string> {
|
||||||
|
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(
|
function enqueueRuntimeTransition(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
transition: () => Promise<void>,
|
transition: () => Promise<void>,
|
||||||
|
|||||||