@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.
- package/dist/accounts.js +1 -1
- package/dist/board.js +1417 -0
- package/dist/claude-config.js +189 -0
- package/dist/cli.js +351 -101
- package/dist/client.js +16 -0
- package/dist/command-state.js +2 -1
- package/dist/dbus.js +92 -13
- package/dist/doctor.js +36 -5
- package/dist/folder-guard.js +89 -0
- package/dist/git-safe.js +3 -3
- package/dist/host-env.js +47 -1
- package/dist/install.js +76 -10
- package/dist/installer.js +2 -1
- package/dist/ledger.js +2 -2
- package/dist/machine.js +174 -3
- package/dist/managed.js +27 -1
- package/dist/notice-linux.js +501 -0
- package/dist/notice-windows.js +323 -0
- package/dist/notifier.js +12 -3
- package/dist/quickstart.js +28 -11
- package/dist/relay.js +64 -9
- package/dist/service.js +455 -29
- package/dist/setup-guide.js +22 -5
- package/dist/store.js +18 -2
- package/dist/uninstall.js +1 -1
- package/dist/upgrade.js +249 -4
- package/dist/uri-handler.js +335 -0
- package/dist/win-host.js +993 -0
- package/dist/worker-policy.js +3 -0
- package/dist/worker.js +11 -11
- package/helper/macos/prebuilt/manifest.json +2 -2
- package/helper/macos/prebuilt/pantheon-hud +0 -0
- package/helper/windows/pantheon-host.cs +170 -0
- package/helper/windows/pantheon-notice.ps1 +127 -0
- package/helper/windows/pantheon-tray.ps1 +731 -0
- package/helper/windows/pantheon.ico +0 -0
- package/installer/install.ps1 +238 -19
- package/installer/install.sh +36 -1
- package/package.json +2 -2
|
@@ -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('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"').Replace("'", ''')
|
|
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
|