dsh-spectacles 0.0.0-stage → 1.0.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.
@@ -0,0 +1,214 @@
1
+ param(
2
+ [string]$Window = "",
3
+ [string]$Crop = "",
4
+ [double]$Scale = 1.0,
5
+ [switch]$Image,
6
+ [switch]$List,
7
+ [string]$Out = "",
8
+ [string]$SaveDir = ""
9
+ )
10
+ $DataDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look' }
11
+ if (-not (Test-Path $DataDir)) { New-Item -ItemType Directory -Force -Path $DataDir | Out-Null }
12
+ if (-not $SaveDir) { $SaveDir = $DataDir }
13
+ $ErrorActionPreference = 'Stop'
14
+
15
+ Add-Type -AssemblyName System.Drawing
16
+ Add-Type -ReferencedAssemblies System.Drawing -TypeDefinition @"
17
+ using System;
18
+ using System.Text;
19
+ using System.Collections.Generic;
20
+ using System.Runtime.InteropServices;
21
+
22
+ public class W2 {
23
+ public delegate bool EnumProc(IntPtr h, IntPtr p);
24
+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr p);
25
+ [DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);
26
+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);
27
+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);
28
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
29
+ [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int idx);
30
+ [DllImport("user32.dll")] public static extern bool PrintWindow(IntPtr h, IntPtr hdc, uint flags);
31
+
32
+ public struct RECT { public int Left, Top, Right, Bottom; }
33
+
34
+ public class Win { public IntPtr H; public string Title; public uint Pid; public int L, T, R, B; }
35
+
36
+ public static List<Win> List() {
37
+ var res = new List<Win>();
38
+ EnumWindows((h, p) => {
39
+ if (!IsWindowVisible(h)) return true;
40
+ var sb = new StringBuilder(512);
41
+ GetWindowText(h, sb, 512);
42
+ string t = sb.ToString();
43
+ if (t.Length == 0) return true;
44
+ RECT r; if (!GetWindowRect(h, out r)) return true;
45
+ uint pid; GetWindowThreadProcessId(h, out pid);
46
+ var w = new Win(); w.H = h; w.Title = t; w.Pid = pid;
47
+ w.L = r.Left; w.T = r.Top; w.R = r.Right; w.B = r.Bottom;
48
+ res.Add(w);
49
+ return true;
50
+ }, IntPtr.Zero);
51
+ return res;
52
+ }
53
+ }
54
+ "@
55
+
56
+ function Resolve-Window([string]$sel) {
57
+ $all = [W2]::List()
58
+
59
+ # picked:用人点过的那一个(look-pick.ps1 写 picked.json)
60
+ if ($sel -eq 'picked') {
61
+ $pf = Join-Path $DataDir 'picked.json'
62
+ if (-not (Test-Path $pf)) { return $null }
63
+ $pj = Get-Content $pf -Raw -Encoding UTF8 | ConvertFrom-Json
64
+ $h = [IntPtr][long]$pj.hwnd
65
+ return ($all | Where-Object { $_.H -eq $h } | Select-Object -First 1)
66
+ }
67
+
68
+ # @名字:查白名单 windows.json
69
+ if ($sel.StartsWith('@')) {
70
+ $name = $sel.Substring(1)
71
+ $cf = Join-Path $DataDir 'windows.json'
72
+ if (-not (Test-Path $cf)) { return $null }
73
+ $cfg = Get-Content $cf -Raw -Encoding UTF8 | ConvertFrom-Json
74
+ $entry = $cfg.windows | Where-Object { $_.name -eq $name } | Select-Object -First 1
75
+ if (-not $entry) { return $null }
76
+ if ($entry.title) { return ($all | Where-Object { $_.Title -like "*$($entry.title)*" } | Select-Object -First 1) }
77
+ return $null
78
+ }
79
+
80
+ if ($sel -match '^\d+$') {
81
+ $h = [IntPtr][long]$sel
82
+ return ($all | Where-Object { $_.H -eq $h } | Select-Object -First 1)
83
+ }
84
+ if ($sel -match '^pid:(\d+)$') {
85
+ $p = [uint32]$Matches[1]
86
+ return ($all | Where-Object { $_.Pid -eq $p } | Select-Object -First 1)
87
+ }
88
+ return ($all | Where-Object { $_.Title -like "*$sel*" } | Select-Object -First 1)
89
+ }
90
+
91
+ if ($List) {
92
+ $all = [W2]::List() | Where-Object { ($_.R - $_.L) -gt 0 -and ($_.B - $_.T) -gt 0 }
93
+ $rows = @()
94
+ foreach ($w in $all) {
95
+ $rows += [ordered]@{
96
+ hwnd = [int64]$w.H
97
+ pid = $w.Pid
98
+ title = $w.Title
99
+ rect = [ordered]@{ x = $w.L; y = $w.T; w = ($w.R - $w.L); h = ($w.B - $w.T) }
100
+ }
101
+ }
102
+ [ordered]@{ ok = $true; count = $rows.Count; windows = $rows } | ConvertTo-Json -Compress -Depth 6
103
+ exit 0
104
+ }
105
+ if (-not $Window) { throw "need -Window (hwnd | title | pid:1234)" }
106
+
107
+ $w = Resolve-Window $Window
108
+ if (-not $w) { @{ ok = $false; error = "window not found: $Window" } | ConvertTo-Json -Compress; exit 1 }
109
+
110
+ $ww = $w.R - $w.L
111
+ $wh = $w.B - $w.T
112
+ if ($ww -le 0 -or $wh -le 0) { @{ ok = $false; error = "bad rect" } | ConvertTo-Json -Compress; exit 1 }
113
+
114
+ $full = [System.Drawing.Bitmap]::new([int]$ww, [int]$wh)
115
+ $g = [System.Drawing.Graphics]::FromImage($full)
116
+ $hdc = $g.GetHdc()
117
+ $ok = [W2]::PrintWindow($w.H, $hdc, 2)
118
+ $g.ReleaseHdc($hdc)
119
+ $g.Dispose()
120
+ if (-not $ok) { @{ ok = $false; error = "PrintWindow failed" } | ConvertTo-Json -Compress; exit 1 }
121
+
122
+ # 可选区域裁剪(相对窗口左上角)
123
+ $rect = New-Object System.Drawing.Rectangle 0, 0, $ww, $wh
124
+ if ($Crop) {
125
+ $c = $Crop -split ','
126
+ $rect = New-Object System.Drawing.Rectangle ([int]$c[0]), ([int]$c[1]), ([int]$c[2]), ([int]$c[3])
127
+ if ($rect.X -lt 0) { $rect.X = 0 }
128
+ if ($rect.Y -lt 0) { $rect.Y = 0 }
129
+ if ($rect.X + $rect.Width -gt $ww) { $rect.Width = $ww - $rect.X }
130
+ if ($rect.Y + $rect.Height -gt $wh) { $rect.Height = $wh - $rect.Y }
131
+ }
132
+
133
+ $img = $full.Clone($rect, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb)
134
+
135
+ # 统计:平均色 + 边缘密度 + 64x36 灰度网格(用于比对"有没有变")
136
+ $bw = 64; $bh = 36
137
+ $small = [System.Drawing.Bitmap]::new([int]$bw, [int]$bh)
138
+ $gs = [System.Drawing.Graphics]::FromImage($small)
139
+ $gs.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
140
+ $gs.DrawImage($img, 0, 0, $bw, $bh)
141
+ $gs.Dispose()
142
+
143
+ $grid = New-Object 'int[]' ($bw * $bh)
144
+ $sumR = 0.0; $sumG = 0.0; $sumB = 0.0; $n = 0
145
+ for ($y = 0; $y -lt $bh; $y++) {
146
+ for ($x = 0; $x -lt $bw; $x++) {
147
+ $c = $small.GetPixel($x, $y)
148
+ $gray = [int](0.299 * $c.R + 0.587 * $c.G + 0.114 * $c.B)
149
+ $grid[$y * $bw + $x] = $gray
150
+ $sumR += $c.R; $sumG += $c.G; $sumB += $c.B; $n++
151
+ }
152
+ }
153
+
154
+ # 边缘密度:相邻像素差 > 24 的比例
155
+ $edge = 0; $tot = 0
156
+ for ($y = 0; $y -lt $bh; $y++) {
157
+ for ($x = 1; $x -lt $bw; $x++) {
158
+ $d = [Math]::Abs($grid[$y * $bw + $x] - $grid[$y * $bw + $x - 1])
159
+ if ($d -gt 24) { $edge++ }
160
+ $tot++
161
+ }
162
+ }
163
+ $edgeDensity = [Math]::Round($edge / [Math]::Max(1, $tot), 4)
164
+
165
+ $key = "w$($w.Pid)_$($rect.X)_$($rect.Y)_$($rect.Width)x$($rect.Height)"
166
+ if (-not (Test-Path $SaveDir)) { New-Item -ItemType Directory -Path $SaveDir -Force | Out-Null }
167
+ $lastFile = Join-Path $SaveDir "last-$key.txt"
168
+ $sig = ($grid -join ',')
169
+ $changed = $null
170
+ if (Test-Path $lastFile) {
171
+ $prev = Get-Content $lastFile -Raw
172
+ if ($prev) { $changed = -not ($prev -eq $sig) }
173
+ }
174
+ Set-Content -Path $lastFile -Value $sig -NoNewline
175
+
176
+ $pngPath = $null
177
+ if ($Image) {
178
+ if (-not $Out) { $Out = Join-Path $SaveDir ("win-" + (Get-Date -Format 'HHmmss') + ".png") }
179
+ $dir = Split-Path $Out -Parent
180
+ if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
181
+ if ($Scale -gt 1) {
182
+ $sx = [int]($img.Width * $Scale); $sy = [int]($img.Height * $Scale)
183
+ $big = [System.Drawing.Bitmap]::new([int]$sx, [int]$sy)
184
+ $gb = [System.Drawing.Graphics]::FromImage($big)
185
+ $gb.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
186
+ $gb.DrawImage($img, 0, 0, $sx, $sy)
187
+ $gb.Dispose()
188
+ $big.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
189
+ $big.Dispose()
190
+ } else {
191
+ $img.Save($Out, [System.Drawing.Imaging.ImageFormat]::Png)
192
+ }
193
+ $pngPath = $Out
194
+ }
195
+
196
+ $avg = "#{0:X2}{1:X2}{2:X2}" -f [int]($sumR / $n), [int]($sumG / $n), [int]($sumB / $n)
197
+
198
+ $res = [ordered]@{
199
+ ok = $true
200
+ when = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff')
201
+ source = 'printwindow'
202
+ window = [ordered]@{ hwnd = [int64]$w.H; title = $w.Title; pid = $w.Pid; rect = @($w.L, $w.T, $ww, $wh) }
203
+ region = @($rect.X, $rect.Y, $rect.Width, $rect.Height)
204
+ avgColor = $avg
205
+ edgeDensity = $edgeDensity
206
+ changed = $changed
207
+ grid = "$bw x $bh"
208
+ }
209
+ if ($pngPath) { $res.png = $pngPath }
210
+
211
+ $img.Dispose(); $full.Dispose(); $small.Dispose()
212
+
213
+ $res | ConvertTo-Json -Compress
214
+
@@ -0,0 +1,198 @@
1
+ param(
2
+ [string]$Crop = '',
3
+ [string]$File = '',
4
+ [int]$S1 = 3,
5
+ [int]$S2 = 4
6
+ )
7
+
8
+ $ErrorActionPreference = 'Stop'
9
+ [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
10
+
11
+ Add-Type -AssemblyName System.Runtime.WindowsRuntime
12
+ Add-Type -AssemblyName System.Drawing
13
+
14
+ $tmpDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look' }
15
+ if (-not (Test-Path $tmpDir)) { New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null }
16
+
17
+ $src = $File
18
+ if ($Crop -ne '') {
19
+ $stamp = Get-Date -Format 'yyyyMMdd-HHmmss-fff'
20
+ $dst = Join-Path $tmpDir "read-$stamp.png"
21
+ if ($File -ne '' -and (Test-Path $File)) {
22
+ # 已经有图:从这张图上裁(窗口截图 + 相对窗口的 crop 走这一条)
23
+ $im = [System.Drawing.Bitmap]::FromFile($File)
24
+ try {
25
+ $c = $Crop -split ','
26
+ if ($c.Length -eq 4) {
27
+ $rx = [Math]::Max(0, [int]$c[0]); $ry = [Math]::Max(0, [int]$c[1])
28
+ $rw = [int]$c[2]; $rh = [int]$c[3]
29
+ if ($rx -ge $im.Width) { $rx = 0 }
30
+ if ($ry -ge $im.Height) { $ry = 0 }
31
+ if ($rx + $rw -gt $im.Width) { $rw = $im.Width - $rx }
32
+ if ($ry + $rh -gt $im.Height) { $rh = $im.Height - $ry }
33
+ if ($rw -ge 1 -and $rh -ge 1) {
34
+ $rect = New-Object System.Drawing.Rectangle $rx, $ry, $rw, $rh
35
+ $bmp = $im.Clone($rect, $im.PixelFormat)
36
+ $bmp.Save($dst, [System.Drawing.Imaging.ImageFormat]::Png)
37
+ $bmp.Dispose()
38
+ $src = $dst
39
+ }
40
+ }
41
+ }
42
+ finally { $im.Dispose() }
43
+ }
44
+ else {
45
+ # 没有现成的图:整屏抓一块
46
+ $look = Join-Path $PSScriptRoot 'look.ps1'
47
+ $larg = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $look, '-Crop', $Crop, '-Image', '-Out', $dst)
48
+ & powershell.exe @larg | Out-Null
49
+ if (Test-Path $dst) { $src = $dst }
50
+ }
51
+ }
52
+ if (-not (Test-Path $src)) { '{"ok":false,"error":"no image"}'; exit 1 }
53
+
54
+ $asTask = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object {
55
+ $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and
56
+ $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1'
57
+ })[0]
58
+ function Await($op, $type) {
59
+ $t = $asTask.MakeGenericMethod($type).Invoke($null, @($op))
60
+ $t.Wait()
61
+ return $t.Result
62
+ }
63
+ [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime] | Out-Null
64
+ [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics.Imaging, ContentType = WindowsRuntime] | Out-Null
65
+ [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime] | Out-Null
66
+
67
+ $engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()
68
+ if ($null -eq $engine) { '{"ok":false,"error":"no ocr engine"}'; exit 1 }
69
+
70
+ $base = [System.Drawing.Image]::FromFile($src)
71
+ $sw = [System.Diagnostics.Stopwatch]::StartNew()
72
+
73
+ function Join-OcrWords($words) {
74
+ # Windows OCR 把中文的每个字当一个 word,直接拼 $_.Text 会变成「新 会 话」。
75
+ # 按字框之间的空隙判:挨得紧就连着写,明显分开才补一个空格。
76
+ $sb = New-Object System.Text.StringBuilder
77
+ $prev = $null
78
+ foreach ($w in $words) {
79
+ $t = $w.Text
80
+ if ([string]::IsNullOrEmpty($t)) { continue }
81
+ if ($null -ne $prev) {
82
+ $gap = $w.BoundingRect.X - ($prev.BoundingRect.X + $prev.BoundingRect.Width)
83
+ $hh = [Math]::Max($w.BoundingRect.Height, $prev.BoundingRect.Height)
84
+ if ($gap -gt ($hh * 0.20)) { [void]$sb.Append(' ') }
85
+ }
86
+ [void]$sb.Append($t)
87
+ $prev = $w
88
+ }
89
+ return $sb.ToString()
90
+ }
91
+
92
+ function Ocr-Scaled($img, [int]$scale, [string]$tag) {
93
+ $bw = $img.Width * $scale
94
+ $bh = $img.Height * $scale
95
+ $b = New-Object System.Drawing.Bitmap $bw, $bh
96
+ $g = [System.Drawing.Graphics]::FromImage($b)
97
+ $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
98
+ $g.DrawImage($img, 0, 0, $bw, $bh)
99
+ $g.Dispose()
100
+ $png = Join-Path $tmpDir ("ocr-" + $tag + ".png")
101
+ $b.Save($png, [System.Drawing.Imaging.ImageFormat]::Png)
102
+ $b.Dispose()
103
+
104
+ $f = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($png)) ([Windows.Storage.StorageFile])
105
+ $st = Await ($f.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])
106
+ $dec = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($st)) ([Windows.Graphics.Imaging.BitmapDecoder])
107
+ $sb = Await ($dec.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])
108
+ $r = Await ($engine.RecognizeAsync($sb)) ([Windows.Media.Ocr.OcrResult])
109
+ $st.Dispose()
110
+ return @($r.Lines | ForEach-Object { Join-OcrWords $_.Words })
111
+ }
112
+
113
+ # Windows OCR 有单边像素上限(OcrEngine.MaxImageDimension),整屏放大 3x/4x 会超限
114
+ $maxDim = 10000
115
+ try { $maxDim = [int][Windows.Media.Ocr.OcrEngine]::MaxImageDimension } catch {}
116
+ if ($maxDim -le 0) { $maxDim = 10000 }
117
+
118
+ # 图本身比上限还大时先整体缩小(否则连 1x 都过不了)
119
+ $longSide = [Math]::Max($base.Width, $base.Height)
120
+ $capped = $false
121
+ if ($longSide -gt $maxDim) {
122
+ $f = $maxDim / $longSide
123
+ $nw = [int][Math]::Max(1, [Math]::Floor($base.Width * $f))
124
+ $nh = [int][Math]::Max(1, [Math]::Floor($base.Height * $f))
125
+ $small = New-Object System.Drawing.Bitmap $nw, $nh
126
+ $gs = [System.Drawing.Graphics]::FromImage($small)
127
+ $gs.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
128
+ $gs.DrawImage($base, 0, 0, $nw, $nh)
129
+ $gs.Dispose()
130
+ $base.Dispose()
131
+ $base = $small
132
+ $capped = $true
133
+ $longSide = [Math]::Max($nw, $nh)
134
+ }
135
+
136
+ $cap = [int][Math]::Floor($maxDim / [Math]::Max(1, $longSide))
137
+ if ($cap -lt 1) { $cap = 1 }
138
+ $s1 = [Math]::Min($S1, $cap); if ($s1 -lt 1) { $s1 = 1 }
139
+ $s2 = [Math]::Min($S2, $cap); if ($s2 -lt 1) { $s2 = 1 }
140
+ # 两轮要不同倍率才有比对意义
141
+ if ($s1 -eq $s2 -and $s2 -gt 1) { $s1 = $s2 - 1 }
142
+
143
+ $h1 = @(Ocr-Scaled $base $s1 "a")
144
+ $h2 = @(Ocr-Scaled $base $s2 "b")
145
+ $base.Dispose()
146
+ $sw.Stop()
147
+
148
+ $A = @($h1 | Where-Object { $_.Trim() -ne '' })
149
+ $B = @($h2 | Where-Object { $_.Trim() -ne '' })
150
+
151
+ function Sim($x, $y) {
152
+ if ($x -eq $y) { return 1.0 }
153
+ $cx = @{}; foreach ($c in $x.ToCharArray()) { $cx[$c] = 1 + ($cx[$c] -as [int]) }
154
+ $used = 0
155
+ foreach ($c in $y.ToCharArray()) { if (($cx[$c] -as [int]) -gt 0) { $cx[$c] = $cx[$c] - 1; $used++ } }
156
+ $max = [Math]::Max($x.Length, $y.Length)
157
+ if ($max -eq 0) { return 1.0 }
158
+ return [double]$used / $max
159
+ }
160
+
161
+ $items = New-Object System.Collections.ArrayList
162
+ $usedB = New-Object 'bool[]' $B.Count
163
+ foreach ($a in $A) {
164
+ $best = -1; $bestS = 0.0
165
+ for ($j = 0; $j -lt $B.Count; $j++) {
166
+ if ($usedB[$j]) { continue }
167
+ $s = Sim $a $B[$j]
168
+ if ($s -gt $bestS) { $bestS = $s; $best = $j }
169
+ }
170
+ if ($best -ge 0 -and $bestS -ge 0.6) {
171
+ $usedB[$best] = $true
172
+ if ($B[$best] -eq $a) {
173
+ [void]$items.Add(@{ text = $a; consistent = $true })
174
+ } else {
175
+ $x = $a; $y = $B[$best]
176
+ if ($y.Length -gt $x.Length) { $x = $B[$best]; $y = $a }
177
+ [void]$items.Add(@{ text = $x; alt = $y; consistent = $false })
178
+ }
179
+ } else {
180
+ [void]$items.Add(@{ text = $a; alt = $null; consistent = $false; uncertain = $true })
181
+ }
182
+ }
183
+
184
+ $chars = 0
185
+ foreach ($it in $items) { $chars += $it.text.Length }
186
+
187
+ $obj = [ordered]@{
188
+ ok = $true
189
+ file = $src
190
+ scales = @($s1, $s2)
191
+ capped = $capped
192
+ lines = $items
193
+ chars = $chars
194
+ disagree = @($items | Where-Object { -not $_.consistent }).Count
195
+ ms = $sw.ElapsedMilliseconds
196
+ }
197
+ $obj | ConvertTo-Json -Depth 6 -Compress
198
+
@@ -0,0 +1,152 @@
1
+ # Spectacles self-test
2
+ # Runs on a machine where the plugin was just installed and reports what works.
3
+ # Usage: powershell -NoProfile -ExecutionPolicy Bypass -File selftest.ps1 [-Json]
4
+ # Exit code 0 = all required checks passed, 1 = something required failed.
5
+
6
+ param([switch]$Json)
7
+
8
+ $ErrorActionPreference = 'Continue'
9
+ $here = $PSScriptRoot
10
+ $dataDir = if ($env:DSH_LOOK_DATA) { $env:DSH_LOOK_DATA } else { Join-Path $env:TEMP 'dsh-look-selftest' }
11
+ if (-not (Test-Path $dataDir)) { New-Item -ItemType Directory -Force -Path $dataDir | Out-Null }
12
+
13
+ $checks = New-Object System.Collections.ArrayList
14
+ function Add-Check([string]$name, [bool]$ok, [string]$detail, [bool]$required = $true) {
15
+ [void]$checks.Add([ordered]@{ name = $name; ok = $ok; required = $required; detail = $detail })
16
+ }
17
+
18
+ # ---------------------------------------------------------------- environment
19
+ $os = [Environment]::OSVersion.Version
20
+ Add-Check 'windows' ($os.Major -ge 10) ('Windows ' + $os.Major + '.' + $os.Minor + ' build ' + $os.Build)
21
+
22
+ $psv = $PSVersionTable.PSVersion
23
+ Add-Check 'powershell' ($psv.Major -ge 5) ('PowerShell ' + $psv.ToString())
24
+
25
+ $needed = @('look.ps1', 'look2.ps1', 'look-pick.ps1', 'read-text.ps1', 'look-region.ps1')
26
+ $missing = @()
27
+ foreach ($n in $needed) { if (-not (Test-Path (Join-Path $here $n))) { $missing += $n } }
28
+ $scriptDetail = 'all ' + $needed.Count + ' scripts present'
29
+ if ($missing.Count -gt 0) { $scriptDetail = 'missing: ' + ($missing -join ', ') }
30
+ Add-Check 'scripts' ($missing.Count -eq 0) $scriptDetail
31
+
32
+ $probe = Join-Path $dataDir 'write-probe.txt'
33
+ $writable = $false
34
+ try {
35
+ Set-Content -Path $probe -Value 'ok' -ErrorAction Stop
36
+ $writable = (Test-Path $probe)
37
+ Remove-Item $probe -Force -ErrorAction SilentlyContinue
38
+ } catch { $writable = $false }
39
+ Add-Check 'temp-writable' $writable $dataDir
40
+
41
+ # ---------------------------------------------------------------- display
42
+ if (-not ('YM.SelfTest' -as [type])) {
43
+ Add-Type -Namespace YM -Name SelfTest -MemberDefinition @'
44
+ [DllImport("user32.dll")] public static extern int GetSystemMetrics(int n);
45
+ [DllImport("user32.dll")] public static extern uint GetDpiForSystem();
46
+ [DllImport("user32.dll")] public static extern bool SetProcessDPIAware();
47
+ '@
48
+ }
49
+ try { [void][YM.SelfTest]::SetProcessDPIAware() } catch {}
50
+
51
+ $sw = [YM.SelfTest]::GetSystemMetrics(0)
52
+ $sh = [YM.SelfTest]::GetSystemMetrics(1)
53
+ Add-Check 'screen-size' ($sw -gt 0 -and $sh -gt 0) ($sw.ToString() + ' x ' + $sh)
54
+
55
+ $dpi = 96
56
+ try { $dpi = [int][YM.SelfTest]::GetDpiForSystem() } catch {}
57
+ Add-Check 'dpi' ($dpi -ge 96) ($dpi.ToString() + ' dpi (' + [math]::Round($dpi / 96 * 100) + '%)')
58
+
59
+ try {
60
+ Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop
61
+ $mon = [System.Windows.Forms.Screen]::AllScreens.Count
62
+ Add-Check 'monitors' ($mon -ge 1) ($mon.ToString() + ' monitor(s)')
63
+ } catch {
64
+ Add-Check 'monitors' $false ('could not query: ' + $_.Exception.Message) $false
65
+ }
66
+
67
+ # ---------------------------------------------------------------- full screen
68
+ $full = Join-Path $dataDir 'selftest-full.png'
69
+ $fullOut = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $here 'look.ps1') -Image -Out $full 2>&1 | Out-String
70
+ $fullOk = $false
71
+ $fullDetail = $fullOut.Trim()
72
+ try {
73
+ $o = $fullOut | ConvertFrom-Json
74
+ $fullOk = [bool]$o.ok -and (Test-Path $full)
75
+ if ($fullOk) { $fullDetail = $o.source + ' ' + $o.screen.w + 'x' + $o.screen.h + ' in ' + $o.captureMs + 'ms' }
76
+ } catch {}
77
+ Add-Check 'fullscreen-capture' $fullOk $fullDetail
78
+
79
+ # ---------------------------------------------------------------- window capture
80
+ $winScript = Join-Path $here 'look2.ps1'
81
+ $picked = $null
82
+ try {
83
+ $listOut = & powershell -NoProfile -ExecutionPolicy Bypass -File $winScript -List 2>&1 | Out-String
84
+ $list = $listOut | ConvertFrom-Json
85
+ if ($list.windows) {
86
+ $picked = $list.windows |
87
+ Where-Object { $_.title -and $_.rect.w -gt 200 -and $_.rect.h -gt 120 -and $_.rect.x -gt -1000 } |
88
+ Select-Object -First 1
89
+ }
90
+ } catch {}
91
+ if ($picked) {
92
+ $winPng = Join-Path $dataDir 'selftest-window.png'
93
+ $winOut = & powershell -NoProfile -ExecutionPolicy Bypass -File $winScript -Window $picked.title -Image -Out $winPng 2>&1 | Out-String
94
+ $winOk = $false
95
+ $winDetail = $winOut.Trim()
96
+ try {
97
+ $o = $winOut | ConvertFrom-Json
98
+ $winOk = [bool]$o.ok -and (Test-Path $winPng)
99
+ if ($winOk) { $winDetail = '"' + $picked.title + '" ' + $o.region[2] + 'x' + $o.region[3] + ' via ' + $o.source }
100
+ } catch {}
101
+ Add-Check 'window-capture' $winOk $winDetail
102
+ } else {
103
+ Add-Check 'window-capture' $false 'no capturable window found (open Notepad and rerun)' $false
104
+ }
105
+
106
+ # ---------------------------------------------------------------- OCR
107
+ if (Test-Path $full) {
108
+ $ocrOut = & powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $here 'read-text.ps1') -File $full 2>&1 | Out-String
109
+ $ocrOk = $false
110
+ $ocrDetail = 'no output'
111
+ try {
112
+ $o = $ocrOut | ConvertFrom-Json
113
+ $ocrOk = [bool]$o.ok -and ([int]$o.chars -gt 0)
114
+ $ocrDetail = $o.chars.ToString() + ' chars, ' + $o.disagree + ' uncertain, ' + $o.ms + 'ms'
115
+ } catch { $ocrDetail = $ocrOut.Trim() }
116
+ Add-Check 'ocr' $ocrOk $ocrDetail
117
+ } else {
118
+ Add-Check 'ocr' $false 'skipped: full screen capture failed'
119
+ }
120
+
121
+ # ---------------------------------------------------------------- optional
122
+ $ff = $env:DSH_LOOK_FFMPEG
123
+ if ($ff) {
124
+ Add-Check 'ffmpeg' (Test-Path $ff) ('DSH_LOOK_FFMPEG=' + $ff) $false
125
+ } else {
126
+ $g = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
127
+ $ffNote = 'not used (optional)'
128
+ if ($g) { $ffNote = 'found on PATH, not used (optional)' }
129
+ Add-Check 'ffmpeg' $true $ffNote $false
130
+ }
131
+
132
+ # ---------------------------------------------------------------- report
133
+ $failed = @($checks | Where-Object { $_.required -and -not $_.ok })
134
+ $ok = ($failed.Count -eq 0)
135
+
136
+ if ($Json) {
137
+ [ordered]@{ ok = $ok; machine = $env:COMPUTERNAME; checks = $checks; dataDir = $dataDir } | ConvertTo-Json -Depth 5
138
+ } else {
139
+ Write-Output ('Spectacles self-test on ' + $env:COMPUTERNAME)
140
+ Write-Output ''
141
+ foreach ($c in $checks) {
142
+ $mark = '[FAIL]'
143
+ if ($c.ok) { $mark = '[ ok ]' } elseif (-not $c.required) { $mark = '[warn]' }
144
+ Write-Output ($mark + ' ' + $c.name.PadRight(18) + $c.detail)
145
+ }
146
+ Write-Output ''
147
+ if ($ok) { Write-Output 'All required checks passed.' }
148
+ else { Write-Output ('Failed: ' + (($failed | ForEach-Object { $_.name }) -join ', ')) }
149
+ Write-Output ('Artifacts: ' + $dataDir)
150
+ }
151
+
152
+ if ($ok) { exit 0 } else { exit 1 }