staysfixed 0.3.1 → 0.6.0
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/CHANGELOG.md +159 -3
- package/README.md +611 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +643 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +734 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +931 -0
- package/src/v2/adapters/source.js +1292 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +371 -0
- package/src/v2/check.js +1429 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +670 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1124 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1702 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +500 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +938 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +910 -0
- package/src/v2/run.js +1080 -0
- package/src/v2/sealed.js +568 -0
- package/src/v2/selfcheck.js +729 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +509 -0
- package/src/v2/waiver.js +511 -0
- package/src/v2/watch/focus.js +215 -0
|
@@ -0,0 +1,1329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native Windows applications, driven over the machine somebody already has.
|
|
3
|
+
*
|
|
4
|
+
* ── WHAT WAS FOUND BEFORE ANY OF THIS WAS WRITTEN ──────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The design costed this platform at a week and a half and assumed a small .NET probe built on
|
|
7
|
+
* FlaUI, shipped as one executable. That was checked against a real Windows 11 machine on
|
|
8
|
+
* 2026-08-29 before a line was written, and it is the wrong answer. Three measurements settled it.
|
|
9
|
+
*
|
|
10
|
+
* 1. The machine has the .NET 8 RUNTIME but no SDK. A FlaUI probe could not be built there,
|
|
11
|
+
* so it would have to be cross-built elsewhere and copied over as an unsigned executable —
|
|
12
|
+
* which is the definition of "installing something on somebody's machine", and which
|
|
13
|
+
* SmartScreen is entitled to block.
|
|
14
|
+
* 2. Windows PowerShell 5.1 is already there, and it can load `UIAutomationClient` from the
|
|
15
|
+
* .NET Framework that ships with the operating system. That is the same UI Automation that
|
|
16
|
+
* FlaUI wraps. It read a real File Explorer window — 148 controls with names, roles, states
|
|
17
|
+
* and rectangles — in 303 milliseconds. Nothing was installed to do it.
|
|
18
|
+
* 3. So the probe is PowerShell, sent down the connection at the start of a run, living only
|
|
19
|
+
* in the memory of the process running it, leaving nothing behind. FlaUI remains the honest
|
|
20
|
+
* upgrade path if a window ever turns out to be too big for this to read quickly, and that
|
|
21
|
+
* is a decision to make with a measurement in hand rather than in advance.
|
|
22
|
+
*
|
|
23
|
+
* ── WHAT IT WATCHES ────────────────────────────────────────────────────────────────────────
|
|
24
|
+
*
|
|
25
|
+
* meaning The window tree from UI Automation: what each control IS (a button, a checkbox,
|
|
26
|
+
* a list), what it is CALLED, its automation id, whether it is on, off, checked,
|
|
27
|
+
* expanded or selected, and what it says it can DO. This is the channel that
|
|
28
|
+
* answers "what does the screen say this control now does", and it is the reason
|
|
29
|
+
* the platform is worth covering at all.
|
|
30
|
+
* effects Programs it started, and files that changed in the folders it was told to watch.
|
|
31
|
+
* complaints Windows Error Reporting entries, application hangs, and anything the program
|
|
32
|
+
* logged to the Windows event log while it was running. Plus whether it exited.
|
|
33
|
+
* results What a console program printed, and the titles of the windows it opened.
|
|
34
|
+
* counters How many windows, how many controls, and how long things took, all in buckets.
|
|
35
|
+
* pixels A picture of each window, as evidence for something another channel already found.
|
|
36
|
+
*
|
|
37
|
+
* ── WHAT IT CANNOT DO, SAID PLAINLY ────────────────────────────────────────────────────────
|
|
38
|
+
*
|
|
39
|
+
* TWO BUILDS CANNOT RUN AT ONCE. Not "should not" — cannot, in principle. UI Automation reads
|
|
40
|
+
* whatever desktop is in front of it, and Windows has one. So runs are strictly sequential, and
|
|
41
|
+
* the same-machine guarantee is weaker here than anywhere else: the desktop is shared with
|
|
42
|
+
* whatever else that person has open, and a notification popping up during a run is a real
|
|
43
|
+
* source of difference that no amount of freezing removes. The wobble measurement absorbs some
|
|
44
|
+
* of it. It does not absorb all of it, and this adapter reports a run on a busy desktop as
|
|
45
|
+
* exactly that.
|
|
46
|
+
*
|
|
47
|
+
* THERE IS NO SAFETY BOUNDARY AT THE WIRE. The CLI adapter can watch a program ask to reach the
|
|
48
|
+
* internet and refuse it, because it loads a watcher inside a Node child. There is no equivalent
|
|
49
|
+
* for a compiled Windows application without administrator rights, and the account this runs
|
|
50
|
+
* under does not have them. So a journey marked irreversible is REFUSED OUTRIGHT here rather
|
|
51
|
+
* than walked carefully — it is reported as missing coverage, and it never runs.
|
|
52
|
+
*
|
|
53
|
+
* FILES AND NETWORK ARE SAMPLED, NOT CAPTURED. Everything that would really capture them —
|
|
54
|
+
* Process Monitor, an ETW kernel session, pktmon — needs administrator. What is left works and
|
|
55
|
+
* is worth having: the folders this adapter is told to watch are compared before and after, and
|
|
56
|
+
* connections belonging to the program are sampled while it runs. A file written outside those
|
|
57
|
+
* folders is not seen. A connection that opens and closes between two samples is not seen. Both
|
|
58
|
+
* are reported as holes, in those words, and never as "it did not do anything".
|
|
59
|
+
*
|
|
60
|
+
* MOST WINDOWS PRODUCTS DO NOT NEED THIS. If the Windows build is Electron — and most desktop
|
|
61
|
+
* products are, including the one this tool was written alongside — it is already covered from
|
|
62
|
+
* any machine over its debug port by the Electron adapter, in full, with two builds able to run
|
|
63
|
+
* side by side. This adapter DECLINES a Chromium window on purpose and says where to go instead.
|
|
64
|
+
* Reading an Electron window through UI Automation would also switch on Chromium's accessibility
|
|
65
|
+
* engine, which changes the timing and behaviour of the very thing being measured.
|
|
66
|
+
*
|
|
67
|
+
* AN EMPTY TREE IS NEVER A PASS. A minimised window on that machine returned zero controls from
|
|
68
|
+
* the fast cached read while a plain tree walk still found children in it. A tool that reported
|
|
69
|
+
* that as "this window has no controls" would compare zero against zero on the next run and call
|
|
70
|
+
* it unchanged. So every read is cross-checked, and a tree that comes back empty or suddenly
|
|
71
|
+
* much smaller is recorded as unchecked with the reason attached.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import fsp from 'node:fs/promises';
|
|
75
|
+
import path from 'node:path';
|
|
76
|
+
import { spawn } from 'node:child_process';
|
|
77
|
+
import {
|
|
78
|
+
countBucket, defineAdapter, joinPath, notCovered, observation, sizeBucket, timeBucket,
|
|
79
|
+
trimForStorage,
|
|
80
|
+
} from './contract.js';
|
|
81
|
+
import { RemoteLinkLost, remoteRunner } from '../remote.js';
|
|
82
|
+
|
|
83
|
+
/** @typedef {import('./contract.js').Build} Build */
|
|
84
|
+
/** @typedef {import('./contract.js').PreparedBuild} PreparedBuild */
|
|
85
|
+
/** @typedef {import('./contract.js').RunContext} RunContext */
|
|
86
|
+
/** @typedef {import('./contract.js').AdapterProject} AdapterProject */
|
|
87
|
+
/** @typedef {import('./contract.js').Detection} Detection */
|
|
88
|
+
/** @typedef {import('./contract.js').Missing} Missing */
|
|
89
|
+
/** @typedef {import('../types.js').Journey} Journey */
|
|
90
|
+
/** @typedef {import('../types.js').Observation} Observation */
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Window classes that mean "this is not really a native Windows app".
|
|
94
|
+
*
|
|
95
|
+
* Chromium-based shells all report one of these. Seeing one is not a failure — it is the
|
|
96
|
+
* adapter finding out that a better tool for the job already exists and saying so.
|
|
97
|
+
*/
|
|
98
|
+
export const CHROMIUM_CLASSES = ['Chrome_WidgetWin_0', 'Chrome_WidgetWin_1'];
|
|
99
|
+
|
|
100
|
+
/** How long to let an app get its first window up before calling it a no-show. */
|
|
101
|
+
const WINDOW_WAIT_MS = 20_000;
|
|
102
|
+
|
|
103
|
+
/** Controls past this many, and the tree is stored as a summary with a fingerprint instead. */
|
|
104
|
+
const MAX_TREE_NODES = 4000;
|
|
105
|
+
|
|
106
|
+
/** A window picture bigger than this is dropped rather than carried back inline. */
|
|
107
|
+
const MAX_SHOT_BYTES = 4 * 1024 * 1024;
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// The probe
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The PowerShell program that runs on the Windows side for the length of one run.
|
|
115
|
+
*
|
|
116
|
+
* It is a request-and-reply loop over standard input and standard output, one JSON object per
|
|
117
|
+
* line, each reply carrying the sentinel the transport looks for. It is written as one string
|
|
118
|
+
* here rather than kept as a .ps1 file for one reason that matters: nothing is ever written to
|
|
119
|
+
* that machine's disk, so there is nothing to leave behind, nothing to go stale, and nothing
|
|
120
|
+
* for the person who owns the machine to find later and wonder about.
|
|
121
|
+
*
|
|
122
|
+
* Two rules are enforced inside the probe itself rather than in JavaScript, because this is the
|
|
123
|
+
* only side that can enforce them:
|
|
124
|
+
*
|
|
125
|
+
* - It only ever stops a process it started. `$ours` is the whole list, and `stop` checks it.
|
|
126
|
+
* Somebody's real work is on that desktop.
|
|
127
|
+
* - Every tree read is taken twice through two different mechanisms — the fast cached read and
|
|
128
|
+
* a plain walker count — and reports both. Disagreement is what tells the engine the read
|
|
129
|
+
* was not trustworthy, and it is the only defence against a confidently empty answer.
|
|
130
|
+
*
|
|
131
|
+
* @returns {string} PowerShell, ready to be base64ed onto the wire
|
|
132
|
+
*/
|
|
133
|
+
export function windowsProbeScript() {
|
|
134
|
+
return `
|
|
135
|
+
$ErrorActionPreference = 'Stop'
|
|
136
|
+
[Console]::OutputEncoding = [Text.Encoding]::UTF8
|
|
137
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
138
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
139
|
+
Add-Type -AssemblyName System.Drawing
|
|
140
|
+
Add-Type -AssemblyName System.Windows.Forms
|
|
141
|
+
Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public struct SFRect { public int L, T, R, B; } public class SFNative { [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr dc, uint flags); [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out SFRect r); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); }'
|
|
142
|
+
|
|
143
|
+
$AE = [System.Windows.Automation.AutomationElement]
|
|
144
|
+
$TC = [System.Windows.Automation.Condition]::TrueCondition
|
|
145
|
+
$ours = New-Object 'System.Collections.Generic.HashSet[int]'
|
|
146
|
+
|
|
147
|
+
function Emit($o) {
|
|
148
|
+
[Console]::Out.WriteLine('#SF#' + ($o | ConvertTo-Json -Compress -Depth 20))
|
|
149
|
+
[Console]::Out.Flush()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function CacheFor() {
|
|
153
|
+
$cr = New-Object System.Windows.Automation.CacheRequest
|
|
154
|
+
foreach ($p in @($AE::NameProperty, $AE::ControlTypeProperty, $AE::AutomationIdProperty,
|
|
155
|
+
$AE::ClassNameProperty, $AE::IsEnabledProperty, $AE::IsOffscreenProperty,
|
|
156
|
+
$AE::BoundingRectangleProperty, $AE::HelpTextProperty,
|
|
157
|
+
$AE::IsKeyboardFocusableProperty, $AE::HasKeyboardFocusProperty)) { $cr.Add($p) }
|
|
158
|
+
$cr.TreeScope = [System.Windows.Automation.TreeScope]::Subtree
|
|
159
|
+
$cr.TreeFilter = [System.Windows.Automation.Automation]::ControlViewCondition
|
|
160
|
+
return $cr
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function StateOf($el) {
|
|
164
|
+
$bits = @()
|
|
165
|
+
try { $t = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.TogglePattern]::Pattern, [ref] $t)) { $bits += 'toggle=' + $t.Current.ToggleState } } catch {}
|
|
166
|
+
try { $x = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.ExpandCollapsePattern]::Pattern, [ref] $x)) { $bits += 'expand=' + $x.Current.ExpandCollapseState } } catch {}
|
|
167
|
+
try { $s = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.SelectionItemPattern]::Pattern, [ref] $s)) { $bits += 'selected=' + $s.Current.IsSelected } } catch {}
|
|
168
|
+
try { $v = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref] $v)) { $bits += 'value=' + $v.Current.Value; $bits += 'readonly=' + $v.Current.IsReadOnly } } catch {}
|
|
169
|
+
try { $r = $null; if ($el.TryGetCurrentPattern([System.Windows.Automation.RangeValuePattern]::Pattern, [ref] $r)) { $bits += 'range=' + $r.Current.Value } } catch {}
|
|
170
|
+
return ($bits -join ' ')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function CanDo($el) {
|
|
174
|
+
$names = @()
|
|
175
|
+
try { foreach ($p in $el.GetSupportedPatterns()) { $names += $p.ProgrammaticName.Replace('PatternIdentifiers.Pattern', '') } } catch {}
|
|
176
|
+
return ($names | Sort-Object)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function WalkCount($el) {
|
|
180
|
+
$w = [System.Windows.Automation.TreeWalker]::ControlViewWalker
|
|
181
|
+
$n = 0
|
|
182
|
+
$stack = New-Object 'System.Collections.Generic.Stack[object]'
|
|
183
|
+
$stack.Push($el)
|
|
184
|
+
while ($stack.Count -gt 0 -and $n -lt 20000) {
|
|
185
|
+
$cur = $stack.Pop()
|
|
186
|
+
$n++
|
|
187
|
+
try { $c = $w.GetFirstChild($cur); while ($c -ne $null) { $stack.Push($c); $c = $w.GetNextSibling($c) } } catch {}
|
|
188
|
+
}
|
|
189
|
+
return $n
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function TopWindows($processId) {
|
|
193
|
+
$out = @()
|
|
194
|
+
foreach ($k in $AE::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, $TC)) {
|
|
195
|
+
try {
|
|
196
|
+
$c = $k.Current
|
|
197
|
+
if ($processId -ge 0 -and $c.ProcessId -ne $processId) { continue }
|
|
198
|
+
$h = [IntPtr] $c.NativeWindowHandle
|
|
199
|
+
$out += @{ name = $c.Name; cls = $c.ClassName; pid = $c.ProcessId; hwnd = [int64] $c.NativeWindowHandle;
|
|
200
|
+
type = $c.ControlType.ProgrammaticName; visible = [SFNative]::IsWindowVisible($h) }
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
return $out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function FindByHandle($hwnd) {
|
|
207
|
+
$cond = New-Object System.Windows.Automation.PropertyCondition($AE::NativeWindowHandleProperty, [int] $hwnd)
|
|
208
|
+
return $AE::RootElement.FindFirst([System.Windows.Automation.TreeScope]::Children, $cond)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function ReadTree($win, $limit) {
|
|
212
|
+
$cr = CacheFor
|
|
213
|
+
$act = $cr.Activate()
|
|
214
|
+
try { $set = $win.FindAll([System.Windows.Automation.TreeScope]::Subtree, $TC) } finally { $act.Dispose() }
|
|
215
|
+
$nodes = @()
|
|
216
|
+
$i = 0
|
|
217
|
+
foreach ($e in $set) {
|
|
218
|
+
if ($i -ge $limit) { break }
|
|
219
|
+
$i++
|
|
220
|
+
$cc = $e.Cached
|
|
221
|
+
$r = $cc.BoundingRectangle
|
|
222
|
+
$nodes += @{
|
|
223
|
+
type = $cc.ControlType.ProgrammaticName.Replace('ControlType.', '')
|
|
224
|
+
name = $cc.Name
|
|
225
|
+
aid = $cc.AutomationId
|
|
226
|
+
cls = $cc.ClassName
|
|
227
|
+
help = $cc.HelpText
|
|
228
|
+
on = $cc.IsEnabled
|
|
229
|
+
hidden = $cc.IsOffscreen
|
|
230
|
+
focusable = $cc.IsKeyboardFocusable
|
|
231
|
+
focused = $cc.HasKeyboardFocus
|
|
232
|
+
w = [int] $r.Width
|
|
233
|
+
h = [int] $r.Height
|
|
234
|
+
state = (StateOf $e)
|
|
235
|
+
can = (CanDo $e)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return @{ nodes = $nodes; cached = $set.Count; walked = (WalkCount $win) }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function Shot($hwnd) {
|
|
242
|
+
$h = [IntPtr] $hwnd
|
|
243
|
+
$r = New-Object SFRect
|
|
244
|
+
[void] [SFNative]::GetWindowRect($h, [ref] $r)
|
|
245
|
+
$w = $r.R - $r.L; $ht = $r.B - $r.T
|
|
246
|
+
if ($w -le 0 -or $ht -le 0) { return @{ ok = $false; why = 'that window has no size on screen right now' } }
|
|
247
|
+
$bmp = New-Object System.Drawing.Bitmap $w, $ht
|
|
248
|
+
$g = [System.Drawing.Graphics]::FromImage($bmp)
|
|
249
|
+
$dc = $g.GetHdc()
|
|
250
|
+
$ok = [SFNative]::PrintWindow($h, $dc, 2)
|
|
251
|
+
$g.ReleaseHdc($dc); $g.Dispose()
|
|
252
|
+
$lit = 0
|
|
253
|
+
$rand = New-Object Random 7
|
|
254
|
+
for ($i = 0; $i -lt 200; $i++) { $px = $bmp.GetPixel($rand.Next($w), $rand.Next($ht)); if ($px.R + $px.G + $px.B -gt 30) { $lit++ } }
|
|
255
|
+
$ms = New-Object System.IO.MemoryStream
|
|
256
|
+
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
257
|
+
$bmp.Dispose()
|
|
258
|
+
return @{ ok = $ok; w = $w; h = $ht; lit = $lit; bytes = $ms.Length; png = [Convert]::ToBase64String($ms.ToArray()) }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function SnapDir($dir) {
|
|
262
|
+
$files = @{}
|
|
263
|
+
if (-not (Test-Path $dir)) { return $files }
|
|
264
|
+
foreach ($f in (Get-ChildItem -Path $dir -Recurse -File -Force -ErrorAction SilentlyContinue | Select-Object -First 5000)) {
|
|
265
|
+
$files[$f.FullName.Substring($dir.Length).TrimStart('\\')] = $f.Length
|
|
266
|
+
}
|
|
267
|
+
return $files
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
$os = Get-CimInstance Win32_OperatingSystem
|
|
271
|
+
$vs = [System.Windows.Forms.SystemInformation]::VirtualScreen
|
|
272
|
+
Emit @{
|
|
273
|
+
id = 'hello'; ok = $true; kind = 'windows'
|
|
274
|
+
host = $env:COMPUTERNAME; user = $env:USERNAME
|
|
275
|
+
windows = $os.Caption + ' ' + $os.Version
|
|
276
|
+
ps = $PSVersionTable.PSVersion.ToString()
|
|
277
|
+
session = (Get-Process -Id $PID).SessionId
|
|
278
|
+
locked = [bool] (Get-Process LogonUI -ErrorAction SilentlyContinue)
|
|
279
|
+
loggedIn = [bool] (Get-Process explorer -ErrorAction SilentlyContinue)
|
|
280
|
+
screens = [System.Windows.Forms.Screen]::AllScreens.Count
|
|
281
|
+
screen = '' + $vs.Width + 'x' + $vs.Height
|
|
282
|
+
temp = $env:TEMP
|
|
283
|
+
admin = (New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
while ($true) {
|
|
287
|
+
$line = [Console]::In.ReadLine()
|
|
288
|
+
if ($null -eq $line) { break }
|
|
289
|
+
if ($line.Trim() -eq '') { continue }
|
|
290
|
+
try { $req = $line | ConvertFrom-Json } catch { Emit @{ id = '?'; ok = $false; error = 'that was not readable json' }; continue }
|
|
291
|
+
$watch = [Diagnostics.Stopwatch]::StartNew()
|
|
292
|
+
try {
|
|
293
|
+
switch ($req.op) {
|
|
294
|
+
|
|
295
|
+
'bye' { Emit @{ id = $req.id; ok = $true }; exit 0 }
|
|
296
|
+
|
|
297
|
+
'ping' { Emit @{ id = $req.id; ok = $true; ms = $watch.ElapsedMilliseconds } }
|
|
298
|
+
|
|
299
|
+
'launch' {
|
|
300
|
+
$si = @{ FilePath = $req.exe; PassThru = $true }
|
|
301
|
+
if ($req.args) { $si.ArgumentList = $req.args }
|
|
302
|
+
if ($req.cwd) { $si.WorkingDirectory = $req.cwd }
|
|
303
|
+
$before = @(Get-CimInstance Win32_Process -Property ProcessId | ForEach-Object { $_.ProcessId })
|
|
304
|
+
$p = Start-Process @si
|
|
305
|
+
[void] $ours.Add($p.Id)
|
|
306
|
+
Emit @{ id = $req.id; ok = $true; pid = $p.Id; before = $before.Count; startedAt = (Get-Date).ToString('o'); ms = $watch.ElapsedMilliseconds }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
'windows' { $w = TopWindows ([int] $req.pid); Emit @{ id = $req.id; ok = $true; windows = $w; count = $w.Count; ms = $watch.ElapsedMilliseconds } }
|
|
310
|
+
|
|
311
|
+
'tree' {
|
|
312
|
+
$win = FindByHandle $req.hwnd
|
|
313
|
+
if ($null -eq $win) { Emit @{ id = $req.id; ok = $false; error = 'that window is not on the desktop any more' }; break }
|
|
314
|
+
$limit = if ($req.limit) { [int] $req.limit } else { 4000 }
|
|
315
|
+
$t = ReadTree $win $limit
|
|
316
|
+
Emit @{ id = $req.id; ok = $true; nodes = $t.nodes; cached = $t.cached; walked = $t.walked; ms = $watch.ElapsedMilliseconds }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
'settle' {
|
|
320
|
+
$win = FindByHandle $req.hwnd
|
|
321
|
+
if ($null -eq $win) { Emit @{ id = $req.id; ok = $false; error = 'that window is not on the desktop any more' }; break }
|
|
322
|
+
$limit = if ($req.limit) { [int] $req.limit } else { 4000 }
|
|
323
|
+
$tries = if ($req.tries) { [int] $req.tries } else { 6 }
|
|
324
|
+
$gap = if ($req.gapMs) { [int] $req.gapMs } else { 250 }
|
|
325
|
+
$last = $null; $lastKey = ''; $agreed = $false; $n = 0
|
|
326
|
+
while ($n -lt $tries) {
|
|
327
|
+
$n++
|
|
328
|
+
$t = ReadTree $win $limit
|
|
329
|
+
$key = ($t.nodes | ForEach-Object { $_.type + '/' + $_.name + '/' + $_.aid + '/' + $_.on + '/' + $_.state }) -join '|'
|
|
330
|
+
if ($key -eq $lastKey -and $n -gt 1) { $agreed = $true; $last = $t; break }
|
|
331
|
+
$lastKey = $key; $last = $t
|
|
332
|
+
Start-Sleep -Milliseconds $gap
|
|
333
|
+
}
|
|
334
|
+
Emit @{ id = $req.id; ok = $true; agreed = $agreed; reads = $n; nodes = $last.nodes; cached = $last.cached; walked = $last.walked; ms = $watch.ElapsedMilliseconds }
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
'shot' { $s = Shot $req.hwnd; $s.id = $req.id; $s.ms = $watch.ElapsedMilliseconds; Emit $s }
|
|
338
|
+
|
|
339
|
+
'spawned' {
|
|
340
|
+
$rows = @()
|
|
341
|
+
foreach ($p in (Get-CimInstance Win32_Process -Property ProcessId, ParentProcessId, Name, CommandLine, CreationDate)) {
|
|
342
|
+
if ($ours.Contains([int] $p.ParentProcessId) -or $ours.Contains([int] $p.ProcessId)) {
|
|
343
|
+
$rows += @{ name = $p.Name; parent = $p.ParentProcessId; pid = $p.ProcessId; cmd = $p.CommandLine }
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
Emit @{ id = $req.id; ok = $true; procs = $rows; ms = $watch.ElapsedMilliseconds }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
'net' {
|
|
350
|
+
$rows = @()
|
|
351
|
+
try {
|
|
352
|
+
foreach ($c in (Get-NetTCPConnection -ErrorAction SilentlyContinue)) {
|
|
353
|
+
if (-not $ours.Contains([int] $c.OwningProcess)) { continue }
|
|
354
|
+
$rows += @{ remote = $c.RemoteAddress; port = $c.RemotePort; state = '' + $c.State }
|
|
355
|
+
}
|
|
356
|
+
Emit @{ id = $req.id; ok = $true; conns = $rows; sampled = $true; ms = $watch.ElapsedMilliseconds }
|
|
357
|
+
} catch { Emit @{ id = $req.id; ok = $false; error = 'connections could not be listed on this account' } }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
'snap' {
|
|
361
|
+
$out = @{}
|
|
362
|
+
foreach ($d in $req.dirs) { $out[$d] = (SnapDir $d) }
|
|
363
|
+
Emit @{ id = $req.id; ok = $true; dirs = $out; ms = $watch.ElapsedMilliseconds }
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
'events' {
|
|
367
|
+
$since = [DateTime]::Parse($req.since)
|
|
368
|
+
$rows = @()
|
|
369
|
+
foreach ($log in @('Application', 'System')) {
|
|
370
|
+
try {
|
|
371
|
+
foreach ($e in (Get-WinEvent -FilterHashtable @{ LogName = $log; StartTime = $since } -MaxEvents 200 -ErrorAction SilentlyContinue)) {
|
|
372
|
+
if ($e.LevelDisplayName -eq 'Information') { continue }
|
|
373
|
+
$rows += @{ log = $log; id = $e.Id; level = $e.LevelDisplayName; source = $e.ProviderName; text = ('' + $e.Message).Substring(0, [Math]::Min(400, ('' + $e.Message).Length)) }
|
|
374
|
+
}
|
|
375
|
+
} catch {}
|
|
376
|
+
}
|
|
377
|
+
Emit @{ id = $req.id; ok = $true; events = $rows; ms = $watch.ElapsedMilliseconds }
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
'alive' {
|
|
381
|
+
$p = Get-Process -Id ([int] $req.pid) -ErrorAction SilentlyContinue
|
|
382
|
+
Emit @{ id = $req.id; ok = $true; running = ($null -ne $p); exit = $(if ($p) { $null } else { 'gone' }); ms = $watch.ElapsedMilliseconds }
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
'stop' {
|
|
386
|
+
$target = [int] $req.pid
|
|
387
|
+
if (-not $ours.Contains($target)) {
|
|
388
|
+
Emit @{ id = $req.id; ok = $false; error = 'refusing to stop a process this run did not start' }
|
|
389
|
+
break
|
|
390
|
+
}
|
|
391
|
+
$p = Get-Process -Id $target -ErrorAction SilentlyContinue
|
|
392
|
+
$code = $null
|
|
393
|
+
if ($p) {
|
|
394
|
+
[void] $p.CloseMainWindow()
|
|
395
|
+
if (-not $p.WaitForExit(4000)) { Stop-Process -Id $target -Force -ErrorAction SilentlyContinue; $code = 'forced' }
|
|
396
|
+
else { $code = $p.ExitCode }
|
|
397
|
+
}
|
|
398
|
+
[void] $ours.Remove($target)
|
|
399
|
+
Emit @{ id = $req.id; ok = $true; exit = $code; ms = $watch.ElapsedMilliseconds }
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
default { Emit @{ id = $req.id; ok = $false; error = 'nothing here knows how to do ' + $req.op } }
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
Emit @{ id = $req.id; ok = $false; error = ('' + $_.Exception.Message) }
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
`;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Turning replies into observations
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* @typedef {object} TreeNode
|
|
417
|
+
* @property {string} type
|
|
418
|
+
* @property {string} name
|
|
419
|
+
* @property {string} aid
|
|
420
|
+
* @property {string} cls
|
|
421
|
+
* @property {boolean} on
|
|
422
|
+
* @property {boolean} hidden
|
|
423
|
+
* @property {boolean} [focused]
|
|
424
|
+
* @property {number} w
|
|
425
|
+
* @property {number} h
|
|
426
|
+
* @property {string} state
|
|
427
|
+
* @property {string[]} can
|
|
428
|
+
*/
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The address one control lives at.
|
|
432
|
+
*
|
|
433
|
+
* Built from what the control IS and what it is CALLED, never from where it sits in the tree.
|
|
434
|
+
* An index would be stable right up until somebody adds a control above it, at which point
|
|
435
|
+
* every control below would report as changed and the real change would be buried in the noise.
|
|
436
|
+
* Where a control has neither a name nor an automation id — and classic Windows dialogs are
|
|
437
|
+
* full of those — the numeric automation id is used, which is what those dialogs actually have.
|
|
438
|
+
*
|
|
439
|
+
* @param {TreeNode} node
|
|
440
|
+
* @param {number} index Only used when a control has no identity of its own.
|
|
441
|
+
* @returns {string}
|
|
442
|
+
*/
|
|
443
|
+
export function controlAddress(node, index) {
|
|
444
|
+
const called = node.aid || node.name;
|
|
445
|
+
if (called) return `${node.type.toLowerCase()}:${called}`;
|
|
446
|
+
if (node.cls) return `${node.type.toLowerCase()}:${node.cls}#${index}`;
|
|
447
|
+
return `${node.type.toLowerCase()}#${index}`;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* What one control says it is, in one line, and it is the only thing compared.
|
|
452
|
+
*
|
|
453
|
+
* Deliberately excludes the rectangle. A window that opens two pixels lower is not a
|
|
454
|
+
* difference anybody wants reported, and a control that MOVED without changing what it is or
|
|
455
|
+
* does is a pixel finding, not a meaning one. What is included is what would make somebody say
|
|
456
|
+
* the product behaves differently: what it is, whether it works, whether it is showing, what it
|
|
457
|
+
* is set to, and what it can be asked to do.
|
|
458
|
+
*
|
|
459
|
+
* @param {TreeNode} node
|
|
460
|
+
* @returns {string}
|
|
461
|
+
*/
|
|
462
|
+
export function controlMeaning(node) {
|
|
463
|
+
const parts = [node.type];
|
|
464
|
+
if (node.name) parts.push(`called "${node.name}"`);
|
|
465
|
+
parts.push(node.on ? 'enabled' : 'disabled');
|
|
466
|
+
if (node.hidden) parts.push('not showing');
|
|
467
|
+
if (node.state) parts.push(node.state);
|
|
468
|
+
if (node.can && node.can.length > 0) parts.push(`can ${node.can.join(', ')}`);
|
|
469
|
+
return parts.join(', ');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Turn one window's tree into observations, or into a hole if the read cannot be trusted.
|
|
474
|
+
*
|
|
475
|
+
* The cross-check is the important part and it is why `cached` and `walked` both come back from
|
|
476
|
+
* the probe. They are two different mechanisms counting the same tree. When they agree, the read
|
|
477
|
+
* is good. When the fast one returns nothing and the slow one finds controls, the fast one is
|
|
478
|
+
* lying, and reporting its answer would put a confident zero into the reference — after which
|
|
479
|
+
* every later run would compare zero against zero and agree that nothing had changed.
|
|
480
|
+
*
|
|
481
|
+
* @param {object} spec
|
|
482
|
+
* @param {Journey} spec.journey
|
|
483
|
+
* @param {string} spec.window Plain name of the window, for the path.
|
|
484
|
+
* @param {TreeNode[]} spec.nodes
|
|
485
|
+
* @param {number} spec.cached How many the fast read found.
|
|
486
|
+
* @param {number} spec.walked How many a plain walk found.
|
|
487
|
+
* @param {boolean} [spec.settled] Did two reads in a row agree.
|
|
488
|
+
* @returns {Observation[]}
|
|
489
|
+
*/
|
|
490
|
+
export function meaningFromTree(spec) {
|
|
491
|
+
const { journey, window: windowName, nodes, cached, walked } = spec;
|
|
492
|
+
const head = ['screen', windowName];
|
|
493
|
+
|
|
494
|
+
if (cached === 0 && walked > 1) {
|
|
495
|
+
return [notCovered({
|
|
496
|
+
channel: 'meaning',
|
|
497
|
+
path: joinPath(...head, 'controls'),
|
|
498
|
+
reason: 'not supported here',
|
|
499
|
+
says: `The window "${windowName}" would not give up its controls: the fast read found none while a plain `
|
|
500
|
+
+ `walk found ${walked}. That happens when a window is minimised or its content has been put away. `
|
|
501
|
+
+ 'Nothing is recorded for it, because recording "no controls" would make the next run agree that nothing changed.',
|
|
502
|
+
})];
|
|
503
|
+
}
|
|
504
|
+
if (nodes.length === 0) {
|
|
505
|
+
return [notCovered({
|
|
506
|
+
channel: 'meaning',
|
|
507
|
+
path: joinPath(...head, 'controls'),
|
|
508
|
+
reason: 'not supported here',
|
|
509
|
+
says: `The window "${windowName}" reported no controls at all. Either it draws itself without telling `
|
|
510
|
+
+ 'Windows what it is showing, or there was nothing on it yet. Either way it is unchecked, not empty.',
|
|
511
|
+
})];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** @type {Observation[]} */
|
|
515
|
+
const out = [];
|
|
516
|
+
/** @type {Map<string, number>} */
|
|
517
|
+
const usedNames = new Map();
|
|
518
|
+
nodes.forEach((node, index) => {
|
|
519
|
+
let address = controlAddress(node, index);
|
|
520
|
+
// Two controls can honestly share a name — two "Close" buttons in two panels. Number the
|
|
521
|
+
// repeats rather than let the second quietly overwrite the first.
|
|
522
|
+
const seen = usedNames.get(address) ?? 0;
|
|
523
|
+
usedNames.set(address, seen + 1);
|
|
524
|
+
if (seen > 0) address = `${address}~${seen + 1}`;
|
|
525
|
+
out.push(observation({
|
|
526
|
+
channel: 'meaning',
|
|
527
|
+
path: joinPath(...head, address),
|
|
528
|
+
value: controlMeaning(node),
|
|
529
|
+
says: `On "${windowName}", ${controlMeaning(node)}.`,
|
|
530
|
+
journey: journey.name,
|
|
531
|
+
surface: 'windows',
|
|
532
|
+
}));
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
out.push(observation({
|
|
536
|
+
channel: 'counters',
|
|
537
|
+
path: joinPath('count', windowName, 'controls'),
|
|
538
|
+
value: countBucket(nodes.length),
|
|
539
|
+
says: `"${windowName}" is showing ${nodes.length} control${nodes.length === 1 ? '' : 's'}.`,
|
|
540
|
+
journey: journey.name,
|
|
541
|
+
surface: 'windows',
|
|
542
|
+
}));
|
|
543
|
+
|
|
544
|
+
if (spec.settled === false) {
|
|
545
|
+
out.push(notCovered({
|
|
546
|
+
channel: 'meaning',
|
|
547
|
+
path: joinPath(...head, 'settled'),
|
|
548
|
+
reason: 'timed out',
|
|
549
|
+
says: `"${windowName}" never held still: two readings in a row never matched. What was recorded is one `
|
|
550
|
+
+ 'snapshot of something still moving, so a difference found in it may be the movement rather than the change.',
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
if (nodes.length >= MAX_TREE_NODES) {
|
|
554
|
+
out.push(notCovered({
|
|
555
|
+
channel: 'meaning',
|
|
556
|
+
path: joinPath(...head, 'all of it'),
|
|
557
|
+
reason: 'too big',
|
|
558
|
+
says: `"${windowName}" has more than ${MAX_TREE_NODES} controls, so only the first ${MAX_TREE_NODES} were `
|
|
559
|
+
+ 'recorded. Anything past that is unchecked.',
|
|
560
|
+
}));
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Programs the app started, as observations.
|
|
567
|
+
*
|
|
568
|
+
* The command line is kept and compared, because "it now launches the updater with a different
|
|
569
|
+
* flag" is exactly the kind of change no screenshot has ever caught.
|
|
570
|
+
*
|
|
571
|
+
* @param {Journey} journey
|
|
572
|
+
* @param {{name: string, pid: number, parent: number, cmd: string|null}[]} procs
|
|
573
|
+
* @param {number} ownPid
|
|
574
|
+
* @returns {Observation[]}
|
|
575
|
+
*/
|
|
576
|
+
export function spawnedObservations(journey, procs, ownPid) {
|
|
577
|
+
const children = procs.filter((p) => p.pid !== ownPid);
|
|
578
|
+
/** @type {Observation[]} */
|
|
579
|
+
const out = children
|
|
580
|
+
.map((p) => ({ name: p.name, cmd: p.cmd ?? '(no command line visible)' }))
|
|
581
|
+
.sort((a, b) => (a.name + a.cmd < b.name + b.cmd ? -1 : 1))
|
|
582
|
+
.map((p, index) => observation({
|
|
583
|
+
channel: 'effects',
|
|
584
|
+
path: joinPath('proc', journey.name, `${p.name}#${index}`),
|
|
585
|
+
value: p.cmd,
|
|
586
|
+
says: `It started ${p.name}. That is a program running because this app ran.`,
|
|
587
|
+
journey: journey.name,
|
|
588
|
+
surface: 'windows',
|
|
589
|
+
}));
|
|
590
|
+
out.push(observation({
|
|
591
|
+
channel: 'counters',
|
|
592
|
+
path: joinPath('count', journey.name, 'programs started'),
|
|
593
|
+
value: countBucket(children.length),
|
|
594
|
+
says: `It started ${children.length} other program${children.length === 1 ? '' : 's'}.`,
|
|
595
|
+
journey: journey.name,
|
|
596
|
+
surface: 'windows',
|
|
597
|
+
}));
|
|
598
|
+
return out;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* What changed on disk in the folders we were told to watch.
|
|
603
|
+
*
|
|
604
|
+
* Sizes rather than contents, because reading every file back over a network connection would
|
|
605
|
+
* cost more than the whole rest of the run. A file that changed size changed; a file that was
|
|
606
|
+
* rewritten with the same length is missed, and that is said out loud rather than hidden.
|
|
607
|
+
*
|
|
608
|
+
* @param {Journey} journey
|
|
609
|
+
* @param {Record<string, Record<string, number>>} before
|
|
610
|
+
* @param {Record<string, Record<string, number>>} after
|
|
611
|
+
* @returns {Observation[]}
|
|
612
|
+
*/
|
|
613
|
+
export function fileObservations(journey, before, after) {
|
|
614
|
+
/** @type {Observation[]} */
|
|
615
|
+
const out = [];
|
|
616
|
+
for (const dir of Object.keys(after).sort()) {
|
|
617
|
+
const was = before[dir] ?? {};
|
|
618
|
+
const now = after[dir] ?? {};
|
|
619
|
+
const names = [...new Set([...Object.keys(was), ...Object.keys(now)])].sort();
|
|
620
|
+
let touched = 0;
|
|
621
|
+
for (const name of names) {
|
|
622
|
+
const oldSize = was[name];
|
|
623
|
+
const newSize = now[name];
|
|
624
|
+
if (oldSize === newSize) continue;
|
|
625
|
+
touched++;
|
|
626
|
+
out.push(observation({
|
|
627
|
+
channel: 'effects',
|
|
628
|
+
path: joinPath('file', journey.name, name),
|
|
629
|
+
value: newSize === undefined ? 'deleted' : oldSize === undefined ? `written, ${sizeBucket(newSize)}` : `changed to ${sizeBucket(newSize)}`,
|
|
630
|
+
says: newSize === undefined
|
|
631
|
+
? `It deleted ${name}.`
|
|
632
|
+
: oldSize === undefined
|
|
633
|
+
? `It wrote ${name}, ${sizeBucket(newSize)}.`
|
|
634
|
+
: `It changed ${name}; it is now ${sizeBucket(newSize)}.`,
|
|
635
|
+
journey: journey.name,
|
|
636
|
+
surface: 'windows',
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
out.push(observation({
|
|
640
|
+
channel: 'counters',
|
|
641
|
+
path: joinPath('count', journey.name, 'files touched'),
|
|
642
|
+
value: countBucket(touched),
|
|
643
|
+
says: `${touched} file${touched === 1 ? '' : 's'} changed under ${dir}.`,
|
|
644
|
+
journey: journey.name,
|
|
645
|
+
surface: 'windows',
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
out.push(notCovered({
|
|
649
|
+
channel: 'effects',
|
|
650
|
+
path: joinPath('file', journey.name, 'everywhere else'),
|
|
651
|
+
reason: 'missing tool',
|
|
652
|
+
says: 'Only the folders this check was told to watch were compared. Watching everything a program writes '
|
|
653
|
+
+ 'needs administrator rights on Windows, which this account does not have, so a file written anywhere '
|
|
654
|
+
+ 'else was not seen. That is a hole, not a clean result.',
|
|
655
|
+
}));
|
|
656
|
+
return out;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Crashes, hangs and anything the program complained about into the Windows event log.
|
|
661
|
+
*
|
|
662
|
+
* The event ids are the ones Windows itself uses and they are worth knowing: 1000 is an
|
|
663
|
+
* application crash, 1002 is an application hang, 1001 is the report Windows filed about it.
|
|
664
|
+
* Everything else is kept too, at warning level and above, because an application that has
|
|
665
|
+
* started logging a new warning every run has changed even if nothing on screen has.
|
|
666
|
+
*
|
|
667
|
+
* @param {Journey} journey
|
|
668
|
+
* @param {{log: string, id: number, level: string, source: string, text: string}[]} events
|
|
669
|
+
* @param {string} appHint Something to recognise this app by in a log line.
|
|
670
|
+
* @returns {Observation[]}
|
|
671
|
+
*/
|
|
672
|
+
export function complaintObservations(journey, events, appHint) {
|
|
673
|
+
const hint = appHint.toLowerCase();
|
|
674
|
+
const mine = events.filter((e) => `${e.source} ${e.text}`.toLowerCase().includes(hint));
|
|
675
|
+
const crashes = mine.filter((e) => [1000, 1001, 1002].includes(e.id));
|
|
676
|
+
/** @type {Observation[]} */
|
|
677
|
+
const out = crashes.map((e, index) => observation({
|
|
678
|
+
channel: 'complaints',
|
|
679
|
+
path: joinPath('log', journey.name, 'crash', String(index)),
|
|
680
|
+
value: `${e.id === 1002 ? 'stopped responding' : 'crashed'}: ${trimForStorage(e.text, 400).text}`,
|
|
681
|
+
says: e.id === 1002
|
|
682
|
+
? 'Windows recorded that this app stopped responding.'
|
|
683
|
+
: 'Windows recorded that this app crashed.',
|
|
684
|
+
journey: journey.name,
|
|
685
|
+
surface: 'windows',
|
|
686
|
+
}));
|
|
687
|
+
out.push(observation({
|
|
688
|
+
channel: 'complaints',
|
|
689
|
+
path: joinPath('log', journey.name, 'complaints'),
|
|
690
|
+
value: countBucket(mine.length),
|
|
691
|
+
says: mine.length === 0
|
|
692
|
+
? 'Windows logged nothing about this app while it ran.'
|
|
693
|
+
: `Windows logged ${mine.length} thing${mine.length === 1 ? '' : 's'} about this app while it ran.`,
|
|
694
|
+
journey: journey.name,
|
|
695
|
+
surface: 'windows',
|
|
696
|
+
}));
|
|
697
|
+
return out;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Connections the app had open, plus the honest note about what sampling misses.
|
|
702
|
+
*
|
|
703
|
+
* @param {Journey} journey
|
|
704
|
+
* @param {{remote: string, port: number, state: string}[]} conns
|
|
705
|
+
* @returns {Observation[]}
|
|
706
|
+
*/
|
|
707
|
+
export function networkObservations(journey, conns) {
|
|
708
|
+
const reachable = conns
|
|
709
|
+
.filter((c) => c.remote !== '0.0.0.0' && c.remote !== '::' && c.remote !== '127.0.0.1')
|
|
710
|
+
.map((c) => `${c.remote}:${c.port}`)
|
|
711
|
+
.sort();
|
|
712
|
+
const unique = [...new Set(reachable)];
|
|
713
|
+
/** @type {Observation[]} */
|
|
714
|
+
const out = unique.map((where, index) => observation({
|
|
715
|
+
channel: 'effects',
|
|
716
|
+
path: joinPath('net', journey.name, String(index)),
|
|
717
|
+
value: where,
|
|
718
|
+
says: `While it was running it had a connection open to ${where}.`,
|
|
719
|
+
journey: journey.name,
|
|
720
|
+
surface: 'windows',
|
|
721
|
+
}));
|
|
722
|
+
out.push(notCovered({
|
|
723
|
+
channel: 'effects',
|
|
724
|
+
path: joinPath('net', journey.name, 'everything it asked for'),
|
|
725
|
+
reason: 'missing tool',
|
|
726
|
+
says: 'Connections were sampled while the app ran, not captured. A request that opened and finished '
|
|
727
|
+
+ 'between two samples was not seen, and nothing here could have stopped one. Capturing every call '
|
|
728
|
+
+ 'needs administrator rights on Windows, which this account does not have.',
|
|
729
|
+
}));
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// Finding the app
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Where the Windows build is, and whether it is already on the far machine.
|
|
739
|
+
*
|
|
740
|
+
* Two honest modes, and which one a project is in changes what a run costs by minutes:
|
|
741
|
+
*
|
|
742
|
+
* `there` The config names a path that already exists on the Windows machine. Free.
|
|
743
|
+
* `push` The build is here and has to be copied over. Real, and reported with its size,
|
|
744
|
+
* because a person who does not know a run copies 200 megabytes over ssh every time
|
|
745
|
+
* will reasonably conclude the tool is broken when it takes four minutes.
|
|
746
|
+
*
|
|
747
|
+
* @param {AdapterProject} project
|
|
748
|
+
* @returns {{mode: 'there'|'push'|'none', exe: string|null, local: string|null, why: string}}
|
|
749
|
+
*/
|
|
750
|
+
export function findWindowsBuild(project) {
|
|
751
|
+
const config = project.config ?? {};
|
|
752
|
+
if (typeof config.remoteExe === 'string' && config.remoteExe.trim() !== '') {
|
|
753
|
+
return {
|
|
754
|
+
mode: 'there',
|
|
755
|
+
exe: config.remoteExe,
|
|
756
|
+
local: null,
|
|
757
|
+
why: `The Windows build is already on that machine at ${config.remoteExe}, so nothing is copied.`,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
if (typeof config.exe === 'string' && config.exe.trim() !== '') {
|
|
761
|
+
const local = path.isAbsolute(config.exe) ? config.exe : path.join(project.root, config.exe);
|
|
762
|
+
return {
|
|
763
|
+
mode: 'push',
|
|
764
|
+
exe: null,
|
|
765
|
+
local,
|
|
766
|
+
why: `The Windows build is here at ${local} and has to be copied to the Windows machine before each run.`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
return {
|
|
770
|
+
mode: 'none',
|
|
771
|
+
exe: null,
|
|
772
|
+
local: null,
|
|
773
|
+
why: 'No Windows build was named, so there is nothing to open.',
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Is this window really a native one, or a Chromium shell wearing a Windows title bar.
|
|
779
|
+
* @param {string} className
|
|
780
|
+
* @returns {boolean}
|
|
781
|
+
*/
|
|
782
|
+
export function isChromiumWindow(className) {
|
|
783
|
+
return CHROMIUM_CLASSES.includes(className);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Make a list out of whatever PowerShell sent.
|
|
788
|
+
*
|
|
789
|
+
* Not defensive programming — a real and famous behaviour of the thing on the other end.
|
|
790
|
+
* `ConvertTo-Json` turns a list of three into a JSON array, a list of one into a BARE OBJECT,
|
|
791
|
+
* and a list of none into `null`. So a window tree with a single control arrives shaped like a
|
|
792
|
+
* single control, and any code that maps over it dies. PowerShell 5.1 has no `-AsArray` to fix
|
|
793
|
+
* this at the source, so it is fixed here, once, and every reply is read through it.
|
|
794
|
+
*
|
|
795
|
+
* @template T
|
|
796
|
+
* @param {unknown} value
|
|
797
|
+
* @returns {T[]}
|
|
798
|
+
*/
|
|
799
|
+
export function asList(value) {
|
|
800
|
+
if (value === null || value === undefined) return [];
|
|
801
|
+
return Array.isArray(value) ? /** @type {T[]} */ (value) : [/** @type {T} */ (value)];
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Copy a build over to the far machine, and say how long it took and how big it was.
|
|
806
|
+
*
|
|
807
|
+
* Streamed through tar rather than scp so it is one connection and one pass, and so a folder
|
|
808
|
+
* of thousands of small files does not become thousands of round trips at a second each.
|
|
809
|
+
*
|
|
810
|
+
* @param {string} host
|
|
811
|
+
* @param {string} localDir
|
|
812
|
+
* @param {string} remoteDir
|
|
813
|
+
* @returns {Promise<{ok: boolean, ms: number, why: string}>}
|
|
814
|
+
*/
|
|
815
|
+
export async function pushBuild(host, localDir, remoteDir) {
|
|
816
|
+
const started = Date.now();
|
|
817
|
+
return await new Promise((resolve) => {
|
|
818
|
+
const tar = spawn('tar', ['-cf', '-', '-C', path.dirname(localDir), path.basename(localDir)]);
|
|
819
|
+
const ssh = spawn('ssh', ['-o', 'BatchMode=yes', host, `mkdir -p '${remoteDir}' && tar -xf - -C '${remoteDir}'`]);
|
|
820
|
+
let trouble = '';
|
|
821
|
+
tar.stdout.pipe(ssh.stdin);
|
|
822
|
+
ssh.stderr.on('data', (d) => { trouble += String(d); });
|
|
823
|
+
tar.stderr.on('data', (d) => { trouble += String(d); });
|
|
824
|
+
ssh.on('close', (code) => {
|
|
825
|
+
const ms = Date.now() - started;
|
|
826
|
+
resolve(code === 0
|
|
827
|
+
? { ok: true, ms, why: `Copied to ${host} in ${timeBucket(ms)}.` }
|
|
828
|
+
: { ok: false, ms, why: `Copying to ${host} failed: ${trouble.trim().slice(0, 300) || `tar exited ${code}`}` });
|
|
829
|
+
});
|
|
830
|
+
ssh.on('error', (e) => resolve({ ok: false, ms: Date.now() - started, why: `Could not start the copy: ${e.message}` }));
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---------------------------------------------------------------------------
|
|
835
|
+
// The adapter
|
|
836
|
+
// ---------------------------------------------------------------------------
|
|
837
|
+
|
|
838
|
+
/** The one connection this adapter holds while a run is going on. */
|
|
839
|
+
let link = /** @type {import('../remote.js').RemoteRunner|null} */ (null);
|
|
840
|
+
|
|
841
|
+
/** Everything this run started over there, so teardown can put it back and nothing else. */
|
|
842
|
+
/** @type {number[]} */
|
|
843
|
+
let startedHere = [];
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Open the Windows machine, once, and keep it.
|
|
847
|
+
* @param {string} host
|
|
848
|
+
* @param {(m: string) => void} [log]
|
|
849
|
+
*/
|
|
850
|
+
async function connect(host, log) {
|
|
851
|
+
if (link && link.alive) return link;
|
|
852
|
+
link = remoteRunner({ host, kind: 'windows', surface: 'windows', agent: windowsProbeScript(), log });
|
|
853
|
+
await link.open();
|
|
854
|
+
return link;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
export const windowsAdapter = defineAdapter({
|
|
858
|
+
name: 'windows',
|
|
859
|
+
title: 'native Windows apps',
|
|
860
|
+
describe:
|
|
861
|
+
'Opens a native Windows program on a real Windows desktop reached over ssh, reads what every control on '
|
|
862
|
+
+ 'screen says it is and does through UI Automation, and watches what it starts, writes and complains '
|
|
863
|
+
+ 'about. It cannot run two builds at once — Windows has one desktop — and it declines Electron apps, '
|
|
864
|
+
+ 'which are covered better and in pairs over their debug port.',
|
|
865
|
+
channels: ['meaning', 'effects', 'complaints', 'results', 'counters', 'pixels'],
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* @param {AdapterProject} project
|
|
869
|
+
* @returns {Promise<Detection>}
|
|
870
|
+
*/
|
|
871
|
+
async detect(project) {
|
|
872
|
+
const config = project.config ?? {};
|
|
873
|
+
const host = typeof config.host === 'string' ? config.host : null;
|
|
874
|
+
const build = findWindowsBuild(project);
|
|
875
|
+
/** @type {Missing[]} */
|
|
876
|
+
const missing = [];
|
|
877
|
+
|
|
878
|
+
if (!host) {
|
|
879
|
+
missing.push({
|
|
880
|
+
what: 'the name of a machine with a Windows desktop on it',
|
|
881
|
+
unlocks: 'checking a native Windows app at all — a Windows window can only be read from Windows',
|
|
882
|
+
howToGet: 'Put {"host": "the-ssh-host-name"} under "windows" in the config. Any ssh host that gets you a '
|
|
883
|
+
+ 'shell on a Windows machine works, including a WSL shell on one — the tool finds powershell.exe from '
|
|
884
|
+
+ 'there by itself. Nothing needs installing on that machine.',
|
|
885
|
+
blocking: true,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
if (build.mode === 'none') {
|
|
889
|
+
missing.push({
|
|
890
|
+
what: 'the built Windows program',
|
|
891
|
+
unlocks: 'opening the app and reading what is on its screen',
|
|
892
|
+
howToGet: 'Either put {"remoteExe": "C:\\\\path\\\\to\\\\YourApp.exe"} under "windows" in the config if the '
|
|
893
|
+
+ 'build already lives on that machine — much faster — or {"exe": "dist/win/YourApp.exe"} to have it '
|
|
894
|
+
+ 'copied over before each run.',
|
|
895
|
+
blocking: true,
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
if (!Array.isArray(config.watchDirs) || config.watchDirs.length === 0) {
|
|
899
|
+
missing.push({
|
|
900
|
+
what: 'the folders this app writes into',
|
|
901
|
+
unlocks: 'seeing what it saved, which is otherwise invisible — Windows will not let this account watch '
|
|
902
|
+
+ 'the whole disk without administrator rights',
|
|
903
|
+
howToGet: 'Put {"watchDirs": ["C:\\\\Users\\\\you\\\\AppData\\\\Roaming\\\\YourApp"]} under "windows" in the config.',
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
let electronish = false;
|
|
908
|
+
try {
|
|
909
|
+
const pkg = JSON.parse(await fsp.readFile(path.join(project.root, 'package.json'), 'utf8'));
|
|
910
|
+
electronish = Boolean(pkg.dependencies?.electron || pkg.devDependencies?.electron || pkg.build?.appId);
|
|
911
|
+
} catch { /* a built app somebody pointed at need not have a package.json */ }
|
|
912
|
+
|
|
913
|
+
if (electronish) {
|
|
914
|
+
return {
|
|
915
|
+
applies: false,
|
|
916
|
+
confidence: 0,
|
|
917
|
+
why: 'This is an Electron app, so the Electron adapter covers its Windows build properly — over the debug '
|
|
918
|
+
+ 'port, from any machine, with two builds able to run side by side. This adapter would be strictly worse: '
|
|
919
|
+
+ 'one build at a time, on one shared desktop, and reading the window would switch on Chromium\'s '
|
|
920
|
+
+ 'accessibility engine and change the timing of the thing being measured.',
|
|
921
|
+
missing: [],
|
|
922
|
+
notes: ['Nothing is missing. There is simply a better tool for this app already in the box.'],
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
const applies = Boolean(host) && build.mode !== 'none';
|
|
927
|
+
return {
|
|
928
|
+
applies,
|
|
929
|
+
confidence: applies ? 0.9 : 0,
|
|
930
|
+
why: applies
|
|
931
|
+
? `${build.why} It will be opened on the desktop behind "${host}", read, and closed again — one build at a `
|
|
932
|
+
+ 'time, because Windows shows one desktop and two cannot be up at once.'
|
|
933
|
+
: 'A native Windows app needs a Windows machine and a built program, and one of those is not named yet.',
|
|
934
|
+
missing,
|
|
935
|
+
notes: [
|
|
936
|
+
'Nothing is installed on the Windows machine. The program that reads the screen is sent down the ssh '
|
|
937
|
+
+ 'connection each run and disappears when it closes.',
|
|
938
|
+
'Two builds can never run at the same time here. That is a property of Windows, not of this tool, and it '
|
|
939
|
+
+ 'makes the same-machine comparison weaker on this platform than on any other.',
|
|
940
|
+
'The desktop is shared with whoever uses that machine. A notification or an update prompt appearing '
|
|
941
|
+
+ 'mid-run is a real difference this cannot tell from a real one, beyond what running twice subtracts.',
|
|
942
|
+
'Nothing irreversible can be stopped here. There is no way to refuse a compiled program\'s network call '
|
|
943
|
+
+ 'without administrator rights, so a journey marked irreversible is refused outright instead of walked.',
|
|
944
|
+
],
|
|
945
|
+
};
|
|
946
|
+
},
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* @param {AdapterProject} project
|
|
950
|
+
* @returns {Promise<Journey[]>}
|
|
951
|
+
*/
|
|
952
|
+
async journeys(project) {
|
|
953
|
+
const config = project.config ?? {};
|
|
954
|
+
const build = findWindowsBuild(project);
|
|
955
|
+
if (build.mode === 'none') return [];
|
|
956
|
+
|
|
957
|
+
/** @type {Journey[]} */
|
|
958
|
+
const journeys = [{
|
|
959
|
+
name: 'open-the-app',
|
|
960
|
+
describe: 'open the Windows app and read every control it puts on screen',
|
|
961
|
+
source: 'code',
|
|
962
|
+
surface: 'windows',
|
|
963
|
+
from: 'the built program named in the config',
|
|
964
|
+
channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
|
|
965
|
+
steps: [{ act: 'launch' }, { act: 'settle' }, { act: 'read' }],
|
|
966
|
+
timeoutMs: 120_000,
|
|
967
|
+
}];
|
|
968
|
+
|
|
969
|
+
// Anything else has to be described by somebody who knows the app. Read out of the config
|
|
970
|
+
// rather than invented here: an adapter that guesses which buttons to press on an unknown
|
|
971
|
+
// native program is an adapter that will one day press "Delete account".
|
|
972
|
+
for (const extra of Array.isArray(config.journeys) ? config.journeys : []) {
|
|
973
|
+
if (!extra || typeof extra.name !== 'string') continue;
|
|
974
|
+
journeys.push({
|
|
975
|
+
name: extra.name,
|
|
976
|
+
describe: typeof extra.describe === 'string' ? extra.describe : `walk "${extra.name}"`,
|
|
977
|
+
source: 'recorded',
|
|
978
|
+
surface: 'windows',
|
|
979
|
+
from: 'the project config',
|
|
980
|
+
channels: ['meaning', 'effects', 'complaints', 'counters', 'pixels'],
|
|
981
|
+
steps: Array.isArray(extra.steps) ? extra.steps : [],
|
|
982
|
+
irreversible: Boolean(extra.irreversible),
|
|
983
|
+
timeoutMs: 120_000,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return journeys;
|
|
987
|
+
},
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* @param {Build} build
|
|
991
|
+
* @param {RunContext} ctx
|
|
992
|
+
* @returns {Promise<PreparedBuild>}
|
|
993
|
+
*/
|
|
994
|
+
async prepare(build, ctx) {
|
|
995
|
+
const config = ctx.config ?? {};
|
|
996
|
+
const host = typeof config.host === 'string' ? config.host : null;
|
|
997
|
+
if (!host) {
|
|
998
|
+
return { build, root: build.root, ready: false, why: 'No Windows machine is named in the config, so there is nowhere to open this.', dispose: async () => {} };
|
|
999
|
+
}
|
|
1000
|
+
const where = findWindowsBuild({ root: build.root, config });
|
|
1001
|
+
if (where.mode === 'none') {
|
|
1002
|
+
return { build, root: build.root, ready: false, why: where.why, dispose: async () => {} };
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let runner;
|
|
1006
|
+
try {
|
|
1007
|
+
runner = await connect(host, ctx.log);
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return {
|
|
1010
|
+
build,
|
|
1011
|
+
root: build.root,
|
|
1012
|
+
ready: false,
|
|
1013
|
+
why: error instanceof RemoteLinkLost
|
|
1014
|
+
? `${error.message}. Nothing was checked on Windows.`
|
|
1015
|
+
: `Could not reach ${host}: ${String(error)}`,
|
|
1016
|
+
dispose: async () => {},
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const facts = runner.facts;
|
|
1021
|
+
if (facts.loggedIn === false) {
|
|
1022
|
+
return {
|
|
1023
|
+
build,
|
|
1024
|
+
root: build.root,
|
|
1025
|
+
ready: false,
|
|
1026
|
+
why: `Nobody is signed in on ${host}, and there is nothing to read on a desktop nobody has signed into. `
|
|
1027
|
+
+ 'Sign in there once and leave the session running; locking the screen afterwards is fine.',
|
|
1028
|
+
dispose: async () => {},
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
let exe = where.exe;
|
|
1033
|
+
/** @type {string[]} */
|
|
1034
|
+
const notes = [];
|
|
1035
|
+
if (where.mode === 'push' && where.local) {
|
|
1036
|
+
const remoteDir = `/tmp/staysfixed-${build.id}`;
|
|
1037
|
+
const pushed = await pushBuild(host, path.dirname(where.local), remoteDir);
|
|
1038
|
+
if (!pushed.ok) {
|
|
1039
|
+
return { build, root: build.root, ready: false, why: pushed.why, dispose: async () => {} };
|
|
1040
|
+
}
|
|
1041
|
+
notes.push(pushed.why);
|
|
1042
|
+
// The copy lands on the Linux side; Windows reaches it through the UNC path WSL publishes.
|
|
1043
|
+
const distro = String(facts.host ?? 'Ubuntu');
|
|
1044
|
+
exe = `\\\\wsl.localhost\\${distro}${remoteDir.replace(/\//g, '\\')}\\${path.basename(path.dirname(where.local))}\\${path.basename(where.local)}`;
|
|
1045
|
+
notes.push('Starting a program from a copied folder is slower than one already on that machine. Naming '
|
|
1046
|
+
+ '"remoteExe" instead, once, removes this from every run.');
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
build,
|
|
1051
|
+
root: build.root,
|
|
1052
|
+
ready: Boolean(exe),
|
|
1053
|
+
why: exe
|
|
1054
|
+
? `${where.why} ${notes.join(' ')}`.trim()
|
|
1055
|
+
: 'The Windows program could not be placed on that machine.',
|
|
1056
|
+
facts: {
|
|
1057
|
+
exe: exe ?? undefined,
|
|
1058
|
+
host,
|
|
1059
|
+
locked: facts.locked,
|
|
1060
|
+
desktop: typeof facts.screen === 'string' ? facts.screen : undefined,
|
|
1061
|
+
},
|
|
1062
|
+
dispose: async () => { /* nothing was installed, so there is nothing to undo */ },
|
|
1063
|
+
};
|
|
1064
|
+
},
|
|
1065
|
+
|
|
1066
|
+
/**
|
|
1067
|
+
* @param {Journey} journey
|
|
1068
|
+
* @param {PreparedBuild} prepared
|
|
1069
|
+
* @param {RunContext} ctx
|
|
1070
|
+
* @returns {Promise<Observation[]>}
|
|
1071
|
+
*/
|
|
1072
|
+
async run(journey, prepared, ctx) {
|
|
1073
|
+
const config = ctx.config ?? {};
|
|
1074
|
+
const exe = String(prepared.facts?.exe ?? '');
|
|
1075
|
+
const host = String(prepared.facts?.host ?? '');
|
|
1076
|
+
|
|
1077
|
+
if (!prepared.ready || !exe) {
|
|
1078
|
+
return [notCovered({
|
|
1079
|
+
channel: 'meaning',
|
|
1080
|
+
path: joinPath('screen', journey.name, 'anything at all'),
|
|
1081
|
+
reason: 'missing tool',
|
|
1082
|
+
says: `"${journey.describe}" was not walked: ${prepared.why}`,
|
|
1083
|
+
})];
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Refused outright rather than walked carefully. There is no wire boundary on Windows
|
|
1087
|
+
// without administrator rights, so "watch it ask and stop it" is not available, and a
|
|
1088
|
+
// careful walk of an irreversible journey is a walk that really does the irreversible thing.
|
|
1089
|
+
if (journey.irreversible) {
|
|
1090
|
+
return [notCovered({
|
|
1091
|
+
channel: 'effects',
|
|
1092
|
+
path: joinPath('screen', journey.name, 'refused'),
|
|
1093
|
+
reason: 'irreversible',
|
|
1094
|
+
says: `"${journey.describe}" would spend money, send a message or destroy data, and on Windows there is no `
|
|
1095
|
+
+ 'way to let it ask and then stop it — that needs administrator rights this account does not have. '
|
|
1096
|
+
+ 'It was not run at all. This is a hole in what was checked, not a pass.',
|
|
1097
|
+
})];
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
let runner;
|
|
1101
|
+
try {
|
|
1102
|
+
runner = await connect(host, ctx.log);
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
return [notCovered({
|
|
1105
|
+
channel: 'meaning',
|
|
1106
|
+
path: joinPath('screen', journey.name, 'anything at all'),
|
|
1107
|
+
reason: 'timed out',
|
|
1108
|
+
says: `"${journey.describe}" was not walked: ${error instanceof Error ? error.message : String(error)}.`,
|
|
1109
|
+
})];
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/** @type {Observation[]} */
|
|
1113
|
+
const seen = [];
|
|
1114
|
+
/** @type {string[]} */
|
|
1115
|
+
const watchDirs = Array.isArray(config.watchDirs) ? config.watchDirs.map(String) : [];
|
|
1116
|
+
let pid = 0;
|
|
1117
|
+
const startedAt = new Date().toISOString();
|
|
1118
|
+
|
|
1119
|
+
try {
|
|
1120
|
+
const before = watchDirs.length > 0 ? await runner.call('snap', { dirs: watchDirs }) : { dirs: {} };
|
|
1121
|
+
|
|
1122
|
+
const launched = await runner.call('launch', {
|
|
1123
|
+
exe,
|
|
1124
|
+
args: Array.isArray(config.args) ? config.args : undefined,
|
|
1125
|
+
cwd: typeof config.cwd === 'string' ? config.cwd : undefined,
|
|
1126
|
+
}, { timeoutMs: 60_000 });
|
|
1127
|
+
if (!launched.ok) {
|
|
1128
|
+
return [notCovered({
|
|
1129
|
+
channel: 'meaning',
|
|
1130
|
+
path: joinPath('screen', journey.name, 'anything at all'),
|
|
1131
|
+
reason: 'crashed',
|
|
1132
|
+
says: `The app would not start on ${host}: ${launched.error}.`,
|
|
1133
|
+
})];
|
|
1134
|
+
}
|
|
1135
|
+
pid = Number(launched.pid);
|
|
1136
|
+
startedHere.push(pid);
|
|
1137
|
+
|
|
1138
|
+
// Wait for a window rather than sleeping a fixed time. A machine under load takes longer,
|
|
1139
|
+
// and a fixed sleep would turn that into a difference in the report.
|
|
1140
|
+
/** @type {{name: string, cls: string, hwnd: number, pid: number, visible: boolean}[]} */
|
|
1141
|
+
let windows = [];
|
|
1142
|
+
const deadline = Date.now() + WINDOW_WAIT_MS;
|
|
1143
|
+
while (Date.now() < deadline) {
|
|
1144
|
+
const reply = await runner.call('windows', { pid });
|
|
1145
|
+
windows = asList(reply.windows).filter((/** @type {any} */ w) => Boolean(w.visible));
|
|
1146
|
+
if (windows.length > 0) break;
|
|
1147
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
if (windows.length === 0) {
|
|
1151
|
+
seen.push(notCovered({
|
|
1152
|
+
channel: 'meaning',
|
|
1153
|
+
path: joinPath('screen', journey.name, 'a window'),
|
|
1154
|
+
reason: 'timed out',
|
|
1155
|
+
says: `The app started on ${host} but put no window on screen within ${timeBucket(WINDOW_WAIT_MS)}. `
|
|
1156
|
+
+ 'Nothing about its screen was checked. It may be a background program, or it may have failed silently.',
|
|
1157
|
+
}));
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const chromium = windows.filter((w) => isChromiumWindow(w.cls));
|
|
1161
|
+
if (chromium.length > 0 && chromium.length === windows.length) {
|
|
1162
|
+
seen.push(notCovered({
|
|
1163
|
+
channel: 'meaning',
|
|
1164
|
+
path: joinPath('screen', journey.name, 'controls'),
|
|
1165
|
+
reason: 'not supported here',
|
|
1166
|
+
says: 'Every window this app opened is a Chromium one, so it is an Electron app after all. It is not read '
|
|
1167
|
+
+ 'here: the Electron adapter covers it properly over its debug port, from any machine, with two builds '
|
|
1168
|
+
+ 'able to run at once. Reading it here would also switch on Chromium\'s accessibility engine and change '
|
|
1169
|
+
+ 'the timing of what is being measured.',
|
|
1170
|
+
}));
|
|
1171
|
+
} else {
|
|
1172
|
+
for (const window of windows.filter((w) => !isChromiumWindow(w.cls))) {
|
|
1173
|
+
const label = window.name || window.cls || 'a window with no title';
|
|
1174
|
+
const tree = await runner.call('settle', { hwnd: window.hwnd, limit: MAX_TREE_NODES }, { timeoutMs: 90_000 });
|
|
1175
|
+
if (!tree.ok) {
|
|
1176
|
+
seen.push(notCovered({
|
|
1177
|
+
channel: 'meaning',
|
|
1178
|
+
path: joinPath('screen', label, 'controls'),
|
|
1179
|
+
reason: 'crashed',
|
|
1180
|
+
says: `"${label}" could not be read: ${tree.error}.`,
|
|
1181
|
+
}));
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
seen.push(...meaningFromTree({
|
|
1185
|
+
journey,
|
|
1186
|
+
window: label,
|
|
1187
|
+
nodes: asList(tree.nodes),
|
|
1188
|
+
cached: Number(tree.cached ?? 0),
|
|
1189
|
+
walked: Number(tree.walked ?? 0),
|
|
1190
|
+
settled: Boolean(tree.agreed),
|
|
1191
|
+
}));
|
|
1192
|
+
seen.push(observation({
|
|
1193
|
+
channel: 'results',
|
|
1194
|
+
path: joinPath('screen', label, 'title'),
|
|
1195
|
+
value: window.name,
|
|
1196
|
+
says: `A window is open called "${window.name}".`,
|
|
1197
|
+
journey: journey.name,
|
|
1198
|
+
surface: 'windows',
|
|
1199
|
+
}));
|
|
1200
|
+
|
|
1201
|
+
// Pixels last, and only as evidence. A picture is written to the evidence folder and
|
|
1202
|
+
// pointed at; it is never the thing compared.
|
|
1203
|
+
const shot = await runner.call('shot', { hwnd: window.hwnd }, { timeoutMs: 45_000 });
|
|
1204
|
+
if (shot.ok && shot.png && Number(shot.bytes) <= MAX_SHOT_BYTES) {
|
|
1205
|
+
const file = path.join(ctx.evidenceDir, `windows-${journey.name}-${label.replace(/[^a-z0-9]+/gi, '-')}.png`);
|
|
1206
|
+
await fsp.writeFile(file, Buffer.from(String(shot.png), 'base64'));
|
|
1207
|
+
seen.push(observation({
|
|
1208
|
+
channel: 'pixels',
|
|
1209
|
+
path: joinPath('screen', label, 'looks like'),
|
|
1210
|
+
value: `${shot.w} by ${shot.h}`,
|
|
1211
|
+
says: `A picture of "${label}" was kept as evidence. It is not compared — it is there to show a person `
|
|
1212
|
+
+ 'something another channel already found.',
|
|
1213
|
+
evidence: file,
|
|
1214
|
+
journey: journey.name,
|
|
1215
|
+
surface: 'windows',
|
|
1216
|
+
}));
|
|
1217
|
+
if (Number(shot.lit) === 0) {
|
|
1218
|
+
seen.push(notCovered({
|
|
1219
|
+
channel: 'pixels',
|
|
1220
|
+
path: joinPath('screen', label, 'picture is usable'),
|
|
1221
|
+
reason: 'not supported here',
|
|
1222
|
+
says: 'The picture came back completely black. On a locked Windows desktop that is expected for '
|
|
1223
|
+
+ 'anything drawn by the graphics card. Every other channel still works; only the picture is lost.',
|
|
1224
|
+
}));
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const spawned = await runner.call('spawned', {});
|
|
1231
|
+
seen.push(...spawnedObservations(journey, asList(spawned.procs), pid));
|
|
1232
|
+
|
|
1233
|
+
const net = await runner.call('net', {});
|
|
1234
|
+
if (net.ok) seen.push(...networkObservations(journey, asList(net.conns)));
|
|
1235
|
+
|
|
1236
|
+
if (watchDirs.length > 0) {
|
|
1237
|
+
const after = await runner.call('snap', { dirs: watchDirs }, { timeoutMs: 90_000 });
|
|
1238
|
+
seen.push(...fileObservations(journey, before.dirs ?? {}, after.dirs ?? {}));
|
|
1239
|
+
} else {
|
|
1240
|
+
seen.push(notCovered({
|
|
1241
|
+
channel: 'effects',
|
|
1242
|
+
path: joinPath('file', journey.name, 'anything written'),
|
|
1243
|
+
reason: 'needs a sample',
|
|
1244
|
+
says: 'Nothing was watched on disk, because no folders were named. Add "watchDirs" under "windows" in the '
|
|
1245
|
+
+ 'config and what this app saves becomes visible.',
|
|
1246
|
+
}));
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
const alive = await runner.call('alive', { pid });
|
|
1250
|
+
seen.push(observation({
|
|
1251
|
+
channel: 'complaints',
|
|
1252
|
+
path: joinPath('proc', journey.name, 'still running'),
|
|
1253
|
+
value: Boolean(alive.running),
|
|
1254
|
+
says: alive.running
|
|
1255
|
+
? 'The app was still running when the check finished, which is what a window app should do.'
|
|
1256
|
+
: 'The app had already exited by the time the check finished. For a window app that usually means it fell over.',
|
|
1257
|
+
journey: journey.name,
|
|
1258
|
+
surface: 'windows',
|
|
1259
|
+
}));
|
|
1260
|
+
|
|
1261
|
+
const events = await runner.call('events', { since: startedAt }, { timeoutMs: 60_000 });
|
|
1262
|
+
seen.push(...complaintObservations(journey, asList(events.events), path.basename(exe).replace(/\.exe$/i, '')));
|
|
1263
|
+
|
|
1264
|
+
return seen;
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
// The machine went away part way through. Keep everything really seen, and say plainly
|
|
1267
|
+
// that the rest is unchecked. Never let a short run look like a clean one.
|
|
1268
|
+
return [...seen, notCovered({
|
|
1269
|
+
channel: 'meaning',
|
|
1270
|
+
path: joinPath('screen', journey.name, 'the rest of it'),
|
|
1271
|
+
reason: 'timed out',
|
|
1272
|
+
says: `"${journey.describe}" stopped part way through on ${host}: `
|
|
1273
|
+
+ `${error instanceof Error ? error.message : String(error)}. Everything after that point is unchecked, `
|
|
1274
|
+
+ 'not unchanged.',
|
|
1275
|
+
})];
|
|
1276
|
+
} finally {
|
|
1277
|
+
if (pid && link && link.alive) {
|
|
1278
|
+
try { await link.call('stop', { pid }, { timeoutMs: 15_000 }); } catch { /* the link is gone; teardown says so */ }
|
|
1279
|
+
startedHere = startedHere.filter((p) => p !== pid);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Put the machine back the way it was found.
|
|
1286
|
+
*
|
|
1287
|
+
* Only ever stops what this run started — the probe itself refuses any other pid, and that
|
|
1288
|
+
* refusal is the last line of defence for somebody's real work sitting on that desktop.
|
|
1289
|
+
*/
|
|
1290
|
+
async teardown() {
|
|
1291
|
+
if (link) {
|
|
1292
|
+
for (const pid of startedHere.slice()) {
|
|
1293
|
+
try { await link.call('stop', { pid }, { timeoutMs: 10_000 }); } catch { /* going away anyway */ }
|
|
1294
|
+
}
|
|
1295
|
+
startedHere = [];
|
|
1296
|
+
try { await link.close(); } catch { /* already closed */ }
|
|
1297
|
+
link = null;
|
|
1298
|
+
}
|
|
1299
|
+
},
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* One paragraph about what this adapter can do on a given machine, for `doctor` and for an
|
|
1304
|
+
* agent reading the tool's own description of itself.
|
|
1305
|
+
*
|
|
1306
|
+
* @param {import('../remote.js').RemoteDescription} remote
|
|
1307
|
+
* @returns {string}
|
|
1308
|
+
*/
|
|
1309
|
+
export function describeWindows(remote) {
|
|
1310
|
+
if (!remote.reachable) {
|
|
1311
|
+
return `No Windows desktop answers through "${remote.host}", so a native Windows app cannot be checked from `
|
|
1312
|
+
+ 'here. If the Windows product is Electron — most desktop products are — it is already covered over its '
|
|
1313
|
+
+ 'debug port and nothing is missing.';
|
|
1314
|
+
}
|
|
1315
|
+
if (!remote.windows) {
|
|
1316
|
+
return `"${remote.host}" answers, but there is no Windows behind it. A native Windows window can only be read `
|
|
1317
|
+
+ 'from Windows itself.';
|
|
1318
|
+
}
|
|
1319
|
+
if (remote.desktopLoggedIn === false) {
|
|
1320
|
+
return `"${remote.host}" is a Windows machine, but nobody is signed in on it. There is nothing on a desktop `
|
|
1321
|
+
+ 'nobody has signed into, so signing in once and leaving the session running is what turns this on.';
|
|
1322
|
+
}
|
|
1323
|
+
const locked = remote.desktopLocked
|
|
1324
|
+
? ' The screen is locked, which is fine — controls read correctly, only full-screen pictures come back black.'
|
|
1325
|
+
: '';
|
|
1326
|
+
return `${remote.windowsVersion ?? 'Windows'} is reachable through "${remote.host}" and its desktop is signed in, `
|
|
1327
|
+
+ `so a native Windows app can be opened there and read.${locked} One build at a time, always: Windows shows `
|
|
1328
|
+
+ 'one desktop and two cannot be up at once.';
|
|
1329
|
+
}
|