diff --git a/.gitignore b/.gitignore index 8f442c5..40258ff 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ memory/*.md node_modules/ sounds/ projects.yaml +app/src-tauri/target/ +app/node_modules/ diff --git a/app/package.json b/app/package.json new file mode 100644 index 0000000..1bc887e --- /dev/null +++ b/app/package.json @@ -0,0 +1,10 @@ +{ + "name": "claude-hub-app", + "version": "0.1.0", + "description": "Claude Hub 通知面板", + "scripts": { + "tauri": "cargo tauri", + "dev": "cargo tauri dev", + "build": "cargo tauri build" + } +} diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml new file mode 100644 index 0000000..497385c --- /dev/null +++ b/app/src-tauri/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "claude-hub" +version = "0.1.0" +description = "Claude Hub - 本地开发调度中心通知面板" +authors = ["hengjun-dev"] +edition = "2021" + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon"] } +tauri-plugin-notification = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +notify = "7" +chrono = "0.4" diff --git a/app/src-tauri/build.rs b/app/src-tauri/build.rs new file mode 100644 index 0000000..d860e1e --- /dev/null +++ b/app/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/app/src-tauri/icons/icon.png b/app/src-tauri/icons/icon.png new file mode 100644 index 0000000..bfe5dd4 Binary files /dev/null and b/app/src-tauri/icons/icon.png differ diff --git a/app/src-tauri/src/main.rs b/app/src-tauri/src/main.rs new file mode 100644 index 0000000..a44df51 --- /dev/null +++ b/app/src-tauri/src/main.rs @@ -0,0 +1,120 @@ +// Claude Hub - 通知托盘 + 状态面板 +// 监听 /tmp/hub-signals/events/ 目录,接收 Claude Code hooks 的事件 + +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +mod watcher; + +use serde::{Deserialize, Serialize}; +use std::sync::Mutex; +use tauri::{ + AppHandle, Manager, + menu::{MenuBuilder, MenuItemBuilder}, + tray::TrayIconBuilder, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HubEvent { + #[serde(rename = "type")] + pub event_type: String, + pub project: String, + pub message: String, + pub timestamp: String, +} + +// 全局状态 +pub struct AppState { + pub events: Mutex>, +} + +// 前端调用:获取历史事件 +#[tauri::command] +fn get_events(state: tauri::State) -> Vec { + state.events.lock().unwrap().clone() +} + +// 前端调用:跳转到项目窗口 +#[tauri::command] +fn focus_project(project: String) { + #[cfg(target_os = "macos")] + { + let script = format!( + r#" + tell application "iTerm2" to activate + do shell script "tmux select-window -t hub:{}" + "#, + project + ); + std::process::Command::new("osascript") + .arg("-e") + .arg(&script) + .spawn() + .ok(); + } + + #[cfg(target_os = "windows")] + { + // Windows: 激活 Windows Terminal + std::process::Command::new("cmd") + .args(["/c", "start", "wt", "-w", "0", "focus-tab", "-t", "0"]) + .spawn() + .ok(); + } +} + +fn main() { + tauri::Builder::default() + .plugin(tauri_plugin_notification::init()) + .manage(AppState { + events: Mutex::new(Vec::new()), + }) + .invoke_handler(tauri::generate_handler![get_events, focus_project]) + .setup(|app| { + let handle = app.handle().clone(); + + // 创建托盘菜单 + let show_item = MenuItemBuilder::with_id("show", "显示面板").build(app)?; + let quit_item = MenuItemBuilder::with_id("quit", "退出").build(app)?; + let menu = MenuBuilder::new(app) + .item(&show_item) + .separator() + .item(&quit_item) + .build()?; + + // 创建托盘图标 + TrayIconBuilder::new() + .menu(&menu) + .tooltip("Claude Hub") + .on_menu_event(move |app, event| { + match event.id().as_ref() { + "show" => { + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + } + "quit" => { + app.exit(0); + } + _ => {} + } + }) + .on_tray_icon_event(|tray, event| { + if let tauri::tray::TrayIconEvent::Click { .. } = event { + let app = tray.app_handle(); + if let Some(window) = app.get_webview_window("main") { + window.show().ok(); + window.set_focus().ok(); + } + } + }) + .build(app)?; + + // 启动文件监听 + watcher::start_watching(handle); + + Ok(()) + }) + .run(tauri::generate_context!()) + .expect("error while running tauri application"); +} diff --git a/app/src-tauri/src/watcher.rs b/app/src-tauri/src/watcher.rs new file mode 100644 index 0000000..c37f7bc --- /dev/null +++ b/app/src-tauri/src/watcher.rs @@ -0,0 +1,123 @@ +// 监听 /tmp/hub-signals/events/ 目录 +// 新 JSON 文件出现 → 读取 → 发通知 → 推送到前端 + +use crate::{AppState, HubEvent}; +use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode, Watcher}; +use std::fs; +use std::path::Path; +use std::sync::mpsc; +use std::thread; +use tauri::{AppHandle, Emitter, Manager}; +use tauri_plugin_notification::NotificationExt; + +const EVENTS_DIR: &str = "/tmp/hub-signals/events"; + +pub fn start_watching(app: AppHandle) { + // 确保目录存在 + fs::create_dir_all(EVENTS_DIR).ok(); + + thread::spawn(move || { + let (tx, rx) = mpsc::channel(); + + let mut watcher = RecommendedWatcher::new(tx, Config::default()) + .expect("Failed to create watcher"); + + watcher + .watch(Path::new(EVENTS_DIR), RecursiveMode::NonRecursive) + .expect("Failed to watch events directory"); + + // 处理文件事件 + for result in rx { + match result { + Ok(event) => { + if matches!(event.kind, EventKind::Create(_)) { + for path in event.paths { + if path.extension().map_or(false, |e| e == "json") { + handle_event_file(&app, &path); + } + } + } + } + Err(e) => eprintln!("Watch error: {}", e), + } + } + }); +} + +fn handle_event_file(app: &AppHandle, path: &Path) { + // 读取 JSON + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return, + }; + + let event: HubEvent = match serde_json::from_str(&content) { + Ok(e) => e, + Err(_) => return, + }; + + // 发系统通知 + send_notification(app, &event); + + // 推送到前端面板 + app.emit("hub-event", &event).ok(); + + // 保存到状态 + if let Some(state) = app.try_state::() { + let mut events = state.events.lock().unwrap(); + events.insert(0, event); + if events.len() > 50 { + events.truncate(50); + } + } + + // 删除已处理的文件 + fs::remove_file(path).ok(); +} + +fn send_notification(app: &AppHandle, event: &HubEvent) { + let (title, icon) = match event.event_type.as_str() { + "stop" => (format!("✅ {}", event.project), "done"), + "permission" => (format!("⚠️ {} 需要授权", event.project), "attention"), + _ => (event.project.clone(), "info"), + }; + + app.notification() + .builder() + .title(&title) + .body(&event.message) + .show() + .ok(); + + // macOS: 播放语音 + #[cfg(target_os = "macos")] + { + let hub_dir = dirs_next::home_dir() + .map(|h| h.join("Documents/code/claude-hub/sounds")) + .unwrap_or_default(); + + let pattern = if event.event_type == "stop" { "done" } else { "auth" }; + if let Ok(entries) = fs::read_dir(&hub_dir) { + let files: Vec<_> = entries + .filter_map(|e| e.ok()) + .filter(|e| { + e.file_name() + .to_string_lossy() + .starts_with(pattern) + }) + .collect(); + + if !files.is_empty() { + let idx = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as usize % files.len()) + .unwrap_or(0); + + std::process::Command::new("afplay") + .arg(files[idx].path()) + .spawn() + .ok(); + } + } + } +} diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json new file mode 100644 index 0000000..e5bd3bc --- /dev/null +++ b/app/src-tauri/tauri.conf.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://raw.githubusercontent.com/nicholasxjy/nicholasxjy.github.io/refs/heads/main/tauri-v2-schema.json", + "productName": "Claude Hub", + "version": "0.1.0", + "identifier": "dev.hengjun.claude-hub", + "build": { + "frontendDist": "../src" + }, + "app": { + "withGlobalTauri": true, + "windows": [ + { + "title": "Claude Hub", + "width": 360, + "height": 480, + "resizable": true, + "visible": false, + "decorations": true, + "transparent": false + } + ], + "trayIcon": { + "iconPath": "icons/icon.png", + "iconAsTemplate": true + } + }, + "plugins": { + "notification": { + "all": true + } + } +} diff --git a/app/src/index.html b/app/src/index.html new file mode 100644 index 0000000..89ccf5b --- /dev/null +++ b/app/src/index.html @@ -0,0 +1,30 @@ + + + + + + Claude Hub + + + +
+
+

Claude Hub

+ 就绪 +
+ +
+ +
+ +
+

最近通知

+
+
等待通知...
+
+
+
+ + + + diff --git a/app/src/main.js b/app/src/main.js new file mode 100644 index 0000000..7525272 --- /dev/null +++ b/app/src/main.js @@ -0,0 +1,128 @@ +// Claude Hub 状态面板 +// 通过 Tauri IPC 从 Rust 后端接收事件 + +const { listen } = window.__TAURI__.event; +const { invoke } = window.__TAURI__.core; + +// 状态存储 +const state = { + projects: {}, // { name: { status, message, time } } + events: [], // [{ type, project, message, timestamp }] +}; + +// 初始化 +async function init() { + // 监听来自 Rust 的事件 + await listen('hub-event', (event) => { + handleEvent(event.payload); + }); + + // 加载历史事件 + try { + const history = await invoke('get_events'); + history.forEach(handleEvent); + } catch (e) { + console.log('No history:', e); + } +} + +function handleEvent(data) { + // 更新项目状态 + state.projects[data.project] = { + status: data.type === 'stop' ? 'done' : 'permission', + message: data.message, + time: formatTime(data.timestamp), + }; + + // 添加到事件列表 + state.events.unshift(data); + if (state.events.length > 20) state.events.pop(); + + render(); +} + +function formatTime(ts) { + try { + const d = new Date(ts); + return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + } catch { + return ts; + } +} + +function render() { + renderProjects(); + renderEvents(); + updateGlobalStatus(); +} + +function renderProjects() { + const list = document.getElementById('projectList'); + const entries = Object.entries(state.projects); + + if (entries.length === 0) { + list.innerHTML = '
没有活跃项目
'; + return; + } + + list.innerHTML = entries.map(([name, info]) => ` +
+
+ ${name} + ${statusText(info.status)} + ${info.time} +
+ `).join(''); + + // 点击跳转 + list.querySelectorAll('.project-item').forEach(el => { + el.addEventListener('click', () => { + const project = el.dataset.project; + invoke('focus_project', { project }); + }); + }); +} + +function renderEvents() { + const list = document.getElementById('eventList'); + + if (state.events.length === 0) { + list.innerHTML = '
等待通知...
'; + return; + } + + list.innerHTML = state.events.map(e => ` +
+ ${formatTime(e.timestamp)} + ${e.type === 'stop' ? '●' : '⚠'} + ${e.project} ${e.message} +
+ `).join(''); +} + +function statusText(status) { + switch (status) { + case 'done': return '完成'; + case 'busy': return '工作中...'; + case 'permission': return '等待授权'; + default: return '空闲'; + } +} + +function updateGlobalStatus() { + const badge = document.getElementById('globalStatus'); + const statuses = Object.values(state.projects).map(p => p.status); + + if (statuses.includes('permission')) { + badge.textContent = '需要操作'; + badge.className = 'status-badge attention'; + } else if (statuses.includes('busy')) { + badge.textContent = '工作中'; + badge.className = 'status-badge busy'; + } else { + badge.textContent = '就绪'; + badge.className = 'status-badge'; + } +} + +init(); diff --git a/app/src/style.css b/app/src/style.css new file mode 100644 index 0000000..1bb20b0 --- /dev/null +++ b/app/src/style.css @@ -0,0 +1,167 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #1a1a2e; + color: #e0e0e0; + font-size: 13px; + overflow: hidden; + user-select: none; +} + +.container { + padding: 16px; + height: 100vh; + display: flex; + flex-direction: column; +} + +header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 1px solid #2a2a4a; +} + +header h1 { + font-size: 15px; + font-weight: 600; + color: #fff; +} + +.status-badge { + font-size: 11px; + padding: 2px 8px; + border-radius: 10px; + background: #2d4a3e; + color: #4ade80; +} + +.status-badge.busy { + background: #2d3a5e; + color: #60a5fa; +} + +.status-badge.attention { + background: #4a2d2d; + color: #f87171; +} + +/* 项目列表 */ +.projects { + flex: 1; + overflow-y: auto; + margin-bottom: 12px; +} + +.project-item { + display: flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; +} + +.project-item:hover { + background: #2a2a4a; +} + +.project-dot { + width: 8px; + height: 8px; + border-radius: 50%; + margin-right: 10px; + flex-shrink: 0; +} + +.project-dot.idle { background: #666; } +.project-dot.done { background: #4ade80; } +.project-dot.busy { background: #60a5fa; animation: pulse 1.5s infinite; } +.project-dot.permission { background: #f87171; animation: pulse 0.8s infinite; } + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.project-name { + flex: 1; + font-weight: 500; +} + +.project-status { + font-size: 11px; + color: #888; +} + +.project-time { + font-size: 11px; + color: #555; + margin-left: 8px; +} + +/* 通知列表 */ +.events { + border-top: 1px solid #2a2a4a; + padding-top: 12px; + max-height: 160px; + overflow-y: auto; +} + +.events h2 { + font-size: 12px; + color: #888; + margin-bottom: 8px; + font-weight: 500; +} + +.event-item { + display: flex; + align-items: center; + padding: 4px 0; + font-size: 12px; +} + +.event-time { + color: #555; + margin-right: 8px; + font-variant-numeric: tabular-nums; +} + +.event-icon { + margin-right: 6px; +} + +.event-icon.stop { color: #4ade80; } +.event-icon.permission { color: #f87171; } + +.event-message { + color: #ccc; +} + +.empty { + color: #555; + font-style: italic; + padding: 8px 0; +} + +/* 滚动条 */ +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #333; + border-radius: 2px; +} diff --git a/scripts/hub-hook-permission.sh b/scripts/hub-hook-permission.sh index 68750e0..6bd9a87 100755 --- a/scripts/hub-hook-permission.sh +++ b/scripts/hub-hook-permission.sh @@ -13,22 +13,35 @@ NAME="${NAME:-$(basename "$PWD" 2>/dev/null)}" HUB_DIR="$HOME/Documents/code/claude-hub" SOUNDS_DIR="$HUB_DIR/sounds" +SIGNAL_DIR="/tmp/hub-signals" +EVENTS_DIR="$SIGNAL_DIR/events" -# 随机选一条提醒语(桌面通知用) +# 随机选一条提醒语 MESSAGES=( - "${NAME} 需要你授权" - "${NAME} 在等你点确认" - "${NAME} 卡住了,需要你批准" - "快去 ${NAME},Claude 在等你" + "需要你授权" + "在等你点确认" + "卡住了,需要你批准" + "Claude 在等你" ) MSG="${MESSAGES[$((RANDOM % ${#MESSAGES[@]}))]}" -# 桌面通知(不同声音区分) -osascript -e "display notification \"${MSG}\" with title \"⚠️ 需要授权\" sound name \"Basso\"" 2>/dev/null +# 写 JSON 事件文件(供 Tauri app 读取) +mkdir -p "$EVENTS_DIR" +cat > "$EVENTS_DIR/$(date +%s%N 2>/dev/null || date +%s).json" << EOF +{ + "type": "permission", + "project": "${NAME}", + "message": "${MSG}", + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%H:%M:%S)" +} +EOF -# 语音播报(预生成的音频文件) -AUTH_FILES=("$SOUNDS_DIR"/auth*.aiff) -if [ ${#AUTH_FILES[@]} -gt 0 ]; then - SOUND="${AUTH_FILES[$((RANDOM % ${#AUTH_FILES[@]}))]}" - afplay "$SOUND" 2>/dev/null +# Fallback: 如果 Tauri app 没运行,用 osascript +if ! pgrep -x "Claude Hub" >/dev/null 2>&1 && ! pgrep -x "claude-hub" >/dev/null 2>&1; then + osascript -e "display notification \"${NAME} ${MSG}\" with title \"⚠️ 需要授权\" sound name \"Basso\"" 2>/dev/null + AUTH_FILES=("$SOUNDS_DIR"/auth*.aiff) + if [ ${#AUTH_FILES[@]} -gt 0 ]; then + SOUND="${AUTH_FILES[$((RANDOM % ${#AUTH_FILES[@]}))]}" + afplay "$SOUND" 2>/dev/null + fi fi diff --git a/scripts/hub-hook-stop.sh b/scripts/hub-hook-stop.sh index c74ed17..bb083b0 100755 --- a/scripts/hub-hook-stop.sh +++ b/scripts/hub-hook-stop.sh @@ -14,31 +14,43 @@ NAME="${NAME:-$(basename "$PWD" 2>/dev/null)}" HUB_DIR="$HOME/Documents/code/claude-hub" SOUNDS_DIR="$HUB_DIR/sounds" +SIGNAL_DIR="/tmp/hub-signals" +EVENTS_DIR="$SIGNAL_DIR/events" -# 随机选一条完成语(桌面通知用) +# 随机选一条完成语 MESSAGES=( - "${NAME} 搞定了,来看看" - "${NAME} 干完了,等你验收" - "${NAME} 这边好了" - "${NAME} 完事儿了" - "${NAME} 做好了,请过目" + "搞定了,来看看" + "干完了,等你验收" + "这边好了" + "完事儿了" + "做好了,请过目" ) -IDX=$((RANDOM % ${#MESSAGES[@]})) -MSG="${MESSAGES[$IDX]}" +MSG="${MESSAGES[$((RANDOM % ${#MESSAGES[@]}))]}" -# 桌面通知 -osascript -e "display notification \"${MSG}\" with title \"🤖 调度中心\" sound name \"Glass\"" 2>/dev/null +# 写 JSON 事件文件(供 Tauri app 读取) +mkdir -p "$EVENTS_DIR" +cat > "$EVENTS_DIR/$(date +%s%N 2>/dev/null || date +%s).json" << EOF +{ + "type": "stop", + "project": "${NAME}", + "message": "${MSG}", + "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date +%H:%M:%S)" +} +EOF -# 语音播报(预生成的音频文件,不会被杀) -DONE_FILES=("$SOUNDS_DIR"/done*.aiff) -if [ ${#DONE_FILES[@]} -gt 0 ]; then - SOUND="${DONE_FILES[$((RANDOM % ${#DONE_FILES[@]}))]}" - afplay "$SOUND" 2>/dev/null +# Fallback: 如果 Tauri app 没运行,用 osascript +if ! pgrep -x "Claude Hub" >/dev/null 2>&1 && ! pgrep -x "claude-hub" >/dev/null 2>&1; then + osascript -e "display notification \"${NAME} ${MSG}\" with title \"🤖 调度中心\" sound name \"Glass\"" 2>/dev/null + # 语音播报 + DONE_FILES=("$SOUNDS_DIR"/done*.aiff) + if [ ${#DONE_FILES[@]} -gt 0 ]; then + SOUND="${DONE_FILES[$((RANDOM % ${#DONE_FILES[@]}))]}" + afplay "$SOUND" 2>/dev/null + fi fi # hub tmux 信号(供程序化监控) if [ "$TMUX_SESSION" = "hub" ] && [ -n "$NAME" ]; then - SIGNAL_DIR="/tmp/hub-signals" mkdir -p "$SIGNAL_DIR" echo "$(date '+%H:%M:%S') $NAME done" > "$SIGNAL_DIR/hub-${NAME}-stop.result" tmux wait-for -S "hub-${NAME}-stop" 2>/dev/null