@nimoxie/dsh-ui-tweaks 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEVELOPMENT.md +28 -6
- package/LICENSE +21 -21
- package/README.md +126 -119
- package/cordis.patch.yml +7 -7
- package/lib/client.js +617 -59
- package/lib/index.js +400 -19
- package/lib/window-helper.cs +195 -195
- package/lib/window-helper.ps1 +276 -276
- package/package.json +1 -1
package/lib/window-helper.ps1
CHANGED
|
@@ -1,276 +1,276 @@
|
|
|
1
|
-
# ui-tweaks.window-helper.ps1
|
|
2
|
-
# Host-side helper for the dsh-ui-tweaks "remember window size/position" feature.
|
|
3
|
-
#
|
|
4
|
-
# Spawned by lib/index.js inside the dsh runtime of the official DeepSeek
|
|
5
|
-
# Harness desktop client (ELECTRON_RUN_AS_NODE=1). Loads the Win32 interop
|
|
6
|
-
# from window-helper.dll (precompiled by lib/index.js with the OS-shipped
|
|
7
|
-
# csc; in-process assembly load, unlike a locally built exe, never triggers
|
|
8
|
-
# the "unknown publisher" security dialog).
|
|
9
|
-
#
|
|
10
|
-
# Host discovery: the client process tree shares one exe name and looks like
|
|
11
|
-
# main -> --expose-internals -> spawn-bridge -> dsh runtime -> (this helper)
|
|
12
|
-
# so the host is the TOPMOST ancestor still named "DeepSeek Harness" (it owns
|
|
13
|
-
# the main window; first-match would pick a windowless middle node). Walked
|
|
14
|
-
# via ntdll ParentPid + .NET Process (no WMI latency).
|
|
15
|
-
#
|
|
16
|
-
# Window identification: top-level windows of the host PID filtered by class
|
|
17
|
-
# Chrome_WidgetWin_1 + WS_THICKFRAME (resizable) + >=400x300, largest wins.
|
|
18
|
-
#
|
|
19
|
-
# Restore: the main window is created hidden (show:false) and shown after the
|
|
20
|
-
# page loads; a 100ms poll applies the saved bounds while still hidden (no
|
|
21
|
-
# visible jump). Safety net: within 15s of start, an already-visible window
|
|
22
|
-
# still gets a catch-up restore. Maximized state is restored on the hidden
|
|
23
|
-
# window so rcNormalPosition (the windowed rect) is preserved.
|
|
24
|
-
#
|
|
25
|
-
# Save: 1s poll of GetWindowPlacement; JSON written atomically on change only.
|
|
26
|
-
#
|
|
27
|
-
# Exit: runtime or host PID dies. Single instance via lock file next to this
|
|
28
|
-
# script (newest helper kills the previous one).
|
|
29
|
-
#
|
|
30
|
-
# NOTE: keep this file pure ASCII. It is written with a UTF-8 BOM by
|
|
31
|
-
# lib/index.js, but PS 5.1 + ANSI + non-ASCII has bitten three times already.
|
|
32
|
-
|
|
33
|
-
param(
|
|
34
|
-
[int]$DshPid = 0,
|
|
35
|
-
[Parameter(Mandatory = $true)][string]$StateFile,
|
|
36
|
-
[string]$LogPath = ''
|
|
37
|
-
)
|
|
38
|
-
|
|
39
|
-
$ErrorActionPreference = 'Continue'
|
|
40
|
-
$ScriptDir = Split-Path -Parent $PSCommandPath
|
|
41
|
-
|
|
42
|
-
# ---------------------------------------------------------------- logging ---
|
|
43
|
-
function Write-Log([string]$Message) {
|
|
44
|
-
if (-not $LogPath) { return }
|
|
45
|
-
try {
|
|
46
|
-
$line = ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message)
|
|
47
|
-
$item = Get-Item -LiteralPath $LogPath -EA SilentlyContinue
|
|
48
|
-
if ($item -and $item.Length -gt 96KB) {
|
|
49
|
-
$tail = Get-Content -LiteralPath $LogPath -Tail 400 -EA SilentlyContinue
|
|
50
|
-
Set-Content -LiteralPath $LogPath -Value $tail -EA SilentlyContinue
|
|
51
|
-
}
|
|
52
|
-
Add-Content -LiteralPath $LogPath -Value $line -EA SilentlyContinue
|
|
53
|
-
} catch { }
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
# --------------------------------------------------------- win32 (from dll) ---
|
|
57
|
-
$dll = Join-Path $ScriptDir 'window-helper.dll'
|
|
58
|
-
try {
|
|
59
|
-
# Assembly.Load(byte[]) instead of Add-Type -Path / LoadFrom: the profile
|
|
60
|
-
# may live on a cloud-synced/virtual drive, which .NET treats as remote
|
|
61
|
-
# and blocks with 0x80131515; byte-loading has no zone check and no
|
|
62
|
-
# security prompt either.
|
|
63
|
-
[void][System.Reflection.Assembly]::Load([IO.File]::ReadAllBytes($dll))
|
|
64
|
-
} catch {
|
|
65
|
-
Write-Log ('exit: window-helper.dll load failed: ' + $_.Exception.Message)
|
|
66
|
-
exit 1
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
# --------------------------------------------------------- single instance ---
|
|
70
|
-
# Lock file holds the current helper PID; the next helper validates and kills it.
|
|
71
|
-
$LockPath = Join-Path $ScriptDir 'window-helper.lock'
|
|
72
|
-
function Stop-OtherHelpers {
|
|
73
|
-
try {
|
|
74
|
-
if (Test-Path -LiteralPath $LockPath) {
|
|
75
|
-
$old = Get-Content -LiteralPath $LockPath -EA SilentlyContinue | Select-Object -First 1
|
|
76
|
-
if ($old -match '^\d+$') {
|
|
77
|
-
$oldProc = Get-Process -Id ([int]$old) -EA SilentlyContinue
|
|
78
|
-
if ($oldProc -and $oldProc.ProcessName -eq 'powershell') {
|
|
79
|
-
Write-Log ('takeover: killing stale helper PID=' + $old)
|
|
80
|
-
try { Stop-Process -Id $oldProc.Id -Force -EA SilentlyContinue } catch { }
|
|
81
|
-
Start-Sleep -Milliseconds 400
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
Set-Content -LiteralPath $LockPath -Value $PID -Encoding ASCII
|
|
86
|
-
} catch { }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
# --------------------------------------------------------- host discovery ---
|
|
90
|
-
$reHost = 'deepseek[ _-]?harness(\.exe)?$'
|
|
91
|
-
|
|
92
|
-
function Find-HostPid([int]$FromPid) {
|
|
93
|
-
# Walk up while ancestors keep the client exe name; host = topmost match.
|
|
94
|
-
$cur = $FromPid
|
|
95
|
-
$hostPid = 0
|
|
96
|
-
$chain = @()
|
|
97
|
-
for ($i = 0; $i -lt 10; $i++) {
|
|
98
|
-
$pp = [DshWin]::ParentPid([long]$cur)
|
|
99
|
-
if ($pp -le 0) { break }
|
|
100
|
-
$proc = Get-Process -Id $pp -EA SilentlyContinue
|
|
101
|
-
if (-not $proc) { break }
|
|
102
|
-
# PID-reuse guard: a real parent cannot be younger than its child.
|
|
103
|
-
try {
|
|
104
|
-
$childProc = Get-Process -Id $cur -EA SilentlyContinue
|
|
105
|
-
if ($childProc -and $proc.StartTime -gt $childProc.StartTime.AddSeconds(1)) {
|
|
106
|
-
Write-Log ('parent walk stopped: PID ' + $pp + ' looks reused')
|
|
107
|
-
break
|
|
108
|
-
}
|
|
109
|
-
} catch { break }
|
|
110
|
-
$chain += ('PID=' + $pp + ' ' + $proc.ProcessName)
|
|
111
|
-
if ($proc.ProcessName -match $reHost) { $hostPid = $pp } else { break }
|
|
112
|
-
$cur = $pp
|
|
113
|
-
}
|
|
114
|
-
if ($hostPid) { Write-Log ('host found: ' + ($chain -join ' <- ')) }
|
|
115
|
-
else { Write-Log ('host NOT found. chain: ' + ($chain -join ' <- ')) }
|
|
116
|
-
return $hostPid
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
# ------------------------------------------------------ window discovery ----
|
|
120
|
-
function Find-PrimaryWindow([uint32]$HostPid) {
|
|
121
|
-
$best = $null
|
|
122
|
-
$bestArea = 0
|
|
123
|
-
foreach ($w in [DshWin]::WindowsOfPid($HostPid)) {
|
|
124
|
-
if ($w.ClassName -ne 'Chrome_WidgetWin_1') { continue }
|
|
125
|
-
if (($w.Style -band 0x00040000) -eq 0) { continue } # WS_THICKFRAME: resizable only
|
|
126
|
-
# Size check uses GetWindowPlacement's normal rect, not GetWindowRect:
|
|
127
|
-
# a minimized window reports (-32000,-32000) 160x28, which the size gate
|
|
128
|
-
# would filter out - the helper then believes the window vanished and stops
|
|
129
|
-
# recording (measured: minimizing once broke tracking until the next launch).
|
|
130
|
-
$cmd = [uint32]0
|
|
131
|
-
$norm = New-Object 'DshWin+RECT'
|
|
132
|
-
if (-not [DshWin]::Placement($w.Hwnd, [ref]$cmd, [ref]$norm)) { continue }
|
|
133
|
-
$wd = $norm.Right - $norm.Left
|
|
134
|
-
$ht = $norm.Bottom - $norm.Top
|
|
135
|
-
if ($wd -lt 400 -or $ht -lt 300) { continue }
|
|
136
|
-
if (($wd * $ht) -gt $bestArea) { $best = $w; $bestArea = $wd * $ht }
|
|
137
|
-
}
|
|
138
|
-
return $best
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function Read-State {
|
|
142
|
-
try {
|
|
143
|
-
$raw = [IO.File]::ReadAllText($StateFile)
|
|
144
|
-
$s = $raw | ConvertFrom-Json
|
|
145
|
-
if ($s.version -ne 1) { return $null }
|
|
146
|
-
foreach ($k in @('x', 'y', 'w', 'h')) { if (($s.$k -isnot [int]) -and ($s.$k -isnot [long]) -and ($s.$k -isnot [double])) { return $null } }
|
|
147
|
-
return $s
|
|
148
|
-
} catch { return $null }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
# ------------------------------------------------------------------ main ----
|
|
152
|
-
if ($DshPid -le 0) { Write-Log 'exit: no DshPid'; exit 1 }
|
|
153
|
-
$rootPid = $DshPid
|
|
154
|
-
$hostPid = Find-HostPid $DshPid
|
|
155
|
-
if (-not $hostPid) { Write-Log 'exit: host (DeepSeek Harness.exe) not found'; exit 0 }
|
|
156
|
-
Stop-OtherHelpers
|
|
157
|
-
Write-Log ('helper start PID=' + $PID + ' mode=desktop dshPid=' + $DshPid + ' hostPid=' + $hostPid)
|
|
158
|
-
|
|
159
|
-
$bootGraceEnd = (Get-Date).AddSeconds(15) # catch-up restore allowed while boot is still settling
|
|
160
|
-
$restoredFor = [IntPtr]::Zero
|
|
161
|
-
$lastSig = ''
|
|
162
|
-
$saveCount = 0
|
|
163
|
-
|
|
164
|
-
# Phase A: wait for the primary window; restore into it while still hidden.
|
|
165
|
-
$deadline = (Get-Date).AddSeconds(180)
|
|
166
|
-
while ($true) {
|
|
167
|
-
if (-not (Get-Process -Id $rootPid -EA SilentlyContinue)) { Write-Log 'exit: root dsh process gone'; exit 0 }
|
|
168
|
-
if (-not (Get-Process -Id $hostPid -EA SilentlyContinue)) { Write-Log 'exit: host process gone'; exit 0 }
|
|
169
|
-
|
|
170
|
-
$win = Find-PrimaryWindow $hostPid
|
|
171
|
-
if ($win) {
|
|
172
|
-
Write-Log ('window found: hwnd=' + $win.Hwnd + ' rect=' + $win.Rect.Left + ',' + $win.Rect.Top + ' ' + ($win.Rect.Right - $win.Rect.Left) + 'x' + ($win.Rect.Bottom - $win.Rect.Top) + ' visible=' + $win.Visible)
|
|
173
|
-
break
|
|
174
|
-
}
|
|
175
|
-
if ((Get-Date) -gt $deadline) {
|
|
176
|
-
Write-Log 'no window in 180s, slowing down (5s cadence)'
|
|
177
|
-
break
|
|
178
|
-
}
|
|
179
|
-
Start-Sleep -Milliseconds 100
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
# Main loop: restore once per new hwnd, then keep saving (1s cadence).
|
|
183
|
-
$slow = $false
|
|
184
|
-
$tick = 0
|
|
185
|
-
while ($true) {
|
|
186
|
-
if (-not (Get-Process -Id $rootPid -EA SilentlyContinue)) { Write-Log 'exit: root dsh process gone'; exit 0 }
|
|
187
|
-
if (-not (Get-Process -Id $hostPid -EA SilentlyContinue)) { Write-Log 'exit: host process gone'; exit 0 }
|
|
188
|
-
|
|
189
|
-
$win = Find-PrimaryWindow $hostPid
|
|
190
|
-
if ($win -and [DshWin]::Alive($win.Hwnd)) {
|
|
191
|
-
if ($win.Hwnd -ne $restoredFor) {
|
|
192
|
-
$state = Read-State
|
|
193
|
-
if ($state) {
|
|
194
|
-
$okBounds = [DshWin]::IntersectsMonitor([int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
195
|
-
$hidden = -not [DshWin]::Visible($win.Hwnd)
|
|
196
|
-
if (-not $okBounds) {
|
|
197
|
-
Write-Log 'restore skipped: saved bounds off every monitor'
|
|
198
|
-
} elseif ($hidden) {
|
|
199
|
-
[void][DshWin]::MoveTo($win.Hwnd, [int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
200
|
-
if ($state.maximized) { [void][DshWin]::Maximize($win.Hwnd) }
|
|
201
|
-
Write-Log ('restore applied (pre-show): ' + $state.x + ',' + $state.y + ' ' + $state.w + 'x' + $state.h + ' maximized=' + $state.maximized)
|
|
202
|
-
} elseif ((Get-Date) -lt $bootGraceEnd) {
|
|
203
|
-
# Safety net for boots where the hidden phase was missed.
|
|
204
|
-
$curCmd = [uint32]0
|
|
205
|
-
$curRect = New-Object 'DshWin+RECT'
|
|
206
|
-
[void][DshWin]::Placement($win.Hwnd, [ref]$curCmd, [ref]$curRect)
|
|
207
|
-
$differs = ([Math]::Abs($curRect.Left - $state.x) -gt 5) -or ([Math]::Abs($curRect.Top - $state.y) -gt 5) -or
|
|
208
|
-
([Math]::Abs(($curRect.Right - $curRect.Left) - $state.w) -gt 5) -or ([Math]::Abs(($curRect.Bottom - $curRect.Top) - $state.h) -gt 5)
|
|
209
|
-
$maxDiffers = ($state.maximized -and $curCmd -ne 3)
|
|
210
|
-
if ($differs -or $maxDiffers) {
|
|
211
|
-
# Hide -> move -> show. Moving an already-visible window makes
|
|
212
|
-
# the user watch it appear at the default spot and then jump -
|
|
213
|
-
# the main reason it felt like nothing changed. Moving while
|
|
214
|
-
# hidden leaves at most one frame at the default position; the
|
|
215
|
-
# original foreground state is preserved so focus is not stolen.
|
|
216
|
-
$wasFg = [DshWin]::IsForeground($win.Hwnd)
|
|
217
|
-
# A maximized window ignores SetWindowPos (its placement is owned by
|
|
218
|
-
# the monitor), so restore to windowed first when the saved state is
|
|
219
|
-
# not maximized. Safe here: the window is already visible.
|
|
220
|
-
if ($curCmd -eq 3 -and -not $state.maximized) { [void][DshWin]::Restore($win.Hwnd); Start-Sleep -Milliseconds 60 }
|
|
221
|
-
[void][DshWin]::Hide($win.Hwnd)
|
|
222
|
-
[void][DshWin]::MoveTo($win.Hwnd, [int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
223
|
-
if ($state.maximized) { [void][DshWin]::Maximize($win.Hwnd) }
|
|
224
|
-
else { [void][DshWin]::Show($win.Hwnd, $wasFg) }
|
|
225
|
-
Write-Log ('restore applied (catch-up, window already shown): ' + $state.x + ',' + $state.y + ' ' + $state.w + 'x' + $state.h + ' maximized=' + $state.maximized)
|
|
226
|
-
} else {
|
|
227
|
-
Write-Log 'restore skipped: window already at saved bounds'
|
|
228
|
-
}
|
|
229
|
-
} else {
|
|
230
|
-
Write-Log 'restore skipped: window already visible (late adoption)'
|
|
231
|
-
}
|
|
232
|
-
} else {
|
|
233
|
-
Write-Log 'no usable state file yet (first run)'
|
|
234
|
-
}
|
|
235
|
-
$restoredFor = $win.Hwnd
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
$showCmd = [uint32]0
|
|
239
|
-
$norm = New-Object 'DshWin+RECT'
|
|
240
|
-
if ([DshWin]::Placement($win.Hwnd, [ref]$showCmd, [ref]$norm)) {
|
|
241
|
-
# Maximized-then-minimized still counts as maximized
|
|
242
|
-
# (WPF_RESTORETOMAXIMIZED); without it, maximize -> minimize -> close
|
|
243
|
-
# degrades into a windowed restore on the next launch.
|
|
244
|
-
$maximized = [DshWin]::MaximizedState($win.Hwnd)
|
|
245
|
-
# Compare by value signature without a timestamp, otherwise every
|
|
246
|
-
# poll would rewrite the file even when nothing changed.
|
|
247
|
-
$sig = '{0},{1},{2},{3},{4}' -f $norm.Left, $norm.Top, ($norm.Right - $norm.Left), ($norm.Bottom - $norm.Top), $maximized
|
|
248
|
-
if ($sig -ne $lastSig) {
|
|
249
|
-
$lastSig = $sig
|
|
250
|
-
$json = '{{"version":1,"x":{0},"y":{1},"w":{2},"h":{3},"maximized":{4},"updatedAt":"{5}"}}' -f `
|
|
251
|
-
$norm.Left, $norm.Top, ($norm.Right - $norm.Left), ($norm.Bottom - $norm.Top),
|
|
252
|
-
$(if ($maximized) { 'true' } else { 'false' }), (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss')
|
|
253
|
-
try {
|
|
254
|
-
$tmp = $StateFile + '.tmp'
|
|
255
|
-
[IO.File]::WriteAllText($tmp, $json)
|
|
256
|
-
Move-Item -LiteralPath $tmp -Destination $StateFile -Force
|
|
257
|
-
$saveCount++
|
|
258
|
-
Write-Log ('state saved #' + $saveCount + ': ' + $json)
|
|
259
|
-
} catch {
|
|
260
|
-
Write-Log ('state write failed: ' + $_.Exception.Message)
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
$slow = $false
|
|
265
|
-
$tick++
|
|
266
|
-
if (($tick % 60) -eq 0) {
|
|
267
|
-
Write-Log ('tick ' + $tick + ': hwnd=' + $win.Hwnd + ' sig=' + $sig)
|
|
268
|
-
}
|
|
269
|
-
Start-Sleep -Milliseconds 1000
|
|
270
|
-
} else {
|
|
271
|
-
# Window gone (destroyed or not yet created): retry. 5s cadence once
|
|
272
|
-
# past the initial 180s catch-up window, 1s before that.
|
|
273
|
-
if (-not $slow) { $slow = $true; Write-Log 'window gone, polling for it' }
|
|
274
|
-
Start-Sleep -Seconds $(if ((Get-Date) -gt $deadline) { 5 } else { 1 })
|
|
275
|
-
}
|
|
276
|
-
}
|
|
1
|
+
# ui-tweaks.window-helper.ps1
|
|
2
|
+
# Host-side helper for the dsh-ui-tweaks "remember window size/position" feature.
|
|
3
|
+
#
|
|
4
|
+
# Spawned by lib/index.js inside the dsh runtime of the official DeepSeek
|
|
5
|
+
# Harness desktop client (ELECTRON_RUN_AS_NODE=1). Loads the Win32 interop
|
|
6
|
+
# from window-helper.dll (precompiled by lib/index.js with the OS-shipped
|
|
7
|
+
# csc; in-process assembly load, unlike a locally built exe, never triggers
|
|
8
|
+
# the "unknown publisher" security dialog).
|
|
9
|
+
#
|
|
10
|
+
# Host discovery: the client process tree shares one exe name and looks like
|
|
11
|
+
# main -> --expose-internals -> spawn-bridge -> dsh runtime -> (this helper)
|
|
12
|
+
# so the host is the TOPMOST ancestor still named "DeepSeek Harness" (it owns
|
|
13
|
+
# the main window; first-match would pick a windowless middle node). Walked
|
|
14
|
+
# via ntdll ParentPid + .NET Process (no WMI latency).
|
|
15
|
+
#
|
|
16
|
+
# Window identification: top-level windows of the host PID filtered by class
|
|
17
|
+
# Chrome_WidgetWin_1 + WS_THICKFRAME (resizable) + >=400x300, largest wins.
|
|
18
|
+
#
|
|
19
|
+
# Restore: the main window is created hidden (show:false) and shown after the
|
|
20
|
+
# page loads; a 100ms poll applies the saved bounds while still hidden (no
|
|
21
|
+
# visible jump). Safety net: within 15s of start, an already-visible window
|
|
22
|
+
# still gets a catch-up restore. Maximized state is restored on the hidden
|
|
23
|
+
# window so rcNormalPosition (the windowed rect) is preserved.
|
|
24
|
+
#
|
|
25
|
+
# Save: 1s poll of GetWindowPlacement; JSON written atomically on change only.
|
|
26
|
+
#
|
|
27
|
+
# Exit: runtime or host PID dies. Single instance via lock file next to this
|
|
28
|
+
# script (newest helper kills the previous one).
|
|
29
|
+
#
|
|
30
|
+
# NOTE: keep this file pure ASCII. It is written with a UTF-8 BOM by
|
|
31
|
+
# lib/index.js, but PS 5.1 + ANSI + non-ASCII has bitten three times already.
|
|
32
|
+
|
|
33
|
+
param(
|
|
34
|
+
[int]$DshPid = 0,
|
|
35
|
+
[Parameter(Mandatory = $true)][string]$StateFile,
|
|
36
|
+
[string]$LogPath = ''
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
$ErrorActionPreference = 'Continue'
|
|
40
|
+
$ScriptDir = Split-Path -Parent $PSCommandPath
|
|
41
|
+
|
|
42
|
+
# ---------------------------------------------------------------- logging ---
|
|
43
|
+
function Write-Log([string]$Message) {
|
|
44
|
+
if (-not $LogPath) { return }
|
|
45
|
+
try {
|
|
46
|
+
$line = ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'), $Message)
|
|
47
|
+
$item = Get-Item -LiteralPath $LogPath -EA SilentlyContinue
|
|
48
|
+
if ($item -and $item.Length -gt 96KB) {
|
|
49
|
+
$tail = Get-Content -LiteralPath $LogPath -Tail 400 -EA SilentlyContinue
|
|
50
|
+
Set-Content -LiteralPath $LogPath -Value $tail -EA SilentlyContinue
|
|
51
|
+
}
|
|
52
|
+
Add-Content -LiteralPath $LogPath -Value $line -EA SilentlyContinue
|
|
53
|
+
} catch { }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
# --------------------------------------------------------- win32 (from dll) ---
|
|
57
|
+
$dll = Join-Path $ScriptDir 'window-helper.dll'
|
|
58
|
+
try {
|
|
59
|
+
# Assembly.Load(byte[]) instead of Add-Type -Path / LoadFrom: the profile
|
|
60
|
+
# may live on a cloud-synced/virtual drive, which .NET treats as remote
|
|
61
|
+
# and blocks with 0x80131515; byte-loading has no zone check and no
|
|
62
|
+
# security prompt either.
|
|
63
|
+
[void][System.Reflection.Assembly]::Load([IO.File]::ReadAllBytes($dll))
|
|
64
|
+
} catch {
|
|
65
|
+
Write-Log ('exit: window-helper.dll load failed: ' + $_.Exception.Message)
|
|
66
|
+
exit 1
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# --------------------------------------------------------- single instance ---
|
|
70
|
+
# Lock file holds the current helper PID; the next helper validates and kills it.
|
|
71
|
+
$LockPath = Join-Path $ScriptDir 'window-helper.lock'
|
|
72
|
+
function Stop-OtherHelpers {
|
|
73
|
+
try {
|
|
74
|
+
if (Test-Path -LiteralPath $LockPath) {
|
|
75
|
+
$old = Get-Content -LiteralPath $LockPath -EA SilentlyContinue | Select-Object -First 1
|
|
76
|
+
if ($old -match '^\d+$') {
|
|
77
|
+
$oldProc = Get-Process -Id ([int]$old) -EA SilentlyContinue
|
|
78
|
+
if ($oldProc -and $oldProc.ProcessName -eq 'powershell') {
|
|
79
|
+
Write-Log ('takeover: killing stale helper PID=' + $old)
|
|
80
|
+
try { Stop-Process -Id $oldProc.Id -Force -EA SilentlyContinue } catch { }
|
|
81
|
+
Start-Sleep -Milliseconds 400
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
Set-Content -LiteralPath $LockPath -Value $PID -Encoding ASCII
|
|
86
|
+
} catch { }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------- host discovery ---
|
|
90
|
+
$reHost = 'deepseek[ _-]?harness(\.exe)?$'
|
|
91
|
+
|
|
92
|
+
function Find-HostPid([int]$FromPid) {
|
|
93
|
+
# Walk up while ancestors keep the client exe name; host = topmost match.
|
|
94
|
+
$cur = $FromPid
|
|
95
|
+
$hostPid = 0
|
|
96
|
+
$chain = @()
|
|
97
|
+
for ($i = 0; $i -lt 10; $i++) {
|
|
98
|
+
$pp = [DshWin]::ParentPid([long]$cur)
|
|
99
|
+
if ($pp -le 0) { break }
|
|
100
|
+
$proc = Get-Process -Id $pp -EA SilentlyContinue
|
|
101
|
+
if (-not $proc) { break }
|
|
102
|
+
# PID-reuse guard: a real parent cannot be younger than its child.
|
|
103
|
+
try {
|
|
104
|
+
$childProc = Get-Process -Id $cur -EA SilentlyContinue
|
|
105
|
+
if ($childProc -and $proc.StartTime -gt $childProc.StartTime.AddSeconds(1)) {
|
|
106
|
+
Write-Log ('parent walk stopped: PID ' + $pp + ' looks reused')
|
|
107
|
+
break
|
|
108
|
+
}
|
|
109
|
+
} catch { break }
|
|
110
|
+
$chain += ('PID=' + $pp + ' ' + $proc.ProcessName)
|
|
111
|
+
if ($proc.ProcessName -match $reHost) { $hostPid = $pp } else { break }
|
|
112
|
+
$cur = $pp
|
|
113
|
+
}
|
|
114
|
+
if ($hostPid) { Write-Log ('host found: ' + ($chain -join ' <- ')) }
|
|
115
|
+
else { Write-Log ('host NOT found. chain: ' + ($chain -join ' <- ')) }
|
|
116
|
+
return $hostPid
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------ window discovery ----
|
|
120
|
+
function Find-PrimaryWindow([uint32]$HostPid) {
|
|
121
|
+
$best = $null
|
|
122
|
+
$bestArea = 0
|
|
123
|
+
foreach ($w in [DshWin]::WindowsOfPid($HostPid)) {
|
|
124
|
+
if ($w.ClassName -ne 'Chrome_WidgetWin_1') { continue }
|
|
125
|
+
if (($w.Style -band 0x00040000) -eq 0) { continue } # WS_THICKFRAME: resizable only
|
|
126
|
+
# Size check uses GetWindowPlacement's normal rect, not GetWindowRect:
|
|
127
|
+
# a minimized window reports (-32000,-32000) 160x28, which the size gate
|
|
128
|
+
# would filter out - the helper then believes the window vanished and stops
|
|
129
|
+
# recording (measured: minimizing once broke tracking until the next launch).
|
|
130
|
+
$cmd = [uint32]0
|
|
131
|
+
$norm = New-Object 'DshWin+RECT'
|
|
132
|
+
if (-not [DshWin]::Placement($w.Hwnd, [ref]$cmd, [ref]$norm)) { continue }
|
|
133
|
+
$wd = $norm.Right - $norm.Left
|
|
134
|
+
$ht = $norm.Bottom - $norm.Top
|
|
135
|
+
if ($wd -lt 400 -or $ht -lt 300) { continue }
|
|
136
|
+
if (($wd * $ht) -gt $bestArea) { $best = $w; $bestArea = $wd * $ht }
|
|
137
|
+
}
|
|
138
|
+
return $best
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function Read-State {
|
|
142
|
+
try {
|
|
143
|
+
$raw = [IO.File]::ReadAllText($StateFile)
|
|
144
|
+
$s = $raw | ConvertFrom-Json
|
|
145
|
+
if ($s.version -ne 1) { return $null }
|
|
146
|
+
foreach ($k in @('x', 'y', 'w', 'h')) { if (($s.$k -isnot [int]) -and ($s.$k -isnot [long]) -and ($s.$k -isnot [double])) { return $null } }
|
|
147
|
+
return $s
|
|
148
|
+
} catch { return $null }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
# ------------------------------------------------------------------ main ----
|
|
152
|
+
if ($DshPid -le 0) { Write-Log 'exit: no DshPid'; exit 1 }
|
|
153
|
+
$rootPid = $DshPid
|
|
154
|
+
$hostPid = Find-HostPid $DshPid
|
|
155
|
+
if (-not $hostPid) { Write-Log 'exit: host (DeepSeek Harness.exe) not found'; exit 0 }
|
|
156
|
+
Stop-OtherHelpers
|
|
157
|
+
Write-Log ('helper start PID=' + $PID + ' mode=desktop dshPid=' + $DshPid + ' hostPid=' + $hostPid)
|
|
158
|
+
|
|
159
|
+
$bootGraceEnd = (Get-Date).AddSeconds(15) # catch-up restore allowed while boot is still settling
|
|
160
|
+
$restoredFor = [IntPtr]::Zero
|
|
161
|
+
$lastSig = ''
|
|
162
|
+
$saveCount = 0
|
|
163
|
+
|
|
164
|
+
# Phase A: wait for the primary window; restore into it while still hidden.
|
|
165
|
+
$deadline = (Get-Date).AddSeconds(180)
|
|
166
|
+
while ($true) {
|
|
167
|
+
if (-not (Get-Process -Id $rootPid -EA SilentlyContinue)) { Write-Log 'exit: root dsh process gone'; exit 0 }
|
|
168
|
+
if (-not (Get-Process -Id $hostPid -EA SilentlyContinue)) { Write-Log 'exit: host process gone'; exit 0 }
|
|
169
|
+
|
|
170
|
+
$win = Find-PrimaryWindow $hostPid
|
|
171
|
+
if ($win) {
|
|
172
|
+
Write-Log ('window found: hwnd=' + $win.Hwnd + ' rect=' + $win.Rect.Left + ',' + $win.Rect.Top + ' ' + ($win.Rect.Right - $win.Rect.Left) + 'x' + ($win.Rect.Bottom - $win.Rect.Top) + ' visible=' + $win.Visible)
|
|
173
|
+
break
|
|
174
|
+
}
|
|
175
|
+
if ((Get-Date) -gt $deadline) {
|
|
176
|
+
Write-Log 'no window in 180s, slowing down (5s cadence)'
|
|
177
|
+
break
|
|
178
|
+
}
|
|
179
|
+
Start-Sleep -Milliseconds 100
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
# Main loop: restore once per new hwnd, then keep saving (1s cadence).
|
|
183
|
+
$slow = $false
|
|
184
|
+
$tick = 0
|
|
185
|
+
while ($true) {
|
|
186
|
+
if (-not (Get-Process -Id $rootPid -EA SilentlyContinue)) { Write-Log 'exit: root dsh process gone'; exit 0 }
|
|
187
|
+
if (-not (Get-Process -Id $hostPid -EA SilentlyContinue)) { Write-Log 'exit: host process gone'; exit 0 }
|
|
188
|
+
|
|
189
|
+
$win = Find-PrimaryWindow $hostPid
|
|
190
|
+
if ($win -and [DshWin]::Alive($win.Hwnd)) {
|
|
191
|
+
if ($win.Hwnd -ne $restoredFor) {
|
|
192
|
+
$state = Read-State
|
|
193
|
+
if ($state) {
|
|
194
|
+
$okBounds = [DshWin]::IntersectsMonitor([int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
195
|
+
$hidden = -not [DshWin]::Visible($win.Hwnd)
|
|
196
|
+
if (-not $okBounds) {
|
|
197
|
+
Write-Log 'restore skipped: saved bounds off every monitor'
|
|
198
|
+
} elseif ($hidden) {
|
|
199
|
+
[void][DshWin]::MoveTo($win.Hwnd, [int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
200
|
+
if ($state.maximized) { [void][DshWin]::Maximize($win.Hwnd) }
|
|
201
|
+
Write-Log ('restore applied (pre-show): ' + $state.x + ',' + $state.y + ' ' + $state.w + 'x' + $state.h + ' maximized=' + $state.maximized)
|
|
202
|
+
} elseif ((Get-Date) -lt $bootGraceEnd) {
|
|
203
|
+
# Safety net for boots where the hidden phase was missed.
|
|
204
|
+
$curCmd = [uint32]0
|
|
205
|
+
$curRect = New-Object 'DshWin+RECT'
|
|
206
|
+
[void][DshWin]::Placement($win.Hwnd, [ref]$curCmd, [ref]$curRect)
|
|
207
|
+
$differs = ([Math]::Abs($curRect.Left - $state.x) -gt 5) -or ([Math]::Abs($curRect.Top - $state.y) -gt 5) -or
|
|
208
|
+
([Math]::Abs(($curRect.Right - $curRect.Left) - $state.w) -gt 5) -or ([Math]::Abs(($curRect.Bottom - $curRect.Top) - $state.h) -gt 5)
|
|
209
|
+
$maxDiffers = ($state.maximized -and $curCmd -ne 3)
|
|
210
|
+
if ($differs -or $maxDiffers) {
|
|
211
|
+
# Hide -> move -> show. Moving an already-visible window makes
|
|
212
|
+
# the user watch it appear at the default spot and then jump -
|
|
213
|
+
# the main reason it felt like nothing changed. Moving while
|
|
214
|
+
# hidden leaves at most one frame at the default position; the
|
|
215
|
+
# original foreground state is preserved so focus is not stolen.
|
|
216
|
+
$wasFg = [DshWin]::IsForeground($win.Hwnd)
|
|
217
|
+
# A maximized window ignores SetWindowPos (its placement is owned by
|
|
218
|
+
# the monitor), so restore to windowed first when the saved state is
|
|
219
|
+
# not maximized. Safe here: the window is already visible.
|
|
220
|
+
if ($curCmd -eq 3 -and -not $state.maximized) { [void][DshWin]::Restore($win.Hwnd); Start-Sleep -Milliseconds 60 }
|
|
221
|
+
[void][DshWin]::Hide($win.Hwnd)
|
|
222
|
+
[void][DshWin]::MoveTo($win.Hwnd, [int]$state.x, [int]$state.y, [int]$state.w, [int]$state.h)
|
|
223
|
+
if ($state.maximized) { [void][DshWin]::Maximize($win.Hwnd) }
|
|
224
|
+
else { [void][DshWin]::Show($win.Hwnd, $wasFg) }
|
|
225
|
+
Write-Log ('restore applied (catch-up, window already shown): ' + $state.x + ',' + $state.y + ' ' + $state.w + 'x' + $state.h + ' maximized=' + $state.maximized)
|
|
226
|
+
} else {
|
|
227
|
+
Write-Log 'restore skipped: window already at saved bounds'
|
|
228
|
+
}
|
|
229
|
+
} else {
|
|
230
|
+
Write-Log 'restore skipped: window already visible (late adoption)'
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
Write-Log 'no usable state file yet (first run)'
|
|
234
|
+
}
|
|
235
|
+
$restoredFor = $win.Hwnd
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
$showCmd = [uint32]0
|
|
239
|
+
$norm = New-Object 'DshWin+RECT'
|
|
240
|
+
if ([DshWin]::Placement($win.Hwnd, [ref]$showCmd, [ref]$norm)) {
|
|
241
|
+
# Maximized-then-minimized still counts as maximized
|
|
242
|
+
# (WPF_RESTORETOMAXIMIZED); without it, maximize -> minimize -> close
|
|
243
|
+
# degrades into a windowed restore on the next launch.
|
|
244
|
+
$maximized = [DshWin]::MaximizedState($win.Hwnd)
|
|
245
|
+
# Compare by value signature without a timestamp, otherwise every
|
|
246
|
+
# poll would rewrite the file even when nothing changed.
|
|
247
|
+
$sig = '{0},{1},{2},{3},{4}' -f $norm.Left, $norm.Top, ($norm.Right - $norm.Left), ($norm.Bottom - $norm.Top), $maximized
|
|
248
|
+
if ($sig -ne $lastSig) {
|
|
249
|
+
$lastSig = $sig
|
|
250
|
+
$json = '{{"version":1,"x":{0},"y":{1},"w":{2},"h":{3},"maximized":{4},"updatedAt":"{5}"}}' -f `
|
|
251
|
+
$norm.Left, $norm.Top, ($norm.Right - $norm.Left), ($norm.Bottom - $norm.Top),
|
|
252
|
+
$(if ($maximized) { 'true' } else { 'false' }), (Get-Date -Format 'yyyy-MM-ddTHH:mm:ss')
|
|
253
|
+
try {
|
|
254
|
+
$tmp = $StateFile + '.tmp'
|
|
255
|
+
[IO.File]::WriteAllText($tmp, $json)
|
|
256
|
+
Move-Item -LiteralPath $tmp -Destination $StateFile -Force
|
|
257
|
+
$saveCount++
|
|
258
|
+
Write-Log ('state saved #' + $saveCount + ': ' + $json)
|
|
259
|
+
} catch {
|
|
260
|
+
Write-Log ('state write failed: ' + $_.Exception.Message)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
$slow = $false
|
|
265
|
+
$tick++
|
|
266
|
+
if (($tick % 60) -eq 0) {
|
|
267
|
+
Write-Log ('tick ' + $tick + ': hwnd=' + $win.Hwnd + ' sig=' + $sig)
|
|
268
|
+
}
|
|
269
|
+
Start-Sleep -Milliseconds 1000
|
|
270
|
+
} else {
|
|
271
|
+
# Window gone (destroyed or not yet created): retry. 5s cadence once
|
|
272
|
+
# past the initial 180s catch-up window, 1s before that.
|
|
273
|
+
if (-not $slow) { $slow = $true; Write-Log 'window gone, polling for it' }
|
|
274
|
+
Start-Sleep -Seconds $(if ((Get-Date) -gt $deadline) { 5 } else { 1 })
|
|
275
|
+
}
|
|
276
|
+
}
|
package/package.json
CHANGED