dsh-macos-notify 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/client.js +204 -0
- package/cordis.patch.yml +3 -0
- package/index.js +858 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CrombastiC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# dsh-macos-notify
|
|
2
|
+
|
|
3
|
+
Native macOS notifications for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), with event-specific sounds, notification filtering, multi-task coalescing, and a settings card in the DSH Web UI.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Notification Center alerts when a turn completes, fails, is blocked, or waits for approval.
|
|
8
|
+
- Separate sounds for completed, error, aborted, and approval events; any event can be muted.
|
|
9
|
+
- System sound picker plus managed custom sound import and deletion from the Web settings card.
|
|
10
|
+
- Custom imports are converted to AIFF, limited to 5 MB and 10 seconds, capped at 20 managed files / 50 MB total, and stored in `~/Library/Sounds`.
|
|
11
|
+
- A 1.5-second coalescing window and optional digest mode prevent parallel tasks from flooding Notification Center.
|
|
12
|
+
- Recent notification diagnostics explain whether an event was sent, queued, suppressed, or failed.
|
|
13
|
+
- A six-event test matrix validates completed, error, approval, aborted, coalesced, and digest notifications.
|
|
14
|
+
- Daily quiet hours and temporary 30-minute, 1-hour, or 24-hour pauses.
|
|
15
|
+
- Duplicate error suppression with a configurable cooldown window.
|
|
16
|
+
- Project path rules for muting, error-only alerts, or important-project bypasses.
|
|
17
|
+
- Minimum turn-duration filtering avoids notifications for near-instant replies.
|
|
18
|
+
- Optional Web-tab focus suppression and macOS HID idle-time gating.
|
|
19
|
+
- Dedicated wording for API rate limits, including provider retry timing when available.
|
|
20
|
+
- OSC 9 notifications for supported terminals, including tmux DCS passthrough.
|
|
21
|
+
- Live settings updates without restarting DSH.
|
|
22
|
+
|
|
23
|
+
## Requirements
|
|
24
|
+
|
|
25
|
+
- macOS
|
|
26
|
+
- Node.js 22 or newer
|
|
27
|
+
- DeepSeek Harness with the `web` profile
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Install the published npm package:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx -y @deepseek-ai/dsh plugin --profile web add dsh-macos-notify
|
|
35
|
+
npx -y @deepseek-ai/dsh web
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Alternatively, install the latest source directly from GitHub:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx -y @deepseek-ai/dsh plugin --profile web add github:CrombastiC/dsh-macos-notify
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Then open **Settings → Plugins → Plugin configuration → macOS notifications**.
|
|
45
|
+
|
|
46
|
+
To remove the plugin:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npx -y @deepseek-ai/dsh plugin --profile web remove dsh-macos-notify
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Local development install
|
|
53
|
+
|
|
54
|
+
From this repository checkout:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx -y @deepseek-ai/dsh plugin --profile web add .
|
|
58
|
+
npx -y @deepseek-ai/dsh web
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`dsh plugin` anchors relative paths to the invoking directory and forwards the install to pnpm in the selected profile. Avoid absolute-path bundle overlays in `cordis.dev.yml`; install the local package through `plugin add` instead.
|
|
62
|
+
|
|
63
|
+
## Configuration
|
|
64
|
+
|
|
65
|
+
The settings namespace is `macos-notify`. Values changed from the Web card apply live.
|
|
66
|
+
|
|
67
|
+
| Option | Default | Description |
|
|
68
|
+
| --- | --- | --- |
|
|
69
|
+
| `onCompleted` | `true` | Notify when a turn completes normally. |
|
|
70
|
+
| `onError` | `true` | Notify for errors and blocked turns. These alerts bypass completion gates and digesting. |
|
|
71
|
+
| `onAborted` | `false` | Notify when a user aborts a turn. |
|
|
72
|
+
| `onApproval` | `true` | Notify immediately when a tool waits for approval. |
|
|
73
|
+
| `minDurationSec` | `30` | Suppress completed-turn notifications shorter than this many seconds; `0` disables the threshold. |
|
|
74
|
+
| `onlyWhenIdleSec` | `0` | Require this many seconds of keyboard/mouse idle time for completed notifications; `0` disables idle gating. |
|
|
75
|
+
| `onlyWhenUnfocused` | `true` | Suppress completed notifications while any DSH Web tab is focused. |
|
|
76
|
+
| `digestMinutes` | `0` | Collect completed notifications into a periodic digest; `0` sends them immediately. |
|
|
77
|
+
| `includeSubagents` | `false` | Include subagent sessions instead of notifying only for top-level sessions. |
|
|
78
|
+
| `channel` | `auto` | `auto`, `osascript`, or `osc9`. |
|
|
79
|
+
| `sounds.completed` | `Glass` | Sound for completed turns. |
|
|
80
|
+
| `sounds.error` | `Basso` | Sound for errors, blocked turns, and rate limits. |
|
|
81
|
+
| `sounds.aborted` | empty | Sound for aborted turns; empty means silent. |
|
|
82
|
+
| `sounds.approval` | `Ping` | Sound for approval requests. |
|
|
83
|
+
| `coalesceMs` | `1500` | Window for merging simultaneous turn results; `0` disables merging. |
|
|
84
|
+
| `quietHoursEnabled` | `false` | Enable the daily local-time quiet period. |
|
|
85
|
+
| `quietStart` | `23:00` | Quiet-period start in local `HH:mm` time. |
|
|
86
|
+
| `quietEnd` | `08:00` | Quiet-period end in local `HH:mm` time. Overnight ranges are supported. |
|
|
87
|
+
| `quietAllowCritical` | `true` | Continue sending error, blocked, and approval alerts during quiet hours. |
|
|
88
|
+
| `pauseUntil` | `0` | Temporary pause deadline as Unix milliseconds; managed by quick actions in the Web card. |
|
|
89
|
+
| `duplicateWindowSec` | `300` | Suppress identical errors from the same session within this window; `0` disables it. |
|
|
90
|
+
| `projectRulesJson` | `[]` | Project rules managed by the Web card. More-specific descendant paths win. |
|
|
91
|
+
| `notifyOnLoad` | `true` | Send a test notification when the plugin loads. |
|
|
92
|
+
|
|
93
|
+
### Project rule modes
|
|
94
|
+
|
|
95
|
+
- `mute` — suppress every notification under the configured project path.
|
|
96
|
+
- `errors` — allow only errors, blocked events, and approval requests.
|
|
97
|
+
- `important` — bypass minimum-duration, focus, idle, quiet-hour, and temporary-pause filters.
|
|
98
|
+
|
|
99
|
+
The settings card keeps the last 50 decisions in process memory. Each row records whether an event was sent, queued, suppressed, or failed and includes the reason. This history resets when DSH restarts and does not contain message content.
|
|
100
|
+
|
|
101
|
+
## Notification channels
|
|
102
|
+
|
|
103
|
+
`auto` uses OSC 9 when the terminal is recognized as supporting it, and falls back to `osascript` otherwise.
|
|
104
|
+
|
|
105
|
+
Recognized OSC 9 terminals include iTerm2, WezTerm, Kitty, Ghostty, and Warp. tmux sessions are wrapped in DCS passthrough automatically.
|
|
106
|
+
|
|
107
|
+
Sound selection applies to the `osascript` channel. With OSC 9, the terminal controls whether and how a notification sound is played.
|
|
108
|
+
|
|
109
|
+
## Custom sounds
|
|
110
|
+
|
|
111
|
+
In the settings card, select **Edit → Import sound**. Supported inputs include AAC, AIFF, CAF, FLAC, M4A, MP3, OGG, Opus, and WAV.
|
|
112
|
+
|
|
113
|
+
The host validates the extension, decoded size, converted duration, managed-file count, and total managed size before writing anything to the user sound directory. Imports are converted to 44.1 kHz mono AIFF using macOS `afconvert`, with `ffmpeg` as a fallback for formats that `afconvert` cannot decode. Existing files are never overwritten; a numeric suffix is added instead.
|
|
114
|
+
|
|
115
|
+
New imports are recorded in `~/Library/Application Support/dsh-macos-notify/sounds.json` and can be deleted from the settings card. If a sound is currently selected, deletion asks for confirmation and changes affected events to silent. Files remain in `~/Library/Sounds` if the plugin is removed without deleting them first.
|
|
116
|
+
|
|
117
|
+
## Known limitations
|
|
118
|
+
|
|
119
|
+
- The plugin is macOS-only. Native notifications use `osascript`, and custom import uses macOS audio tooling.
|
|
120
|
+
- OSC 9 sound behavior belongs to the terminal and ignores the per-event sound selection.
|
|
121
|
+
- The Web settings card uses the trusted `/macos-notify` RPC channel because the current DSH Web settings proxy has a namespace allowlist for built-in settings.
|
|
122
|
+
- Only sounds imported by v0.2.0 or later are tracked as managed sounds. Earlier manually copied/imported files can still be selected, but must be removed from `~/Library/Sounds` manually.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
The package is intentionally build-free:
|
|
127
|
+
|
|
128
|
+
- `index.js` — host plugin, event handling, notification delivery, settings RPC, and sound import.
|
|
129
|
+
- `client.js` — hand-written DSH client module for focus reporting and the Web settings card.
|
|
130
|
+
- `cordis.patch.yml` — profile bundle patch.
|
|
131
|
+
|
|
132
|
+
Run the release checks:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
node --check index.js
|
|
136
|
+
node --check client.js
|
|
137
|
+
npm test
|
|
138
|
+
npm pack --dry-run
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## 中文说明
|
|
142
|
+
|
|
143
|
+
这是一个仅支持 macOS 的 DeepSeek Harness 通知插件。它可以在任务完成、出错、等待审批时发送系统通知,并支持通知诊断、测试矩阵、每日勿扰、临时暂停、自定义声音管理、重复错误抑制、项目规则、焦点抑制、合并通知和定时汇总。推荐从 npm 安装,也可以直接从 GitHub 安装最新版源码。
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
[MIT](LICENSE) © 2026 CrombastiC
|
package/client.js
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// dsh-macos-notify Web UI: focus reporting, settings, sound management and diagnostics.
|
|
2
|
+
window.__ModuleLoader__.load({
|
|
3
|
+
id: 'dsh-macos-notify',
|
|
4
|
+
factory: (require) => {
|
|
5
|
+
var module = { exports: {} }
|
|
6
|
+
var exports = module.exports
|
|
7
|
+
var React = require('react')
|
|
8
|
+
var h = React.createElement
|
|
9
|
+
|
|
10
|
+
var SOUND_ROWS = [
|
|
11
|
+
['completed', '任务完成'], ['error', '出错 / 被阻止'],
|
|
12
|
+
['aborted', '任务中断'], ['approval', '等待审批'],
|
|
13
|
+
]
|
|
14
|
+
var FALLBACK_SOUNDS = ['Basso', 'Blow', 'Bottle', 'Frog', 'Funk', 'Glass', 'Hero', 'Morse', 'Ping', 'Pop', 'Purr', 'Sosumi', 'Submarine', 'Tink']
|
|
15
|
+
var RULE_LABELS = { mute: '全部静音', errors: '仅错误与审批', important: '重要项目(忽略过滤)' }
|
|
16
|
+
var KIND_LABELS = { completed: '完成', error: '错误', aborted: '中断', approval: '审批' }
|
|
17
|
+
var inputStyle = { width: '150px', padding: '5px 8px', fontSize: '12px', boxSizing: 'border-box', border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '6px', background: 'transparent', color: 'inherit' }
|
|
18
|
+
var buttonStyle = { padding: '5px 10px', minHeight: '29px', fontSize: '12px', cursor: 'pointer', border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '6px', background: 'transparent', color: 'inherit' }
|
|
19
|
+
var sectionStyle = { borderTop: '1px solid var(--dsw-alias-border-l2,#444)', paddingTop: '12px', display: 'flex', flexDirection: 'column', gap: '8px' }
|
|
20
|
+
var rowStyle = { display: 'flex', alignItems: 'center', gap: '9px', minHeight: '30px' }
|
|
21
|
+
var css = [
|
|
22
|
+
'@keyframes dshSaved{0%{box-shadow:0 0 0 0 rgba(46,157,98,.3);border-color:rgba(46,157,98,.7)}100%{box-shadow:0 0 0 5px rgba(46,157,98,0)}}',
|
|
23
|
+
'@keyframes dshShake{0%,100%{transform:translateX(0)}30%{transform:translateX(-3px)}70%{transform:translateX(3px)}}',
|
|
24
|
+
'.dsh-notify-saved{animation:dshSaved 650ms ease-out both}.dsh-notify-error{animation:dshShake 240ms ease-out both}',
|
|
25
|
+
'@media(prefers-reduced-motion:reduce){.dsh-notify-saved,.dsh-notify-error{animation:none!important}}',
|
|
26
|
+
].join('\n')
|
|
27
|
+
|
|
28
|
+
function parseRules(raw) {
|
|
29
|
+
try {
|
|
30
|
+
var value = JSON.parse(raw || '[]')
|
|
31
|
+
return Array.isArray(value) ? value.map(function (rule) { return { path: String(rule.path || ''), mode: String(rule.mode || 'mute') } }) : []
|
|
32
|
+
} catch { return [] }
|
|
33
|
+
}
|
|
34
|
+
function toDraft(settings) {
|
|
35
|
+
return Object.assign({}, settings, { sounds: Object.assign({}, settings.sounds || {}), projectRules: parseRules(settings.projectRulesJson) })
|
|
36
|
+
}
|
|
37
|
+
function toPatch(draft) {
|
|
38
|
+
return {
|
|
39
|
+
onCompleted: !!draft.onCompleted, onError: !!draft.onError, onAborted: !!draft.onAborted, onApproval: !!draft.onApproval,
|
|
40
|
+
minDurationSec: Math.max(0, Number(draft.minDurationSec) || 0), onlyWhenIdleSec: Math.max(0, Number(draft.onlyWhenIdleSec) || 0),
|
|
41
|
+
onlyWhenUnfocused: !!draft.onlyWhenUnfocused, digestMinutes: Math.max(0, Number(draft.digestMinutes) || 0),
|
|
42
|
+
includeSubagents: !!draft.includeSubagents, channel: draft.channel || 'auto', sounds: Object.assign({}, draft.sounds),
|
|
43
|
+
coalesceMs: Math.max(0, Number(draft.coalesceMs) || 0), quietHoursEnabled: !!draft.quietHoursEnabled,
|
|
44
|
+
quietStart: draft.quietStart || '23:00', quietEnd: draft.quietEnd || '08:00', quietAllowCritical: !!draft.quietAllowCritical,
|
|
45
|
+
pauseUntil: Number(draft.pauseUntil) || 0, duplicateWindowSec: Math.max(0, Number(draft.duplicateWindowSec) || 0),
|
|
46
|
+
projectRulesJson: JSON.stringify((draft.projectRules || []).filter(function (rule) { return rule.path.trim() }).map(function (rule) { return { path: rule.path.trim(), mode: rule.mode } })),
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function SectionTitle(props) {
|
|
50
|
+
return h('div', { style: { display: 'flex', justifyContent: 'space-between', gap: '8px' } },
|
|
51
|
+
h('strong', null, props.title), props.note ? h('span', { style: { opacity: .55, fontSize: '11px', textAlign: 'right' } }, props.note) : null)
|
|
52
|
+
}
|
|
53
|
+
function Toggle(props) {
|
|
54
|
+
return h('label', { style: rowStyle }, h('input', { type: 'checkbox', checked: !!props.value, disabled: props.disabled, onChange: function (e) { props.onChange(e.target.checked) } }), h('span', { style: { flex: 1 } }, props.label))
|
|
55
|
+
}
|
|
56
|
+
function SettingRow(props) {
|
|
57
|
+
return h('label', { style: rowStyle }, h('span', { style: { flex: 1 } }, props.label, props.note ? h('small', { style: { display: 'block', opacity: .5 } }, props.note) : null), props.children)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function Card(props) {
|
|
61
|
+
var settingsState = React.useState(null), settings = settingsState[0], setSettings = settingsState[1]
|
|
62
|
+
var draftState = React.useState(null), draft = draftState[0], setDraft = draftState[1]
|
|
63
|
+
var catalogState = React.useState({ names: [], managed: [], limits: {} }), catalog = catalogState[0], setCatalog = catalogState[1]
|
|
64
|
+
var diagState = React.useState({ entries: [], status: {} }), diagnostics = diagState[0], setDiagnostics = diagState[1]
|
|
65
|
+
var editingState = React.useState(false), editing = editingState[0], setEditing = editingState[1]
|
|
66
|
+
var savingState = React.useState(false), saving = savingState[0], setSaving = savingState[1]
|
|
67
|
+
var savedState = React.useState(false), saved = savedState[0], setSaved = savedState[1]
|
|
68
|
+
var importingState = React.useState(false), importing = importingState[0], setImporting = importingState[1]
|
|
69
|
+
var testingState = React.useState(''), testing = testingState[0], setTesting = testingState[1]
|
|
70
|
+
var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
|
|
71
|
+
var fileInput = React.useRef(null), savedTimer = React.useRef(null), testTimer = React.useRef(null)
|
|
72
|
+
|
|
73
|
+
var loadSettings = function () { return props.call('settings', { op: 'get' }).then(function (value) { setSettings(value); setDraft(toDraft(value)); return value }) }
|
|
74
|
+
var loadCatalog = function () { return props.call('sounds', {}).then(function (value) { if (value && Array.isArray(value.names)) setCatalog(value); return value }) }
|
|
75
|
+
var loadDiagnostics = function () { return props.call('diagnostics', { op: 'get' }).then(setDiagnostics).catch(function () {}) }
|
|
76
|
+
React.useEffect(function () {
|
|
77
|
+
Promise.all([loadSettings(), loadCatalog(), loadDiagnostics()]).catch(function (err) { setError(String(err && err.message || '设置读取失败')) })
|
|
78
|
+
var timer = setInterval(loadDiagnostics, 5000)
|
|
79
|
+
return function () { clearInterval(timer); if (savedTimer.current) clearTimeout(savedTimer.current); if (testTimer.current) clearTimeout(testTimer.current) }
|
|
80
|
+
}, [])
|
|
81
|
+
if (!settings || !draft) return h('div', { style: { padding: '12px', opacity: .6 } }, '加载中…')
|
|
82
|
+
|
|
83
|
+
var patch = toPatch(draft), dirty = JSON.stringify(patch) !== JSON.stringify(toPatch(toDraft(settings)))
|
|
84
|
+
var status = diagnostics.status || {}, pauseActive = Number(status.pauseUntil) > Date.now()
|
|
85
|
+
var availableSounds = catalog.names.length ? catalog.names : FALLBACK_SOUNDS
|
|
86
|
+
var change = function (field, value) { setDraft(Object.assign({}, draft, { [field]: value })) }
|
|
87
|
+
var changeSound = function (kind, value) { setDraft(Object.assign({}, draft, { sounds: Object.assign({}, draft.sounds, { [kind]: value }) })) }
|
|
88
|
+
var beginEdit = function () { setDraft(toDraft(settings)); setEditing(true); setSaved(false); setError('') }
|
|
89
|
+
var cancel = function () { setDraft(toDraft(settings)); setEditing(false); setError('') }
|
|
90
|
+
var save = function () {
|
|
91
|
+
if (!dirty || saving) return
|
|
92
|
+
setSaving(true); setError('')
|
|
93
|
+
props.call('settings', { op: 'patch', value: patch }).then(function (value) {
|
|
94
|
+
setSettings(value); setDraft(toDraft(value)); setEditing(false); setSaved(true); loadDiagnostics()
|
|
95
|
+
if (savedTimer.current) clearTimeout(savedTimer.current)
|
|
96
|
+
savedTimer.current = setTimeout(function () { setSaved(false) }, 1800)
|
|
97
|
+
}).catch(function (err) { setError(String(err && err.message || '保存失败')) }).finally(function () { setSaving(false) })
|
|
98
|
+
}
|
|
99
|
+
var test = function (kind, sound) {
|
|
100
|
+
setTesting(kind); setError('')
|
|
101
|
+
props.call('test', { kind: kind, sound: sound }).then(loadDiagnostics).catch(function (err) { setError(String(err && err.message || '测试失败')) })
|
|
102
|
+
if (testTimer.current) clearTimeout(testTimer.current)
|
|
103
|
+
testTimer.current = setTimeout(function () { setTesting('') }, 1200)
|
|
104
|
+
}
|
|
105
|
+
var pause = function (duration) {
|
|
106
|
+
var until = duration ? Date.now() + duration : 0
|
|
107
|
+
props.call('settings', { op: 'set', field: 'pauseUntil', value: until }).then(function () {
|
|
108
|
+
setSettings(Object.assign({}, settings, { pauseUntil: until }))
|
|
109
|
+
setDraft(Object.assign({}, draft, { pauseUntil: until }))
|
|
110
|
+
return loadDiagnostics()
|
|
111
|
+
}).catch(function (err) { setError(String(err && err.message || '暂停设置失败')) })
|
|
112
|
+
}
|
|
113
|
+
var importFile = function (event) {
|
|
114
|
+
var file = event.target.files && event.target.files[0]; event.target.value = ''
|
|
115
|
+
if (!file) return
|
|
116
|
+
if (file.size > 5 * 1024 * 1024) { setError('声音文件不能超过 5MB'); return }
|
|
117
|
+
setImporting(true); setError('')
|
|
118
|
+
var reader = new FileReader()
|
|
119
|
+
reader.onload = function () {
|
|
120
|
+
props.call('sound/import', { filename: file.name, data: String(reader.result || '').split(',')[1] || '' }).then(function (result) {
|
|
121
|
+
if (!result || !result.name) throw new Error('导入结果无效')
|
|
122
|
+
changeSound('completed', result.name); return loadCatalog()
|
|
123
|
+
}).catch(function (err) { setError(String(err && err.message || '导入失败')) }).finally(function () { setImporting(false) })
|
|
124
|
+
}
|
|
125
|
+
reader.onerror = function () { setImporting(false); setError('声音文件读取失败') }
|
|
126
|
+
reader.readAsDataURL(file)
|
|
127
|
+
}
|
|
128
|
+
var deleteSound = function (name, force) {
|
|
129
|
+
props.call('sound/delete', { name: name, force: !!force }).then(function (result) {
|
|
130
|
+
if (result && result.requiresConfirmation) {
|
|
131
|
+
var used = result.inUse.map(function (kind) { return KIND_LABELS[kind] || kind }).join('、')
|
|
132
|
+
if (window.confirm('“' + name + '”正在用于' + used + '通知。删除后会改为静音,继续吗?')) return deleteSound(name, true)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
var nextSavedSounds = Object.assign({}, settings.sounds)
|
|
136
|
+
var nextDraftSounds = Object.assign({}, draft.sounds)
|
|
137
|
+
Object.keys(nextSavedSounds).forEach(function (kind) { if (nextSavedSounds[kind] === name) nextSavedSounds[kind] = '' })
|
|
138
|
+
Object.keys(nextDraftSounds).forEach(function (kind) { if (nextDraftSounds[kind] === name) nextDraftSounds[kind] = '' })
|
|
139
|
+
setSettings(Object.assign({}, settings, { sounds: nextSavedSounds }))
|
|
140
|
+
setDraft(Object.assign({}, draft, { sounds: nextDraftSounds }))
|
|
141
|
+
return loadCatalog()
|
|
142
|
+
}).catch(function (err) { setError(String(err && err.message || '删除失败')) })
|
|
143
|
+
}
|
|
144
|
+
var updateRule = function (index, field, value) { var rules = draft.projectRules.slice(); rules[index] = Object.assign({}, rules[index], { [field]: value }); change('projectRules', rules) }
|
|
145
|
+
var removeRule = function (index) { change('projectRules', draft.projectRules.filter(function (_, candidate) { return candidate !== index })) }
|
|
146
|
+
var addRule = function () { change('projectRules', draft.projectRules.concat({ path: props.getCurrentCwd() || '', mode: 'mute' })) }
|
|
147
|
+
|
|
148
|
+
return h('div', { className: saved ? 'dsh-notify-saved' : '', style: { border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '10px', padding: '14px 16px', fontSize: '13px', display: 'flex', flexDirection: 'column', gap: '12px' } },
|
|
149
|
+
h('style', null, css),
|
|
150
|
+
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' } },
|
|
151
|
+
h('div', null, h('strong', null, 'macOS 通知'), h('div', { style: { opacity: .55, fontSize: '11px', marginTop: '2px' } }, '通道:' + (status.channel || settings.channel) + ' · 诊断记录 ' + diagnostics.entries.length + ' 条')),
|
|
152
|
+
editing ? h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(47,111,237,.1)' } }, '整体编辑中') : h('button', { style: buttonStyle, disabled: saved, onClick: beginEdit }, saved ? '已保存 ✓' : '编辑全部')),
|
|
153
|
+
error ? h('div', { className: 'dsh-notify-error', role: 'alert', style: { padding: '7px 9px', borderRadius: '6px', color: '#d94b43', background: 'rgba(217,75,67,.1)' } }, error) : null,
|
|
154
|
+
|
|
155
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note: status.quietActive ? '当前处于勿扰时段' : pauseActive ? '通知已暂停' : '运行正常' }),
|
|
156
|
+
h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, ['completed', 'error', 'approval', 'aborted', 'coalesced', 'digest'].map(function (kind) {
|
|
157
|
+
var names = { completed: '完成', error: '错误', approval: '审批', aborted: '中断', coalesced: '合并', digest: '摘要' }
|
|
158
|
+
return h('button', { key: kind, style: buttonStyle, onClick: function () { test(kind, draft.sounds[kind]) } }, testing === kind ? '发送中…' : '测试' + names[kind])
|
|
159
|
+
})),
|
|
160
|
+
h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } }, h('small', { style: { opacity: .55 } }, pauseActive ? '暂停至 ' + new Date(status.pauseUntil).toLocaleTimeString() : '临时暂停:'),
|
|
161
|
+
h('button', { style: buttonStyle, onClick: function () { pause(30 * 60 * 1000) } }, '30 分钟'), h('button', { style: buttonStyle, onClick: function () { pause(60 * 60 * 1000) } }, '1 小时'), h('button', { style: buttonStyle, onClick: function () { pause(24 * 60 * 60 * 1000) } }, '24 小时'), pauseActive ? h('button', { style: buttonStyle, onClick: function () { pause(0) } }, '立即恢复') : null)),
|
|
162
|
+
|
|
163
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '通知事件与过滤' }),
|
|
164
|
+
[['onCompleted', '任务完成'], ['onError', '错误与阻止'], ['onApproval', '等待审批'], ['onAborted', '用户中断']].map(function (row) { return h(Toggle, { key: row[0], disabled: !editing, value: draft[row[0]], label: row[1], onChange: function (value) { change(row[0], value) } }) }),
|
|
165
|
+
h(Toggle, { disabled: !editing, value: draft.onlyWhenUnfocused, label: '仅在 DSH 页面未聚焦时发送完成通知', onChange: function (value) { change('onlyWhenUnfocused', value) } }),
|
|
166
|
+
h(Toggle, { disabled: !editing, value: draft.includeSubagents, label: '包含子 Agent 会话', onChange: function (value) { change('includeSubagents', value) } }),
|
|
167
|
+
[['minDurationSec', '完成通知最短耗时(秒)'], ['onlyWhenIdleSec', '键鼠空闲门槛(秒)'], ['digestMinutes', '摘要间隔(分钟)'], ['coalesceMs', '合并窗口(毫秒)'], ['duplicateWindowSec', '重复错误抑制(秒)']].map(function (row) { return h(SettingRow, { key: row[0], label: row[1] }, h('input', { style: inputStyle, type: 'number', min: 0, disabled: !editing, value: draft[row[0]], onChange: function (e) { change(row[0], e.target.value) } })) }),
|
|
168
|
+
h(SettingRow, { label: '通知通道' }, h('select', { style: inputStyle, disabled: !editing, value: draft.channel, onChange: function (e) { change('channel', e.target.value) } }, h('option', { value: 'auto' }, '自动'), h('option', { value: 'osascript' }, 'osascript'), h('option', { value: 'osc9' }, 'OSC 9')))),
|
|
169
|
+
|
|
170
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '每日勿扰', note: '使用本机时间' }),
|
|
171
|
+
h(Toggle, { disabled: !editing, value: draft.quietHoursEnabled, label: '启用每日勿扰时段', onChange: function (value) { change('quietHoursEnabled', value) } }),
|
|
172
|
+
h(SettingRow, { label: '时段' }, h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, h('input', { 'aria-label': '勿扰开始时间', style: Object.assign({}, inputStyle, { width: '104px' }), type: 'time', disabled: !editing, value: draft.quietStart, onChange: function (e) { change('quietStart', e.target.value) } }), h('span', null, '至'), h('input', { 'aria-label': '勿扰结束时间', style: Object.assign({}, inputStyle, { width: '104px' }), type: 'time', disabled: !editing, value: draft.quietEnd, onChange: function (e) { change('quietEnd', e.target.value) } }))),
|
|
173
|
+
h(Toggle, { disabled: !editing, value: draft.quietAllowCritical, label: '勿扰时仍允许错误、阻止和审批', onChange: function (value) { change('quietAllowCritical', value) } })),
|
|
174
|
+
|
|
175
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '提示音', note: '单个≤5MB/10秒;最多20个/50MB' }),
|
|
176
|
+
editing ? h('div', { style: rowStyle }, h('input', { ref: fileInput, type: 'file', accept: '.aac,.aif,.aiff,.caf,.flac,.m4a,.mp3,.oga,.ogg,.opus,.wav,audio/*', style: { display: 'none' }, onChange: importFile }), h('button', { style: buttonStyle, disabled: importing, onClick: function () { if (fileInput.current) fileInput.current.click() } }, importing ? '导入中…' : '导入声音'), h('small', { style: { opacity: .55 } }, '已管理 ' + catalog.managed.length + ' 个')) : null,
|
|
177
|
+
SOUND_ROWS.map(function (row) { var kind = row[0], value = draft.sounds[kind] || '', choices = availableSounds.includes(value) || !value ? availableSounds : [value].concat(availableSounds); return h('div', { key: kind, style: rowStyle }, h('span', { style: { width: '110px' } }, row[1]), h('select', { style: Object.assign({}, inputStyle, { flex: 1, width: 'auto' }), disabled: !editing, value: value, onChange: function (e) { changeSound(kind, e.target.value) } }, h('option', { value: '' }, '静音'), choices.map(function (name) { return h('option', { key: name, value: name }, name) })), h('button', { style: buttonStyle, onClick: function () { test(kind, value) } }, testing === kind ? '播放中…' : '试听')) }),
|
|
178
|
+
catalog.managed.length ? h('div', { style: { padding: '8px', borderRadius: '7px', background: 'rgba(127,127,127,.06)' } }, catalog.managed.map(function (item) { return h('div', { key: item.name, style: rowStyle }, h('span', { style: { flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' } }, item.name), h('small', { style: { opacity: .5 } }, (item.bytes / 1024).toFixed(0) + 'KB'), editing ? h('button', { style: Object.assign({}, buttonStyle, { color: '#d94b43' }), onClick: function () { deleteSound(item.name, false) } }, '删除') : null) })) : null),
|
|
179
|
+
|
|
180
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '项目规则', note: '更具体的路径优先' }),
|
|
181
|
+
draft.projectRules.length ? draft.projectRules.map(function (rule, index) { return h('div', { key: index, style: { display: 'grid', gridTemplateColumns: 'minmax(0,1fr) 150px auto', gap: '6px' } }, h('input', { style: Object.assign({}, inputStyle, { width: '100%' }), disabled: !editing, value: rule.path, placeholder: '/Users/name/project', onChange: function (e) { updateRule(index, 'path', e.target.value) } }), h('select', { style: Object.assign({}, inputStyle, { width: '100%' }), disabled: !editing, value: rule.mode, onChange: function (e) { updateRule(index, 'mode', e.target.value) } }, Object.keys(RULE_LABELS).map(function (mode) { return h('option', { key: mode, value: mode }, RULE_LABELS[mode]) })), editing ? h('button', { style: Object.assign({}, buttonStyle, { color: '#d94b43' }), onClick: function () { removeRule(index) } }, '移除') : h('span')) }) : h('small', { style: { opacity: .55 } }, '尚未配置项目规则'),
|
|
182
|
+
editing ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: addRule }, props.getCurrentCwd() ? '添加当前项目' : '添加规则') : null),
|
|
183
|
+
|
|
184
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '最近通知诊断', note: '当前进程最近50条' }),
|
|
185
|
+
diagnostics.entries.length ? diagnostics.entries.slice(0, 12).map(function (entry) { var colors = { sent: '#238452', suppressed: '#b36b00', queued: '#3867c8', error: '#d94b43' }, labels = { sent: '已发送', suppressed: '已抑制', queued: '排队中', error: '失败' }; return h('div', { key: entry.id, style: { display: 'grid', gridTemplateColumns: '58px minmax(0,1fr) auto', gap: '7px', padding: '6px 0', borderBottom: '1px solid rgba(127,127,127,.12)' } }, h('span', { style: { color: colors[entry.status], fontSize: '11px', fontWeight: 600 } }, labels[entry.status] || entry.status), h('span', null, h('b', null, entry.label || entry.title || entry.kind), h('small', { style: { display: 'block', opacity: .55 } }, entry.detail)), h('small', { style: { opacity: .45, whiteSpace: 'nowrap' } }, new Date(entry.time).toLocaleTimeString())) }) : h('small', { style: { opacity: .55 } }, '暂无记录,可以点击上方测试按钮。'),
|
|
186
|
+
diagnostics.entries.length ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: function () { props.call('diagnostics', { op: 'clear' }).then(setDiagnostics) } }, '清空记录') : null),
|
|
187
|
+
|
|
188
|
+
editing ? h('div', { style: { position: 'sticky', bottom: '8px', display: 'flex', gap: '8px', padding: '9px', borderRadius: '8px', background: 'var(--dsw-alias-bg-primary,#1d1d1d)', border: '1px solid var(--dsw-alias-border-l2,#444)', boxShadow: '0 5px 18px rgba(0,0,0,.18)' } }, h('button', { style: Object.assign({}, buttonStyle, { fontWeight: 650 }), disabled: !dirty || saving || importing, onClick: save }, saving ? '保存中…' : dirty ? '保存全部' : '没有改动'), h('button', { style: buttonStyle, disabled: saving || importing, onClick: cancel }, '取消')) : null)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
exports.inject = ['connection', 'slots', 'sessions']
|
|
192
|
+
exports.apply = function apply(ctx) {
|
|
193
|
+
var clientId = crypto.randomUUID()
|
|
194
|
+
var report = function () { ctx.connection.rpc.call('/macos-notify', 'visibility', { id: clientId, focused: document.visibilityState === 'visible' && document.hasFocus() }).catch(function () {}) }
|
|
195
|
+
report(); document.addEventListener('visibilitychange', report); window.addEventListener('focus', report); window.addEventListener('blur', report)
|
|
196
|
+
var timer = setInterval(report, 30000)
|
|
197
|
+
var call = function (endpoint, payload) { return ctx.connection.rpc.call('/macos-notify', endpoint, payload).then(function (result) { if (!result || result.ok !== true) throw new Error(result && result.error && result.error.message || 'RPC 调用失败'); return result.value }) }
|
|
198
|
+
var getCurrentCwd = function () { try { var state = ctx.sessions.list.getSnapshot(); return state.current && state.byId[state.current] && state.byId[state.current].cwd || '' } catch { return '' } }
|
|
199
|
+
ctx.slots.inject('settings.plugin.item', function* () { yield ctx.slots.register({ name: 'settings.plugin.item', id: 'macos-notify', order: 100, inject: function () { return { call: call, getCurrentCwd: getCurrentCwd } } }, Card) })
|
|
200
|
+
ctx.effect(function () { return function () { clearInterval(timer); document.removeEventListener('visibilitychange', report); window.removeEventListener('focus', report); window.removeEventListener('blur', report) } })
|
|
201
|
+
}
|
|
202
|
+
return module.exports
|
|
203
|
+
},
|
|
204
|
+
})
|
package/cordis.patch.yml
ADDED
package/index.js
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { constants as fsConstants } from 'node:fs'
|
|
3
|
+
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { homedir, tmpdir } from 'node:os'
|
|
5
|
+
import { basename, extname, join, resolve } from 'node:path'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
8
|
+
|
|
9
|
+
export const name = 'dsh-macos-notify'
|
|
10
|
+
export const inject = ['sessions', 'settings']
|
|
11
|
+
|
|
12
|
+
export const Config = Schema.object({
|
|
13
|
+
/** 轮次正常完成时通知 */
|
|
14
|
+
onCompleted: Schema.boolean().default(true),
|
|
15
|
+
/** 轮次出错或被阻止时通知(不受 minDurationSec / onlyWhenIdleSec / onlyWhenUnfocused / digestMinutes 限制) */
|
|
16
|
+
onError: Schema.boolean().default(true),
|
|
17
|
+
/** 用户取消(中断)时通知 */
|
|
18
|
+
onAborted: Schema.boolean().default(false),
|
|
19
|
+
/** 工具等待审批时通知(立即发送,不参与合并) */
|
|
20
|
+
onApproval: Schema.boolean().default(true),
|
|
21
|
+
/** 「完成」通知的最短轮次时长(秒):短于此值的轮次不弹完成通知,0 表示都弹 */
|
|
22
|
+
minDurationSec: Schema.number().default(30),
|
|
23
|
+
/** 「完成」类通知仅在键鼠空闲超过此值(秒)时发送,0 关闭。注意:测的是输入空闲,切走应用但还在打字就不算空闲 */
|
|
24
|
+
onlyWhenIdleSec: Schema.number().default(0),
|
|
25
|
+
/** 「完成」类通知仅在没有任何浏览器 tab 聚焦 dsh 时发送(切走 tab 或切走应用都会弹)。无客户端上报时(如 headless)视为未聚焦,照弹 */
|
|
26
|
+
onlyWhenUnfocused: Schema.boolean().default(true),
|
|
27
|
+
/** 「完成」类通知的汇总间隔(分钟):攒一批定时合并发送,0 表示实时。审批和出错不受影响 */
|
|
28
|
+
digestMinutes: Schema.number().default(0),
|
|
29
|
+
/** 子 agent 会话也通知(默认只通知顶层会话,避免刷屏) */
|
|
30
|
+
includeSubagents: Schema.boolean().default(false),
|
|
31
|
+
/** 通知通道:auto = 支持的终端走 OSC 9,否则 osascript;osc9/osascript 强制指定 */
|
|
32
|
+
channel: Schema.union(['auto', 'osascript', 'osc9']).default('auto'),
|
|
33
|
+
/** 各事件类型的通知声音(macOS 声音名,空串为静音;OSC 9 通道下声音由终端决定,此配置无效) */
|
|
34
|
+
sounds: Schema.object({
|
|
35
|
+
completed: Schema.string().default('Glass'),
|
|
36
|
+
error: Schema.string().default('Basso'),
|
|
37
|
+
aborted: Schema.string().default(''),
|
|
38
|
+
approval: Schema.string().default('Ping'),
|
|
39
|
+
}).default({ completed: 'Glass', error: 'Basso', aborted: '', approval: 'Ping' }),
|
|
40
|
+
/** 轮次结束通知的合并窗口(毫秒):窗口内多个会话的结束合并成一条,0 关闭合并 */
|
|
41
|
+
coalesceMs: Schema.number().default(1500),
|
|
42
|
+
/** 是否启用每日勿扰时段 */
|
|
43
|
+
quietHoursEnabled: Schema.boolean().default(false),
|
|
44
|
+
/** 每日勿扰开始时间(本机时间,HH:mm) */
|
|
45
|
+
quietStart: Schema.string().default('23:00'),
|
|
46
|
+
/** 每日勿扰结束时间(本机时间,HH:mm) */
|
|
47
|
+
quietEnd: Schema.string().default('08:00'),
|
|
48
|
+
/** 勿扰时段仍允许错误、阻止和审批通知 */
|
|
49
|
+
quietAllowCritical: Schema.boolean().default(true),
|
|
50
|
+
/** 临时暂停截止时间(Unix 毫秒);0 表示未暂停 */
|
|
51
|
+
pauseUntil: Schema.number().default(0),
|
|
52
|
+
/** 相同会话相同错误的重复抑制窗口(秒);0 表示关闭 */
|
|
53
|
+
duplicateWindowSec: Schema.number().default(300),
|
|
54
|
+
/** 项目规则 JSON:[{ path, mode }],mode 为 mute / errors / important */
|
|
55
|
+
projectRulesJson: Schema.string().default('[]'),
|
|
56
|
+
/** 插件加载时发一条测试通知 */
|
|
57
|
+
notifyOnLoad: Schema.boolean().default(true),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
/** 设置页可编辑的提示音字段 */
|
|
61
|
+
const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
|
|
62
|
+
const SOUND_EXTENSIONS = new Set(['.aif', '.aiff', '.caf', '.m4a', '.wav'])
|
|
63
|
+
const IMPORT_EXTENSIONS = new Set([
|
|
64
|
+
'.aac', '.aif', '.aiff', '.caf', '.flac', '.m4a', '.mp3', '.oga', '.ogg', '.opus', '.wav',
|
|
65
|
+
])
|
|
66
|
+
const MAX_SOUND_BYTES = 5 * 1024 * 1024
|
|
67
|
+
const MAX_SOUND_DURATION_SEC = 10
|
|
68
|
+
const MAX_MANAGED_SOUND_COUNT = 20
|
|
69
|
+
const MAX_MANAGED_SOUND_BYTES = 50 * 1024 * 1024
|
|
70
|
+
const MAX_DIAGNOSTICS = 50
|
|
71
|
+
const execFileAsync = promisify(execFile)
|
|
72
|
+
|
|
73
|
+
function userSoundsDir() {
|
|
74
|
+
return join(homedir(), 'Library/Sounds')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function soundRegistryPath() {
|
|
78
|
+
return join(homedir(), 'Library/Application Support/dsh-macos-notify/sounds.json')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function readSoundRegistry() {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(await readFile(soundRegistryPath(), 'utf8'))
|
|
84
|
+
return Array.isArray(parsed) ? parsed.filter((item) =>
|
|
85
|
+
item && typeof item.name === 'string' && typeof item.filename === 'string') : []
|
|
86
|
+
} catch {
|
|
87
|
+
return []
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function writeSoundRegistry(entries) {
|
|
92
|
+
const path = soundRegistryPath()
|
|
93
|
+
await mkdir(resolve(path, '..'), { recursive: true })
|
|
94
|
+
await writeFile(path, `${JSON.stringify(entries, null, 2)}\n`, { mode: 0o600 })
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function managedSoundCatalog() {
|
|
98
|
+
const dir = userSoundsDir()
|
|
99
|
+
const registry = await readSoundRegistry()
|
|
100
|
+
const catalog = []
|
|
101
|
+
const kept = []
|
|
102
|
+
for (const item of registry) {
|
|
103
|
+
const path = join(dir, item.filename)
|
|
104
|
+
try {
|
|
105
|
+
const info = await stat(path)
|
|
106
|
+
if (!info.isFile()) continue
|
|
107
|
+
kept.push(item)
|
|
108
|
+
catalog.push({
|
|
109
|
+
name: item.name,
|
|
110
|
+
filename: item.filename,
|
|
111
|
+
bytes: info.size,
|
|
112
|
+
importedAt: Number(item.importedAt) || info.birthtimeMs || info.mtimeMs,
|
|
113
|
+
})
|
|
114
|
+
} catch {
|
|
115
|
+
// 用户在 Finder 中删除了文件;下次写回时清理失效注册项。
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (kept.length !== registry.length) await writeSoundRegistry(kept)
|
|
119
|
+
return catalog.sort((a, b) => b.importedAt - a.importedAt)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function safeSoundName(filename) {
|
|
123
|
+
const extension = extname(filename).toLowerCase()
|
|
124
|
+
if (!IMPORT_EXTENSIONS.has(extension)) {
|
|
125
|
+
throw new Error('不支持该音频格式')
|
|
126
|
+
}
|
|
127
|
+
const name = basename(filename, extension)
|
|
128
|
+
.normalize('NFKC')
|
|
129
|
+
.replace(/[<>:"/\\|?*\x00-\x1f]/g, ' ')
|
|
130
|
+
.replace(/\s+/g, ' ')
|
|
131
|
+
.replace(/^\.+|\.+$/g, '')
|
|
132
|
+
.trim()
|
|
133
|
+
.slice(0, 80)
|
|
134
|
+
if (!name) throw new Error('声音文件名无效')
|
|
135
|
+
return { extension, name }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function availableSoundPath(name) {
|
|
139
|
+
const dir = userSoundsDir()
|
|
140
|
+
await mkdir(dir, { recursive: true })
|
|
141
|
+
const existing = new Set((await readdir(dir)).map((item) => item.toLowerCase()))
|
|
142
|
+
for (let suffix = 0; suffix < 1000; suffix += 1) {
|
|
143
|
+
const candidateName = suffix === 0 ? name : `${name} (${suffix + 1})`
|
|
144
|
+
const filename = `${candidateName}.aiff`
|
|
145
|
+
if (!existing.has(filename.toLowerCase())) {
|
|
146
|
+
return { candidateName, path: join(dir, filename) }
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
throw new Error('同名声音文件过多')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function convertToAiff(input, output) {
|
|
153
|
+
try {
|
|
154
|
+
await execFileAsync('/usr/bin/afconvert', ['-f', 'AIFF', '-d', 'BEI16@44100', input, output])
|
|
155
|
+
} catch (afconvertError) {
|
|
156
|
+
try {
|
|
157
|
+
await execFileAsync('ffmpeg', [
|
|
158
|
+
'-y', '-loglevel', 'error', '-i', input,
|
|
159
|
+
'-ar', '44100', '-ac', '1', '-c:a', 'pcm_s16be', output,
|
|
160
|
+
])
|
|
161
|
+
} catch {
|
|
162
|
+
throw new Error(`音频转换失败:${afconvertError?.message ?? '格式无法识别'}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function validateSoundDuration(path) {
|
|
168
|
+
const { stdout } = await execFileAsync('/usr/bin/afinfo', ['-r', path])
|
|
169
|
+
const match = stdout.match(/estimated duration:\s*([\d.]+)\s*sec/i)
|
|
170
|
+
const duration = match ? Number(match[1]) : NaN
|
|
171
|
+
if (!Number.isFinite(duration)) throw new Error('无法读取音频时长')
|
|
172
|
+
if (duration > MAX_SOUND_DURATION_SEC) {
|
|
173
|
+
throw new Error(`提示音时长不能超过 ${MAX_SOUND_DURATION_SEC} 秒`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function importSound(payload) {
|
|
178
|
+
if (typeof payload?.filename !== 'string' || typeof payload?.data !== 'string') {
|
|
179
|
+
throw new Error('缺少声音文件')
|
|
180
|
+
}
|
|
181
|
+
if (payload.data.length > Math.ceil(MAX_SOUND_BYTES * 4 / 3) + 8) {
|
|
182
|
+
throw new Error('声音文件不能超过 5MB')
|
|
183
|
+
}
|
|
184
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload.data)) {
|
|
185
|
+
throw new Error('声音文件内容无效')
|
|
186
|
+
}
|
|
187
|
+
const bytes = Buffer.from(payload.data, 'base64')
|
|
188
|
+
if (bytes.length === 0 || bytes.length > MAX_SOUND_BYTES) {
|
|
189
|
+
throw new Error('声音文件不能超过 5MB')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const { extension, name } = safeSoundName(payload.filename)
|
|
193
|
+
const tempDir = await mkdtemp(join(tmpdir(), 'dsh-macos-notify-'))
|
|
194
|
+
try {
|
|
195
|
+
const input = join(tempDir, `input${extension}`)
|
|
196
|
+
const output = join(tempDir, 'output.aiff')
|
|
197
|
+
await writeFile(input, bytes)
|
|
198
|
+
await convertToAiff(input, output)
|
|
199
|
+
await validateSoundDuration(output)
|
|
200
|
+
const existing = await managedSoundCatalog()
|
|
201
|
+
const outputInfo = await stat(output)
|
|
202
|
+
const usedBytes = existing.reduce((total, item) => total + item.bytes, 0)
|
|
203
|
+
if (existing.length >= MAX_MANAGED_SOUND_COUNT) {
|
|
204
|
+
throw new Error(`最多管理 ${MAX_MANAGED_SOUND_COUNT} 个自定义提示音,请先删除不用的声音`)
|
|
205
|
+
}
|
|
206
|
+
if (usedBytes + outputInfo.size > MAX_MANAGED_SOUND_BYTES) {
|
|
207
|
+
throw new Error('自定义提示音总容量不能超过 50MB,请先删除不用的声音')
|
|
208
|
+
}
|
|
209
|
+
const destination = await availableSoundPath(name)
|
|
210
|
+
await copyFile(output, destination.path, fsConstants.COPYFILE_EXCL)
|
|
211
|
+
await chmod(destination.path, 0o644)
|
|
212
|
+
try {
|
|
213
|
+
const registry = await readSoundRegistry()
|
|
214
|
+
registry.push({
|
|
215
|
+
name: destination.candidateName,
|
|
216
|
+
filename: basename(destination.path),
|
|
217
|
+
importedAt: Date.now(),
|
|
218
|
+
})
|
|
219
|
+
await writeSoundRegistry(registry)
|
|
220
|
+
} catch (err) {
|
|
221
|
+
await rm(destination.path, { force: true })
|
|
222
|
+
throw err
|
|
223
|
+
}
|
|
224
|
+
return destination.candidateName
|
|
225
|
+
} finally {
|
|
226
|
+
await rm(tempDir, { recursive: true, force: true })
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function deleteManagedSound(name) {
|
|
231
|
+
const registry = await readSoundRegistry()
|
|
232
|
+
const index = registry.findIndex((item) => item.name === name)
|
|
233
|
+
if (index < 0) throw new Error('只能删除由本插件导入并管理的声音')
|
|
234
|
+
const [entry] = registry.splice(index, 1)
|
|
235
|
+
const target = resolve(userSoundsDir(), entry.filename)
|
|
236
|
+
const root = `${resolve(userSoundsDir())}/`
|
|
237
|
+
if (!target.startsWith(root) || basename(target) !== entry.filename) {
|
|
238
|
+
throw new Error('声音文件路径无效')
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
await unlink(target)
|
|
242
|
+
} catch (err) {
|
|
243
|
+
if (err?.code !== 'ENOENT') throw err
|
|
244
|
+
}
|
|
245
|
+
await writeSoundRegistry(registry)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** macOS 系统与用户声音目录;读取失败的目录直接忽略 */
|
|
249
|
+
async function systemSoundNames() {
|
|
250
|
+
const dirs = [
|
|
251
|
+
'/System/Library/Sounds',
|
|
252
|
+
'/Library/Sounds',
|
|
253
|
+
userSoundsDir(),
|
|
254
|
+
]
|
|
255
|
+
const names = new Set()
|
|
256
|
+
await Promise.all(dirs.map(async (dir) => {
|
|
257
|
+
try {
|
|
258
|
+
const entries = await readdir(dir, { withFileTypes: true })
|
|
259
|
+
for (const entry of entries) {
|
|
260
|
+
if (!entry.isFile()) continue
|
|
261
|
+
const extension = extname(entry.name).toLowerCase()
|
|
262
|
+
if (SOUND_EXTENSIONS.has(extension)) names.add(basename(entry.name, extension))
|
|
263
|
+
}
|
|
264
|
+
} catch {
|
|
265
|
+
// 目录可能不存在或无读取权限;其余目录仍可用
|
|
266
|
+
}
|
|
267
|
+
}))
|
|
268
|
+
return [...names].sort((a, b) => a.localeCompare(b, 'en'))
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function soundCatalog() {
|
|
272
|
+
const [names, managed] = await Promise.all([systemSoundNames(), managedSoundCatalog()])
|
|
273
|
+
return {
|
|
274
|
+
names,
|
|
275
|
+
managed,
|
|
276
|
+
limits: { count: MAX_MANAGED_SOUND_COUNT, bytes: MAX_MANAGED_SOUND_BYTES },
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function esc(s) {
|
|
281
|
+
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function notify(title, body, sound, channel, onResult = () => {}) {
|
|
285
|
+
if (channel === 'osc9') {
|
|
286
|
+
emitOsc9(title, body)
|
|
287
|
+
onResult(null)
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
const soundPart = sound ? ` sound name "${esc(sound)}"` : ''
|
|
291
|
+
const script = `display notification "${esc(body)}" with title "${esc(title)}"${soundPart}`
|
|
292
|
+
execFile('osascript', ['-e', script], (err) => {
|
|
293
|
+
if (err) console.warn('[dsh-macos-notify] osascript failed:', err.message)
|
|
294
|
+
onResult(err ?? null)
|
|
295
|
+
})
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// —— OSC 9 通道(思路参考 kimi-code 的 terminal-notification.ts)——
|
|
299
|
+
|
|
300
|
+
/** 认识 OSC 9 桌面通知的终端白名单;不认识 OSC 9 的终端收到转义序列会打印乱码,所以必须保守 */
|
|
301
|
+
function supportsOsc9(env = process.env) {
|
|
302
|
+
const termProgram = env.TERM_PROGRAM ?? ''
|
|
303
|
+
if (['iTerm.app', 'WezTerm', 'ghostty', 'WarpTerminal'].includes(termProgram)) return true
|
|
304
|
+
const term = env.TERM ?? ''
|
|
305
|
+
return term === 'xterm-kitty' || term === 'xterm-ghostty'
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 剥掉控制字符,避免污染终端 */
|
|
309
|
+
function sanitizeOsc9(s) {
|
|
310
|
+
return String(s).replace(/[\x00-\x1f\x7f]/g, ' ').trim()
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function emitOsc9(title, body) {
|
|
314
|
+
const message = [title, body].map(sanitizeOsc9).filter(Boolean).join(': ').slice(0, 256)
|
|
315
|
+
if (!message) return
|
|
316
|
+
let seq = `\x1b]9;${message}\x07`
|
|
317
|
+
// tmux 会吞掉 OSC,需要 DCS passthrough 包裹并把载荷里的 ESC 双写
|
|
318
|
+
if (process.env.TMUX) {
|
|
319
|
+
seq = `\x1bPtmux;${seq.replaceAll('\x1b', '\x1b\x1b')}\x1b\\`
|
|
320
|
+
}
|
|
321
|
+
process.stdout.write(seq)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** macOS 键鼠空闲秒数;读不到时返回 0(视为"人在电脑前",即不抑制通知) */
|
|
325
|
+
function idleSeconds() {
|
|
326
|
+
return new Promise((resolve) => {
|
|
327
|
+
execFile('ioreg', ['-c', 'IOHIDSystem', '-d', '1'], (err, stdout) => {
|
|
328
|
+
const m = stdout?.match(/"HIDIdleTime" = (\d+)/)
|
|
329
|
+
resolve(m ? Number(m[1]) / 1e9 : 0)
|
|
330
|
+
})
|
|
331
|
+
})
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function minuteOfDay(value) {
|
|
335
|
+
const match = /^(\d{2}):(\d{2})$/.exec(String(value))
|
|
336
|
+
if (!match) return null
|
|
337
|
+
const hour = Number(match[1])
|
|
338
|
+
const minute = Number(match[2])
|
|
339
|
+
if (hour > 23 || minute > 59) return null
|
|
340
|
+
return hour * 60 + minute
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function quietHoursActive(config, now = new Date()) {
|
|
344
|
+
if (!config.quietHoursEnabled) return false
|
|
345
|
+
const start = minuteOfDay(config.quietStart)
|
|
346
|
+
const end = minuteOfDay(config.quietEnd)
|
|
347
|
+
if (start === null || end === null || start === end) return false
|
|
348
|
+
const currentMinute = now.getHours() * 60 + now.getMinutes()
|
|
349
|
+
return start < end
|
|
350
|
+
? currentMinute >= start && currentMinute < end
|
|
351
|
+
: currentMinute >= start || currentMinute < end
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseProjectRules(raw) {
|
|
355
|
+
try {
|
|
356
|
+
const value = JSON.parse(raw)
|
|
357
|
+
if (!Array.isArray(value)) return []
|
|
358
|
+
return value.slice(0, 50).flatMap((item) => {
|
|
359
|
+
const path = typeof item?.path === 'string' ? item.path.trim() : ''
|
|
360
|
+
const mode = item?.mode
|
|
361
|
+
if (!path || !['mute', 'errors', 'important'].includes(mode)) return []
|
|
362
|
+
return [{ path: resolve(path), mode }]
|
|
363
|
+
})
|
|
364
|
+
} catch {
|
|
365
|
+
return []
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function matchingProjectRule(rules, cwd) {
|
|
370
|
+
if (!cwd) return null
|
|
371
|
+
const target = resolve(cwd)
|
|
372
|
+
return rules
|
|
373
|
+
.filter((rule) => target === rule.path || target.startsWith(`${rule.path}/`))
|
|
374
|
+
.sort((a, b) => b.path.length - a.path.length)[0] ?? null
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isCriticalKind(kind) {
|
|
378
|
+
return kind === '出错' || kind === '被阻止' || kind === '审批'
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function apply(ctx, config) {
|
|
382
|
+
// 配置三层叠加:schema 默认值 < cordis 组合层(base = 插件 config)< 用户层(设置页)。
|
|
383
|
+
// 设置页写入后经 watch 实时生效,不需要重启。
|
|
384
|
+
const scope = ctx.settings.register('macos-notify', Config, { base: config, applies: 'live' })
|
|
385
|
+
let current = scope.get()
|
|
386
|
+
|
|
387
|
+
// sessionId -> 最新标题(session/title 事件是 latest-wins 快照)
|
|
388
|
+
const titles = new Map()
|
|
389
|
+
// sessionId -> 本轮 turn/start 的时间戳,用于算轮次时长
|
|
390
|
+
const turnStartedAt = new Map()
|
|
391
|
+
// 有未关闭轮次的顶层会话,用于「还有 N 个任务进行中」
|
|
392
|
+
const running = new Set()
|
|
393
|
+
// 合并窗口内待发的轮次结束通知(实时通道)
|
|
394
|
+
let pending = []
|
|
395
|
+
let flushTimer = null
|
|
396
|
+
// digest 通道:只攒「完成」
|
|
397
|
+
let digestPending = []
|
|
398
|
+
let digestTimer = null
|
|
399
|
+
// 最近的通知决策,仅保存在当前进程内;设置页用于解释“为什么没弹”。
|
|
400
|
+
let diagnostics = []
|
|
401
|
+
let diagnosticSeq = 0
|
|
402
|
+
// 重复错误键 -> 首次发送时间、被抑制次数
|
|
403
|
+
const duplicates = new Map()
|
|
404
|
+
let projectRules = parseProjectRules(current.projectRulesJson)
|
|
405
|
+
|
|
406
|
+
const record = (status, item, detail, extra = {}) => {
|
|
407
|
+
diagnostics.unshift({
|
|
408
|
+
id: ++diagnosticSeq,
|
|
409
|
+
time: Date.now(),
|
|
410
|
+
status,
|
|
411
|
+
kind: item?.kind ?? '系统',
|
|
412
|
+
title: item?.title ?? '',
|
|
413
|
+
label: item?.label ?? '',
|
|
414
|
+
sessionId: item?.sessionId ?? null,
|
|
415
|
+
cwd: item?.cwd ?? null,
|
|
416
|
+
detail,
|
|
417
|
+
channel: resolvedChannel,
|
|
418
|
+
...extra,
|
|
419
|
+
})
|
|
420
|
+
if (diagnostics.length > MAX_DIAGNOSTICS) diagnostics.length = MAX_DIAGNOSTICS
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// 通知通道:auto 在支持的终端里走 OSC 9(终端自己转系统通知),否则 osascript
|
|
424
|
+
let resolvedChannel = resolveChannel()
|
|
425
|
+
function resolveChannel() {
|
|
426
|
+
return current.channel === 'auto' ? (supportsOsc9() ? 'osc9' : 'osascript') : current.channel
|
|
427
|
+
}
|
|
428
|
+
const send = (title, body, sound, items = [], options = {}) => {
|
|
429
|
+
const representative = items[0] ?? { kind: options.kind ?? '系统', title, label: body }
|
|
430
|
+
notify(title, body, sound, resolvedChannel, (err) => {
|
|
431
|
+
if (err) {
|
|
432
|
+
for (const item of items.length ? items : [representative]) {
|
|
433
|
+
record('error', item, `发送失败:${err.message}`)
|
|
434
|
+
}
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
for (const item of items.length ? items : [representative]) {
|
|
438
|
+
record('sent', item, options.detail ?? `已通过 ${resolvedChannel} 发送`)
|
|
439
|
+
}
|
|
440
|
+
})
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// 上报「正在看 dsh tab」的浏览器客户端:id -> 最近一次聚焦上报的时间戳
|
|
444
|
+
const focusedClients = new Map()
|
|
445
|
+
|
|
446
|
+
/** 是否有客户端正聚焦在 dsh tab 上(90 秒未上报视为已关闭) */
|
|
447
|
+
const anyFocused = () => {
|
|
448
|
+
const cutoff = Date.now() - 90_000
|
|
449
|
+
for (const [id, at] of focusedClients) {
|
|
450
|
+
if (at < cutoff) focusedClients.delete(id)
|
|
451
|
+
}
|
|
452
|
+
return focusedClients.size > 0
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// 接收浏览器半的焦点上报、试听与设置读写。connection 服务只在 web profile 存在,
|
|
456
|
+
// 用 ctx.inject() 延迟挂载:headless 下回调永不触发(视为无人盯着,通知照发)
|
|
457
|
+
//
|
|
458
|
+
// 注意一:设置读写走自有 RPC 而不是 settings.describe/mutate 线面——
|
|
459
|
+
// dsh-host-apiproxy 对暴露给 Web 的设置命名空间有硬编码白名单
|
|
460
|
+
// (WEB_SETTINGS_NAMESPACES),第三方命名空间目前上不了那条线。
|
|
461
|
+
// 注意二:共享的 /api 通道只允许一个拦截器(已被 api-gateway 占用),
|
|
462
|
+
// 所以用 rpc.handle 注册独立通道 /macos-notify。
|
|
463
|
+
ctx.inject(['connection'], (connCtx) => {
|
|
464
|
+
try {
|
|
465
|
+
connCtx.connection.rpc.handle(
|
|
466
|
+
'/macos-notify',
|
|
467
|
+
async (endpoint, payload) => {
|
|
468
|
+
if (endpoint === 'visibility') {
|
|
469
|
+
const { id, focused } = payload ?? {}
|
|
470
|
+
if (typeof id === 'string') {
|
|
471
|
+
if (focused) focusedClients.set(id, Date.now())
|
|
472
|
+
else focusedClients.delete(id)
|
|
473
|
+
}
|
|
474
|
+
return { ok: true, value: null }
|
|
475
|
+
}
|
|
476
|
+
if (endpoint === 'test') {
|
|
477
|
+
// 测试矩阵绕过勿扰和过滤规则,确保能直接验证系统投递通道。
|
|
478
|
+
const kind = payload?.kind
|
|
479
|
+
if (SOUND_KINDS.includes(kind)) {
|
|
480
|
+
const sound = typeof payload?.sound === 'string' ? payload.sound : current.sounds[kind]
|
|
481
|
+
const labels = {
|
|
482
|
+
completed: ['完成测试', '模拟任务已完成'],
|
|
483
|
+
error: ['错误测试', '模拟任务发生错误'],
|
|
484
|
+
aborted: ['中断测试', '模拟任务已中断'],
|
|
485
|
+
approval: ['审批测试', '模拟工具正在等待审批'],
|
|
486
|
+
}
|
|
487
|
+
const item = { kind: kind === 'approval' ? '审批' : `测试/${kind}`, title: labels[kind][0], label: labels[kind][1] }
|
|
488
|
+
send(labels[kind][0], labels[kind][1], sound, [item], { detail: '设置页测试通知' })
|
|
489
|
+
} else if (kind === 'coalesced') {
|
|
490
|
+
const items = [
|
|
491
|
+
{ kind: '完成', title: '合并测试', label: '示例任务 A' },
|
|
492
|
+
{ kind: '完成', title: '合并测试', label: '示例任务 B' },
|
|
493
|
+
]
|
|
494
|
+
send('2 个任务有结果', '2 个完成:示例任务 A、示例任务 B', current.sounds.completed, items, { detail: '设置页合并通知测试' })
|
|
495
|
+
} else if (kind === 'digest') {
|
|
496
|
+
const item = { kind: '完成', title: '摘要测试', label: '3 个示例任务' }
|
|
497
|
+
send('3 个任务完成', '示例任务 A、示例任务 B、示例任务 C', current.sounds.completed, [item], { detail: '设置页摘要通知测试' })
|
|
498
|
+
}
|
|
499
|
+
return { ok: true, value: null }
|
|
500
|
+
}
|
|
501
|
+
if (endpoint === 'sounds') {
|
|
502
|
+
return { ok: true, value: await soundCatalog() }
|
|
503
|
+
}
|
|
504
|
+
if (endpoint === 'sound/import') {
|
|
505
|
+
try {
|
|
506
|
+
const imported = await importSound(payload)
|
|
507
|
+
return { ok: true, value: { name: imported } }
|
|
508
|
+
} catch (err) {
|
|
509
|
+
console.warn('[dsh-macos-notify] sound import failed:', err?.message ?? err)
|
|
510
|
+
return {
|
|
511
|
+
ok: false,
|
|
512
|
+
error: { code: 'internal', message: String(err?.message ?? err), details: {} },
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (endpoint === 'sound/delete') {
|
|
517
|
+
try {
|
|
518
|
+
const soundName = typeof payload?.name === 'string' ? payload.name : ''
|
|
519
|
+
const inUse = SOUND_KINDS.filter((kind) => current.sounds[kind] === soundName)
|
|
520
|
+
if (inUse.length && payload?.force !== true) {
|
|
521
|
+
return { ok: true, value: { requiresConfirmation: true, inUse } }
|
|
522
|
+
}
|
|
523
|
+
await deleteManagedSound(soundName)
|
|
524
|
+
if (inUse.length) {
|
|
525
|
+
const sounds = { ...current.sounds }
|
|
526
|
+
for (const kind of inUse) sounds[kind] = ''
|
|
527
|
+
await scope.update({ sounds })
|
|
528
|
+
}
|
|
529
|
+
return { ok: true, value: { deleted: true, catalog: await soundCatalog() } }
|
|
530
|
+
} catch (err) {
|
|
531
|
+
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (endpoint === 'diagnostics') {
|
|
535
|
+
const op = payload?.op ?? 'get'
|
|
536
|
+
if (op === 'clear') diagnostics = []
|
|
537
|
+
anyFocused()
|
|
538
|
+
return {
|
|
539
|
+
ok: true,
|
|
540
|
+
value: {
|
|
541
|
+
entries: diagnostics,
|
|
542
|
+
status: {
|
|
543
|
+
channel: resolvedChannel,
|
|
544
|
+
configuredChannel: current.channel,
|
|
545
|
+
focusedTabs: focusedClients.size,
|
|
546
|
+
quietActive: quietHoursActive(current),
|
|
547
|
+
pauseUntil: current.pauseUntil,
|
|
548
|
+
pending: pending.length,
|
|
549
|
+
digestPending: digestPending.length,
|
|
550
|
+
},
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
if (endpoint === 'settings') {
|
|
555
|
+
// 设置卡片读写用户层:get 返回解析后的生效值,set 写入一个字段
|
|
556
|
+
const op = payload?.op
|
|
557
|
+
if (op === 'get') return { ok: true, value: scope.get() }
|
|
558
|
+
if (op === 'set' && typeof payload.field === 'string') {
|
|
559
|
+
try {
|
|
560
|
+
await scope.update({ [payload.field]: payload.value })
|
|
561
|
+
return { ok: true, value: null }
|
|
562
|
+
} catch (err) {
|
|
563
|
+
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
if (op === 'patch' && payload.value && typeof payload.value === 'object' && !Array.isArray(payload.value)) {
|
|
567
|
+
try {
|
|
568
|
+
const editable = new Set([
|
|
569
|
+
'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
|
|
570
|
+
'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
|
|
571
|
+
'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
|
|
572
|
+
'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
|
|
573
|
+
])
|
|
574
|
+
if (Object.keys(payload.value).some((key) => !editable.has(key))) throw new Error('包含不可编辑的设置字段')
|
|
575
|
+
for (const field of ['quietStart', 'quietEnd']) {
|
|
576
|
+
if (field in payload.value && minuteOfDay(payload.value[field]) === null) throw new Error('勿扰时间格式无效')
|
|
577
|
+
}
|
|
578
|
+
if (typeof payload.value.projectRulesJson === 'string') {
|
|
579
|
+
const parsed = JSON.parse(payload.value.projectRulesJson)
|
|
580
|
+
if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
|
|
581
|
+
if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
|
|
582
|
+
throw new Error('项目规则格式无效')
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
await scope.update(payload.value)
|
|
586
|
+
return { ok: true, value: scope.get() }
|
|
587
|
+
} catch (err) {
|
|
588
|
+
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
return { ok: false, error: { code: 'internal', message: 'unknown settings op', details: {} } }
|
|
592
|
+
}
|
|
593
|
+
return { ok: false, error: { code: 'internal', message: 'unknown endpoint', details: {} } }
|
|
594
|
+
},
|
|
595
|
+
{ authority: 'trusted-host' },
|
|
596
|
+
)
|
|
597
|
+
console.log('[dsh-macos-notify] RPC intercept mounted')
|
|
598
|
+
} catch (err) {
|
|
599
|
+
console.error('[dsh-macos-notify] RPC intercept failed:', err)
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
const label = (session) =>
|
|
604
|
+
titles.get(session.id) ??
|
|
605
|
+
(session.header?.cwd ? basename(session.header.cwd) : String(session.id).slice(0, 8))
|
|
606
|
+
|
|
607
|
+
const runningSuffix = () => {
|
|
608
|
+
const n = running.size
|
|
609
|
+
return n > 0 ? `(还有 ${n} 个任务进行中)` : ''
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const makeItem = (kind, title, body, sound, session) => ({
|
|
613
|
+
kind,
|
|
614
|
+
title,
|
|
615
|
+
body,
|
|
616
|
+
sound,
|
|
617
|
+
label: label(session),
|
|
618
|
+
sessionId: session?.id ?? null,
|
|
619
|
+
cwd: session?.header?.cwd ?? null,
|
|
620
|
+
important: false,
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
const applyProjectRule = (item) => {
|
|
624
|
+
const rule = matchingProjectRule(projectRules, item.cwd)
|
|
625
|
+
if (!rule) return true
|
|
626
|
+
if (rule.mode === 'mute') {
|
|
627
|
+
record('suppressed', item, `项目规则已静音:${rule.path}`)
|
|
628
|
+
return false
|
|
629
|
+
}
|
|
630
|
+
if (rule.mode === 'errors' && !isCriticalKind(item.kind)) {
|
|
631
|
+
record('suppressed', item, `项目规则仅允许错误和审批:${rule.path}`)
|
|
632
|
+
return false
|
|
633
|
+
}
|
|
634
|
+
if (rule.mode === 'important') item.important = true
|
|
635
|
+
return true
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const applyTimePolicy = (item) => {
|
|
639
|
+
if (item.important) return true
|
|
640
|
+
if (current.pauseUntil > Date.now()) {
|
|
641
|
+
record('suppressed', item, `通知已暂停至 ${new Date(current.pauseUntil).toLocaleString()}`)
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
if (quietHoursActive(current) && !(current.quietAllowCritical && isCriticalKind(item.kind))) {
|
|
645
|
+
record('suppressed', item, `当前处于勿扰时段 ${current.quietStart}–${current.quietEnd}`)
|
|
646
|
+
return false
|
|
647
|
+
}
|
|
648
|
+
return true
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const applyDuplicatePolicy = (item) => {
|
|
652
|
+
if (!['出错', '被阻止'].includes(item.kind) || current.duplicateWindowSec <= 0) return true
|
|
653
|
+
const now = Date.now()
|
|
654
|
+
const key = `${item.sessionId ?? ''}\u0000${item.kind}\u0000${item.body}`
|
|
655
|
+
const previous = duplicates.get(key)
|
|
656
|
+
const windowMs = current.duplicateWindowSec * 1000
|
|
657
|
+
if (previous && now - previous.at < windowMs) {
|
|
658
|
+
previous.count += 1
|
|
659
|
+
record('suppressed', item, `重复通知已合并(本窗口第 ${previous.count} 次)`)
|
|
660
|
+
return false
|
|
661
|
+
}
|
|
662
|
+
if (previous?.count) item.body += `(此前重复 ${previous.count} 次)`
|
|
663
|
+
duplicates.set(key, { at: now, count: 0 })
|
|
664
|
+
for (const [candidate, value] of duplicates) {
|
|
665
|
+
if (now - value.at > Math.max(windowMs * 2, 60_000)) duplicates.delete(candidate)
|
|
666
|
+
}
|
|
667
|
+
return true
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// 发送前再检查实时策略;摘要可能在进入队列后才跨入勿扰时段。
|
|
671
|
+
const gateAndNotify = async (items, sendBatch) => {
|
|
672
|
+
let allowed = items.filter(applyTimePolicy)
|
|
673
|
+
if (allowed.length === 0) return
|
|
674
|
+
if (current.onlyWhenUnfocused && anyFocused()) {
|
|
675
|
+
allowed = allowed.filter((item) => {
|
|
676
|
+
if (item.kind !== '完成' || item.important) return true
|
|
677
|
+
record('suppressed', item, 'DSH Web 页面当前处于聚焦状态')
|
|
678
|
+
return false
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
const idleCandidates = allowed.filter((item) => item.kind === '完成' && !item.important)
|
|
682
|
+
if (current.onlyWhenIdleSec > 0 && idleCandidates.length) {
|
|
683
|
+
const idle = await idleSeconds()
|
|
684
|
+
if (idle < current.onlyWhenIdleSec) {
|
|
685
|
+
allowed = allowed.filter((item) => {
|
|
686
|
+
if (item.kind !== '完成' || item.important) return true
|
|
687
|
+
record('suppressed', item, `键鼠仅空闲 ${Math.floor(idle)} 秒,要求 ${current.onlyWhenIdleSec} 秒`)
|
|
688
|
+
return false
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (allowed.length) sendBatch(allowed)
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const render = (items) => {
|
|
696
|
+
if (items.length === 1) {
|
|
697
|
+
return { title: items[0].title, body: items[0].body, sound: items[0].sound }
|
|
698
|
+
}
|
|
699
|
+
// 同类合并:N 个完成 / N 个出错;列表取前 3 个标签
|
|
700
|
+
const byKind = Map.groupBy(items, (i) => i.kind)
|
|
701
|
+
const parts = []
|
|
702
|
+
let sound = ''
|
|
703
|
+
for (const [kind, group] of byKind) {
|
|
704
|
+
const names = group.slice(0, 3).map((g) => g.label).join('、')
|
|
705
|
+
const more = group.length > 3 ? ` 等 ${group.length} 个` : ''
|
|
706
|
+
parts.push(`${group.length} 个${kind}:${names}${more}`)
|
|
707
|
+
// 合并通知的声音:有出错优先用出错声,否则用第一条的
|
|
708
|
+
if (!sound || kind === '出错' || kind === '被阻止') sound = group[0].sound
|
|
709
|
+
}
|
|
710
|
+
return { title: `${items.length} 个任务有结果`, body: parts.join(';'), sound }
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const flush = () => {
|
|
714
|
+
flushTimer = null
|
|
715
|
+
const items = pending
|
|
716
|
+
pending = []
|
|
717
|
+
if (items.length === 0) return
|
|
718
|
+
void gateAndNotify(items, (allowed) => {
|
|
719
|
+
const { title, body, sound } = render(allowed)
|
|
720
|
+
send(title, body + runningSuffix(), sound, allowed)
|
|
721
|
+
})
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const flushDigest = () => {
|
|
725
|
+
const items = digestPending
|
|
726
|
+
digestPending = []
|
|
727
|
+
if (items.length === 0) return
|
|
728
|
+
void gateAndNotify(items, (allowed) => {
|
|
729
|
+
const names = allowed.slice(0, 5).map((i) => i.label).join('、')
|
|
730
|
+
const more = allowed.length > 5 ? ` 等 ${allowed.length} 个` : ''
|
|
731
|
+
send(
|
|
732
|
+
`${allowed.length} 个任务完成`,
|
|
733
|
+
`${names}${more}${runningSuffix()}`,
|
|
734
|
+
current.sounds.completed,
|
|
735
|
+
allowed,
|
|
736
|
+
)
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const setupDigest = () => {
|
|
741
|
+
if (digestTimer) {
|
|
742
|
+
clearInterval(digestTimer)
|
|
743
|
+
digestTimer = null
|
|
744
|
+
}
|
|
745
|
+
if (current.digestMinutes > 0) {
|
|
746
|
+
digestTimer = setInterval(flushDigest, current.digestMinutes * 60_000)
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
setupDigest()
|
|
750
|
+
|
|
751
|
+
// 设置页写入实时生效:换配置快照、重算通道、按新间隔重建 digest 定时器
|
|
752
|
+
scope.watch((next) => {
|
|
753
|
+
current = next
|
|
754
|
+
resolvedChannel = resolveChannel()
|
|
755
|
+
projectRules = parseProjectRules(current.projectRulesJson)
|
|
756
|
+
setupDigest()
|
|
757
|
+
})
|
|
758
|
+
|
|
759
|
+
const enqueue = (kind, title, body, sound, session, options = {}) => {
|
|
760
|
+
const item = makeItem(kind, title, body, sound, session)
|
|
761
|
+
if (!applyProjectRule(item)) return
|
|
762
|
+
if (kind === '完成' && options.durationSec < current.minDurationSec && !item.important) {
|
|
763
|
+
record('suppressed', item, `任务耗时 ${options.durationSec.toFixed(1)} 秒,短于 ${current.minDurationSec} 秒`)
|
|
764
|
+
return
|
|
765
|
+
}
|
|
766
|
+
if (!applyDuplicatePolicy(item)) return
|
|
767
|
+
if (kind === '完成' && current.digestMinutes > 0) {
|
|
768
|
+
digestPending.push(item)
|
|
769
|
+
record('queued', item, `已进入 ${current.digestMinutes} 分钟摘要队列`)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
if (current.coalesceMs <= 0) {
|
|
773
|
+
void gateAndNotify([item], (allowed) => {
|
|
774
|
+
send(title, body + runningSuffix(), sound, allowed)
|
|
775
|
+
})
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
pending.push(item)
|
|
779
|
+
record('queued', item, `等待 ${current.coalesceMs} 毫秒合并窗口`)
|
|
780
|
+
if (!flushTimer) flushTimer = setTimeout(flush, current.coalesceMs)
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
if (current.notifyOnLoad) {
|
|
784
|
+
send('DSH', 'macOS 通知插件已加载', current.sounds.completed, [], { detail: '插件加载测试通知' })
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
ctx.on('session/event', (session, event) => {
|
|
788
|
+
if (event.type === 'session/title') {
|
|
789
|
+
titles.set(session.id, event.data.title)
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (!current.includeSubagents && session.header?.origin === 'subagent') {
|
|
794
|
+
if (event.type === 'turn/end' || event.type === 'approval/asked') {
|
|
795
|
+
record('suppressed', makeItem('子 Agent', '通知已过滤', label(session), '', session), '配置已排除子 Agent 会话')
|
|
796
|
+
}
|
|
797
|
+
return
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (event.type === 'turn/start') {
|
|
801
|
+
running.add(session.id)
|
|
802
|
+
turnStartedAt.set(session.id, event.time)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (event.type === 'turn/end') {
|
|
807
|
+
running.delete(session.id)
|
|
808
|
+
const startedAt = turnStartedAt.get(session.id)
|
|
809
|
+
turnStartedAt.delete(session.id)
|
|
810
|
+
const durationSec = startedAt ? (event.time - startedAt) / 1000 : Infinity
|
|
811
|
+
|
|
812
|
+
const kind = event.data.reason?.kind
|
|
813
|
+
const name = label(session)
|
|
814
|
+
if (kind === 'completed') {
|
|
815
|
+
if (current.onCompleted) enqueue('完成', '任务完成', name, current.sounds.completed, session, { durationSec })
|
|
816
|
+
else record('suppressed', makeItem('完成', '任务完成', name, current.sounds.completed, session), '完成通知已关闭')
|
|
817
|
+
} else if (kind === 'aborted') {
|
|
818
|
+
if (current.onAborted) enqueue('中断', '任务已中断', name, current.sounds.aborted, session)
|
|
819
|
+
else record('suppressed', makeItem('中断', '任务已中断', name, current.sounds.aborted, session), '中断通知已关闭')
|
|
820
|
+
} else if (kind === 'blocked') {
|
|
821
|
+
if (current.onError) enqueue('被阻止', '任务被阻止', name, current.sounds.error, session)
|
|
822
|
+
else record('suppressed', makeItem('被阻止', '任务被阻止', name, current.sounds.error, session), '错误通知已关闭')
|
|
823
|
+
} else if (kind === 'error' && current.onError) {
|
|
824
|
+
const err = event.data.reason?.error
|
|
825
|
+
if (err?.code === 'RATE_LIMIT' || err?.status === 429) {
|
|
826
|
+
const retry = err.providerRetryAfterMs
|
|
827
|
+
? `,服务商建议 ${Math.ceil(err.providerRetryAfterMs / 1000)} 秒后重试`
|
|
828
|
+
: ''
|
|
829
|
+
enqueue('出错', 'API 限流', `${name}: 请求被限流(429)${retry}`, current.sounds.error, session)
|
|
830
|
+
} else {
|
|
831
|
+
const msg = err?.message ?? '未知错误'
|
|
832
|
+
enqueue('出错', '任务出错', `${name}: ${msg}`, current.sounds.error, session)
|
|
833
|
+
}
|
|
834
|
+
} else if (kind === 'error') {
|
|
835
|
+
record('suppressed', makeItem('出错', '任务出错', name, current.sounds.error, session), '错误通知已关闭')
|
|
836
|
+
}
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
if (event.type === 'approval/asked' && current.onApproval) {
|
|
841
|
+
// 审批需要人处理,立即发,不合并
|
|
842
|
+
const reason = event.data.reason ? ` — ${event.data.reason}` : ''
|
|
843
|
+
const body = `${label(session)}: ${event.data.toolName}${reason}`
|
|
844
|
+
const item = makeItem('审批', '等待审批', body, current.sounds.approval, session)
|
|
845
|
+
if (applyProjectRule(item)) {
|
|
846
|
+
void gateAndNotify([item], (allowed) => send('等待审批', body, current.sounds.approval, allowed))
|
|
847
|
+
}
|
|
848
|
+
} else if (event.type === 'approval/asked') {
|
|
849
|
+
record('suppressed', makeItem('审批', '等待审批', label(session), current.sounds.approval, session), '审批通知已关闭')
|
|
850
|
+
}
|
|
851
|
+
})
|
|
852
|
+
|
|
853
|
+
// 卸载时清掉未发出的通知,避免插件已卸载还弹通知
|
|
854
|
+
ctx.effect(() => () => {
|
|
855
|
+
if (flushTimer) clearTimeout(flushTimer)
|
|
856
|
+
if (digestTimer) clearInterval(digestTimer)
|
|
857
|
+
})
|
|
858
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-macos-notify",
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Native macOS notifications and configurable sounds for DeepSeek Harness",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./client": "./client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": ["index.js", "client.js", "cordis.patch.yml"],
|
|
13
|
+
"keywords": ["dsh-plugin", "deepseek-harness", "macos", "notifications"],
|
|
14
|
+
"author": "CrombastiC",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"homepage": "https://github.com/CrombastiC/dsh-macos-notify#readme",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/CrombastiC/dsh-macos-notify/issues"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/CrombastiC/dsh-macos-notify.git"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22"
|
|
30
|
+
},
|
|
31
|
+
"os": ["darwin"],
|
|
32
|
+
"scripts": {
|
|
33
|
+
"test": "node --test tests/*.test.mjs"
|
|
34
|
+
},
|
|
35
|
+
"dsh": {
|
|
36
|
+
"bundle": { "patch": "./cordis.patch.yml" },
|
|
37
|
+
"client": {
|
|
38
|
+
"platform": "web",
|
|
39
|
+
"immediately": true,
|
|
40
|
+
"inject": [
|
|
41
|
+
"@deepseek-ai/dsh-client-connection",
|
|
42
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
49
|
+
}
|
|
50
|
+
}
|