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,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('&amp;') }
187
+ '<' { [void]$builder.Append('&lt;') }
188
+ '>' { [void]$builder.Append('&gt;') }
189
+ '"' { [void]$builder.Append('&quot;') }
190
+ "'" { [void]$builder.Append('&apos;') }
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