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,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
|
+
}
|