dsh-console-sentinel 0.3.0 → 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.
- package/CHANGELOG.md +438 -1
- package/README.md +104 -108
- package/README.zh.md +96 -102
- package/docs/screenshots/01-panel-overview.png +0 -0
- package/docs/screenshots/02-confirm-dialog.png +0 -0
- package/docs/screenshots/03-preview-result.png +0 -0
- package/docs/screenshots/04-detail-view.png +0 -0
- package/docs/screenshots/05-console-tab.png +0 -0
- package/docs/screenshots/06-whitelist-tab.png +0 -0
- package/lib/client.js +733 -91
- package/lib/desktop.js +397 -9
- package/lib/index.js +305 -7
- package/lib/probe.js +2 -1
- package/lib/whitelist.js +12 -6
- package/package.json +90 -82
- package/README-es.md +0 -29
- package/README-hi.md +0 -29
- package/README-pt.md +0 -29
package/lib/desktop.js
CHANGED
|
@@ -18,7 +18,10 @@
|
|
|
18
18
|
// explicit keepTitles / keepPids.
|
|
19
19
|
// everything else that is a visible titled app window -> target.
|
|
20
20
|
|
|
21
|
-
import { spawn } from 'node:child_process'
|
|
21
|
+
import { spawn, execFileSync } from 'node:child_process'
|
|
22
|
+
import fs from 'node:fs'
|
|
23
|
+
import os from 'node:os'
|
|
24
|
+
import path from 'node:path'
|
|
22
25
|
|
|
23
26
|
const JSON_BEGIN = '===SENTINEL-DESKTOP-JSON-BEGIN==='
|
|
24
27
|
const JSON_END = '===SENTINEL-DESKTOP-JSON-END==='
|
|
@@ -74,6 +77,39 @@ if (-not ('PInvoke.DesktopW32' -as [type])) {
|
|
|
74
77
|
"@
|
|
75
78
|
}
|
|
76
79
|
|
|
80
|
+
# Token-elevation probe (separate Add-Type so the block above stays untouched).
|
|
81
|
+
if (-not ('PInvoke.ElevK' -as [type])) {
|
|
82
|
+
Add-Type -TypeDefinition @"
|
|
83
|
+
using System; using System.Runtime.InteropServices;
|
|
84
|
+
namespace PInvoke {
|
|
85
|
+
public class ElevK {
|
|
86
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint a, bool i, int pid);
|
|
87
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool CloseHandle(IntPtr h);
|
|
88
|
+
[DllImport("advapi32.dll", SetLastError=true)] public static extern bool OpenProcessToken(IntPtr p, uint a, out IntPtr t);
|
|
89
|
+
[DllImport("advapi32.dll", SetLastError=true)] public static extern bool GetTokenInformation(IntPtr t, int c, out int info, int len, out int ret);
|
|
90
|
+
public static int Elev(int pid) {
|
|
91
|
+
IntPtr h = OpenProcess(0x1000, false, pid);
|
|
92
|
+
if (h == IntPtr.Zero) return -1;
|
|
93
|
+
try {
|
|
94
|
+
IntPtr tok;
|
|
95
|
+
if (!OpenProcessToken(h, 0x0008, out tok)) return -2;
|
|
96
|
+
try { int info, ret; if (!GetTokenInformation(tok, 20, out info, 4, out ret)) return -3; return info; }
|
|
97
|
+
finally { CloseHandle(tok); }
|
|
98
|
+
} finally { CloseHandle(h); }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
"@
|
|
103
|
+
}
|
|
104
|
+
$elevCache = @{}
|
|
105
|
+
function Get-Elev([int]$procid) {
|
|
106
|
+
if ($elevCache.ContainsKey($procid)) { return $elevCache[$procid] }
|
|
107
|
+
$e = $false
|
|
108
|
+
try { if ([PInvoke.ElevK]::Elev($procid) -eq 1) { $e = $true } } catch {}
|
|
109
|
+
$elevCache[$procid] = $e
|
|
110
|
+
return $e
|
|
111
|
+
}
|
|
112
|
+
|
|
77
113
|
$excludeClass = @(${SHELL_CLASSES_PS})
|
|
78
114
|
$GWL_EXSTYLE = -20
|
|
79
115
|
$WS_EX_APPWINDOW = 0x00040000
|
|
@@ -81,7 +117,33 @@ $WS_EX_TOOLWINDOW = 0x00000080
|
|
|
81
117
|
$GW_OWNER = 4
|
|
82
118
|
|
|
83
119
|
$allProcs = @{}
|
|
84
|
-
|
|
120
|
+
$pathMap = @{}
|
|
121
|
+
foreach ($p in [System.Diagnostics.Process]::GetProcesses()) {
|
|
122
|
+
$allProcs[[int]$p.Id] = $p.ProcessName
|
|
123
|
+
try { if ($p.Path) { $pathMap[[int]$p.Id] = $p.Path } } catch {}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Load System.Drawing once for icon extraction
|
|
127
|
+
try { Add-Type -AssemblyName System.Drawing -ErrorAction SilentlyContinue } catch {}
|
|
128
|
+
$iconCache = @{}
|
|
129
|
+
function Get-IconData([string]$exePath) {
|
|
130
|
+
if ([string]::IsNullOrWhiteSpace($exePath)) { return '' }
|
|
131
|
+
if ($iconCache.ContainsKey($exePath)) { return $iconCache[$exePath] }
|
|
132
|
+
$result = ''
|
|
133
|
+
try {
|
|
134
|
+
$icon = [System.Drawing.Icon]::ExtractAssociatedIcon($exePath)
|
|
135
|
+
if ($icon) {
|
|
136
|
+
$bmp = $icon.ToBitmap()
|
|
137
|
+
$ms = New-Object System.IO.MemoryStream
|
|
138
|
+
$bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
|
|
139
|
+
$bytes = $ms.ToArray()
|
|
140
|
+
$result = 'data:image/png;base64,' + [Convert]::ToBase64String($bytes)
|
|
141
|
+
$bmp.Dispose(); $icon.Dispose(); $ms.Dispose()
|
|
142
|
+
}
|
|
143
|
+
} catch {}
|
|
144
|
+
$iconCache[$exePath] = $result
|
|
145
|
+
return $result
|
|
146
|
+
}
|
|
85
147
|
|
|
86
148
|
$wins = New-Object System.Collections.Generic.List[object]
|
|
87
149
|
$fg = [PInvoke.DesktopW32]::GetForegroundWindow()
|
|
@@ -102,13 +164,18 @@ $cb = [PInvoke.DesktopW32+EnumWindowsProc]{
|
|
|
102
164
|
$procid = 0; $null = [PInvoke.DesktopW32]::GetWindowThreadProcessId($hWnd, [ref]$procid)
|
|
103
165
|
$pname = ''
|
|
104
166
|
if ($allProcs.ContainsKey([int]$procid)) { $pname = $allProcs[[int]$procid] }
|
|
105
|
-
$
|
|
167
|
+
$icon = ''
|
|
168
|
+
if ($pathMap.ContainsKey([int]$procid)) { $icon = Get-IconData $pathMap[[int]$procid] }
|
|
169
|
+
$elevated = Get-Elev ([int]$procid)
|
|
170
|
+
$wins.Add([PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $title; Pid = [int]$procid; Process = $pname; Icon = $icon; Elevated = [bool]$elevated })
|
|
106
171
|
return $true
|
|
107
172
|
}
|
|
108
173
|
$null = [PInvoke.DesktopW32]::EnumWindows($cb, [IntPtr]::Zero)
|
|
109
174
|
[System.GC]::KeepAlive($cb)
|
|
110
175
|
|
|
111
|
-
$
|
|
176
|
+
$selfEl = $false
|
|
177
|
+
try { $selfEl = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) } catch {}
|
|
178
|
+
$out = [PSCustomObject]@{ windows = $wins; fgHwnd = $fg.ToInt64(); selfElevated = [bool]$selfEl }
|
|
112
179
|
Write-Output '__JSON_BEGIN__'
|
|
113
180
|
Write-Output ($out | ConvertTo-Json -Depth 4 -Compress)
|
|
114
181
|
Write-Output '__JSON_END__'
|
|
@@ -152,7 +219,7 @@ export async function runDesktopProbe(timeoutMs = 20000) {
|
|
|
152
219
|
if (!json) return { ok: false, reason: 'no-marker-output', stderr: res.stderr.slice(-400) }
|
|
153
220
|
try {
|
|
154
221
|
const parsed = JSON.parse(json)
|
|
155
|
-
return { ok: true, windows: parsed.windows || [], fgHwnd: parsed.fgHwnd || 0 }
|
|
222
|
+
return { ok: true, windows: parsed.windows || [], fgHwnd: parsed.fgHwnd || 0, selfElevated: Boolean(parsed.selfElevated) }
|
|
156
223
|
} catch (err) {
|
|
157
224
|
return { ok: false, reason: 'json-parse: ' + err.message, stderr: res.stdout.slice(-400) }
|
|
158
225
|
}
|
|
@@ -194,8 +261,16 @@ export function selectCleanupTargets(windows, opts = {}) {
|
|
|
194
261
|
class: raw.class ?? raw.Class ?? '',
|
|
195
262
|
title: raw.title ?? raw.Title ?? '',
|
|
196
263
|
process: raw.process ?? raw.Process ?? '',
|
|
264
|
+
icon: raw.icon ?? raw.Icon ?? '',
|
|
265
|
+
elevated: Boolean(raw.elevated ?? raw.Elevated),
|
|
197
266
|
}
|
|
198
267
|
const proc = String(w.process).toLowerCase().replace(/\.exe$/, '')
|
|
268
|
+
// Never select DSH/Electron, Fluent/MPI/ANSYS/solver consoles — bulk-closing
|
|
269
|
+
// those kills the host or an in-progress CFD run. Belt-and-suspenders on top
|
|
270
|
+
// of the protectedProcesses list (mpiexec consoles look like plain apps).
|
|
271
|
+
if (/mpi|fluent|ansys|^cx\d|solver|cae|launcher|^dsh|electron|deepseek/.test(proc)) {
|
|
272
|
+
kept.push({ ...w, keepReason: 'hard-protected' }); continue
|
|
273
|
+
}
|
|
199
274
|
if (w.hwnd === fgHwnd) { kept.push({ ...w, keepReason: 'foreground' }); continue }
|
|
200
275
|
if (selfPid && w.pid === selfPid) { kept.push({ ...w, keepReason: 'self' }); continue }
|
|
201
276
|
if (SHELL_CLASSES.includes(w.class)) { kept.push({ ...w, keepReason: 'shell-class' }); continue }
|
|
@@ -269,15 +344,328 @@ export async function runDesktopCleanup(opts = {}) {
|
|
|
269
344
|
if (!opts.confirm || targets.length === 0) {
|
|
270
345
|
return { ok: true, dryRun: !opts.confirm, fgHwnd: probe.fgHwnd, targets, kept, closed: [] }
|
|
271
346
|
}
|
|
272
|
-
|
|
347
|
+
// Split: normal → plain WM_CLOSE; elevated → one UAC-elevated graceful close
|
|
348
|
+
// (an unelevated WM_CLOSE to an elevated window is silently dropped by UIPI).
|
|
349
|
+
const normal = targets.filter((t) => !t.elevated)
|
|
350
|
+
const elevated = targets.filter((t) => t.elevated)
|
|
351
|
+
const close = await closeDesktopWindows(normal.map((t) => t.hwnd))
|
|
273
352
|
const closedHwnds = new Set((close.results || []).filter((r) => r.Posted).map((r) => r.Hwnd))
|
|
353
|
+
let elevRes = { results: [], closedCount: 0 }
|
|
354
|
+
if (elevated.length) {
|
|
355
|
+
elevRes = await runElevatedClose(elevated.map((t) => t.hwnd), opts.elevatedTimeoutMs || 120000)
|
|
356
|
+
for (const r of (elevRes.results || [])) if (r.Closed) closedHwnds.add(Number(r.Hwnd))
|
|
357
|
+
}
|
|
274
358
|
return {
|
|
275
|
-
ok: close.ok,
|
|
359
|
+
ok: close.ok && (!elevated.length || elevRes.ok !== false || elevRes.closedCount > 0),
|
|
276
360
|
dryRun: false,
|
|
277
361
|
fgHwnd: probe.fgHwnd,
|
|
278
362
|
targets,
|
|
279
363
|
kept,
|
|
280
|
-
closed: targets.filter((t) => closedHwnds.has(t.hwnd)),
|
|
281
|
-
|
|
364
|
+
closed: targets.filter((t) => closedHwnds.has(Number(t.hwnd))),
|
|
365
|
+
elevatedCount: elevated.length,
|
|
366
|
+
elevatedUacCancelled: elevated.length > 0 && elevRes.reason === 'uac-denied-or-cancelled',
|
|
367
|
+
closeFailed: close.ok ? [] : (close.reason ? [{ reason: close.reason }] : normal),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// --- elevated force-kill (UAC path, "强制关闭(管理员)") ------------------------
|
|
372
|
+
//
|
|
373
|
+
// WHY: a window owned by an elevated (admin) process cannot be closed by an
|
|
374
|
+
// unelevated process — Windows UIPI silently DROPS the WM_CLOSE we post, so the
|
|
375
|
+
// button appears to "do nothing" (measured 2026-11-07: Taskmgr / iScrCompressor /
|
|
376
|
+
// iScrRec all ran elevated while the DSH host did not). The only way to act on
|
|
377
|
+
// them is to run taskkill with an elevated token, which requires a UAC prompt.
|
|
378
|
+
//
|
|
379
|
+
// Contract: runElevatedKill(pids) spawns ONE elevated taskkill /F for all pids.
|
|
380
|
+
// The user sees exactly one UAC dialog; answering No yields
|
|
381
|
+
// { ok:false, reason:'uac-denied-or-cancelled' }. We never run taskkill
|
|
382
|
+
// unelevated as a fallback (it would only fail with "拒绝访问" anyway).
|
|
383
|
+
//
|
|
384
|
+
// 🛑 SAFETY: `elevated: true` makes Task.Run show the UAC shield and ASK the
|
|
385
|
+
// user. Without it PowerShell inherits the caller's (unelevated) token, the
|
|
386
|
+
// kill silently fails with access-denied, and the user thinks the button is
|
|
387
|
+
// broken again. Never drop this flag.
|
|
388
|
+
|
|
389
|
+
// Runs INSIDE the elevated child. Writes its verdict as JSON to __OUT_FILE__
|
|
390
|
+
// (the caller cannot read an elevated child's stdout reliably).
|
|
391
|
+
// Liveness uses tasklist (exact PID match) rather than Get-Process: 2026-11-08
|
|
392
|
+
// 实测 Get-Process 在某些会话下对存在进程返回空,导致误判 already-gone。
|
|
393
|
+
const ELEVATED_KILL_SCRIPT = `
|
|
394
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
395
|
+
$ErrorActionPreference = 'Continue'
|
|
396
|
+
$pids = @(__PID_LIST__)
|
|
397
|
+
function Test-Alive([int]$procId) {
|
|
398
|
+
$o = & tasklist.exe /FI "PID eq $procId" /FO CSV /NH 2>$null
|
|
399
|
+
if (-not $o) { return $false }
|
|
400
|
+
$s = ($o | Out-String)
|
|
401
|
+
if ($s -match ('"' + $procId + '"')) { return $true }
|
|
402
|
+
return $false
|
|
403
|
+
}
|
|
404
|
+
$results = @()
|
|
405
|
+
foreach ($p in $pids) {
|
|
406
|
+
if (-not (Test-Alive $p)) { $results += [PSCustomObject]@{ Pid = $p; Killed = $true; Reason = 'already-gone' }; continue }
|
|
407
|
+
$out = & taskkill.exe /F /PID $p 2>&1 | Out-String
|
|
408
|
+
Start-Sleep -Milliseconds 250
|
|
409
|
+
$still = Test-Alive $p
|
|
410
|
+
$results += [PSCustomObject]@{ Pid = $p; Killed = (-not $still); Reason = $(if ($still) { 'still-running' } else { 'killed' }); Detail = $out.Trim() }
|
|
411
|
+
}
|
|
412
|
+
$json = ConvertTo-Json -InputObject @($results) -Depth 4 -Compress
|
|
413
|
+
[System.IO.File]::WriteAllText('__OUT_FILE__', $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
414
|
+
`
|
|
415
|
+
|
|
416
|
+
const EK_BEGIN = '===SENTINEL-EK-BEGIN==='
|
|
417
|
+
const EK_END = '===SENTINEL-EK-END==='
|
|
418
|
+
// (markers kept for backward-compatible logs only; the elevated child now
|
|
419
|
+
// reports via a temp JSON file because its stdout is not readable.)
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Force-kill processes with an elevated token (one UAC prompt for the batch).
|
|
423
|
+
* @param {number[]} pids target process ids
|
|
424
|
+
* @param {number} timeoutMs overall timeout (the user may take a while on UAC)
|
|
425
|
+
*/
|
|
426
|
+
export function runElevatedKill(pids, timeoutMs = 120000) {
|
|
427
|
+
const list = (pids || [])
|
|
428
|
+
.map((p) => Number(p))
|
|
429
|
+
.filter((p) => Number.isInteger(p) && p > 0)
|
|
430
|
+
if (list.length === 0) return Promise.resolve({ ok: false, reason: 'no-pids', results: [] })
|
|
431
|
+
|
|
432
|
+
// 🔴 Why a temp .ps1 + Start-Process -Verb RunAs instead of spawn(-Command):
|
|
433
|
+
// 2026-11-08 实测 — 多行脚本经 `-Command` 传参时换行被 shell 层吃掉,语句
|
|
434
|
+
// 粘连导致存活检测误判 already-gone,notepad 明明活着却报告"已杀",且全程
|
|
435
|
+
// 只花 337ms、没有弹 UAC(因为它压根没提权)。现在改为:
|
|
436
|
+
// ① 把脚本写进临时 .ps1(UTF-8 BOM,中文路径安全);
|
|
437
|
+
// ② 结果写到临时 .json(提权子进程的 stdout 拿不到,只能靠文件回传);
|
|
438
|
+
// ③ 用 Start-Process -Verb RunAs 真正触发 UAC;
|
|
439
|
+
// ④ 等子进程退出后读 .json —— 拿不到就明确报 uac-denied-or-cancelled。
|
|
440
|
+
const stamp = Date.now() + '_' + Math.floor(Math.random() * 1e6)
|
|
441
|
+
const outFile = path.join(os.tmpdir(), 'dsh-sentinel-ek-' + stamp + '.json')
|
|
442
|
+
const psFile = path.join(os.tmpdir(), 'dsh-sentinel-ek-' + stamp + '.ps1')
|
|
443
|
+
|
|
444
|
+
const body = ELEVATED_KILL_SCRIPT
|
|
445
|
+
.replace('__PID_LIST__', list.join(','))
|
|
446
|
+
.replace('__OUT_FILE__', outFile.replace(/'/g, "''"))
|
|
447
|
+
|
|
448
|
+
// Write BOM-marked UTF-8: PS 5.1 without a BOM decodes non-ASCII as GBK and
|
|
449
|
+
// the Chinese reason strings would be mojibake.
|
|
450
|
+
const withBom = '\ufeff' + body
|
|
451
|
+
fs.writeFileSync(psFile, withBom, 'utf8')
|
|
452
|
+
|
|
453
|
+
const cleanup = () => {
|
|
454
|
+
try { fs.unlinkSync(psFile) } catch (_) {}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Launch elevated. Start-Process is run from a (non-elevated) helper; -Verb
|
|
458
|
+
// RunAs makes Windows show the UAC prompt. -Wait blocks until the elevated
|
|
459
|
+
// child exits so we know when to read the result file.
|
|
460
|
+
const launcher =
|
|
461
|
+
"$ErrorActionPreference='Stop'\n" +
|
|
462
|
+
"try {\n" +
|
|
463
|
+
" $p = Start-Process -FilePath 'powershell.exe' " +
|
|
464
|
+
"-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','" + psFile.replace(/'/g, "''") + "') " +
|
|
465
|
+
"-Verb RunAs -WindowStyle Hidden -PassThru -Wait\n" +
|
|
466
|
+
" Write-Output ('EXIT=' + $p.ExitCode)\n" +
|
|
467
|
+
"} catch { Write-Output ('LAUNCH_FAILED=' + $_.Exception.Message) }\n"
|
|
468
|
+
|
|
469
|
+
return new Promise((resolve) => {
|
|
470
|
+
const args = ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', launcher]
|
|
471
|
+
let child
|
|
472
|
+
try {
|
|
473
|
+
child = spawn('powershell.exe', args, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
474
|
+
} catch (err) {
|
|
475
|
+
cleanup()
|
|
476
|
+
return resolve({ ok: false, reason: 'spawn-failed: ' + err.message, results: [] })
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
let stdout = ''
|
|
480
|
+
let stderr = ''
|
|
481
|
+
let done = false
|
|
482
|
+
const finish = (result) => {
|
|
483
|
+
if (done) return
|
|
484
|
+
done = true
|
|
485
|
+
clearTimeout(timer)
|
|
486
|
+
cleanup()
|
|
487
|
+
try { fs.unlinkSync(outFile) } catch (_) {}
|
|
488
|
+
resolve(result)
|
|
489
|
+
}
|
|
490
|
+
const timer = setTimeout(() => {
|
|
491
|
+
try { child.kill() } catch (_) {}
|
|
492
|
+
finish({ ok: false, reason: 'timeout', results: [] })
|
|
493
|
+
}, timeoutMs)
|
|
494
|
+
|
|
495
|
+
child.stdout.on('data', (d) => { stdout += d })
|
|
496
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
497
|
+
child.on('error', (err) => finish({ ok: false, reason: 'spawn-failed: ' + err.message, results: [] }))
|
|
498
|
+
child.on('close', () => {
|
|
499
|
+
// The elevated child's stdout is NOT ours — the truth is in outFile.
|
|
500
|
+
let raw = ''
|
|
501
|
+
try { raw = fs.readFileSync(outFile, 'utf8') } catch (_) { raw = '' }
|
|
502
|
+
if (!raw) {
|
|
503
|
+
const launched = stdout.indexOf('LAUNCH_FAILED=') >= 0
|
|
504
|
+
return finish({
|
|
505
|
+
ok: false,
|
|
506
|
+
// Start-Process throws "操作已被用户取消" when the user clicks No.
|
|
507
|
+
reason: launched ? 'uac-denied-or-cancelled' : 'no-result-file',
|
|
508
|
+
stderr: (stdout + stderr).slice(-500),
|
|
509
|
+
results: [],
|
|
510
|
+
})
|
|
511
|
+
}
|
|
512
|
+
try {
|
|
513
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, '').trim())
|
|
514
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed]
|
|
515
|
+
const killed = arr.filter((r) => r.Killed).length
|
|
516
|
+
finish({ ok: killed > 0, results: arr, killedCount: killed, total: arr.length })
|
|
517
|
+
} catch (err) {
|
|
518
|
+
finish({ ok: false, reason: 'json-parse: ' + err.message, stderr: raw.slice(-300), results: [] })
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Graceful, elevation-aware batch CLOSE (WM_CLOSE = "per-window Alt+F4").
|
|
526
|
+
*
|
|
527
|
+
* Use this instead of taskkill when we want a friendly close of an elevated
|
|
528
|
+
* app's windows (taskkill /F is only for when a graceful close is refused).
|
|
529
|
+
* Runs inside ONE elevated child (one UAC prompt) that PostMessage(WM_CLOSE)s
|
|
530
|
+
* each hwnd, then reports which windows are actually gone via a temp JSON file.
|
|
531
|
+
*
|
|
532
|
+
* @param {number[]} hwnds decimal window handles
|
|
533
|
+
* @param {number} timeoutMs
|
|
534
|
+
*/
|
|
535
|
+
const ELEVATED_CLOSE_SCRIPT = `
|
|
536
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
537
|
+
$ErrorActionPreference = 'Continue'
|
|
538
|
+
$hwnds = @(__HWND_LIST__)
|
|
539
|
+
if (-not ('PInvoke.CloseW' -as [type])) {
|
|
540
|
+
Add-Type -TypeDefinition @"
|
|
541
|
+
using System; using System.Runtime.InteropServices;
|
|
542
|
+
namespace PInvoke {
|
|
543
|
+
public class CloseW {
|
|
544
|
+
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint msg, IntPtr w, IntPtr l);
|
|
545
|
+
[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
"@
|
|
549
|
+
}
|
|
550
|
+
$res = @()
|
|
551
|
+
foreach ($hv in $hwnds) {
|
|
552
|
+
$h = [IntPtr][int64]$hv
|
|
553
|
+
$aliveBefore = [PInvoke.CloseW]::IsWindow($h)
|
|
554
|
+
$posted = $false
|
|
555
|
+
if ($aliveBefore) { $posted = [PInvoke.CloseW]::PostMessage($h, 0x0010, [IntPtr]::Zero, [IntPtr]::Zero) }
|
|
556
|
+
Start-Sleep -Milliseconds 220
|
|
557
|
+
$aliveAfter = [PInvoke.CloseW]::IsWindow($h)
|
|
558
|
+
$res += [PSCustomObject]@{ Hwnd = [int64]$hv; Posted = [bool]$posted; Closed = (-not $aliveAfter); WasAlive = [bool]$aliveBefore }
|
|
559
|
+
}
|
|
560
|
+
$json = ConvertTo-Json -InputObject @($res) -Depth 4 -Compress
|
|
561
|
+
[System.IO.File]::WriteAllText('__OUT_FILE__', $json, (New-Object System.Text.UTF8Encoding($false)))
|
|
562
|
+
`
|
|
563
|
+
|
|
564
|
+
export function runElevatedClose(hwnds, timeoutMs = 120000) {
|
|
565
|
+
const list = (hwnds || [])
|
|
566
|
+
.map((h) => Number(h))
|
|
567
|
+
.filter((h) => Number.isInteger(h) && h > 0)
|
|
568
|
+
if (list.length === 0) return Promise.resolve({ ok: false, reason: 'no-hwnds', results: [] })
|
|
569
|
+
|
|
570
|
+
const stamp = Date.now() + '_' + Math.floor(Math.random() * 1e6)
|
|
571
|
+
const outFile = path.join(os.tmpdir(), 'dsh-sentinel-ec-' + stamp + '.json')
|
|
572
|
+
const psFile = path.join(os.tmpdir(), 'dsh-sentinel-ec-' + stamp + '.ps1')
|
|
573
|
+
const body = ELEVATED_CLOSE_SCRIPT
|
|
574
|
+
.replace('__HWND_LIST__', list.join(','))
|
|
575
|
+
.replace('__OUT_FILE__', outFile.replace(/'/g, "''"))
|
|
576
|
+
fs.writeFileSync(psFile, '\ufeff' + body, 'utf8')
|
|
577
|
+
|
|
578
|
+
const cleanup = () => { try { fs.unlinkSync(psFile) } catch (_) {} }
|
|
579
|
+
const launcher =
|
|
580
|
+
"$ErrorActionPreference='Stop'\n" +
|
|
581
|
+
"try {\n" +
|
|
582
|
+
" $p = Start-Process -FilePath 'powershell.exe' " +
|
|
583
|
+
"-ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','" + psFile.replace(/'/g, "''") + "') " +
|
|
584
|
+
"-Verb RunAs -WindowStyle Hidden -PassThru -Wait\n" +
|
|
585
|
+
" Write-Output ('EXIT=' + $p.ExitCode)\n" +
|
|
586
|
+
"} catch { Write-Output ('LAUNCH_FAILED=' + $_.Exception.Message) }\n"
|
|
587
|
+
|
|
588
|
+
return new Promise((resolve) => {
|
|
589
|
+
let child
|
|
590
|
+
try {
|
|
591
|
+
child = spawn('powershell.exe',
|
|
592
|
+
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', launcher],
|
|
593
|
+
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true })
|
|
594
|
+
} catch (err) {
|
|
595
|
+
cleanup()
|
|
596
|
+
return resolve({ ok: false, reason: 'spawn-failed: ' + err.message, results: [] })
|
|
597
|
+
}
|
|
598
|
+
let stdout = ''
|
|
599
|
+
let stderr = ''
|
|
600
|
+
let done = false
|
|
601
|
+
const finish = (result) => {
|
|
602
|
+
if (done) return
|
|
603
|
+
done = true; clearTimeout(timer); cleanup()
|
|
604
|
+
try { fs.unlinkSync(outFile) } catch (_) {}
|
|
605
|
+
resolve(result)
|
|
606
|
+
}
|
|
607
|
+
const timer = setTimeout(() => { try { child.kill() } catch (_) {} finish({ ok: false, reason: 'timeout', results: [] }) }, timeoutMs)
|
|
608
|
+
child.stdout.on('data', (d) => { stdout += d })
|
|
609
|
+
child.stderr.on('data', (d) => { stderr += d })
|
|
610
|
+
child.on('error', (err) => finish({ ok: false, reason: 'spawn-failed: ' + err.message, results: [] }))
|
|
611
|
+
child.on('close', () => {
|
|
612
|
+
let raw = ''
|
|
613
|
+
try { raw = fs.readFileSync(outFile, 'utf8') } catch (_) { raw = '' }
|
|
614
|
+
if (!raw) {
|
|
615
|
+
const launched = stdout.indexOf('LAUNCH_FAILED=') >= 0
|
|
616
|
+
return finish({ ok: false, reason: launched ? 'uac-denied-or-cancelled' : 'no-result-file', stderr: (stdout + stderr).slice(-500), results: [] })
|
|
617
|
+
}
|
|
618
|
+
try {
|
|
619
|
+
const parsed = JSON.parse(raw.replace(/^\uFEFF/, '').trim())
|
|
620
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed]
|
|
621
|
+
const closed = arr.filter((r) => r.Closed).length
|
|
622
|
+
finish({ ok: closed > 0, results: arr, closedCount: closed, total: arr.length })
|
|
623
|
+
} catch (err) {
|
|
624
|
+
finish({ ok: false, reason: 'json-parse: ' + err.message, stderr: raw.slice(-300), results: [] })
|
|
625
|
+
}
|
|
626
|
+
})
|
|
627
|
+
})
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Elevation-aware batch close used by the panel's "强制关闭(管理员)" button.
|
|
632
|
+
*
|
|
633
|
+
* @param {number[]} pids
|
|
634
|
+
* @param {object} opts { selfPid, allowedProcesses: string[], timeoutMs }
|
|
635
|
+
*/
|
|
636
|
+
export async function forceCloseElevated(pids, opts = {}) {
|
|
637
|
+
const hardBlocked = (name) => {
|
|
638
|
+
const n = String(name || '').toLowerCase().replace(/\.exe$/, '')
|
|
639
|
+
return n === 'dsh' || n.startsWith('dsh ') || n.startsWith('dsh next') || n === 'dsh desktop'
|
|
282
640
|
}
|
|
641
|
+
|
|
642
|
+
const allowed = new Set((opts.allowedProcesses || []).map((s) => String(s).toLowerCase().replace(/\.exe$/, '')))
|
|
643
|
+
const selfPid = Number(opts.selfPid || 0)
|
|
644
|
+
const requested = (pids || []).map((p) => Number(p)).filter((p) => Number.isInteger(p) && p > 0)
|
|
645
|
+
if (!requested.length) return { ok: false, reason: 'no-pids', results: [] }
|
|
646
|
+
|
|
647
|
+
const accepted = []
|
|
648
|
+
const rejected = []
|
|
649
|
+
for (const pid of requested) {
|
|
650
|
+
if (selfPid && pid === selfPid) { rejected.push({ pid, reason: 'self' }); continue }
|
|
651
|
+
let pname = ''
|
|
652
|
+
try {
|
|
653
|
+
const out = execFileSync('tasklist', ['/FI', 'PID eq ' + pid, '/FO', 'CSV', '/NH'], { encoding: 'utf8', timeout: 8000 })
|
|
654
|
+
const line = out.trim().split(/\r?\n/)[0] || ''
|
|
655
|
+
const m = line.match(/^"([^"]+)"/)
|
|
656
|
+
pname = m ? m[1] : ''
|
|
657
|
+
} catch (_) { pname = '' }
|
|
658
|
+
if (!pname) { rejected.push({ pid, reason: 'not-running' }); continue }
|
|
659
|
+
if (hardBlocked(pname)) { rejected.push({ pid, reason: 'dsh-host', process: pname }); continue }
|
|
660
|
+
if (!allowed.has(String(pname).toLowerCase().replace(/\.exe$/, ''))) {
|
|
661
|
+
rejected.push({ pid, reason: 'not-in-request', process: pname })
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
accepted.push(pid)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (!accepted.length) return { ok: false, reason: 'all-rejected', results: [], rejected }
|
|
668
|
+
|
|
669
|
+
const kill = await runElevatedKill(accepted, opts.timeoutMs)
|
|
670
|
+
return { ...kill, rejected }
|
|
283
671
|
}
|