dsh-plugin-desktop-notify 0.5.6 → 0.6.0
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.en.md +80 -175
- package/README.md +66 -195
- package/index.js +177 -2
- package/lib/client.js +102 -13
- package/locale/en.json +2 -2
- package/locale/zh.json +1 -1
- package/package.json +2 -2
package/README.en.md
CHANGED
|
@@ -1,214 +1,119 @@
|
|
|
1
1
|
# dsh-plugin-desktop-notify
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
[DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) desktop app.
|
|
5
|
-
|
|
6
|
-
- A native banner, attributed to **DeepSeek Harness itself**, when a turn finishes
|
|
7
|
-
**even while the window is in the background**, when a tool approval / question /
|
|
8
|
-
plan review is waiting, or when a turn fails.
|
|
9
|
-
- **Click the banner to jump back to that session** and raise the window.
|
|
10
|
-
- The Dock icon keeps a number for every session that needs you until you actually
|
|
11
|
-
look at it.
|
|
12
|
-
|
|
13
|
-
[中文](README.md) · MIT
|
|
3
|
+
Desktop notifications and an app-icon badge for DSH: a finished turn, a pending approval or question, and a failure, with click-through back to that session.
|
|
14
4
|
|
|
15
5
|
## What it does
|
|
16
6
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
| You open (and look at) that session | — | cleared |
|
|
23
|
-
|
|
24
|
-
Rules:
|
|
25
|
-
|
|
26
|
-
- **A notification always implies a badge**: both read the same attention facts.
|
|
27
|
-
- **“Watching” means** the session is in the main view **and** the window is focused
|
|
28
|
-
**and** visible. The harness’s own `completionUnread` only covers the first case;
|
|
29
|
-
a backgrounded window is invisible to the host, so the plugin adds the
|
|
30
|
-
focus/visibility half itself with `document.hasFocus()` / `visibilityState`.
|
|
31
|
-
- **The badge is sticky** until you actually look at the session; the harness
|
|
32
|
-
clearing `completionUnread` does not silently drop it.
|
|
33
|
-
- A failed turn posts the error banner only, never an extra “completed” one.
|
|
34
|
-
- Sessions of subagents / Agent Teams members are ignored.
|
|
35
|
-
|
|
36
|
-
## Requirements
|
|
37
|
-
|
|
38
|
-
- Tested on **DSH Desktop 0.2.1-rc.3** (Electron **44.7.0**) on **macOS**.
|
|
39
|
-
- The desktop shell must register `dsh-app://` as a secure context (current builds do),
|
|
40
|
-
otherwise `navigator.setAppBadge` and `Notification` are unavailable
|
|
41
|
-
(`capabilities()` in DevTools reports `badge: false`).
|
|
42
|
-
- System Settings → Notifications → **DeepSeek Harness** must be allowed, otherwise
|
|
43
|
-
you get the badge without banners.
|
|
44
|
-
- **Windows / Linux: untested, but expected to work.** Both surfaces are plain web
|
|
45
|
-
APIs: Electron has supported the Badging API on Windows since v29 (taskbar overlay,
|
|
46
|
-
see [the v29.1.5 Windows badge fix](https://releases.electronjs.org/release/v29.1.5)),
|
|
47
|
-
and `Notification` maps to Windows toasts. The real caveat is that Windows toasts
|
|
48
|
-
need an AppUserModelID plus a Start Menu shortcut, and the DSH main process never
|
|
49
|
-
calls `app.setAppUserModelId`, so whether banners appear depends on the shortcut
|
|
50
|
-
the installer writes.
|
|
51
|
-
- The only genuinely macOS-only code is the **optional host fallback**
|
|
52
|
-
(`osascript` / `afplay` / `lsappinfo`), disabled by default.
|
|
53
|
-
- To check a platform: run `capabilities()` (it reports `platform`) and then
|
|
54
|
-
`simulateComplete()`, and open an issue with the result.
|
|
55
|
-
|
|
56
|
-
### Banner click does nothing?
|
|
57
|
-
|
|
58
|
-
Read `__DSH_DESKTOP_NOTIFY__.state().lastClick`:
|
|
59
|
-
|
|
60
|
-
- `null` → the click never reached the renderer. Rare on macOS, **common on Windows**:
|
|
61
|
-
WinRT toast activation needs a registered COM activator
|
|
62
|
-
([electron/electron#24524](https://github.com/electron/electron/issues/24524)), and without
|
|
63
|
-
one the click may simply dismiss the toast. A plugin cannot fix that layer.
|
|
64
|
-
- `opened.ok: false` → the click arrived but the session switch failed; `opened.attempts`
|
|
65
|
-
says why (`uiWorkspace` unreachable, `retain` failed, …). Since v0.5.2 the plugin declares
|
|
66
|
-
`uiWorkspace` as a dependency (the same way the official `ui-chat` plugin does), which is
|
|
67
|
-
what made the macOS click fail to switch sessions before.
|
|
68
|
-
- `opened.ok: true` but the view did not change → the window was hidden; the click only
|
|
69
|
-
activates the app, so bring it forward from the Dock/taskbar.
|
|
70
|
-
|
|
71
|
-
The click path can be exercised directly with `__DSH_DESKTOP_NOTIFY__.openSession("<session id>")`.
|
|
72
|
-
|
|
73
|
-
### Windows: the click switches the session, but the window may stay behind
|
|
74
|
-
|
|
75
|
-
Not something a plugin can fix. The click reaches the renderer (since v0.5.2
|
|
76
|
-
`lastClick.opened.ok === true` and the session really switches), but raising the main
|
|
77
|
-
window needs the main process (`BrowserWindow.show()` / `focus()`) or a registered
|
|
78
|
-
AppUserModelID. The renderer only has `webContents.focus()`, and Windows' foreground
|
|
79
|
-
lock ignores requests from non-foreground processes.
|
|
80
|
-
|
|
81
|
-
Two upstream fixes, either works:
|
|
82
|
-
|
|
83
|
-
1. Call `app.setAppUserModelId("com.deepseek.dsh")` in the desktop main process and ship a
|
|
84
|
-
Start Menu shortcut with the same AUMID — Windows then activates the app on toast
|
|
85
|
-
clicks, so the window comes forward.
|
|
86
|
-
2. Expose a "focus window" IPC channel the renderer can call.
|
|
87
|
-
|
|
88
|
-
Until then: if the window does not surface, click the taskbar/tray icon — **the session
|
|
89
|
-
is already selected**.
|
|
90
|
-
|
|
91
|
-
### Windows: badge appears, no banner
|
|
92
|
-
|
|
93
|
-
**Fixed in v0.4.4**: older builds passed a long `tag`
|
|
94
|
-
(`dsh-plugin-desktop-notify:complete:<sessionId>`), and Windows 11 silently drops web
|
|
95
|
-
notifications whose tag is longer than ~34 characters
|
|
96
|
-
([electron/electron#40433](https://github.com/electron/electron/issues/40433)). The
|
|
97
|
-
plugin no longer sets `tag` / `silent`; dedupe relies on status edges, which is why a
|
|
98
|
-
bare `new Notification("t",{body:"b"})` showed up while the plugin's banner did not.
|
|
99
|
-
|
|
100
|
-
If it still fails after upgrading, use the checks below:
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
The badge is a taskbar overlay and needs no AppUserModelID. **Windows toasts do need an
|
|
104
|
-
AppUserModelID plus a Start Menu shortcut**, and the DSH main process never calls
|
|
105
|
-
`app.setAppUserModelId` (checked `lib/main.js`), so Windows can drop the toast silently —
|
|
106
|
-
it is not the plugin's logic. Confirm with:
|
|
107
|
-
|
|
108
|
-
```js
|
|
109
|
-
new Notification("test", { body: "windows toast check" }) // nothing here means app-level
|
|
110
|
-
__DSH_DESKTOP_NOTIFY__.state().lastNotify // the last delivery attempt
|
|
111
|
-
```
|
|
7
|
+
- **Turn finished** — including while the window is backgrounded or another app is in front
|
|
8
|
+
- **You are needed** — a tool approval, a question, or a plan waiting for confirmation
|
|
9
|
+
- **Turn failed** — the error is summarised in the body
|
|
10
|
+
- **Badge** — the app icon carries the number of sessions that need you, cleared once you have looked
|
|
11
|
+
- **Click a notification** — it switches to that session instead of only raising the app
|
|
112
12
|
|
|
113
|
-
|
|
114
|
-
with click-through), or the host half falls back to a PowerShell toast (visible, but
|
|
115
|
-
attributed to Windows PowerShell and without click-through).
|
|
13
|
+
Subagent sessions are skipped by default.
|
|
116
14
|
|
|
117
15
|
## Install
|
|
118
16
|
|
|
119
|
-
|
|
17
|
+
DSH desktop → **Plugins** → Install, then paste the package name:
|
|
120
18
|
|
|
121
19
|
```
|
|
122
20
|
dsh-plugin-desktop-notify
|
|
123
21
|
```
|
|
124
22
|
|
|
125
|
-
Press **⌘R** afterwards
|
|
126
|
-
|
|
23
|
+
Press **⌘R** afterwards. The GitHub address
|
|
24
|
+
(`https://github.com/chendaleiQ/dsh-plugin-desktop-notify`) and an absolute local
|
|
25
|
+
directory work the same way.
|
|
26
|
+
|
|
27
|
+
## Preferences
|
|
127
28
|
|
|
128
|
-
|
|
129
|
-
(`https://github.com/chendaleiQ/dsh-plugin-desktop-notify`) and a local checkout
|
|
130
|
-
directory both work the same way.
|
|
131
|
-
For development, symlink it into the profile:
|
|
29
|
+
Stored in localStorage under `dsh-plugin-desktop-notify.preferences`:
|
|
132
30
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
31
|
+
| Field | Default | Meaning |
|
|
32
|
+
| --- | --- | --- |
|
|
33
|
+
| `notifications` | `true` | Master switch |
|
|
34
|
+
| `notifyComplete` | `true` | Notify when a turn finishes |
|
|
35
|
+
| `notifyPending` | `true` | Notify for an approval / question / plan |
|
|
36
|
+
| `notifyError` | `true` | Notify when a turn fails |
|
|
37
|
+
| `notifyViewed` | `false` | Also notify for the session you are looking at |
|
|
38
|
+
| `badge` | `true` | Show the badge |
|
|
39
|
+
| `excludeSubagents` | `true` | Ignore subagent sessions |
|
|
40
|
+
| `titleComplete` / `titlePending` / `titleError` | empty | Custom titles; empty keeps the built-in copy |
|
|
41
|
+
| `maxBodyChars` | `140` | Body length limit |
|
|
42
|
+
|
|
43
|
+
## Language
|
|
44
|
+
|
|
45
|
+
The copy follows DSH's language setting (Settings → General → Language): Chinese
|
|
46
|
+
by default, English once the app is switched.
|
|
140
47
|
|
|
141
48
|
## Self-check
|
|
142
49
|
|
|
143
|
-
|
|
50
|
+
From the renderer console:
|
|
144
51
|
|
|
145
52
|
```js
|
|
146
|
-
__DSH_DESKTOP_NOTIFY__.capabilities()
|
|
147
|
-
__DSH_DESKTOP_NOTIFY__.state()
|
|
148
|
-
__DSH_DESKTOP_NOTIFY__.
|
|
149
|
-
__DSH_DESKTOP_NOTIFY__.reset() // undo the simulation
|
|
150
|
-
__DSH_DESKTOP_NOTIFY__.state().lastClick // what the last banner click did
|
|
151
|
-
__DSH_DESKTOP_NOTIFY__.openSession("<session id>") // run the click path manually
|
|
53
|
+
__DSH_DESKTOP_NOTIFY__.capabilities()
|
|
54
|
+
__DSH_DESKTOP_NOTIFY__.state()
|
|
55
|
+
__DSH_DESKTOP_NOTIFY__.report()
|
|
152
56
|
```
|
|
153
57
|
|
|
154
|
-
## Preferences
|
|
155
58
|
|
|
156
|
-
|
|
157
|
-
(desktop and web profiles are independent), applied after ⌘R:
|
|
59
|
+
## Notification interface for other tools
|
|
158
60
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
61
|
+
The Host half exposes two loopback routes for scripts, browser agents and cron
|
|
62
|
+
jobs:
|
|
63
|
+
|
|
64
|
+
| Method | Path | Purpose |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| `POST` | `/desktop-notify/request` | Post one notification: `{"title": "...", "body": "...", "sessionId": "optional"}` |
|
|
67
|
+
| `GET` | `/desktop-notify/pending` | The renderer drains what is waiting (scripts do not call it) |
|
|
68
|
+
|
|
69
|
+
The address is written to `~/.dsh/desktop-notify.endpoint`, so callers never guess
|
|
70
|
+
the port:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
curl -s "$(cat ~/.dsh/desktop-notify.endpoint)" \
|
|
74
|
+
-H 'content-type: application/json' \
|
|
75
|
+
-d '{"title":"BrowserOS needs you","body":"A takeover request is waiting"}'
|
|
174
76
|
```
|
|
175
77
|
|
|
176
|
-
|
|
78
|
+
Python:
|
|
177
79
|
|
|
178
|
-
|
|
80
|
+
```python
|
|
81
|
+
import json, pathlib, urllib.request
|
|
179
82
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
83
|
+
url = pathlib.Path.home().joinpath(".dsh/desktop-notify.endpoint").read_text().strip()
|
|
84
|
+
request = urllib.request.Request(
|
|
85
|
+
url,
|
|
86
|
+
data=json.dumps({"title": "BrowserOS needs you", "body": "A takeover request is waiting"}).encode(),
|
|
87
|
+
headers={"content-type": "application/json"},
|
|
88
|
+
)
|
|
89
|
+
urllib.request.urlopen(request, timeout=3).read()
|
|
90
|
+
```
|
|
184
91
|
|
|
185
|
-
|
|
186
|
-
(`completionUnread`, `pendingInteraction`, the same data the sidebar dots read) plus
|
|
187
|
-
`api-session/error` for failures, so “read” means exactly what the official UI means.
|
|
92
|
+
Notes:
|
|
188
93
|
|
|
189
|
-
The
|
|
190
|
-
|
|
191
|
-
|
|
94
|
+
- The banner is posted by **DSH itself** (attributed to DeepSeek Harness), not by
|
|
95
|
+
a script host
|
|
96
|
+
- Clicking it raises the window; with `sessionId` it also switches to that session
|
|
97
|
+
- The interface binds loopback only and needs no login; `enabled: false` switches
|
|
98
|
+
off the osascript fallback, not the interface
|
|
192
99
|
|
|
193
|
-
##
|
|
100
|
+
## Known limits
|
|
101
|
+
|
|
102
|
+
- **Windows**: clicking a notification switches the session, but the window does
|
|
103
|
+
not always come to the front — the AppUserModelID and a main-process focus
|
|
104
|
+
channel are the desktop app's to provide
|
|
105
|
+
- **Attribution**: the notification centre shows DeepSeek Harness, because the
|
|
106
|
+
banner is a renderer-side native notification
|
|
107
|
+
- **Host half** (`index.js`, an osascript fallback) is off by default; switch it on
|
|
108
|
+
only when a renderer cannot post notifications
|
|
194
109
|
|
|
195
|
-
|
|
110
|
+
## Development
|
|
196
111
|
|
|
197
112
|
```bash
|
|
198
|
-
node test/client.test.mjs
|
|
199
|
-
node test/host.test.mjs
|
|
200
|
-
npm test
|
|
113
|
+
node test/client.test.mjs
|
|
114
|
+
node test/host.test.mjs
|
|
201
115
|
```
|
|
202
116
|
|
|
203
|
-
## Limitations
|
|
204
|
-
|
|
205
|
-
- macOS is verified; Windows / Linux are untested (see Requirements).
|
|
206
|
-
- “Stuck” covers waiting-for-approval / question / plan review / failure; a
|
|
207
|
-
“still running but silent for N minutes” watchdog would need a host timer.
|
|
208
|
-
- Badge facts live in host memory and reset when the app restarts (harness semantics).
|
|
209
|
-
- Conflicts with `@caohaoyangcn/dsh-dock-badge` (badge-only, with a settings page):
|
|
210
|
-
install only one, they fight over the same Dock badge.
|
|
211
|
-
|
|
212
117
|
## License
|
|
213
118
|
|
|
214
|
-
MIT
|
|
119
|
+
MIT · https://github.com/chendaleiQ/dsh-plugin-desktop-notify
|
package/README.md
CHANGED
|
@@ -1,238 +1,109 @@
|
|
|
1
1
|
# dsh-plugin-desktop-notify
|
|
2
2
|
|
|
3
|
-
给
|
|
3
|
+
给 DSH 加**桌面通知 + 应用图标小红点**:任务完成、等待你操作、任务失败时提醒你,点通知直接回到那个会话。
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- 有工具等你审批、有提问等你回答、有计划等你确认时弹「需要你确认」;
|
|
7
|
-
- 任务出错时弹「任务出错」,正文带错误摘要;
|
|
8
|
-
- **点横幅直接回到那个会话**并抬起窗口;
|
|
9
|
-
- 需要你处理的会话在 Dock 图标上留一个数字,直到你真的看过它才清零。
|
|
5
|
+
## 它做什么
|
|
10
6
|
|
|
11
|
-
|
|
7
|
+
- **任务完成**——包括窗口切到后台、你在别的应用里的时候
|
|
8
|
+
- **等待你操作**——工具审批、提问、执行计划确认
|
|
9
|
+
- **任务失败**——这一轮出错时提示
|
|
10
|
+
- **小红点**——应用图标上显示「需要你的会话数」,你看过之后才清零
|
|
11
|
+
- **点击通知**——切到对应会话,而不是只把应用叫到前面
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
| Codex 桌面版 | 这里 |
|
|
16
|
-
|---|---|
|
|
17
|
-
| Settings → Notifications → **Task completion** | 标题「任务已完成」,正文「对话名」已完成,用时 3 分 12 秒 |
|
|
18
|
-
| Settings → Notifications → **Approval required** | 标题「等待你的批准」,正文「对话名」请求使用 Bash,等待你的批准 |
|
|
19
|
-
| 等你回复(提问 / 计划确认) | 标题「等待你的回答」/「等待你的确认」,正文带会话名 |
|
|
20
|
-
| 任务失败 | 标题「任务执行失败」,正文带会话名与错误摘要 |
|
|
21
|
-
| Dock 徽标 | 数字 = **当前需要你的会话数**;打开该会话即清 |
|
|
22
|
-
|
|
23
|
-
规则:
|
|
24
|
-
|
|
25
|
-
- **有通知就有红点**:两者读同一份 attention 事实,弹了横幅就一定进徽标。
|
|
26
|
-
- **「在看」才算看过**:`主视图是它` **且** `窗口有焦点` **且** `窗口可见`,三者同时成立才不打扰。
|
|
27
|
-
宿主的 `completionUnread` 只覆盖第一种(切到别的会话);窗口被切到后台时它看不见,
|
|
28
|
-
这部分由插件用 `document.hasFocus()` / `visibilityState` 自己补——否则最常见的那种
|
|
29
|
-
「切到别的 App 等它跑完」永远不会提醒。
|
|
30
|
-
- **红点粘住**:弹过横幅的会话,红点保留到你真的切回来并看着它;宿主把 `completionUnread`
|
|
31
|
-
清掉并不会让红点消失。
|
|
32
|
-
- **失败不重复播**:一轮以错误结束时只发「任务出错」,不再补一条「任务完成」。
|
|
33
|
-
- 子会话(subagent / Agent Teams 成员)不弹不计;同一会话同类横幅用 `tag` 覆盖,不堆一屏。
|
|
34
|
-
|
|
35
|
-
## 兼容性
|
|
36
|
-
|
|
37
|
-
- **已在 macOS 实测**:DSH Desktop 0.2.1-rc.3(Electron 44.7.0)。
|
|
38
|
-
- 需要桌面端把 `dsh-app://` 注册为 secure context(现版本就是这么做的),否则渲染层拿不到
|
|
39
|
-
`navigator.setAppBadge` 与 `Notification`;`capabilities()` 自检里会显示 `badge: false`。
|
|
40
|
-
- 系统设置 → 通知 → **DeepSeek Harness** 必须是允许状态,否则只有红点没有横幅。
|
|
41
|
-
- **Windows / Linux:未实测,但理论上可用**。渲染层用的是标准 Web API:
|
|
42
|
-
Electron 从 29 起支持 Windows 上的 Badging API(任务栏 overlay,修过 [Windows 下徽标文字不显示](https://releases.electronjs.org/release/v29.1.5)),
|
|
43
|
-
`Notification` 走 Windows toast。真正的风险在 Windows 的 toast 需要 AppUserModelID +
|
|
44
|
-
开始菜单快捷方式,而 DSH 主进程没有显式 `setAppUserModelId`(我查过 `lib/main.js`),
|
|
45
|
-
所以能不能出横幅取决于安装包写的快捷方式。
|
|
46
|
-
- **唯一确定只在 macOS 的是宿主兜底那半边**(`osascript` / `afplay` / `lsappinfo`),
|
|
47
|
-
它默认关闭,只在你没有 `Notification` 时才需要。
|
|
48
|
-
- 想验证 Windows:跑一次 `capabilities()`(会带上 `platform`),再 `simulateComplete()`,
|
|
49
|
-
看 toast 与任务栏数字是否出现,把结果开个 issue 就行。
|
|
50
|
-
|
|
51
|
-
### 点击通知没反应?
|
|
52
|
-
|
|
53
|
-
先看 `__DSH_DESKTOP_NOTIFY__.state().lastClick`:
|
|
54
|
-
|
|
55
|
-
- `null` → 点击事件根本没送到渲染层。macOS 上基本不会;**Windows 上很常见**:Electron 的 WinRT toast 点击激活需要 COM activator 注册([electron/electron#24524](https://github.com/electron/electron/issues/24524)),没注册时点横幅可能只是把系统通知收起。这一层插件修不了。
|
|
56
|
-
- `opened.ok: false` → 点击到了,但切会话失败,`opened.attempts` 里写着原因(`uiWorkspace` 服务不可达 / `retain` 失败等)。
|
|
57
|
-
v0.5.2 起插件把 `uiWorkspace` 声明成了依赖(和官方 `ui-chat` 插件一致),此前它在这个插件里是取不到的——这就是 macOS 上「点击只激活应用、不切会话」的原因。
|
|
58
|
-
- `opened.ok: true` 但界面没变 → 窗口被藏起来了(点横幅只激活应用,不一定会显示窗口),手动点 Dock/任务栏图标把窗口调出来即可。
|
|
59
|
-
|
|
60
|
-
也可以直接验证切会话这条链路:`__DSH_DESKTOP_NOTIFY__.openSession("<会话 id>")`。
|
|
61
|
-
|
|
62
|
-
### Windows:点通知切会话成功,但窗口不一定到最前
|
|
63
|
-
|
|
64
|
-
这不是插件能修的:点击事件已经送到渲染层(v0.5.2 起 `lastClick.opened.ok === true`,会话也确实切了),
|
|
65
|
-
但把**主窗口提到前台**需要主进程的 `BrowserWindow.show()` / `focus()`,或者系统层面的
|
|
66
|
-
AppUserModelID 注册;渲染层只有 `webContents.focus()`,而 Windows 的前台锁定会忽略来自
|
|
67
|
-
非前台进程的请求。
|
|
68
|
-
|
|
69
|
-
两种上游修法(任一即可):
|
|
70
|
-
|
|
71
|
-
1. 桌面端主进程调用 `app.setAppUserModelId("com.deepseek.dsh")`,并让安装包写出带同一 AUMID
|
|
72
|
-
的开始菜单快捷方式——Windows 就会在点击 toast 时激活应用,窗口自然到前台。
|
|
73
|
-
2. 主进程暴露一个「focus window」的 IPC 通道,让插件请求抬窗。
|
|
74
|
-
|
|
75
|
-
在此之前:点通知后若窗口没上来,点任务栏/托盘图标即可,**会话已经切好了**。
|
|
76
|
-
|
|
77
|
-
### Windows 上「有红点、没横幅」
|
|
78
|
-
|
|
79
|
-
**v0.4.4 已修**:旧版本给每条通知带了一个很长的 `tag`(`dsh-plugin-desktop-notify:complete:<sessionId>`),
|
|
80
|
-
而 Windows 11 会**静默丢弃 tag 长度超过约 34 字符的 Web Notification**([electron/electron#40433](https://github.com/electron/electron/issues/40433))。
|
|
81
|
-
现在不再设置 `tag` / `silent`,去重改由状态边沿判断完成——所以裸 `new Notification("t",{body:"b"})` 能弹、
|
|
82
|
-
插件的不弹,正是这个原因。
|
|
83
|
-
|
|
84
|
-
如果升级后仍不弹,再按下面排查:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
红点走的是任务栏 overlay,不需要 AppUserModelID;**Windows toast 需要 AppUserModelID +
|
|
88
|
-
开始菜单快捷方式**才会显示。DSH 主进程没有调用 `app.setAppUserModelId`(`lib/main.js` 里没有
|
|
89
|
-
这行),所以 toast 很可能被 Windows 静默丢弃——这不是插件逻辑问题。先确认:
|
|
90
|
-
|
|
91
|
-
```js
|
|
92
|
-
new Notification("test", { body: "windows toast check" }) // 连这条都不弹 = 应用层问题
|
|
93
|
-
__DSH_DESKTOP_NOTIFY__.state().lastNotify // 插件最后一次投递的结果
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
两条路:等 DSH 主进程补上 `app.setAppUserModelId`(那时现有代码就能正常出 toast 并支持点击);
|
|
97
|
-
或改用宿主侧 PowerShell toast 兜底(能提示,但归属会显示成 Windows PowerShell,点击无法跳回会话)。
|
|
13
|
+
子智能体的会话默认不提醒(可在偏好里打开)。
|
|
98
14
|
|
|
99
15
|
## 安装
|
|
100
16
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
DSH 桌面版左侧栏 **插件** → **安装**,填包名:
|
|
17
|
+
DSH 桌面版 → 左侧栏「插件」→ 安装,填包名:
|
|
104
18
|
|
|
105
19
|
```
|
|
106
20
|
dsh-plugin-desktop-notify
|
|
107
21
|
```
|
|
108
22
|
|
|
109
|
-
装完按 **⌘R**
|
|
110
|
-
|
|
23
|
+
装完按 **⌘R** 刷新窗口。也可以填 GitHub 地址
|
|
24
|
+
(`https://github.com/chendaleiQ/dsh-plugin-desktop-notify`)或本地目录的绝对路径。
|
|
111
25
|
|
|
112
|
-
|
|
113
|
-
> 或者直接用下面的 GitHub / 本地方式。
|
|
26
|
+
## 偏好
|
|
114
27
|
|
|
115
|
-
|
|
28
|
+
存在浏览器 localStorage 的 `dsh-plugin-desktop-notify.preferences`:
|
|
116
29
|
|
|
117
|
-
|
|
30
|
+
| 字段 | 默认 | 说明 |
|
|
31
|
+
| --- | --- | --- |
|
|
32
|
+
| `notifications` | `true` | 总开关 |
|
|
33
|
+
| `notifyComplete` | `true` | 任务完成时通知 |
|
|
34
|
+
| `notifyPending` | `true` | 等待审批 / 回答 / 计划确认时通知 |
|
|
35
|
+
| `notifyError` | `true` | 任务失败时通知 |
|
|
36
|
+
| `notifyViewed` | `false` | 正在看的会话也通知(默认跳过) |
|
|
37
|
+
| `badge` | `true` | 显示小红点 |
|
|
38
|
+
| `excludeSubagents` | `true` | 忽略子智能体会话 |
|
|
39
|
+
| `titleComplete` / `titlePending` / `titleError` | 空 | 自定义标题,空 = 用内置文案 |
|
|
40
|
+
| `maxBodyChars` | `140` | 正文长度上限 |
|
|
118
41
|
|
|
119
|
-
|
|
120
|
-
https://github.com/chendaleiQ/dsh-plugin-desktop-notify
|
|
121
|
-
```
|
|
42
|
+
## 语言
|
|
122
43
|
|
|
123
|
-
|
|
44
|
+
跟随 DSH 的语言设置(设置 → 通用 → 语言):默认中文,切成英文后通知文案同步变成英文。
|
|
124
45
|
|
|
125
|
-
|
|
126
|
-
git clone https://github.com/chendaleiQ/dsh-plugin-desktop-notify.git
|
|
127
|
-
```
|
|
46
|
+
## 自检
|
|
128
47
|
|
|
129
|
-
|
|
48
|
+
渲染层控制台:
|
|
130
49
|
|
|
131
|
-
```
|
|
132
|
-
|
|
50
|
+
```js
|
|
51
|
+
__DSH_DESKTOP_NOTIFY__.capabilities() // 平台、通知权限、小红点、服务可达性
|
|
52
|
+
__DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近一次通知/点击
|
|
53
|
+
__DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
|
|
133
54
|
```
|
|
134
55
|
|
|
135
|
-
### 方式四:开发调试(直接 link 进 profile)
|
|
136
56
|
|
|
137
|
-
|
|
138
|
-
git clone https://github.com/chendaleiQ/dsh-plugin-desktop-notify.git
|
|
139
|
-
cd dsh-plugin-desktop-notify
|
|
140
|
-
ln -s "$PWD" ~/.dsh/profiles/desktop/node_modules/dsh-plugin-desktop-notify
|
|
141
|
-
# 再把 dsh-plugin-desktop-notify 加进 ~/.dsh/profiles/desktop/package.json 的 dsh.profile.bundles
|
|
142
|
-
```
|
|
57
|
+
## 给其它工具用的通知接口
|
|
143
58
|
|
|
144
|
-
|
|
59
|
+
宿主半边在**回环地址**上开了两个路由,供脚本、浏览器代理、定时任务调用:
|
|
145
60
|
|
|
146
|
-
|
|
61
|
+
| 方法 | 路径 | 作用 |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
|
|
64
|
+
| `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
|
|
147
65
|
|
|
148
|
-
|
|
66
|
+
地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
|
|
149
67
|
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
__DSH_DESKTOP_NOTIFY__.reset() // 清掉自检状态
|
|
155
|
-
__DSH_DESKTOP_NOTIFY__.state().lastClick // 上一次点横幅发生了什么
|
|
156
|
-
__DSH_DESKTOP_NOTIFY__.openSession("<会话 id>") // 直接按点击逻辑切会话,验证服务可达
|
|
68
|
+
```bash
|
|
69
|
+
curl -s "$(cat ~/.dsh/desktop-notify.endpoint)" \
|
|
70
|
+
-H 'content-type: application/json' \
|
|
71
|
+
-d '{"title":"BrowserOS 需要你","body":"有一个接管请求"}'
|
|
157
72
|
```
|
|
158
73
|
|
|
159
|
-
|
|
160
|
-
点横幅回来,数字清零。
|
|
161
|
-
|
|
162
|
-
## 偏好
|
|
74
|
+
Python(比如监听 neo 的守护脚本):
|
|
163
75
|
|
|
164
|
-
|
|
76
|
+
```python
|
|
77
|
+
import json, pathlib, urllib.request
|
|
165
78
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
badge: true, // 总开关:Dock 红点
|
|
174
|
-
excludeSubagents: true, // 子会话不弹不计(建议保持)
|
|
175
|
-
// 留空 = 用内置文案(跟随界面语言 zh/en)
|
|
176
|
-
titleComplete: "",
|
|
177
|
-
titlePending: "",
|
|
178
|
-
titleError: "",
|
|
179
|
-
maxBodyChars: 140
|
|
180
|
-
}))
|
|
181
|
-
// 恢复默认
|
|
182
|
-
localStorage.removeItem("dsh-plugin-desktop-notify:preferences")
|
|
79
|
+
url = pathlib.Path.home().joinpath(".dsh/desktop-notify.endpoint").read_text().strip()
|
|
80
|
+
request = urllib.request.Request(
|
|
81
|
+
url,
|
|
82
|
+
data=json.dumps({"title": "BrowserOS 需要你", "body": "有一个接管请求"}).encode(),
|
|
83
|
+
headers={"content-type": "application/json"},
|
|
84
|
+
)
|
|
85
|
+
urllib.request.urlopen(request, timeout=3).read()
|
|
183
86
|
```
|
|
184
87
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
这两件事只能在桌面**渲染进程**做,Host 子进程碰不到:
|
|
188
|
-
|
|
189
|
-
| 能力 | 接口 | 去处 |
|
|
190
|
-
|---|---|---|
|
|
191
|
-
| 原生横幅 + 点击回调 | `new Notification(...)` / `onclick` | Electron → macOS 通知中心,**归属 DeepSeek Harness** |
|
|
192
|
-
| Dock 小红点 | `navigator.setAppBadge(n)` / `clearAppBadge()` | Chromium → Electron `BadgeManager` → `NSDockTile.badgeLabel` |
|
|
88
|
+
要点:
|
|
193
89
|
|
|
194
|
-
|
|
195
|
-
|
|
90
|
+
- 通知由 **DSH 自己弹出**(归属 DeepSeek Harness),不是 Script Editor
|
|
91
|
+
- 点通知会把窗口叫到前面;带 `sessionId` 时会切到那个会话
|
|
92
|
+
- 接口只监听回环地址,不需要登录;`enabled: false` 只关 osascript 兜底,不影响接口
|
|
196
93
|
|
|
197
|
-
|
|
198
|
-
和侧边栏状态点同源)+ `api-session/error`(失败),插件不自己记账,所以「读过」的语义和官方 UI 一致。
|
|
199
|
-
|
|
200
|
-
## 目录
|
|
201
|
-
|
|
202
|
-
```
|
|
203
|
-
dsh-plugin-desktop-notify/
|
|
204
|
-
├── package.json # dsh.bundle.patch + dsh.client(渲染层入口与 inject 顺序)
|
|
205
|
-
├── cordis.patch.yml # desktop-notify 一行(宿主兜底默认 enabled: false)
|
|
206
|
-
├── lib/client.js # 渲染层:横幅 + 点击跳转 + Dock 徽标
|
|
207
|
-
├── index.js # Host 半边:osascript 兜底,默认关闭
|
|
208
|
-
├── test/client.test.mjs # 渲染层测试(node:vm + 假浏览器全局)
|
|
209
|
-
├── test/host.test.mjs # 宿主兜底测试
|
|
210
|
-
├── icon.svg / locale/{en,zh}.json / README(.en).md / LICENSE
|
|
211
|
-
```
|
|
94
|
+
## 已知限制
|
|
212
95
|
|
|
213
|
-
|
|
96
|
+
- **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
|
|
97
|
+
- **通知归属**:系统通知中心里显示为 DeepSeek Harness(用的是渲染层原生通知)
|
|
98
|
+
- **宿主半边**(`index.js`,osascript 兜底)默认关闭;只有渲染层拿不到 `Notification` 时才需要打开
|
|
214
99
|
|
|
215
|
-
|
|
100
|
+
## 开发
|
|
216
101
|
|
|
217
102
|
```bash
|
|
218
|
-
node test/client.test.mjs
|
|
219
|
-
node test/host.test.mjs
|
|
220
|
-
npm test # 两个都跑
|
|
103
|
+
node test/client.test.mjs # 渲染层:文案、小红点、点击链路
|
|
104
|
+
node test/host.test.mjs # 宿主兜底:dryRun 下的事件处理
|
|
221
105
|
```
|
|
222
106
|
|
|
223
|
-
|
|
224
|
-
完成/审批/失败各自增减、失败不重复播、子会话排除、各偏好开关。
|
|
225
|
-
|
|
226
|
-
## 已知限制
|
|
227
|
-
|
|
228
|
-
- macOS 已实测;Windows / Linux 未实测(见上面「兼容性」)。
|
|
229
|
-
- 「卡住」目前覆盖**等待审批 / 提问 / 计划确认 / 失败**;没有做「还在 running 但长时间无输出」
|
|
230
|
-
的超时判定(需要宿主侧定时器)。
|
|
231
|
-
- `completionUnread` 与失败事实在宿主内存里,重启应用从零开始(官方语义)。
|
|
232
|
-
- 点击横幅若对应会话已归档,只会抬窗。
|
|
233
|
-
- 与 [`@caohaoyangcn/dsh-dock-badge`](https://www.npmjs.com/package/@caohaoyangcn/dsh-dock-badge)
|
|
234
|
-
功能重叠(那个只做徽标、带设置页),**两个都装会抢写同一个 Dock 徽标,只留一个**。
|
|
235
|
-
|
|
236
|
-
## License
|
|
107
|
+
## 协议
|
|
237
108
|
|
|
238
|
-
MIT
|
|
109
|
+
MIT · 仓库 https://github.com/chendaleiQ/dsh-plugin-desktop-notify
|
package/index.js
CHANGED
|
@@ -15,9 +15,9 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { execFile, spawn } from "node:child_process"
|
|
18
|
-
import { appendFile, mkdir } from "node:fs/promises"
|
|
18
|
+
import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"
|
|
19
19
|
import { homedir } from "node:os"
|
|
20
|
-
import { basename, dirname } from "node:path"
|
|
20
|
+
import { basename, dirname, join } from "node:path"
|
|
21
21
|
|
|
22
22
|
/** Read the notification in argv order, so no AppleScript string escaping is needed. */
|
|
23
23
|
const APPLESCRIPT = [
|
|
@@ -42,6 +42,14 @@ const DEFAULTS = {
|
|
|
42
42
|
maxTrackedSessions: 64,
|
|
43
43
|
/** Log the decision without spawning osascript/afplay; the tests use it. */
|
|
44
44
|
dryRun: false,
|
|
45
|
+
/** Local interface other tools post notifications to (see README). */
|
|
46
|
+
api: true,
|
|
47
|
+
/** Where the endpoint URL is written for those tools. */
|
|
48
|
+
endpointPath: "~/.dsh/desktop-notify.endpoint",
|
|
49
|
+
/** Longest accepted request body. */
|
|
50
|
+
apiBodyLimit: 8192,
|
|
51
|
+
/** How many unread requests the interface keeps. */
|
|
52
|
+
apiQueueLimit: 50,
|
|
45
53
|
}
|
|
46
54
|
|
|
47
55
|
/** Turn-end bodies for the kinds that carry no model text. */
|
|
@@ -52,6 +60,172 @@ const TURN_END_BODIES = {
|
|
|
52
60
|
"max-tokens": () => "输出达到上限,本轮结束",
|
|
53
61
|
}
|
|
54
62
|
|
|
63
|
+
/** The URL path tools post to, and the path the renderer drains. */
|
|
64
|
+
const API_REQUEST_PATH = "/desktop-notify/request"
|
|
65
|
+
const API_PENDING_PATH = "/desktop-notify/pending"
|
|
66
|
+
|
|
67
|
+
/** Read one JSON request body, bounded by `limit` bytes. */
|
|
68
|
+
function readBody(request, limit) {
|
|
69
|
+
return new Promise((resolve, reject) => {
|
|
70
|
+
const chunks = []
|
|
71
|
+
let size = 0
|
|
72
|
+
request.on("data", (chunk) => {
|
|
73
|
+
size += chunk.length
|
|
74
|
+
if (size > limit) {
|
|
75
|
+
reject(new Error("body too large"))
|
|
76
|
+
request.destroy()
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
chunks.push(chunk)
|
|
80
|
+
})
|
|
81
|
+
request.on("end", () => {
|
|
82
|
+
try {
|
|
83
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")))
|
|
84
|
+
} catch (error) {
|
|
85
|
+
reject(error)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
request.on("error", reject)
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Write one JSON answer with the headers a local tool needs. */
|
|
93
|
+
function answer(response, status, payload) {
|
|
94
|
+
const body = JSON.stringify(payload)
|
|
95
|
+
response.writeHead(status, {
|
|
96
|
+
"content-type": "application/json; charset=utf-8",
|
|
97
|
+
"content-length": Buffer.byteLength(body),
|
|
98
|
+
"cache-control": "no-store",
|
|
99
|
+
"access-control-allow-origin": "*",
|
|
100
|
+
"access-control-allow-headers": "content-type",
|
|
101
|
+
})
|
|
102
|
+
response.end(body)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Mount the local notification interface other tools (a watch script, a
|
|
107
|
+
* browser agent, a cron job) post to.
|
|
108
|
+
*
|
|
109
|
+
* The interface is two loopback HTTP routes on the Host's own web server: POST
|
|
110
|
+
* a request, and the renderer drains the queue over the app protocol. The
|
|
111
|
+
* renderer shows the banner, so it is attributed to DeepSeek Harness rather
|
|
112
|
+
* than to a script host. Nothing here needs authentication: the server binds
|
|
113
|
+
* loopback, and the endpoint URL is written to a file the caller can read.
|
|
114
|
+
*/
|
|
115
|
+
function mountInterface(ctx, config) {
|
|
116
|
+
if (!config.api) return
|
|
117
|
+
const server = (() => {
|
|
118
|
+
try {
|
|
119
|
+
return ctx.get?.("webServer")
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
if (server === undefined || typeof server.register !== "function") return
|
|
125
|
+
|
|
126
|
+
const queue = []
|
|
127
|
+
const disposers = []
|
|
128
|
+
|
|
129
|
+
const post = async (request, response) => {
|
|
130
|
+
if (request.method === "OPTIONS") {
|
|
131
|
+
answer(response, 204, {})
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
if (request.method !== "POST") {
|
|
135
|
+
answer(response, 405, { ok: false, error: "use POST" })
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
let payload
|
|
139
|
+
try {
|
|
140
|
+
payload = await readBody(request, Math.max(1024, config.apiBodyLimit))
|
|
141
|
+
} catch (error) {
|
|
142
|
+
answer(response, 400, { ok: false, error: String(error?.message ?? error) })
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const title = typeof payload?.title === "string" ? payload.title.trim() : ""
|
|
146
|
+
const body = typeof payload?.body === "string" ? payload.body.trim() : ""
|
|
147
|
+
if (title === "" && body === "") {
|
|
148
|
+
answer(response, 400, { ok: false, error: "title or body is required" })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
queue.push({
|
|
152
|
+
at: new Date().toISOString(),
|
|
153
|
+
title: title === "" ? "DeepSeek Harness" : title,
|
|
154
|
+
body: body.slice(0, 400),
|
|
155
|
+
...typeof payload?.sessionId === "string" && payload.sessionId !== "" ? { sessionId: payload.sessionId } : {},
|
|
156
|
+
})
|
|
157
|
+
while (queue.length > Math.max(1, config.apiQueueLimit)) queue.shift()
|
|
158
|
+
answer(response, 202, { ok: true, queued: queue.length })
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pending = (request, response) => {
|
|
162
|
+
if (request.method === "OPTIONS") {
|
|
163
|
+
answer(response, 204, {})
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
if (request.method !== "GET") {
|
|
167
|
+
answer(response, 405, { ok: false, error: "use GET" })
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
const items = queue.splice(0, queue.length)
|
|
171
|
+
answer(response, 200, items)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
disposers.push(server.register({ kind: "exact", path: API_REQUEST_PATH, handler: (request, response) => {
|
|
176
|
+
void post(request, response).catch(() => answer(response, 500, { ok: false }))
|
|
177
|
+
} }))
|
|
178
|
+
disposers.push(server.register({ kind: "exact", path: API_PENDING_PATH, handler: (request, response) => {
|
|
179
|
+
try {
|
|
180
|
+
pending(request, response)
|
|
181
|
+
} catch {
|
|
182
|
+
answer(response, 500, [])
|
|
183
|
+
}
|
|
184
|
+
} }))
|
|
185
|
+
} catch (error) {
|
|
186
|
+
void Promise.resolve(config.logPath).then((path) => noteToLog(path, `interface: ${String(error)}`))
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Hand the caller an address to post to; the port is whatever the Host took.
|
|
191
|
+
const endpointPath = expandHome(config.endpointPath)
|
|
192
|
+
const origin = (() => {
|
|
193
|
+
try {
|
|
194
|
+
return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
|
|
195
|
+
} catch {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
})() ?? (typeof server.port === "number" ? `http://127.0.0.1:${server.port}` : undefined)
|
|
199
|
+
if (endpointPath !== "" && origin !== undefined) {
|
|
200
|
+
void mkdir(dirname(endpointPath), { recursive: true })
|
|
201
|
+
.then(() => writeFile(endpointPath, `${origin}${API_REQUEST_PATH}\n`, "utf8"))
|
|
202
|
+
.catch(() => {})
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
ctx.effect(() => () => {
|
|
206
|
+
for (const dispose of disposers) {
|
|
207
|
+
try {
|
|
208
|
+
dispose()
|
|
209
|
+
} catch {
|
|
210
|
+
/* the route table is being torn down anyway */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
|
|
214
|
+
}, "desktop-notify: local interface")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Append one best-effort line to the log from outside the session machinery. */
|
|
218
|
+
async function noteToLog(logPathConfig, line) {
|
|
219
|
+
try {
|
|
220
|
+
const path = expandHome(logPathConfig)
|
|
221
|
+
if (path === "") return
|
|
222
|
+
await mkdir(dirname(path), { recursive: true })
|
|
223
|
+
await appendFile(path, `${new Date().toISOString()} ${line}\n`, "utf8")
|
|
224
|
+
} catch {
|
|
225
|
+
/* logging is best effort */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
55
229
|
/** Merge the row's config over the defaults, keeping field types honest. */
|
|
56
230
|
function resolveConfig(raw) {
|
|
57
231
|
const config = { ...DEFAULTS }
|
|
@@ -142,6 +316,7 @@ function runDetached(command, args) {
|
|
|
142
316
|
*/
|
|
143
317
|
export function apply(ctx, rawConfig) {
|
|
144
318
|
const config = resolveConfig(rawConfig)
|
|
319
|
+
mountInterface(ctx, config)
|
|
145
320
|
if (!config.enabled) return
|
|
146
321
|
|
|
147
322
|
const logPath = expandHome(config.logPath)
|
package/lib/client.js
CHANGED
|
@@ -30,7 +30,7 @@ window.__ModuleLoader__.load({
|
|
|
30
30
|
factory: (require) => {
|
|
31
31
|
const NS = "dsh-plugin-desktop-notify"
|
|
32
32
|
/** Reported by capabilities(); keep in step with package.json (cross-machine support). */
|
|
33
|
-
const VERSION = "0.
|
|
33
|
+
const VERSION = "0.6.0"
|
|
34
34
|
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
35
|
|
|
36
36
|
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
@@ -91,7 +91,7 @@ window.__ModuleLoader__.load({
|
|
|
91
91
|
return name === "" ? text : `「${name}」${text}`
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
/** The UI language
|
|
94
|
+
/** The UI language before the app's locale service answers: `<html lang>`, then the browser. */
|
|
95
95
|
function activeLanguage() {
|
|
96
96
|
try {
|
|
97
97
|
const html = typeof document === "undefined" ? "" : document.documentElement?.lang ?? ""
|
|
@@ -104,14 +104,14 @@ window.__ModuleLoader__.load({
|
|
|
104
104
|
return "zh"
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/** The copy table for
|
|
108
|
-
function copy() {
|
|
109
|
-
return COPY[
|
|
107
|
+
/** The copy table for one UI language. */
|
|
108
|
+
function copy(language) {
|
|
109
|
+
return COPY[language] ?? COPY.zh
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Compact elapsed time: "42 秒" / "3 分 12 秒" / "1 小时 5 分". */
|
|
113
|
-
function durationText(milliseconds) {
|
|
114
|
-
const table = copy()
|
|
113
|
+
function durationText(milliseconds, language) {
|
|
114
|
+
const table = copy(language)
|
|
115
115
|
const total = Math.max(1, Math.round(milliseconds / 1000))
|
|
116
116
|
if (total < 60) return table.seconds(total)
|
|
117
117
|
const minutes = Math.floor(total / 60)
|
|
@@ -243,12 +243,28 @@ window.__ModuleLoader__.load({
|
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
245
|
|
|
246
|
-
const inject = ["sessions", "uiSession", "uiWorkspace"]
|
|
246
|
+
const inject = ["sessions", "uiSession", "uiWorkspace", "locale"]
|
|
247
247
|
|
|
248
248
|
/**
|
|
249
249
|
* @param ctx - Client root context.
|
|
250
250
|
*/
|
|
251
251
|
function apply(ctx) {
|
|
252
|
+
/**
|
|
253
|
+
* The app's own language, in the order DSH resolves it: the locale service
|
|
254
|
+
* (Settings → General), then `<html lang>`, then the browser. Chinese is
|
|
255
|
+
* the default; English is the only other language this plugin ships.
|
|
256
|
+
*/
|
|
257
|
+
const languageOf = () => {
|
|
258
|
+
try {
|
|
259
|
+
const active = String(ctx.locale?.getSnapshot?.().active ?? "").toLowerCase()
|
|
260
|
+
if (active.startsWith("zh")) return "zh"
|
|
261
|
+
if (active.startsWith("en")) return "en"
|
|
262
|
+
} catch {
|
|
263
|
+
/* an older host without the locale service falls back below */
|
|
264
|
+
}
|
|
265
|
+
return activeLanguage()
|
|
266
|
+
}
|
|
267
|
+
|
|
252
268
|
const preferences = readPreferences()
|
|
253
269
|
askPermission()
|
|
254
270
|
|
|
@@ -328,6 +344,74 @@ window.__ModuleLoader__.load({
|
|
|
328
344
|
return { ok: false, attempts }
|
|
329
345
|
}
|
|
330
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Banners requested by other tools: the Host half accepts POSTs on a
|
|
349
|
+
* loopback route and this half drains them over the app protocol, so the
|
|
350
|
+
* banner is a real DeepSeek Harness notification rather than a script
|
|
351
|
+
* host's.
|
|
352
|
+
*/
|
|
353
|
+
const EXTERNAL_PENDING_PATH = "/desktop-notify/pending"
|
|
354
|
+
let externalTimer
|
|
355
|
+
let externalStopped = false
|
|
356
|
+
const showExternal = (item) => {
|
|
357
|
+
const title = typeof item?.title === "string" && item.title !== "" ? item.title : "DeepSeek Harness"
|
|
358
|
+
const body = typeof item?.body === "string" ? item.body.slice(0, 200) : ""
|
|
359
|
+
if (!preferences.notifications) {
|
|
360
|
+
recordNotify({ kind: "external", title, body, result: "skipped: preferences.notifications=false" })
|
|
361
|
+
return
|
|
362
|
+
}
|
|
363
|
+
if (!canNotify()) {
|
|
364
|
+
recordNotify({ kind: "external", title, body, result: "skipped: Notification unavailable" })
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
let notification
|
|
368
|
+
try {
|
|
369
|
+
notification = new Notification(title, { body })
|
|
370
|
+
} catch (error) {
|
|
371
|
+
recordNotify({ kind: "external", title, body, result: `threw: ${String(error)}` })
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
recordNotify({ kind: "external", title, body, result: "posted" })
|
|
375
|
+
notification.onclick = () => {
|
|
376
|
+
let focused = false
|
|
377
|
+
try {
|
|
378
|
+
window.focus()
|
|
379
|
+
focused = true
|
|
380
|
+
} catch {
|
|
381
|
+
/* focus is best effort */
|
|
382
|
+
}
|
|
383
|
+
const sessionId = typeof item?.sessionId === "string" && item.sessionId !== "" ? item.sessionId : null
|
|
384
|
+
const opened = sessionId === null ? undefined : openSessionTarget(sessionId)
|
|
385
|
+
lastClick = {
|
|
386
|
+
at: new Date().toISOString(),
|
|
387
|
+
kind: "external",
|
|
388
|
+
sessionId,
|
|
389
|
+
...opened === undefined ? {} : { opened },
|
|
390
|
+
focused,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
const pollExternal = async () => {
|
|
395
|
+
if (externalStopped || typeof fetch !== "function") return
|
|
396
|
+
try {
|
|
397
|
+
const response = await fetch(EXTERNAL_PENDING_PATH, {
|
|
398
|
+
headers: { accept: "application/json" },
|
|
399
|
+
cache: "no-store",
|
|
400
|
+
})
|
|
401
|
+
if (!response.ok) return
|
|
402
|
+
const items = await response.json()
|
|
403
|
+
for (const item of Array.isArray(items) ? items : []) showExternal(item)
|
|
404
|
+
} catch {
|
|
405
|
+
/* no Host half, or this build does not proxy the path */
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (preferences.notifications) {
|
|
409
|
+
externalTimer = setInterval(() => { void pollExternal() }, 2500)
|
|
410
|
+
// Node-style timers can hold a process open; the renderer's cannot.
|
|
411
|
+
externalTimer?.unref?.()
|
|
412
|
+
void pollExternal()
|
|
413
|
+
}
|
|
414
|
+
|
|
331
415
|
/** Post one banner; clicking it opens the session and raises the window. */
|
|
332
416
|
function notify(kind, sessionId, title, body) {
|
|
333
417
|
if (!preferences.notifications) {
|
|
@@ -439,13 +523,13 @@ window.__ModuleLoader__.load({
|
|
|
439
523
|
if (!turnStartedAt.has(sessionId)) turnStartedAt.set(sessionId, Date.now())
|
|
440
524
|
}
|
|
441
525
|
|
|
442
|
-
const table = copy()
|
|
526
|
+
const table = copy(languageOf())
|
|
443
527
|
const name = sessionName(summary, preferences.maxBodyChars)
|
|
444
528
|
|
|
445
529
|
if (completionEdge) {
|
|
446
530
|
const started = turnStartedAt.get(sessionId)
|
|
447
531
|
turnStartedAt.delete(sessionId)
|
|
448
|
-
const duration = started === undefined ? "" : durationText(Date.now() - started)
|
|
532
|
+
const duration = started === undefined ? "" : durationText(Date.now() - started, languageOf())
|
|
449
533
|
const title = preferences.titleComplete || table.completeTitle
|
|
450
534
|
const body = bodyText(table.complete(name, duration), preferences.maxBodyChars)
|
|
451
535
|
if (looking) {
|
|
@@ -498,7 +582,7 @@ window.__ModuleLoader__.load({
|
|
|
498
582
|
const summary = summaries[sessionId]
|
|
499
583
|
if (preferences.excludeSubagents && isSubagent(summary)) return
|
|
500
584
|
if (isLooking(summary)) return
|
|
501
|
-
const table = copy()
|
|
585
|
+
const table = copy(languageOf())
|
|
502
586
|
const raw = typeof message === "string" ? message.trim() : ""
|
|
503
587
|
const text = bodyText(raw, preferences.maxBodyChars)
|
|
504
588
|
const before = attention.get(sessionId) ?? {}
|
|
@@ -566,6 +650,11 @@ window.__ModuleLoader__.load({
|
|
|
566
650
|
}
|
|
567
651
|
}, "dsh-plugin-desktop-notify: failure watch")
|
|
568
652
|
|
|
653
|
+
ctx.effect(() => () => {
|
|
654
|
+
externalStopped = true
|
|
655
|
+
clearInterval(externalTimer)
|
|
656
|
+
}, "dsh-plugin-desktop-notify: external interface release")
|
|
657
|
+
|
|
569
658
|
ctx.effect(() => () => {
|
|
570
659
|
try {
|
|
571
660
|
if (canBadge()) void navigator.clearAppBadge()
|
|
@@ -622,9 +711,9 @@ window.__ModuleLoader__.load({
|
|
|
622
711
|
}
|
|
623
712
|
},
|
|
624
713
|
simulateComplete(sessionId = "desktop-notify-simulation") {
|
|
625
|
-
const table = copy()
|
|
714
|
+
const table = copy(languageOf())
|
|
626
715
|
setAttention(sessionId, { completed: true })
|
|
627
|
-
notify("complete", sessionId, preferences.titleComplete || table.completeTitle, table.complete("", durationText(12000)))
|
|
716
|
+
notify("complete", sessionId, preferences.titleComplete || table.completeTitle, table.complete("", durationText(12000, languageOf())))
|
|
628
717
|
applyBadge()
|
|
629
718
|
return this.state()
|
|
630
719
|
},
|
package/locale/en.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"meta": {
|
|
3
|
-
"title": "Desktop Notifications &
|
|
4
|
-
"description": "
|
|
3
|
+
"title": "Desktop Notifications & Badge",
|
|
4
|
+
"description": "A system notification when a turn finishes, an approval or question is waiting, or a task fails, plus a badge on the app icon counting the sessions that need you. Click the notification to open that session."
|
|
5
5
|
}
|
|
6
6
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-desktop-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "
|
|
5
|
+
"description": "Desktop notifications and a Dock/taskbar badge for DeepSeek Harness: a finished turn, a pending approval or question, and a failure, with click-through back to the session",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"exports": {
|