diff --git a/.env.example b/.env.example index dc7ea7d..4ffd29d 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,12 @@ NOTIFICATION_ENABLED=true # 是否启用声音提醒 (true/false) SOUND_ENABLED=true +# macOS 原生通知(仅 macOS 生效) +# 任务完成时弹出通知中心横幅并播放提示音,默认开启,无需额外配置 +# MAC_NOTIFICATION_ENABLED=false +# 提示音名称,见 /System/Library/Sounds(如 Glass、Ping、Hero、Submarine) +# MAC_NOTIFICATION_SOUND=Glass + # Telegram Bot配置 # 获取方法: # 1. 与 @BotFather 对话创建机器人,获取 token diff --git a/README.md b/README.md index 19803f5..f902d2b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Claude Code 完成任务时自动发通知到手机/手环,你不用一直盯着屏幕等。 -支持飞书 Webhook、Telegram Bot、Windows 声音提醒。 +支持飞书 Webhook、Telegram Bot、macOS 原生通知、Windows 声音提醒。 ## 一句话配置 @@ -23,6 +23,12 @@ node notify-system.js --task "测试通知" 飞书 Webhook 获取:群设置 → 群机器人 → 添加自定义机器人 → 复制地址。 +**macOS 原生通知**(弹横幅+提示音,默认开启,无需 webhook)需先安装一次: + +```bash +brew install terminal-notifier +``` + 配置 Claude Code Hook,在 `~/.claude/settings.json` 中添加: ```json @@ -31,9 +37,29 @@ node notify-system.js --task "测试通知" "Stop": [{ "hooks": [{ "type": "command", - "command": "node /你的路径/ccdd/notify-system.js" + "command": "node /your_path/ccdd/notify-system.js" }] - }] + }], + "Notification": [ + { + "matcher": "permission_prompt", + "hooks": [ + { + "type": "command", + "command": "node /your_path/ccdd/notify-system.js" + } + ] + }, + { + "matcher": "agent_needs_input", + "hooks": [ + { + "type": "command", + "command": "node /your_path/ccdd/notify-system.js" + } + ] + } + ] } } ``` diff --git a/SETUP.md b/SETUP.md index eef86fc..b5d1ad5 100644 --- a/SETUP.md +++ b/SETUP.md @@ -28,8 +28,26 @@ TELEGRAM_CHAT_ID=你的chat_id 默认开启,仅支持 Windows。不需要的话设 `SOUND_ENABLED=false`。 +## macOS 原生通知 + +macOS 上默认开启,任务完成时弹出通知中心横幅并播放提示音,无需配置 webhook。 + +需要先安装 `terminal-notifier`(一次性): + +```bash +brew install terminal-notifier +``` + +首次运行时若 macOS 弹出「"terminal-notifier" 想要发送通知」,点**允许**即可。 +如果只进通知中心不弹横幅,去 **系统设置 → 通知 → terminal-notifier** 把样式设为**横幅/提醒**。 + +可选配置: +- `MAC_NOTIFICATION_ENABLED=false` — 关闭原生通知 +- `MAC_NOTIFICATION_SOUND=Hero` — 换提示音(见 `/System/Library/Sounds`,如 Glass/Ping/Hero/Submarine) + ## 故障排除 - 飞书收不到:检查 webhook 地址是否完整复制了 - 手环不震:确认飞书通知权限开着,手环和手机蓝牙连着 - 声音不响:Windows only,检查 PowerShell 能否正常运行 +- macOS 没横幅/没声音:先确认已 `brew install terminal-notifier`;再去 **系统设置 → 通知 → terminal-notifier** 开启通知并设为横幅样式(osascript 的通知会被 Script Editor 权限静默丢弃,故本项目改用 terminal-notifier) diff --git a/env-config.js b/env-config.js index 2a25787..cc1581b 100644 --- a/env-config.js +++ b/env-config.js @@ -62,14 +62,26 @@ class EnvConfig { /** * 获取声音通知配置 + * Windows 声音基于 PowerShell,在 macOS 上无效,由 mac 原生通知接管声音 */ getSoundConfig() { return { - enabled: process.env.SOUND_ENABLED !== 'false', + enabled: process.platform !== 'darwin' && process.env.SOUND_ENABLED !== 'false', backup: true }; } + /** + * 获取 macOS 原生通知配置 + * 仅在 macOS 平台生效,默认开启,可通过 MAC_NOTIFICATION_ENABLED=false 关闭 + */ + getMacConfig() { + return { + enabled: process.platform === 'darwin' && process.env.MAC_NOTIFICATION_ENABLED !== 'false', + sound: process.env.MAC_NOTIFICATION_SOUND || 'Glass' + }; + } + /** * 获取通用通知配置 */ @@ -87,6 +99,7 @@ class EnvConfig { feishu: this.getFeishuConfig(), telegram: this.getTelegramConfig(), sound: this.getSoundConfig(), + mac: this.getMacConfig(), notification: this.getNotificationConfig() }; } diff --git a/notification-manager.js b/notification-manager.js index 2bd27f5..2f6faed 100644 --- a/notification-manager.js +++ b/notification-manager.js @@ -5,6 +5,7 @@ const { FeishuNotifier } = require('./feishu-notify'); const { TelegramNotifier } = require('./telegram-notify'); +const { MacNotifier } = require('./notify-mac'); /** * 通知管理器类 @@ -46,6 +47,18 @@ class NotificationManager { }; } + // macOS 原生通知器 + if (this.config.notification.mac && this.config.notification.mac.enabled) { + notifiers.mac = { + enabled: true, + notifier: new MacNotifier(this.config.notification.mac.sound), + send: async (taskInfo) => { + const { notifyTaskCompletion } = require('./notify-mac'); + return await notifyTaskCompletion(taskInfo, this.projectName, this.config.notification.mac.sound); + } + }; + } + return notifiers; } @@ -97,6 +110,7 @@ class NotificationManager { const typeNames = { feishu: '飞书通知', telegram: 'Telegram通知', + mac: 'macOS通知', sound: '声音提醒' }; return typeNames[type] || type; @@ -114,7 +128,7 @@ class NotificationManager { const typeName = this.getTypeName(type); const result = results[index]; const status = result && result.value && result.value.success ? '✅ 成功' : '❌ 失败'; - const icon = type === 'feishu' ? '📱' : type === 'telegram' ? '📲' : '🔊'; + const icon = type === 'feishu' ? '📱' : type === 'telegram' ? '📲' : type === 'mac' ? '💻' : '🔊'; console.log(` ${icon} ${typeName}:${status}`); }); @@ -127,6 +141,9 @@ class NotificationManager { if (this.notifiers.telegram) { console.log(' 📲 Telegram将收到推送通知'); } + if (this.notifiers.mac) { + console.log(' 💻 macOS 将弹出通知横幅并播放提示音'); + } console.log(''); } @@ -137,6 +154,7 @@ class NotificationManager { const icons = []; if (this.notifiers.feishu) icons.push('📱'); if (this.notifiers.telegram) icons.push('📲'); + if (this.notifiers.mac) icons.push('💻'); return icons.join(' '); } } diff --git a/notify-mac.js b/notify-mac.js new file mode 100644 index 0000000..b8ee2c7 --- /dev/null +++ b/notify-mac.js @@ -0,0 +1,123 @@ +/** + * macOS 原生通知脚本 + * 通过 terminal-notifier 投递系统通知,弹出横幅并播放系统提示音 + * 仅在 macOS (darwin) 平台生效 + * + * 为什么用 terminal-notifier 而非 osascript: + * osascript 的 `display notification` 把通知归属给 "Script Editor" 宿主 App, + * 一旦 Script Editor 通知权限被关,通知会被系统静默丢弃且退出码仍为 0(假成功)。 + * terminal-notifier 以自身身份投递,权限清晰,后台 hook 触发也可靠。 + */ + +const { spawn } = require('child_process'); + +/** + * macOS 通知器 + */ +class MacNotifier { + /** + * 构造函数 + * @param {string} sound - 系统提示音名称,见 /System/Library/Sounds(如 Glass、Ping、Hero),或 'default' + */ + constructor(sound = 'Glass') { + this.sound = sound; + } + + /** + * 折叠换行为空格并截断,避免通知内容过长 + * @param {string} str - 原始字符串 + * @param {number} max - 最大长度 + * @returns {string} + */ + clean(str, max) { + return String(str).replace(/[\r\n]+/g, ' ').trim().slice(0, max); + } + + /** + * 弹出一条 macOS 通知 + * 参数经 spawn 数组直传 argv,不经过 shell,天然免注入,无需转义 + * @param {string} title - 通知标题 + * @param {string} body - 通知正文 + * @returns {Promise} 发送是否成功 + */ + notify(title, body) { + const safeTitle = this.clean(title, 120) || 'Claude Code'; + const safeBody = this.clean(body, 400) || '任务完成'; + + return new Promise((resolve) => { + const proc = spawn('terminal-notifier', [ + '-title', safeTitle, + '-message', safeBody, + '-sound', this.sound + ], { stdio: 'ignore' }); + + proc.on('error', (error) => { + console.error('❌ macOS 通知发送失败(terminal-notifier 未安装?请执行 brew install terminal-notifier):', error.message); + resolve(false); + }); + + proc.on('close', (code) => { + if (code === 0) { + console.log('✅ macOS 原生通知已弹出'); + resolve(true); + } else { + console.error(`❌ macOS 通知发送失败 (退出码 ${code})`); + resolve(false); + } + }); + }); + } +} + +/** + * 任务完成通知函数 + * @param {string} taskInfo - 任务信息 + * @param {string} projectName - 项目名称 + * @param {string} sound - 系统提示音名称 + * @returns {Promise} 发送是否成功 + */ +async function notifyTaskCompletion(taskInfo = 'Claude Code任务已完成', projectName = '', sound = 'Glass') { + if (process.platform !== 'darwin') { + console.log('⏭️ 非 macOS 平台,跳过原生通知'); + return false; + } + + const notifier = new MacNotifier(sound); + const title = projectName || 'Claude Code'; + return await notifier.notify(title, taskInfo); +} + +/** + * 获取命令行参数 + */ +function getCommandLineArgs() { + const args = process.argv.slice(2); + const options = {}; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + const value = args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : true; + options[key] = value; + if (value !== true) i++; + } + } + + return options; +} + +// 如果直接运行此脚本 +if (require.main === module) { + const options = getCommandLineArgs(); + const taskInfo = options.message || options.task || 'Claude Code任务已完成'; + const projectName = options.project || ''; + + console.log('🚀 发送 macOS 原生通知...'); + notifyTaskCompletion(taskInfo, projectName); +} + +module.exports = { + MacNotifier, + notifyTaskCompletion +}; diff --git a/notify-system.js b/notify-system.js index a18559d..81cdfb2 100644 --- a/notify-system.js +++ b/notify-system.js @@ -29,6 +29,7 @@ class NotificationSystem { type: envVars.feishu.enabled ? 'feishu' : 'sound', feishu: envVars.feishu, telegram: envVars.telegram, + mac: envVars.mac, sound: envVars.sound } }; @@ -229,11 +230,18 @@ function buildMessageFromContext(options) { return options.message || options.task; } - // 2. 尝试从 stdin 读取 Claude Stop hook 的 JSON 上下文 + // 2. 尝试从 stdin 读取 Claude hook 的 JSON 上下文 const stdin = readStdinSync(); if (stdin) { try { const ctx = JSON.parse(stdin); + + // Notification hook:Claude 需要权限确认或等待输入,直接透传通知文案 + if (ctx.hook_event_name === 'Notification' && ctx.message) { + return ctx.message; + } + + // Stop hook:从最后一条消息提取任务摘要 if (ctx.last_assistant_message) { const text = ctx.last_assistant_message .split('\n')