@nimoxie/dsh-ui-tweaks 0.2.0 → 0.2.1

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.
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nimoxie/dsh-ui-tweaks",
3
- "version": "0.2.0",
4
- "description": "dsh Web UI 微调合集:宽表格越界修复、对话区左侧自绘滚动条、插件更新检查面板(不含 dsh 本体)",
3
+ "version": "0.2.1",
4
+ "description": "dsh Web UI 微调合集:回合尾「本回合改动」卡在会话释放后仍按官方样式显示(用会话日志重建数据喂给官方卡)、宽表格越界修复、对话区左侧自绘滚动条、动画修复、插件更新检查面板、桌面端窗口记忆(不含 dsh 本体)",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {