dsh-macos-notify 0.3.0 → 0.4.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/README.md +9 -6
- package/client.js +69 -20
- package/index.js +148 -109
- package/package.json +4 -3
- package/src/policy.js +247 -0
- package/src/state.js +58 -0
package/README.md
CHANGED
|
@@ -6,13 +6,14 @@ Native macOS notifications for [DeepSeek Harness](https://github.com/deepseek-ai
|
|
|
6
6
|
|
|
7
7
|
- Notification Center alerts when a turn completes, fails, is blocked, or waits for approval.
|
|
8
8
|
- Separate sounds for completed, error, aborted, and approval events; any event can be muted.
|
|
9
|
-
- System sound picker
|
|
9
|
+
- System sound picker, in-page sound preview via afplay, and managed custom sound import and deletion from the Web settings page.
|
|
10
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
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.
|
|
12
|
+
- Recent notification diagnostics explain whether an event was sent, queued, suppressed, or failed; they persist across restarts.
|
|
13
13
|
- A six-event test matrix validates completed, error, approval, aborted, coalesced, and digest notifications.
|
|
14
|
-
- Daily quiet hours and temporary
|
|
15
|
-
-
|
|
14
|
+
- Daily quiet hours and temporary pauses with custom durations and a remaining-time display.
|
|
15
|
+
- Common toggles and the sound picker save instantly from the read-only view; number inputs are clamped to safe ranges.
|
|
16
|
+
- Duplicate error suppression with a configurable cooldown window and volatile-fragment normalization.
|
|
16
17
|
- Project path rules for muting, error-only alerts, or important-project bypasses.
|
|
17
18
|
- Minimum turn-duration filtering avoids notifications for near-instant replies.
|
|
18
19
|
- Optional Web-tab focus suppression and macOS HID idle-time gating.
|
|
@@ -95,7 +96,7 @@ The settings namespace is `macos-notify`. Values changed from the Web card apply
|
|
|
95
96
|
- `errors` — allow only errors, blocked events, and approval requests.
|
|
96
97
|
- `important` — bypass minimum-duration, focus, idle, quiet-hour, and temporary-pause filters.
|
|
97
98
|
|
|
98
|
-
The settings page keeps the last 50 decisions
|
|
99
|
+
The settings page keeps the last 50 decisions, the duplicate-error cooldown state, and recent session titles, persisted to `~/Library/Application Support/dsh-macos-notify/state.json` so they survive DSH restarts. Each row records whether an event was sent, queued, suppressed, or failed and includes the reason. This history does not contain message content.
|
|
99
100
|
|
|
100
101
|
## Notification channels
|
|
101
102
|
|
|
@@ -126,6 +127,8 @@ The package is intentionally build-free:
|
|
|
126
127
|
|
|
127
128
|
- `index.js` — host plugin, event handling, notification delivery, settings RPC, and sound import.
|
|
128
129
|
- `client.js` — hand-written DSH client module for focus reporting and the first-level Web settings page.
|
|
130
|
+
- `src/policy.js` — pure policy helpers (quiet hours, project rules, duplicate merging) covered by unit tests.
|
|
131
|
+
- `src/state.js` — validated, atomic persistence for diagnostics, duplicate state, and session titles.
|
|
129
132
|
- `cordis.patch.yml` — profile bundle patch.
|
|
130
133
|
|
|
131
134
|
Run the release checks:
|
|
@@ -139,7 +142,7 @@ npm pack --dry-run
|
|
|
139
142
|
|
|
140
143
|
## 中文说明
|
|
141
144
|
|
|
142
|
-
这是一个仅支持 macOS 的 DeepSeek Harness
|
|
145
|
+
这是一个仅支持 macOS 的 DeepSeek Harness 通知插件。它可以在任务完成、出错、等待审批时发送系统通知,并支持通知诊断(跨重启持久化)、afplay 本地试听、测试矩阵、每日勿扰、自定义时长的临时暂停、自定义声音管理与导入指派、常用开关即时保存、重复错误抑制、项目路径自动补全、焦点抑制、合并通知和定时汇总。推荐从 npm 安装,也可以直接从 GitHub 安装最新版源码。
|
|
143
146
|
|
|
144
147
|
## License
|
|
145
148
|
|
package/client.js
CHANGED
|
@@ -67,12 +67,19 @@ window.__ModuleLoader__.load({
|
|
|
67
67
|
var savedState = React.useState(false), saved = savedState[0], setSaved = savedState[1]
|
|
68
68
|
var importingState = React.useState(false), importing = importingState[0], setImporting = importingState[1]
|
|
69
69
|
var testingState = React.useState(''), testing = testingState[0], setTesting = testingState[1]
|
|
70
|
+
var importTargetState = React.useState('none'), importTarget = importTargetState[0], setImportTarget = importTargetState[1]
|
|
70
71
|
var errorState = React.useState(''), error = errorState[0], setError = errorState[1]
|
|
71
72
|
var fileInput = React.useRef(null), savedTimer = React.useRef(null), testTimer = React.useRef(null)
|
|
72
73
|
|
|
73
74
|
var loadSettings = function () { return props.call('settings', { op: 'get' }).then(function (value) { setSettings(value); setDraft(toDraft(value)); return value }) }
|
|
74
75
|
var loadCatalog = function () { return props.call('sounds', {}).then(function (value) { if (value && Array.isArray(value.names)) setCatalog(value); return value }) }
|
|
75
|
-
var loadDiagnostics = function () {
|
|
76
|
+
var loadDiagnostics = function () {
|
|
77
|
+
if (document.hidden) return Promise.resolve()
|
|
78
|
+
return props.call('diagnostics', { op: 'get' }).then(function (next) {
|
|
79
|
+
if (document.hidden) return
|
|
80
|
+
setDiagnostics(function (prev) { return JSON.stringify(prev) === JSON.stringify(next) ? prev : next })
|
|
81
|
+
}).catch(function () {})
|
|
82
|
+
}
|
|
76
83
|
React.useEffect(function () {
|
|
77
84
|
Promise.all([loadSettings(), loadCatalog(), loadDiagnostics()]).catch(function (err) { setError(String(err && err.message || '设置读取失败')) })
|
|
78
85
|
var timer = setInterval(loadDiagnostics, 5000)
|
|
@@ -82,11 +89,45 @@ window.__ModuleLoader__.load({
|
|
|
82
89
|
|
|
83
90
|
var patch = toPatch(draft), dirty = JSON.stringify(patch) !== JSON.stringify(toPatch(toDraft(settings)))
|
|
84
91
|
var status = diagnostics.status || {}, pauseActive = Number(status.pauseUntil) > Date.now()
|
|
92
|
+
var focusCount = Number(status.focusedTabs) || 0
|
|
93
|
+
var statusNotes = []
|
|
94
|
+
if (status.quietActive) statusNotes.push('勿扰时段中')
|
|
95
|
+
if (pauseActive) statusNotes.push('通知已暂停')
|
|
96
|
+
if (focusCount > 0) statusNotes.push(focusCount + ' 个标签页聚焦中,完成通知将被抑制')
|
|
97
|
+
var statusNote = statusNotes.length ? statusNotes.join(';') : '运行正常'
|
|
85
98
|
var availableSounds = catalog.names.length ? catalog.names : FALLBACK_SOUNDS
|
|
86
99
|
var change = function (field, value) { setDraft(Object.assign({}, draft, { [field]: value })) }
|
|
87
100
|
var changeSound = function (kind, value) { setDraft(Object.assign({}, draft, { sounds: Object.assign({}, draft.sounds, { [kind]: value }) })) }
|
|
101
|
+
var NUMBER_BOUNDS = { minDurationSec: [0, 3600], onlyWhenIdleSec: [0, 3600], digestMinutes: [0, 1440], coalesceMs: [0, 60000], duplicateWindowSec: [0, 86400] }
|
|
102
|
+
var clampNumber = function (field, raw) {
|
|
103
|
+
var bounds = NUMBER_BOUNDS[field] || [0, Infinity]
|
|
104
|
+
var num = Math.floor(Number(raw))
|
|
105
|
+
if (!Number.isFinite(num)) num = bounds[0]
|
|
106
|
+
return Math.min(bounds[1], Math.max(bounds[0], num))
|
|
107
|
+
}
|
|
108
|
+
// 读视图下的单字段即时保存:与快捷暂停同走 op:'set';批量编辑期间退回草稿模式
|
|
109
|
+
var saveNow = function (field, value) {
|
|
110
|
+
setSaving(true); setError('')
|
|
111
|
+
props.call('settings', { op: 'set', field: field, value: value }).then(function (next) {
|
|
112
|
+
setSettings(next); setDraft(toDraft(next)); setSaved(true); loadDiagnostics()
|
|
113
|
+
if (savedTimer.current) clearTimeout(savedTimer.current)
|
|
114
|
+
savedTimer.current = setTimeout(function () { setSaved(false) }, 1800)
|
|
115
|
+
}).catch(function (err) { setError(String(err && err.message || '保存失败')) }).finally(function () { setSaving(false) })
|
|
116
|
+
}
|
|
117
|
+
var previewSound = function (name) {
|
|
118
|
+
if (!name || testing) return
|
|
119
|
+
setTesting('preview:' + name); setError('')
|
|
120
|
+
props.call('sound/preview', { name: name }).catch(function (err) { setError(String(err && err.message || '试听失败')) }).finally(function () { setTesting('') })
|
|
121
|
+
}
|
|
122
|
+
var customPauseState = React.useState('30'), customPause = customPauseState[0], setCustomPause = customPauseState[1]
|
|
123
|
+
var humanizeDuration = function (ms) {
|
|
124
|
+
var minutes = Math.max(1, Math.round(ms / 60000))
|
|
125
|
+
if (minutes < 60) return minutes + ' 分钟'
|
|
126
|
+
var hours = Math.floor(minutes / 60), rest = minutes % 60
|
|
127
|
+
return rest ? hours + ' 小时 ' + rest + ' 分钟' : hours + ' 小时'
|
|
128
|
+
}
|
|
88
129
|
var beginEdit = function () { setDraft(toDraft(settings)); setEditing(true); setSaved(false); setError('') }
|
|
89
|
-
var cancel = function () { setDraft(toDraft(settings)); setEditing(false); setError('') }
|
|
130
|
+
var cancel = function () { if (dirty && !window.confirm('放弃未保存的修改?')) return; setDraft(toDraft(settings)); setEditing(false); setError('') }
|
|
90
131
|
var save = function () {
|
|
91
132
|
if (!dirty || saving) return
|
|
92
133
|
setSaving(true); setError('')
|
|
@@ -119,7 +160,8 @@ window.__ModuleLoader__.load({
|
|
|
119
160
|
reader.onload = function () {
|
|
120
161
|
props.call('sound/import', { filename: file.name, data: String(reader.result || '').split(',')[1] || '' }).then(function (result) {
|
|
121
162
|
if (!result || !result.name) throw new Error('导入结果无效')
|
|
122
|
-
|
|
163
|
+
if (importTarget !== 'none') changeSound(importTarget, result.name)
|
|
164
|
+
return loadCatalog()
|
|
123
165
|
}).catch(function (err) { setError(String(err && err.message || '导入失败')) }).finally(function () { setImporting(false) })
|
|
124
166
|
}
|
|
125
167
|
reader.onerror = function () { setImporting(false); setError('声音文件读取失败') }
|
|
@@ -145,45 +187,51 @@ window.__ModuleLoader__.load({
|
|
|
145
187
|
var removeRule = function (index) { change('projectRules', draft.projectRules.filter(function (_, candidate) { return candidate !== index })) }
|
|
146
188
|
var addRule = function () { change('projectRules', draft.projectRules.concat({ path: props.getCurrentCwd() || '', mode: 'mute' })) }
|
|
147
189
|
|
|
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' } },
|
|
190
|
+
return h('div', { className: saved ? 'dsh-notify-saved' : '', style: { border: '1px solid var(--dsw-alias-border-l2,#444)', borderRadius: '10px', padding: editing ? '14px 16px 52px' : '14px 16px', fontSize: '13px', display: 'flex', flexDirection: 'column', gap: '12px' } },
|
|
149
191
|
h('style', null, css),
|
|
150
192
|
h('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' } },
|
|
151
193
|
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 ? '已保存 ✓' : '编辑全部')),
|
|
194
|
+
editing ? [h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(47,111,237,.1)' } }, '整体编辑中'), dirty ? h('span', { style: { padding: '3px 7px', borderRadius: '99px', fontSize: '11px', background: 'rgba(179,107,0,.15)', color: '#b36b00' } }, '未保存') : null] : h('button', { style: buttonStyle, disabled: saved, onClick: beginEdit }, saved ? '已保存 ✓' : '编辑全部')),
|
|
153
195
|
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
196
|
|
|
155
|
-
h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note:
|
|
197
|
+
h('div', { style: sectionStyle }, h(SectionTitle, { title: '运行状态与测试', note: statusNote }),
|
|
156
198
|
h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap' } }, ['completed', 'error', 'approval', 'aborted', 'coalesced', 'digest'].map(function (kind) {
|
|
157
199
|
var names = { completed: '完成', error: '错误', approval: '审批', aborted: '中断', coalesced: '合并', digest: '摘要' }
|
|
158
200
|
return h('button', { key: kind, style: buttonStyle, onClick: function () { test(kind, draft.sounds[kind]) } }, testing === kind ? '发送中…' : '测试' + names[kind])
|
|
159
201
|
})),
|
|
160
|
-
h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } }, h('small', { style: { opacity: .55 } }, pauseActive ? '暂停至 ' + new Date(status.pauseUntil).
|
|
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 小时'),
|
|
202
|
+
h('div', { style: { display: 'flex', gap: '6px', flexWrap: 'wrap', alignItems: 'center' } }, h('small', { style: { opacity: .55 } }, pauseActive ? '暂停至 ' + new Date(status.pauseUntil).toLocaleString() + '(剩余约 ' + humanizeDuration(status.pauseUntil - Date.now()) + ')' : '临时暂停:'),
|
|
203
|
+
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 小时'),
|
|
204
|
+
h('input', { 'aria-label': '自定义暂停分钟数', style: Object.assign({}, inputStyle, { width: '76px' }), type: 'number', min: 1, max: 10080, value: customPause, onChange: function (e) { setCustomPause(e.target.value) } }),
|
|
205
|
+
h('button', { style: buttonStyle, onClick: function () { var minutes = Math.floor(Number(customPause)); if (minutes > 0) pause(Math.min(minutes, 10080) * 60000) } }, '暂停'),
|
|
206
|
+
pauseActive ? h('button', { style: buttonStyle, onClick: function () { pause(0) } }, '立即恢复') : null)),
|
|
162
207
|
|
|
163
208
|
h('div', { style: sectionStyle }, h(SectionTitle, { title: '通知事件与过滤' }),
|
|
164
|
-
[['onCompleted', '任务完成'], ['onError', '错误与阻止'], ['onApproval', '等待审批'], ['onAborted', '用户中断']].map(function (row) { return h(Toggle, { key: row[0], disabled:
|
|
165
|
-
h(Toggle, { disabled:
|
|
166
|
-
h(Toggle, { disabled:
|
|
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:
|
|
209
|
+
[['onCompleted', '任务完成'], ['onError', '错误与阻止'], ['onApproval', '等待审批'], ['onAborted', '用户中断']].map(function (row) { return h(Toggle, { key: row[0], disabled: saving, value: draft[row[0]], label: row[1], onChange: function (value) { if (editing) change(row[0], value); else saveNow(row[0], value) } }) }),
|
|
210
|
+
h(Toggle, { disabled: saving, value: draft.onlyWhenUnfocused, label: '仅在 DSH 页面未聚焦时发送完成通知', onChange: function (value) { if (editing) change('onlyWhenUnfocused', value); else saveNow('onlyWhenUnfocused', value) } }),
|
|
211
|
+
h(Toggle, { disabled: saving, value: draft.includeSubagents, label: '包含子 Agent 会话', onChange: function (value) { if (editing) change('includeSubagents', value); else saveNow('includeSubagents', value) } }),
|
|
212
|
+
[['minDurationSec', '完成通知最短耗时(秒)', 3600], ['onlyWhenIdleSec', '键鼠空闲门槛(秒)', 3600], ['digestMinutes', '摘要间隔(分钟)', 1440], ['coalesceMs', '合并窗口(毫秒)', 60000], ['duplicateWindowSec', '重复错误抑制(秒)', 86400]].map(function (row) { return h(SettingRow, { key: row[0], label: row[1] }, h('input', { style: inputStyle, type: 'number', min: 0, max: row[2], disabled: !editing, value: draft[row[0]], onChange: function (e) { change(row[0], clampNumber(row[0], e.target.value)) } })) }),
|
|
213
|
+
h(SettingRow, { label: '通知通道', note: 'OSC 9 仅支持 iTerm2、WezTerm、Kitty、Ghostty、Warp' }, h('select', { style: inputStyle, disabled: saving, value: draft.channel, onChange: function (e) { var value = e.target.value; if (editing) change('channel', value); else saveNow('channel', value) } }, h('option', { value: 'auto' }, '自动'), h('option', { value: 'osascript' }, 'osascript'), h('option', { value: 'osc9' }, 'OSC 9')))),
|
|
169
214
|
|
|
170
215
|
h('div', { style: sectionStyle }, h(SectionTitle, { title: '每日勿扰', note: '使用本机时间' }),
|
|
171
|
-
h(Toggle, { disabled:
|
|
216
|
+
h(Toggle, { disabled: saving, value: draft.quietHoursEnabled, label: '启用每日勿扰时段', onChange: function (value) { if (editing) change('quietHoursEnabled', value); else saveNow('quietHoursEnabled', value) } }),
|
|
172
217
|
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(
|
|
218
|
+
draft.quietHoursEnabled && draft.quietStart === draft.quietEnd ? h('small', { style: { opacity: .7, color: '#b36b00' } }, '开始与结束时间相同视为未启用') : null,
|
|
219
|
+
h(Toggle, { disabled: saving, value: draft.quietAllowCritical, label: '勿扰时仍允许错误、阻止和审批', onChange: function (value) { if (editing) change('quietAllowCritical', value); else saveNow('quietAllowCritical', value) } })),
|
|
174
220
|
|
|
175
221
|
h('div', { style: sectionStyle }, h(SectionTitle, { title: '提示音', note: '单个≤5MB/10秒;最多20个/50MB' }),
|
|
176
|
-
|
|
177
|
-
|
|
222
|
+
status.channel === 'osc9' ? h('small', { style: { opacity: .55 } }, '当前经 OSC 9 输出:通知声音由终端控制;此处设置对 osascript 通道生效,试听为本地播放') : null,
|
|
223
|
+
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('select', { style: Object.assign({}, inputStyle, { width: '150px' }), value: importTarget, onChange: function (e) { setImportTarget(e.target.value) } }, h('option', { value: 'none' }, '导入后不指派'), SOUND_ROWS.map(function (row) { return h('option', { key: row[0], value: row[0] }, '并指派给' + row[1]) })), h('small', { style: { opacity: .55 } }, '已管理 ' + catalog.managed.length + ' 个')) : null,
|
|
224
|
+
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: saving, value: value, onChange: function (e) { var next = e.target.value; if (editing) changeSound(kind, next); else saveNow('sounds', Object.assign({}, draft.sounds, { [kind]: next })) } }, h('option', { value: '' }, '静音'), choices.map(function (name) { return h('option', { key: name, value: name }, name) })), h('button', { style: buttonStyle, disabled: !value, onClick: function () { previewSound(value) } }, testing === 'preview:' + value ? '播放中…' : '试听')) }),
|
|
178
225
|
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
226
|
|
|
180
227
|
h('div', { style: sectionStyle }, h(SectionTitle, { title: '项目规则', note: '更具体的路径优先' }),
|
|
181
|
-
|
|
228
|
+
h('datalist', { id: 'dsh-notify-cwds' }, (props.getSessionCwds ? props.getSessionCwds() : []).map(function (cwd) { return h('option', { key: cwd, value: cwd }) })),
|
|
229
|
+
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, list: 'dsh-notify-cwds', 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
230
|
editing ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: addRule }, props.getCurrentCwd() ? '添加当前项目' : '添加规则') : null),
|
|
183
231
|
|
|
184
232
|
h('div', { style: sectionStyle }, h(SectionTitle, { title: '最近通知诊断', note: '当前进程最近50条' }),
|
|
185
233
|
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),
|
|
234
|
+
diagnostics.entries.length ? h('button', { style: Object.assign({}, buttonStyle, { alignSelf: 'flex-start' }), onClick: function () { if (!window.confirm('清空全部诊断记录?')) return; props.call('diagnostics', { op: 'clear' }).then(setDiagnostics) } }, '清空记录') : null),
|
|
187
235
|
|
|
188
236
|
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
237
|
}
|
|
@@ -196,13 +244,14 @@ window.__ModuleLoader__.load({
|
|
|
196
244
|
var timer = setInterval(report, 30000)
|
|
197
245
|
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
246
|
var getCurrentCwd = function () { try { var state = ctx.sessions.list.getSnapshot(); return state.current && state.byId[state.current] && state.byId[state.current].cwd || '' } catch { return '' } }
|
|
247
|
+
var getSessionCwds = function () { try { var state = ctx.sessions.list.getSnapshot(); var seen = {}, cwds = []; Object.keys(state.byId || {}).forEach(function (id) { var cwd = state.byId[id] && state.byId[id].cwd; if (cwd && !seen[cwd]) { seen[cwd] = true; cwds.push(cwd) } }); return cwds } catch { return [] } }
|
|
199
248
|
ctx.slots.inject('settings.section', function () {
|
|
200
249
|
return ctx.slots.register({
|
|
201
250
|
name: 'settings.section',
|
|
202
251
|
id: 'macos-notify',
|
|
203
252
|
order: 100,
|
|
204
253
|
label: 'macOS 通知',
|
|
205
|
-
inject: function () { return { call: call, getCurrentCwd: getCurrentCwd } },
|
|
254
|
+
inject: function () { return { call: call, getCurrentCwd: getCurrentCwd, getSessionCwds: getSessionCwds } },
|
|
206
255
|
}, Card)
|
|
207
256
|
})
|
|
208
257
|
ctx.effect(function () { return function () { clearInterval(timer); document.removeEventListener('visibilitychange', report); window.removeEventListener('focus', report); window.removeEventListener('blur', report) } })
|
package/index.js
CHANGED
|
@@ -5,6 +5,21 @@ import { homedir, tmpdir } from 'node:os'
|
|
|
5
5
|
import { basename, extname, join, resolve } from 'node:path'
|
|
6
6
|
import { promisify } from 'node:util'
|
|
7
7
|
import Schema from '@deepseek-ai/schemastery'
|
|
8
|
+
import {
|
|
9
|
+
DuplicateTracker,
|
|
10
|
+
SOUND_KINDS,
|
|
11
|
+
TtlCache,
|
|
12
|
+
buildNotificationScript,
|
|
13
|
+
duplicateKey,
|
|
14
|
+
isCompletionKind,
|
|
15
|
+
isCriticalKind,
|
|
16
|
+
matchingProjectRule,
|
|
17
|
+
parseProjectRules,
|
|
18
|
+
quietHoursActive,
|
|
19
|
+
truncateNotification,
|
|
20
|
+
validateSettingsPatch,
|
|
21
|
+
} from './src/policy.js'
|
|
22
|
+
import { loadStateSync, saveState } from './src/state.js'
|
|
8
23
|
|
|
9
24
|
export const name = 'dsh-macos-notify'
|
|
10
25
|
export const inject = ['sessions', 'settings']
|
|
@@ -57,8 +72,6 @@ export const Config = Schema.object({
|
|
|
57
72
|
notifyOnLoad: Schema.boolean().default(false),
|
|
58
73
|
})
|
|
59
74
|
|
|
60
|
-
/** 设置页可编辑的提示音字段 */
|
|
61
|
-
const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
|
|
62
75
|
const SOUND_EXTENSIONS = new Set(['.aif', '.aiff', '.caf', '.m4a', '.wav'])
|
|
63
76
|
const IMPORT_EXTENSIONS = new Set([
|
|
64
77
|
'.aac', '.aif', '.aiff', '.caf', '.flac', '.m4a', '.mp3', '.oga', '.ogg', '.opus', '.wav',
|
|
@@ -78,6 +91,12 @@ function soundRegistryPath() {
|
|
|
78
91
|
return join(homedir(), 'Library/Application Support/dsh-macos-notify/sounds.json')
|
|
79
92
|
}
|
|
80
93
|
|
|
94
|
+
/** 决策历史与重复合并状态的落盘位置;测试可通过 DSH_MACOS_NOTIFY_STATE_FILE 重定向 */
|
|
95
|
+
function stateFilePath() {
|
|
96
|
+
return process.env.DSH_MACOS_NOTIFY_STATE_FILE
|
|
97
|
+
|| join(homedir(), 'Library/Application Support/dsh-macos-notify/state.json')
|
|
98
|
+
}
|
|
99
|
+
|
|
81
100
|
async function readSoundRegistry() {
|
|
82
101
|
try {
|
|
83
102
|
const parsed = JSON.parse(await readFile(soundRegistryPath(), 'utf8'))
|
|
@@ -245,6 +264,31 @@ async function deleteManagedSound(name) {
|
|
|
245
264
|
await writeSoundRegistry(registry)
|
|
246
265
|
}
|
|
247
266
|
|
|
267
|
+
/** 试听:把声音名解析为磁盘文件后交给 afplay 本地播放,不经过通知通道 */
|
|
268
|
+
async function previewSound(name) {
|
|
269
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) {
|
|
270
|
+
throw new Error('声音名无效')
|
|
271
|
+
}
|
|
272
|
+
const dir = userSoundsDir()
|
|
273
|
+
const registry = await readSoundRegistry()
|
|
274
|
+
const managed = registry.find((item) => item.name === name)
|
|
275
|
+
const candidates = managed ? [join(dir, managed.filename)] : []
|
|
276
|
+
for (const base of ['/System/Library/Sounds', '/Library/Sounds', dir]) {
|
|
277
|
+
for (const extension of SOUND_EXTENSIONS) candidates.push(join(base, `${name}${extension}`))
|
|
278
|
+
}
|
|
279
|
+
for (const candidate of candidates) {
|
|
280
|
+
try {
|
|
281
|
+
const info = await stat(candidate)
|
|
282
|
+
if (!info.isFile()) continue
|
|
283
|
+
} catch {
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
await execFileAsync('/usr/bin/afplay', [candidate])
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
throw new Error('找不到声音文件')
|
|
290
|
+
}
|
|
291
|
+
|
|
248
292
|
/** macOS 系统与用户声音目录;读取失败的目录直接忽略 */
|
|
249
293
|
async function systemSoundNames() {
|
|
250
294
|
const dirs = [
|
|
@@ -277,19 +321,13 @@ async function soundCatalog() {
|
|
|
277
321
|
}
|
|
278
322
|
}
|
|
279
323
|
|
|
280
|
-
function esc(s) {
|
|
281
|
-
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
282
|
-
}
|
|
283
|
-
|
|
284
324
|
function notify(title, body, sound, channel, onResult = () => {}) {
|
|
285
325
|
if (channel === 'osc9') {
|
|
286
326
|
emitOsc9(title, body)
|
|
287
327
|
onResult(null)
|
|
288
328
|
return
|
|
289
329
|
}
|
|
290
|
-
|
|
291
|
-
const script = `display notification "${esc(body)}" with title "${esc(title)}"${soundPart}`
|
|
292
|
-
execFile('osascript', ['-e', script], (err) => {
|
|
330
|
+
execFile('osascript', ['-e', buildNotificationScript(title, body, sound)], (err) => {
|
|
293
331
|
if (err) console.warn('[dsh-macos-notify] osascript failed:', err.message)
|
|
294
332
|
onResult(err ?? null)
|
|
295
333
|
})
|
|
@@ -316,7 +354,8 @@ function sanitizeOsc9(s) {
|
|
|
316
354
|
}
|
|
317
355
|
|
|
318
356
|
function emitOsc9(title, body) {
|
|
319
|
-
const
|
|
357
|
+
const truncated = truncateNotification(title, body)
|
|
358
|
+
const message = [truncated.title, truncated.body].map(sanitizeOsc9).filter(Boolean).join(': ').slice(0, 256)
|
|
320
359
|
if (!message) return
|
|
321
360
|
let seq = `\x1b]9;${message}\x07`
|
|
322
361
|
// tmux 会吞掉 OSC,需要 DCS passthrough 包裹并把载荷里的 ESC 双写
|
|
@@ -336,53 +375,6 @@ function idleSeconds() {
|
|
|
336
375
|
})
|
|
337
376
|
}
|
|
338
377
|
|
|
339
|
-
function minuteOfDay(value) {
|
|
340
|
-
const match = /^(\d{2}):(\d{2})$/.exec(String(value))
|
|
341
|
-
if (!match) return null
|
|
342
|
-
const hour = Number(match[1])
|
|
343
|
-
const minute = Number(match[2])
|
|
344
|
-
if (hour > 23 || minute > 59) return null
|
|
345
|
-
return hour * 60 + minute
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function quietHoursActive(config, now = new Date()) {
|
|
349
|
-
if (!config.quietHoursEnabled) return false
|
|
350
|
-
const start = minuteOfDay(config.quietStart)
|
|
351
|
-
const end = minuteOfDay(config.quietEnd)
|
|
352
|
-
if (start === null || end === null || start === end) return false
|
|
353
|
-
const currentMinute = now.getHours() * 60 + now.getMinutes()
|
|
354
|
-
return start < end
|
|
355
|
-
? currentMinute >= start && currentMinute < end
|
|
356
|
-
: currentMinute >= start || currentMinute < end
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function parseProjectRules(raw) {
|
|
360
|
-
try {
|
|
361
|
-
const value = JSON.parse(raw)
|
|
362
|
-
if (!Array.isArray(value)) return []
|
|
363
|
-
return value.slice(0, 50).flatMap((item) => {
|
|
364
|
-
const path = typeof item?.path === 'string' ? item.path.trim() : ''
|
|
365
|
-
const mode = item?.mode
|
|
366
|
-
if (!path || !['mute', 'errors', 'important'].includes(mode)) return []
|
|
367
|
-
return [{ path: resolve(path), mode }]
|
|
368
|
-
})
|
|
369
|
-
} catch {
|
|
370
|
-
return []
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
function matchingProjectRule(rules, cwd) {
|
|
375
|
-
if (!cwd) return null
|
|
376
|
-
const target = resolve(cwd)
|
|
377
|
-
return rules
|
|
378
|
-
.filter((rule) => target === rule.path || target.startsWith(`${rule.path}/`))
|
|
379
|
-
.sort((a, b) => b.path.length - a.path.length)[0] ?? null
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
function isCriticalKind(kind) {
|
|
383
|
-
return kind === '出错' || kind === '被阻止' || kind === '审批'
|
|
384
|
-
}
|
|
385
|
-
|
|
386
378
|
function applyImpl(ctx, config) {
|
|
387
379
|
// 配置三层叠加:schema 默认值 < cordis 组合层(base = 插件 config)< 用户层(设置页)。
|
|
388
380
|
// 设置页写入后经 watch 实时生效,不需要重启。
|
|
@@ -398,16 +390,37 @@ function applyImpl(ctx, config) {
|
|
|
398
390
|
// 合并窗口内待发的轮次结束通知(实时通道)
|
|
399
391
|
let pending = []
|
|
400
392
|
let flushTimer = null
|
|
401
|
-
// digest
|
|
393
|
+
// digest 通道:只攒「完成」;发送时刻绑定在已积累的批次上,设置变更不重置已排定的 deadline
|
|
402
394
|
let digestPending = []
|
|
403
395
|
let digestTimer = null
|
|
396
|
+
let digestDeadline = 0
|
|
404
397
|
// 最近的通知决策,仅保存在当前进程内;设置页用于解释“为什么没弹”。
|
|
405
398
|
let diagnostics = []
|
|
406
399
|
let diagnosticSeq = 0
|
|
407
400
|
// 重复错误键 -> 首次发送时间、被抑制次数
|
|
408
|
-
|
|
401
|
+
let duplicates = new DuplicateTracker()
|
|
409
402
|
let projectRules = parseProjectRules(current.projectRulesJson)
|
|
410
403
|
|
|
404
|
+
// —— 状态持久化:通知决策、重复合并、会话标题。防抖落盘,退出时立即 flush ——
|
|
405
|
+
const stateFile = stateFilePath()
|
|
406
|
+
let persistTimer = null
|
|
407
|
+
const schedulePersist = () => {
|
|
408
|
+
if (persistTimer) return
|
|
409
|
+
persistTimer = setTimeout(() => {
|
|
410
|
+
persistTimer = null
|
|
411
|
+
saveState(stateFile, {
|
|
412
|
+
diagnostics,
|
|
413
|
+
duplicates: duplicates.toJSON(),
|
|
414
|
+
titles: [...titles.entries()],
|
|
415
|
+
}).catch((err) => console.warn('[dsh-macos-notify] state persist failed:', err?.message ?? err))
|
|
416
|
+
}, 500)
|
|
417
|
+
persistTimer.unref?.()
|
|
418
|
+
}
|
|
419
|
+
const restored = loadStateSync(stateFile)
|
|
420
|
+
if (diagnostics.length === 0 && restored.diagnostics.length) diagnostics = restored.diagnostics
|
|
421
|
+
if (duplicates.size === 0 && restored.duplicates.length) duplicates = DuplicateTracker.fromJSON(restored.duplicates)
|
|
422
|
+
if (titles.size === 0 && restored.titles.length) for (const [id, title] of restored.titles) titles.set(id, title)
|
|
423
|
+
|
|
411
424
|
const record = (status, item, detail, extra = {}) => {
|
|
412
425
|
diagnostics.unshift({
|
|
413
426
|
id: ++diagnosticSeq,
|
|
@@ -423,6 +436,7 @@ function applyImpl(ctx, config) {
|
|
|
423
436
|
...extra,
|
|
424
437
|
})
|
|
425
438
|
if (diagnostics.length > MAX_DIAGNOSTICS) diagnostics.length = MAX_DIAGNOSTICS
|
|
439
|
+
schedulePersist()
|
|
426
440
|
}
|
|
427
441
|
|
|
428
442
|
// 通知通道:auto 在支持的终端里走 OSC 9(终端自己转系统通知),否则 osascript
|
|
@@ -536,9 +550,21 @@ function applyImpl(ctx, config) {
|
|
|
536
550
|
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
537
551
|
}
|
|
538
552
|
}
|
|
553
|
+
if (endpoint === 'sound/preview') {
|
|
554
|
+
try {
|
|
555
|
+
await previewSound(typeof payload?.name === 'string' ? payload.name.trim() : '')
|
|
556
|
+
return { ok: true, value: null }
|
|
557
|
+
} catch (err) {
|
|
558
|
+
console.warn('[dsh-macos-notify] sound preview failed:', err?.message ?? err)
|
|
559
|
+
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
560
|
+
}
|
|
561
|
+
}
|
|
539
562
|
if (endpoint === 'diagnostics') {
|
|
540
563
|
const op = payload?.op ?? 'get'
|
|
541
|
-
if (op === 'clear')
|
|
564
|
+
if (op === 'clear') {
|
|
565
|
+
diagnostics = []
|
|
566
|
+
schedulePersist()
|
|
567
|
+
}
|
|
542
568
|
anyFocused()
|
|
543
569
|
return {
|
|
544
570
|
ok: true,
|
|
@@ -552,6 +578,7 @@ function applyImpl(ctx, config) {
|
|
|
552
578
|
pauseUntil: current.pauseUntil,
|
|
553
579
|
pending: pending.length,
|
|
554
580
|
digestPending: digestPending.length,
|
|
581
|
+
digestDeadline,
|
|
555
582
|
},
|
|
556
583
|
},
|
|
557
584
|
}
|
|
@@ -562,32 +589,15 @@ function applyImpl(ctx, config) {
|
|
|
562
589
|
if (op === 'get') return { ok: true, value: scope.get() }
|
|
563
590
|
if (op === 'set' && typeof payload.field === 'string') {
|
|
564
591
|
try {
|
|
565
|
-
await scope.update({ [payload.field]: payload.value })
|
|
566
|
-
return { ok: true, value:
|
|
592
|
+
await scope.update(validateSettingsPatch({ [payload.field]: payload.value }, current))
|
|
593
|
+
return { ok: true, value: scope.get() }
|
|
567
594
|
} catch (err) {
|
|
568
595
|
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
569
596
|
}
|
|
570
597
|
}
|
|
571
598
|
if (op === 'patch' && payload.value && typeof payload.value === 'object' && !Array.isArray(payload.value)) {
|
|
572
599
|
try {
|
|
573
|
-
|
|
574
|
-
'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
|
|
575
|
-
'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
|
|
576
|
-
'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
|
|
577
|
-
'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
|
|
578
|
-
])
|
|
579
|
-
if (Object.keys(payload.value).some((key) => !editable.has(key))) throw new Error('包含不可编辑的设置字段')
|
|
580
|
-
for (const field of ['quietStart', 'quietEnd']) {
|
|
581
|
-
if (field in payload.value && minuteOfDay(payload.value[field]) === null) throw new Error('勿扰时间格式无效')
|
|
582
|
-
}
|
|
583
|
-
if (typeof payload.value.projectRulesJson === 'string') {
|
|
584
|
-
const parsed = JSON.parse(payload.value.projectRulesJson)
|
|
585
|
-
if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
|
|
586
|
-
if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
|
|
587
|
-
throw new Error('项目规则格式无效')
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
await scope.update(payload.value)
|
|
600
|
+
await scope.update(validateSettingsPatch(payload.value, current))
|
|
591
601
|
return { ok: true, value: scope.get() }
|
|
592
602
|
} catch (err) {
|
|
593
603
|
return { ok: false, error: { code: 'internal', message: String(err?.message ?? err), details: {} } }
|
|
@@ -655,41 +665,44 @@ function applyImpl(ctx, config) {
|
|
|
655
665
|
}
|
|
656
666
|
|
|
657
667
|
const applyDuplicatePolicy = (item) => {
|
|
658
|
-
if (!['出错', '被阻止'].includes(item.kind)
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
if (previous && now - previous.at < windowMs) {
|
|
664
|
-
previous.count += 1
|
|
665
|
-
record('suppressed', item, `重复通知已合并(本窗口第 ${previous.count} 次)`)
|
|
668
|
+
if (!['出错', '被阻止'].includes(item.kind)) return true
|
|
669
|
+
const decision = duplicates.admit(duplicateKey(item), Date.now(), current.duplicateWindowSec * 1000)
|
|
670
|
+
schedulePersist()
|
|
671
|
+
if (!decision.send) {
|
|
672
|
+
record('suppressed', item, `重复通知已合并(本窗口第 ${decision.count} 次)`)
|
|
666
673
|
return false
|
|
667
674
|
}
|
|
668
|
-
if (
|
|
669
|
-
duplicates.set(key, { at: now, count: 0 })
|
|
670
|
-
for (const [candidate, value] of duplicates) {
|
|
671
|
-
if (now - value.at > Math.max(windowMs * 2, 60_000)) duplicates.delete(candidate)
|
|
672
|
-
}
|
|
675
|
+
if (decision.suffix) item.body += decision.suffix
|
|
673
676
|
return true
|
|
674
677
|
}
|
|
675
678
|
|
|
679
|
+
// 键鼠空闲查询要 fork ioreg,5 秒内复用上次结果
|
|
680
|
+
const idleCache = new TtlCache(5000)
|
|
681
|
+
const idleSecondsCached = async () => {
|
|
682
|
+
const cached = idleCache.get(Date.now())
|
|
683
|
+
if (cached.hit) return cached.value
|
|
684
|
+
const idle = await idleSeconds()
|
|
685
|
+
idleCache.set(Date.now(), idle)
|
|
686
|
+
return idle
|
|
687
|
+
}
|
|
688
|
+
|
|
676
689
|
// 发送前再检查实时策略;摘要可能在进入队列后才跨入勿扰时段。
|
|
677
690
|
const gateAndNotify = async (items, sendBatch) => {
|
|
678
691
|
let allowed = items.filter(applyTimePolicy)
|
|
679
692
|
if (allowed.length === 0) return
|
|
680
693
|
if (current.onlyWhenUnfocused && anyFocused()) {
|
|
681
694
|
allowed = allowed.filter((item) => {
|
|
682
|
-
if (item.kind
|
|
695
|
+
if (!isCompletionKind(item.kind) || item.important) return true
|
|
683
696
|
record('suppressed', item, 'DSH Web 页面当前处于聚焦状态')
|
|
684
697
|
return false
|
|
685
698
|
})
|
|
686
699
|
}
|
|
687
|
-
const idleCandidates = allowed.filter((item) => item.kind
|
|
700
|
+
const idleCandidates = allowed.filter((item) => isCompletionKind(item.kind) && !item.important)
|
|
688
701
|
if (current.onlyWhenIdleSec > 0 && idleCandidates.length) {
|
|
689
|
-
const idle = await
|
|
702
|
+
const idle = await idleSecondsCached()
|
|
690
703
|
if (idle < current.onlyWhenIdleSec) {
|
|
691
704
|
allowed = allowed.filter((item) => {
|
|
692
|
-
if (item.kind
|
|
705
|
+
if (!isCompletionKind(item.kind) || item.important) return true
|
|
693
706
|
record('suppressed', item, `键鼠仅空闲 ${Math.floor(idle)} 秒,要求 ${current.onlyWhenIdleSec} 秒`)
|
|
694
707
|
return false
|
|
695
708
|
})
|
|
@@ -743,40 +756,52 @@ function applyImpl(ctx, config) {
|
|
|
743
756
|
})
|
|
744
757
|
}
|
|
745
758
|
|
|
746
|
-
|
|
759
|
+
// 摘要按绝对 deadline 触发:入队时排定,设置变更只重挂定时器、不重置等待时间;
|
|
760
|
+
// 关闭摘要时立即发掉已积累的批次,避免通知滞留。
|
|
761
|
+
const armDigest = () => {
|
|
747
762
|
if (digestTimer) {
|
|
748
|
-
|
|
763
|
+
clearTimeout(digestTimer)
|
|
749
764
|
digestTimer = null
|
|
750
765
|
}
|
|
751
|
-
if (current.digestMinutes
|
|
752
|
-
|
|
766
|
+
if (current.digestMinutes <= 0 || digestPending.length === 0) {
|
|
767
|
+
digestDeadline = 0
|
|
768
|
+
return
|
|
753
769
|
}
|
|
770
|
+
if (!digestDeadline) digestDeadline = Date.now() + current.digestMinutes * 60_000
|
|
771
|
+
digestTimer = setTimeout(() => {
|
|
772
|
+
digestTimer = null
|
|
773
|
+
digestDeadline = 0
|
|
774
|
+
flushDigest()
|
|
775
|
+
}, Math.max(0, digestDeadline - Date.now()))
|
|
776
|
+
digestTimer.unref?.()
|
|
754
777
|
}
|
|
755
|
-
setupDigest()
|
|
756
778
|
|
|
757
|
-
//
|
|
779
|
+
// 设置页写入实时生效:换配置快照、重算通道、按新间隔重挂 digest 定时器
|
|
758
780
|
scope.watch((next) => {
|
|
759
781
|
current = next
|
|
760
782
|
resolvedChannel = resolveChannel()
|
|
761
783
|
projectRules = parseProjectRules(current.projectRulesJson)
|
|
762
|
-
|
|
784
|
+
if (current.digestMinutes <= 0 && digestPending.length) flushDigest()
|
|
785
|
+
armDigest()
|
|
763
786
|
})
|
|
764
787
|
|
|
765
788
|
const enqueue = (kind, title, body, sound, session, options = {}) => {
|
|
766
789
|
const item = makeItem(kind, title, body, sound, session)
|
|
767
790
|
if (!applyProjectRule(item)) return
|
|
768
|
-
if (kind
|
|
791
|
+
if (isCompletionKind(kind) && options.durationSec < current.minDurationSec && !item.important) {
|
|
769
792
|
record('suppressed', item, `任务耗时 ${options.durationSec.toFixed(1)} 秒,短于 ${current.minDurationSec} 秒`)
|
|
770
793
|
return
|
|
771
794
|
}
|
|
772
795
|
if (!applyDuplicatePolicy(item)) return
|
|
773
|
-
if (kind
|
|
796
|
+
if (isCompletionKind(kind) && current.digestMinutes > 0) {
|
|
774
797
|
digestPending.push(item)
|
|
775
798
|
record('queued', item, `已进入 ${current.digestMinutes} 分钟摘要队列`)
|
|
799
|
+
armDigest()
|
|
776
800
|
return
|
|
777
801
|
}
|
|
778
802
|
if (current.coalesceMs <= 0) {
|
|
779
803
|
void gateAndNotify([item], (allowed) => {
|
|
804
|
+
const { title, body, sound } = render(allowed)
|
|
780
805
|
send(title, body + runningSuffix(), sound, allowed)
|
|
781
806
|
})
|
|
782
807
|
return
|
|
@@ -788,7 +813,10 @@ function applyImpl(ctx, config) {
|
|
|
788
813
|
|
|
789
814
|
ctx.on('session/event', (session, event) => {
|
|
790
815
|
if (event.type === 'session/title') {
|
|
816
|
+
// 标题按会话累积,封顶避免长驻进程缓慢泄漏
|
|
817
|
+
if (titles.size >= 200) titles.delete(titles.keys().next().value)
|
|
791
818
|
titles.set(session.id, event.data.title)
|
|
819
|
+
schedulePersist()
|
|
792
820
|
return
|
|
793
821
|
}
|
|
794
822
|
|
|
@@ -842,10 +870,12 @@ function applyImpl(ctx, config) {
|
|
|
842
870
|
if (event.type === 'approval/asked' && current.onApproval) {
|
|
843
871
|
// 审批需要人处理,立即发,不合并
|
|
844
872
|
const reason = event.data.reason ? ` — ${event.data.reason}` : ''
|
|
845
|
-
const
|
|
846
|
-
const item = makeItem('审批', '等待审批', body, current.sounds.approval, session)
|
|
873
|
+
const item = makeItem('审批', '等待审批', `${label(session)}: ${event.data.toolName}${reason}`, current.sounds.approval, session)
|
|
847
874
|
if (applyProjectRule(item)) {
|
|
848
|
-
void gateAndNotify([item], (allowed) =>
|
|
875
|
+
void gateAndNotify([item], (allowed) => {
|
|
876
|
+
const { title, body, sound } = render(allowed)
|
|
877
|
+
send(title, body, sound, allowed)
|
|
878
|
+
})
|
|
849
879
|
}
|
|
850
880
|
} else if (event.type === 'approval/asked') {
|
|
851
881
|
record('suppressed', makeItem('审批', '等待审批', label(session), current.sounds.approval, session), '审批通知已关闭')
|
|
@@ -856,6 +886,15 @@ function applyImpl(ctx, config) {
|
|
|
856
886
|
ctx.effect(() => () => {
|
|
857
887
|
if (flushTimer) clearTimeout(flushTimer)
|
|
858
888
|
if (digestTimer) clearInterval(digestTimer)
|
|
889
|
+
if (persistTimer) {
|
|
890
|
+
clearTimeout(persistTimer)
|
|
891
|
+
persistTimer = null
|
|
892
|
+
}
|
|
893
|
+
saveState(stateFile, {
|
|
894
|
+
diagnostics,
|
|
895
|
+
duplicates: duplicates.toJSON(),
|
|
896
|
+
titles: [...titles.entries()],
|
|
897
|
+
}).catch(() => {})
|
|
859
898
|
})
|
|
860
899
|
}
|
|
861
900
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-macos-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Native macOS notifications and configurable sounds for DeepSeek Harness",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"./client": "./client.js",
|
|
10
10
|
"./package.json": "./package.json"
|
|
11
11
|
},
|
|
12
|
-
"files": ["index.js", "client.js", "cordis.patch.yml"],
|
|
12
|
+
"files": ["index.js", "client.js", "src/policy.js", "src/state.js", "cordis.patch.yml"],
|
|
13
13
|
"keywords": ["dsh-plugin", "deepseek-harness", "macos", "notifications"],
|
|
14
14
|
"author": "CrombastiC",
|
|
15
15
|
"license": "MIT",
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
},
|
|
31
31
|
"os": ["darwin"],
|
|
32
32
|
"scripts": {
|
|
33
|
-
"test": "node --test tests/*.test.mjs"
|
|
33
|
+
"test": "node --test tests/*.test.mjs",
|
|
34
|
+
"prepublishOnly": "node --check index.js && node --check client.js && node --check src/policy.js && node --check src/state.js && npm test"
|
|
34
35
|
},
|
|
35
36
|
"dsh": {
|
|
36
37
|
"bundle": { "patch": "./cordis.patch.yml" },
|
package/src/policy.js
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
// 纯策略函数:时间窗、项目规则、重复合并。不碰 IO,便于脱离插件宿主单测。
|
|
2
|
+
import { resolve } from 'node:path'
|
|
3
|
+
|
|
4
|
+
/** 关键事件:勿扰时段和「errors」项目规则下仍然放行 */
|
|
5
|
+
export function isCriticalKind(kind) {
|
|
6
|
+
return kind === '出错' || kind === '被阻止' || kind === '审批'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** 「完成类」通知:唯一受 最短耗时 / 聚焦 / 空闲 / 摘要 过滤约束的类型 */
|
|
10
|
+
export function isCompletionKind(kind) {
|
|
11
|
+
return kind === '完成'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 本机 HH:mm -> 当日分钟数;格式无效返回 null */
|
|
15
|
+
export function minuteOfDay(value) {
|
|
16
|
+
const match = /^(\d{2}):(\d{2})$/.exec(String(value))
|
|
17
|
+
if (!match) return null
|
|
18
|
+
const hour = Number(match[1])
|
|
19
|
+
const minute = Number(match[2])
|
|
20
|
+
if (hour > 23 || minute > 59) return null
|
|
21
|
+
return hour * 60 + minute
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 每日勿扰是否生效;支持跨午夜区间,start === end 视为未配置 */
|
|
25
|
+
export function quietHoursActive(config, now = new Date()) {
|
|
26
|
+
if (!config.quietHoursEnabled) return false
|
|
27
|
+
const start = minuteOfDay(config.quietStart)
|
|
28
|
+
const end = minuteOfDay(config.quietEnd)
|
|
29
|
+
if (start === null || end === null || start === end) return false
|
|
30
|
+
const currentMinute = now.getHours() * 60 + now.getMinutes()
|
|
31
|
+
return start < end
|
|
32
|
+
? currentMinute >= start && currentMinute < end
|
|
33
|
+
: currentMinute >= start || currentMinute < end
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 项目规则 JSON -> [{ path, mode }];非法项丢弃,最多 50 条,相对路径按本进程 cwd 解析 */
|
|
37
|
+
export function parseProjectRules(raw) {
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(raw)
|
|
40
|
+
if (!Array.isArray(value)) return []
|
|
41
|
+
return value.slice(0, 50).flatMap((item) => {
|
|
42
|
+
const path = typeof item?.path === 'string' ? item.path.trim() : ''
|
|
43
|
+
const mode = item?.mode
|
|
44
|
+
if (!path || !['mute', 'errors', 'important'].includes(mode)) return []
|
|
45
|
+
return [{ path: resolve(path), mode }]
|
|
46
|
+
})
|
|
47
|
+
} catch {
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 命中 cwd 的最具体规则;前缀比较要求完整路径段,避免 /tmp 误匹配 /tmpx */
|
|
53
|
+
export function matchingProjectRule(rules, cwd) {
|
|
54
|
+
if (!cwd) return null
|
|
55
|
+
const target = resolve(cwd)
|
|
56
|
+
return rules
|
|
57
|
+
.filter((rule) => target === rule.path || target.startsWith(`${rule.path}/`))
|
|
58
|
+
.sort((a, b) => b.path.length - a.path.length)[0] ?? null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 重复指纹归一化:只收敛已知易变片段(UUID、长 token、带单位的数量/时长),
|
|
62
|
+
* 裸数字(状态码、端口、行号)原样保留,避免把不同错误合并成同一个 */
|
|
63
|
+
export function normalizeDuplicateText(text) {
|
|
64
|
+
let value = String(text ?? '').replace(/\s+/g, ' ').trim()
|
|
65
|
+
value = value.replace(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/g, '<uuid>')
|
|
66
|
+
value = value.replace(/(?<![0-9a-zA-Z_])[0-9a-fA-F]{16,}(?![0-9a-zA-Z_])/gi, '<token>')
|
|
67
|
+
value = value.replace(/(\d+(?:\.\d+)?)\s*(毫秒|秒|分钟|小时|次|个|MB|KB|GB|ms|s|min|seconds?|secs?)(?![0-9a-zA-Z_])/gi, '<n> $2')
|
|
68
|
+
return value.length > 500 ? value.slice(0, 500) : value
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function duplicateKey(item) {
|
|
72
|
+
return `${item.sessionId ?? ''}\u0000${item.kind}\u0000${normalizeDuplicateText(item.body)}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 重复通知合并器:相同 key 在窗口内只发第一条;窗口过期后的第一条会带上
|
|
77
|
+
* 「此前重复 N 次」的汇总。窗口按条目各自的首次发送时间独立计算。
|
|
78
|
+
*/
|
|
79
|
+
export class DuplicateTracker {
|
|
80
|
+
#entries = new Map()
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} key 重复判定键
|
|
84
|
+
* @param {number} now 当前时间戳(毫秒),显式传入便于测试
|
|
85
|
+
* @param {number} windowMs 抑制窗口毫秒数;<=0 时直通且不记录状态
|
|
86
|
+
* @returns {{ send: boolean, count?: number, suffix: string }}
|
|
87
|
+
* send=false 时 count 为本窗口内已合并次数;send=true 时 suffix 需追加到正文(可为空串)
|
|
88
|
+
*/
|
|
89
|
+
admit(key, now, windowMs) {
|
|
90
|
+
if (!(windowMs > 0)) return { send: true, suffix: '' }
|
|
91
|
+
const previous = this.#entries.get(key)
|
|
92
|
+
if (previous && now - previous.at < windowMs) {
|
|
93
|
+
previous.count += 1
|
|
94
|
+
return { send: false, count: previous.count }
|
|
95
|
+
}
|
|
96
|
+
const suffix = previous?.count ? `(此前重复 ${previous.count} 次)` : ''
|
|
97
|
+
this.#entries.set(key, { at: now, count: 0 })
|
|
98
|
+
const threshold = Math.max(windowMs * 2, 60_000)
|
|
99
|
+
for (const [candidate, value] of this.#entries) {
|
|
100
|
+
if (now - value.at > threshold) this.#entries.delete(candidate)
|
|
101
|
+
}
|
|
102
|
+
return { send: true, suffix }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get size() {
|
|
106
|
+
return this.#entries.size
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 供持久化:[key, { at, count }] 数组 */
|
|
110
|
+
toJSON() {
|
|
111
|
+
return [...this.#entries.entries()]
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
static fromJSON(entries) {
|
|
115
|
+
const tracker = new DuplicateTracker()
|
|
116
|
+
for (const [key, value] of entries) tracker.#entries.set(key, value)
|
|
117
|
+
return tracker
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 声音事件字段;服务端与客户端共享同一集合 */
|
|
122
|
+
export const SOUND_KINDS = ['completed', 'error', 'aborted', 'approval']
|
|
123
|
+
|
|
124
|
+
export const MAX_NOTIFICATION_TITLE = 120
|
|
125
|
+
export const MAX_NOTIFICATION_BODY = 500
|
|
126
|
+
|
|
127
|
+
/** 通知文案截断(内容策略):标题/正文各自封顶并加省略号;通道级的总长限制另算 */
|
|
128
|
+
export function truncateNotification(title, body) {
|
|
129
|
+
const trim = (value, max) => {
|
|
130
|
+
const text = String(value ?? '')
|
|
131
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text
|
|
132
|
+
}
|
|
133
|
+
return { title: trim(title, MAX_NOTIFICATION_TITLE), body: trim(body, MAX_NOTIFICATION_BODY) }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** AppleScript 字符串转义 + 通知脚本组装;换行会截断 display notification 的字面量,压成空格 */
|
|
137
|
+
export function buildNotificationScript(title, body, sound) {
|
|
138
|
+
const truncated = truncateNotification(title, body)
|
|
139
|
+
const clean = (value) => String(value).replace(/\r?\n/g, ' ')
|
|
140
|
+
const quote = (value) => value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
|
141
|
+
const soundPart = sound ? ` sound name "${quote(sound)}"` : ''
|
|
142
|
+
return `display notification "${quote(clean(truncated.body))}" with title "${quote(clean(truncated.title))}"${soundPart}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** settings set/patch 共用的可编辑字段与校验(服务端唯一真相;客户端只做输入提示) */
|
|
146
|
+
export const EDITABLE_SETTINGS = new Set([
|
|
147
|
+
'onCompleted', 'onError', 'onAborted', 'onApproval', 'minDurationSec',
|
|
148
|
+
'onlyWhenIdleSec', 'onlyWhenUnfocused', 'digestMinutes', 'includeSubagents',
|
|
149
|
+
'channel', 'sounds', 'coalesceMs', 'quietHoursEnabled', 'quietStart', 'quietEnd',
|
|
150
|
+
'quietAllowCritical', 'pauseUntil', 'duplicateWindowSec', 'projectRulesJson',
|
|
151
|
+
])
|
|
152
|
+
/** 数值字段的合法区间(与客户端 NUMBER_BOUNDS 一致,服务端强制执行) */
|
|
153
|
+
export const NUMBER_BOUNDS = {
|
|
154
|
+
minDurationSec: [0, 3600],
|
|
155
|
+
onlyWhenIdleSec: [0, 3600],
|
|
156
|
+
digestMinutes: [0, 1440],
|
|
157
|
+
coalesceMs: [0, 60000],
|
|
158
|
+
duplicateWindowSec: [0, 86400],
|
|
159
|
+
}
|
|
160
|
+
const BOOLEAN_SETTINGS = new Set([
|
|
161
|
+
'onCompleted', 'onError', 'onAborted', 'onApproval',
|
|
162
|
+
'onlyWhenUnfocused', 'includeSubagents', 'quietHoursEnabled', 'quietAllowCritical',
|
|
163
|
+
])
|
|
164
|
+
const CHANNELS = new Set(['auto', 'osascript', 'osc9'])
|
|
165
|
+
|
|
166
|
+
export function assertProjectRulesJson(raw) {
|
|
167
|
+
let parsed
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(raw)
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error('项目规则格式无效')
|
|
172
|
+
}
|
|
173
|
+
if (!Array.isArray(parsed) || parsed.length > 50) throw new Error('项目规则格式无效')
|
|
174
|
+
if (parsed.some((rule) => typeof rule?.path !== 'string' || !['mute', 'errors', 'important'].includes(rule?.mode))) {
|
|
175
|
+
throw new Error('项目规则格式无效')
|
|
176
|
+
}
|
|
177
|
+
return raw
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validateSettingsField(field, value, current) {
|
|
181
|
+
if (BOOLEAN_SETTINGS.has(field)) {
|
|
182
|
+
if (typeof value !== 'boolean') throw new Error(`${field} 必须为布尔值`)
|
|
183
|
+
return value
|
|
184
|
+
}
|
|
185
|
+
if (NUMBER_BOUNDS[field]) {
|
|
186
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) throw new Error(`${field} 必须为有限数值`)
|
|
187
|
+
const [min, max] = NUMBER_BOUNDS[field]
|
|
188
|
+
if (value < min || value > max) throw new Error(`${field} 超出范围 [${min}, ${max}]`)
|
|
189
|
+
return value
|
|
190
|
+
}
|
|
191
|
+
if (field === 'pauseUntil') {
|
|
192
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) throw new Error('pauseUntil 必须为非负数值')
|
|
193
|
+
return value
|
|
194
|
+
}
|
|
195
|
+
if (field === 'channel') {
|
|
196
|
+
if (!CHANNELS.has(value)) throw new Error('channel 取值无效')
|
|
197
|
+
return value
|
|
198
|
+
}
|
|
199
|
+
if (field === 'quietStart' || field === 'quietEnd') {
|
|
200
|
+
if (minuteOfDay(value) === null) throw new Error('勿扰时间格式无效')
|
|
201
|
+
return value
|
|
202
|
+
}
|
|
203
|
+
if (field === 'sounds') {
|
|
204
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('sounds 格式无效')
|
|
205
|
+
for (const [kind, name] of Object.entries(value)) {
|
|
206
|
+
if (!SOUND_KINDS.includes(kind)) throw new Error(`未知的声音字段:${kind}`)
|
|
207
|
+
if (typeof name !== 'string') throw new Error(`声音 ${kind} 必须为字符串`)
|
|
208
|
+
}
|
|
209
|
+
return { ...(current?.sounds ?? {}), ...value }
|
|
210
|
+
}
|
|
211
|
+
if (field === 'projectRulesJson') {
|
|
212
|
+
if (typeof value !== 'string') throw new Error('项目规则格式无效')
|
|
213
|
+
return assertProjectRulesJson(value)
|
|
214
|
+
}
|
|
215
|
+
throw new Error(`不可编辑的设置字段:${field}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 校验并归一化设置 patch;set 端点用单字段对象调用同一入口 */
|
|
219
|
+
export function validateSettingsPatch(patch, current = {}) {
|
|
220
|
+
if (!patch || typeof patch !== 'object' || Array.isArray(patch)) throw new Error('设置格式无效')
|
|
221
|
+
const normalized = {}
|
|
222
|
+
for (const [field, value] of Object.entries(patch)) {
|
|
223
|
+
if (!EDITABLE_SETTINGS.has(field)) throw new Error('包含不可编辑的设置字段')
|
|
224
|
+
normalized[field] = validateSettingsField(field, value, current)
|
|
225
|
+
}
|
|
226
|
+
return normalized
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 极简 TTL 缓存:命中返回 { hit: true, value },过期或未填充返回 { hit: false } */
|
|
230
|
+
export class TtlCache {
|
|
231
|
+
constructor(ttlMs) {
|
|
232
|
+
this.ttlMs = ttlMs
|
|
233
|
+
this.value = null
|
|
234
|
+
this.at = 0
|
|
235
|
+
this.filled = false
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
get(now) {
|
|
239
|
+
return this.filled && now - this.at < this.ttlMs ? { hit: true, value: this.value } : { hit: false, value: null }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
set(now, value) {
|
|
243
|
+
this.at = now
|
|
244
|
+
this.value = value
|
|
245
|
+
this.filled = true
|
|
246
|
+
}
|
|
247
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// 通知决策历史、重复合并状态、会话标题的磁盘持久化。
|
|
2
|
+
// 文件缺失或损坏一律按空状态处理,绝不让坏文件拖垮通知插件。
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { mkdir, rename, writeFile } from 'node:fs/promises'
|
|
5
|
+
import { dirname } from 'node:path'
|
|
6
|
+
|
|
7
|
+
export const STATE_VERSION = 1
|
|
8
|
+
export const MAX_PERSISTED_DIAGNOSTICS = 50
|
|
9
|
+
export const MAX_PERSISTED_DUPLICATES = 500
|
|
10
|
+
export const MAX_PERSISTED_TITLES = 200
|
|
11
|
+
|
|
12
|
+
/** 校验并收敛状态;各字段独立取舍,坏条目丢弃而不是整体作废 */
|
|
13
|
+
export function sanitizeState(raw) {
|
|
14
|
+
const state = { diagnostics: [], duplicates: [], titles: [] }
|
|
15
|
+
if (!raw || typeof raw !== 'object') return state
|
|
16
|
+
if (Array.isArray(raw.diagnostics)) {
|
|
17
|
+
state.diagnostics = raw.diagnostics
|
|
18
|
+
.filter((item) => item && typeof item === 'object')
|
|
19
|
+
.slice(0, MAX_PERSISTED_DIAGNOSTICS)
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(raw.duplicates)) {
|
|
22
|
+
state.duplicates = raw.duplicates.slice(0, MAX_PERSISTED_DUPLICATES).flatMap((entry) => {
|
|
23
|
+
const value = Array.isArray(entry) ? entry[1] : null
|
|
24
|
+
if (!entry || typeof entry[0] !== 'string' || !value
|
|
25
|
+
|| typeof value.at !== 'number' || typeof value.count !== 'number') return []
|
|
26
|
+
return [[entry[0], { at: value.at, count: value.count }]]
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
if (Array.isArray(raw.titles)) {
|
|
30
|
+
state.titles = raw.titles
|
|
31
|
+
.slice(-MAX_PERSISTED_TITLES)
|
|
32
|
+
.flatMap((entry) => (
|
|
33
|
+
Array.isArray(entry) && typeof entry[0] === 'string' && typeof entry[1] === 'string'
|
|
34
|
+
? [[entry[0], entry[1]]]
|
|
35
|
+
: []
|
|
36
|
+
))
|
|
37
|
+
}
|
|
38
|
+
return state
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 初始化时同步读取:保证恢复完成后再处理任何事件,不存在恢复与事件的竞态 */
|
|
42
|
+
export function loadStateSync(file) {
|
|
43
|
+
try {
|
|
44
|
+
return sanitizeState(JSON.parse(readFileSync(file, 'utf8')))
|
|
45
|
+
} catch {
|
|
46
|
+
return { diagnostics: [], duplicates: [], titles: [] }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 先写临时文件再 rename,避免崩溃留下半个 JSON */
|
|
51
|
+
export async function saveState(file, state) {
|
|
52
|
+
const clean = sanitizeState(state)
|
|
53
|
+
const payload = JSON.stringify({ version: STATE_VERSION, ...clean }, null, 2)
|
|
54
|
+
const temp = `${file}.tmp`
|
|
55
|
+
await mkdir(dirname(file), { recursive: true })
|
|
56
|
+
await writeFile(temp, payload, { mode: 0o600 })
|
|
57
|
+
await rename(temp, file)
|
|
58
|
+
}
|