dsh-workspace-plus 0.4.0 → 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.md +35 -446
- package/cordis.patch.yml +45 -8
- package/lib/client.js +1040 -231
- package/lib/notice/click.ps1 +67 -0
- package/lib/notice/format.js +313 -0
- package/lib/notice/icon.svg +16 -0
- package/lib/notice/index.js +951 -0
- package/lib/notice/locale/en.json +6 -0
- package/lib/notice/locale/zh.json +6 -0
- package/lib/notice/package.json +6 -0
- package/lib/notice/settings.js +336 -0
- package/lib/notice/toast-host.ps1 +616 -0
- package/lib/notice/toast.js +517 -0
- package/lib/notice/win32.js +206 -0
- package/package.json +12 -4
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-workspace-plus/notice — host half of the 通知 sub-feature.
|
|
3
|
+
*
|
|
4
|
+
* Ported verbatim from the dsh-notice plugin (0.2.1) when it was merged into
|
|
5
|
+
* dsh-workspace-plus as a second loader row: same events, same routes, same
|
|
6
|
+
* Windows transport, same `~/.dsh/notice` store. Only the module identity and
|
|
7
|
+
* the log prefix changed, so an existing dsh-notice install upgrades in place
|
|
8
|
+
* (settings, protocol registration and in-flight notifications all carry over).
|
|
9
|
+
*
|
|
10
|
+
* The plugin watches a running Harness and raises a Windows notification when a
|
|
11
|
+
* conversation needs the user, marking WHICH kind of need it is. Four moments
|
|
12
|
+
* are covered, all of them read from events the Harness already emits, so the
|
|
13
|
+
* plugin never changes what the agent does:
|
|
14
|
+
*
|
|
15
|
+
* approval/request a tool call is waiting for a permission decision
|
|
16
|
+
* user-questions/request the model asked the user a structured question
|
|
17
|
+
* session/event turn/end the turn finished, was interrupted, or failed
|
|
18
|
+
* agent/error the agent failed outside a durable turn position
|
|
19
|
+
*
|
|
20
|
+
* Every listener is an observer. The two waterfall listeners are registered with
|
|
21
|
+
* `{ prepend: true }` and call `next()` unconditionally, returning its outcome
|
|
22
|
+
* unchanged — the only shape that leaves the decision with the composed
|
|
23
|
+
* answerers. Prepending is not a preference: a waterfall runs
|
|
24
|
+
* first-registered-first with the first listener outermost, and the shipped
|
|
25
|
+
* remote forwarder (registered at host load, before any bundle row) resolves an
|
|
26
|
+
* approval or a question WITHOUT calling `next()` once the desktop page answers
|
|
27
|
+
* it, so a tail-registered observer would never run in the very environment this
|
|
28
|
+
* plugin targets. Nothing here can allow, deny, or delay a tool call.
|
|
29
|
+
*
|
|
30
|
+
* A click on a notification must reopen the conversation it is about, and that
|
|
31
|
+
* is the one place the plugin leaves the host process:
|
|
32
|
+
*
|
|
33
|
+
* 1. the toast carries a `dsh-notice:open?s=<session>` protocol activation;
|
|
34
|
+
* 2. the plugin registers that protocol under HKCU, pointing at a small
|
|
35
|
+
* PowerShell launcher that calls this plugin's own loopback route;
|
|
36
|
+
* 3. the route records the session, brings the desktop window forward, and
|
|
37
|
+
* the browser half — which polls the plugin's route — selects that
|
|
38
|
+
* conversation through the Harness's own `uiWorkspace.openSession`.
|
|
39
|
+
*
|
|
40
|
+
* Step 3 is why the browser half exists at all: the Host cannot select a
|
|
41
|
+
* conversation in a page's UI, so it publishes the intent and the page applies
|
|
42
|
+
* it through the Client service that owns navigation. In the merged package the
|
|
43
|
+
* browser half is `lib/client.js` — the workspace+ row's client bundle — which
|
|
44
|
+
* hosts the 通知 settings page and the pending-open poll next to the workspace
|
|
45
|
+
* features; when the `notice` row is disabled those two degrade silently.
|
|
46
|
+
*
|
|
47
|
+
* @module dsh-workspace-plus/notice
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { randomUUID, timingSafeEqual } from 'node:crypto'
|
|
51
|
+
import { dirname, join } from 'node:path'
|
|
52
|
+
import { fileURLToPath } from 'node:url'
|
|
53
|
+
import {
|
|
54
|
+
describeAgentError,
|
|
55
|
+
describeApproval,
|
|
56
|
+
describeQuestion,
|
|
57
|
+
describeTurnEnd,
|
|
58
|
+
isInScope,
|
|
59
|
+
openUrl,
|
|
60
|
+
} from './format.js'
|
|
61
|
+
import {
|
|
62
|
+
KIND_LABELS,
|
|
63
|
+
KINDS,
|
|
64
|
+
clearOverride,
|
|
65
|
+
mergeOverrides,
|
|
66
|
+
normalizeSettings,
|
|
67
|
+
readStore,
|
|
68
|
+
storePath,
|
|
69
|
+
writeStore,
|
|
70
|
+
} from './settings.js'
|
|
71
|
+
import { ToastDaemon, toastTag } from './toast.js'
|
|
72
|
+
import {
|
|
73
|
+
focusDesktopWindow,
|
|
74
|
+
isSupported,
|
|
75
|
+
powershellPath,
|
|
76
|
+
protocolCommand,
|
|
77
|
+
readProtocolCommand,
|
|
78
|
+
registerProtocol,
|
|
79
|
+
unregisterProtocol,
|
|
80
|
+
} from './win32.js'
|
|
81
|
+
|
|
82
|
+
/** Plugin name shown by the loader and in host logs. */
|
|
83
|
+
export const name = 'dsh-workspace-plus/notice'
|
|
84
|
+
|
|
85
|
+
/** Settings and control route the browser half talks to. */
|
|
86
|
+
export const API_PATH = '/dsh-notice/api'
|
|
87
|
+
|
|
88
|
+
/** Route the registered protocol launcher calls. */
|
|
89
|
+
export const OPEN_PATH = '/dsh-notice/api/open'
|
|
90
|
+
|
|
91
|
+
/** Route the browser half polls for a pending "open this conversation" intent. */
|
|
92
|
+
export const PENDING_PATH = '/dsh-notice/api/pending'
|
|
93
|
+
|
|
94
|
+
/** Directory holding this plugin's shipped assets. */
|
|
95
|
+
const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
|
|
96
|
+
|
|
97
|
+
/** PowerShell launcher the protocol registration points at. */
|
|
98
|
+
export const CLICK_SCRIPT = join(MODULE_DIR, 'click.ps1')
|
|
99
|
+
|
|
100
|
+
/** Largest accepted settings request body. */
|
|
101
|
+
const MAX_BODY_BYTES = 64 * 1024
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* How much of an over-long body is read and discarded before the connection is
|
|
105
|
+
* dropped. Bounded so a refusal cannot be turned into a memory sink.
|
|
106
|
+
*/
|
|
107
|
+
const DRAIN_CAP_BYTES = 8 * 1024 * 1024
|
|
108
|
+
|
|
109
|
+
/** How long a pending "open this conversation" intent stays valid. */
|
|
110
|
+
const PENDING_TTL_MS = 60_000
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Suppression window for a repeated notification of the same class about the
|
|
114
|
+
* same conversation. A retry loop can append several failing turns within
|
|
115
|
+
* seconds; the toast for each would replace the previous one anyway, so the
|
|
116
|
+
* window only removes flicker and daemon round-trips.
|
|
117
|
+
*/
|
|
118
|
+
const MIN_REPEAT_MS = 1_200
|
|
119
|
+
|
|
120
|
+
/** Addresses that mean "this machine" for the loopback fallback below. */
|
|
121
|
+
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
|
|
122
|
+
/**
|
|
123
|
+
* Merge the bundle config with the user's runtime overrides.
|
|
124
|
+
*
|
|
125
|
+
* `kinds` is the one nested object, and the settings page flips one class at a
|
|
126
|
+
* time, so it merges key by key while every scalar replaces.
|
|
127
|
+
*
|
|
128
|
+
* @param base - normalized bundle config.
|
|
129
|
+
* @param overrides - stored overrides.
|
|
130
|
+
* @returns complete, normalized effective settings.
|
|
131
|
+
*/
|
|
132
|
+
export function composeSettings(base, overrides) {
|
|
133
|
+
const patch = overrides !== null && typeof overrides === 'object' ? overrides : {}
|
|
134
|
+
const patchKinds = patch.kinds !== null && typeof patch.kinds === 'object' ? patch.kinds : {}
|
|
135
|
+
return normalizeSettings({
|
|
136
|
+
...base,
|
|
137
|
+
...patch,
|
|
138
|
+
kinds: { ...base.kinds, ...patchKinds },
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Whether one HTTP request arrives over the loopback interface.
|
|
144
|
+
*
|
|
145
|
+
* @param req - incoming request.
|
|
146
|
+
* @returns true for this machine's own connections.
|
|
147
|
+
*/
|
|
148
|
+
export function isLoopbackRequest(req) {
|
|
149
|
+
const address = req?.socket?.remoteAddress
|
|
150
|
+
return typeof address === 'string' && LOOPBACK_ADDRESSES.has(address)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Whether a request is same-site, as far as the browser is willing to say.
|
|
155
|
+
*
|
|
156
|
+
* The loopback exemption below trusts this machine's own processes, and a
|
|
157
|
+
* malicious page in the operator's browser IS such a process. A cross-origin
|
|
158
|
+
* `fetch` is what turns that trust into a blind CSRF primitive, so the two
|
|
159
|
+
* headers a browser always attaches to one are checked before the exemption is
|
|
160
|
+
* granted: `Sec-Fetch-Site` is `cross-site`, or `Origin` names a different
|
|
161
|
+
* authority than `Host`. Neither header is present on a non-browser caller
|
|
162
|
+
* (the protocol launcher, curl), which is exactly who the exemption is for.
|
|
163
|
+
*
|
|
164
|
+
* @param req - incoming request.
|
|
165
|
+
* @returns true when the request may be treated as same-site.
|
|
166
|
+
*/
|
|
167
|
+
export function isSameSiteRequest(req) {
|
|
168
|
+
const headers = req?.headers ?? {}
|
|
169
|
+
const site = headers['sec-fetch-site']
|
|
170
|
+
if (typeof site === 'string' && site.toLowerCase() === 'cross-site') return false
|
|
171
|
+
const origin = headers.origin
|
|
172
|
+
// A missing Origin is a non-browser caller — the protocol launcher and curl
|
|
173
|
+
// send none — which is exactly who the loopback exemption is for. A literal
|
|
174
|
+
// `null` says the opposite: a browser that cannot name its own origin (a
|
|
175
|
+
// sandboxed frame, a `file://` page) declared that, and it must not inherit
|
|
176
|
+
// the trust the exemption grants.
|
|
177
|
+
if (typeof origin !== 'string' || origin === '') return true
|
|
178
|
+
if (origin === 'null') return false
|
|
179
|
+
const host = headers.host
|
|
180
|
+
if (typeof host !== 'string' || host === '') return true
|
|
181
|
+
try {
|
|
182
|
+
return new URL(origin).host.toLowerCase() === host.toLowerCase()
|
|
183
|
+
} catch {
|
|
184
|
+
return false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The port a page's reported origin implies, when that origin is this machine.
|
|
190
|
+
*
|
|
191
|
+
* The value decides where the registered protocol launcher sends its secret
|
|
192
|
+
* token, so only a loopback http/https origin is accepted: neither a page nor a
|
|
193
|
+
* local process may be able to re-point the registry at another host. An
|
|
194
|
+
* omitted port means the scheme default, which is how the plugin has always
|
|
195
|
+
* read it.
|
|
196
|
+
*
|
|
197
|
+
* @param origin - origin reported by the browser half.
|
|
198
|
+
* @returns the port, or null when the origin is absent, malformed, or not local.
|
|
199
|
+
*/
|
|
200
|
+
export function loopbackOriginPort(origin) {
|
|
201
|
+
if (typeof origin !== 'string' || origin === '') return null
|
|
202
|
+
let url
|
|
203
|
+
try {
|
|
204
|
+
url = new URL(origin)
|
|
205
|
+
} catch {
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
|
|
209
|
+
const host = url.hostname.toLowerCase()
|
|
210
|
+
if (host !== '127.0.0.1' && host !== 'localhost' && host !== '::1' && host !== '[::1]') return null
|
|
211
|
+
if (url.port === '') return url.protocol === 'https:' ? 443 : 80
|
|
212
|
+
const port = Number(url.port)
|
|
213
|
+
return Number.isInteger(port) && port > 0 ? port : null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compare two secrets without leaking their length or content through timing.
|
|
218
|
+
*
|
|
219
|
+
* @param a - first secret.
|
|
220
|
+
* @param b - second secret.
|
|
221
|
+
* @returns true when they are equal.
|
|
222
|
+
*/
|
|
223
|
+
export function secretEquals(a, b) {
|
|
224
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false
|
|
225
|
+
const left = Buffer.from(a, 'utf8')
|
|
226
|
+
const right = Buffer.from(b, 'utf8')
|
|
227
|
+
if (left.length !== right.length) return false
|
|
228
|
+
return timingSafeEqual(left, right)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Host half entry point.
|
|
233
|
+
*
|
|
234
|
+
* @param ctx - the plugin's Cordis context.
|
|
235
|
+
* @param config - the bundle row's `config` object, used as deployment defaults.
|
|
236
|
+
*/
|
|
237
|
+
export function apply(ctx, config) {
|
|
238
|
+
const file = storePath()
|
|
239
|
+
const baseConfig = normalizeSettings(config)
|
|
240
|
+
|
|
241
|
+
let loaded = readStore(file)
|
|
242
|
+
let overrides = loaded.store.overrides
|
|
243
|
+
if (loaded.warning !== null) ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${loaded.warning}`)
|
|
244
|
+
|
|
245
|
+
/** Current effective settings. */
|
|
246
|
+
const current = () => composeSettings(baseConfig, overrides)
|
|
247
|
+
|
|
248
|
+
/** Token authorizing the protocol launcher; fresh per host process. */
|
|
249
|
+
const token = randomUUID()
|
|
250
|
+
|
|
251
|
+
/** The session-title service, when this composition mounts one. */
|
|
252
|
+
let sessionTitle = null
|
|
253
|
+
ctx.inject(['sessionTitle'], (scope) => {
|
|
254
|
+
sessionTitle = scope.sessionTitle
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/** Origin the browser half reported, used to learn the listen port. */
|
|
258
|
+
let guiOrigin = null
|
|
259
|
+
|
|
260
|
+
/** Command line currently written into the registry, or null. */
|
|
261
|
+
let registeredCommand = null
|
|
262
|
+
|
|
263
|
+
/** Scheme that command was written under, so a change can retire it. */
|
|
264
|
+
let registeredScheme = null
|
|
265
|
+
|
|
266
|
+
/** The last conversation the plugin announced, used by the test action. */
|
|
267
|
+
let lastSessionId = null
|
|
268
|
+
|
|
269
|
+
/** A pending "open this conversation" intent, consumed by the browser half. */
|
|
270
|
+
let pendingOpen = null
|
|
271
|
+
|
|
272
|
+
/** Turn start times, so a finished turn can report how long it took. */
|
|
273
|
+
const turnStartedAt = new Map()
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Last announcement time per tag, carrying the conversation it belongs to.
|
|
277
|
+
*
|
|
278
|
+
* The tag is a digest (`<kind>-<hash>`), so a session cannot be recovered from
|
|
279
|
+
* a key: the session id is recorded beside the timestamp for the disposal
|
|
280
|
+
* prune below.
|
|
281
|
+
*/
|
|
282
|
+
const lastAnnouncedAt = new Map()
|
|
283
|
+
|
|
284
|
+
const daemon = new ToastDaemon({
|
|
285
|
+
appId: current().appId,
|
|
286
|
+
log: (message) => ctx.logger?.info?.(message),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
// --- session lookup -------------------------------------------------------
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The live session behind an agent reference.
|
|
293
|
+
*
|
|
294
|
+
* The `Agent` type the event contracts publish is only `{ id }`, but the
|
|
295
|
+
* event's own payload carries the live agent; the session store is the
|
|
296
|
+
* fallback for a composition that publishes a narrower agent object.
|
|
297
|
+
*
|
|
298
|
+
* @param agent - agent reference from an event payload.
|
|
299
|
+
* @returns the live session, or undefined.
|
|
300
|
+
*/
|
|
301
|
+
const sessionOf = (agent) => {
|
|
302
|
+
const direct = agent?.session
|
|
303
|
+
if (direct !== undefined && direct !== null) return direct
|
|
304
|
+
const id = agent?.id
|
|
305
|
+
if (id === undefined || id === null) return undefined
|
|
306
|
+
return ctx.get('sessions')?.get(id)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The conversation label used by a notification.
|
|
311
|
+
*
|
|
312
|
+
* @param session - live session.
|
|
313
|
+
* @returns `{ id, title, cwd }`.
|
|
314
|
+
*/
|
|
315
|
+
const infoOf = (session) => ({
|
|
316
|
+
id: session === undefined || session === null ? '' : String(session.id),
|
|
317
|
+
title: sessionTitle?.get?.(session)?.title ?? '',
|
|
318
|
+
cwd: session?.header?.cwd,
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
// --- notification dispatch ------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The activation a notification should carry.
|
|
325
|
+
*
|
|
326
|
+
* With a working protocol registration the click can name the conversation.
|
|
327
|
+
* Without one it must still do something, so it degrades to `dsh://open` —
|
|
328
|
+
* the desktop application's own protocol, which brings its window forward.
|
|
329
|
+
* A notification whose click does nothing at all would be worse than one that
|
|
330
|
+
* only raises the window.
|
|
331
|
+
*
|
|
332
|
+
* @param settings - effective settings.
|
|
333
|
+
* @param sessionId - conversation to open.
|
|
334
|
+
* @param kind - notification class.
|
|
335
|
+
* @returns the protocol URL for the toast's activation.
|
|
336
|
+
*/
|
|
337
|
+
const activationFor = (settings, sessionId, kind) =>
|
|
338
|
+
settings.registerProtocol === true && registeredCommand !== null
|
|
339
|
+
? openUrl(settings.scheme, sessionId, kind)
|
|
340
|
+
: 'dsh://open'
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Raise one notification, after applying the settings that gate it.
|
|
344
|
+
*
|
|
345
|
+
* @param descriptor - wording produced by `format.js`, or null to skip.
|
|
346
|
+
* @param session - the conversation it is about.
|
|
347
|
+
* @param requestId - identity of the pending request, when the caller has one.
|
|
348
|
+
* Two approvals can be outstanding at once in one conversation (parallel tool
|
|
349
|
+
* calls), and Windows replaces a toast that reuses a tag — so without this a
|
|
350
|
+
* second request would silently overwrite the first, and answering the first
|
|
351
|
+
* would withdraw the notification for the request that is still waiting.
|
|
352
|
+
* @returns `{ tag, shown }` where `shown` resolves with the send ack, or null
|
|
353
|
+
* when nothing was shown (disabled, throttled, or out of scope).
|
|
354
|
+
*/
|
|
355
|
+
const announce = (descriptor, session, requestId) => {
|
|
356
|
+
if (descriptor === null || descriptor === undefined) return null
|
|
357
|
+
const settings = current()
|
|
358
|
+
if (!settings.enabled) return null
|
|
359
|
+
if (settings.kinds[descriptor.kind] !== true) return null
|
|
360
|
+
if (!isInScope(session?.header, settings.scope)) return null
|
|
361
|
+
|
|
362
|
+
const sessionId = session === undefined || session === null ? '' : String(session.id)
|
|
363
|
+
// Windows rejects a toast tag longer than 64 characters — measured on this
|
|
364
|
+
// machine, 64 is accepted and 69 throws 0x803E0209 — and a rejected tag is
|
|
365
|
+
// swallowed, leaving a notification that can never be withdrawn. A real
|
|
366
|
+
// session id is already `session-<uuid>` (44 characters), so the tag is
|
|
367
|
+
// derived through a fixed-width digest rather than concatenated.
|
|
368
|
+
const tag = toastTag(descriptor.kind, sessionId, requestId)
|
|
369
|
+
const now = Date.now()
|
|
370
|
+
const previous = lastAnnouncedAt.get(tag)
|
|
371
|
+
if (previous !== undefined && now - previous.at < MIN_REPEAT_MS) return null
|
|
372
|
+
lastAnnouncedAt.set(tag, { at: now, sessionId })
|
|
373
|
+
// The map is pruned when a session is disposed, and this sweep covers the
|
|
374
|
+
// sessions the host never disposes: a long-lived host that disposes nothing
|
|
375
|
+
// still cannot grow it without bound, because every entry is refreshed
|
|
376
|
+
// rather than appended.
|
|
377
|
+
if (lastAnnouncedAt.size > 512) {
|
|
378
|
+
for (const [key, entry] of lastAnnouncedAt) {
|
|
379
|
+
if (now - entry.at > 60_000) lastAnnouncedAt.delete(key)
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
lastSessionId = sessionId === '' ? lastSessionId : sessionId
|
|
383
|
+
|
|
384
|
+
const shown = daemon
|
|
385
|
+
.show({
|
|
386
|
+
title: descriptor.title,
|
|
387
|
+
session: descriptor.session,
|
|
388
|
+
detail: descriptor.detail,
|
|
389
|
+
launch: activationFor(settings, sessionId, descriptor.kind),
|
|
390
|
+
tag,
|
|
391
|
+
silent: settings.sound !== true,
|
|
392
|
+
duration: settings.duration,
|
|
393
|
+
})
|
|
394
|
+
.catch((error) => {
|
|
395
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${String((error && error.message) || error)}`)
|
|
396
|
+
return { ok: false, error: String((error && error.message) || error) }
|
|
397
|
+
})
|
|
398
|
+
return { tag, shown }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Withdraw the notification raised for a request that has now been answered.
|
|
403
|
+
*
|
|
404
|
+
* The AUMID has to come from the `show` ack rather than from the daemon's
|
|
405
|
+
* current identity: `show()` may have fallen back to another candidate, and a
|
|
406
|
+
* toast filed under one identity cannot be removed under another. Waiting for
|
|
407
|
+
* the ack also means the removal cannot race the show.
|
|
408
|
+
*
|
|
409
|
+
* @param raised - the value `announce` returned, or null when nothing was shown.
|
|
410
|
+
* @returns nothing.
|
|
411
|
+
*/
|
|
412
|
+
const withdraw = (raised) => {
|
|
413
|
+
if (raised === null) return
|
|
414
|
+
void raised.shown
|
|
415
|
+
.then((ack) => (ack.ok === true ? daemon.remove(raised.tag, ack.appId) : undefined))
|
|
416
|
+
.catch(() => {})
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// --- 1. a tool call is waiting for a permission decision ------------------
|
|
420
|
+
// A waterfall listener, and it MUST be prepended. A waterfall is dispatched
|
|
421
|
+
// first-registered-first, and the first listener is outermost: the shipped
|
|
422
|
+
// `@deepseek-ai/dsh-api-remotes` forwarder registers at host load (long before
|
|
423
|
+
// any bundle row) and, when the desktop page answers, resolves the request
|
|
424
|
+
// WITHOUT calling `next()`. Registered at the tail, this listener would simply
|
|
425
|
+
// never run while the GUI is open — which is exactly the case it exists for.
|
|
426
|
+
// Prepended, it observes the request first and then delegates unchanged, so
|
|
427
|
+
// the decision still belongs entirely to the composed answerers. The toast is
|
|
428
|
+
// withdrawn once that decision is made, so Action Center does not keep a
|
|
429
|
+
// request that has already been answered.
|
|
430
|
+
ctx.on(
|
|
431
|
+
'approval/request',
|
|
432
|
+
async (req, next) => {
|
|
433
|
+
let raised = null
|
|
434
|
+
try {
|
|
435
|
+
const session = sessionOf(req?.agent)
|
|
436
|
+
raised = announce(
|
|
437
|
+
describeApproval({
|
|
438
|
+
info: infoOf(session),
|
|
439
|
+
toolName: req?.toolName,
|
|
440
|
+
reason: req?.reason,
|
|
441
|
+
displayReason: req?.displayReason,
|
|
442
|
+
}),
|
|
443
|
+
session,
|
|
444
|
+
req?.callId,
|
|
445
|
+
)
|
|
446
|
+
} catch (error) {
|
|
447
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] 权限通知失败:${String((error && error.message) || error)}`)
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
return await next()
|
|
451
|
+
} finally {
|
|
452
|
+
withdraw(raised)
|
|
453
|
+
}
|
|
454
|
+
},
|
|
455
|
+
{ prepend: true },
|
|
456
|
+
)
|
|
457
|
+
|
|
458
|
+
// --- 2. the model asked the user a question ------------------------------
|
|
459
|
+
// Prepended for the same reason as the approval listener above.
|
|
460
|
+
ctx.on(
|
|
461
|
+
'user-questions/request',
|
|
462
|
+
async (request, next) => {
|
|
463
|
+
let raised = null
|
|
464
|
+
try {
|
|
465
|
+
const session = sessionOf(request?.agent)
|
|
466
|
+
raised = announce(
|
|
467
|
+
describeQuestion({ info: infoOf(session), questions: request?.questions }),
|
|
468
|
+
session,
|
|
469
|
+
request?.wait?.callId,
|
|
470
|
+
)
|
|
471
|
+
} catch (error) {
|
|
472
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] 提问通知失败:${String((error && error.message) || error)}`)
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
return await next()
|
|
476
|
+
} finally {
|
|
477
|
+
withdraw(raised)
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
{ prepend: true },
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
// --- 3. turns end: finished, interrupted, or failed ----------------------
|
|
484
|
+
ctx.on('session/event', (session, event) => {
|
|
485
|
+
if (event === null || typeof event !== 'object') return
|
|
486
|
+
const sessionId = String(session?.id ?? '')
|
|
487
|
+
try {
|
|
488
|
+
if (event.type === 'turn/start') {
|
|
489
|
+
turnStartedAt.set(sessionId, Date.now())
|
|
490
|
+
return
|
|
491
|
+
}
|
|
492
|
+
if (event.type !== 'turn/end') return
|
|
493
|
+
const startedAt = turnStartedAt.get(sessionId)
|
|
494
|
+
turnStartedAt.delete(sessionId)
|
|
495
|
+
const durationMs = startedAt === undefined ? undefined : Date.now() - startedAt
|
|
496
|
+
|
|
497
|
+
const descriptor = describeTurnEnd({ info: infoOf(session), reason: event.data?.reason, durationMs })
|
|
498
|
+
if (descriptor === null) return
|
|
499
|
+
if (descriptor.kind === 'done' && durationMs !== undefined && durationMs < current().minTurnMs) return
|
|
500
|
+
announce(descriptor, session)
|
|
501
|
+
} catch (error) {
|
|
502
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] 任务状态通知失败:${String((error && error.message) || error)}`)
|
|
503
|
+
}
|
|
504
|
+
})
|
|
505
|
+
|
|
506
|
+
// --- 4. the agent failed outside a durable turn position -----------------
|
|
507
|
+
ctx.on('agent/error', (payload) => {
|
|
508
|
+
try {
|
|
509
|
+
const session = sessionOf(payload?.agent)
|
|
510
|
+
announce(describeAgentError({ info: infoOf(session), error: payload?.error }), session)
|
|
511
|
+
} catch (error) {
|
|
512
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] 错误通知失败:${String((error && error.message) || error)}`)
|
|
513
|
+
}
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
/** A disposed session must not leave bookkeeping behind. */
|
|
517
|
+
ctx.on('session/disposed', (session) => {
|
|
518
|
+
const sessionId = String(session?.id ?? '')
|
|
519
|
+
turnStartedAt.delete(sessionId)
|
|
520
|
+
for (const [tag, entry] of [...lastAnnouncedAt]) {
|
|
521
|
+
if (entry.sessionId === sessionId) lastAnnouncedAt.delete(tag)
|
|
522
|
+
}
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
// --- the protocol registration -------------------------------------------
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The loopback port the launcher must call, once it is known.
|
|
529
|
+
*
|
|
530
|
+
* The listen port is not published on any service the plugin can read with
|
|
531
|
+
* certainty, so it is resolved from the most authoritative source that exists
|
|
532
|
+
* at the time:
|
|
533
|
+
*
|
|
534
|
+
* 1. the service that owns the listener. `ctx.webServer.port` is the port
|
|
535
|
+
* actually bound, and it is the only source available before a page has
|
|
536
|
+
* spoken. It matters because a `0` port means "let the OS pick": the
|
|
537
|
+
* desktop application launches the profile with `--port 0` and reads the
|
|
538
|
+
* real port back from this same service, so the startup value below is a
|
|
539
|
+
* request, not an answer, and is 0 there.
|
|
540
|
+
* 2. the origin the browser half reported, which is the address the page was
|
|
541
|
+
* actually served from. It is validated by `loopbackOriginPort` before it
|
|
542
|
+
* is adopted.
|
|
543
|
+
* 3. the startup value, for a caller that passed `--port` explicitly.
|
|
544
|
+
*
|
|
545
|
+
* The launcher always calls 127.0.0.1: the click happens on this machine, and
|
|
546
|
+
* a Harness bound to every interface still answers on loopback.
|
|
547
|
+
*
|
|
548
|
+
* @returns the port, or null while no source knows one.
|
|
549
|
+
*/
|
|
550
|
+
const listenPort = () => {
|
|
551
|
+
const bound = Number(ctx.get('webServer')?.port)
|
|
552
|
+
if (Number.isInteger(bound) && bound > 0) return bound
|
|
553
|
+
const reported = loopbackOriginPort(guiOrigin)
|
|
554
|
+
if (reported !== null) return reported
|
|
555
|
+
const port = ctx.get('webStartup')?.port
|
|
556
|
+
if (typeof port === 'number' && Number.isInteger(port) && port > 0) return port
|
|
557
|
+
return null
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* One synchronous-ish pass over the registry. Callers use `syncProtocol`.
|
|
562
|
+
*
|
|
563
|
+
* @returns the outcome for the settings page.
|
|
564
|
+
*/
|
|
565
|
+
const syncProtocolNow = async () => {
|
|
566
|
+
const settings = current()
|
|
567
|
+
const scheme = settings.scheme
|
|
568
|
+
|
|
569
|
+
/** Retire a registration this plugin owns under a scheme it no longer uses. */
|
|
570
|
+
const retireOldScheme = async () => {
|
|
571
|
+
if (registeredScheme === null || registeredScheme === scheme) return
|
|
572
|
+
const previous = registeredScheme
|
|
573
|
+
const previousCommand = registeredCommand
|
|
574
|
+
registeredScheme = null
|
|
575
|
+
registeredCommand = null
|
|
576
|
+
const stored = await readProtocolCommand(previous)
|
|
577
|
+
if (previousCommand === null || stored === previousCommand) await unregisterProtocol(previous)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!isSupported()) return { ok: false, message: '仅 Windows 支持协议跳转' }
|
|
581
|
+
if (settings.registerProtocol !== true) {
|
|
582
|
+
await retireOldScheme()
|
|
583
|
+
if (registeredCommand !== null) {
|
|
584
|
+
await unregisterProtocol(scheme)
|
|
585
|
+
registeredCommand = null
|
|
586
|
+
registeredScheme = null
|
|
587
|
+
}
|
|
588
|
+
return { ok: true, message: '已关闭协议注册:点击通知只会把 Harness 窗口切到前台' }
|
|
589
|
+
}
|
|
590
|
+
const port = listenPort()
|
|
591
|
+
if (port === null) return { ok: false, message: '尚未获知 Harness 监听端口,稍后重试' }
|
|
592
|
+
await retireOldScheme()
|
|
593
|
+
|
|
594
|
+
const command = protocolCommand({
|
|
595
|
+
powershell: powershellPath(),
|
|
596
|
+
script: CLICK_SCRIPT,
|
|
597
|
+
scheme,
|
|
598
|
+
port,
|
|
599
|
+
token,
|
|
600
|
+
})
|
|
601
|
+
const stored = await readProtocolCommand(scheme)
|
|
602
|
+
if (stored === command) {
|
|
603
|
+
registeredCommand = command
|
|
604
|
+
registeredScheme = scheme
|
|
605
|
+
return { ok: true, message: `${scheme}: 已注册` }
|
|
606
|
+
}
|
|
607
|
+
const result = await registerProtocol({ scheme, command })
|
|
608
|
+
registeredCommand = result.ok ? command : null
|
|
609
|
+
registeredScheme = result.ok ? scheme : null
|
|
610
|
+
return result
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/** Tail of the serialization chain, so registry writes never interleave. */
|
|
614
|
+
let protocolChain = Promise.resolve()
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Make the registry match the current settings and listen port.
|
|
618
|
+
*
|
|
619
|
+
* Idempotent on purpose: it is called at load, whenever the user changes the
|
|
620
|
+
* settings, and whenever the browser half reports a new origin. It reads the
|
|
621
|
+
* stored command first, so a steady state costs one `reg query` and no write.
|
|
622
|
+
*
|
|
623
|
+
* A scheme the user changed has to be retired explicitly. Leaving the old key
|
|
624
|
+
* behind would keep a protocol registered that points at this plugin's
|
|
625
|
+
* launcher with a token from a previous process — a click on any older
|
|
626
|
+
* notification would then reach a route that rejects it.
|
|
627
|
+
*
|
|
628
|
+
* Every run is serialized behind the previous one. The bookkeeping
|
|
629
|
+
* (`registeredCommand`/`registeredScheme`) is assigned after `await`s, and the
|
|
630
|
+
* four call sites genuinely race — a page reporting its origin while the user
|
|
631
|
+
* flips a switch would otherwise be able to leave the variables describing a
|
|
632
|
+
* registry state that no longer exists, or an orphaned key behind.
|
|
633
|
+
*
|
|
634
|
+
* @returns the outcome for the settings page.
|
|
635
|
+
*/
|
|
636
|
+
const syncProtocol = () => {
|
|
637
|
+
const run = protocolChain.then(syncProtocolNow, syncProtocolNow)
|
|
638
|
+
protocolChain = run.then(
|
|
639
|
+
() => undefined,
|
|
640
|
+
() => undefined,
|
|
641
|
+
)
|
|
642
|
+
return run
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- HTTP routes ----------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Admit one request, mirroring the Harness's own browser trust checks.
|
|
649
|
+
*
|
|
650
|
+
* `connection.admit` applies the Host fence and the browser session cookie to
|
|
651
|
+
* a plugin route. A loopback request that it refuses is still allowed: the
|
|
652
|
+
* machine's own processes are the operator, and a page that has not completed
|
|
653
|
+
* its token login must not lose the ability to read plugin state.
|
|
654
|
+
*
|
|
655
|
+
* The exemption is granted only to a request that is not visibly cross-site,
|
|
656
|
+
* because loopback is not a trust boundary against the operator's own browser:
|
|
657
|
+
* a hostile page can reach 127.0.0.1, and for a state-changing request that
|
|
658
|
+
* would be a blind CSRF primitive. `Sec-Fetch-Site`/`Origin` close it, and a
|
|
659
|
+
* JSON content type is required for writes so a cross-origin attempt cannot
|
|
660
|
+
* even be a CORS "simple request" — it has to preflight, and the preflight is
|
|
661
|
+
* refused.
|
|
662
|
+
*
|
|
663
|
+
* @param req - incoming request.
|
|
664
|
+
* @param res - response to write a refusal to.
|
|
665
|
+
* @returns true when the handler may run.
|
|
666
|
+
*/
|
|
667
|
+
const admitted = (req, res) => {
|
|
668
|
+
const refuse = (status) => {
|
|
669
|
+
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
670
|
+
res.end(status === 401 ? 'unauthorized' : 'forbidden')
|
|
671
|
+
return false
|
|
672
|
+
}
|
|
673
|
+
if (!isSameSiteRequest(req)) return refuse(403)
|
|
674
|
+
|
|
675
|
+
const writes = req.method !== undefined && req.method !== 'GET' && req.method !== 'HEAD'
|
|
676
|
+
if (writes) {
|
|
677
|
+
const type = String(req?.headers?.['content-type'] ?? '').toLowerCase()
|
|
678
|
+
if (!type.startsWith('application/json')) return refuse(403)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const connection = ctx.get('connection')
|
|
682
|
+
const result = connection?.admit?.(req)
|
|
683
|
+
if (result !== undefined && 'rejection' in result && result.rejection !== undefined) {
|
|
684
|
+
if (!isLoopbackRequest(req)) return refuse(result.rejection)
|
|
685
|
+
}
|
|
686
|
+
return true
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Write one JSON response.
|
|
691
|
+
*
|
|
692
|
+
* @param res - response.
|
|
693
|
+
* @param status - HTTP status.
|
|
694
|
+
* @param payload - serializable body.
|
|
695
|
+
* @returns nothing.
|
|
696
|
+
*/
|
|
697
|
+
const sendJson = (res, status, payload) => {
|
|
698
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
699
|
+
res.end(JSON.stringify(payload))
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Read a bounded JSON request body.
|
|
704
|
+
*
|
|
705
|
+
* An over-long body is DRAINED rather than abandoned. Leaving the `for await`
|
|
706
|
+
* early calls the iterator's `return()`, which destroys the request — and a
|
|
707
|
+
* destroyed socket swallows the 413 that was supposed to explain the refusal,
|
|
708
|
+
* so the client sees a connection reset instead. The drain is itself bounded,
|
|
709
|
+
* because a caller determined to stream gigabytes at a notification route has
|
|
710
|
+
* forfeited the courtesy of an answer.
|
|
711
|
+
*
|
|
712
|
+
* @param req - incoming request.
|
|
713
|
+
* @returns the parsed body, or an error marker.
|
|
714
|
+
*/
|
|
715
|
+
const readBody = async (req) => {
|
|
716
|
+
const chunks = []
|
|
717
|
+
let bytes = 0
|
|
718
|
+
let tooLarge = false
|
|
719
|
+
for await (const chunk of req) {
|
|
720
|
+
bytes += chunk.length
|
|
721
|
+
if (bytes > MAX_BODY_BYTES) {
|
|
722
|
+
if (!tooLarge) {
|
|
723
|
+
tooLarge = true
|
|
724
|
+
chunks.length = 0
|
|
725
|
+
}
|
|
726
|
+
if (bytes > DRAIN_CAP_BYTES) break
|
|
727
|
+
continue
|
|
728
|
+
}
|
|
729
|
+
chunks.push(chunk)
|
|
730
|
+
}
|
|
731
|
+
if (tooLarge) return { error: 'request body too large' }
|
|
732
|
+
try {
|
|
733
|
+
return { value: JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
734
|
+
} catch {
|
|
735
|
+
return { error: 'body is not JSON' }
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Snapshot the browser half and the settings page read. */
|
|
740
|
+
const snapshot = () => {
|
|
741
|
+
const settings = current()
|
|
742
|
+
return {
|
|
743
|
+
ok: true,
|
|
744
|
+
settings,
|
|
745
|
+
defaults: normalizeSettings({}),
|
|
746
|
+
base: baseConfig,
|
|
747
|
+
overrides,
|
|
748
|
+
kinds: KINDS.map((kind) => ({ id: kind, label: KIND_LABELS[kind], enabled: settings.kinds[kind] === true })),
|
|
749
|
+
toast: daemon.status(),
|
|
750
|
+
// The registered command line embeds the launcher's token, which is the
|
|
751
|
+
// credential for the open route. The page only needs to know WHETHER the
|
|
752
|
+
// registration is current, so the command itself never leaves the host.
|
|
753
|
+
protocol: {
|
|
754
|
+
scheme: settings.scheme,
|
|
755
|
+
registeredScheme,
|
|
756
|
+
registered: registeredCommand !== null,
|
|
757
|
+
port: listenPort(),
|
|
758
|
+
},
|
|
759
|
+
platformSupported: isSupported(),
|
|
760
|
+
lastSessionId,
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// --- 4. the browser half's routes ----------------------------------------
|
|
765
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
766
|
+
const register = (path, handler) =>
|
|
767
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: 'exact', path, handler }), `dsh-workspace-plus/notice: ${path}`)
|
|
768
|
+
|
|
769
|
+
register(API_PATH, async (req, res) => {
|
|
770
|
+
if (!admitted(req, res)) return
|
|
771
|
+
try {
|
|
772
|
+
if (req.method === 'GET') {
|
|
773
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
774
|
+
const reported = url.searchParams.get('origin')
|
|
775
|
+
// Only an origin that names this machine is kept: it sets the port the
|
|
776
|
+
// registered launcher will send its token to, and a page — or any local
|
|
777
|
+
// process that can reach this route — must not be able to move it.
|
|
778
|
+
const accepted = loopbackOriginPort(reported) === null ? null : reported
|
|
779
|
+
if (accepted !== null && accepted !== guiOrigin) {
|
|
780
|
+
guiOrigin = accepted
|
|
781
|
+
void syncProtocol().catch(() => {})
|
|
782
|
+
}
|
|
783
|
+
sendJson(res, 200, snapshot())
|
|
784
|
+
return
|
|
785
|
+
}
|
|
786
|
+
if (req.method !== 'POST') {
|
|
787
|
+
sendJson(res, 405, { ok: false, message: 'method not allowed' })
|
|
788
|
+
return
|
|
789
|
+
}
|
|
790
|
+
const body = await readBody(req)
|
|
791
|
+
if (body.error !== undefined) {
|
|
792
|
+
sendJson(res, 400, { ok: false, message: body.error })
|
|
793
|
+
return
|
|
794
|
+
}
|
|
795
|
+
const payload = body.value !== null && typeof body.value === 'object' ? body.value : {}
|
|
796
|
+
const op = payload.op
|
|
797
|
+
|
|
798
|
+
if (op === 'test') {
|
|
799
|
+
const settings = current()
|
|
800
|
+
const launch = activationFor(settings, lastSessionId ?? 'test', 'approval')
|
|
801
|
+
const ack = await daemon.show({
|
|
802
|
+
title: '这是一条测试通知',
|
|
803
|
+
session: lastSessionId === null ? 'dsh-notice' : '点击可打开最近的对话',
|
|
804
|
+
detail: '通知类型、声音与点击跳转都已按当前设置发送',
|
|
805
|
+
launch,
|
|
806
|
+
tag: 'test:approval',
|
|
807
|
+
silent: settings.sound !== true,
|
|
808
|
+
duration: settings.duration,
|
|
809
|
+
})
|
|
810
|
+
sendJson(res, 200, {
|
|
811
|
+
...snapshot(),
|
|
812
|
+
ok: ack.ok === true,
|
|
813
|
+
message: ack.ok === true ? '已发送测试通知' : String(ack.error ?? '发送失败'),
|
|
814
|
+
})
|
|
815
|
+
return
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (op === 'registerProtocol' || op === 'unregisterProtocol') {
|
|
819
|
+
if (op === 'unregisterProtocol') {
|
|
820
|
+
const result = await unregisterProtocol(current().scheme)
|
|
821
|
+
registeredCommand = null
|
|
822
|
+
registeredScheme = null
|
|
823
|
+
sendJson(res, 200, { ...snapshot(), ok: true, message: result.message })
|
|
824
|
+
return
|
|
825
|
+
}
|
|
826
|
+
const result = await syncProtocol()
|
|
827
|
+
sendJson(res, 200, { ...snapshot(), ...result })
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
if (op === 'patch' || op === 'clear') {
|
|
832
|
+
// `clear` without a key means "restore every default": the overrides
|
|
833
|
+
// object IS the diff from the bundle config, so dropping it is the
|
|
834
|
+
// whole operation — nothing has to remember what the defaults were.
|
|
835
|
+
overrides =
|
|
836
|
+
op === 'patch'
|
|
837
|
+
? mergeOverrides(overrides, payload.patch)
|
|
838
|
+
: payload.key === undefined || payload.key === null
|
|
839
|
+
? {}
|
|
840
|
+
: clearOverride(overrides, payload.key)
|
|
841
|
+
const written = writeStore(file, { version: loaded.store.version, overrides })
|
|
842
|
+
if (!written.ok) {
|
|
843
|
+
sendJson(res, 500, { ...snapshot(), ok: false, message: written.message })
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
daemon.setAppId(current().appId)
|
|
847
|
+
void syncProtocol().catch(() => {})
|
|
848
|
+
sendJson(res, 200, snapshot())
|
|
849
|
+
return
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if (op !== undefined) {
|
|
853
|
+
sendJson(res, 400, { ok: false, message: `unknown op: ${String(op)}` })
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
sendJson(res, 200, snapshot())
|
|
857
|
+
} catch (error) {
|
|
858
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${String((error && error.message) || error)}`)
|
|
859
|
+
sendJson(res, 500, { ok: false, message: String((error && error.message) || error) })
|
|
860
|
+
}
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
register(OPEN_PATH, async (req, res) => {
|
|
864
|
+
try {
|
|
865
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
866
|
+
if (!secretEquals(url.searchParams.get('token') ?? '', token)) {
|
|
867
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
868
|
+
res.end('forbidden')
|
|
869
|
+
return
|
|
870
|
+
}
|
|
871
|
+
const activated = url.searchParams.get('url') ?? ''
|
|
872
|
+
let sessionId = ''
|
|
873
|
+
// `searchParams.get('url')` has already percent-decoded once, so the
|
|
874
|
+
// activated URL must be parsed as a URL rather than decoded again: a
|
|
875
|
+
// second `decodeURIComponent` would turn `%2541` into `A`. A scheme
|
|
876
|
+
// Windows hands over verbatim may still not parse, hence the fallback to
|
|
877
|
+
// the route's own `s` parameter.
|
|
878
|
+
if (activated !== '') {
|
|
879
|
+
try {
|
|
880
|
+
sessionId = new URL(activated).searchParams.get('s') ?? ''
|
|
881
|
+
} catch {
|
|
882
|
+
sessionId = ''
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
if (sessionId === '') sessionId = url.searchParams.get('s') ?? ''
|
|
886
|
+
|
|
887
|
+
pendingOpen = { sessionId, at: Date.now() }
|
|
888
|
+
const settings = current()
|
|
889
|
+
res.writeHead(200, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
890
|
+
res.end('ok')
|
|
891
|
+
if (settings.focusOnClick === true) void focusDesktopWindow().catch(() => {})
|
|
892
|
+
} catch (error) {
|
|
893
|
+
ctx.logger?.warn?.(`[dsh-workspace-plus/notice] ${String((error && error.message) || error)}`)
|
|
894
|
+
res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
|
|
895
|
+
res.end('error')
|
|
896
|
+
}
|
|
897
|
+
})
|
|
898
|
+
|
|
899
|
+
register(PENDING_PATH, (req, res) => {
|
|
900
|
+
if (!admitted(req, res)) return
|
|
901
|
+
if (req.method !== 'GET') {
|
|
902
|
+
sendJson(res, 405, { ok: false, message: 'method not allowed' })
|
|
903
|
+
return
|
|
904
|
+
}
|
|
905
|
+
const fresh = pendingOpen !== null && Date.now() - pendingOpen.at <= PENDING_TTL_MS ? pendingOpen : null
|
|
906
|
+
pendingOpen = null
|
|
907
|
+
sendJson(res, 200, { ok: true, sessionId: fresh === null ? null : fresh.sessionId })
|
|
908
|
+
})
|
|
909
|
+
})
|
|
910
|
+
|
|
911
|
+
// --- start-up and teardown ------------------------------------------------
|
|
912
|
+
|
|
913
|
+
// The toast daemon is started by the first notification rather than here: it
|
|
914
|
+
// holds the host's event loop while it runs (see `ToastDaemon`), so a profile
|
|
915
|
+
// command that loads this plugin and exits must not spawn one. The protocol
|
|
916
|
+
// registration, by contrast, is cheap and idempotent and runs now.
|
|
917
|
+
void syncProtocol()
|
|
918
|
+
.then((result) => {
|
|
919
|
+
if (result.ok !== true) ctx.logger?.info?.(`[dsh-workspace-plus/notice] ${result.message}`)
|
|
920
|
+
})
|
|
921
|
+
.catch(() => {})
|
|
922
|
+
|
|
923
|
+
const releaseProtocol = () => {
|
|
924
|
+
const owned = registeredCommand
|
|
925
|
+
const scheme = registeredScheme
|
|
926
|
+
registeredCommand = null
|
|
927
|
+
registeredScheme = null
|
|
928
|
+
if (owned === null || scheme === null) return
|
|
929
|
+
void readProtocolCommand(scheme)
|
|
930
|
+
.then((stored) => {
|
|
931
|
+
// Only remove a registration this plugin still owns: a user who
|
|
932
|
+
// re-pointed the scheme at something else keeps their version.
|
|
933
|
+
if (stored === null || stored !== owned) return
|
|
934
|
+
return unregisterProtocol(scheme)
|
|
935
|
+
})
|
|
936
|
+
.catch(() => {})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
ctx.effect(
|
|
940
|
+
() => () => {
|
|
941
|
+
daemon.stop()
|
|
942
|
+
releaseProtocol()
|
|
943
|
+
},
|
|
944
|
+
'dsh-workspace-plus/notice: toast daemon and protocol registration',
|
|
945
|
+
)
|
|
946
|
+
|
|
947
|
+
ctx.logger?.info?.(
|
|
948
|
+
`[dsh-workspace-plus/notice] 已加载:${isSupported() ? 'Windows 原生通知' : '当前平台不支持通知'},` +
|
|
949
|
+
`类型 ${KINDS.filter((kind) => current().kinds[kind] === true).join('/')}`,
|
|
950
|
+
)
|
|
951
|
+
}
|