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/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
- foreach ($p in [System.Diagnostics.Process]::GetProcesses()) { $allProcs[[int]$p.Id] = $p.ProcessName }
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
- $wins.Add([PSCustomObject]@{ Hwnd = $hWnd.ToInt64(); Class = $cls; Title = $title; Pid = [int]$procid; Process = $pname })
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
- $out = [PSCustomObject]@{ windows = $wins; fgHwnd = $fg.ToInt64() }
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
- const close = await closeDesktopWindows(targets.map((t) => t.hwnd))
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
- closeFailed: close.ok ? [] : (close.reason ? [{ reason: close.reason }] : targets),
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
  }