dsh-plugin-desktop-notify 0.5.7 → 0.6.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.en.md +42 -0
- package/README.md +38 -0
- package/index.js +183 -2
- package/lib/client.js +74 -1
- package/package.json +1 -1
package/README.en.md
CHANGED
|
@@ -55,6 +55,48 @@ __DSH_DESKTOP_NOTIFY__.state()
|
|
|
55
55
|
__DSH_DESKTOP_NOTIFY__.report()
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
+
|
|
59
|
+
## Notification interface for other tools
|
|
60
|
+
|
|
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"}'
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Python:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
import json, pathlib, urllib.request
|
|
82
|
+
|
|
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
|
+
```
|
|
91
|
+
|
|
92
|
+
Notes:
|
|
93
|
+
|
|
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
|
|
99
|
+
|
|
58
100
|
## Known limits
|
|
59
101
|
|
|
60
102
|
- **Windows**: clicking a notification switches the session, but the window does
|
package/README.md
CHANGED
|
@@ -53,6 +53,44 @@ __DSH_DESKTOP_NOTIFY__.state() // 小红点、待处理会话、最近
|
|
|
53
53
|
__DSH_DESKTOP_NOTIFY__.report() // 上面全部 + 窗口焦点,便于排查
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
+
|
|
57
|
+
## 给其它工具用的通知接口
|
|
58
|
+
|
|
59
|
+
宿主半边在**回环地址**上开了两个路由,供脚本、浏览器代理、定时任务调用:
|
|
60
|
+
|
|
61
|
+
| 方法 | 路径 | 作用 |
|
|
62
|
+
| --- | --- | --- |
|
|
63
|
+
| `POST` | `/desktop-notify/request` | 发一条通知,body:`{"title": "...", "body": "...", "sessionId": "可选"}` |
|
|
64
|
+
| `GET` | `/desktop-notify/pending` | 渲染层取走待显示的通知(脚本不用调) |
|
|
65
|
+
|
|
66
|
+
地址写在 `~/.dsh/desktop-notify.endpoint`,所以调用方不用猜端口:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -s "$(cat ~/.dsh/desktop-notify.endpoint)" \
|
|
70
|
+
-H 'content-type: application/json' \
|
|
71
|
+
-d '{"title":"BrowserOS 需要你","body":"有一个接管请求"}'
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Python(比如监听 neo 的守护脚本):
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
import json, pathlib, urllib.request
|
|
78
|
+
|
|
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()
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
要点:
|
|
89
|
+
|
|
90
|
+
- 通知由 **DSH 自己弹出**(归属 DeepSeek Harness),不是 Script Editor
|
|
91
|
+
- 点通知会把窗口叫到前面;带 `sessionId` 时会切到那个会话
|
|
92
|
+
- 接口只监听回环地址,不需要登录;`enabled: false` 只关 osascript 兜底,不影响接口
|
|
93
|
+
|
|
56
94
|
## 已知限制
|
|
57
95
|
|
|
58
96
|
- **Windows**:点通知会切到对应会话,但窗口不一定到前台——`AppUserModelID` 和主进程 focus 通道属于桌面端,渲染层插件做不到
|
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,178 @@ 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") {
|
|
125
|
+
void noteToLog(config.logPath, "interface: the Host has no webServer service; the interface is unavailable")
|
|
126
|
+
return
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const queue = []
|
|
130
|
+
const disposers = []
|
|
131
|
+
|
|
132
|
+
const post = async (request, response) => {
|
|
133
|
+
if (request.method === "OPTIONS") {
|
|
134
|
+
answer(response, 204, {})
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (request.method !== "POST") {
|
|
138
|
+
answer(response, 405, { ok: false, error: "use POST" })
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
let payload
|
|
142
|
+
try {
|
|
143
|
+
payload = await readBody(request, Math.max(1024, config.apiBodyLimit))
|
|
144
|
+
} catch (error) {
|
|
145
|
+
answer(response, 400, { ok: false, error: String(error?.message ?? error) })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const title = typeof payload?.title === "string" ? payload.title.trim() : ""
|
|
149
|
+
const body = typeof payload?.body === "string" ? payload.body.trim() : ""
|
|
150
|
+
if (title === "" && body === "") {
|
|
151
|
+
answer(response, 400, { ok: false, error: "title or body is required" })
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
queue.push({
|
|
155
|
+
at: new Date().toISOString(),
|
|
156
|
+
title: title === "" ? "DeepSeek Harness" : title,
|
|
157
|
+
body: body.slice(0, 400),
|
|
158
|
+
...typeof payload?.sessionId === "string" && payload.sessionId !== "" ? { sessionId: payload.sessionId } : {},
|
|
159
|
+
})
|
|
160
|
+
while (queue.length > Math.max(1, config.apiQueueLimit)) queue.shift()
|
|
161
|
+
answer(response, 202, { ok: true, queued: queue.length })
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const pending = (request, response) => {
|
|
165
|
+
if (request.method === "OPTIONS") {
|
|
166
|
+
answer(response, 204, {})
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
if (request.method !== "GET") {
|
|
170
|
+
answer(response, 405, { ok: false, error: "use GET" })
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
const items = queue.splice(0, queue.length)
|
|
174
|
+
answer(response, 200, items)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
disposers.push(server.register({ kind: "exact", path: API_REQUEST_PATH, handler: (request, response) => {
|
|
179
|
+
void post(request, response).catch(() => answer(response, 500, { ok: false }))
|
|
180
|
+
} }))
|
|
181
|
+
disposers.push(server.register({ kind: "exact", path: API_PENDING_PATH, handler: (request, response) => {
|
|
182
|
+
try {
|
|
183
|
+
pending(request, response)
|
|
184
|
+
} catch {
|
|
185
|
+
answer(response, 500, [])
|
|
186
|
+
}
|
|
187
|
+
} }))
|
|
188
|
+
} catch (error) {
|
|
189
|
+
void noteToLog(config.logPath, `interface: cannot register routes: ${String(error)}`)
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Hand the caller an address to post to; the port is whatever the Host took.
|
|
194
|
+
const endpointPath = expandHome(config.endpointPath)
|
|
195
|
+
const origin = (() => {
|
|
196
|
+
try {
|
|
197
|
+
return typeof server.getOrigin === "function" ? server.getOrigin() : undefined
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined
|
|
200
|
+
}
|
|
201
|
+
})() ?? (typeof server.port === "number" ? `http://127.0.0.1:${server.port}` : undefined)
|
|
202
|
+
if (endpointPath !== "" && origin !== undefined) {
|
|
203
|
+
void mkdir(dirname(endpointPath), { recursive: true })
|
|
204
|
+
.then(() => writeFile(endpointPath, `${origin}${API_REQUEST_PATH}\n`, "utf8"))
|
|
205
|
+
.then(() => noteToLog(config.logPath, `interface: ${origin}${API_REQUEST_PATH}`))
|
|
206
|
+
.catch((error) => noteToLog(config.logPath, `interface: cannot write the endpoint file: ${String(error)}`))
|
|
207
|
+
} else {
|
|
208
|
+
void noteToLog(config.logPath, `interface: routes registered but the address is unknown (origin=${String(origin)})`)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
ctx.effect(() => () => {
|
|
212
|
+
for (const dispose of disposers) {
|
|
213
|
+
try {
|
|
214
|
+
dispose()
|
|
215
|
+
} catch {
|
|
216
|
+
/* the route table is being torn down anyway */
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (endpointPath !== "") void rm(endpointPath, { force: true }).catch(() => {})
|
|
220
|
+
}, "desktop-notify: local interface")
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Append one best-effort line to the log from outside the session machinery. */
|
|
224
|
+
async function noteToLog(logPathConfig, line) {
|
|
225
|
+
try {
|
|
226
|
+
const path = expandHome(logPathConfig)
|
|
227
|
+
if (path === "") return
|
|
228
|
+
await mkdir(dirname(path), { recursive: true })
|
|
229
|
+
await appendFile(path, `${new Date().toISOString()} ${line}\n`, "utf8")
|
|
230
|
+
} catch {
|
|
231
|
+
/* logging is best effort */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
55
235
|
/** Merge the row's config over the defaults, keeping field types honest. */
|
|
56
236
|
function resolveConfig(raw) {
|
|
57
237
|
const config = { ...DEFAULTS }
|
|
@@ -142,6 +322,7 @@ function runDetached(command, args) {
|
|
|
142
322
|
*/
|
|
143
323
|
export function apply(ctx, rawConfig) {
|
|
144
324
|
const config = resolveConfig(rawConfig)
|
|
325
|
+
mountInterface(ctx, config)
|
|
145
326
|
if (!config.enabled) return
|
|
146
327
|
|
|
147
328
|
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.1"
|
|
34
34
|
const PREFERENCES_KEY = `${NS}:preferences`
|
|
35
35
|
|
|
36
36
|
/** Defaults; override per field with the localStorage JSON described in README. */
|
|
@@ -344,6 +344,74 @@ window.__ModuleLoader__.load({
|
|
|
344
344
|
return { ok: false, attempts }
|
|
345
345
|
}
|
|
346
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
|
+
|
|
347
415
|
/** Post one banner; clicking it opens the session and raises the window. */
|
|
348
416
|
function notify(kind, sessionId, title, body) {
|
|
349
417
|
if (!preferences.notifications) {
|
|
@@ -582,6 +650,11 @@ window.__ModuleLoader__.load({
|
|
|
582
650
|
}
|
|
583
651
|
}, "dsh-plugin-desktop-notify: failure watch")
|
|
584
652
|
|
|
653
|
+
ctx.effect(() => () => {
|
|
654
|
+
externalStopped = true
|
|
655
|
+
clearInterval(externalTimer)
|
|
656
|
+
}, "dsh-plugin-desktop-notify: external interface release")
|
|
657
|
+
|
|
585
658
|
ctx.effect(() => () => {
|
|
586
659
|
try {
|
|
587
660
|
if (canBadge()) void navigator.clearAppBadge()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-desktop-notify",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"type": "module",
|
|
5
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",
|