dsh-workspace-plus 0.2.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,517 @@
1
+ /**
2
+ * dsh-workspace-plus/notice — the Windows toast transport (ported from dsh-notice).
3
+ *
4
+ * Showing a Windows toast from an unpackaged Node process means going through
5
+ * WinRT, which Node cannot reach. Spawning `powershell.exe` once per
6
+ * notification would work but costs a few hundred milliseconds and a fresh
7
+ * interpreter every time a turn ends, so this module keeps ONE Windows
8
+ * PowerShell process alive and feeds it newline-delimited JSON over stdin. The
9
+ * first toast pays the interpreter start; every later one is a pipe write.
10
+ *
11
+ * The daemon is a cache, not a source of truth: if it dies it is restarted on
12
+ * the next notification, and a restarted daemon loses nothing but the messages
13
+ * that were in flight.
14
+ *
15
+ * @module dsh-workspace-plus/notice/toast
16
+ */
17
+
18
+ import { spawn } from 'node:child_process'
19
+ import { createHash, randomUUID } from 'node:crypto'
20
+ import { fileURLToPath } from 'node:url'
21
+ import { aumidCandidates, isSupported, powershellPath } from './win32.js'
22
+
23
+ /** Absolute path of the PowerShell daemon shipped beside this module. */
24
+ export const TOAST_HOST_SCRIPT = fileURLToPath(new URL('./toast-host.ps1', import.meta.url))
25
+
26
+ /** Group every toast of this plugin shares, so tag-scoped removal is precise. */
27
+ export const TOAST_GROUP = 'dsh-workspace-plus/notice'
28
+
29
+ /**
30
+ * Longest notification tag Windows accepts.
31
+ *
32
+ * Measured on this machine with Windows PowerShell 5.1: assigning a 64-character
33
+ * `ToastNotification.Tag` succeeds, a 69-character one throws `0x803E0209`
34
+ * ("The size of the notification tag is too large"). The failure is invisible to
35
+ * the user — the toast is shown without a tag — and a later `History.Remove`
36
+ * under that tag quietly matches nothing, so the notification can never be
37
+ * withdrawn. Nothing in this plugin may therefore build a tag by concatenation.
38
+ */
39
+ export const MAX_TAG_LENGTH = 64
40
+
41
+ /**
42
+ * Build the tag for one notification.
43
+ *
44
+ * A real DSH session id is `session-<uuid>` — 44 characters on its own — so a
45
+ * readable `sessionId:kind:callId` tag overflows the limit as soon as a tool
46
+ * call id joins it. The tag is therefore `kind-<16 hex>`, where the hex is a
47
+ * digest of the session and the pending request: fixed width, stable for the
48
+ * same request (so the withdrawal finds it), and distinct for two requests that
49
+ * are outstanding at once in one conversation.
50
+ *
51
+ * @param kind - notification class.
52
+ * @param sessionId - conversation the notification is about.
53
+ * @param requestId - pending tool-call id, when the caller has one.
54
+ * @returns a tag of at most {@link MAX_TAG_LENGTH} characters.
55
+ */
56
+ export function toastTag(kind, sessionId, requestId) {
57
+ const seed = `${String(sessionId ?? '')}\u0000${String(requestId ?? '')}`
58
+ const digest = createHash('sha1').update(seed, 'utf8').digest('hex').slice(0, 16)
59
+ return `${String(kind ?? 'notice')}-${digest}`.slice(0, MAX_TAG_LENGTH)
60
+ }
61
+
62
+ /** Restart backoff, in milliseconds, indexed by consecutive failure count. */
63
+ const RESTART_BACKOFF_MS = [500, 1_000, 2_000, 5_000, 15_000]
64
+
65
+ /** A daemon that never acknowledged anything for this long is considered dead. */
66
+ const ACK_TIMEOUT_MS = 15_000
67
+
68
+ /**
69
+ * A supervised Windows toast daemon.
70
+ *
71
+ * Every method is safe to call when toasts cannot be shown at all: the class
72
+ * reports the reason through `lastError` instead of throwing, because a plugin
73
+ * that cannot draw a notification must still leave the Harness working.
74
+ */
75
+ export class ToastDaemon {
76
+ /**
77
+ * @param options - interpreter, script, AUMID preference and diagnostics sink.
78
+ */
79
+ constructor(options = {}) {
80
+ /** Windows PowerShell interpreter. */
81
+ this.powershell = options.powershell ?? powershellPath()
82
+ /** Daemon script. */
83
+ this.script = options.script ?? TOAST_HOST_SCRIPT
84
+ /** Explicit AUMID, or an empty string to let the daemon adapt. */
85
+ this.configuredAppId = typeof options.appId === 'string' ? options.appId.trim() : ''
86
+ /** AUMIDs still worth trying, best attribution first. */
87
+ this.candidates = this.configuredAppId === '' ? aumidCandidates() : [this.configuredAppId]
88
+ /** The AUMID that most recently worked. */
89
+ this.appId = this.candidates[0]
90
+ /** Where `appId` came from: `'configured'`, `'probe'`, or `'guess'`. */
91
+ this.aumidSource = this.configuredAppId === '' ? 'guess' : 'configured'
92
+ /** Explanation of the last AUMID decision, for the settings page. */
93
+ this.probeNote = null
94
+ /** In-flight (or settled) registration probe, so it runs at most once. */
95
+ this.probePromise = null
96
+ /** `node:child_process` handle while the daemon runs. */
97
+ this.child = null
98
+ /** Whether a spawn is in flight. */
99
+ this.starting = false
100
+ /**
101
+ * Whether the daemon has ever been started in this plugin's life.
102
+ *
103
+ * The daemon starts lazily, so "not running" is the ordinary state until the
104
+ * first notification. Without this the settings page would report a fresh,
105
+ * perfectly healthy plugin as if its transport had died.
106
+ */
107
+ this.started = false
108
+ /** Consecutive spawn/exit failures, driving the backoff. */
109
+ this.failures = 0
110
+ /** Acks waiting for their notification. */
111
+ this.pending = new Map()
112
+ /** Line buffer for the daemon's stdout. */
113
+ this.stdoutBuffer = ''
114
+ /** Last error text, or null. */
115
+ this.lastError = null
116
+ /** Counters for the settings page. */
117
+ this.stats = { shown: 0, failed: 0, restarted: 0 }
118
+ /** Diagnostics sink; the plugin passes its logger. */
119
+ this.log = typeof options.log === 'function' ? options.log : () => {}
120
+ /** How long an ack may take before it is treated as a transport failure. */
121
+ this.ackTimeoutMs = typeof options.ackTimeoutMs === 'number' && options.ackTimeoutMs > 0 ? options.ackTimeoutMs : ACK_TIMEOUT_MS
122
+ /** Set while `stop()` runs, so the exit handler does not restart. */
123
+ this.stopping = false
124
+ /** Timer handle for a pending restart. */
125
+ this.restartTimer = null
126
+ }
127
+
128
+ /** @returns true when this platform can show toasts at all. */
129
+ get supported() {
130
+ return isSupported()
131
+ }
132
+
133
+ /**
134
+ * Whether the daemon currently holds a live process.
135
+ *
136
+ * @returns true while notifications can be written immediately.
137
+ */
138
+ get running() {
139
+ return this.child !== null
140
+ }
141
+
142
+ /**
143
+ * Snapshot for the settings page.
144
+ *
145
+ * @returns a plain, JSON-serializable status object.
146
+ */
147
+ status() {
148
+ return {
149
+ supported: this.supported,
150
+ running: this.running,
151
+ started: this.started,
152
+ appId: this.appId,
153
+ appIdConfigured: this.configuredAppId !== '',
154
+ aumidSource: this.aumidSource,
155
+ probeNote: this.probeNote,
156
+ lastError: this.lastError,
157
+ ...this.stats,
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Apply a changed AUMID preference.
163
+ *
164
+ * The AUMID is baked into each command rather than into the daemon process, so
165
+ * a change needs no restart — only the candidate list, the current choice and
166
+ * the settled probe are replaced, and the next notification re-derives the
167
+ * identity it speaks for.
168
+ *
169
+ * @param appId - configured AUMID, or an empty string to adapt again.
170
+ * @returns nothing.
171
+ */
172
+ setAppId(appId) {
173
+ const next = typeof appId === 'string' ? appId.trim() : ''
174
+ if (next === this.configuredAppId) return
175
+ this.configuredAppId = next
176
+ this.candidates = next === '' ? aumidCandidates() : [next]
177
+ this.appId = this.candidates[0]
178
+ this.aumidSource = next === '' ? 'guess' : 'configured'
179
+ this.probeNote = null
180
+ this.probePromise = null
181
+ }
182
+
183
+ /**
184
+ * Choose the AUMID to speak for, once per plugin life.
185
+ *
186
+ * `Show()` does NOT throw for an unregistered AppUserModelID — Windows files
187
+ * the toast in Action Center under an identity with no Start Menu entry, so it
188
+ * renders with no application name and no icon. Inferring a fallback from a
189
+ * thrown error therefore never works, and the registration has to be checked
190
+ * before the first notification instead. The daemon does that check against
191
+ * the Start Menu; this method consumes its answer.
192
+ *
193
+ * A user-configured AUMID is used verbatim: it is an explicit instruction, and
194
+ * second-guessing it would make the setting meaningless.
195
+ *
196
+ * @returns the settled promise, so concurrent callers share one probe.
197
+ */
198
+ resolveAppId() {
199
+ if (this.probePromise !== null) return this.probePromise
200
+ if (this.configuredAppId !== '' || !this.supported) {
201
+ this.probePromise = Promise.resolve()
202
+ return this.probePromise
203
+ }
204
+ this.probePromise = (async () => {
205
+ const ack = await this.request({ op: 'probeAumid', candidates: this.candidates })
206
+ if (ack.ok === true && typeof ack.appId === 'string' && ack.appId !== '') {
207
+ if (ack.appId !== this.appId) {
208
+ this.log(`[dsh-workspace-plus/notice] 使用已注册的 AUMID ${ack.appId}`)
209
+ }
210
+ this.appId = ack.appId
211
+ this.aumidSource = 'probe'
212
+ return
213
+ }
214
+ // Either the enumeration is unavailable or none of the candidates has a
215
+ // Start Menu entry. Both keep the best-attribution guess: the toast still
216
+ // appears, and the settings page reports why the attribution is unverified.
217
+ this.probeNote =
218
+ ack.ok === true
219
+ ? `没有在开始菜单中找到已注册的候选 AUMID(共扫描 ${ack.startApps ?? 0} 项),继续使用 ${this.appId}。`
220
+ : `无法确认 AUMID 是否已注册:${String(ack.error ?? '未知原因')}`
221
+ this.log(`[dsh-workspace-plus/notice] ${this.probeNote}`)
222
+ })()
223
+ return this.probePromise
224
+ }
225
+
226
+ /**
227
+ * Start the daemon if it is not running.
228
+ *
229
+ * @returns nothing; failures surface through `lastError` and the restart timer.
230
+ */
231
+ start() {
232
+ if (!this.supported || this.child !== null || this.starting || this.stopping) return
233
+ this.starting = true
234
+ let child
235
+ try {
236
+ child = spawn(
237
+ this.powershell,
238
+ ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', this.script],
239
+ { stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true },
240
+ )
241
+ } catch (error) {
242
+ this.starting = false
243
+ this.lastError = `无法启动 Windows PowerShell:${String((error && error.message) || error)}`
244
+ this.scheduleRestart()
245
+ return
246
+ }
247
+
248
+ this.child = child
249
+ this.starting = false
250
+ this.started = true
251
+ this.stdoutBuffer = ''
252
+ child.stdout.setEncoding('utf8')
253
+ child.stderr.setEncoding('utf8')
254
+ child.stdout.on('data', (chunk) => this.onStdout(chunk))
255
+ child.stderr.on('data', (chunk) => {
256
+ const text = String(chunk).trim()
257
+ if (text !== '') this.log(`[toast-host] ${text}`)
258
+ })
259
+ child.stdin.on('error', (error) => {
260
+ this.log(`[toast-host] stdin error: ${String((error && error.message) || error)}`)
261
+ })
262
+ child.on('error', (error) => {
263
+ this.lastError = `toast 守护进程错误:${String((error && error.message) || error)}`
264
+ this.log(`[toast-host] ${this.lastError}`)
265
+ })
266
+ child.on('exit', (code, signal) => {
267
+ this.child = null
268
+ for (const [, entry] of this.pending) entry.reject(new Error('toast daemon exited'))
269
+ this.pending.clear()
270
+ if (this.stopping) return
271
+ this.failures += 1
272
+ this.lastError = `toast 守护进程退出(code=${code ?? 'null'} signal=${signal ?? 'null'})`
273
+ this.log(`[toast-host] ${this.lastError}`)
274
+ })
275
+
276
+ // Deliberately detached from the host's event loop: a one-shot profile
277
+ // command (`dsh -p "…"`) raises its `任务完成` notification and must still
278
+ // exit, and a referenced child with piped stdio would pin it forever. The
279
+ // cost is that an ack in flight can be dropped if the host has nothing else
280
+ // to do — and a long-lived host, which is the only kind that shows more than
281
+ // one notification, always has a server holding the loop.
282
+ //
283
+ // "Unref by default, re-reference while an ack is pending" was tried and is
284
+ // NOT available: measured on the bundled Node 24, `ref()` after `unref()` on
285
+ // a stdio pipe does not restore liveness, so a notification could be dropped
286
+ // mid-flight in exactly the host that must not drop it.
287
+ child.unref()
288
+ for (const stream of [child.stdin, child.stdout, child.stderr]) stream.unref?.()
289
+ }
290
+
291
+ /**
292
+ * Schedule a restart after the current backoff step.
293
+ *
294
+ * @returns nothing.
295
+ */
296
+ scheduleRestart() {
297
+ if (this.restartTimer !== null || this.stopping || !this.supported) return
298
+ const delay = RESTART_BACKOFF_MS[Math.min(this.failures, RESTART_BACKOFF_MS.length - 1)]
299
+ this.restartTimer = setTimeout(() => {
300
+ this.restartTimer = null
301
+ if (this.stopping) return
302
+ this.stats.restarted += 1
303
+ this.start()
304
+ }, delay)
305
+ this.restartTimer.unref?.()
306
+ }
307
+
308
+ /**
309
+ * Write one command to the daemon, starting it when necessary.
310
+ *
311
+ * @param command - JSON-serializable command.
312
+ * @returns true when the line reached the pipe.
313
+ */
314
+ write(command) {
315
+ const child = this.child
316
+ if (child === null || child.stdin.destroyed) return false
317
+ try {
318
+ child.stdin.write(`${JSON.stringify(command)}\n`)
319
+ return true
320
+ } catch (error) {
321
+ this.lastError = `写入 toast 守护进程失败:${String((error && error.message) || error)}`
322
+ return false
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Split the daemon's stdout into lines, parse each as an ack, and settle the
328
+ * notification waiting on it.
329
+ *
330
+ * @param chunk - decoded stdout text.
331
+ * @returns nothing.
332
+ */
333
+ onStdout(chunk) {
334
+ this.stdoutBuffer += chunk
335
+ // The daemon terminates every ack with a newline, so at most one partial
336
+ // line is ever buffered. A cap keeps a misbehaving daemon from growing this
337
+ // without bound, at the cost of one dropped ack that would have timed out.
338
+ if (this.stdoutBuffer.length > 1_048_576) {
339
+ this.log('[toast-host] 应答缓冲超过 1 MiB,已丢弃')
340
+ this.stdoutBuffer = ''
341
+ return
342
+ }
343
+ for (;;) {
344
+ const index = this.stdoutBuffer.indexOf('\n')
345
+ if (index < 0) break
346
+ const raw = this.stdoutBuffer.slice(0, index).trim()
347
+ this.stdoutBuffer = this.stdoutBuffer.slice(index + 1)
348
+ if (raw === '') continue
349
+ let ack
350
+ try {
351
+ ack = JSON.parse(raw)
352
+ } catch {
353
+ this.log(`[toast-host] 无法解析的应答:${raw}`)
354
+ continue
355
+ }
356
+ const entry = this.pending.get(ack.id)
357
+ if (entry === undefined) continue
358
+ this.pending.delete(ack.id)
359
+ clearTimeout(entry.timer)
360
+ entry.resolve(ack)
361
+ }
362
+ }
363
+
364
+ /**
365
+ * Send one command and wait for its ack.
366
+ *
367
+ * A transport failure is marked `transport: true` so the caller can tell it
368
+ * apart from the daemon answering `ok: false`. That distinction matters:
369
+ * `show()` reacts to a rejected notification by trying the next AppUserModelID,
370
+ * and a daemon that is simply not running must not consume that list — doing so
371
+ * would silently downgrade every later notification to the fallback identity.
372
+ *
373
+ * @param command - command without an `id`.
374
+ * @returns the ack, or a synthetic transport failure.
375
+ */
376
+ request(command) {
377
+ this.start()
378
+ if (!this.running) {
379
+ return Promise.resolve({
380
+ id: command.id,
381
+ ok: false,
382
+ transport: true,
383
+ error: this.lastError ?? 'toast 守护进程未运行',
384
+ })
385
+ }
386
+ const id = randomUUID()
387
+ const payload = { ...command, id }
388
+ return new Promise((resolve) => {
389
+ const timer = setTimeout(() => {
390
+ this.pending.delete(id)
391
+ resolve({ id, ok: false, transport: true, error: 'toast 守护进程未在超时内应答' })
392
+ }, this.ackTimeoutMs)
393
+ timer.unref?.()
394
+ this.pending.set(id, {
395
+ resolve,
396
+ reject: (error) =>
397
+ resolve({ id, ok: false, transport: true, error: String((error && error.message) || error) }),
398
+ timer,
399
+ })
400
+ if (!this.write(payload)) {
401
+ this.pending.delete(id)
402
+ clearTimeout(timer)
403
+ resolve({ id, ok: false, transport: true, error: this.lastError ?? 'toast 守护进程不可写' })
404
+ }
405
+ })
406
+ }
407
+
408
+ /**
409
+ * Show one notification.
410
+ *
411
+ * The registered identity is settled first, so the first notification of a
412
+ * session is already attributed correctly rather than being corrected by a
413
+ * second attempt. A `Show` failure still walks the remaining candidates once,
414
+ * which covers the Windows builds that DO reject an unknown AUMID outright.
415
+ *
416
+ * @param notification - `{ title, session, detail, launch, tag, silent, duration }`.
417
+ * @returns the ack describing what happened.
418
+ */
419
+ async show(notification) {
420
+ if (!this.supported) {
421
+ return { ok: false, error: '当前平台不支持 Windows 原生通知' }
422
+ }
423
+ await this.resolveAppId()
424
+ const attempted = []
425
+ for (;;) {
426
+ const appId = this.appId
427
+ attempted.push(appId)
428
+ const ack = await this.request({
429
+ op: 'show',
430
+ appId,
431
+ title: notification.title,
432
+ body: [notification.session, notification.detail].filter((part) => typeof part === 'string' && part !== ''),
433
+ launch: notification.launch,
434
+ tag: notification.tag,
435
+ group: TOAST_GROUP,
436
+ silent: notification.silent === true,
437
+ duration: notification.duration,
438
+ })
439
+ if (ack.ok === true) {
440
+ this.failures = 0
441
+ this.lastError = null
442
+ this.stats.shown += 1
443
+ // The AUMID travels with the ack: a notification shown under a fallback
444
+ // identity can only be withdrawn under that same identity.
445
+ return { ...ack, appId }
446
+ }
447
+ if (ack.transport === true) {
448
+ this.stats.failed += 1
449
+ this.lastError = `通知未能送达守护进程:${ack.error ?? '未知错误'}`
450
+ this.log(`[dsh-workspace-plus/notice] ${this.lastError}`)
451
+ return ack
452
+ }
453
+ const next = this.candidates.find((candidate) => !attempted.includes(candidate))
454
+ if (next === undefined) {
455
+ this.stats.failed += 1
456
+ this.lastError = `通知发送失败:${ack.error ?? '未知错误'}`
457
+ this.log(`[dsh-workspace-plus/notice] ${this.lastError}`)
458
+ return ack
459
+ }
460
+ this.appId = next
461
+ this.log(`[dsh-workspace-plus/notice] AUMID ${appId} 不可用,改用 ${next}`)
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Ask Windows to drop a notification from Action Center.
467
+ *
468
+ * Used when the decision a notification announced has been made, so the user
469
+ * does not keep seeing a request that is already resolved.
470
+ *
471
+ * The identity is passed in rather than read from `this.appId`: `show()` may
472
+ * have fallen back to another candidate for that particular notification, and
473
+ * `History.Remove` matches on tag, group AND AppUserModelID, so removing under
474
+ * the wrong one silently does nothing. The call goes through `request()`, which
475
+ * restarts a daemon that died between the notification and its answer.
476
+ *
477
+ * @param tag - the notification's tag.
478
+ * @param appId - the identity the notification was shown under.
479
+ * @returns the ack.
480
+ */
481
+ async remove(tag, appId) {
482
+ if (!this.supported) return { ok: false, error: '当前平台不支持 Windows 原生通知' }
483
+ return this.request({ op: 'remove', appId: appId ?? this.appId, tag, group: TOAST_GROUP })
484
+ }
485
+
486
+ /**
487
+ * Stop the daemon and cancel any scheduled restart.
488
+ *
489
+ * @returns nothing.
490
+ */
491
+ stop() {
492
+ this.stopping = true
493
+ if (this.restartTimer !== null) {
494
+ clearTimeout(this.restartTimer)
495
+ this.restartTimer = null
496
+ }
497
+ const child = this.child
498
+ this.child = null
499
+ if (child !== null) {
500
+ try {
501
+ child.stdin.end()
502
+ } catch {
503
+ // the pipe was already gone
504
+ }
505
+ const killer = setTimeout(() => {
506
+ try {
507
+ child.kill()
508
+ } catch {
509
+ // the process already exited
510
+ }
511
+ }, 1_000)
512
+ killer.unref?.()
513
+ }
514
+ for (const [, entry] of this.pending) entry.reject(new Error('toast daemon stopped'))
515
+ this.pending.clear()
516
+ }
517
+ }