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:
1 parent
f92763f8fb
commit
5403bdad1f
13 files changed
+685
-28
No files matched your search
@@ -6,3 +6,5 @@ memory/*.md
|
||||
node_modules/
|
||||
sounds/
|
||||
projects.yaml
|
||||
app/src-tauri/target/
|
||||
app/node_modules/
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 156 B |
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user