diff --git a/.gitignore b/.gitignore index c72dcf56..c342020e 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ target *.njsproj *.sln *.sw? +/*/CLAUDE.md +/CLAUDE.md +/.claude/ +/.vscode/ +/package-lock.json diff --git a/Cargo.lock b/Cargo.lock index 3c465988..c945989f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -408,8 +408,12 @@ dependencies = [ name = "bongo-cat" version = "1.1.0" dependencies = [ + "anyhow", + "dirs 6.0.0", "fs_extra", "gilrs", + "notify", + "notify-debouncer-mini", "rdev", "serde", "serde_json", @@ -432,6 +436,8 @@ dependencies = [ "tauri-plugin-process", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tokio", + "toml 0.8.2", ] [[package]] @@ -1505,6 +1511,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -2584,6 +2599,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2832,6 +2867,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2942,6 +2978,45 @@ dependencies = [ "memchr", ] +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.11.1", + "fsevent-sys", + "inotify 0.11.1", + "kqueue", + "libc", + "log", + "mio 1.2.0", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a689eb4262184d9a1727f9087cd03883ea716682ab03ed24efec57d7716dccb8" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.11.1", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -5722,7 +5797,9 @@ dependencies = [ "bytes", "libc", "mio 1.2.0", + "parking_lot", "pin-project-lite", + "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f8c8d73b..6e86e3cb 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -40,6 +40,12 @@ tauri-plugin-global-shortcut = "2" tauri-plugin-locale = "2" rdev = { git = "https://github.com/kunkunsh/rdev" } gilrs = { git = "https://github.com/ayangweb/gilrs", default-features = false, features = ["xinput"] } +notify = "8" +notify-debouncer-mini = "0.6" +tokio = { version = "1", features = ["full"] } +anyhow = "1" +dirs = "6" +toml = "0.8" [target."cfg(target_os = \"macos\")".dependencies] tauri-nspanel.workspace = true diff --git a/src-tauri/src/core/label.rs b/src-tauri/src/core/label.rs new file mode 100644 index 00000000..784b9eff --- /dev/null +++ b/src-tauri/src/core/label.rs @@ -0,0 +1,26 @@ +use std::sync::{Arc, Mutex}; + +use tauri::State; + +pub struct LabelState { + pub text: Arc>, +} + +impl LabelState { + pub fn new() -> Self { + Self { + text: Arc::new(Mutex::new(String::new())), + } + } +} + +#[tauri::command] +pub async fn set_label_text( + text: String, + state: State<'_, Arc>, +) -> Result<(), String> { + if let Ok(mut t) = state.text.lock() { + *t = text; + } + Ok(()) +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 2e56f773..750c12d6 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,4 +1,5 @@ pub mod device; pub mod gamepad; +pub mod label; pub mod prevent_default; pub mod setup; diff --git a/src-tauri/src/core/setup/macos.rs b/src-tauri/src/core/setup/macos.rs index d61b1d4a..9011691e 100644 --- a/src-tauri/src/core/setup/macos.rs +++ b/src-tauri/src/core/setup/macos.rs @@ -1,4 +1,5 @@ #![allow(deprecated)] +#![allow(clippy::unused_unit)] use tauri::{AppHandle, Emitter, EventTarget, Manager, WebviewWindow}; use tauri_nspanel::{CollectionBehavior, PanelLevel, StyleMask, WebviewWindowExt, tauri_panel}; use tauri_plugin_custom_window::MAIN_WINDOW_LABEL; diff --git a/src-tauri/src/hook/claude.rs b/src-tauri/src/hook/claude.rs new file mode 100644 index 00000000..adaddbf6 --- /dev/null +++ b/src-tauri/src/hook/claude.rs @@ -0,0 +1,565 @@ +use std::collections::HashMap; +use std::io::{Read, Seek, SeekFrom}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex}; + +use notify::RecursiveMode; +use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +use super::config::SharedConfig; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ClaudeEventSource { + Claude, + Signal, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ClaudeEventState { + Idle, + Thinking, + Coding, + Success, + Error, + Waiting, + Dormant, +} + +impl ClaudeEventState { + pub fn as_str(&self) -> &'static str { + match self { + ClaudeEventState::Idle => "idle", + ClaudeEventState::Thinking => "thinking", + ClaudeEventState::Coding => "coding", + ClaudeEventState::Success => "success", + ClaudeEventState::Error => "error", + ClaudeEventState::Waiting => "waiting", + ClaudeEventState::Dormant => "dormant", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeEvent { + pub state: String, + pub source: ClaudeEventSource, + pub session_id: Option, + pub project_name: Option, + pub detail: Option, + pub raw_text: Option, + pub tool_name: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct JsonlEntry { + #[serde(rename = "type")] + entry_type: Option, + message: Option, + #[serde(rename = "sessionId")] + session_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +struct MessagePayload { + content: Option, + stop_reason: Option, +} + +struct FileState { + offset: u64, + #[cfg(unix)] + inode: u64, + partial_line: String, +} + +impl FileState { + #[cfg(unix)] + fn new(inode: u64) -> Self { + Self { + offset: 0, + inode, + partial_line: String::new(), + } + } + + #[cfg(not(unix))] + fn new() -> Self { + Self { + offset: 0, + partial_line: String::new(), + } + } +} + +struct IncrementalParser { + files: HashMap, +} + +impl IncrementalParser { + fn new() -> Self { + Self { + files: HashMap::new(), + } + } + + fn parse_new_entries(&mut self, path: &Path) -> Vec { + let mut entries = Vec::new(); + let mut file = match std::fs::File::open(path) { + Ok(file) => file, + Err(_) => return entries, + }; + let metadata = match file.metadata() { + Ok(metadata) => metadata, + Err(_) => return entries, + }; + + let file_size = metadata.len(); + + #[cfg(unix)] + let current_inode = { + use std::os::unix::fs::MetadataExt; + metadata.ino() + }; + + let state = self + .files + .entry(path.to_path_buf()) + .or_insert_with(|| { + #[cfg(unix)] + { + FileState::new(current_inode) + } + #[cfg(not(unix))] + { + FileState::new() + } + }); + + #[cfg(unix)] + if state.inode != current_inode { + *state = FileState::new(current_inode); + } + + if file_size < state.offset { + state.offset = 0; + state.partial_line.clear(); + } + + if file.seek(SeekFrom::Start(state.offset)).is_err() { + return entries; + } + + let mut buffer = String::new(); + if file.read_to_string(&mut buffer).is_err() || buffer.is_empty() { + return entries; + } + + let combined = format!("{}{}", state.partial_line, buffer); + state.partial_line.clear(); + + if !combined.ends_with('\n') + && let Some((complete, partial)) = combined.rsplit_once('\n') + { + state.partial_line = partial.to_string(); + for line in complete.lines() { + push_entry(&mut entries, line); + } + state.offset += buffer.len() as u64; + return entries; + } + + for line in combined.lines() { + push_entry(&mut entries, line); + } + + state.offset += buffer.len() as u64; + entries + } +} + +fn push_entry(entries: &mut Vec, line: &str) { + let trimmed = line.trim(); + if trimmed.is_empty() { + return; + } + + if let Ok(entry) = serde_json::from_str::(trimmed) { + entries.push(entry); + } +} + +fn claude_projects_dir() -> Option { + dirs::home_dir().map(|home| home.join(".claude").join("projects")) +} + +fn project_name_from_path(path: &Path) -> Option { + let folder = path.parent()?.file_name()?.to_str()?; + Some(folder.replace('-', "/")) +} + +fn truncate_text(text: &str, max_chars: usize) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let truncated: String = chars.by_ref().take(max_chars).collect(); + if chars.next().is_some() { + format!("{}…", truncated) + } else { + truncated + } +} + +fn extract_text(content: &serde_json::Value) -> Option { + let items = content.as_array()?; + items.iter().find_map(|item| { + if item.get("type").and_then(|value| value.as_str()) != Some("text") { + return None; + } + item.get("text") + .and_then(|value| value.as_str()) + .map(|text| truncate_text(text, 36)) + .filter(|text| !text.is_empty()) + }) +} + +fn extract_tool_name(content: &serde_json::Value) -> Option { + let items = content.as_array()?; + items.iter().find_map(|item| { + if item.get("type").and_then(|value| value.as_str()) != Some("tool_use") { + return None; + } + item.get("name") + .and_then(|value| value.as_str()) + .map(ToString::to_string) + }) +} + +fn has_tool_use(content: &serde_json::Value) -> bool { + content + .as_array() + .is_some_and(|items| { + items.iter().any(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("tool_use") + }) + }) +} + +fn contains_only_thinking(content: &serde_json::Value) -> bool { + content + .as_array() + .is_some_and(|items| { + !items.is_empty() + && items.iter().all(|item| { + item.get("type").and_then(|value| value.as_str()) == Some("thinking") + }) + }) +} + +fn has_error(content: &serde_json::Value) -> bool { + content + .as_array() + .is_some_and(|items| { + items.iter().any(|item| { + item.get("is_error") + .and_then(|value| value.as_bool()) + .unwrap_or(false) + }) + }) +} + +fn resolve_event(entry: &JsonlEntry, project_name: Option) -> Option { + let entry_type = entry.entry_type.as_deref()?; + + match entry_type { + "file-history-snapshot" | "last-prompt" => None, + "user" => { + let raw_text = entry + .message + .as_ref() + .and_then(|message| message.content.as_ref()) + .and_then(extract_text); + Some(ClaudeEvent { + state: ClaudeEventState::Thinking.as_str().to_string(), + source: ClaudeEventSource::Claude, + session_id: entry.session_id.clone(), + project_name, + detail: raw_text.clone(), + raw_text, + tool_name: None, + }) + } + "assistant" => { + let message = entry.message.as_ref()?; + let content = message.content.as_ref(); + let raw_text = content.and_then(extract_text); + let tool_name = content.and_then(extract_tool_name); + + let state = match message.stop_reason.as_deref() { + Some("end_turn") | Some("max_tokens") | Some("stop_sequence") => { + ClaudeEventState::Success + } + _ => { + if content.is_some_and(has_tool_use) { + ClaudeEventState::Coding + } else if content.is_some_and(contains_only_thinking) { + ClaudeEventState::Thinking + } else { + ClaudeEventState::Thinking + } + } + }; + + Some(ClaudeEvent { + state: state.as_str().to_string(), + source: ClaudeEventSource::Claude, + session_id: entry.session_id.clone(), + project_name, + detail: raw_text.clone().or_else(|| tool_name.clone()), + raw_text, + tool_name, + }) + } + "tool_result" => { + let message = entry.message.as_ref()?; + let content = message.content.as_ref(); + let is_error = content.is_some_and(has_error); + let raw_text = if is_error { + content.and_then(extract_text) + } else { + None + }; + let state = if is_error { + ClaudeEventState::Error + } else { + ClaudeEventState::Coding + }; + + Some(ClaudeEvent { + state: state.as_str().to_string(), + source: ClaudeEventSource::Claude, + session_id: entry.session_id.clone(), + project_name, + detail: raw_text.clone(), + raw_text, + tool_name: None, + }) + } + _ => None, + } +} + +// ---- 超时检测状态 ---- + +static LAST_EVENT_MILLIS: AtomicU64 = AtomicU64::new(0); +static IS_ACTIVE: AtomicBool = AtomicBool::new(false); + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +pub fn update_last_event() { + LAST_EVENT_MILLIS.store(now_millis(), Ordering::Relaxed); +} + +pub fn set_active(active: bool) { + IS_ACTIVE.store(active, Ordering::Relaxed); +} + +fn is_active_state(state: &str) -> bool { + !matches!(state, "idle" | "dormant") +} + +pub fn waiting_event() -> ClaudeEvent { + ClaudeEvent { + state: ClaudeEventState::Waiting.as_str().to_string(), + source: ClaudeEventSource::Signal, + session_id: None, + project_name: None, + detail: Some("permission_prompt".to_string()), + raw_text: None, + tool_name: None, + } +} + +pub fn start_watching( + app_handle: tauri::AppHandle, + config: SharedConfig, +) -> anyhow::Result> { + let projects_dir = claude_projects_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&projects_dir)?; + + let debounce_ms = config + .read() + .map(|value| value.jsonl_debounce_ms) + .unwrap_or(300) + .max(50); + + let parser = Arc::new(Mutex::new(IncrementalParser::new())); + let (tx, rx) = std::sync::mpsc::channel::(); + let app = app_handle.clone(); + + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + let events = match result { + Ok(events) => events, + Err(_) => continue, + }; + + for event in events { + let path = &event.path; + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + + let project_name = project_name_from_path(path); + let entries = { + let mut parser = match parser.lock() { + Ok(parser) => parser, + Err(_) => continue, + }; + parser.parse_new_entries(path) + }; + + for entry in entries { + let Some(event) = resolve_event(&entry, project_name.clone()) else { + continue; + }; + update_last_event(); + set_active(is_active_state(&event.state)); + let _ = app.emit("claude-event", &event); + } + } + } + }); + + // 超时检测线程:活跃状态超时回 idle,idle 超时进 dormant + let timeout_app = app_handle; + let timeout_config = config; + std::thread::spawn(move || { + let check_interval = std::time::Duration::from_secs(1); + loop { + std::thread::sleep(check_interval); + + let (active_timeout_secs, idle_timeout_secs) = timeout_config + .read() + .map(|c| (c.active_state_timeout_secs, c.idle_sleep_secs)) + .unwrap_or((30, 300)); + + let now = now_millis(); + let last = LAST_EVENT_MILLIS.load(Ordering::Relaxed); + let active = IS_ACTIVE.load(Ordering::Relaxed); + + if last == 0 { + continue; + } + + let elapsed_secs = (now.saturating_sub(last)) / 1000; + + if active && elapsed_secs >= active_timeout_secs { + IS_ACTIVE.store(false, Ordering::Relaxed); + let event = ClaudeEvent { + state: ClaudeEventState::Idle.as_str().to_string(), + source: ClaudeEventSource::Signal, + session_id: None, + project_name: None, + detail: None, + raw_text: None, + tool_name: None, + }; + let _ = timeout_app.emit("claude-event", &event); + update_last_event(); + } else if !active && elapsed_secs >= idle_timeout_secs { + let event = ClaudeEvent { + state: ClaudeEventState::Dormant.as_str().to_string(), + source: ClaudeEventSource::Signal, + session_id: None, + project_name: None, + detail: None, + raw_text: None, + tool_name: None, + }; + let _ = timeout_app.emit("claude-event", &event); + update_last_event(); + } + } + }); + + let mut debouncer = new_debouncer(std::time::Duration::from_millis(debounce_ms), tx)?; + debouncer + .watcher() + .watch(&projects_dir, RecursiveMode::Recursive)?; + Ok(debouncer) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assistant_entry(content: serde_json::Value, stop_reason: Option<&str>) -> JsonlEntry { + JsonlEntry { + entry_type: Some("assistant".to_string()), + message: Some(MessagePayload { + content: Some(content), + stop_reason: stop_reason.map(ToString::to_string), + }), + session_id: Some("session-1".to_string()), + } + } + + #[test] + fn assistant_tool_use_maps_to_coding_event() { + let entry = assistant_entry( + serde_json::json!([ + {"type": "text", "text": "先读一下文件"}, + {"type": "tool_use", "name": "Read"} + ]), + None, + ); + + let event = resolve_event(&entry, Some("demo/project".to_string())).unwrap(); + assert_eq!(event.state, "coding"); + assert_eq!(event.tool_name.as_deref(), Some("Read")); + assert_eq!(event.raw_text.as_deref(), Some("先读一下文件")); + } + + #[test] + fn assistant_end_turn_maps_to_success_event() { + let entry = assistant_entry( + serde_json::json!([ + {"type": "text", "text": "已经完成了"} + ]), + Some("end_turn"), + ); + + let event = resolve_event(&entry, None).unwrap(); + assert_eq!(event.state, "success"); + assert_eq!(event.raw_text.as_deref(), Some("已经完成了")); + } + + #[test] + fn tool_result_error_maps_to_error_event() { + let entry = JsonlEntry { + entry_type: Some("tool_result".to_string()), + message: Some(MessagePayload { + content: Some(serde_json::json!([ + {"type": "text", "text": "permission denied", "is_error": true} + ])), + stop_reason: None, + }), + session_id: None, + }; + + let event = resolve_event(&entry, None).unwrap(); + assert_eq!(event.state, "error"); + assert_eq!(event.raw_text.as_deref(), Some("permission denied")); + } +} diff --git a/src-tauri/src/hook/commands.rs b/src-tauri/src/hook/commands.rs new file mode 100644 index 00000000..cc7d1878 --- /dev/null +++ b/src-tauri/src/hook/commands.rs @@ -0,0 +1,92 @@ +use std::sync::Arc; + +use tauri::State; + +use super::{ + config::{AppConfig, SharedConfig}, + ipc::PendingRequests, + permissions, settings, +}; + +#[tauri::command] +pub fn check_hook_status() -> Result { + Ok(settings::verify_hook_integrity()) +} + +#[tauri::command] +pub fn install_notification_hook() -> Result<(), String> { + settings::install_hook().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn uninstall_notification_hook() -> Result<(), String> { + settings::uninstall_hook().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn check_pretooluse_hook_status() -> Result { + settings::is_pretooluse_hook_installed().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn install_pretooluse_hook() -> Result<(), String> { + settings::install_pretooluse_hook().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn uninstall_pretooluse_hook() -> Result<(), String> { + settings::uninstall_pretooluse_hook().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn set_intercept_active(active: bool) -> Result<(), String> { + permissions::set_intercept_active(active).map_err(|e| e.to_string()) +} + +#[tauri::command] +pub fn get_intercept_active() -> bool { + permissions::is_intercept_active() +} + +#[tauri::command] +pub async fn respond_permission( + request_id: String, + decision: String, + reason: Option, + pending: State<'_, Arc>, +) -> Result<(), String> { + let allow = decision == "allow"; + let resolved = pending + .resolve(&request_id, super::ipc::Decision { allow }) + .await; + if !resolved { + permissions::respond(&request_id, &decision, reason.as_deref()) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[tauri::command] +pub fn get_config(config: State<'_, SharedConfig>) -> AppConfig { + config.read().map(|c| c.clone()).unwrap_or_default() +} + +#[tauri::command] +pub fn update_config( + config: State<'_, SharedConfig>, + new_config: AppConfig, +) -> Result<(), String> { + let path = dirs::home_dir() + .map(|h| h.join(".BongoCat").join("config.toml")) + .ok_or("Cannot find home directory")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + let header = "# BongoCat 配置文件\n# 修改后自动生效,无需重启\n\n"; + let content = toml::to_string_pretty(&new_config).map_err(|e| e.to_string())?; + std::fs::write(&path, format!("{}{}", header, content)).map_err(|e| e.to_string())?; + if let Ok(mut guard) = config.write() { + *guard = new_config; + } + Ok(()) +} diff --git a/src-tauri/src/hook/config.rs b/src-tauri/src/hook/config.rs new file mode 100644 index 00000000..5d1e2c0d --- /dev/null +++ b/src-tauri/src/hook/config.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use notify::RecursiveMode; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AppConfig { + pub idle_sleep_secs: u64, + pub session_window_secs: u64, + pub hook_timeout_secs: u64, + pub jsonl_debounce_ms: u64, + pub active_state_timeout_secs: u64, + pub session_poll_fallback_secs: u64, + pub cursor_track_near_ms: u64, + pub cursor_track_far_ms: u64, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + idle_sleep_secs: 300, + session_window_secs: 600, + hook_timeout_secs: 120, + jsonl_debounce_ms: 300, + active_state_timeout_secs: 30, + session_poll_fallback_secs: 30, + cursor_track_near_ms: 32, + cursor_track_far_ms: 150, + } + } +} + +fn config_path() -> Option { + dirs::home_dir().map(|h| h.join(".BongoCat").join("config.toml")) +} + +pub fn load() -> AppConfig { + let Some(path) = config_path() else { + return AppConfig::default(); + }; + if !path.exists() { + let default = AppConfig::default(); + if let Ok(content) = toml::to_string_pretty(&default) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let header = "# BongoCat 配置文件\n# 修改后自动生效,无需重启\n\n"; + let _ = std::fs::write(&path, format!("{}{}", header, content)); + } + return default; + } + match std::fs::read_to_string(&path) { + Ok(content) => toml::from_str::(&content).unwrap_or_default(), + Err(_) => AppConfig::default(), + } +} + +pub type SharedConfig = Arc>; + +pub fn shared() -> SharedConfig { + Arc::new(RwLock::new(load())) +} + +pub fn start_watching( + config: SharedConfig, + app_handle: tauri::AppHandle, +) -> Option> { + let path = config_path()?; + let dir = path.parent()?.to_path_buf(); + + let (tx, rx) = std::sync::mpsc::channel::(); + + let cfg = config; + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + let events = match result { + Ok(events) => events, + Err(_) => continue, + }; + let config_changed = events.iter().any(|e| { + e.path.file_name().and_then(|n| n.to_str()) == Some("config.toml") + }); + if !config_changed { + continue; + } + let new_config = load(); + if let Ok(mut guard) = cfg.write() { + *guard = new_config.clone(); + } + let _ = app_handle.emit("hook-config-changed", &new_config); + } + }); + + let mut debouncer = new_debouncer(Duration::from_millis(500), tx).ok()?; + debouncer + .watcher() + .watch(&dir, RecursiveMode::NonRecursive) + .ok()?; + Some(debouncer) +} diff --git a/src-tauri/src/hook/ipc.rs b/src-tauri/src/hook/ipc.rs new file mode 100644 index 00000000..ecb994ed --- /dev/null +++ b/src-tauri/src/hook/ipc.rs @@ -0,0 +1,223 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use tauri::Emitter; +use tokio::sync::{oneshot, Mutex}; + +use super::{config::SharedConfig, permissions::PermissionRequest}; + +#[derive(Debug, Deserialize)] +struct HookRequest { + id: String, + payload: serde_json::Value, +} + +#[derive(Debug, Serialize)] +struct HookResponse { + decision: String, +} + +#[derive(Debug, Clone)] +pub struct Decision { + pub allow: bool, +} + +pub struct PendingRequests { + senders: Mutex>>, +} + +impl PendingRequests { + pub fn new() -> Self { + Self { + senders: Mutex::new(HashMap::new()), + } + } + + async fn register(&self, id: String) -> oneshot::Receiver { + let (tx, rx) = oneshot::channel(); + self.senders.lock().await.insert(id, tx); + rx + } + + pub async fn resolve(&self, id: &str, decision: Decision) -> bool { + if let Some(sender) = self.senders.lock().await.remove(id) { + let _ = sender.send(decision); + true + } else { + false + } + } + + async fn remove(&self, id: &str) { + self.senders.lock().await.remove(id); + } +} + +pub fn socket_path() -> Option { + super::settings::signal_dir().map(|d| d.join("ipc.sock")) +} + +pub fn cleanup_socket() { + if let Some(path) = socket_path() { + let _ = std::fs::remove_file(&path); + } +} + +#[cfg(unix)] +pub async fn serve( + app_handle: tauri::AppHandle, + pending: Arc, + config: SharedConfig, +) -> anyhow::Result<()> { + use tokio::net::UnixListener; + + let sock_path = socket_path() + .ok_or_else(|| anyhow::anyhow!("Cannot determine socket path"))?; + + if let Some(parent) = sock_path.parent() { + std::fs::create_dir_all(parent)?; + } + let _ = std::fs::remove_file(&sock_path); + + let listener = UnixListener::bind(&sock_path)?; + + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&sock_path, std::fs::Permissions::from_mode(0o600))?; + } + + loop { + match listener.accept().await { + Ok((stream, _)) => { + let app = app_handle.clone(); + let pending = pending.clone(); + let config = config.clone(); + tokio::spawn(async move { + if let Err(e) = handle_client(stream, app, pending, config).await { + eprintln!("IPC client error: {e}"); + } + }); + } + Err(e) => { + eprintln!("IPC accept error: {e}"); + } + } + } +} + +#[cfg(not(unix))] +pub async fn serve( + _app_handle: tauri::AppHandle, + _pending: Arc, + _config: SharedConfig, +) -> anyhow::Result<()> { + // Unix Domain Socket IPC 仅在 Unix 平台支持 + Ok(()) +} + +#[cfg(unix)] +async fn handle_client( + stream: tokio::net::UnixStream, + app: tauri::AppHandle, + pending: Arc, + config: SharedConfig, +) -> anyhow::Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + let (reader, mut writer) = stream.into_split(); + let mut buf_reader = BufReader::new(reader); + + let mut line = String::new(); + buf_reader.read_line(&mut line).await?; + let line = line.trim(); + if line.is_empty() { + return Ok(()); + } + + let hook_req: HookRequest = serde_json::from_str(line) + .map_err(|e| anyhow::anyhow!("Invalid hook request JSON: {e}"))?; + + let request_id = hook_req.id.clone(); + let valid_request_id = !request_id.is_empty() + && request_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + if !valid_request_id { + let resp = serde_json::to_string(&HookResponse { + decision: "deny".to_string(), + })?; + writer.write_all(resp.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.shutdown().await?; + return Ok(()); + } + + let tool_name = hook_req + .payload + .get("tool_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let session_id = hook_req + .payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let tool_input = hook_req.payload.get("tool_input").map(|v| v.to_string()); + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + let perm_req = PermissionRequest { + request_id: request_id.clone(), + session_id, + tool_name, + tool_input, + timestamp: now, + }; + + let rx = pending.register(request_id.clone()).await; + + if app.emit("permission-request", &perm_req).is_err() { + pending.remove(&request_id).await; + let resp = serde_json::to_string(&HookResponse { + decision: "deny".to_string(), + })?; + writer.write_all(resp.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.shutdown().await?; + return Ok(()); + } + + let timeout_secs = config.read().map(|c| c.hook_timeout_secs).unwrap_or(120); + let decision = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await; + + let decision_str = match decision { + Ok(Ok(d)) => { + if d.allow { + "allow" + } else { + "deny" + } + } + Ok(Err(_)) => "deny", + Err(_) => { + pending.remove(&request_id).await; + "ask" + } + }; + + let resp = serde_json::to_string(&HookResponse { + decision: decision_str.to_string(), + })?; + writer.write_all(resp.as_bytes()).await?; + writer.write_all(b"\n").await?; + writer.shutdown().await?; + Ok(()) +} + diff --git a/src-tauri/src/hook/mod.rs b/src-tauri/src/hook/mod.rs new file mode 100644 index 00000000..06324469 --- /dev/null +++ b/src-tauri/src/hook/mod.rs @@ -0,0 +1,6 @@ +pub mod claude; +pub mod commands; +pub mod config; +pub mod ipc; +pub mod permissions; +pub mod settings; diff --git a/src-tauri/src/hook/permissions.rs b/src-tauri/src/hook/permissions.rs new file mode 100644 index 00000000..5c1ada12 --- /dev/null +++ b/src-tauri/src/hook/permissions.rs @@ -0,0 +1,141 @@ +use std::path::PathBuf; +use std::time::Duration; + +use notify::RecursiveMode; +use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer}; +use serde::{Deserialize, Serialize}; +use tauri::Emitter; + +use super::settings; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PermissionRequest { + pub request_id: String, + pub session_id: String, + pub tool_name: String, + pub tool_input: Option, + pub timestamp: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PermissionResponse { + pub decision: String, + pub reason: Option, +} + +fn sanitize_request_id(request_id: &str) -> Option<&str> { + let valid = !request_id.is_empty() + && request_id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'); + valid.then_some(request_id) +} + +pub fn start_permission_watching( + app_handle: tauri::AppHandle, +) -> anyhow::Result> { + let requests_dir = settings::requests_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&requests_dir)?; + + let watch_dir = requests_dir.clone(); + let (tx, rx) = std::sync::mpsc::channel::(); + + let app = app_handle; + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + let events = match result { + Ok(events) => events, + Err(_) => continue, + }; + for event in events { + let path = &event.path; + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + if !path.exists() { + continue; + } + let content = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + let request: PermissionRequest = match serde_json::from_str(&content) { + Ok(r) => r, + Err(_) => continue, + }; + let _ = app.emit("permission-request", &request); + } + } + }); + + let mut debouncer = new_debouncer(Duration::from_millis(100), tx)?; + debouncer + .watcher() + .watch(&watch_dir, RecursiveMode::NonRecursive)?; + Ok(debouncer) +} + +pub fn respond(request_id: &str, decision: &str, reason: Option<&str>) -> anyhow::Result<()> { + let responses_dir = settings::responses_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&responses_dir)?; + + let safe_request_id = sanitize_request_id(request_id) + .ok_or_else(|| anyhow::anyhow!("Invalid request id"))?; + + let response = PermissionResponse { + decision: decision.to_string(), + reason: reason.map(|s| s.to_string()), + }; + let response_path = responses_dir.join(format!("{}.json", safe_request_id)); + let tmp_path = responses_dir.join(format!("{}.tmp", safe_request_id)); + let content = serde_json::to_string(&response)?; + std::fs::write(&tmp_path, &content)?; + std::fs::rename(&tmp_path, &response_path)?; + Ok(()) +} + +pub fn set_intercept_active(active: bool) -> anyhow::Result<()> { + let flag_path = intercept_flag_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + if active { + if let Some(parent) = flag_path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&flag_path, "1")?; + } else { + let _ = std::fs::remove_file(&flag_path); + } + Ok(()) +} + +pub fn is_intercept_active() -> bool { + intercept_flag_path() + .map(|p| p.exists()) + .unwrap_or(false) +} + +fn intercept_flag_path() -> Option { + settings::signal_dir().map(|d| d.join("intercept-active")) +} + +pub fn cleanup_stale_files() { + let now = std::time::SystemTime::now(); + let max_age = Duration::from_secs(120); + for dir_fn in [settings::requests_dir, settings::responses_dir] { + if let Some(dir) = dir_fn() + && let Ok(entries) = std::fs::read_dir(&dir) + { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() + && let Ok(modified) = metadata.modified() + && now.duration_since(modified).unwrap_or_default() > max_age + { + let _ = std::fs::remove_file(entry.path()); + } + } + } + } +} diff --git a/src-tauri/src/hook/settings.rs b/src-tauri/src/hook/settings.rs new file mode 100644 index 00000000..3bc84f8f --- /dev/null +++ b/src-tauri/src/hook/settings.rs @@ -0,0 +1,465 @@ +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::Result; +use notify::RecursiveMode; +use notify_debouncer_mini::{DebounceEventResult, Debouncer, new_debouncer}; +use serde_json::Value; +use tauri::Emitter; + +const SIGNAL_FILE_NAME: &str = "waiting-signal"; +const SCRIPT_FILE_NAME: &str = "notify-waiting.sh"; +const PRETOOLUSE_SCRIPT_NAME: &str = "pretooluse-hook.sh"; +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); +const MAX_BACKUPS: usize = 3; + +// ---- 路径辅助 ---- + +fn claude_settings_path() -> Option { + dirs::home_dir().map(|h| h.join(".claude").join("settings.json")) +} + +pub fn signal_dir() -> Option { + dirs::home_dir().map(|h| h.join(".BongoCat")) +} + +pub fn signal_file_path() -> Option { + signal_dir().map(|d| d.join(SIGNAL_FILE_NAME)) +} + +fn signal_script_path() -> Option { + signal_dir().map(|d| d.join(SCRIPT_FILE_NAME)) +} + +fn pretooluse_script_path() -> Option { + signal_dir().map(|d| d.join(PRETOOLUSE_SCRIPT_NAME)) +} + +pub fn requests_dir() -> Option { + signal_dir().map(|d| d.join("requests")) +} + +pub fn responses_dir() -> Option { + signal_dir().map(|d| d.join("responses")) +} + +// ---- 原子写入 + 备份 ---- + +fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> { + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, data)?; + std::fs::rename(&tmp, path)?; + Ok(()) +} + +fn backup_settings(path: &Path) -> Result<()> { + if !path.exists() { + return Ok(()); + } + let parent = path.parent().unwrap_or(Path::new(".")); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let backup_path = parent.join(format!("settings.json.bongocat.bak.{}", ts)); + std::fs::copy(path, &backup_path)?; + + let mut backups: Vec<_> = std::fs::read_dir(parent)? + .flatten() + .filter(|e| { + e.file_name() + .to_str() + .map(|n| n.starts_with("settings.json.bongocat.bak.")) + .unwrap_or(false) + }) + .collect(); + if backups.len() > MAX_BACKUPS { + backups.sort_by_key(|e| e.file_name()); + for old in &backups[..backups.len() - MAX_BACKUPS] { + let _ = std::fs::remove_file(old.path()); + } + } + Ok(()) +} + +// ---- settings.json 读写 ---- + +fn read_claude_settings() -> Result { + let path = claude_settings_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + if !path.exists() { + return Ok(serde_json::json!({})); + } + let content = std::fs::read(&path)?; + let value: Value = serde_json::from_slice(&content) + .map_err(|e| anyhow::anyhow!("settings.json is not valid JSON: {}", e))?; + Ok(value) +} + +fn write_claude_settings(value: &Value) -> Result<()> { + let path = claude_settings_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + backup_settings(&path)?; + let content = serde_json::to_string_pretty(value)?; + serde_json::from_str::(&content)?; + atomic_write(&path, content.as_bytes())?; + Ok(()) +} + +// ---- managed 标记辅助 ---- + +fn is_managed(entry: &Value) -> bool { + entry + .get("_bongocat_managed") + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + +fn make_managed_entry(matcher: &str, script_path: &Path) -> Value { + serde_json::json!({ + "_bongocat_managed": true, + "_bongocat_version": APP_VERSION, + "matcher": matcher, + "hooks": [{ + "type": "command", + "command": script_path.display().to_string() + }] + }) +} + +fn remove_managed_entries(arr: &mut Vec) { + arr.retain(|entry| !is_managed(entry)); +} + +fn extract_script_path(entry: &Value) -> Option { + entry + .get("hooks") + .and_then(|h| h.as_array()) + .and_then(|hooks| hooks.first()) + .and_then(|hook| hook.get("command")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()) +} + +// ---- Hook 状态 ---- + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HookHealth { + pub notification: HookEntryHealth, + pub pre_tool_use: HookEntryHealth, + pub settings_valid: bool, +} + +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub enum HookEntryHealth { + Healthy, + Inactive, + Broken, +} + +// ---- 公共 API ---- + +pub fn is_pretooluse_hook_installed() -> Result { + let settings = read_claude_settings()?; + Ok(has_managed_hook(&settings, "PreToolUse")) +} + +fn has_managed_hook(settings: &Value, hook_type: &str) -> bool { + settings + .get("hooks") + .and_then(|h| h.get(hook_type)) + .and_then(|n| n.as_array()) + .map(|arr| arr.iter().any(is_managed)) + .unwrap_or(false) +} + +pub fn install_hook() -> Result<()> { + let dir = signal_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&dir)?; + + let script_path = signal_script_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + let signal_file = signal_file_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + + let script_content = format!( + "#!/bin/bash\n# BongoCat notification hook\nLOG_DIR=\"$HOME/.BongoCat/logs\"\nmkdir -p \"$LOG_DIR\"\nexec 2>>\"$LOG_DIR/hook.log\"\necho \"[$(date -u +%%FT%%TZ)] Notification hook invoked\" >&2\ndate +%s > \"{}\"\n", + signal_file.display() + ); + std::fs::write(&script_path, &script_content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + } + + let mut settings = read_claude_settings()?; + let hook_entry = make_managed_entry("permission_prompt", &script_path); + inject_hook(&mut settings, "Notification", hook_entry)?; + write_claude_settings(&settings)?; + Ok(()) +} + +pub fn uninstall_hook() -> Result<()> { + let mut settings = read_claude_settings()?; + if remove_hook(&mut settings, "Notification") { + write_claude_settings(&settings)?; + } + if let Some(script) = signal_script_path() { + let _ = std::fs::remove_file(&script); + } + if let Some(signal) = signal_file_path() { + let _ = std::fs::remove_file(&signal); + } + Ok(()) +} + +pub fn install_pretooluse_hook() -> Result<()> { + let dir = signal_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&dir)?; + + if let Some(req_dir) = requests_dir() { + std::fs::create_dir_all(&req_dir)?; + } + if let Some(resp_dir) = responses_dir() { + std::fs::create_dir_all(&resp_dir)?; + } + + let script_path = pretooluse_script_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + let intercept_flag = dir.join("intercept-active"); + let sock_path = dir.join("ipc.sock"); + + let script_content = format!( + r#"#!/bin/bash +# BongoCat PreToolUse hook — Unix Domain Socket IPC +LOG_DIR="$HOME/.BongoCat/logs" +mkdir -p "$LOG_DIR" +exec 2>>"$LOG_DIR/hook.log" +echo "[$(date -u +%FT%TZ)] PreToolUse hook invoked" >&2 + +SOCK="{sock_path}" + +if [ ! -f "{intercept_flag}" ]; then + exit 0 +fi + +if [ ! -S "$SOCK" ]; then + echo "[$(date -u +%FT%TZ)] [warn] socket missing, pass-through" >&2 + exit 0 +fi + +INPUT=$(cat) +REQUEST_ID=$(uuidgen | tr '[:upper:]' '[:lower:]') +echo "[$(date -u +%FT%TZ)] request=$REQUEST_ID" >&2 + +RESP=$(printf '{{"id":"%s","payload":%s}}\n' "$REQUEST_ID" "$INPUT" \ + | timeout 125 nc -U "$SOCK" 2>/dev/null) + +if [ -z "$RESP" ]; then + echo "[$(date -u +%FT%TZ)] [warn] no response from socket, pass-through" >&2 + exit 0 +fi + +echo "[$(date -u +%FT%TZ)] response=$RESP" >&2 + +case "$RESP" in + *'"allow"'*) DECISION="allow" ;; + *'"deny"'*) DECISION="deny" ;; + *) DECISION="ask" ;; +esac + +if [ "$DECISION" = "deny" ]; then + echo '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"BongoCat user denied"}}}}' + exit 2 +fi + +if [ "$DECISION" = "allow" ]; then + echo '{{"hookSpecificOutput":{{"hookEventName":"PreToolUse","permissionDecision":"allow","permissionDecisionReason":"BongoCat user approved"}}}}' + exit 0 +fi + +exit 0 +"#, + sock_path = sock_path.display(), + intercept_flag = intercept_flag.display(), + ); + + std::fs::write(&script_path, &script_content)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?; + } + + let mut settings = read_claude_settings()?; + let hook_entry = make_managed_entry("", &script_path); + inject_hook(&mut settings, "PreToolUse", hook_entry)?; + write_claude_settings(&settings)?; + Ok(()) +} + +pub fn uninstall_pretooluse_hook() -> Result<()> { + let mut settings = read_claude_settings()?; + if remove_hook(&mut settings, "PreToolUse") { + write_claude_settings(&settings)?; + } + if let Some(script) = pretooluse_script_path() { + let _ = std::fs::remove_file(&script); + } + Ok(()) +} + +pub fn verify_hook_integrity() -> HookHealth { + let settings_valid = match read_claude_settings() { + Ok(mut settings) => { + let mut dirty = false; + for hook_type in &["Notification", "PreToolUse"] { + if let Some(arr) = settings + .get_mut("hooks") + .and_then(|h| h.get_mut(*hook_type)) + .and_then(|n| n.as_array_mut()) + { + let before = arr.len(); + arr.retain(|entry| { + if !is_managed(entry) { + return true; + } + if let Some(script) = extract_script_path(entry) { + Path::new(&script).exists() + } else { + false + } + }); + if arr.len() != before { + dirty = true; + } + } + } + if dirty { + let _ = write_claude_settings(&settings); + } + true + } + Err(_) => false, + }; + + HookHealth { + notification: check_entry_health("Notification"), + pre_tool_use: check_entry_health("PreToolUse"), + settings_valid, + } +} + +fn check_entry_health(hook_type: &str) -> HookEntryHealth { + let settings = match read_claude_settings() { + Ok(s) => s, + Err(_) => return HookEntryHealth::Broken, + }; + let entries = settings + .get("hooks") + .and_then(|h| h.get(hook_type)) + .and_then(|n| n.as_array()); + let Some(arr) = entries else { + return HookEntryHealth::Inactive; + }; + let managed: Vec<_> = arr.iter().filter(|e| is_managed(e)).collect(); + if managed.is_empty() { + return HookEntryHealth::Inactive; + } + for entry in &managed { + if let Some(script) = extract_script_path(entry) { + if !Path::new(&script).exists() { + return HookEntryHealth::Broken; + } + } else { + return HookEntryHealth::Broken; + } + } + HookEntryHealth::Healthy +} + +fn inject_hook(settings: &mut Value, hook_type: &str, entry: Value) -> Result<()> { + let hooks = settings + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))? + .entry("hooks") + .or_insert_with(|| serde_json::json!({})); + let arr = hooks + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("hooks is not an object"))? + .entry(hook_type) + .or_insert_with(|| serde_json::json!([])); + let arr = arr + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("{} is not an array", hook_type))?; + remove_managed_entries(arr); + arr.push(entry); + Ok(()) +} + +fn remove_hook(settings: &mut Value, hook_type: &str) -> bool { + if let Some(arr) = settings + .get_mut("hooks") + .and_then(|h| h.get_mut(hook_type)) + .and_then(|n| n.as_array_mut()) + { + let before = arr.len(); + remove_managed_entries(arr); + arr.len() != before + } else { + false + } +} + +// ---- Signal 文件监听 ---- + +pub fn start_signal_watching( + app_handle: tauri::AppHandle, +) -> anyhow::Result> { + let watch_dir = signal_dir() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + std::fs::create_dir_all(&watch_dir)?; + + let signal_path = signal_file_path() + .ok_or_else(|| anyhow::anyhow!("Cannot find home directory"))?; + + let (tx, rx) = std::sync::mpsc::channel::(); + + let app = app_handle; + std::thread::spawn(move || { + while let Ok(result) = rx.recv() { + let events = match result { + Ok(events) => events, + Err(_) => continue, + }; + for event in events { + if event.path != signal_path { + continue; + } + if !event.path.exists() { + continue; + } + crate::hook::claude::update_last_event(); + crate::hook::claude::set_active(true); + let _ = app.emit("claude-event", crate::hook::claude::waiting_event()); + } + } + }); + + let mut debouncer = new_debouncer(Duration::from_millis(100), tx)?; + debouncer + .watcher() + .watch(&watch_dir, RecursiveMode::NonRecursive)?; + Ok(debouncer) +} + diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b43b119..9b3fe661 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,11 +1,25 @@ mod core; +mod hook; mod utils; +use std::sync::Arc; + use core::{ device::start_device_listening, gamepad::{start_gamepad_listing, stop_gamepad_listing}, + label::{set_label_text, LabelState}, prevent_default, setup, }; +use hook::{ + claude, + commands::{ + check_hook_status, check_pretooluse_hook_status, get_config, get_intercept_active, + install_notification_hook, install_pretooluse_hook, respond_permission, + set_intercept_active, uninstall_notification_hook, uninstall_pretooluse_hook, + update_config, + }, + config, ipc, permissions, +}; use tauri::{Manager, WindowEvent, generate_handler}; use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_custom_window::{ @@ -15,15 +29,55 @@ use utils::fs_extra::copy_dir; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let shared_config = config::shared(); + let pending_requests = Arc::new(ipc::PendingRequests::new()); + let label_state = Arc::new(LabelState::new()); + let app = tauri::Builder::default() - .setup(|app| { + .manage(shared_config.clone()) + .manage(pending_requests.clone()) + .manage(label_state.clone()) + .setup(move |app| { let app_handle = app.handle(); let main_window = app.get_webview_window(MAIN_WINDOW_LABEL).unwrap(); let preference_window = app.get_webview_window(PREFERENCE_WINDOW_LABEL).unwrap(); - setup::default(&app_handle, main_window.clone(), preference_window.clone()); + setup::default(app_handle, main_window.clone(), preference_window.clone()); + + // 启动配置热重载监听 + let _config_watcher = config::start_watching(shared_config.clone(), app_handle.clone()); + // 泄漏 watcher 以保持其在整个应用生命周期内存活 + Box::leak(Box::new(_config_watcher)); + + // 启动权限请求文件监听 + if let Ok(watcher) = permissions::start_permission_watching(app_handle.clone()) { + Box::leak(Box::new(watcher)); + } + + // 启动 Claude JSONL 事件监听 + if let Ok(watcher) = claude::start_watching(app_handle.clone(), shared_config.clone()) { + Box::leak(Box::new(watcher)); + } + + // 启动通知 signal 文件监听 + if let Ok(watcher) = hook::settings::start_signal_watching(app_handle.clone()) { + Box::leak(Box::new(watcher)); + } + + // 启动 IPC Unix Domain Socket 服务器 + let pending = pending_requests.clone(); + let app_h = app_handle.clone(); + let cfg_for_ipc = shared_config.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = ipc::serve(app_h, pending, cfg_for_ipc).await { + eprintln!("IPC server error: {e}"); + } + }); + + // 清理过期请求/响应文件 + permissions::cleanup_stale_files(); Ok(()) }) @@ -31,7 +85,19 @@ pub fn run() { copy_dir, start_device_listening, start_gamepad_listing, - stop_gamepad_listing + stop_gamepad_listing, + set_label_text, + check_hook_status, + install_notification_hook, + uninstall_notification_hook, + check_pretooluse_hook_status, + install_pretooluse_hook, + uninstall_pretooluse_hook, + set_intercept_active, + get_intercept_active, + respond_permission, + get_config, + update_config, ]) .plugin(tauri_plugin_custom_window::init()) .plugin(tauri_plugin_os::init()) @@ -61,13 +127,12 @@ pub fn run() { .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_locale::init()) - .on_window_event(|window, event| match event { - WindowEvent::CloseRequested { api, .. } => { + .on_window_event(|window, event| { + if let WindowEvent::CloseRequested { api, .. } = event { let _ = window.hide(); api.prevent_close(); } - _ => {} }) .build(tauri::generate_context!()) .expect("error while running tauri application"); @@ -77,6 +142,10 @@ pub fn run() { tauri::RunEvent::Reopen { .. } => { show_preference_window(app_handle); } + tauri::RunEvent::Exit => { + ipc::cleanup_socket(); + let _ = app_handle; + } _ => { let _ = app_handle; } diff --git a/src/App.vue b/src/App.vue index a90bea08..581eab4c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,6 @@ + + diff --git a/src/pages/preference/index.vue b/src/pages/preference/index.vue index 23c600b8..60c5a917 100644 --- a/src/pages/preference/index.vue +++ b/src/pages/preference/index.vue @@ -14,6 +14,7 @@ import { isMac } from '@/utils/platform' import About from './components/about/index.vue' import Cat from './components/cat/index.vue' import General from './components/general/index.vue' +import Hook from './components/hook/index.vue' import Model from './components/model/index.vue' import Shortcut from './components/shortcut/index.vue' @@ -45,6 +46,11 @@ const menus = computed(() => [ icon: 'i-solar:magic-stick-3-bold', component: Model, }, + { + label: t('pages.preference.hook.title'), + icon: 'i-solar:tag-bold', + component: Hook, + }, { label: t('pages.preference.shortcut.title'), icon: 'i-solar:keyboard-bold', diff --git a/src/stores/hook.ts b/src/stores/hook.ts new file mode 100644 index 00000000..6cd25973 --- /dev/null +++ b/src/stores/hook.ts @@ -0,0 +1,111 @@ +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' +import { defineStore } from 'pinia' +import { ref } from 'vue' + +export interface HookHealth { + notification: 'healthy' | 'inactive' | 'broken' + preToolUse: 'healthy' | 'inactive' | 'broken' + settingsValid: boolean +} + +export interface AppConfig { + idle_sleep_secs: number + session_window_secs: number + hook_timeout_secs: number + jsonl_debounce_ms: number + active_state_timeout_secs: number + session_poll_fallback_secs: number + cursor_track_near_ms: number + cursor_track_far_ms: number +} + +export interface PermissionRequest { + requestId: string + sessionId: string + toolName: string + toolInput: string | null + timestamp: number +} + +export const useHookStore = defineStore('hook', () => { + const hookHealth = ref(null) + const pretoolHookEnabled = ref(false) + const interceptActive = ref(false) + const config = ref(null) + const pendingRequests = ref([]) + + async function refresh() { + const [health, pretool, intercept, cfg] = await Promise.all([ + invoke('check_hook_status'), + invoke('check_pretooluse_hook_status'), + invoke('get_intercept_active'), + invoke('get_config'), + ]) + hookHealth.value = health + pretoolHookEnabled.value = pretool + interceptActive.value = intercept + config.value = cfg + } + + async function installNotificationHook() { + await invoke('install_notification_hook') + await refresh() + } + + async function uninstallNotificationHook() { + await invoke('uninstall_notification_hook') + await refresh() + } + + async function installPretoolHook() { + await invoke('install_pretooluse_hook') + await refresh() + } + + async function uninstallPretoolHook() { + await invoke('uninstall_pretooluse_hook') + await refresh() + } + + async function toggleIntercept(active: boolean) { + await invoke('set_intercept_active', { active }) + interceptActive.value = active + } + + async function saveConfig(newConfig: AppConfig) { + await invoke('update_config', { newConfig }) + config.value = { ...newConfig } + } + + async function respondPermission(requestId: string, decision: 'allow' | 'deny') { + await invoke('respond_permission', { requestId, decision }) + pendingRequests.value = pendingRequests.value.filter(r => r.requestId !== requestId) + } + + function setupPermissionListener() { + return listen('permission-request', (event) => { + const req = event.payload + if (!pendingRequests.value.find(r => r.requestId === req.requestId)) { + pendingRequests.value = [...pendingRequests.value, req] + } + }) + } + + return { + hookHealth, + pretoolHookEnabled, + interceptActive, + config, + pendingRequests, + refresh, + installNotificationHook, + uninstallNotificationHook, + installPretoolHook, + uninstallPretoolHook, + toggleIntercept, + saveConfig, + respondPermission, + setupPermissionListener, + } +}) diff --git a/src/stores/label.ts b/src/stores/label.ts new file mode 100644 index 00000000..7858f441 --- /dev/null +++ b/src/stores/label.ts @@ -0,0 +1,225 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import { useCatStore } from './cat' + +export const LABEL_SIZES = ['small', 'medium', 'large', 'xlarge'] as const +export const LABEL_COLLAPSE_LIMIT = 50 +export const SESSION_TITLE_MAX_CHARS = 16 + +export type LabelSize = typeof LABEL_SIZES[number] +export type RuntimeLabelState = 'idle' | 'thinking' | 'coding' | 'success' | 'error' | 'waiting' | 'dormant' +export type RuntimeLabelSource = 'claude' | 'signal' | 'static' + +export interface LabelSizeMetrics { + fontSize: number + lineHeight: number + reservedHeight: number +} + +export interface LabelMetrics extends LabelSizeMetrics { + isVisible: boolean +} + +export interface RuntimeLabelPayload { + text: string + state: RuntimeLabelState + source: RuntimeLabelSource + detail?: string | null + rawText?: string | null + toolName?: string | null + sessionId?: string | null + sessionTitle?: string | null +} + +export const LABEL_SIZE_MAP: Record = { + small: { + fontSize: 16, + lineHeight: 22, + reservedHeight: 52, + }, + medium: { + fontSize: 22, + lineHeight: 30, + reservedHeight: 68, + }, + large: { + fontSize: 28, + lineHeight: 36, + reservedHeight: 80, + }, + xlarge: { + fontSize: 34, + lineHeight: 42, + reservedHeight: 92, + }, +} + +export function truncateLabelText(text: string, maxChars: number): string { + const normalized = text.trim() + + if (!normalized) { + return '' + } + + const chars = [...normalized] + + if (chars.length <= maxChars) { + return normalized + } + + return `${chars.slice(0, maxChars).join('')}…` +} + +export function normalizeLabelText(text: string): string { + const normalized = text + .replace(/\r\n/g, '\n') + .split('\n') + .map(line => line.trim().replace(/\s+/g, ' ')) + .filter(Boolean) + .join('\n') + + return normalized.trim() +} + +export function flattenLabelText(text: string): string { + return normalizeLabelText(text).replace(/\n+/g, ' ').trim() +} + +export function buildSessionTitle(text: string): string { + return truncateLabelText(flattenLabelText(text), SESSION_TITLE_MAX_CHARS) +} + +export function getLabelMetrics(text: string, size: LabelSize, scale: number): LabelMetrics { + const trimmedText = text.trim() + + if (!trimmedText) { + return { + fontSize: 0, + lineHeight: 0, + reservedHeight: 0, + isVisible: false, + } + } + + const baseMetrics = LABEL_SIZE_MAP[size] + const factor = scale / 100 + + return { + fontSize: Math.max(1, Math.round(baseMetrics.fontSize * factor)), + lineHeight: Math.max(1, Math.round(baseMetrics.lineHeight * factor)), + reservedHeight: Math.max(1, Math.round(baseMetrics.reservedHeight * factor)), + isVisible: true, + } +} + +export const useLabelStore = defineStore('label', () => { + const defaultText = ref('') + const runtimeText = ref('') + const runtimeTitle = ref('') + const runtimeState = ref('idle') + const runtimeSource = ref('static') + const size = ref('medium') + const showSessionTitle = ref(true) + const collapseLongText = ref(true) + const showDetailedContent = ref(true) + const catStore = useCatStore() + + const isRuntimeActive = computed(() => runtimeText.value.trim().length > 0) + const normalizedRuntimeText = computed(() => normalizeLabelText(runtimeText.value)) + const normalizedDefaultText = computed(() => defaultText.value.trim()) + const fullBodyText = computed(() => isRuntimeActive.value ? normalizedRuntimeText.value : normalizedDefaultText.value) + const displayTitle = computed(() => { + if (!isRuntimeActive.value || runtimeSource.value === 'signal' || !showSessionTitle.value) { + return '' + } + + const title = buildSessionTitle(runtimeTitle.value) + + return title ? `[${title}]` : '' + }) + const shouldCollapse = computed(() => { + if (!isRuntimeActive.value || !collapseLongText.value) { + return false + } + + return fullBodyText.value.includes('\n') || flattenLabelText(fullBodyText.value).length > LABEL_COLLAPSE_LIMIT + }) + const displayBody = computed(() => { + if (!fullBodyText.value) { + return '' + } + + const flattened = flattenLabelText(fullBodyText.value) + + if (!shouldCollapse.value) { + return flattened + } + + return truncateLabelText(flattened, LABEL_COLLAPSE_LIMIT) + }) + const displayBodyTag = computed(() => { + if (!displayBody.value) { + return '' + } + + return displayTitle.value ? `[${displayBody.value}]` : displayBody.value + }) + const tooltipText = computed(() => { + if (!fullBodyText.value) { + return '' + } + + const body = flattenLabelText(fullBodyText.value) + + return displayTitle.value ? `${displayTitle.value}[${body}]` : body + }) + const displayText = computed(() => `${displayTitle.value}${displayBodyTag.value}`.trim()) + const hasText = computed(() => displayText.value.length > 0) + const metrics = computed(() => getLabelMetrics(displayText.value, size.value, catStore.window.scale)) + + function setDefaultText(text: string) { + defaultText.value = text + } + + function setRuntimeLabel(payload: RuntimeLabelPayload) { + runtimeText.value = payload.text + runtimeTitle.value = payload.sessionTitle?.trim() ?? '' + runtimeState.value = payload.state + runtimeSource.value = payload.source + } + + function clearRuntimeLabel(nextState: RuntimeLabelState = 'idle') { + runtimeText.value = '' + runtimeTitle.value = '' + runtimeState.value = nextState + runtimeSource.value = 'static' + } + + return { + defaultText, + runtimeText, + runtimeTitle, + runtimeState, + runtimeSource, + displayText, + displayTitle, + displayBody, + displayBodyTag, + tooltipText, + size, + showSessionTitle, + collapseLongText, + showDetailedContent, + hasText, + metrics, + shouldCollapse, + setDefaultText, + setRuntimeLabel, + clearRuntimeLabel, + } +}, { + tauri: { + filterKeys: ['runtimeText', 'runtimeTitle', 'runtimeState', 'runtimeSource'], + }, +}) diff --git a/src/utils/live2d.ts b/src/utils/live2d.ts index c228c5e0..97220d65 100644 --- a/src/utils/live2d.ts +++ b/src/utils/live2d.ts @@ -25,12 +25,13 @@ class Live2d { if (this.app) return const view = document.getElementById('live2dCanvas') as HTMLCanvasElement + const stage = document.getElementById('modelStage') as HTMLElement | null this.app = new Application() return this.app.init({ view, - resizeTo: window, + resizeTo: stage ?? window, backgroundAlpha: 0, autoDensity: true, resolution: devicePixelRatio, @@ -92,18 +93,20 @@ class Live2d { this.model = null } - public resizeModel(modelSize: ModelSize) { + public resizeModel(modelSize: ModelSize, reservedTopHeight = 0) { if (!this.model) return const { width, height } = modelSize - - const scaleX = innerWidth / width - const scaleY = innerHeight / height + const stage = document.getElementById('modelStage') as HTMLElement | null + const stageWidth = stage?.clientWidth ?? innerWidth + const stageHeight = stage?.clientHeight ?? Math.max(innerHeight - reservedTopHeight, 1) + const scaleX = stageWidth / width + const scaleY = stageHeight / height const scale = Math.min(scaleX, scaleY) this.model.scale.set(scale) - this.model.x = innerWidth / 2 - this.model.y = innerHeight / 2 + this.model.x = stageWidth / 2 + this.model.y = stageHeight / 2 this.model.anchor.set(0.5) }