@join-pantheon/cli 0.32.1 → 0.33.0-rc.2

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,731 @@
1
+ <#
2
+ Pantheon's tray icon on Windows: the macOS menu bar's job, in a notification-area icon.
3
+
4
+ A thin shell, like helper/macos/menubar.swift: everything it knows comes from
5
+ `pantheon tray-status` (JSON), and everything it does is a pantheon command.
6
+ It never answers, sends or decides anything itself.
7
+
8
+ The Pantheon desktop host (src/win-host.ts trayArgv) runs this file's text as
9
+ a script block, never with -File: Windows client editions default to the
10
+ Restricted execution policy, which refuses -File without a Bypass.
11
+ powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command
12
+ "& ([scriptblock]::Create([IO.File]::ReadAllText('<this file>'))) -Pantheon '<command>' -Home '<PANTHEON_HOME>'"
13
+ So it never relies on $PSScriptRoot (a script block has none) or on script
14
+ scope: everything shared lives in one global table, $PT, and every function is
15
+ global, so a WinForms event handler finds the same state however it was started.
16
+
17
+ -Pantheon how to run Pantheon, as a command line: "C:\...\node.exe" --use-system-ca "C:\...\cli.js",
18
+ or one launcher path
19
+ -Home Pantheon's home folder; every command it runs gets it as PANTHEON_HOME
20
+ -Icon the .ico to show (the host passes the package's helper\windows\pantheon.ico;
21
+ without it, the one beside the cli.js -Pantheon names)
22
+ -NoticeScript helper\windows\pantheon-notice.ps1: the tray shows Pantheon's alerts
23
+ with its functions (the notifier queues them in <Home>\host\notices)
24
+ -Poll seconds between checks (15)
25
+
26
+ Quit writes <Home>\host\tray.disabled naming the host's session
27
+ (PANTHEON_HOST_SESSION), which keeps the host from starting it again until
28
+ the next logon -- or until `pantheon tray`, `pantheon service start` or setup
29
+ asks for it -- then exits 0. The host asks it to close by writing
30
+ <Home>\host\tray.stop (with this process's id): it then takes its icon away
31
+ itself, rather than leaving a ghost behind as a killed process would.
32
+ #>
33
+ param(
34
+ [Parameter(Mandatory = $true)][string]$Pantheon,
35
+ [Parameter(Mandatory = $true)][Alias('Home')][string]$PantheonHome,
36
+ [string]$Icon = '',
37
+ [string]$NoticeScript = '',
38
+ [ValidateRange(5, 3600)][int]$Poll = 15
39
+ )
40
+
41
+ $ErrorActionPreference = 'Stop'
42
+
43
+ # ---------------------------------------------------------------- the command
44
+
45
+ # -Pantheon is a command line: a quoted program and its leading arguments
46
+ # ("node.exe" --use-system-ca "cli.js"), or one bare path (which may hold spaces).
47
+ function global:Split-PantheonCommand([string]$line) {
48
+ $line = $line.Trim()
49
+ if ($line.StartsWith('"')) {
50
+ $end = $line.IndexOf('"', 1)
51
+ if ($end -lt 0) { return @{ exe = $line.Trim('"'); args = @() } }
52
+ $exe = $line.Substring(1, $end - 1)
53
+ $rest = $line.Substring($end + 1)
54
+ } elseif ((Test-Path -LiteralPath $line -PathType Leaf) -or ($line -notmatch '\s')) {
55
+ return @{ exe = $line; args = @() }
56
+ } else {
57
+ $sp = $line.IndexOfAny([char[]]@(' ', "`t"))
58
+ $exe = $line.Substring(0, $sp)
59
+ $rest = $line.Substring($sp + 1)
60
+ }
61
+ $words = @()
62
+ foreach ($m in [regex]::Matches($rest, '"([^"]*)"|(\S+)')) {
63
+ if ($m.Groups[1].Success) { $words += $m.Groups[1].Value } else { $words += $m.Groups[2].Value }
64
+ }
65
+ return @{ exe = $exe; args = $words }
66
+ }
67
+
68
+ function global:Quote-Arg([string]$a) {
69
+ # Pantheon's own words (tray-status, stop, worker) as they are; a path quoted whole. Windows paths hold no double quote.
70
+ if ($a -match '^[A-Za-z0-9._:-]+$') { return $a }
71
+ return '"' + $a + '"'
72
+ }
73
+
74
+ # The package's helper\windows\pantheon.ico, found from the cli.js the command names.
75
+ function global:Find-TrayIcon([string[]]$words) {
76
+ foreach ($w in $words) {
77
+ if ($w -match '\.js$') {
78
+ $pkg = Split-Path -Parent (Split-Path -Parent $w)
79
+ $ico = Join-Path $pkg 'helper\windows\pantheon.ico'
80
+ if (Test-Path -LiteralPath $ico) { return $ico }
81
+ }
82
+ }
83
+ return ''
84
+ }
85
+
86
+ $cmd = Split-PantheonCommand $Pantheon
87
+ $global:PT = @{
88
+ home = $PantheonHome; poll = $Poll; exe = $cmd.exe; pre = @($cmd.args)
89
+ # What pause and resume run: the same commands as the terminal. `service start`
90
+ # brings the worker back with the flags it was installed with.
91
+ pauseArgs = @('stop', 'worker'); resumeArgs = @('service', 'start', 'worker')
92
+ # How long a check may take, and how many may go missing before it says so:
93
+ # like the macOS tray, two misses keep what was last known; the third is shown.
94
+ checkTimeout = 20; missesTolerated = 2
95
+ misses = 0; check = $null; board = $null; nextCheck = (Get-Date); quit = $false
96
+ logDir = (Join-Path $PantheonHome 'logs')
97
+ hostDir = (Join-Path $PantheonHome 'host')
98
+ noticeDir = (Join-Path (Join-Path $PantheonHome 'host') 'notices')
99
+ session = [string]$env:PANTHEON_HOST_SESSION
100
+ # Alerts are shown from here when the alert functions loaded (Show-PantheonToast).
101
+ notices = $false
102
+ # An alert older than this when it is found is not news any more: dropped, and said so in the log.
103
+ noticeMaxAgeSeconds = 600
104
+ # This file is kept ASCII (Windows PowerShell reads a .ps1 without a BOM in the
105
+ # system code page); the typography it shows is made from code points.
106
+ dash = [string][char]0x2014; dot = [string][char]0x00B7; ellipsis = [string][char]0x2026
107
+ state = @{
108
+ loaded = $false; reachable = $false; configured = $false; relayOk = $false; decisionsOk = $true
109
+ member = ''; space = ''; spaces = @(); need = 0; workerRunning = $false; workerInstalled = $false
110
+ }
111
+ icons = @{}
112
+ }
113
+ if ($Icon) { $PT.iconPath = $Icon } else { $PT.iconPath = Find-TrayIcon $PT.pre }
114
+
115
+ function global:Write-TrayLog([string]$line) {
116
+ try {
117
+ if (-not (Test-Path -LiteralPath $PT.logDir)) { [void](New-Item -ItemType Directory -Path $PT.logDir -Force) }
118
+ Add-Content -LiteralPath (Join-Path $PT.logDir 'tray.log') -Value ((Get-Date).ToString('o') + ' ' + $line) -Encoding UTF8
119
+ } catch { }
120
+ }
121
+
122
+ # The alert functions (Show-PantheonToast, Write-PantheonAlertLog), from the
123
+ # fixed alert script, loaded the same way this file is: as a script block.
124
+ if ($NoticeScript -and (Test-Path -LiteralPath $NoticeScript)) {
125
+ try { . ([scriptblock]::Create([IO.File]::ReadAllText($NoticeScript))); $PT.notices = $true }
126
+ catch { Write-TrayLog ('alerts: could not load ' + $NoticeScript + ': ' + $_.Exception.Message) }
127
+ }
128
+
129
+ # ---------------------------------------------------------------- alerts the notifier queued
130
+
131
+ # Nothing in the queue is trusted. A queued alert carries the toast's fields,
132
+ # never XML (src/notice-windows.ts QueuedNotice, version 2); each is checked
133
+ # here, and the XML is built here, with the same escaping as Node's toastXml,
134
+ # so a file someone else drops into the queue cannot inject markup. Every link
135
+ # must be one of the four pantheon:// forms (src/uri-handler.ts parseUri), and
136
+ # the app ids the two the notifier uses. Anything else is dropped and logged.
137
+ $PT.linkForms = @{
138
+ board = @{ required = @(); optional = @() }
139
+ done = @{ required = @('decision', 'k'); optional = @('act', 'space') }
140
+ explain = @{ required = @('decision', 'k'); optional = @('act', 'space') }
141
+ details = @{ required = @('act', 'k'); optional = @('decision', 'space') }
142
+ }
143
+ $PT.linkValues = @{ decision = '^o_[a-z0-9]{6,48}$'; act = '^a_[A-Za-z0-9]{6,48}$'; space = '^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$'; k = '^[0-9a-f]{32}$' }
144
+ # Pantheon's own app id (uri-handler.ts PANTHEON_AUMID) and Windows PowerShell's (notice-windows.ts POWERSHELL_AUMID).
145
+ $PT.aumids = @('JoinPantheon.Pantheon', '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe')
146
+ # A queued file that cannot be taken (read and deleted) is tried for this long, then skipped while it is there unchanged.
147
+ $PT.stuck = @{}
148
+ $PT.stuckSeconds = 5
149
+ $PT.skippedFile = Join-Path $PT.hostDir 'notices.skipped'
150
+
151
+ # One link: pantheon://board, or done/explain/details with each parameter once,
152
+ # every value in the form Pantheon writes it. Strict, as the handler is.
153
+ function global:Test-PantheonLink($uri) {
154
+ if ($uri -isnot [string] -or -not $uri -or $uri.Length -gt 400) { return $false }
155
+ if ($uri -cnotmatch '^[A-Za-z0-9:/?&=%._~-]+$') { return $false }
156
+ $m = [regex]::Match($uri, '^pantheon://([a-z]+)(?:\?(.+))?$')
157
+ if (-not $m.Success) { return $false }
158
+ $what = $m.Groups[1].Value
159
+ if (@($PT.linkForms.Keys) -cnotcontains $what) { return $false }
160
+ $form = $PT.linkForms[$what]
161
+ $names = @($form.required) + @($form.optional)
162
+ $seen = @{}
163
+ if ($m.Groups[2].Success) {
164
+ foreach ($part in $m.Groups[2].Value.Split('&')) {
165
+ $eq = $part.IndexOf('=')
166
+ if ($eq -le 0) { return $false }
167
+ $name = $part.Substring(0, $eq)
168
+ if ($names -cnotcontains $name -or $seen.ContainsKey($name)) { return $false }
169
+ if ($part.Substring($eq + 1) -cnotmatch $PT.linkValues[$name]) { return $false }
170
+ $seen[$name] = $true
171
+ }
172
+ }
173
+ foreach ($r in $form.required) { if (-not $seen.ContainsKey($r)) { return $false } }
174
+ return $true
175
+ }
176
+
177
+ # Text for the toast or the balloon: a string, no control characters (Node's
178
+ # toastText removes them all), no longer than $max. $null when it is not.
179
+ function global:Test-NoticeText($v, [int]$max, [bool]$empty = $true) {
180
+ if ($v -isnot [string]) { return $false }
181
+ if (-not $empty -and -not $v) { return $false }
182
+ if ($v.Length -gt $max) { return $false }
183
+ return ($v -cnotmatch '[\x00-\x1F\x7F-\x9F\uFFFE\uFFFF]')
184
+ }
185
+
186
+ # XML-escaped, for text and attribute values alike (notice-windows.ts xmlEscape).
187
+ function global:ConvertTo-XmlText([string]$s) {
188
+ return $s.Replace('&', '&amp;').Replace('<', '&lt;').Replace('>', '&gt;').Replace('"', '&quot;').Replace("'", '&apos;')
189
+ }
190
+
191
+ # The toast XML from its checked fields: the same bytes as notice-windows.ts toastXmlFrom.
192
+ function global:ConvertTo-PantheonToastXml($t) {
193
+ $attrs = 'activationType="protocol" launch="' + (ConvertTo-XmlText $t.launch) + '"'
194
+ if ($t.scenario) { $attrs += ' scenario="' + $t.scenario + '"' } elseif ($t.duration) { $attrs += ' duration="' + $t.duration + '"' }
195
+ $sb = New-Object System.Text.StringBuilder
196
+ [void]$sb.Append('<toast ' + $attrs + '>')
197
+ [void]$sb.Append('<visual><binding template="ToastGeneric">')
198
+ foreach ($l in $t.lines) { [void]$sb.Append('<text>' + (ConvertTo-XmlText $l) + '</text>') }
199
+ if ($t.who) { [void]$sb.Append('<text placement="attribution">' + (ConvertTo-XmlText $t.who) + '</text>') }
200
+ [void]$sb.Append('</binding></visual>')
201
+ if (@($t.buttons).Count -gt 0) {
202
+ [void]$sb.Append('<actions>')
203
+ foreach ($b in $t.buttons) { [void]$sb.Append('<action content="' + (ConvertTo-XmlText $b.content) + '" activationType="protocol" arguments="' + (ConvertTo-XmlText $b.uri) + '"/>') }
204
+ [void]$sb.Append('</actions>')
205
+ }
206
+ if ($t.silent) { [void]$sb.Append('<audio silent="true"/>') }
207
+ [void]$sb.Append('</toast>')
208
+ return $sb.ToString()
209
+ }
210
+
211
+ # The last check before a toast is shown: the XML parses, holds only the
212
+ # elements and attributes a Pantheon toast has, each where it belongs, and every
213
+ # activation is a pantheon:// link of the four forms.
214
+ function global:Test-PantheonToastXml([string]$xml) {
215
+ $doc = New-Object System.Xml.XmlDocument
216
+ $doc.XmlResolver = $null
217
+ try { $doc.LoadXml($xml) } catch { return $false }
218
+ if ($null -ne $doc.DocumentType -or $null -eq $doc.DocumentElement) { return $false }
219
+ $attrs = @{
220
+ toast = @('activationType', 'launch', 'scenario', 'duration'); visual = @(); binding = @('template'); text = @('placement')
221
+ actions = @(); action = @('content', 'activationType', 'arguments'); audio = @('silent')
222
+ }
223
+ $parent = @{ toast = '#document'; visual = 'toast'; binding = 'visual'; text = 'binding'; actions = 'toast'; action = 'actions'; audio = 'toast' }
224
+ foreach ($n in @($doc.SelectNodes('//node()'))) {
225
+ if ($n.NodeType -eq [System.Xml.XmlNodeType]::Text) { if ($n.ParentNode.LocalName -cne 'text') { return $false }; continue }
226
+ if ($n.NodeType -ne [System.Xml.XmlNodeType]::Element) { return $false }
227
+ $name = $n.LocalName
228
+ if (@($attrs.Keys) -cnotcontains $name -or $n.NamespaceURI -or $n.Name -cne $name) { return $false }
229
+ if ($n.ParentNode.Name -cne $parent[$name]) { return $false }
230
+ foreach ($a in @($n.Attributes)) {
231
+ if ($attrs[$name] -cnotcontains $a.Name) { return $false }
232
+ if ($a.Name -ceq 'activationType' -and $a.Value -cne 'protocol') { return $false }
233
+ if (($a.Name -ceq 'launch' -or $a.Name -ceq 'arguments') -and -not (Test-PantheonLink $a.Value)) { return $false }
234
+ }
235
+ if ($name -ceq 'toast' -and -not $n.HasAttribute('launch')) { return $false }
236
+ if ($name -ceq 'action' -and -not $n.HasAttribute('arguments')) { return $false }
237
+ }
238
+ return $true
239
+ }
240
+
241
+ # One queued alert, from its file's text: a table, @{ stale = $true } when it
242
+ # is too old to be news, or $null when it is not one Pantheon writes (and so is
243
+ # never shown).
244
+ function global:Read-QueuedNotice([string]$json, [datetime]$now) {
245
+ try {
246
+ $q = $json | ConvertFrom-Json
247
+ if ($null -eq $q -or $q -isnot [System.Management.Automation.PSCustomObject] -or [string]$q.v -cne '2') { return $null }
248
+ $about = [string]$q.about
249
+ if ($about -cnotmatch '^[a-z]{1,24}( [A-Za-z0-9_]{1,64})?$') { $about = 'an alert' }
250
+ if ([string]$q.at -cnotmatch '^\d{1,16}$') { return $null }
251
+ $at = [DateTimeOffset]::FromUnixTimeMilliseconds([int64][string]$q.at).UtcDateTime
252
+ if (($now.ToUniversalTime() - $at).TotalSeconds -gt $PT.noticeMaxAgeSeconds) { return @{ stale = $true; about = $about } }
253
+ $t = $q.toast
254
+ if ($null -eq $t -or $t -isnot [System.Management.Automation.PSCustomObject]) { return $null }
255
+ if ($t.lines -isnot [System.Array] -or @($t.lines).Count -lt 1 -or @($t.lines).Count -gt 3) { return $null }
256
+ foreach ($l in $t.lines) { if (-not (Test-NoticeText $l 700 $false)) { return $null } }
257
+ if (-not (Test-NoticeText $t.who 200)) { return $null }
258
+ if (-not (Test-PantheonLink $t.launch)) { return $null }
259
+ if ($t.buttons -isnot [System.Array] -or @($t.buttons).Count -gt 3) { return $null }
260
+ $buttons = @()
261
+ foreach ($b in $t.buttons) {
262
+ if ($null -eq $b -or $b -isnot [System.Management.Automation.PSCustomObject] -or -not (Test-NoticeText $b.content 60 $false) -or -not (Test-PantheonLink $b.uri)) { return $null }
263
+ $buttons += , @{ content = [string]$b.content; uri = [string]$b.uri }
264
+ }
265
+ if (@('', 'reminder') -cnotcontains [string]$t.scenario -or $t.scenario -isnot [string]) { return $null }
266
+ if (@('', 'long') -cnotcontains [string]$t.duration -or $t.duration -isnot [string]) { return $null }
267
+ if ($t.silent -isnot [bool]) { return $null }
268
+ if ($q.tag -isnot [string] -or $q.tag -cnotmatch '^[A-Za-z0-9_]{0,60}$') { return $null }
269
+ if ($q.aumids -isnot [System.Array] -or @($q.aumids).Count -lt 1 -or @($q.aumids).Count -gt 2) { return $null }
270
+ foreach ($id in $q.aumids) { if ($id -isnot [string] -or $PT.aumids -cnotcontains $id) { return $null } }
271
+ $balloon = $q.balloon
272
+ if ($null -eq $balloon -or -not (Test-NoticeText $balloon.title 200) -or -not (Test-NoticeText $balloon.text 600)) { return $null }
273
+ $xml = ConvertTo-PantheonToastXml @{
274
+ lines = @($t.lines | ForEach-Object { [string]$_ }); who = [string]$t.who; launch = [string]$t.launch; buttons = $buttons
275
+ scenario = [string]$t.scenario; duration = [string]$t.duration; silent = [bool]$t.silent
276
+ }
277
+ if (-not (Test-PantheonToastXml $xml)) { return $null }
278
+ # NotifyIcon's own limits (it throws beyond them).
279
+ $title = [string]$balloon.title; if ($title.Length -gt 63) { $title = $title.Substring(0, 63) }
280
+ $text = [string]$balloon.text; if ($text.Length -gt 255) { $text = $text.Substring(0, 255) }
281
+ if (-not $title) { $title = 'Pantheon' }
282
+ if (-not $text) { $text = 'Open Pantheon' }
283
+ return @{
284
+ stale = $false; about = $about; xml = $xml; tag = [string]$q.tag
285
+ aumids = @($q.aumids | ForEach-Object { [string]$_ }); title = $title; text = $text
286
+ }
287
+ } catch { return $null }
288
+ }
289
+
290
+ # Take one queued file: read it, then delete it. A seam of its own, so the
291
+ # tests can stand in a file that will not go.
292
+ function global:Receive-QueuedFile([string]$f) {
293
+ $text = [IO.File]::ReadAllText($f, [Text.Encoding]::UTF8)
294
+ [IO.File]::Delete($f)
295
+ if ([IO.File]::Exists($f)) { throw 'still there after it was deleted' }
296
+ return $text
297
+ }
298
+
299
+ # The skipped files, for the notifier: "<name><TAB><mtime ms>" a line
300
+ # (notice-windows.ts skippedNotices), or no file when nothing is skipped.
301
+ function global:Save-SkippedNotices {
302
+ $lines = @($PT.stuck.Keys | Sort-Object | Where-Object { $PT.stuck[$_].skip } | ForEach-Object { $_ + "`t" + $PT.stuck[$_].mtime })
303
+ try {
304
+ if ($lines.Count) { [IO.File]::WriteAllText($PT.skippedFile, (($lines -join "`n") + "`n")) }
305
+ elseif ([IO.File]::Exists($PT.skippedFile)) { [IO.File]::Delete($PT.skippedFile) }
306
+ } catch { Write-TrayLog ('alerts: could not write ' + $PT.skippedFile + ': ' + $_.Exception.Message) }
307
+ }
308
+
309
+ # What an earlier run of the tray skipped, so it is skipped from the start.
310
+ function global:Read-SkippedNotices([datetime]$now) {
311
+ try {
312
+ if (-not [IO.File]::Exists($PT.skippedFile)) { return }
313
+ foreach ($line in [IO.File]::ReadAllLines($PT.skippedFile)) {
314
+ $m = [regex]::Match($line.Trim(), '^([0-9A-Za-z._-]+\.json)\t(\d{1,16})$')
315
+ if ($m.Success) { $PT.stuck[$m.Groups[1].Value] = @{ mtime = [int64]$m.Groups[2].Value; since = $now; skip = $true } }
316
+ }
317
+ } catch { }
318
+ }
319
+
320
+ # The queue, oldest first. Each file is taken (read and deleted) before it is
321
+ # shown, so an alert is shown once. One that cannot be taken (a virus scanner
322
+ # holds a new file for a moment) is tried again for $PT.stuckSeconds, then
323
+ # skipped for as long as it is there unchanged: remembered by name and
324
+ # modification time, said once in tray.log, and listed in host\notices.skipped
325
+ # so the notifier does not take it for a tray that stopped reading
326
+ # (trayTakesNotices). New alerts keep coming through either way.
327
+ function global:Get-QueuedNotices([datetime]$now) {
328
+ $out = @()
329
+ if (-not [IO.Directory]::Exists($PT.noticeDir)) { return , $out }
330
+ $present = @{}
331
+ $changed = $false
332
+ foreach ($f in ([IO.Directory]::GetFiles($PT.noticeDir, '*.json') | Sort-Object)) {
333
+ if (-not $f.EndsWith('.json')) { continue }
334
+ $name = [IO.Path]::GetFileName($f)
335
+ $present[$name] = $true
336
+ $mtime = [int64]0
337
+ try { $mtime = [DateTimeOffset]::new([IO.File]::GetLastWriteTimeUtc($f)).ToUnixTimeMilliseconds() } catch { continue }
338
+ $s = $PT.stuck[$name]
339
+ if ($s -and $s.skip -and $s.mtime -eq $mtime) { continue }
340
+ $text = $null
341
+ try { $text = Receive-QueuedFile $f } catch { $text = $null }
342
+ if ($null -eq $text) {
343
+ if (-not $s -or $s.mtime -ne $mtime) { $PT.stuck[$name] = @{ mtime = $mtime; since = $now; skip = $false } }
344
+ elseif (($now - $s.since).TotalSeconds -ge $PT.stuckSeconds) {
345
+ $s.skip = $true
346
+ $changed = $true
347
+ Write-TrayLog ('alerts: skipping ' + $name + ' in the queue: it cannot be deleted; new alerts are still shown')
348
+ }
349
+ continue
350
+ }
351
+ if ($s) { if ($s.skip) { $changed = $true }; $PT.stuck.Remove($name) }
352
+ $n = Read-QueuedNotice $text $now
353
+ if ($null -eq $n) { Write-TrayLog ('alerts: dropped a queued file that is not an alert Pantheon writes: ' + $name); continue }
354
+ $out += , $n
355
+ }
356
+ foreach ($g in @($PT.stuck.Keys | Where-Object { -not $present.ContainsKey($_) })) {
357
+ if ($PT.stuck[$g].skip) { $changed = $true }
358
+ $PT.stuck.Remove($g)
359
+ }
360
+ if ($changed) { Save-SkippedNotices }
361
+ return , $out
362
+ }
363
+ Read-SkippedNotices (Get-Date)
364
+
365
+ # The host asks the tray to close by writing host\tray.stop with this
366
+ # process's id (or nothing): true when that is waiting, and it is taken.
367
+ function global:Test-StopRequest {
368
+ $f = Join-Path $PT.hostDir 'tray.stop'
369
+ if (-not [IO.File]::Exists($f)) { return $false }
370
+ $who = ''
371
+ try { $who = ([IO.File]::ReadAllText($f)).Trim() } catch { return $false }
372
+ if ($who -and $who -ne [string]$PID) { return $false }
373
+ try { [IO.File]::Delete($f) } catch { }
374
+ return $true
375
+ }
376
+
377
+ # What Quit writes into host\tray.disabled: the session it holds for.
378
+ function global:Get-QuitText {
379
+ $s = if ($PT.session) { 'session=' + $PT.session + ' ' } else { '' }
380
+ return 'quit from the tray ' + $s + 'at=' + (Get-Date).ToUniversalTime().ToString('o')
381
+ }
382
+
383
+ # Every command started from here reads its program from the environment, so no
384
+ # path is spliced into a command line it did not come in on.
385
+ $env:PANTHEON_HOME = $PantheonHome
386
+ $env:PANTHEON_TRAY_EXE = $PT.exe
387
+ $env:PANTHEON_TRAY_ARGS = ($PT.pre -join "`n")
388
+
389
+ function global:New-PantheonStart([string[]]$words, [bool]$capture) {
390
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
391
+ $psi.FileName = $PT.exe
392
+ $psi.Arguments = ((@($PT.pre) + @($words) | ForEach-Object { Quote-Arg $_ }) -join ' ')
393
+ $psi.UseShellExecute = $false
394
+ $psi.CreateNoWindow = $true
395
+ if ($capture) {
396
+ $psi.RedirectStandardOutput = $true
397
+ $psi.RedirectStandardError = $true
398
+ $psi.StandardOutputEncoding = [Text.Encoding]::UTF8
399
+ }
400
+ return $psi
401
+ }
402
+
403
+ # A console the person reads: `pantheon <words>` in a new PowerShell window that stays open.
404
+ # The command text is fixed and passed encoded; the program comes from the environment.
405
+ function global:Get-ConsoleCommand([string]$title, [string]$words) {
406
+ return "`$Host.UI.RawUI.WindowTitle = '$title'; `$pre = @(`$env:PANTHEON_TRAY_ARGS -split ""``n"" | Where-Object { `$_ }); & `$env:PANTHEON_TRAY_EXE @pre $words"
407
+ }
408
+ function global:Open-PantheonConsole([string]$title, [string]$words) {
409
+ $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes((Get-ConsoleCommand $title $words)))
410
+ try {
411
+ Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') -ArgumentList @('-NoProfile', '-NoExit', '-EncodedCommand', $enc) | Out-Null
412
+ } catch { Write-TrayLog ('could not open a console for ' + $words + ': ' + $_.Exception.Message) }
413
+ }
414
+
415
+ # ---------------------------------------------------------------- status
416
+
417
+ function global:Read-Status([string]$json) {
418
+ $j = $json | ConvertFrom-Json
419
+ $need = 0
420
+ foreach ($i in @($j.items)) {
421
+ if ($null -eq $i) { continue }
422
+ $lane = [string]$i.lane
423
+ $kind = [string]$i.kind
424
+ # The board's cards: lane "now", or a row from an older CLI without lanes that is not a wait (menubar.swift effectiveLane).
425
+ if ($lane -eq 'now' -or (@('now', 'handling', 'waiting', 'older') -notcontains $lane -and $kind -ne 'waiting')) { $need++ }
426
+ }
427
+ return @{
428
+ loaded = $true; reachable = $true
429
+ configured = [bool]$j.configured; relayOk = [bool]$j.relay_ok
430
+ decisionsOk = ($null -eq $j.decisions_ok -or [bool]$j.decisions_ok)
431
+ member = [string]$j.member; space = [string]$j.space; spaces = @($j.spaces | Where-Object { $_ })
432
+ need = $need; workerRunning = [bool]$j.worker_running; workerInstalled = [bool]$j.worker_installed
433
+ }
434
+ }
435
+
436
+ function global:Get-Summary {
437
+ $s = $PT.state
438
+ if (-not $s.loaded) { return 'Checking status' + $PT.ellipsis }
439
+ if (-not $s.reachable) { return 'Unable to check Pantheon status' }
440
+ if (-not $s.configured) { return 'Not joined to a team yet' }
441
+ if (-not $s.relayOk) { return 'Connection unavailable' }
442
+ if (-not $s.decisionsOk) { return 'Unable to check every decision' }
443
+ if ($s.need -eq 0) { return 'Nothing needs you' }
444
+ if ($s.need -eq 1) { return '1 needs you' }
445
+ return "$($s.need) need you"
446
+ }
447
+
448
+ # A check that did not come back is not news that the relay is down: what was
449
+ # last known stands through two misses; only the third in a row is shown.
450
+ function global:Complete-Check($json) {
451
+ $PT.check = $null
452
+ $next = $null
453
+ if ($json) { try { $next = Read-Status $json } catch { $next = $null } }
454
+ $s = $PT.state
455
+ if ($null -eq $next) {
456
+ $PT.misses++
457
+ if ($s.loaded -and $s.reachable -and $PT.misses -le $PT.missesTolerated) { return $false }
458
+ $s.loaded = $true; $s.reachable = $false
459
+ } else {
460
+ $PT.misses = 0
461
+ foreach ($k in $next.Keys) { $s[$k] = $next[$k] }
462
+ }
463
+ return $true
464
+ }
465
+
466
+ # ---------------------------------------------------------------- the desktop (Windows only from here)
467
+
468
+ function global:Get-TrayIcon([int]$count, [string]$mode) {
469
+ $shown = [Math]::Min($count, 10)
470
+ $key = "$mode-$shown"
471
+ if ($PT.icons.ContainsKey($key)) { return $PT.icons[$key] }
472
+ $w = $PT.small.Width; $h = $PT.small.Height
473
+ $bmp = New-Object System.Drawing.Bitmap $w, $h
474
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
475
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
476
+ $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAliasGridFit
477
+ $g.Clear([System.Drawing.Color]::Transparent)
478
+ $base = $PT.baseIcon.ToBitmap()
479
+ if ($mode -eq 'off') {
480
+ # Faint with a slash, as the macOS mark is when the relay cannot be reached or no team is joined.
481
+ $cm = New-Object System.Drawing.Imaging.ColorMatrix
482
+ $cm.Matrix33 = 0.45
483
+ $ia = New-Object System.Drawing.Imaging.ImageAttributes
484
+ $ia.SetColorMatrix($cm)
485
+ $g.DrawImage($base, (New-Object System.Drawing.Rectangle 0, 0, $w, $h), 0, 0, $base.Width, $base.Height, [System.Drawing.GraphicsUnit]::Pixel, $ia)
486
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(230, 200, 60, 60)), ([Math]::Max(2, $w / 8))
487
+ $g.DrawLine($pen, [int]($w * 0.15), [int]($h * 0.85), [int]($w * 0.85), [int]($h * 0.15))
488
+ $pen.Dispose()
489
+ } else {
490
+ $g.DrawImage($base, 0, 0, $w, $h)
491
+ }
492
+ if ($shown -gt 0) {
493
+ # The count, on a red badge in the corner: what needs the person.
494
+ $d = [int]([Math]::Ceiling($w * 0.62))
495
+ $x = $w - $d; $y = $h - $d
496
+ $brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 214, 48, 49))
497
+ $g.FillEllipse($brush, $x, $y, $d - 1, $d - 1)
498
+ $label = if ($shown -ge 10) { '+' } else { [string]$shown }
499
+ $font = New-Object System.Drawing.Font 'Segoe UI', ([Math]::Max(6, $d * 0.62)), ([System.Drawing.FontStyle]::Bold), ([System.Drawing.GraphicsUnit]::Pixel)
500
+ $fmt = New-Object System.Drawing.StringFormat
501
+ $fmt.Alignment = [System.Drawing.StringAlignment]::Center
502
+ $fmt.LineAlignment = [System.Drawing.StringAlignment]::Center
503
+ $g.DrawString($label, $font, [System.Drawing.Brushes]::White, (New-Object System.Drawing.RectangleF $x, ($y + 0.5), $d, $d), $fmt)
504
+ $font.Dispose(); $brush.Dispose(); $fmt.Dispose()
505
+ }
506
+ $g.Dispose(); $base.Dispose()
507
+ # Drawn once per state and kept: there are few (count 0-9, 9+, three modes).
508
+ $made = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
509
+ $PT.icons[$key] = $made
510
+ return $made
511
+ }
512
+
513
+ function global:Update-Tray {
514
+ $s = $PT.state
515
+ $mode = if (-not $s.loaded) { 'idle' } elseif (-not $s.reachable -or -not $s.configured -or -not $s.relayOk) { 'off' } elseif ($s.workerRunning) { 'full' } else { 'idle' }
516
+ $count = if ($s.loaded -and $s.reachable) { $s.need } else { 0 }
517
+ $PT.tray.Icon = Get-TrayIcon $count $mode
518
+ # The tooltip holds 63 characters at most (it throws beyond that).
519
+ $tip = 'Pantheon ' + $PT.dash + ' ' + (Get-Summary)
520
+ if ($tip.Length -gt 63) { $tip = $tip.Substring(0, 62) + $PT.ellipsis }
521
+ $PT.tray.Text = $tip
522
+ }
523
+
524
+ function global:Start-Check {
525
+ try {
526
+ $p = New-Object System.Diagnostics.Process
527
+ $p.StartInfo = New-PantheonStart @('tray-status') $true
528
+ [void]$p.Start()
529
+ $PT.check = @{ process = $p; out = $p.StandardOutput.ReadToEndAsync(); err = $p.StandardError.ReadToEndAsync(); started = Get-Date }
530
+ } catch {
531
+ Write-TrayLog ('tray-status did not start: ' + $_.Exception.Message)
532
+ if (Complete-Check $null) { Update-Tray }
533
+ }
534
+ }
535
+
536
+ function global:Watch-Check {
537
+ $c = $PT.check
538
+ if ($null -eq $c) { return }
539
+ $p = $c.process
540
+ if ($p.HasExited -and $c.out.IsCompleted) {
541
+ $code = $p.ExitCode
542
+ $out = $c.out.Result
543
+ $p.Dispose()
544
+ if ($code -ne 0) { Write-TrayLog "tray-status exited $code"; $out = $null }
545
+ if (Complete-Check $out) { Update-Tray }
546
+ return
547
+ }
548
+ if (((Get-Date) - $c.started).TotalSeconds -gt $PT.checkTimeout) {
549
+ try { $p.Kill() } catch { }
550
+ Write-TrayLog ('tray-status took longer than ' + $PT.checkTimeout + ' s')
551
+ $p.Dispose()
552
+ if (Complete-Check $null) { Update-Tray }
553
+ }
554
+ }
555
+
556
+ function global:Open-Board {
557
+ # The board (pantheon board --open) starts hidden and opens the browser; a build
558
+ # without one, or a start that fails at once, falls back to the brief in a console.
559
+ try {
560
+ $p = New-Object System.Diagnostics.Process
561
+ $p.StartInfo = New-PantheonStart @('board', '--open') $false
562
+ [void]$p.Start()
563
+ $PT.board = @{ process = $p; started = Get-Date }
564
+ } catch {
565
+ Write-TrayLog ('board did not start: ' + $_.Exception.Message)
566
+ Open-PantheonConsole 'Pantheon brief' 'brief'
567
+ }
568
+ }
569
+
570
+ function global:Watch-Board {
571
+ $b = $PT.board
572
+ if ($null -eq $b) { return }
573
+ $p = $b.process
574
+ if ($p.HasExited) {
575
+ if ($p.ExitCode -ne 0) { Write-TrayLog ('board exited ' + $p.ExitCode + '; opening the brief'); Open-PantheonConsole 'Pantheon brief' 'brief' }
576
+ $p.Dispose(); $PT.board = $null
577
+ } elseif (((Get-Date) - $b.started).TotalSeconds -gt 8) {
578
+ $PT.board = $null # still serving: it started
579
+ }
580
+ }
581
+
582
+ function global:Invoke-Hidden([string[]]$words, [string]$what) {
583
+ try {
584
+ $p = [System.Diagnostics.Process]::Start((New-PantheonStart $words $false))
585
+ Write-TrayLog ($what + ': started')
586
+ $p.Dispose()
587
+ } catch { Write-TrayLog ($what + ' failed: ' + $_.Exception.Message) }
588
+ $PT.nextCheck = (Get-Date).AddSeconds(3)
589
+ }
590
+
591
+ # Quit: the host must not start the tray again in this session, so it is told
592
+ # so first. It comes back at the next logon, or with `pantheon tray`.
593
+ function global:Exit-Tray {
594
+ try {
595
+ $dir = $PT.hostDir
596
+ if (-not (Test-Path -LiteralPath $dir)) { [void](New-Item -ItemType Directory -Path $dir -Force) }
597
+ Set-Content -LiteralPath (Join-Path $dir 'tray.disabled') -Value (Get-QuitText) -Encoding UTF8
598
+ } catch { Write-TrayLog ('could not record the quit: ' + $_.Exception.Message) }
599
+ Write-TrayLog 'quit from the menu (until the next logon, or pantheon tray)'
600
+ $PT.tray.Visible = $false
601
+ $PT.quit = $true
602
+ $PT.context.ExitThread()
603
+ }
604
+
605
+ # Every alert waiting in the queue, shown from this process: a toast, or a
606
+ # balloon from this icon where toasts cannot be shown. What happened to each
607
+ # is said in alerts.log -- shown, a balloon, or not shown, never assumed.
608
+ function global:Show-QueuedNotices {
609
+ foreach ($n in (Get-QueuedNotices (Get-Date))) {
610
+ if ($n.stale) { Write-PantheonAlertLog ('tray: dropped ' + $n.about + ' (queued more than ten minutes ago)'); continue }
611
+ $r = Show-PantheonToast $n.xml $n.tag $n.aumids
612
+ if ($r.code -eq 0) { Write-PantheonAlertLog ('tray: shown ' + $n.about + ' (' + ($r.said -join ' | ') + ')'); continue }
613
+ try {
614
+ $PT.tray.ShowBalloonTip(15000, $n.title, $n.text, [System.Windows.Forms.ToolTipIcon]::Info)
615
+ Write-PantheonAlertLog ('tray: balloon for ' + $n.about + ' (toast exit ' + $r.code + ': ' + ($r.said -join ' | ') + ')')
616
+ } catch { Write-PantheonAlertLog ('tray: not shown ' + $n.about + ' (toast exit ' + $r.code + '; balloon: ' + $_.Exception.Message + ')') }
617
+ }
618
+ }
619
+
620
+ function global:Add-MenuItem([string]$text, [scriptblock]$onClick, [bool]$bold = $false, [bool]$enabled = $true) {
621
+ $item = New-Object System.Windows.Forms.ToolStripMenuItem $text
622
+ $item.Enabled = $enabled
623
+ if ($bold) { $item.Font = New-Object System.Drawing.Font $item.Font, ([System.Drawing.FontStyle]::Bold) }
624
+ if ($onClick) {
625
+ # The action rides on the item itself; one handler runs it.
626
+ $item.Tag = $onClick
627
+ $item.add_Click({ param($s, $e) try { & $s.Tag } catch { Write-TrayLog ('menu: ' + $_.Exception.Message) } })
628
+ }
629
+ [void]$PT.menu.Items.Add($item)
630
+ }
631
+
632
+ function global:Build-Menu {
633
+ $menu = $PT.menu
634
+ $s = $PT.state
635
+ $menu.Items.Clear()
636
+ $who = @()
637
+ if ($s.member) { $who += $s.member }
638
+ if (@($s.spaces).Count -gt 1) { $who += "$(@($s.spaces).Count) teams" } elseif ($s.space) { $who += $s.space }
639
+ $head = Get-Summary
640
+ if ($who.Count) { $head = $head + ' ' + $PT.dot + ' ' + ($who -join (' ' + $PT.dot + ' ')) }
641
+ Add-MenuItem $head $null $false $false
642
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
643
+ Add-MenuItem 'Open board' { Open-Board } $true
644
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
645
+ if ($s.workerRunning) { Add-MenuItem 'Pause unattended work' { Invoke-Hidden $PT.pauseArgs 'pause worker' } }
646
+ elseif ($s.workerInstalled) { Add-MenuItem 'Resume unattended work' { Invoke-Hidden $PT.resumeArgs 'resume worker' } }
647
+ else { Add-MenuItem 'Unattended work is not set up' $null $false $false }
648
+ Add-MenuItem 'Status' { Open-PantheonConsole 'Pantheon status' 'status' }
649
+ Add-MenuItem 'Doctor' { Open-PantheonConsole 'Pantheon doctor' 'doctor' }
650
+ Add-MenuItem 'Refresh now' { $PT.nextCheck = Get-Date }
651
+ [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
652
+ Add-MenuItem 'Quit tray (until you next log in)' { Exit-Tray }
653
+ }
654
+
655
+ # A test loads the functions above without a desktop: it stops here.
656
+ if ($env:PANTHEON_TRAY_NO_UI -eq '1') { return }
657
+
658
+ # No WinForms here (Server Core, a policy that removes it): starting again
659
+ # every minute would fail every minute. Exit 64 tells the host to leave the
660
+ # tray stopped until the next logon (win-host.ts GIVE_UP_CODE).
661
+ try {
662
+ Add-Type -AssemblyName System.Windows.Forms
663
+ Add-Type -AssemblyName System.Drawing
664
+ } catch {
665
+ Write-TrayLog ('no tray here: ' + $_.Exception.Message)
666
+ exit 64
667
+ }
668
+ [System.Windows.Forms.Application]::EnableVisualStyles()
669
+
670
+ # One tray per Pantheon home: a second start (a restart racing a login) leaves quietly.
671
+ $sha = [System.Security.Cryptography.SHA256]::Create()
672
+ $homeId = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($PantheonHome.ToLowerInvariant())) | Select-Object -First 8 | ForEach-Object { $_.ToString('x2') })
673
+ $createdNew = $false
674
+ $PT.mutex = [System.Threading.Mutex]::new($true, "Local\PantheonTray-$homeId", [ref]$createdNew)
675
+ if (-not $createdNew) { exit 0 }
676
+
677
+ $PT.small = [System.Windows.Forms.SystemInformation]::SmallIconSize
678
+ if ($PT.iconPath -and (Test-Path -LiteralPath $PT.iconPath)) { $PT.baseIcon = New-Object System.Drawing.Icon -ArgumentList $PT.iconPath, $PT.small }
679
+ else { $PT.baseIcon = [System.Drawing.SystemIcons]::Application }
680
+
681
+ $PT.tray = New-Object System.Windows.Forms.NotifyIcon
682
+ $PT.tray.Icon = Get-TrayIcon 0 'idle'
683
+ $PT.tray.Text = 'Pantheon'
684
+ $PT.menu = New-Object System.Windows.Forms.ContextMenuStrip
685
+ $PT.menu.add_Opening({ try { Build-Menu } catch { Write-TrayLog ('menu: ' + $_.Exception.Message) } })
686
+ Build-Menu
687
+ $PT.tray.ContextMenuStrip = $PT.menu
688
+ # A left click opens the board; the right click shows the menu.
689
+ $PT.tray.add_MouseClick({
690
+ param($s, $e)
691
+ if ($e.Button -eq [System.Windows.Forms.MouseButtons]::Left) { try { Open-Board } catch { Write-TrayLog ('click: ' + $_.Exception.Message) } }
692
+ })
693
+ # A click on an alert's balloon opens the board, as a click on its toast does.
694
+ $PT.tray.add_BalloonTipClicked({ try { Open-Board } catch { Write-TrayLog ('balloon click: ' + $_.Exception.Message) } })
695
+ $PT.timer = New-Object System.Windows.Forms.Timer
696
+ $PT.timer.Interval = 500
697
+ $PT.timer.add_Tick({
698
+ try {
699
+ # Asked to close by the host: the icon goes with this process, not after it.
700
+ if (Test-StopRequest) { Write-TrayLog 'asked to close by the Pantheon host'; $PT.timer.Stop(); $PT.tray.Visible = $false; $PT.context.ExitThread(); return }
701
+ # Alerts: polled every half second. Not a FileSystemWatcher: its events
702
+ # arrive on a thread with no PowerShell runspace, which ends the process.
703
+ if ($PT.notices) { Show-QueuedNotices }
704
+ Watch-Check
705
+ Watch-Board
706
+ if ($null -eq $PT.check -and (Get-Date) -ge $PT.nextCheck) {
707
+ $PT.nextCheck = (Get-Date).AddSeconds($PT.poll)
708
+ Start-Check
709
+ }
710
+ } catch { Write-TrayLog ('tick: ' + $_.Exception.Message) }
711
+ })
712
+
713
+ $PT.context = New-Object System.Windows.Forms.ApplicationContext
714
+ $PT.tray.Visible = $true
715
+ Update-Tray
716
+ $PT.timer.Start()
717
+ Write-TrayLog ('tray started (home ' + $PantheonHome + ', every ' + $Poll + ' s)')
718
+ $failed = $false
719
+ try {
720
+ [System.Windows.Forms.Application]::Run($PT.context)
721
+ } catch {
722
+ Write-TrayLog ('tray stopped: ' + $_.Exception.Message)
723
+ $failed = $true
724
+ } finally {
725
+ $PT.timer.Stop()
726
+ $PT.tray.Visible = $false
727
+ $PT.tray.Dispose()
728
+ try { $PT.mutex.ReleaseMutex() } catch { }
729
+ }
730
+ if ($failed) { exit 1 }
731
+ exit 0