feat: Tauri 桌面通知 app + 状态面板

- app/ 目录:Tauri v2 项目(Rust + HTML/JS)
- 托盘图标 + 点击显示状态面板
- 监听 /tmp/hub-signals/events/ 的 JSON 事件
- 系统原生通知,点击跳转到对应 tmux 窗口
- hooks 改为写 JSON 事件文件,Tauri 没运行时 fallback 到 osascript

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
wuguanganandClaude Opus 4.6 committed 2026-03-17 09:59:04 +08:00
1 parent f92763f8fb
commit 5403bdad1f
13 files changed
+685 -28

No files matched your search

+2
View File
@@ -6,3 +6,5 @@ memory/*.md
node_modules/
sounds/
projects.yaml
app/src-tauri/target/
app/node_modules/
+10
View File
@@ -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"
}
}
+17
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 156 B

+120
View File
@@ -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<Vec<HubEvent>>,
}
// 前端调用:获取历史事件
#[tauri::command]
fn get_events(state: tauri::State<AppState>) -> Vec<HubEvent> {
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");
}
+123
View File
@@ -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::<AppState>() {
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();
}
}
}
}
+32
View File
@@ -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
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Hub</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<h1>Claude Hub</h1>
<span class="status-badge" id="globalStatus">就绪</span>
</header>
<section class="projects" id="projectList">
<!-- 动态填充 -->
</section>
<section class="events">
<h2>最近通知</h2>
<div id="eventList">
<div class="empty">等待通知...</div>
</div>
</section>
</div>
<script src="main.js"></script>
</body>
</html>
+128
View File
@@ -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 = '<div class="empty">没有活跃项目</div>';
return;
}
list.innerHTML = entries.map(([name, info]) => `
<div class="project-item" data-project="${name}">
<div class="project-dot ${info.status}"></div>
<span class="project-name">${name}</span>
<span class="project-status">${statusText(info.status)}</span>
<span class="project-time">${info.time}</span>
</div>
`).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 = '<div class="empty">等待通知...</div>';
return;
}
list.innerHTML = state.events.map(e => `
<div class="event-item">
<span class="event-time">${formatTime(e.timestamp)}</span>
<span class="event-icon ${e.type}">${e.type === 'stop' ? '●' : '⚠'}</span>
<span class="event-message">${e.project} ${e.message}</span>
</div>
`).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();
+167
View File
@@ -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;
}
+25 -12
View File
@@ -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
+28 -16
View File
@@ -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