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.
@@ -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
+ }