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,616 @@
|
|
|
1
|
+
<#
|
|
2
|
+
.SYNOPSIS
|
|
3
|
+
Long-lived Windows toast daemon for the dsh-notice plugin.
|
|
4
|
+
|
|
5
|
+
.DESCRIPTION
|
|
6
|
+
Reads one UTF-8 JSON command per line from stdin and answers with exactly one
|
|
7
|
+
UTF-8 JSON ack per command on stdout.
|
|
8
|
+
|
|
9
|
+
stdout is a pure ack channel: the parent Node process matches acks to commands
|
|
10
|
+
strictly in order, so a single stray line on stdout would desynchronise every
|
|
11
|
+
later command. All diagnostics therefore go to stderr.
|
|
12
|
+
|
|
13
|
+
This script must be run by Windows PowerShell 5.1
|
|
14
|
+
(%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe). The WinRT type
|
|
15
|
+
projection that toast notifications need is only reliable in that host; pwsh 7
|
|
16
|
+
projects these types differently and is not supported here.
|
|
17
|
+
|
|
18
|
+
We keep the process long-lived because starting PowerShell and loading the
|
|
19
|
+
WinRT projection costs roughly a second, which is far too slow to pay per
|
|
20
|
+
notification.
|
|
21
|
+
|
|
22
|
+
Invocation:
|
|
23
|
+
powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass `
|
|
24
|
+
-File lib\toast-host.ps1 [-Once]
|
|
25
|
+
|
|
26
|
+
Commands (JSON objects, one per line):
|
|
27
|
+
{"op":"show","id":..,"title":..,"body":..,"launch":..,"appId":..,
|
|
28
|
+
"tag":..,"group":..,"silent":bool,"duration":"short"|"long",
|
|
29
|
+
"buttons":[{"content":..,"arguments":..}]}
|
|
30
|
+
{"op":"remove","id":..,"appId":..,"tag":..,"group":..}
|
|
31
|
+
{"op":"probeAumid","id":..,"candidates":[..]}
|
|
32
|
+
{"op":"ping","id":..}
|
|
33
|
+
|
|
34
|
+
`body` is either one string or an array of strings. An array becomes one
|
|
35
|
+
<text> element per entry, which is how the plugin renders its three line
|
|
36
|
+
notification (kind, conversation, detail); a plain string is one line.
|
|
37
|
+
|
|
38
|
+
Acks:
|
|
39
|
+
{"id":"..","ok":true}
|
|
40
|
+
{"id":"..","ok":false,"error":".."}
|
|
41
|
+
{"id":"..","ok":true,"appId":"..","matched":bool,"startApps":int}
|
|
42
|
+
#>
|
|
43
|
+
|
|
44
|
+
[CmdletBinding()]
|
|
45
|
+
param(
|
|
46
|
+
# Read a single command from stdin, execute it and exit. Used by the smoke
|
|
47
|
+
# path and by the development test harness, which would otherwise have to
|
|
48
|
+
# manage a daemon lifecycle just to prove one toast works.
|
|
49
|
+
[switch]$Once
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Any unhandled error is a bug we want to see, but the read loop itself wraps each
|
|
53
|
+
# command in try/catch so that one bad command can never end the daemon.
|
|
54
|
+
$ErrorActionPreference = 'Stop'
|
|
55
|
+
|
|
56
|
+
# The Windows AUMID that this plugin registers for itself. CreateToastNotifier()
|
|
57
|
+
# without an argument fails with 0x80070490 ("Element not found") from a plain
|
|
58
|
+
# unpackaged process, so a registered AUMID is mandatory, not optional.
|
|
59
|
+
$DefaultAppId = 'com.deepseek.dsh'
|
|
60
|
+
|
|
61
|
+
# Fallback title: the toast XML schema requires at least one non-empty <text>
|
|
62
|
+
# element, so an empty title would produce a document that either fails to load
|
|
63
|
+
# or renders as an unusable blank notification.
|
|
64
|
+
$DefaultTitle = 'dsh-notice'
|
|
65
|
+
|
|
66
|
+
# UTF-8 without a byte order mark, forced in all three directions, because the
|
|
67
|
+
# plugin sends Chinese text and the default console encoding on a zh-CN machine
|
|
68
|
+
# is a legacy code page that would corrupt it. The BOM is omitted on purpose:
|
|
69
|
+
# the parent parses stdout line by line and a BOM would glue itself to the first
|
|
70
|
+
# ack, breaking JSON.parse on that line.
|
|
71
|
+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
|
72
|
+
|
|
73
|
+
# [Console]::In / [Console]::Out cache their encoding when first used, and the
|
|
74
|
+
# setters discard those cached readers, so the encodings must be assigned before
|
|
75
|
+
# any read or write happens.
|
|
76
|
+
try {
|
|
77
|
+
[Console]::InputEncoding = $utf8NoBom
|
|
78
|
+
[Console]::OutputEncoding = $utf8NoBom
|
|
79
|
+
# $OutputEncoding is what PowerShell itself uses when it pipes text to a
|
|
80
|
+
# native program; it is a separate setting from [Console]::OutputEncoding.
|
|
81
|
+
$OutputEncoding = $utf8NoBom
|
|
82
|
+
} catch {
|
|
83
|
+
# A failure here degrades non-ASCII text but does not make the daemon
|
|
84
|
+
# useless, so report and continue rather than refusing to start.
|
|
85
|
+
[Console]::Error.WriteLine('toast-host: could not force UTF-8 console encoding: ' + $_.Exception.Message)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# WinRT startup
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
# Load the projection once, up front. A daemon that cannot show toasts has no
|
|
93
|
+
# reason to exist, so a failure here is fatal and must be loud: the parent reads
|
|
94
|
+
# the exit code and the stderr text to explain the problem to the user.
|
|
95
|
+
try {
|
|
96
|
+
Add-Type -AssemblyName System.Runtime.WindowsRuntime -ErrorAction Stop
|
|
97
|
+
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
|
|
98
|
+
[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] > $null
|
|
99
|
+
} catch {
|
|
100
|
+
[Console]::Error.WriteLine('toast-host: failed to load the WinRT toast types: ' + $_.Exception.Message)
|
|
101
|
+
exit 3
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Small helpers
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# Reads a property from a ConvertFrom-Json object without ever throwing: a
|
|
109
|
+
# missing property, a null object or a non-object value all return $null instead
|
|
110
|
+
# of a PropertyNotFoundException.
|
|
111
|
+
function Get-Field {
|
|
112
|
+
param(
|
|
113
|
+
$Object,
|
|
114
|
+
[string]$Name
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
if ($null -eq $Object) { return $null }
|
|
118
|
+
|
|
119
|
+
$property = $Object.PSObject.Properties[$Name]
|
|
120
|
+
if ($null -eq $property) { return $null }
|
|
121
|
+
|
|
122
|
+
return $property.Value
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Returns a usable string for a field, falling back when the field is absent,
|
|
126
|
+
# null or whitespace only.
|
|
127
|
+
function Get-StringField {
|
|
128
|
+
param(
|
|
129
|
+
$Object,
|
|
130
|
+
[string]$Name,
|
|
131
|
+
[string]$Default = ''
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
$value = Get-Field -Object $Object -Name $Name
|
|
135
|
+
if ($null -eq $value) { return $Default }
|
|
136
|
+
|
|
137
|
+
$text = [string]$value
|
|
138
|
+
if ([string]::IsNullOrWhiteSpace($text)) { return $Default }
|
|
139
|
+
|
|
140
|
+
return $text
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# JSON booleans arrive as real booleans, but a hand-written command line (or a
|
|
144
|
+
# future caller) may send the string "false" - and [bool]'false' is $true in
|
|
145
|
+
# PowerShell, which would silently mute nothing. Parse those spellings properly.
|
|
146
|
+
function ConvertTo-Boolean {
|
|
147
|
+
param($Value)
|
|
148
|
+
|
|
149
|
+
if ($null -eq $Value) { return $false }
|
|
150
|
+
if ($Value -is [bool]) { return $Value }
|
|
151
|
+
|
|
152
|
+
if ($Value -is [string]) {
|
|
153
|
+
$text = $Value.Trim()
|
|
154
|
+
if ($text -eq '') { return $false }
|
|
155
|
+
return ($text -ieq 'true' -or $text -eq '1' -or $text -ieq 'yes')
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return [bool]$Value
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# Escapes text for XML character data and for attribute values (both need the
|
|
162
|
+
# same five entities, and attribute values here are always wrapped in double
|
|
163
|
+
# quotes). User supplied titles, bodies, launch URIs and button arguments all end
|
|
164
|
+
# up inside the toast XML, so this is a genuine injection surface: a body of
|
|
165
|
+
# "</text></binding>" must render as literal text, never as markup.
|
|
166
|
+
#
|
|
167
|
+
# Characters that XML 1.0 forbids outright (most C0 controls) are replaced with a
|
|
168
|
+
# space, because they cannot be escaped into validity and would otherwise make
|
|
169
|
+
# XmlDocument.LoadXml throw for an otherwise harmless notification.
|
|
170
|
+
function ConvertTo-XmlText {
|
|
171
|
+
param([string]$Value)
|
|
172
|
+
|
|
173
|
+
if ([string]::IsNullOrEmpty($Value)) { return '' }
|
|
174
|
+
|
|
175
|
+
$builder = New-Object System.Text.StringBuilder
|
|
176
|
+
|
|
177
|
+
foreach ($character in $Value.ToCharArray()) {
|
|
178
|
+
$code = [int]$character
|
|
179
|
+
|
|
180
|
+
if ($code -lt 0x20 -and $code -ne 0x09 -and $code -ne 0x0A -and $code -ne 0x0D) {
|
|
181
|
+
[void]$builder.Append(' ')
|
|
182
|
+
continue
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
switch ($character) {
|
|
186
|
+
'&' { [void]$builder.Append('&') }
|
|
187
|
+
'<' { [void]$builder.Append('<') }
|
|
188
|
+
'>' { [void]$builder.Append('>') }
|
|
189
|
+
'"' { [void]$builder.Append('"') }
|
|
190
|
+
"'" { [void]$builder.Append(''') }
|
|
191
|
+
default { [void]$builder.Append($character) }
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return $builder.ToString()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
# Encodes one string as a JSON string literal, quotes included. The acks are tiny
|
|
199
|
+
# and have a fixed shape, so they are written by hand instead of via
|
|
200
|
+
# ConvertTo-Json: that keeps the output strictly single-line and independent of
|
|
201
|
+
# the serializer's per-version escaping quirks.
|
|
202
|
+
function ConvertTo-JsonString {
|
|
203
|
+
param([string]$Value)
|
|
204
|
+
|
|
205
|
+
if ([string]::IsNullOrEmpty($Value)) { return '""' }
|
|
206
|
+
|
|
207
|
+
$builder = New-Object System.Text.StringBuilder
|
|
208
|
+
|
|
209
|
+
foreach ($character in $Value.ToCharArray()) {
|
|
210
|
+
switch ($character) {
|
|
211
|
+
'"' { [void]$builder.Append('\"') }
|
|
212
|
+
'\' { [void]$builder.Append('\\') }
|
|
213
|
+
"`b" { [void]$builder.Append('\b') }
|
|
214
|
+
"`f" { [void]$builder.Append('\f') }
|
|
215
|
+
"`n" { [void]$builder.Append('\n') }
|
|
216
|
+
"`r" { [void]$builder.Append('\r') }
|
|
217
|
+
"`t" { [void]$builder.Append('\t') }
|
|
218
|
+
default {
|
|
219
|
+
$code = [int]$character
|
|
220
|
+
# Control characters must be escaped to keep the ack parseable.
|
|
221
|
+
if ($code -lt 0x20 -or $code -eq 0x7F) {
|
|
222
|
+
[void]$builder.Append(('\u{0:x4}' -f $code))
|
|
223
|
+
} else {
|
|
224
|
+
[void]$builder.Append($character)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return '"' + $builder.ToString() + '"'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
# Tracks whether the command currently being handled already produced an ack.
|
|
234
|
+
# Without this, an exception raised after a successful ack would emit a second
|
|
235
|
+
# ack for one command and desynchronise the parent's strict ordering.
|
|
236
|
+
$script:AckWritten = $false
|
|
237
|
+
|
|
238
|
+
# Writes exactly one already-serialised JSON ack line and flushes it immediately.
|
|
239
|
+
# Kept separate from Write-Ack so a command whose ack carries extra fields (the
|
|
240
|
+
# AUMID probe) still goes through one guarded "exactly one ack" path.
|
|
241
|
+
function Write-AckLine {
|
|
242
|
+
param([string]$Json)
|
|
243
|
+
|
|
244
|
+
try {
|
|
245
|
+
[Console]::Out.WriteLine($Json)
|
|
246
|
+
[Console]::Out.Flush()
|
|
247
|
+
$script:AckWritten = $true
|
|
248
|
+
} catch {
|
|
249
|
+
# Last resort: keep the ack channel alive with a structurally valid line,
|
|
250
|
+
# and put the real problem on stderr where it cannot corrupt stdout.
|
|
251
|
+
[Console]::Error.WriteLine('toast-host: failed to write ack: ' + $_.Exception.Message)
|
|
252
|
+
try {
|
|
253
|
+
[Console]::Out.WriteLine('{"id":"","ok":false,"error":"ack-failure"}')
|
|
254
|
+
[Console]::Out.Flush()
|
|
255
|
+
$script:AckWritten = $true
|
|
256
|
+
} catch {
|
|
257
|
+
# stdout is gone; there is nothing left to report to.
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# Writes exactly one ack line and flushes it immediately, so the parent never
|
|
263
|
+
# waits on a buffer. This function must never throw: an exception escaping it
|
|
264
|
+
# would be reported as a second ack by the caller.
|
|
265
|
+
function Write-Ack {
|
|
266
|
+
param(
|
|
267
|
+
[string]$Id,
|
|
268
|
+
[bool]$Ok,
|
|
269
|
+
[string]$Error
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
$line = '{"id":' + (ConvertTo-JsonString $Id) + ',"ok":' + $(if ($Ok) { 'true' } else { 'false' })
|
|
273
|
+
|
|
274
|
+
if (-not $Ok -and -not [string]::IsNullOrEmpty($Error)) {
|
|
275
|
+
$line += ',"error":' + (ConvertTo-JsonString $Error)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
$line += '}'
|
|
279
|
+
|
|
280
|
+
Write-AckLine -Json $line
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Command handlers
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
# Builds the ToastGeneric document for a show command.
|
|
288
|
+
#
|
|
289
|
+
# The activationType/launch pair on <toast> is what makes clicking the toast open
|
|
290
|
+
# the protocol URL; without it the notification would be inert. Element order
|
|
291
|
+
# follows the toast schema (visual, audio, actions) because the notification
|
|
292
|
+
# pipeline validates the document against that schema even though
|
|
293
|
+
# XmlDocument.LoadXml only checks well-formedness.
|
|
294
|
+
function New-ToastXml {
|
|
295
|
+
param($Command)
|
|
296
|
+
|
|
297
|
+
$title = Get-StringField -Object $Command -Name 'title' -Default $DefaultTitle
|
|
298
|
+
$launch = Get-StringField -Object $Command -Name 'launch'
|
|
299
|
+
$silent = ConvertTo-Boolean (Get-Field -Object $Command -Name 'silent')
|
|
300
|
+
|
|
301
|
+
# `body` is either one string or an array of strings. The plugin sends the
|
|
302
|
+
# array form because its notification is three separate lines (kind,
|
|
303
|
+
# conversation, detail) and ToastGeneric renders one <text> per line; a
|
|
304
|
+
# single string would let PowerShell's array-to-string coercion paste the
|
|
305
|
+
# parts together with spaces into one run-on line.
|
|
306
|
+
$bodyLines = @()
|
|
307
|
+
$bodyField = Get-Field -Object $Command -Name 'body'
|
|
308
|
+
if ($null -ne $bodyField) {
|
|
309
|
+
if ($bodyField -is [string]) {
|
|
310
|
+
if (-not [string]::IsNullOrWhiteSpace($bodyField)) { $bodyLines = @([string]$bodyField) }
|
|
311
|
+
} else {
|
|
312
|
+
# @(...) normalises the two shapes ConvertFrom-Json can produce for a
|
|
313
|
+
# JSON array (Object[] and, for a single element, a bare object).
|
|
314
|
+
foreach ($item in @($bodyField)) {
|
|
315
|
+
if ($null -eq $item) { continue }
|
|
316
|
+
$text = [string]$item
|
|
317
|
+
if (-not [string]::IsNullOrWhiteSpace($text)) { $bodyLines += $text }
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
# Only the two documented values are accepted; anything else is dropped rather
|
|
323
|
+
# than passed into the attribute, because an unknown duration makes the toast
|
|
324
|
+
# pipeline reject the whole document.
|
|
325
|
+
$duration = ''
|
|
326
|
+
$requestedDuration = Get-StringField -Object $Command -Name 'duration'
|
|
327
|
+
if ($requestedDuration -ieq 'long' -or $requestedDuration -ieq 'short') {
|
|
328
|
+
$duration = $requestedDuration.ToLowerInvariant()
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
$xml = New-Object System.Text.StringBuilder
|
|
332
|
+
|
|
333
|
+
[void]$xml.Append('<toast activationType="protocol"')
|
|
334
|
+
# An empty launch attribute would still be schema-valid but produces a toast
|
|
335
|
+
# that silently does nothing when clicked, so it is omitted instead.
|
|
336
|
+
if ($launch -ne '') {
|
|
337
|
+
[void]$xml.Append(' launch="' + (ConvertTo-XmlText $launch) + '"')
|
|
338
|
+
}
|
|
339
|
+
if ($duration -ne '') {
|
|
340
|
+
[void]$xml.Append(' duration="' + $duration + '"')
|
|
341
|
+
}
|
|
342
|
+
[void]$xml.Append('>')
|
|
343
|
+
|
|
344
|
+
[void]$xml.Append('<visual><binding template="ToastGeneric">')
|
|
345
|
+
[void]$xml.Append('<text>' + (ConvertTo-XmlText $title) + '</text>')
|
|
346
|
+
foreach ($bodyLine in $bodyLines) {
|
|
347
|
+
[void]$xml.Append('<text>' + (ConvertTo-XmlText $bodyLine) + '</text>')
|
|
348
|
+
}
|
|
349
|
+
[void]$xml.Append('</binding></visual>')
|
|
350
|
+
|
|
351
|
+
if ($silent) {
|
|
352
|
+
[void]$xml.Append('<audio silent="true"/>')
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
$buttons = Get-Field -Object $Command -Name 'buttons'
|
|
356
|
+
if ($null -ne $buttons) {
|
|
357
|
+
$actionXml = New-Object System.Text.StringBuilder
|
|
358
|
+
|
|
359
|
+
# @(...) is required because a one element JSON array can arrive as a
|
|
360
|
+
# bare object depending on the deserialiser path.
|
|
361
|
+
foreach ($button in @($buttons)) {
|
|
362
|
+
if ($null -eq $button) { continue }
|
|
363
|
+
|
|
364
|
+
$content = Get-StringField -Object $button -Name 'content'
|
|
365
|
+
$arguments = Get-StringField -Object $button -Name 'arguments'
|
|
366
|
+
if ($content -eq '' -and $arguments -eq '') { continue }
|
|
367
|
+
|
|
368
|
+
[void]$actionXml.Append('<action')
|
|
369
|
+
[void]$actionXml.Append(' content="' + (ConvertTo-XmlText $content) + '"')
|
|
370
|
+
[void]$actionXml.Append(' activationType="protocol"')
|
|
371
|
+
[void]$actionXml.Append(' arguments="' + (ConvertTo-XmlText $arguments) + '"')
|
|
372
|
+
[void]$actionXml.Append(' placement="content"')
|
|
373
|
+
[void]$actionXml.Append('/>')
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
# An empty <actions> element is invalid, so it is only emitted when at
|
|
377
|
+
# least one usable button survived.
|
|
378
|
+
if ($actionXml.Length -gt 0) {
|
|
379
|
+
[void]$xml.Append('<actions>' + $actionXml.ToString() + '</actions>')
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
[void]$xml.Append('</toast>')
|
|
384
|
+
|
|
385
|
+
return $xml.ToString()
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function Invoke-ShowCommand {
|
|
389
|
+
param($Command, [string]$Id)
|
|
390
|
+
|
|
391
|
+
$appId = Get-StringField -Object $Command -Name 'appId' -Default $DefaultAppId
|
|
392
|
+
$tag = Get-StringField -Object $Command -Name 'tag'
|
|
393
|
+
$group = Get-StringField -Object $Command -Name 'group'
|
|
394
|
+
|
|
395
|
+
try {
|
|
396
|
+
$document = New-Object Windows.Data.Xml.Dom.XmlDocument
|
|
397
|
+
$document.LoadXml((New-ToastXml -Command $Command))
|
|
398
|
+
|
|
399
|
+
$toast = New-Object Windows.UI.Notifications.ToastNotification $document
|
|
400
|
+
|
|
401
|
+
# Tag/group are what a later remove command matches on. Assigning an empty
|
|
402
|
+
# string throws, and a tag that violates the length/charset rules throws
|
|
403
|
+
# too, so each assignment is guarded: losing the tag only means the toast
|
|
404
|
+
# cannot be retracted later, which is much better than losing the toast.
|
|
405
|
+
if ($tag -ne '') {
|
|
406
|
+
try {
|
|
407
|
+
$toast.Tag = $tag
|
|
408
|
+
} catch {
|
|
409
|
+
[Console]::Error.WriteLine('toast-host: could not set tag: ' + $_.Exception.Message)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if ($group -ne '') {
|
|
414
|
+
try {
|
|
415
|
+
$toast.Group = $group
|
|
416
|
+
} catch {
|
|
417
|
+
[Console]::Error.WriteLine('toast-host: could not set group: ' + $_.Exception.Message)
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
# A registered AUMID is required; an unknown one makes CreateToastNotifier
|
|
422
|
+
# or Show throw, which is reported as a failed ack rather than a crash.
|
|
423
|
+
$notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId)
|
|
424
|
+
$notifier.Show($toast)
|
|
425
|
+
|
|
426
|
+
Write-Ack -Id $Id -Ok $true
|
|
427
|
+
} catch {
|
|
428
|
+
[Console]::Error.WriteLine('toast-host: show failed: ' + $_.Exception.Message)
|
|
429
|
+
Write-Ack -Id $Id -Ok $false -Error $_.Exception.Message
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function Invoke-RemoveCommand {
|
|
434
|
+
param($Command, [string]$Id)
|
|
435
|
+
$appId = Get-StringField -Object $Command -Name 'appId' -Default $DefaultAppId
|
|
436
|
+
$tag = Get-StringField -Object $Command -Name 'tag'
|
|
437
|
+
$group = Get-StringField -Object $Command -Name 'group'
|
|
438
|
+
|
|
439
|
+
# History.Remove matches on tag plus group, and both must be non-empty or the
|
|
440
|
+
# call throws; a caller that omitted them has nothing to remove.
|
|
441
|
+
if ($tag -eq '' -or $group -eq '') {
|
|
442
|
+
Write-Ack -Id $Id -Ok $false -Error 'remove requires a non-empty tag and group'
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
try {
|
|
447
|
+
[Windows.UI.Notifications.ToastNotificationManager]::History.Remove($tag, $group, $appId)
|
|
448
|
+
Write-Ack -Id $Id -Ok $true
|
|
449
|
+
} catch {
|
|
450
|
+
[Console]::Error.WriteLine('toast-host: remove failed: ' + $_.Exception.Message)
|
|
451
|
+
Write-Ack -Id $Id -Ok $false -Error $_.Exception.Message
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
# Cached Start Menu AppIDs. $null means "not probed yet"; an empty array means
|
|
456
|
+
# the probe ran and found nothing. The distinction matters because an empty list
|
|
457
|
+
# is evidence and an unrun probe is not.
|
|
458
|
+
$script:StartAppIds = $null
|
|
459
|
+
|
|
460
|
+
# Enumerates the AppUserModelIDs Windows would accept for a notification.
|
|
461
|
+
#
|
|
462
|
+
# This exists because Show() does NOT fail for an unregistered AUMID: Windows
|
|
463
|
+
# accepts the toast and files it in Action Center under an identity that has no
|
|
464
|
+
# Start Menu entry, so it appears with no application name and no icon. Waiting
|
|
465
|
+
# for an exception therefore never selects a fallback, and the only way to know
|
|
466
|
+
# whether an identity is real is to ask the Start Menu directly.
|
|
467
|
+
#
|
|
468
|
+
# @returns an array of AppIDs, or $null when the enumeration is unavailable.
|
|
469
|
+
function Get-StartAppIds {
|
|
470
|
+
if ($null -ne $script:StartAppIds) { return $script:StartAppIds }
|
|
471
|
+
|
|
472
|
+
try {
|
|
473
|
+
$script:StartAppIds = @(Get-StartApps -ErrorAction Stop | ForEach-Object { [string]$_.AppID })
|
|
474
|
+
} catch {
|
|
475
|
+
[Console]::Error.WriteLine('toast-host: Get-StartApps unavailable: ' + $_.Exception.Message)
|
|
476
|
+
$script:StartAppIds = $null
|
|
477
|
+
return $null
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
return $script:StartAppIds
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
# Picks the first candidate AUMID that is actually registered on this machine.
|
|
484
|
+
#
|
|
485
|
+
# The parent sends its candidates best-attribution-first (the DeepSeek Harness
|
|
486
|
+
# desktop application, then the Windows PowerShell entry that every Windows
|
|
487
|
+
# install carries) and uses the answer to speak for a real identity rather than
|
|
488
|
+
# an orphaned one.
|
|
489
|
+
function Invoke-ProbeAumidCommand {
|
|
490
|
+
param($Command, [string]$Id)
|
|
491
|
+
|
|
492
|
+
$candidates = @()
|
|
493
|
+
$field = Get-Field -Object $Command -Name 'candidates'
|
|
494
|
+
if ($null -ne $field) {
|
|
495
|
+
foreach ($item in @($field)) {
|
|
496
|
+
if ($null -eq $item) { continue }
|
|
497
|
+
$text = [string]$item
|
|
498
|
+
if (-not [string]::IsNullOrWhiteSpace($text)) { $candidates += $text }
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
if ($candidates.Count -eq 0) {
|
|
503
|
+
Write-Ack -Id $Id -Ok $false -Error 'probeAumid requires a non-empty candidates array'
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
$ids = Get-StartAppIds
|
|
508
|
+
if ($null -eq $ids) {
|
|
509
|
+
Write-Ack -Id $Id -Ok $false -Error 'Get-StartApps is unavailable on this machine'
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
$matched = ''
|
|
514
|
+
foreach ($candidate in $candidates) {
|
|
515
|
+
if ($ids -contains $candidate) {
|
|
516
|
+
$matched = $candidate
|
|
517
|
+
break
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
$json = '{"id":' + (ConvertTo-JsonString $Id) + ',"ok":true'
|
|
522
|
+
$json += ',"appId":' + (ConvertTo-JsonString $matched)
|
|
523
|
+
$json += ',"matched":' + $(if ($matched -ne '') { 'true' } else { 'false' })
|
|
524
|
+
$json += ',"startApps":' + $ids.Count
|
|
525
|
+
$json += '}'
|
|
526
|
+
|
|
527
|
+
Write-AckLine -Json $json
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
# Dispatches one already parsed command object.
|
|
531
|
+
function Invoke-CommandObject {
|
|
532
|
+
param($Command)
|
|
533
|
+
|
|
534
|
+
$id = Get-StringField -Object $Command -Name 'id'
|
|
535
|
+
$op = (Get-StringField -Object $Command -Name 'op' -Default 'show').ToLowerInvariant()
|
|
536
|
+
|
|
537
|
+
switch ($op) {
|
|
538
|
+
'show' { Invoke-ShowCommand -Command $Command -Id $id }
|
|
539
|
+
'remove' { Invoke-RemoveCommand -Command $Command -Id $id }
|
|
540
|
+
'probeaumid' { Invoke-ProbeAumidCommand -Command $Command -Id $id }
|
|
541
|
+
'ping' {
|
|
542
|
+
# Liveness probe: proves the daemon is up and the pipe is healthy
|
|
543
|
+
# without putting anything on the user's screen.
|
|
544
|
+
Write-Ack -Id $id -Ok $true
|
|
545
|
+
}
|
|
546
|
+
default {
|
|
547
|
+
Write-Ack -Id $id -Ok $false -Error ("unsupported op '" + $op + "'")
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
# Handles one raw stdin line. Malformed input is answered with an error ack and
|
|
553
|
+
# never propagates, because a single bad line must not kill a long-lived daemon.
|
|
554
|
+
function Invoke-InputLine {
|
|
555
|
+
param([string]$Line)
|
|
556
|
+
|
|
557
|
+
# Blank lines are skipped without an ack: the parent never sends them, so
|
|
558
|
+
# answering one would desynchronise the ack stream.
|
|
559
|
+
if ([string]::IsNullOrWhiteSpace($Line)) { return }
|
|
560
|
+
|
|
561
|
+
$script:AckWritten = $false
|
|
562
|
+
|
|
563
|
+
$command = $null
|
|
564
|
+
try {
|
|
565
|
+
$command = ConvertFrom-Json -InputObject $Line -ErrorAction Stop
|
|
566
|
+
} catch {
|
|
567
|
+
[Console]::Error.WriteLine('toast-host: malformed JSON command: ' + $_.Exception.Message)
|
|
568
|
+
Write-Ack -Id '' -Ok $false -Error ('malformed JSON: ' + $_.Exception.Message)
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
# Anything that is not a JSON object (array, bare string, number, null) has no
|
|
573
|
+
# op and no fields, so it is a protocol error rather than an empty show.
|
|
574
|
+
if ($command -isnot [System.Management.Automation.PSCustomObject]) {
|
|
575
|
+
Write-Ack -Id '' -Ok $false -Error 'command must be a JSON object'
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
try {
|
|
580
|
+
Invoke-CommandObject -Command $command
|
|
581
|
+
} catch {
|
|
582
|
+
[Console]::Error.WriteLine('toast-host: command failed: ' + $_.Exception.Message)
|
|
583
|
+
if (-not $script:AckWritten) {
|
|
584
|
+
Write-Ack -Id (Get-StringField -Object $command -Name 'id') -Ok $false -Error $_.Exception.Message
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
# ---------------------------------------------------------------------------
|
|
590
|
+
# Read loop
|
|
591
|
+
# ---------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
if ($Once) {
|
|
594
|
+
# One command, then exit. The exit code still reports transport success: a
|
|
595
|
+
# command that failed keeps exit code 0 because its failure is carried by the
|
|
596
|
+
# ack, while a missing command is a real invocation error.
|
|
597
|
+
$line = [Console]::In.ReadLine()
|
|
598
|
+
if ($null -eq $line) {
|
|
599
|
+
[Console]::Error.WriteLine('toast-host: -Once was set but stdin was empty')
|
|
600
|
+
exit 1
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
Invoke-InputLine -Line $line
|
|
604
|
+
exit 0
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
while ($true) {
|
|
608
|
+
# ReadLine returns $null only at end of input, which is how the parent shuts
|
|
609
|
+
# the daemon down by closing its stdin.
|
|
610
|
+
$line = [Console]::In.ReadLine()
|
|
611
|
+
if ($null -eq $line) { break }
|
|
612
|
+
|
|
613
|
+
Invoke-InputLine -Line $line
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
exit 0
|