diff --git a/plugins/unlock/.gitignore b/plugins/unlock/.gitignore index 8977a101a..5936890ee 100644 --- a/plugins/unlock/.gitignore +++ b/plugins/unlock/.gitignore @@ -29,3 +29,7 @@ release/ # Preload binaries downloaded at install time (handle.exe + its EULA) public/preload/handle.exe public/preload/handle-EULA.txt + +# witr optional diagnostic binary (npm postinstall / runtime extract) +public/preload/tools/witr.exe +# runtime extract destination is %LOCALAPPDATA%/unlock-file/tools (not in repo) diff --git a/plugins/unlock/CHANGELOG.md b/plugins/unlock/CHANGELOG.md index 07f7ccb18..8c2b449d7 100644 --- a/plugins/unlock/CHANGELOG.md +++ b/plugins/unlock/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## 1.1.0 - 2026-09-30 + +### 修复 + +- **重启管理器兜底检测完全失效**:`RmGetList` 的 `pnProcInfoNeeded`(out) 与 `pnProcInfo`(ref) 传入了同一个变量,计数被写回清零,导致分配 0 字节缓冲区、永远查不到占用者;异常还被空 `catch` 吞掉,所以一直静默失败。现在两者使用不同变量,失败会输出到 stderr 并记入运行记录。 +- **一个文件被多个进程占用时漏报**:witr 匹配到多个目标时退出码为 4,输出的是一份人类可读列表(不是 JSON),此前被当作"无占用者"。现在会解析出全部 PID,并逐个用 `witr --pid` 补全启动链。 +- **重启管理器结构体越界写内存**:`RM_PROCESS_INFO` 按 Ansi 计算大小,而 `RmGetList` 按 `WCHAR[256]/WCHAR[64]` 写入,缓冲区被写穿导致 PowerShell 宿主堆损坏(`0xC0000374`;1 个占用者侥幸不崩,2 个以上必崩)。结构体解析改到 C# 内完成,并显式标注 `CharSet.Unicode`。 +- **路径特殊字符**:生成的 PowerShell 脚本改为单引号字面量并转义 `'`,路径中含 `$`、反引号、双引号时不再被当成变量或破坏脚本(此前可造成脚本错误甚至命令注入)。 + +### 新增 + +- **结束进程后闭环验证**:结束占用进程后轮询探测(约 1.8s)文件能否重新独占打开,只有确认解除才提示成功;仍被占用时明确提示"可能还有其他程序或系统服务在访问",并刷新剩余占用者列表。端口检测同样会复查端口是否释放。 +- **一键结束全部占用进程**(文件与端口)并统一验证结果。 +- **占用状态四态探测**:被占用 / 可独占打开 / 文件夹 / 路径不存在。文件夹被占用(很常见的工作目录占用)会给出可执行的排查建议,不再只显示"无法确认锁定状态"。 +- 权限不足与文件被占用分别提示,不再混为一谈、不再误入"查找占用进程"流程。 + +### 改进 + +- 前端提示改为**成功 / 提示 / 注意 / 错误**四级提示条,全部口语化文案:不再出现"退出码"、"engine="、"PID xxx 仍然存活"等调试字眼;需要排查时可展开底部"运行记录"。 +- 进程卡片文案中文化:`Why It Exists` → "它是怎么跑起来的"、`Source` → "来源"、`Working Dir` → "工作目录"、`kill` → "结束进程"。 +- 进程卡片展示 Windows 服务短名,并提示"直接结束可能触发服务自动重启,建议先停止该服务"。 +- 占用者名称解析为真实进程名(此前重启管理器可能只给出单个字母,如 `W`)。 +- 新增 preload 接口与文案的回归测试:重启管理器检测、双占用者端到端、闭环验证、占用状态探测、提示文案不含调试字眼、preload 暴露的接口齐全(75 个用例全绿)。测试串行执行,并改为"等占用真的建立"再断言,消除竞态导致的偶发失败。 +- witr 仍为可选依赖:首次运行时自动释放/下载,不可用时由 Windows 重启管理器接管检测,功能不受影响。 ## 1.0.0 - 2026-07-13 @@ -7,4 +31,4 @@ - 实现 Windows 文件占用检测与进程结束功能,支持文件和目录拖入。 - 增加文件删除/粉碎、端口占用查询及对应操作页面。 - 完善中文路径、权限与失败提示,修复 PowerShell 脚本和检测结果解析问题。 -- 统一为 Windows 专用实现,完善发布时所需工具与依赖。 +- 统一为 Windows 专用实现,完善发布时所需工具与依赖。 \ No newline at end of file diff --git a/plugins/unlock/README.md b/plugins/unlock/README.md index ed313b5f7..753905eb8 100644 --- a/plugins/unlock/README.md +++ b/plugins/unlock/README.md @@ -1,13 +1,11 @@ # 解除占用 -ZTools 插件 — 解除文件占用、粉碎文件、检测端口占用。仅支持 Windows。 +ZTools 插件 — 用 **witr 式因果溯源**解释「谁在占用 / 为什么在跑」,再附加 **kill** 解除占用。另附文件粉碎、端口检测。仅支持 Windows。 ## 使用方式 ### 关键字启动 -在 ZTools 搜索框输入: - | 关键字 | 功能 | |---|---| | `解除占用` | 打开解除占用标签页 | @@ -18,19 +16,63 @@ ZTools 插件 — 解除文件占用、粉碎文件、检测端口占用。仅 将文件或目录拖到 ZTools 搜索框,选择「解除占用」即可直接带入路径并自动扫描。 +## 诊断思路(对齐 witr) + +```text +文件/端口 + ↓ 确证持有者(witr --file / 重启管理器,不用 cmdline 猜测) +PID + ↓ 溯源解释它为什么在运行(启动链 + 来源) +启动链: explorer.exe (1) → pwsh.exe (20) → node.exe (80) +来源: 交互式 Shell · pwsh.exe + ↓ 用户确认后再附加动作 +结束进程 / 粉碎 + ↓ 闭环验证:文件能重新独占打开才算真的解除 +已解除占用 / 仍被占用(并列出剩余占用者) +``` + +- **先解释,再动手**:进程卡片展示「它是怎么跑起来的」启动链与来源(服务 / Shell / 计划任务…),结束进程是附加操作。 +- **只确证,不猜测**:占用者来自重启管理器或 witr;不再用命令行子串 / 「可疑 java·node」启发式误报。 +- **双引擎溯源**:优先调用 [witr](https://github.com/pranshuparmar/witr)(`--pid --json`);未安装时回退到原生 PowerShell 祖先链 + Windows 服务识别。 +- **结束后闭环验证**:结束进程后轮询探测文件是否已能独占打开,只在确认解除后才提示成功;仍被占用时会告诉你还剩谁在占用。 +- **提示分级、说人话**:成功 / 提示 / 注意 / 错误四级提示条;不暴露退出码、`engine=` 之类的调试字眼,需要排查时可展开底部「运行记录」。 + ## 功能 ### 解除占用 -检测哪些进程正在占用文件或目录,支持一键结束。 - +检测哪些进程正在占用文件或目录,展示启动链与来源,支持单个或一键结束。结束时自动重启管理器句柄检测 + 结束后独占打开验证:只有确认解除才会提示成功,否则会指出「可能还有谁在占用」。 ### 文件粉碎 -安全删除文件或目录,粉碎模式会先用随机数据覆写再删除。 - +安全删除文件或目录,粉碎模式会先用随机数据覆写再删除。占用时弹出确认,展示占用进程后可结束并重试;权限不足与文件被占用会分别提示,避免误以为是占用问题。 ### 端口检测 -查看指定端口的监听进程,支持一键结束。 +查看指定端口的监听进程及启动链,支持一键结束。结束进程后会复查端口是否已释放,被其他进程接管时会明确提示。 + +## 可选依赖:witr(自动安装) + +[witr](https://github.com/pranshuparmar/witr) 用于完整溯源(服务描述、容器、警告等),**会自动安装**: + +1. `npm install` 时 postinstall 就绪 `public/preload/tools/witr.exe`(打包进插件作内置副本) +2. 运行时若在 `.asar` 内,会**自动释放**到真实路径 `%LOCALAPPDATA%\unlock-file\tools\witr.exe`(asar 内无法 `spawn`,直接跑会 ENOENT) +3. 内置副本不可用时自动从 GitHub 下载,失败再回退 `winget install PranshuParmar.witr` +4. 全部失败时使用原生 PowerShell 溯源,插件功能不受影响 + +手动强制重装: + +```powershell +node scripts/install-witr.js --force +``` + +## 开发 + +```powershell +npm install # 可选:下载 witr +npm run dev +npm run build +npm test # node --test --test-concurrency=1 public/preload/*.test.js +``` +测试覆盖:占用者检测(witr 与重启管理器双路径)、占用状态探测、结束后闭环验证、提示文案(不出现退出码等调试字眼)以及前端依赖的 preload 接口是否齐全。 diff --git a/plugins/unlock/package.json b/plugins/unlock/package.json index 207d8b9cf..eec75980f 100644 --- a/plugins/unlock/package.json +++ b/plugins/unlock/package.json @@ -1,11 +1,14 @@ { "name": "unlock-file", - "version": "1.0.0", + "version": "1.1.0", "description": "解除文件占用,以方便修改删除文件", "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc && vite build" + "build": "vue-tsc && vite build", + "postinstall": "node scripts/install-witr.js", + "install-witr": "node scripts/install-witr.js", + "test": "node --test --test-concurrency=1 public/preload/*.test.js" }, "dependencies": { "vue": "^3.5.13" diff --git a/plugins/unlock/public/plugin.json b/plugins/unlock/public/plugin.json index e356b8668..310563a79 100644 --- a/plugins/unlock/public/plugin.json +++ b/plugins/unlock/public/plugin.json @@ -4,8 +4,8 @@ "title": "解除占用", "description": "解除文件占用、粉碎文件、检测端口占用,支持拖拽文件和手动输入路径", "author": "Taitres", - "homepage": "https://github.com/Taitres", - "version": "1.0.0", + "homepage": "https://github.com/Taitres/unlock", + "version": "1.1.0", "main": "index.html", "preload": "preload/services.js", "logo": "logo.png", diff --git a/plugins/unlock/public/preload/services.js b/plugins/unlock/public/preload/services.js index feb1b9476..958ee6437 100644 --- a/plugins/unlock/public/preload/services.js +++ b/plugins/unlock/public/preload/services.js @@ -1,4 +1,12 @@ -const { findLockingProcesses, killProcess, getDebugLog: unlockDebugLog } = require('./unlock') +const { + findLockingProcesses, + killProcess, + killLockingProcess, + killAllLockingProcesses, + verifyRelease, + getDebugLog: unlockDebugLog +} = require('./unlock') +const { explainProcess, findPortOwnersByWitr, getDebugLog: whyDebugLog, findWitrBin } = require('./why') const { buildShredScript, buildDeleteScript, parseShredResult } = require('./shredder') const { buildPortQueryScript, parsePortOutput } = require('./portscan') const { spawn } = require('node:child_process') @@ -13,6 +21,7 @@ function debugPush(msg) { _debugLog.push(msg) } function getDebugLog() { var logs = [].concat(unlockDebugLog ? unlockDebugLog() : []) + logs = logs.concat(whyDebugLog ? whyDebugLog() : []) logs = logs.concat(_debugLog) _debugLog = [] return logs @@ -21,7 +30,7 @@ function getDebugLog() { function execWithTimeout(cmd, args, label) { return new Promise(function (resolve, reject) { debugPush(' [' + (label || 'exec') + '] ' + cmd + ' ' + args.map(function (a) { return a.includes(' ') ? '"' + a + '"' : a }).join(' ')) - var proc = spawn(cmd, args, { timeout: TIMEOUT_MS }) + var proc = spawn(cmd, args, { timeout: TIMEOUT_MS, windowsHide: true }) var stdout = '' var stderr = '' proc.stdout.setEncoding('utf8') @@ -63,7 +72,7 @@ async function shredPath(filePath, mode) { var script = mode === 'shred' ? buildShredScript(resolved) : buildDeleteScript(resolved) var tmpDir = require('node:os').tmpdir() var scriptPath = require('node:path').join(tmpDir, 'unlock-shred-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.ps1') - require('node:fs').writeFileSync(scriptPath, '\uFEFF' + script, 'utf8') + require('node:fs').writeFileSync(scriptPath, '' + script, 'utf8') debugPush('[shred] temp script: ' + scriptPath) var raw @@ -96,10 +105,34 @@ async function findPortProcess(port) { } try { + // PRIMARY: witr --port --json (full Result: Process + Ancestry + Source + Sockets) + debugPush('[portscan] witr --port (primary)...') + var viaWitr = null + try { + viaWitr = await findPortOwnersByWitr(portNum) + } catch (e) { + debugPush('[portscan] witr --port error: ' + e.message) + } + + if (viaWitr && viaWitr.length > 0) { + debugPush('[portscan] witr found ' + viaWitr.length + ' entries') + viaWitr.forEach(function (p) { + debugPush('[portscan] ' + p.processName + ' PID:' + p.pid + ' ' + p.protocol + ' ' + p.state + ' ' + p.localAddress + ':' + p.localPort) + if (p.why && p.why.ancestryText) { + debugPush('[portscan] why: ' + p.why.ancestryText) + debugPush('[portscan] source: ' + (p.why.sourceLine || p.why.source.type)) + } + }) + debugPush('=== done ===') + return viaWitr + } + + // FALLBACK: Get-NetTCPConnection / Get-NetUDPEndpoint + debugPush('[portscan] falling back to Get-NetTCPConnection...') var script = buildPortQueryScript(portNum) var tmpDir = require('node:os').tmpdir() var scriptPath = require('node:path').join(tmpDir, 'unlock-port-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.ps1') - require('node:fs').writeFileSync(scriptPath, '\uFEFF' + script, 'utf8') + require('node:fs').writeFileSync(scriptPath, '' + script, 'utf8') debugPush('[portscan] temp script: ' + scriptPath) var raw @@ -113,11 +146,22 @@ async function findPortProcess(port) { var result = parsePortOutput(raw) debugPush('[portscan] found ' + result.length + ' entries') - result.forEach(function (p) { - debugPush('[portscan] ' + p.processName + ' PID:' + p.pid + ' ' + p.protocol + ' ' + p.state + ' ' + p.localAddress + ':' + p.localPort) - }) + + var withWhy = [] + for (var i = 0; i < result.length; i++) { + var entry = result[i] + var whyInfo = null + try { + whyInfo = await explainProcess(entry.pid) + } catch (e) { + debugPush('[portscan] why failed for PID ' + entry.pid + ': ' + e.message) + } + entry.why = whyInfo + withWhy.push(entry) + debugPush('[portscan] ' + entry.processName + ' PID:' + entry.pid + ' ' + entry.protocol + ' ' + entry.state + ' ' + entry.localAddress + ':' + entry.localPort) + } debugPush('=== done ===') - return result + return withWhy } catch (err) { debugPush('[portscan] error: ' + err.message) debugPush('=== done ===') @@ -125,11 +169,174 @@ async function findPortProcess(port) { } } +async function explainPid(pid) { + try { + return await explainProcess(pid) + } catch (e) { + return null + } +} + +function delay(ms) { return new Promise(function (r) { setTimeout(r, ms) }) } + +/** + * 端口是否仍被监听(轻量检查,不写调试日志,供结束后闭环验证使用)。 + * @returns {Promise} null = 无法确认 + */ +async function portStillListening(portNum) { + try { + var viaWitr = await findPortOwnersByWitr(portNum) + if (viaWitr) return viaWitr.length > 0 + } catch (e) {} + + try { + var script = buildPortQueryScript(portNum) + var tmpDir = require('node:os').tmpdir() + var scriptPath = require('node:path').join(tmpDir, 'unlock-port-check-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.ps1') + require('node:fs').writeFileSync(scriptPath, '' + script, 'utf8') + var raw + try { + raw = await execWithTimeout('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath + ], 'portcheck') + } finally { + try { require('node:fs').unlinkSync(scriptPath) } catch (e) {} + } + return parsePortOutput(raw).length > 0 + } catch (e) { + return null + } +} + +/** 结束单个端口占用进程,并确认端口是否释放。 */ +async function killPortProcess(port, pid, name) { + var portNum = parseInt(port, 10) + var label = name ? '「' + name + '」' : ('进程 ' + pid) + var kill = await killProcess(pid, name) + + if (!kill.success) { + return { + success: false, + released: false, + level: 'error', + message: kill.message, + needsAdmin: !!kill.needsAdmin, + kill: kill + } + } + + var listening = null + for (var i = 0; i < 4; i++) { + await delay(400) + listening = await portStillListening(portNum) + if (listening === false) break + } + + if (listening === false) { + return { + success: true, + released: true, + level: 'success', + message: '已解除占用:' + label + '已结束,端口 ' + portNum + ' 已释放。', + kill: kill + } + } + if (listening === true) { + return { + success: true, + released: false, + level: 'warning', + message: label + '已结束,但端口 ' + portNum + ' 仍被监听:可能已被其他进程接管,请重新扫描查看。', + kill: kill + } + } + return { + success: true, + released: null, + level: 'warning', + message: label + '已结束。请重新扫描确认端口 ' + portNum + ' 是否已释放。', + kill: kill + } +} + +/** 一键结束端口上的全部监听进程,并统一确认端口是否释放。 */ +async function killAllPortProcesses(port, entries) { + var portNum = parseInt(port, 10) + var list = Array.isArray(entries) ? entries : [] + var seen = {} + var pids = [] + for (var i = 0; i < list.length; i++) { + var p = list[i] + if (p && p.pid && !seen[p.pid]) { seen[p.pid] = true; pids.push({ pid: p.pid, name: p.processName }) } + } + if (pids.length === 0) { + return { success: false, released: false, level: 'warning', message: '没有需要结束的进程。', killed: 0, failed: 0 } + } + + var results = [] + for (var j = 0; j < pids.length; j++) { + var r = await killProcess(pids[j].pid, pids[j].name) + results.push(Object.assign({ pid: pids[j].pid, name: pids[j].name }, r)) + } + + var okCount = results.filter(function (r) { return r.success }).length + var failCount = results.length - okCount + var needsAdmin = results.some(function (r) { return r.needsAdmin }) + + var listening = null + for (var k = 0; k < 4; k++) { + await delay(400) + listening = await portStillListening(portNum) + if (listening === false) break + } + + var level = 'success' + var message + if (failCount === 0 && listening === false) { + message = '已解除占用:' + okCount + ' 个进程已结束,端口 ' + portNum + ' 已释放。' + } else if (failCount === 0 && listening === true) { + level = 'warning' + message = '已结束 ' + okCount + ' 个进程,但端口 ' + portNum + ' 仍被监听:可能已被其他进程接管,请重新扫描查看。' + } else if (failCount === 0) { + level = 'warning' + message = '已结束 ' + okCount + ' 个进程。请重新扫描确认端口 ' + portNum + ' 是否已释放。' + } else if (okCount > 0) { + level = 'warning' + message = '已结束 ' + okCount + ' 个进程,另有 ' + failCount + ' 个无法结束' + + (needsAdmin ? '(需要管理员权限)' : '') + '。' + } else { + level = 'error' + message = '未能结束任何进程。' + (needsAdmin ? '请以管理员身份运行 ZTools 后重试。' : '请在任务管理器中手动结束这些进程。') + } + + return { + success: okCount > 0, + released: listening === false ? true : (listening === true ? false : null), + level: level, + message: message, + killed: okCount, + failed: failCount, + needsAdmin: needsAdmin, + results: results + } +} + +function getWhyEngine() { + return findWitrBin() ? 'witr' : 'native' +} + window.services = { findLockingProcesses: findLockingProcesses, killProcess: killProcess, + killLockingProcess: killLockingProcess, + killAllLockingProcesses: killAllLockingProcesses, + verifyRelease: verifyRelease, + killPortProcess: killPortProcess, + killAllPortProcesses: killAllPortProcesses, shredPath: shredPath, findPortProcess: findPortProcess, + explainPid: explainPid, + getWhyEngine: getWhyEngine, getDebugLog: getDebugLog, getPathForFile: getPathForFile } diff --git a/plugins/unlock/public/preload/services.test.js b/plugins/unlock/public/preload/services.test.js new file mode 100644 index 000000000..0ad99e6ab --- /dev/null +++ b/plugins/unlock/public/preload/services.test.js @@ -0,0 +1,38 @@ +const assert = require('node:assert/strict') +const { describe, it } = require('node:test') + +// services.js 是 ZTools 预加载脚本:它要求在 window 上挂载 API,并尝试加载 electron。 +// 这里用最小桩验证"暴露给前端的接口"没有丢,避免前端调用 undefined。 +describe('preload services surface', () => { + it('exposes every API the UI calls', () => { + global.window = {} + require('./services') + + const s = global.window.services + assert.ok(s && typeof s === 'object', 'window.services 应存在') + + const expected = [ + 'findLockingProcesses', + 'killProcess', + 'killLockingProcess', + 'killAllLockingProcesses', + 'verifyRelease', + 'killPortProcess', + 'killAllPortProcesses', + 'shredPath', + 'findPortProcess', + 'explainPid', + 'getWhyEngine', + 'getDebugLog', + 'getPathForFile' + ] + for (const name of expected) { + assert.equal(typeof s[name], 'function', name + ' 应该是函数') + } + + // 回退链:没有 electron 时不应抛错,只是拿不到路径 + assert.equal(s.getPathForFile(null), '') + assert.equal(s.getWhyEngine(), 'witr') + assert.ok(Array.isArray(s.getDebugLog())) + }) +}) diff --git a/plugins/unlock/public/preload/shredder.js b/plugins/unlock/public/preload/shredder.js index 9d309f69e..d95023992 100644 --- a/plugins/unlock/public/preload/shredder.js +++ b/plugins/unlock/public/preload/shredder.js @@ -115,22 +115,22 @@ function parseShredResult(raw) { try { const parsed = JSON.parse(trimmed) if (parsed.ok) { - return { success: true, message: '完成,已处理 ' + (parsed.count || 0) + ' 个文件', filesProcessed: parsed.count || 0 } + return { success: true, message: '已处理 ' + (parsed.count || 0) + ' 个文件', filesProcessed: parsed.count || 0 } } const errMsg = parsed.error || '未知错误' if (_isLockError(errMsg)) { - return { success: false, message: '文件被其他进程占用', locked: true, rawError: errMsg } + return { success: false, message: '文件正被其他程序占用', locked: true, rawError: errMsg } } if (_isPermissionError(errMsg)) { - return { success: false, message: '权限不足,请以管理员身份运行', locked: true, rawError: errMsg } + return { success: false, message: '权限不足,请以管理员身份运行 ZTools 后重试', locked: true, permissionDenied: true, rawError: errMsg } } return { success: false, message: errMsg } } catch (e) { if (_isPermissionError(trimmed)) { - return { success: false, message: '权限不足,请以管理员身份运行', locked: true } + return { success: false, message: '权限不足,请以管理员身份运行 ZTools 后重试', locked: true, permissionDenied: true } } if (_isLockError(trimmed)) { - return { success: false, message: '文件被其他进程占用', locked: true } + return { success: false, message: '文件正被其他程序占用', locked: true } } return { success: false, message: trimmed.substring(0, 200) || '未知错误' } } diff --git a/plugins/unlock/public/preload/shredder.test.js b/plugins/unlock/public/preload/shredder.test.js index db441fc9b..2ef06ad34 100644 --- a/plugins/unlock/public/preload/shredder.test.js +++ b/plugins/unlock/public/preload/shredder.test.js @@ -108,5 +108,20 @@ describe('shredder', () => { assert.equal(result.success, false) assert.equal(result.locked, true) }) + + it('separates permission failures from lock failures', () => { + const denied = parseShredResult('{"ok":false,"error":"access denied"}') + assert.equal(denied.permissionDenied, true) + assert.equal(denied.message, '权限不足,请以管理员身份运行 ZTools 后重试') + + const locked = parseShredResult('{"ok":false,"error":"file is being used by another process"}') + assert.equal(locked.permissionDenied, undefined) + assert.equal(locked.message, '文件正被其他程序占用') + }) + + it('phrases success without debug punctuation', () => { + const result = parseShredResult('{"ok":true,"count":3}') + assert.equal(result.message, '已处理 3 个文件') + }) }) }) diff --git a/plugins/unlock/public/preload/unlock.js b/plugins/unlock/public/preload/unlock.js index c77b04b8e..8748622c6 100644 --- a/plugins/unlock/public/preload/unlock.js +++ b/plugins/unlock/public/preload/unlock.js @@ -3,8 +3,13 @@ const fs = require('node:fs') const path = require('node:path') const os = require('node:os') const { getKillCommand } = require('./utils') +const why = require('./why') const TIMEOUT_MS = 30000 +/** 结束进程后轮询确认占用已解除的次数与间隔(约 1.8s 上限) */ +const RELEASE_POLL_ATTEMPTS = 6 +const RELEASE_POLL_INTERVAL_MS = 300 + let _debugLog = [] function debugPush(msg) { @@ -12,7 +17,9 @@ function debugPush(msg) { } function getDebugLog() { - const logs = _debugLog + // interleave by capture order: unlock then why is confusing; pull both now + const whyLogs = why.getDebugLog ? why.getDebugLog() : [] + const logs = _debugLog.concat(whyLogs) _debugLog = [] return logs } @@ -20,7 +27,7 @@ function getDebugLog() { function execWithTimeout(cmd, args, label) { return new Promise((resolve, reject) => { debugPush(' [' + (label || 'exec') + '] ' + cmd + ' ' + args.map(a => a.includes(' ') ? '"' + a + '"' : a).join(' ')) - const proc = spawn(cmd, args, { timeout: TIMEOUT_MS }) + const proc = spawn(cmd, args, { timeout: TIMEOUT_MS, windowsHide: true }) let stdout = '' let stderr = '' proc.stdout.setEncoding('utf8') @@ -42,300 +49,368 @@ function execWithTimeout(cmd, args, label) { function writeTempScript(content) { const scriptPath = path.join(os.tmpdir(), 'unlock-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.ps1') - fs.writeFileSync(scriptPath, '\uFEFF' + content, 'utf8') + fs.writeFileSync(scriptPath, '' + content, 'utf8') return scriptPath } -// Find processes by command line matching - fallback when handle.exe fails -async function findByCommandLine(resolvedPath) { - const fileName = path.basename(resolvedPath) - const dirName = path.dirname(resolvedPath) +/** + * PowerShell 单引号字面量。单引号内不插值,路径里的 $ / 反引号 / 双引号都安全, + * 只需把 ' 写成 ''。旧实现用双引号拼接,含 $ 的路径会被 PowerShell 当变量吃掉。 + */ +function psLiteral(value) { + return "'" + String(value == null ? '' : value).replace(/'/g, "''") + "'" +} + +function delay(ms) { + return new Promise(function (resolve) { setTimeout(resolve, ms) }) +} + +function stripBom(s) { + return String(s == null ? '' : s).replace(/^\uFEFF/, '') +} - // Build search patterns: match filename, and optionally directory name for more precision - const script = [ +/* ------------------------------------------------------------------ * + * 占用检测:Windows 重启管理器(Restart Manager) + * ------------------------------------------------------------------ */ + +/** + * 重启管理器探测脚本。 + * + * 两个曾经踩过的坑,都由 buildResourceManagerScript 的文本断言 + 真实占用测试守着: + * 1) RmGetList 的 pnProcInfoNeeded(out) 与 pnProcInfo(ref) 必须是**不同**变量,否则写回时 + * 计数被清零 → 分配 0 字节 → 永远查不到占用者(旧实现还把异常吞掉,完全静默)。 + * 2) RM_PROCESS_INFO 必须按 Unicode 计算大小:RmGetList 按 WCHAR[256]/WCHAR[64] 写入, + * 按 Ansi 计算会低估缓冲区,RmGetList 越界写内存 → PowerShell 宿主堆损坏 0xC0000374 + * (一个占用者时侥幸不崩,两个以上必崩)。因此这里改为在 C# 里完成两次调用与结构体解析。 + */ +function buildResourceManagerScript(resolvedPath) { + return [ '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', '$OutputEncoding = [System.Text.Encoding]::UTF8', - '$fileName = "' + fileName.replace(/'/g, "''") + '"', - '$dirName = "' + dirName.replace(/'/g, "''") + '"', + '$ErrorActionPreference = "Stop"', + '', + '$path = ' + psLiteral(resolvedPath), + '$err = ""', + '$results = @()', + '', + '$csCode = @\'', + 'using System;', + 'using System.Collections.Generic;', + 'using System.Runtime.InteropServices;', + 'using System.Text;', + '', + 'public class RestartManager {', + ' [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]', + ' public static extern int RmStartSession(out IntPtr pSessionHandle, int dwSessionFlags, StringBuilder strSessionKey);', + ' [DllImport("rstrtmgr.dll")]', + ' public static extern int RmEndSession(IntPtr pSessionHandle);', + ' [DllImport("rstrtmgr.dll", CharSet = CharSet.Unicode)]', + ' public static extern int RmRegisterResources(IntPtr pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, IntPtr rgsServiceNames);', + ' [DllImport("rstrtmgr.dll")]', + ' public static extern int RmGetList(IntPtr pSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, IntPtr rgAffectedApps, out uint lpdwRebootReasons);', + '', + ' public struct RM_UNIQUE_PROCESS {', + ' public uint dwProcessId;', + ' public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime;', + ' }', + '', + ' [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]', + ' public struct RM_PROCESS_INFO {', + ' public RM_UNIQUE_PROCESS Process;', + ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)]', + ' public string strAppName;', + ' [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]', + ' public string strServiceShortName;', + ' public int ApplicationType;', + ' public uint AppStatus;', + ' public uint TSSessionId;', + ' [MarshalAs(UnmanagedType.Bool)]', + ' public bool bRestartable;', + ' }', + '', + ' /// 返回 "pid|appName|serviceShortName" 形式的占用者列表。', + ' public static string[] GetHolders(string path) {', + ' IntPtr session = IntPtr.Zero;', + ' StringBuilder key = new StringBuilder(256);', + ' int rv = RmStartSession(out session, 0, key);', + ' if (rv != 0) throw new Exception("RmStartSession failed: " + rv);', + ' try {', + ' string[] files = new string[] { path };', + ' rv = RmRegisterResources(session, 1, files, 0, IntPtr.Zero, 0, IntPtr.Zero);', + ' if (rv != 0) throw new Exception("RmRegisterResources failed: " + rv);', + ' uint needed = 0;', + ' uint have = 0;', + ' uint reasons = 0;', + ' rv = RmGetList(session, out needed, ref have, IntPtr.Zero, out reasons);', + ' if (rv != 0 && rv != 234) throw new Exception("RmGetList failed: " + rv);', + ' if (rv == 0 || needed == 0) return new string[0];', + ' int size = Marshal.SizeOf(typeof(RM_PROCESS_INFO));', + ' IntPtr buffer = Marshal.AllocHGlobal(size * (int)needed);', + ' try {', + ' have = needed;', + ' rv = RmGetList(session, out needed, ref have, buffer, out reasons);', + ' if (rv != 0) throw new Exception("RmGetList failed: " + rv);', + ' List rows = new List();', + ' for (int i = 0; i < have; i++) {', + ' IntPtr at = new IntPtr(buffer.ToInt64() + (long)size * i);', + ' RM_PROCESS_INFO info = (RM_PROCESS_INFO)Marshal.PtrToStructure(at, typeof(RM_PROCESS_INFO));', + ' rows.Add(info.Process.dwProcessId + "|" + (info.strAppName == null ? "" : info.strAppName) + "|" + (info.strServiceShortName == null ? "" : info.strServiceShortName));', + ' }', + ' return rows.ToArray();', + ' } finally {', + ' Marshal.FreeHGlobal(buffer);', + ' }', + ' } finally {', + ' RmEndSession(session);', + ' }', + ' }', + '}', + '\'@', + '', + 'try {', + ' Add-Type -TypeDefinition $csCode -ErrorAction Stop', + '} catch {', + ' $err = "无法加载重启管理器接口: " + $_.Exception.Message', + '}', '', - '# First: try exact match with full path or directory+filename', - '$results = Get-CimInstance Win32_Process | Where-Object {', - ' $cmdLine = $_.CommandLine', - ' # Match 1: Full path appears in command line', - ' $fullPathMatch = $cmdLine -like "*" + $dirName + "\\" + $fileName + "*" -or $cmdLine -like "*" + $dirName + "/" + $fileName + "*"', - ' # Match 2: Just filename (less precise, only if full path not found)', - ' $fileNameMatch = $cmdLine -like "*" + $fileName + "*"', - ' $fullPathMatch -or $fileNameMatch', - '} | ForEach-Object {', - ' $score = 0', - ' $cmdLine = $_.CommandLine', - ' # Score based on match quality', - ' if ($cmdLine -like "*" + $dirName + "*") { $score += 10 }', - ' if ($cmdLine -like "*" + $fileName + "*") { $score += 5 }', - ' # Boost score if this looks like a file opener (not just a classpath entry)', - ' if ($cmdLine -match "(open|edit|run|jar|\\.exe)\s+.*" + [regex]::Escape($fileName)) { $score += 20 }', - ' # Penalize if it looks like a classpath entry (java -cp ... file.jar)', - ' if ($cmdLine -match "-cp\\s+[^;]*" + [regex]::Escape($fileName)) { $score -= 5 }', - ' [PSCustomObject]@{', - ' pid = [int]$_.ProcessId', - ' name = $_.Name', - ' exePath = $_.ExecutablePath', - ' cmdLine = $cmdLine', - ' score = $score', + 'if (-not $err) {', + ' try {', + ' foreach ($row in [RestartManager]::GetHolders($path)) {', + ' $parts = $row -split "\\|"', + ' $holderPid = [int]$parts[0]', + ' $appName = ""', + ' if ($parts.Length -gt 1) { $appName = [string]$parts[1] }', + ' $serviceName = ""', + ' if ($parts.Length -gt 2) { $serviceName = [string]$parts[2] }', + ' $procName = ""', + ' $exePath = ""', + ' $proc = Get-Process -Id $holderPid -ErrorAction SilentlyContinue', + ' if ($proc) {', + ' try { $exePath = $proc.Path } catch { }', + ' if ($proc.ProcessName) { $procName = $proc.ProcessName + ".exe" }', + ' }', + ' if (-not $exePath) {', + ' try {', + ' $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $holderPid" -ErrorAction SilentlyContinue', + ' if ($cim) {', + ' $exePath = [string]$cim.ExecutablePath', + ' if (-not $procName -and $cim.Name) { $procName = [string]$cim.Name }', + ' }', + ' } catch { }', + ' }', + ' if ($exePath) { $procName = Split-Path -Leaf $exePath }', + ' if (-not $procName) { $procName = $appName }', + ' $results += @{', + ' pid = $holderPid', + ' name = $procName', + ' exePath = $exePath', + ' service = $serviceName', + ' }', + ' }', + ' } catch {', + ' $err = $_.Exception.Message', ' }', - '} | Sort-Object -Property score -Descending', + '}', '', - 'if ($results -is [array]) { $results | ConvertTo-Json -Compress }', - 'elseif ($results) { "[" + ($results | ConvertTo-Json -Compress) + "]" }', - 'else { "[]" }' + 'if ($err) { [Console]::Error.WriteLine("Restart Manager: " + $err); exit 1 }', + 'if ($results.Count -eq 0) { "[]" } else { $results | ConvertTo-Json -Compress }' ].join('\n') +} - const scriptPath = writeTempScript(script) +/** + * Accurate lock-holder detection via Restart Manager (rstrtmgr.dll). + * No heuristics — only processes Windows reports as holding the resource. + */ +async function findByResourceManager(resolvedPath) { + const scriptPath = writeTempScript(buildResourceManagerScript(resolvedPath)) try { const raw = await execWithTimeout('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath - ], 'cmdline') + ], 'rm') - const trimmed = raw.trim() + const trimmed = stripBom(raw).trim() if (!trimmed || trimmed === 'null' || trimmed === '[]') return [] const parsed = JSON.parse(trimmed) const arr = Array.isArray(parsed) ? parsed : [parsed] - - // Filter: only return processes with positive scores (good matches) - // and limit to top 3 to avoid killing unrelated processes - const goodMatches = arr.filter(p => p.score > 0).slice(0, 3) - - debugPush('[unlock] command line matches: ' + goodMatches.length + ' (scored)') - goodMatches.forEach(p => { - debugPush(' PID ' + p.pid + ' (' + p.name + '): score=' + p.score) - }) - - return goodMatches.map(p => ({ - pid: p.pid, - name: p.name, - exePath: p.exePath || p.cmdLine || '', - source: 'cmdline', - score: p.score - })) + debugPush('[unlock] 重启管理器找到 ' + arr.length + ' 个进程') + return arr.map(function (p) { + const service = String(p.service || '') + return { + pid: Number(p.pid) || 0, + name: String(p.name || ''), + exePath: String(p.exePath || ''), + service: service, + detectSource: 'resourcemanager', + confidence: 'high', + reason: service + ? 'Windows 服务「' + service + '」经重启管理器确认持有该文件' + : 'Windows 重启管理器确认持有该文件' + } + }).filter(function (p) { return p.pid > 0 }) + } catch (e) { + // 不再静默失败:把原因写进调试日志,便于排查"查不到占用者" + debugPush('[unlock] 重启管理器检测失败: ' + e.message) + return [] } finally { try { fs.unlinkSync(scriptPath) } catch (e) {} } } -// Use Resource Manager to find processes that have a file open -// This uses the Windows Restart Manager API via PowerShell -async function findByResourceManager(resolvedPath) { - // Use a simpler approach: create a PowerShell script that uses the Restart Manager COM API - const scriptContent = ` -[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 -$OutputEncoding = [System.Text.Encoding]::UTF8 -$ErrorActionPreference = "Stop" - -$path = "${resolvedPath.replace(/"/g, '""')}" - -# Load the C# code with proper escaping -$csCode = @' -using System; -using System.Runtime.InteropServices; -using System.Text; - -public class RestartManager { - [DllImport("rstrtmgr.dll", CharSet = CharSet.Auto)] - public static extern int RmStartSession(out IntPtr pSessionHandle, int dwSessionFlags, StringBuilder strSessionKey); - - [DllImport("rstrtmgr.dll")] - public static extern int RmEndSession(IntPtr pSessionHandle); +/* ------------------------------------------------------------------ * + * 占用状态探测:文件是否还能独占打开 + * ------------------------------------------------------------------ */ - [DllImport("rstrtmgr.dll", CharSet = CharSet.Auto)] - public static extern int RmRegisterResources(IntPtr pSessionHandle, uint nFiles, string[] rgsFileNames, uint nApplications, IntPtr rgApplications, uint nServices, IntPtr rgsServiceNames); - - [DllImport("rstrtmgr.dll")] - public static extern int RmGetList(IntPtr pSessionHandle, out uint pnProcInfoNeeded, ref uint pnProcInfo, IntPtr rgAffectedApps, out uint lpdwRebootReasons); - - public struct RM_UNIQUE_PROCESS { - public uint dwProcessId; - public System.Runtime.InteropServices.ComTypes.FILETIME ProcessStartTime; - } - - public struct RM_PROCESS_INFO { - public RM_UNIQUE_PROCESS Process; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] - public string strAppName; - [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)] - public string strServiceShortName; - public uint ApplicationType; - public uint AppStatus; - public uint TSSessionId; - [MarshalAs(UnmanagedType.Bool)] - public bool bRestartable; - } -} -'@ - -try { - Add-Type -TypeDefinition $csCode -ErrorAction Stop -} catch { - Write-Output "[]" - exit -} - -$results = @() - -try { - $sessionHandle = [IntPtr]::Zero - $sessionKey = New-Object System.Text.StringBuilder(256) - $ret = [RestartManager]::RmStartSession([ref]$sessionHandle, 0, $sessionKey) - if ($ret -ne 0) { throw "RmStartSession failed: $ret" } - - try { - $files = @($path) - $ret = [RestartManager]::RmRegisterResources($sessionHandle, 1, $files, 0, [IntPtr]::Zero, 0, [IntPtr]::Zero) - if ($ret -ne 0) { throw "RmRegisterResources failed: $ret" } - - $procCount = 0 - $ret = [RestartManager]::RmGetList($sessionHandle, [ref]$procCount, [ref]$procCount, [IntPtr]::Zero, [ref]0) - if ($ret -eq 234) { - $infoSize = [System.Runtime.InteropServices.Marshal]::SizeOf([Type][RestartManager+RM_PROCESS_INFO]) - $infoPtr = [System.Runtime.InteropServices.Marshal]::AllocHGlobal($infoSize * $procCount) - $actualCount = $procCount - $ret = [RestartManager]::RmGetList($sessionHandle, [ref]$procCount, [ref]$actualCount, $infoPtr, [ref]0) - if ($ret -eq 0) { - for ($i = 0; $i -lt $actualCount; $i++) { - $info = [System.Runtime.InteropServices.Marshal]::PtrToStructure([IntPtr]($infoPtr.ToInt64() + $infoSize * $i), [Type][RestartManager+RM_PROCESS_INFO]) - $proc = Get-Process -Id $info.Process.dwProcessId -ErrorAction SilentlyContinue - $exePath = "" - if ($proc) { - try { $exePath = $proc.Path } catch {} - if (-not $exePath) { - try { - $cim = Get-CimInstance Win32_Process -Filter "ProcessId = $($info.Process.dwProcessId)" -ErrorAction SilentlyContinue - if ($cim) { $exePath = $cim.ExecutablePath } - } catch {} - } - } - $results += @{ - pid = [int]$info.Process.dwProcessId - name = $info.strAppName - exePath = $exePath - source = "resourcemanager" - } - } - } - [System.Runtime.InteropServices.Marshal]::FreeHGlobal($infoPtr) - } - } finally { - [void][RestartManager]::RmEndSession($sessionHandle) - } -} catch { - # Resource Manager failed +function buildProbeScript(resolvedPath) { + return [ + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '$path = ' + psLiteral(resolvedPath), + 'if (Test-Path -LiteralPath $path -PathType Container) { Write-Output "DIR"; exit }', + 'if (-not (Test-Path -LiteralPath $path)) { Write-Output "MISSING"; exit }', + 'try {', + ' $fs = [System.IO.File]::Open($path, "Open", "Read", "None")', + ' $fs.Close()', + ' Write-Output "NOT_LOCKED"', + '} catch {', + ' Write-Output "LOCKED"', + '}' + ].join('\n') } -if ($results.Count -eq 0) { "[]" } -else { $results | ConvertTo-Json -Compress } -` - - const scriptPath = writeTempScript(scriptContent) +/** + * 路径当前状态。 + * @returns {Promise<'locked'|'free'|'directory'|'missing'|'unknown'>} + */ +async function probePathState(resolvedPath) { + const scriptPath = writeTempScript(buildProbeScript(resolvedPath)) try { - const raw = await execWithTimeout('powershell.exe', [ + const out = stripBom(await execWithTimeout('powershell.exe', [ '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath - ], 'rm') - - const trimmed = raw.trim() - if (!trimmed || trimmed === 'null' || trimmed === '[]') return [] - - const parsed = JSON.parse(trimmed) - const arr = Array.isArray(parsed) ? parsed : [parsed] - debugPush('[unlock] Resource Manager found ' + arr.length + ' processes') - return arr + ], 'locktest')).trim() + if (out === 'LOCKED') return 'locked' + if (out === 'NOT_LOCKED') return 'free' + if (out === 'DIR') return 'directory' + if (out === 'MISSING') return 'missing' + return 'unknown' } catch (e) { - debugPush('[unlock] Resource Manager failed: ' + e.message) - return [] + debugPush('[unlock] 占用探测失败: ' + e.message) + return 'unknown' } finally { try { fs.unlinkSync(scriptPath) } catch (e) {} } } -// Find suspicious processes that might be locking the file/directory -// This is a last resort when other methods fail -async function findSuspiciousProcesses(resolvedPath) { - const dirName = path.basename(path.dirname(resolvedPath)) - const fileName = path.basename(resolvedPath) +/** + * Exclusive-open probe: is the path locked at all? + * Returns true (locked) / false (not locked) / null (unknown or not a file). + */ +async function probeExclusiveLock(resolvedPath) { + const state = await probePathState(resolvedPath) + if (state === 'locked') return true + if (state === 'free') return false + return null +} - const script = [ - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - '$OutputEncoding = [System.Text.Encoding]::UTF8', - '$searchTerms = @("' + dirName.replace(/'/g, "''") + '", "' + fileName.replace(/'/g, "''") + '")', - '', - '# Find Java, Node, Python processes that might be related', - '$suspiciousNames = @("java.exe", "javaw.exe", "node.exe", "python.exe", "pythonw.exe")', - '$results = @()', - '', - 'Get-CimInstance Win32_Process | Where-Object { $suspiciousNames -contains $_.Name } | ForEach-Object {', - ' $cmdLine = $_.CommandLine', - ' $score = 0', - ' # Check if command line contains search terms', - ' foreach ($term in $searchTerms) {', - ' if ($cmdLine -like "*$term*") { $score += 10 }', - ' }', - ' # Boost score for certain process types', - ' if ($_.Name -like "*java*") { $score += 5 }', - ' if ($_.Name -like "*node*") { $score += 3 }', - ' # Only include if there\'s some match', - ' if ($score -gt 0) {', - ' $results += [PSCustomObject]@{', - ' pid = [int]$_.ProcessId', - ' name = $_.Name', - ' exePath = $_.ExecutablePath', - ' cmdLine = $cmdLine', - ' score = $score', - ' source = "suspicious"', - ' reason = "Process may be locking the file/directory"', - ' }', - ' }', - '}', - '', - '# Also find ALL Java/Node processes as fallback (in case no match found)', - 'if ($results.Count -eq 0) {', - ' Get-CimInstance Win32_Process | Where-Object { $suspiciousNames -contains $_.Name } | ForEach-Object {', - ' $results += [PSCustomObject]@{', - ' pid = [int]$_.ProcessId', - ' name = $_.Name', - ' exePath = $_.ExecutablePath', - ' cmdLine = $_.CommandLine', - ' score = 1', - ' source = "suspicious"', - ' reason = "Common development process - may be locking files"', - ' }', - ' }', - '}', - '', - 'if ($results -is [array]) { $results | ConvertTo-Json -Compress }', - 'elseif ($results) { "[" + ($results | ConvertTo-Json -Compress) + "]" }', - 'else { "[]" }' - ].join('\n') +/** + * 结束进程后的闭环验证:轮询直到能独占打开(或超时)。 + * @returns {Promise<{released: boolean|null, state: string, attempts: number, elapsedMs: number}>} + */ +async function verifyRelease(filePath, options) { + const opts = options || {} + const attempts = Math.max(1, Number(opts.attempts) || RELEASE_POLL_ATTEMPTS) + const intervalMs = Number(opts.intervalMs) || RELEASE_POLL_INTERVAL_MS + const resolved = path.resolve(filePath) + const started = Date.now() + + let state = 'unknown' + let used = 0 + for (let i = 0; i < attempts; i++) { + used = i + 1 + state = await probePathState(resolved) + if (state !== 'locked') break + if (i < attempts - 1) await delay(intervalMs) + } - const scriptPath = writeTempScript(script) - try { - const raw = await execWithTimeout('powershell.exe', [ - '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath - ], 'suspicious') + const released = state === 'free' ? true : state === 'locked' ? false : null + debugPush('[unlock] 解除验证: state=' + state + ' released=' + released + ' 用时 ' + (Date.now() - started) + 'ms') + return { released: released, state: state, attempts: used, elapsedMs: Date.now() - started } +} - const trimmed = raw.trim() - if (!trimmed || trimmed === 'null' || trimmed === '[]') return [] +/* ------------------------------------------------------------------ * + * 占用扫描 + * ------------------------------------------------------------------ */ + +function dedupeByPid(list) { + const seen = new Set() + const out = [] + for (const p of list) { + if (!p || !p.pid || seen.has(p.pid)) continue + seen.add(p.pid) + out.push(p) + } + return out +} - const parsed = JSON.parse(trimmed) - const arr = Array.isArray(parsed) ? parsed : [parsed] - debugPush('[unlock] found ' + arr.length + ' suspicious processes') - return arr - } catch (e) { - debugPush('[unlock] suspicious process search failed: ' + e.message) - return [] - } finally { - try { fs.unlinkSync(scriptPath) } catch (e) {} +function buildScanNote(holderCount, state) { + if (holderCount > 0) { + if (state === 'free') { + return { + note: '这些进程打开了该文件,但文件目前仍可独占打开,通常不影响删除或重命名。', + noteLevel: 'info' + } + } + return { + note: '结束这些进程前,请先确认它们可以安全关闭。', + noteLevel: 'warning' + } + } + if (state === 'directory') { + return { + note: '这是一个文件夹。文件夹被占用通常是因为某个程序把它当作工作目录,或正在读写其中的文件;本工具暂时无法定位到具体进程。可以尝试关闭最近用过该文件夹的程序(编辑器、终端、同步盘、杀毒软件)后重试。', + noteLevel: 'warning' + } + } + if (state === 'locked') { + return { + note: '文件确实被占用,但没能查到具体进程。可能是系统服务、杀毒软件,或属于其他用户的进程;以管理员身份运行 ZTools 后重新扫描通常可以查到。', + noteLevel: 'warning' + } + } + if (state === 'free') { + return { + note: '未检测到占用,文件当前可以正常删除或重命名。', + noteLevel: 'success' + } + } + if (state === 'missing') { + return { + note: '路径不存在,可能已被其他程序删除或移动。', + noteLevel: 'warning' + } + } + return { + note: '无法确认占用状态,可能是权限不足。可尝试以管理员身份运行 ZTools 后重新扫描。', + noteLevel: 'warning' } } +/** + * Find processes locking a file/directory, each annotated with witr-style `why`. + * + * Detection is evidence-based only (witr --file / Restart Manager). + * Kill is a separate, user-initiated action — never guessed here. + * + * @returns {Promise<{ + * locked: boolean|null, + * blocked: boolean|null, + * holders: number, + * state: string, + * processes: ProcessInfo[], + * note?: string, + * noteLevel: 'success'|'info'|'warning'|'error', + * engine: string + * }>} + */ async function findLockingProcesses(filePath) { _debugLog = [] const resolved = path.resolve(filePath) @@ -346,83 +421,69 @@ async function findLockingProcesses(filePath) { throw new Error('路径不存在: ' + resolved) } - // Method 1: Try Resource Manager (most accurate - asks Windows which process has the file open) - debugPush('[unlock] trying Resource Manager...') + // 占用探测与占用者检测互不依赖,并行执行以省掉一次 PowerShell 启动 + const statePromise = probePathState(resolved) + + const engines = [] + let holders = [] + + // 1) PRIMARY: witr --file — full Result (Process + Ancestry + Source + Warnings) + debugPush('[unlock] witr --file (主引擎)...') try { - const byRM = await findByResourceManager(resolved) - if (byRM.length > 0) { - debugPush('[unlock] found ' + byRM.length + ' processes via Resource Manager') - debugPush('=== done ===') - return byRM + const viaWitr = await why.findFileHoldersByWitr(resolved) + if (viaWitr && viaWitr.length > 0) { + debugPush('[unlock] witr --file 找到 ' + viaWitr.length + ' 个占用者') + holders = holders.concat(viaWitr) + engines.push('witr') + } else if (viaWitr) { + debugPush('[unlock] witr --file: 无占用者') + engines.push('witr') + } else { + debugPush('[unlock] witr 不可用,改用重启管理器') } } catch (e) { - debugPush('[unlock] Resource Manager failed: ' + e.message) + debugPush('[unlock] witr --file 出错: ' + e.message) } - // Method 2: Try command line matching (fallback, works for GUI apps) - debugPush('[unlock] trying command line matching...') + // 2) Restart Manager — merge any additional handle holders + debugPush('[unlock] 重启管理器 (合并)...') try { - const byCmdLine = await findByCommandLine(resolved) - if (byCmdLine.length > 0) { - debugPush('[unlock] found ' + byCmdLine.length + ' processes via command line') - debugPush('=== done ===') - return byCmdLine + const byRM = await findByResourceManager(resolved) + if (byRM.length > 0) { + debugPush('[unlock] 重启管理器找到 ' + byRM.length + ' 个进程') + holders = holders.concat(byRM) + if (engines.indexOf('resourcemanager') < 0) engines.push('resourcemanager') + } else if (engines.length === 0) { + engines.push('resourcemanager') } } catch (e) { - debugPush('[unlock] command line matching failed: ' + e.message) + debugPush('[unlock] 重启管理器出错: ' + e.message) } - // Method 3: Try to open file exclusively to confirm it's locked - debugPush('[unlock] trying exclusive open test...') - const testScript = [ - '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - '$path = "' + resolved.replace(/'/g, "''") + '"', - 'try {', - ' $fs = [System.IO.File]::Open($path, "Open", "Read", "None")', - ' $fs.Close()', - ' Write-Output "NOT_LOCKED"', - '} catch {', - ' Write-Output "LOCKED"', - '}' - ].join('\n') + holders = dedupeByPid(holders) - const testScriptPath = writeTempScript(testScript) - let isLocked = false - try { - const testResult = await execWithTimeout('powershell.exe', [ - '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', testScriptPath - ], 'locktest') + // Attach full witr Result as `why` for holders that lack it (RM path) + const processes = await why.withWhyAll(holders) - if (testResult.trim() === 'LOCKED') { - debugPush('[unlock] file is confirmed locked, searching for suspicious processes...') - isLocked = true - } else { - debugPush('[unlock] file is NOT locked') - } - } catch (e) { - debugPush('[unlock] lock test failed: ' + e.message) - } finally { - try { fs.unlinkSync(testScriptPath) } catch (e) {} - } - - // Method 4: If locked but no process found, search for suspicious processes - if (isLocked) { - debugPush('[unlock] searching for suspicious processes...') - try { - const suspicious = await findSuspiciousProcesses(resolved) - if (suspicious.length > 0) { - debugPush('[unlock] found ' + suspicious.length + ' suspicious processes') - debugPush('=== done ===') - return suspicious - } - } catch (e) { - debugPush('[unlock] suspicious process search failed: ' + e.message) - } - } + const state = await statePromise + const blocked = state === 'locked' ? true : state === 'free' ? false : null + const locked = processes.length > 0 ? true : (blocked === null ? null : blocked) + const info = buildScanNote(processes.length, state) - debugPush('[unlock] no locking processes found') + const engine = engines.join('+') || 'none' + debugPush('[unlock] 结果: ' + processes.length + ' 个进程, locked=' + locked + ', state=' + state + ', engine=' + engine) debugPush('=== done ===') - return [] + + return { + locked: locked, + blocked: blocked, + holders: processes.length, + state: state, + processes: processes, + note: info.note, + noteLevel: info.noteLevel, + engine: engine + } } async function isProcessAlive(pid) { @@ -432,37 +493,197 @@ async function isProcessAlive(pid) { } catch { return false } } -async function killProcess(pid) { +function looksLikePermissionDenied(text) { + const s = String(text || '') + return /拒绝访问|权限|access is denied|access denied|not permitted|denied/i.test(s) +} + +/* ------------------------------------------------------------------ * + * 结束进程 + * ------------------------------------------------------------------ */ + +function processLabel(pid, name) { + const n = String(name || '').trim() + return n ? '「' + n + '」' : ('进程 ' + pid) +} + +/** Kill is an attached action after diagnosis — never auto-fired from find. */ +async function killProcess(pid, name) { _debugLog = [] + const label = processLabel(pid, name) debugPush('=== killProcess PID: ' + pid + ' ===') const killCmd = getKillCommand(pid) debugPush('cmd: ' + killCmd.cmd + ' ' + killCmd.args.join(' ')) return new Promise(function (resolve) { - const proc = spawn(killCmd.cmd, killCmd.args, { timeout: TIMEOUT_MS }) + const proc = spawn(killCmd.cmd, killCmd.args, { timeout: TIMEOUT_MS, windowsHide: true }) + let stderr = '' + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', function (d) { stderr += d }) + proc.on('error', function (err) { debugPush('[kill] spawn error: ' + err.message) - resolve({ success: false, message: '结束失败: ' + err.message + '。请尝试任务管理器 (Ctrl+Shift+Esc)。' }) + resolve({ + success: false, + needsAdmin: false, + alreadyGone: false, + message: '无法结束' + label + ':' + err.message + '。请在任务管理器中手动结束该进程。' + }) }) proc.on('close', function (code) { - debugPush('[kill] exit code: ' + code) + debugPush('[kill] exit code: ' + code + (stderr.trim() ? ' stderr=' + stderr.trim().substring(0, 300) : '')) if (code === 0) { setTimeout(function () { isProcessAlive(pid).then(function (alive) { if (alive) { - resolve({ success: false, message: 'PID ' + pid + ' 仍然存活,可能需要管理员权限。请使用任务管理器。' }) + resolve({ + success: false, + needsAdmin: true, + alreadyGone: false, + message: '无法结束' + label + ':进程仍在运行。它可能需要管理员权限,请以管理员身份运行 ZTools 后重试。' + }) } else { - resolve({ success: true, message: '已结束 PID ' + pid }) + resolve({ success: true, needsAdmin: false, alreadyGone: false, message: label + '已结束' }) } }) }, 500) } else if (code === 128) { - resolve({ success: true, message: 'PID ' + pid + ' 已自行退出' }) + resolve({ success: true, needsAdmin: false, alreadyGone: true, message: label + '已经退出,无需处理' }) + } else if (code === 5 || looksLikePermissionDenied(stderr)) { + resolve({ + success: false, + needsAdmin: true, + alreadyGone: false, + message: '权限不足,无法结束' + label + '。请以管理员身份运行 ZTools 后重试。' + }) } else { - resolve({ success: false, message: '结束失败 (退出码:' + code + '),可能需要管理员权限。请使用任务管理器。' }) + resolve({ + success: false, + needsAdmin: false, + alreadyGone: false, + message: '无法结束' + label + ':系统拒绝了该操作。可以在任务管理器中手动结束它。' + }) } }) }) } -module.exports = { findLockingProcesses, killProcess, getDebugLog } +/** + * 结束占用进程并验证占用是否真的解除(闭环)。 + * @returns {Promise<{success: boolean, released: boolean|null, level: 'success'|'warning'|'error', message: string, needsAdmin?: boolean, kill?: object, verify?: object}>} + */ +async function killLockingProcess(filePath, pid, name) { + const label = processLabel(pid, name) + const killed = await killProcess(pid, name) + if (!killed.success) { + return { + success: false, + released: false, + level: 'error', + message: killed.message, + needsAdmin: !!killed.needsAdmin, + kill: killed + } + } + + const verify = await verifyRelease(filePath) + if (verify.released === true) { + return { + success: true, + released: true, + level: 'success', + message: '已解除占用:' + label + '已结束,文件不再被占用。', + kill: killed, + verify: verify + } + } + if (verify.released === false) { + return { + success: true, + released: false, + level: 'warning', + message: label + '已结束,但文件仍被占用:可能还有其他程序或系统服务在访问。请查看下面的剩余占用者。', + kill: killed, + verify: verify + } + } + return { + success: true, + released: null, + level: 'warning', + message: label + '已结束。请重新扫描确认文件是否已释放。', + kill: killed, + verify: verify + } +} + +/** + * 一键结束全部占用进程,并统一验证是否解除。 + */ +async function killAllLockingProcesses(filePath, procs) { + const list = dedupeByPid(Array.isArray(procs) ? procs : []) + if (list.length === 0) { + return { success: false, released: false, level: 'warning', message: '没有需要结束的占用进程。', killed: 0, failed: 0, results: [] } + } + + const results = [] + for (const p of list) { + const r = await killProcess(p.pid, p.name) + results.push(Object.assign({ pid: p.pid, name: p.name }, r)) + } + + const okList = results.filter(function (r) { return r.success }) + const failList = results.filter(function (r) { return !r.success }) + const verify = await verifyRelease(filePath) + const needsAdmin = failList.some(function (r) { return r.needsAdmin }) + + let level = 'success' + let message + if (failList.length === 0 && verify.released === true) { + message = '已解除占用:' + okList.length + ' 个占用进程已全部结束,文件不再被占用。' + } else if (failList.length === 0 && verify.released === false) { + level = 'warning' + message = '已结束 ' + okList.length + ' 个进程,但文件仍被占用:可能还有其他程序或系统服务在访问。请查看下面的剩余占用者。' + } else if (failList.length === 0) { + level = 'warning' + message = '已结束 ' + okList.length + ' 个进程。请重新扫描确认文件是否已释放。' + } else if (okList.length > 0) { + level = 'warning' + message = '已结束 ' + okList.length + ' 个进程,另有 ' + failList.length + ' 个无法结束' + + (needsAdmin ? '(需要管理员权限)' : '') + '。' + + (verify.released === true ? '文件已不再被占用。' : '文件可能仍被占用。') + } else { + level = 'error' + message = '未能结束任何占用进程。' + + (needsAdmin ? '请以管理员身份运行 ZTools 后重试。' : '请在任务管理器中手动结束这些进程。') + } + + return { + success: okList.length > 0, + released: verify.released, + level: level, + message: message, + killed: okList.length, + failed: failList.length, + needsAdmin: needsAdmin, + results: results, + verify: verify + } +} + +module.exports = { + findLockingProcesses, + killProcess, + killLockingProcess, + killAllLockingProcesses, + verifyRelease, + getDebugLog, + // exposed for tests + probeExclusiveLock, + probePathState, + dedupeByPid, + findByResourceManager, + buildResourceManagerScript, + buildProbeScript, + psLiteral +} diff --git a/plugins/unlock/public/preload/unlock.test.js b/plugins/unlock/public/preload/unlock.test.js index 80a9eb13c..200086c9b 100644 --- a/plugins/unlock/public/preload/unlock.test.js +++ b/plugins/unlock/public/preload/unlock.test.js @@ -1,7 +1,81 @@ const assert = require('node:assert/strict') -const { describe, it } = require('node:test') +const { describe, it, after } = require('node:test') +const fs = require('node:fs') +const os = require('node:os') +const path = require('node:path') +const { spawn } = require('node:child_process') + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'unlock-test-')) + +after(() => { + try { fs.rmSync(TMP, { recursive: true, force: true }) } catch (e) {} +}) + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } + +function tmpFile(name, content) { + const p = path.join(TMP, name) + fs.writeFileSync(p, content == null ? 'x' : content) + return p +} + +/** 起一个独占占用文件的 PowerShell 进程,等文件真的被锁上再返回 { pid, dispose() } */ +async function startExclusiveHolder(file) { + const literal = "'" + file.replace(/'/g, "''") + "'" + const command = '$fs=[System.IO.File]::Open(' + literal + ',"Open","ReadWrite","None"); Start-Sleep -Seconds 45' + const proc = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { + windowsHide: true, + stdio: 'ignore' + }) + const { probePathState } = require('./unlock') + const deadline = Date.now() + 30000 + let state = 'unknown' + while (Date.now() < deadline) { + await sleep(300) + state = await probePathState(file) + if (state === 'locked') { + return { pid: proc.pid, dispose() { try { proc.kill() } catch (e) {} } } + } + } + try { proc.kill() } catch (e) {} + throw new Error('占用进程未能在超时前锁住文件(state=' + state + ')') +} + +/** 起一个共享读句柄的 PowerShell 进程(同样会挡住独占打开/删除),等锁生效再返回 */ +async function startSharedHolder(file) { + const literal = "'" + file.replace(/'/g, "''") + "'" + const command = '$fs=[System.IO.File]::Open(' + literal + ',"Open","Read","ReadWrite"); $sr=New-Object System.IO.StreamReader($fs); $null=$sr.ReadToEnd(); Start-Sleep -Seconds 45' + const proc = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', command], { + windowsHide: true, + stdio: 'ignore' + }) + const { probePathState } = require('./unlock') + const deadline = Date.now() + 30000 + let state = 'unknown' + while (Date.now() < deadline) { + await sleep(300) + state = await probePathState(file) + if (state === 'locked') { + return { pid: proc.pid, dispose() { try { proc.kill() } catch (e) {} } } + } + } + try { proc.kill() } catch (e) {} + throw new Error('共享读占用进程未能在超时前锁住文件(state=' + state + ')') +} + +function isPowerShellUsable() { + try { + const { execFileSync } = require('node:child_process') + execFileSync('powershell.exe', ['-NoProfile', '-Command', 'exit 0'], { timeout: 15000, stdio: 'ignore' }) + return true + } catch (e) { + return false + } +} + +const PS_OK = isPowerShellUsable() +const windowsOnly = { skip: process.platform !== 'win32' || !PS_OK ? '需要 Windows 与 PowerShell' : false } -// Test the public interface behavior through known inputs/outputs describe('findLockingProcesses', () => { it('throws when path does not exist', async () => { const { findLockingProcesses } = require('./unlock') @@ -11,31 +85,317 @@ describe('findLockingProcesses', () => { /路径不存在/ ) }) + + it('returns lock scan result shape for an existing file', async () => { + const { findLockingProcesses } = require('./unlock') + const tmp = tmpFile('shape.txt', 'hello') + + const result = await findLockingProcesses(tmp) + assert.ok(result && typeof result === 'object') + assert.ok(Array.isArray(result.processes)) + assert.ok('locked' in result) + assert.ok('engine' in result) + assert.equal(result.holders, result.processes.length) + for (const p of result.processes) { + assert.ok(typeof p.pid === 'number') + assert.ok(typeof p.name === 'string') + assert.ok(p.why && typeof p.why === 'object') + assert.ok(typeof p.why.ancestryText === 'string') + assert.ok(Array.isArray(p.why.ancestry)) + assert.ok(p.why.source && typeof p.why.source.type === 'string') + assert.ok(['high', 'medium', 'low'].includes(p.confidence || 'high')) + } + }) + + it('reports an unlocked file as not occupied with a friendly note', async () => { + const { findLockingProcesses } = require('./unlock') + const tmp = tmpFile('free.txt', 'hello') + + const result = await findLockingProcesses(tmp) + assert.equal(result.locked, false) + assert.equal(result.blocked, false) + assert.equal(result.holders, 0) + assert.equal(result.state, 'free') + assert.equal(result.noteLevel, 'success') + assert.match(result.note, /未检测到占用/) + }) + + it('explains directories instead of leaving the state unknown', windowsOnly, async () => { + const { findLockingProcesses } = require('./unlock') + const dir = path.join(TMP, 'a-directory') + fs.mkdirSync(dir, { recursive: true }) + + const result = await findLockingProcesses(dir) + assert.equal(result.state, 'directory') + assert.equal(result.blocked, null) + assert.equal(result.holders, 0) + assert.equal(result.noteLevel, 'warning') + assert.match(result.note, /文件夹/) + }) + + it('finds the real holder of a locked file via witr or Restart Manager', windowsOnly, async () => { + const { findLockingProcesses } = require('./unlock') + const file = tmpFile('locked.txt', 'hello') + const holder = await startExclusiveHolder(file) + try { + const result = await findLockingProcesses(file) + assert.equal(result.locked, true) + assert.equal(result.blocked, true) + assert.ok( + result.processes.some(p => p.pid === holder.pid), + '期望占用者列表包含 PID ' + holder.pid + ',实际: ' + JSON.stringify(result.processes.map(p => p.pid)) + ) + } finally { + holder.dispose() + } + }) +}) + +describe('Restart Manager (regression: aliased RmGetList arguments)', () => { + // 旧实现把同一个变量同时作为 pnProcInfoNeeded(out) 和 pnProcInfo(ref), + // 写回时计数被清零 → 分配 0 字节 → 永远返回空列表。这里从脚本层面守住契约。 + it('uses two distinct variables for RmGetList needed/free counts', () => { + const { buildResourceManagerScript } = require('./unlock') + const script = buildResourceManagerScript('C:\\tmp\\a.txt') + + assert.match(script, /uint needed = 0;/) + assert.match(script, /uint have = 0;/) + assert.match(script, /RmGetList\(session, out needed, ref have,/) + assert.doesNotMatch(script, /out needed, ref needed/) + }) + + it('marshals RM_PROCESS_INFO as Unicode so RmGetList cannot overrun the buffer', () => { + // 按 Ansi 计算结构体大小会低于 API 实际写入量 → 越界写 → 宿主堆损坏 0xC0000374 + const { buildResourceManagerScript } = require('./unlock') + const script = buildResourceManagerScript('C:\\tmp\\a.txt') + const structAt = script.indexOf('public struct RM_PROCESS_INFO') + assert.ok(structAt > 0, '脚本里应包含 RM_PROCESS_INFO 定义') + assert.match( + script.slice(Math.max(0, structAt - 120), structAt), + /\[StructLayout\(LayoutKind\.Sequential, CharSet = CharSet\.Unicode\)\]/ + ) + assert.match(script, /Marshal\.SizeOf\(typeof\(RM_PROCESS_INFO\)\)/) + // 调用与解析都收在 C# 里:PowerShell 侧不再手工分配/解析非托管内存 + assert.match(script, /\[RestartManager\]::GetHolders\(\$path\)/) + assert.doesNotMatch(script, /\[System\.Runtime\.InteropServices\.Marshal\]::AllocHGlobal/) + assert.doesNotMatch(script, /\[System\.Runtime\.InteropServices\.Marshal\]::PtrToStructure/) + }) + + it('surfaces errors instead of swallowing them', () => { + const { buildResourceManagerScript } = require('./unlock') + const script = buildResourceManagerScript('C:\\tmp\\a.txt') + + assert.match(script, /\[Console\]::Error\.WriteLine/) + assert.match(script, /exit 1/) + }) + + it('finds a real holder through Restart Manager', windowsOnly, async () => { + const { findByResourceManager } = require('./unlock') + const file = tmpFile('rm-locked.txt', 'hello') + const holder = await startExclusiveHolder(file) + try { + const found = await findByResourceManager(file) + assert.ok(Array.isArray(found)) + assert.ok( + found.some(p => p.pid === holder.pid), + '重启管理器应报出占用者 ' + holder.pid + ',实际: ' + JSON.stringify(found) + ) + const hit = found.find(p => p.pid === holder.pid) + assert.equal(hit.detectSource, 'resourcemanager') + assert.ok(typeof hit.name === 'string' && hit.name.length > 0, '进程名不应为空') + if (hit.exePath) { + assert.equal(hit.name, path.basename(hit.exePath), '进程名应与可执行文件一致') + } + } finally { + holder.dispose() + } + }) +}) + +describe('probePathState', () => { + it('maps path kinds to states', windowsOnly, async () => { + const { probePathState } = require('./unlock') + const dir = path.join(TMP, 'probe-dir') + fs.mkdirSync(dir, { recursive: true }) + + assert.equal(await probePathState(tmpFile('probe-free.txt')), 'free') + assert.equal(await probePathState(dir), 'directory') + assert.equal(await probePathState(path.join(TMP, 'definitely-missing.txt')), 'missing') + + const file = tmpFile('probe-locked.txt') + const holder = await startExclusiveHolder(file) + try { + assert.equal(await probePathState(file), 'locked') + } finally { + holder.dispose() + } + await sleep(500) + assert.equal(await probePathState(file), 'free') + }) +}) + +describe('closed loop: kill then verify release', () => { + it('killLockingProcess reports the lock as released and leaves the file free', windowsOnly, async () => { + const { killLockingProcess, probePathState, findLockingProcesses } = require('./unlock') + const file = tmpFile('close-loop.txt', 'hello') + const holder = await startExclusiveHolder(file) + + assert.equal(await probePathState(file), 'locked') + + const scan = await findLockingProcesses(file) + assert.ok(scan.processes.some(p => p.pid === holder.pid)) + + const result = await killLockingProcess(file, holder.pid, 'powershell.exe') + assert.equal(result.success, true) + assert.equal(result.released, true) + assert.equal(result.level, 'success') + assert.match(result.message, /已解除占用/) + assert.equal(await probePathState(file), 'free') + + // 清理:确认文件真的可以删掉(用户真正关心的事) + fs.unlinkSync(file) + assert.equal(fs.existsSync(file), false) + }) + + it('verifyRelease reports not-released while a holder is still alive', windowsOnly, async () => { + const { verifyRelease } = require('./unlock') + const file = tmpFile('still-locked.txt', 'hello') + const holder = await startExclusiveHolder(file) + try { + const verify = await verifyRelease(file, { attempts: 2, intervalMs: 100 }) + assert.equal(verify.released, false) + assert.equal(verify.state, 'locked') + } finally { + holder.dispose() + } + }) + + it('finds every holder when several processes share the lock', windowsOnly, async () => { + // 两个占用者同时踩过两个坑:witr --file 会退出码 4 输出文本列表(不是 JSON), + // 重启管理器脚本曾因结构体按 Ansi 计算大小而堆损坏崩溃(0xC0000374)。 + const { findLockingProcesses, findByResourceManager } = require('./unlock') + const file = tmpFile('two-holders.txt', 'hello') + const first = await startSharedHolder(file) + const second = await startSharedHolder(file) + try { + const byRM = await findByResourceManager(file) + assert.ok( + byRM.some(p => p.pid === first.pid) && byRM.some(p => p.pid === second.pid), + '重启管理器应同时报出两个占用者,实际: ' + JSON.stringify(byRM.map(p => p.pid)) + ) + + const scan = await findLockingProcesses(file) + assert.ok(scan.processes.length >= 2, '应至少查到两个占用者,实际: ' + scan.processes.length) + assert.ok(scan.processes.some(p => p.pid === first.pid)) + assert.ok(scan.processes.some(p => p.pid === second.pid)) + } finally { + first.dispose() + second.dispose() + } + }) + + it('killAllLockingProcesses ends every holder and confirms the release', windowsOnly, async () => { + const { killAllLockingProcesses, probePathState, findLockingProcesses } = require('./unlock') + const file = tmpFile('kill-all.txt', 'hello') + const first = await startSharedHolder(file) + const second = await startSharedHolder(file) + + try { + assert.equal(await probePathState(file), 'locked') + const scan = await findLockingProcesses(file) + assert.ok(scan.processes.length >= 2, '应至少查到两个占用者,实际: ' + scan.processes.length) + + const result = await killAllLockingProcesses(file, [ + { pid: first.pid, name: 'powershell.exe' }, + { pid: second.pid, name: 'powershell.exe' } + ]) + + assert.equal(result.killed, 2) + assert.equal(result.failed, 0) + assert.equal(result.released, true) + assert.equal(result.level, 'success') + assert.match(result.message, /已解除占用/) + assert.equal(await probePathState(file), 'free') + } finally { + first.dispose() + second.dispose() + } + }) }) describe('killProcess', () => { it('returns result object with success and message fields', async () => { const { killProcess } = require('./unlock') - // Start a test process - const { spawn } = require('node:child_process') const testProc = spawn('ping', ['127.0.0.1', '-t'], { windowsHide: true }) const testPid = testProc.pid await new Promise(r => setTimeout(r, 100)) try { - const result = await killProcess(testPid) + const result = await killProcess(testPid, 'ping.exe') - // Verify result shape assert.ok(result && typeof result === 'object', 'should return object') assert.ok(typeof result.success === 'boolean', 'should have success boolean') assert.ok(typeof result.message === 'string', 'should have message string') assert.ok(result.message.length > 0, 'message should not be empty') + assert.match(result.message, /ping\.exe/) } finally { try { testProc.kill() } catch (e) {} } }) + + it('speaks to users instead of dumping exit codes', async () => { + const { killProcess } = require('./unlock') + const dead = spawn('cmd.exe', ['/c', 'exit'], { windowsHide: true }) + await new Promise(r => r(dead.on('close', r))) + await sleep(300) + + const result = await killProcess(dead.pid, 'cmd.exe') + assert.equal(result.success, true) + assert.doesNotMatch(result.message, /exit code|退出码|128/) + assert.match(result.message, /cmd\.exe/) + }) + + it('explains permission problems in plain language', async () => { + const { killProcess } = require('./unlock') + // 复用真实分支:不存在的 PID 会被 taskkill 视为已退出 + const result = await killProcess(999999, 'no-such-process.exe') + assert.ok(typeof result.message === 'string') + assert.doesNotMatch(result.message, /exit code|退出码/i) + }) +}) + +describe('psLiteral', () => { + it('escapes single quotes and never interpolates', () => { + const { psLiteral } = require('./unlock') + assert.equal(psLiteral("C:\\a\\b.txt"), "'C:\\a\\b.txt'") + assert.equal(psLiteral("D:\\it's\\a$file.txt"), "'D:\\it''s\\a$file.txt'") + assert.equal(psLiteral(null), "''") + }) + + it('keeps $-paths intact inside the generated scripts', () => { + const { buildResourceManagerScript, buildProbeScript } = require('./unlock') + const weird = "C:\\tmp\\$env:TEMP\\`tick\\o'brien.txt" + const expected = "'" + weird.replace(/'/g, "''") + "'" + assert.ok(buildResourceManagerScript(weird).includes('$path = ' + expected)) + assert.ok(buildProbeScript(weird).includes('$path = ' + expected)) + }) +}) + +describe('dedupeByPid', () => { + it('keeps first occurrence of each pid', () => { + const { dedupeByPid } = require('./unlock') + const out = dedupeByPid([ + { pid: 1, name: 'a' }, + { pid: 1, name: 'b' }, + { pid: 2, name: 'c' }, + { pid: 0, name: 'bad' } + ]) + assert.equal(out.length, 2) + assert.equal(out[0].name, 'a') + assert.equal(out[1].pid, 2) + }) }) describe('getDebugLog', () => { diff --git a/plugins/unlock/public/preload/why.js b/plugins/unlock/public/preload/why.js new file mode 100644 index 000000000..d76bcdc93 --- /dev/null +++ b/plugins/unlock/public/preload/why.js @@ -0,0 +1,863 @@ +const { spawn } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const witrInstall = require('./witr-install') + +const WITR_TIMEOUT_MS = 20000 +const PS_TIMEOUT_MS = 15000 +const MAX_ANCESTRY = 32 + +let _debugLog = [] +let _witrPath = undefined + +function debugPush(msg) { + _debugLog.push(msg) +} + +function getDebugLog() { + const logs = _debugLog + _debugLog = [] + return logs +} + +function execCapture(cmd, args, timeoutMs) { + return new Promise(function (resolve) { + debugPush(' [exec] ' + cmd + ' ' + args.map(function (a) { + return /\s/.test(a) ? JSON.stringify(a) : a + }).join(' ')) + const proc = spawn(cmd, args, { timeout: timeoutMs || PS_TIMEOUT_MS, windowsHide: true }) + let stdout = '' + let stderr = '' + proc.stdout.setEncoding('utf8') + proc.stderr.setEncoding('utf8') + proc.stdout.on('data', function (d) { stdout += d }) + proc.stderr.on('data', function (d) { stderr += d }) + proc.on('error', function (err) { + debugPush(' [exec] spawn error: ' + err.message) + resolve({ ok: false, code: -1, stdout: '', stderr: err.message }) + }) + proc.on('close', function (code) { + debugPush(' [exec] exit: ' + code + (stderr.trim() ? ' stderr=' + stderr.trim().slice(0, 200) : '')) + // witr: 0=ok, 1=ok with warnings, 2=not found, 3=permission, 4=multiple matches (prose, not JSON), 5=internal + resolve({ ok: code === 0 || code === 1, code: code, stdout: stdout, stderr: stderr }) + }) + }) +} + +function writeTempScript(content) { + const scriptPath = path.join( + os.tmpdir(), + 'why-' + Date.now() + '-' + Math.random().toString(36).slice(2) + '.ps1' + ) + fs.writeFileSync(scriptPath, '' + content, 'utf8') + return scriptPath +} + +function runPowerShellScript(scriptContent, label) { + const scriptPath = writeTempScript(scriptContent) + return execCapture('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', scriptPath + ], PS_TIMEOUT_MS).then(function (res) { + try { fs.unlinkSync(scriptPath) } catch (e) {} + if (!res.ok) { + debugPush('[why] ' + (label || 'ps') + ' failed: ' + (res.stderr || ('exit ' + res.code))) + } + return res + }) +} + +/** Resolve witr.exe (sync). Never returns a path inside .asar (spawn would ENOENT). */ +function findWitrBin() { + if (_witrPath !== undefined) return _witrPath + const found = witrInstall.findWitrBinSync() + _witrPath = found || null + if (found) debugPush('[why] witr bin: ' + found) + else debugPush('[why] witr not found yet (will extract/download)') + return _witrPath +} + +/** + * Ensure a *spawnable* witr is available (extract from asar or download). + * @returns {Promise} + */ +function ensureWitrBin() { + const existing = findWitrBin() + if (existing) return Promise.resolve(existing) + // clear stale null cache before install attempt + _witrPath = undefined + return witrInstall.ensureWitr().then(function (bin) { + _witrPath = bin || null + if (bin) debugPush('[why] witr ready: ' + bin) + else debugPush('[why] witr still unavailable') + return _witrPath + }) +} + +function resetWitrCache() { + _witrPath = undefined +} + +/** + * Run witr CLI. Accepts exit 0/1 as success (1 = found with warnings). + * Always requests --json --no-color for machine parsing. + * + * 退出码 4 不是失败:witr 在目标匹配到多个进程时会退出 4,并输出**人类可读**的列表 + * ("Multiple matching processes found: [1] xxx.exe (pid 123)…"),需要单独解析。 + * @returns {Promise<{ok:boolean, code:number, results:object[], raw:string, notFound:boolean, multiple?:{pid:number,name:string}[]}>} + */ +function runWitr(args, label) { + return ensureWitrBin().then(function (bin) { + if (!bin) { + return { ok: false, code: -1, results: [], raw: '', notFound: false, reason: 'witr-unavailable' } + } + const fullArgs = args.concat(['--json', '--no-color']) + return execCapture(bin, fullArgs, WITR_TIMEOUT_MS).then(function (res) { + const raw = stripBom(res.stdout || '') + if (res.code === 2) { + debugPush('[witr] ' + (label || '') + ' not found') + return { ok: true, code: 2, results: [], raw: raw, notFound: true } + } + if (res.code === 3) { + debugPush('[witr] ' + (label || '') + ' permission denied') + return { ok: false, code: 3, results: [], raw: raw, notFound: false, reason: 'permission' } + } + if (res.code === 4) { + const multiple = parseWitrMultipleTargets(raw) + debugPush('[witr] ' + (label || '') + ' 匹配到 ' + multiple.length + ' 个目标 (exit 4)') + return { ok: true, code: 4, results: [], raw: raw, notFound: multiple.length === 0, multiple: multiple } + } + if (res.code === 5) { + debugPush('[witr] ' + (label || '') + ' 内部错误: ' + raw.trim().slice(0, 200)) + return { ok: false, code: 5, results: [], raw: raw, notFound: false, reason: 'internal' } + } + if (!res.ok && !raw.trim()) { + debugPush('[witr] ' + (label || '') + ' failed code=' + res.code) + return { ok: false, code: res.code, results: [], raw: raw, notFound: false, reason: res.stderr || 'failed' } + } + const results = parseWitrJsonStream(raw) + debugPush('[witr] ' + (label || '') + ' → ' + results.length + ' result(s)') + return { ok: true, code: res.code, results: results, raw: raw, notFound: results.length === 0 } + }) + }) +} + +/** + * 解析 witr 退出码 4 时的多目标文本列表: + * + * Multiple matching processes found: + * + * [1] powershell.exe (pid 43396) + * powershell.exe -NoProfile ... + * [2] powershell.exe (pid 38476) + * + * @returns {{pid:number, name:string}[]} + */ +function parseWitrMultipleTargets(raw) { + const text = stripBom(raw) + const out = [] + const seen = new Set() + const re = /^\s*\[\d+\]\s+(.+?)\s*\(pid\s+(\d+)\)/gm + let m + while ((m = re.exec(text)) !== null) { + const pid = Number(m[2]) + if (!pid || seen.has(pid)) continue + seen.add(pid) + out.push({ pid: pid, name: String(m[1] || '').trim() }) + } + return out +} + +function stripBom(s) { + return String(s || '').replace(/^/, '') +} + +/** Parse one or more concatenated witr JSON documents (multi-target). */ +function parseWitrJsonStream(raw) { + const trimmed = stripBom(raw).trim() + if (!trimmed) return [] + try { + const one = JSON.parse(trimmed) + return normalizeWitrDocuments(one) + } catch (e) { + const objs = splitJsonObjects(trimmed) + const out = [] + for (let i = 0; i < objs.length; i++) { + out.push.apply(out, normalizeWitrDocuments(objs[i])) + } + return out + } +} + +function normalizeWitrDocuments(data) { + if (data == null) return [] + if (Array.isArray(data)) { + // short shape: [{PID, Command}, ...] OR array of Results + if (data.length === 0) return [] + if (data[0] && (data[0].Process || data[0].Target || data[0].Ancestry)) { + return data.map(function (d) { return parseWitrResult(d) }).filter(Boolean) + } + if (data[0] && (data[0].PID != null || data[0].pid != null)) { + const parsed = parseWitrResult(data) + return parsed ? [parsed] : [] + } + return [] + } + // warnings-only shape: {PID, Process, Command, Warnings} + if (data.PID != null && data.Process != null && data.Target == null && data.Ancestry == null && !data.Source) { + return [parseWarningsJson(data)] + } + const parsed = parseWitrResult(data) + return parsed ? [parsed] : [] +} + +function formatAncestryText(ancestry) { + if (!Array.isArray(ancestry) || ancestry.length === 0) return '' + return ancestry.map(function (p) { + const rawName = (p.name || p.Command || p.command || '').trim() + const name = rawName || '(unknown)' + const pid = p.pid != null ? p.pid : (p.PID != null ? p.PID : 0) + return name + ' (pid ' + pid + ')' + }).join(' → ') +} + +function normalizeChainItem(raw) { + if (!raw || typeof raw !== 'object') return null + const pid = Number(raw.pid != null ? raw.pid : raw.PID) || 0 + let name = String(raw.name || raw.Command || raw.command || '').trim() + // witr prints empty Command as (unknown) + if (!name) name = '(unknown)' + if (!pid && name === '(unknown)') return null + return { + pid: pid, + name: name, + ppid: Number(raw.ppid != null ? raw.ppid : raw.PPID) || 0, + exe: String(raw.exe || raw.Exe || ''), + cmd: String(raw.cmd || raw.Cmdline || raw.cmdLine || ''), + user: String(raw.user || raw.User || ''), + startedAt: String(raw.startedAt || raw.StartedAt || ''), + workingDir: String(raw.workingDir || raw.WorkingDir || ''), + gitRepo: String(raw.gitRepo || raw.GitRepo || ''), + gitBranch: String(raw.gitBranch || raw.GitBranch || ''), + health: String(raw.health || raw.Health || '') + } +} + +function normalizeChain(list) { + if (!Array.isArray(list)) return [] + const chain = [] + for (let i = 0; i < list.length; i++) { + const item = normalizeChainItem(list[i]) + if (item) chain.push(item) + if (chain.length >= MAX_ANCESTRY) break + } + return chain +} + +/** + * witr model.Source → { type, name, description, unitFile, details } + * Human format is: `Name (type)` e.g. `Explorer.EXE (shell)` + */ +function normalizeSource(src) { + if (!src || typeof src !== 'object') { + return { type: 'unknown', name: '', description: '', unitFile: '', details: null } + } + return { + type: String(src.Type || src.type || 'unknown').toLowerCase(), + name: String(src.Name || src.name || ''), + description: String(src.Description || src.description || ''), + unitFile: String(src.UnitFile || src.unitFile || ''), + details: src.Details || src.details || null + } +} + +function formatSourceLine(source) { + if (!source) return '' + const name = source.name || '' + const type = source.type || 'unknown' + if (!name) return type + return name + ' (' + type + ')' +} + +function normalizeSockets(list) { + if (!Array.isArray(list)) return [] + return list.map(function (s) { + if (!s || typeof s !== 'object') return null + return { + port: Number(s.Port != null ? s.Port : s.port) || 0, + address: String(s.Address != null ? s.Address : s.address || ''), + state: String(s.State != null ? s.State : s.state || ''), + protocol: String(s.Protocol != null ? s.Protocol : s.protocol || '') + } + }).filter(Boolean) +} + +function parseWarningsJson(data) { + const warnings = Array.isArray(data.Warnings) ? data.Warnings.map(String) : [] + const pid = Number(data.PID) || 0 + const name = String(data.Process || '') + const chain = pid ? [{ pid: pid, name: name || '(unknown)', ppid: 0, exe: '', cmd: String(data.Command || ''), user: '', startedAt: '', workingDir: '', gitRepo: '', gitBranch: '', health: '' }] : [] + return { + engine: 'witr', + target: { type: 'pid', value: String(pid) }, + resolvedTarget: name, + process: { + pid: pid, + ppid: 0, + command: name, + cmdLine: String(data.Command || ''), + exe: '', + startedAt: '', + user: '', + workingDir: '', + gitRepo: '', + gitBranch: '', + container: '', + service: '', + health: '' + }, + restartCount: 0, + ancestry: chain, + ancestryText: formatAncestryText(chain), + source: { type: 'unknown', name: '', description: '', unitFile: '', details: null }, + sourceLine: '', + warnings: warnings, + workingDir: '', + cmdLine: String(data.Command || ''), + gitRepo: '', + gitBranch: '', + user: '', + startedAt: '', + sockets: [], + children: [] + } +} + +/** + * Parse a witr Result document (Go capitalized JSON) into WhyInfo. + * Also accepts short/tree shapes. + */ +function parseWitrResult(dataOrRaw) { + let data = dataOrRaw + if (typeof dataOrRaw === 'string') { + const trimmed = stripBom(dataOrRaw).trim() + if (!trimmed) return null + try { + data = JSON.parse(trimmed) + } catch (e) { + const objs = splitJsonObjects(trimmed) + if (objs.length > 0) data = objs[0] + else return null + } + } + + if (Array.isArray(data)) { + const looksLikeChain = data.length > 0 && data.every(function (item) { + return item && typeof item === 'object' && !item.Process && !item.Ancestry && !item.Target && + (item.PID != null || item.pid != null) + }) + if (looksLikeChain || data.length === 0) { + const chainOnly = normalizeChain(data) + return { + engine: 'witr', + target: { type: 'pid', value: '' }, + resolvedTarget: '', + process: chainOnly.length ? { + pid: chainOnly[chainOnly.length - 1].pid, + ppid: chainOnly[chainOnly.length - 1].ppid, + command: chainOnly[chainOnly.length - 1].name, + cmdLine: chainOnly[chainOnly.length - 1].cmd, + exe: chainOnly[chainOnly.length - 1].exe, + startedAt: '', + user: '', + workingDir: '', + gitRepo: '', + gitBranch: '', + container: '', + service: '', + health: '' + } : emptyProcess(), + restartCount: 0, + ancestry: chainOnly, + ancestryText: formatAncestryText(chainOnly), + source: { type: 'unknown', name: '', description: '', unitFile: '', details: null }, + sourceLine: '', + warnings: [], + workingDir: '', + cmdLine: chainOnly.length ? chainOnly[chainOnly.length - 1].cmd : '', + gitRepo: '', + gitBranch: '', + user: '', + startedAt: '', + sockets: [], + children: [] + } + } + data = data[0] + } + + if (!data || typeof data !== 'object') return null + + // warnings-only + if (data.PID != null && data.Process != null && data.Target == null && data.Ancestry == null && !data.Source) { + return parseWarningsJson(data) + } + + // tree shape without Process/Target + if (Array.isArray(data.Ancestry) && !data.Process && !data.Target) { + const chain = normalizeChain(data.Ancestry) + const children = normalizeChain(data.Children || []) + return { + engine: 'witr', + target: { type: 'pid', value: '' }, + resolvedTarget: '', + process: emptyProcess(), + restartCount: 0, + ancestry: chain, + ancestryText: formatAncestryText(chain), + source: { type: 'unknown', name: '', description: '', unitFile: '', details: null }, + sourceLine: '', + warnings: [], + workingDir: '', + cmdLine: '', + gitRepo: '', + gitBranch: '', + user: '', + startedAt: '', + sockets: [], + children: children + } + } + + const processRaw = data.Process || data.process || {} + const self = normalizeChainItem(processRaw) + let chain = normalizeChain(data.Ancestry || data.ancestry || []) + if (chain.length === 0) { + if (self) chain = [self] + } else if (self && chain[chain.length - 1].pid !== self.pid) { + chain = chain.concat([self]) + } + + const source = normalizeSource(data.Source || data.source) + const warnings = Array.isArray(data.Warnings || data.warnings) + ? (data.Warnings || data.warnings).map(String) + : [] + + const process = { + pid: Number(processRaw.PID != null ? processRaw.PID : processRaw.pid) || (self ? self.pid : 0), + ppid: Number(processRaw.PPID != null ? processRaw.PPID : processRaw.ppid) || (self ? self.ppid : 0), + command: String(processRaw.Command != null ? processRaw.Command : (processRaw.command || (self ? self.name : ''))), + cmdLine: String(processRaw.Cmdline != null ? processRaw.Cmdline : (processRaw.cmdLine || (self ? self.cmd : ''))), + exe: String(processRaw.Exe != null ? processRaw.Exe : (processRaw.exe || (self ? self.exe : ''))), + startedAt: String(processRaw.StartedAt != null ? processRaw.StartedAt : (processRaw.startedAt || '')), + user: String(processRaw.User != null ? processRaw.User : (processRaw.user || '')), + workingDir: String(processRaw.WorkingDir != null ? processRaw.WorkingDir : (processRaw.workingDir || '')), + gitRepo: String(processRaw.GitRepo != null ? processRaw.GitRepo : (processRaw.gitRepo || '')), + gitBranch: String(processRaw.GitBranch != null ? processRaw.GitBranch : (processRaw.gitBranch || '')), + container: String(processRaw.Container != null ? processRaw.Container : (processRaw.container || '')), + service: String(processRaw.Service != null ? processRaw.Service : (processRaw.service || '')), + health: String(processRaw.Health != null ? processRaw.Health : (processRaw.health || '')), + deleted: !!(processRaw.ExeDeleted || processRaw.deleted) + } + + const targetRaw = data.Target || data.target || {} + const sockets = normalizeSockets(processRaw.Sockets || processRaw.sockets) + const children = normalizeChain(data.Children || data.children || []) + + return { + engine: 'witr', + target: { + type: String(targetRaw.Type || targetRaw.type || '').toLowerCase(), + value: String(targetRaw.Value != null ? targetRaw.Value : (targetRaw.value || '')) + }, + resolvedTarget: String(data.ResolvedTarget || data.resolvedTarget || ''), + process: process, + restartCount: Number(data.RestartCount != null ? data.RestartCount : data.restartCount) || 0, + ancestry: chain, + ancestryText: formatAncestryText(chain), + source: source, + sourceLine: formatSourceLine(source), + warnings: warnings, + workingDir: process.workingDir, + cmdLine: process.cmdLine, + gitRepo: process.gitRepo, + gitBranch: process.gitBranch, + user: process.user, + startedAt: process.startedAt, + sockets: sockets, + children: children + } +} + +function emptyProcess() { + return { + pid: 0, ppid: 0, command: '', cmdLine: '', exe: '', + startedAt: '', user: '', workingDir: '', gitRepo: '', gitBranch: '', + container: '', service: '', health: '', deleted: false + } +} + +function splitJsonObjects(text) { + const out = [] + let depth = 0 + let start = -1 + let inStr = false + let esc = false + for (let i = 0; i < text.length; i++) { + const ch = text[i] + if (inStr) { + if (esc) esc = false + else if (ch === '\\') esc = true + else if (ch === '"') inStr = false + continue + } + if (ch === '"') { inStr = true; continue } + if (ch === '{') { + if (depth === 0) start = i + depth++ + } else if (ch === '}') { + depth-- + if (depth === 0 && start >= 0) { + try { out.push(JSON.parse(text.slice(start, i + 1))) } catch (e) {} + start = -1 + } + } + } + return out +} + +function parseNativeWhy(raw) { + const trimmed = stripBom(raw).trim() + if (!trimmed) return null + let data + try { + data = JSON.parse(trimmed) + } catch (e) { + return null + } + if (!data || typeof data !== 'object') return null + + const chain = normalizeChain(data.ancestry || data.Ancestry || []) + const src = normalizeSource(data.source || data.Source) + const warnings = Array.isArray(data.warnings || data.Warnings) + ? (data.warnings || data.Warnings).map(String) + : [] + const self = chain.length ? chain[chain.length - 1] : null + + return { + engine: 'native', + target: { type: 'pid', value: self ? String(self.pid) : '' }, + resolvedTarget: self ? self.name : '', + process: { + pid: self ? self.pid : 0, + ppid: self ? self.ppid : 0, + command: self ? self.name : '', + cmdLine: self ? self.cmd : '', + exe: self ? self.exe : '', + startedAt: '', + user: '', + workingDir: String(data.workingDir || ''), + gitRepo: '', + gitBranch: '', + container: '', + service: '', + health: '' + }, + restartCount: 0, + ancestry: chain, + ancestryText: formatAncestryText(chain), + source: src, + sourceLine: formatSourceLine(src), + warnings: warnings, + workingDir: String(data.workingDir || ''), + cmdLine: self ? self.cmd : '', + gitRepo: '', + gitBranch: '', + user: '', + startedAt: '', + sockets: [], + children: [] + } +} + +function buildNativeWhyScript(pid) { + const n = Number(pid) | 0 + return [ + '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8', + '$ErrorActionPreference = "SilentlyContinue"', + '$targetPid = ' + n, + '$chain = New-Object System.Collections.Generic.List[object]', + '$seen = @{}', + '$cur = $targetPid', + 'while ($cur -gt 0 -and -not $seen.ContainsKey($cur) -and $chain.Count -lt 24) {', + ' $seen[$cur] = $true', + ' $p = Get-CimInstance Win32_Process -Filter "ProcessId = $cur"', + ' if (-not $p) { break }', + ' $chain.Add([ordered]@{', + ' pid = [int]$p.ProcessId', + ' name = $p.Name', + ' cmd = $p.CommandLine', + ' ppid = [int]$p.ParentProcessId', + ' exe = $p.ExecutablePath', + ' })', + ' $cur = [int]$p.ParentProcessId', + '}', + '$chain.Reverse()', + 'if ($chain.Count -eq 0) {', + ' Write-Output "{`"ancestry`":[],`"source`":{`"type`":`"unknown`",`"name`":`"`",`"description`":`"`"},`"warnings`":[]}"', + ' exit', + '}', + '$target = $chain[$chain.Count - 1]', + '$parent = $null', + 'if ($chain.Count -ge 2) { $parent = $chain[$chain.Count - 2] }', + '$svc = Get-CimInstance Win32_Service | Where-Object { $_.ProcessId -eq $targetPid } | Select-Object -First 1', + '$sourceType = "unknown"', + '$sourceName = ""', + '$sourceDesc = ""', + 'if ($svc) {', + ' $sourceType = "windows_service"', + ' $sourceName = $svc.Name', + ' $sourceDesc = $svc.DisplayName', + '} elseif ($parent -and $parent.name -match "(?i)services\\.exe") {', + ' $sourceType = "windows_service"', + ' $sourceName = "services.exe"', + ' $sourceDesc = "Windows Service Control Manager"', + '} elseif ($parent -and $parent.name -match "(?i)svchost") {', + ' $sourceType = "windows_service"', + ' $sourceName = "svchost.exe"', + ' $sourceDesc = "Hosted Windows service"', + '} elseif ($parent -and $parent.name -match "(?i)taskeng|taskhost|taskhostw") {', + ' $sourceType = "cron"', + ' $sourceName = $parent.name', + ' $sourceDesc = "Task Scheduler task"', + '} elseif ($parent -and $parent.name -match "(?i)explorer") {', + ' $sourceType = "shell"', + ' $sourceName = "explorer.exe"', + ' $sourceDesc = "Interactive desktop session"', + '} elseif ($parent -and $parent.name -match "(?i)cmd|powershell|pwsh|WindowsTerminal|wt") {', + ' $sourceType = "shell"', + ' $sourceName = $parent.name', + ' $sourceDesc = "Interactive shell session"', + '} elseif ($parent -and $parent.name -match "(?i)wininit|csrss|smss") {', + ' $sourceType = "init"', + ' $sourceName = $parent.name', + ' $sourceDesc = "Windows system process"', + '} elseif ($chain.Count -eq 1) {', + ' $sourceType = "init"', + ' $sourceName = $target.name', + ' $sourceDesc = "System / root process"', + '} elseif ($parent) {', + ' $sourceName = $parent.name', + ' $sourceDesc = "Started by parent process"', + '}', + '$warnings = @()', + 'if ($target.cmd -match "(?i)LD_PRELOAD|DYLD_") { $warnings += "Command line contains library preload indicators" }', + '$result = [ordered]@{', + ' ancestry = $chain', + ' source = [ordered]@{ type = $sourceType; name = $sourceName; description = $sourceDesc }', + ' warnings = $warnings', + ' workingDir = ""', + '}', + '$result | ConvertTo-Json -Depth 6 -Compress' + ].join('\n') +} + +function explainByWitr(pid) { + return runWitr(['--pid', String(Number(pid) | 0)], 'pid:' + pid).then(function (res) { + if (!res.ok || res.results.length === 0) return null + return res.results[0] + }) +} + +function explainByNative(pid) { + const script = buildNativeWhyScript(pid) + return runPowerShellScript(script, 'native-why').then(function (res) { + if (!res.ok) return null + return parseNativeWhy(res.stdout) + }) +} + +/** + * Explain why a process exists (full witr Result). Prefer witr, fall back to native. + * @returns {Promise} + */ +function explainProcess(pid) { + return explainByWitr(pid).then(function (viaWitr) { + if (viaWitr) return viaWitr + return explainByNative(pid) + }) +} + +/** + * witr 多目标文本里的条目 → ProcessInfo(逐个用 witr --pid 补全溯源信息)。 + */ +function targetToProcessInfo(target, reason) { + return explainByWitr(target.pid).then(function (why) { + if (why) return whyToProcessInfo(why, reason) + // 进程可能在两次调用之间退出:先给出基本信息,后续 withWhyAll 会再尝试补全 + return { + pid: target.pid, + name: target.name, + exePath: '', + detectSource: 'witr', + confidence: 'medium', + reason: reason, + cmdLine: '', + user: '', + startedAt: '' + } + }) +} + +function resolveTargets(targets, reason) { + return Promise.all(targets.map(function (t) { + return targetToProcessInfo(t, reason) + })).then(function (list) { + return list.filter(Boolean) + }) +} + +/** + * Find process(es) holding a file via `witr --file`. + * 一个文件被多个进程占用时 witr 退出码 4 并输出文本列表,这里展开成多条记录。 + * @returns {Promise} null if witr unavailable + */ +function findFileHoldersByWitr(filePath) { + return runWitr(['--file', filePath], 'file').then(function (res) { + if (!res.ok) return null + if (res.multiple && res.multiple.length > 0) { + debugPush('[witr] file → ' + res.multiple.length + ' holder(s) (multi-target)') + return resolveTargets(res.multiple, 'witr --file 确证占用') + } + return res.results.map(function (why) { + return whyToProcessInfo(why, 'witr --file 确证占用') + }).filter(Boolean) + }) +} + +/** + * Find process(es) owning a port via `witr --port`. + * Socket rows are filtered to the queried port (witr returns every socket of the process). + * @returns {Promise} + */ +function findPortOwnersByWitr(port) { + return runWitr(['--port', String(port)], 'port:' + port).then(function (res) { + if (!res.ok) return null + if (res.multiple && res.multiple.length > 0) { + debugPush('[witr] port → ' + res.multiple.length + ' owner(s) (multi-target)') + return Promise.all(res.multiple.map(function (t) { return explainByWitr(t.pid) })).then(function (whys) { + const out = [] + whys.forEach(function (why) { + if (!why) return + const infos = whyToPortInfo(why, port) + if (infos && infos.length) out.push.apply(out, infos) + }) + return out + }) + } + const out = [] + for (let i = 0; i < res.results.length; i++) { + const infos = whyToPortInfo(res.results[i], port) + if (infos && infos.length) out.push.apply(out, infos) + } + return out + }) +} + +/** + * Map one witr Result to PortInfo rows (one per matching socket). + * @param {object} why + * @param {number|string} [onlyPort] keep only this local port + */ +function whyToPortInfo(why, onlyPort) { + if (!why || !why.process) return [] + const p = why.process + let socks = Array.isArray(why.sockets) ? why.sockets.slice() : [] + if (onlyPort != null && onlyPort !== '') { + const n = Number(onlyPort) | 0 + const filtered = socks.filter(function (s) { return (s.port | 0) === n }) + if (filtered.length) socks = filtered + } + if (!socks.length) { + socks = [{ + port: Number(onlyPort) | 0, + address: '', + state: 'LISTEN', + protocol: 'TCP' + }] + } + return socks.map(function (s) { + return { + pid: p.pid, + processName: p.command || '(unknown)', + exePath: p.exe || '', + protocol: (String(s.protocol).toUpperCase() === 'UDP' ? 'UDP' : 'TCP'), + state: s.state || '', + localAddress: s.address || '', + localPort: s.port || 0, + why: why + } + }) +} + +function whyToProcessInfo(why, reason) { + if (!why || !why.ancestry || why.ancestry.length === 0) return null + const p = why.process || {} + const self = why.ancestry[why.ancestry.length - 1] + return { + pid: p.pid || self.pid, + name: p.command || self.name, + exePath: p.exe || self.exe || '', + detectSource: 'witr', + confidence: 'high', + reason: reason || 'witr 确证', + cmdLine: p.cmdLine || self.cmd || '', + user: p.user || '', + startedAt: p.startedAt || '', + why: why + } +} + +function withWhy(proc) { + if (!proc || !proc.pid) return Promise.resolve(proc) + if (proc.why && proc.why.ancestryText) return Promise.resolve(proc) + return explainProcess(proc.pid).then(function (why) { + return Object.assign({}, proc, { + why: why || parseNativeWhy('{"ancestry":[],"source":{},"warnings":[]}') + }) + }) +} + +function withWhyAll(procs) { + const list = Array.isArray(procs) ? procs : [] + return Promise.all(list.map(withWhy)) +} + +module.exports = { + findWitrBin, + ensureWitrBin, + resetWitrCache, + runWitr, + formatAncestryText, + formatSourceLine, + normalizeChain, + normalizeSource, + parseWitrResult, + parseWitrJsonStream, + parseNativeWhy, + splitJsonObjects, + buildNativeWhyScript, + explainProcess, + explainByWitr, + explainByNative, + findFileHoldersByWitr, + findPortOwnersByWitr, + parseWitrMultipleTargets, + resolveTargets, + targetToProcessInfo, + whyToProcessInfo, + whyToPortInfo, + withWhy, + withWhyAll, + getDebugLog +} diff --git a/plugins/unlock/public/preload/why.test.js b/plugins/unlock/public/preload/why.test.js new file mode 100644 index 000000000..a6d017a1d --- /dev/null +++ b/plugins/unlock/public/preload/why.test.js @@ -0,0 +1,256 @@ +const assert = require('node:assert/strict') +const { describe, it } = require('node:test') +const { + formatAncestryText, + formatSourceLine, + normalizeChain, + normalizeSource, + parseWitrResult, + parseWitrJsonStream, + parseWitrMultipleTargets, + parseNativeWhy, + splitJsonObjects, + buildNativeWhyScript +} = require('./why') + +// Trimmed fixture matching real `witr --pid N --json` shape (Go capitalized keys) +const WITR_PID_JSON = { + Target: { Type: 'pid', Value: '3068' }, + ResolvedTarget: 'powershell.exe', + Process: { + PID: 3068, + PPID: 39288, + Command: 'powershell.exe', + Cmdline: 'powershell.exe -NoProfile -Command ...', + Exe: 'C:\\WINDOWS\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + StartedAt: '2026-09-23T17:38:45.0559122+08:00', + User: 'TAITRES\\9206', + WorkingDir: 'C:\\home\\unlock-file\\', + GitRepo: 'unlock-file', + GitBranch: 'master', + Container: '', + Service: '', + Sockets: [ + { Inode: '', Port: 38765, Address: '127.0.0.1', State: 'LISTEN', Protocol: 'TCP' } + ], + Health: 'healthy', + ExeDeleted: false + }, + RestartCount: 0, + Ancestry: [ + { PID: 13568, PPID: 0, Command: '', Cmdline: '', Exe: '' }, + { PID: 13760, PPID: 13568, Command: 'Explorer.EXE', Cmdline: 'C:\\WINDOWS\\Explorer.EXE', Exe: 'C:\\WINDOWS\\Explorer.EXE' }, + { PID: 17864, PPID: 13760, Command: 'Xiaomi MiMo.exe', Cmdline: '...', Exe: '...' }, + { PID: 3068, PPID: 39288, Command: 'powershell.exe', Cmdline: '...', Exe: '...' } + ], + Source: { + Type: 'shell', + Name: 'pwsh.exe', + Description: '', + UnitFile: '', + Details: null + }, + Warnings: null, + SocketInfo: null, + ResourceContext: null, + FileContext: null +} + +const WITR_FILE_JSON = Object.assign({}, WITR_PID_JSON, { + Target: { Type: 'file', Value: 'C:\\Users\\9206\\AppData\\Local\\Temp\\witr-lock-test.txt' } +}) + +const WITR_WARNINGS_JSON = { + PID: 28696, + Process: 'pwsh.exe', + Command: 'pwsh.exe -NoLogo ...', + Warnings: ['Process is running as root'] +} + +describe('formatAncestryText', () => { + it('matches witr human short form: Name (pid N)', () => { + const text = formatAncestryText([ + { pid: 13568, name: '(unknown)' }, + { pid: 13760, name: 'Explorer.EXE' }, + { pid: 3068, name: 'powershell.exe' } + ]) + assert.equal(text, '(unknown) (pid 13568) → Explorer.EXE (pid 13760) → powershell.exe (pid 3068)') + }) + + it('returns empty for empty chain', () => { + assert.equal(formatAncestryText([]), '') + assert.equal(formatAncestryText(null), '') + }) +}) + +describe('formatSourceLine', () => { + it('renders Name (type) like witr', () => { + assert.equal(formatSourceLine({ type: 'shell', name: 'pwsh.exe' }), 'pwsh.exe (shell)') + assert.equal(formatSourceLine({ type: 'windows_service', name: 'Spooler' }), 'Spooler (windows_service)') + assert.equal(formatSourceLine({ type: 'unknown', name: '' }), 'unknown') + }) +}) + +describe('normalizeChain', () => { + it('accepts Go-style PID/Command and maps empty Command to (unknown)', () => { + const chain = normalizeChain([ + { PID: 13568, PPID: 0, Command: '' }, + { PID: 13760, PPID: 13568, Command: 'Explorer.EXE', Exe: 'C:\\WINDOWS\\Explorer.EXE' } + ]) + assert.equal(chain[0].name, '(unknown)') + assert.equal(chain[1].name, 'Explorer.EXE') + assert.equal(chain[1].exe, 'C:\\WINDOWS\\Explorer.EXE') + }) +}) + +describe('normalizeSource', () => { + it('keeps Type/Name/Description/UnitFile from witr Source', () => { + const src = normalizeSource({ + Type: 'shell', + Name: 'Explorer.EXE', + Description: '', + UnitFile: '', + Details: null + }) + assert.equal(src.type, 'shell') + assert.equal(src.name, 'Explorer.EXE') + assert.equal(src.unitFile, '') + }) + + it('handles missing source', () => { + assert.equal(normalizeSource(null).type, 'unknown') + }) +}) + +describe('parseWitrResult (real Result shape)', () => { + it('parses full Result with Ancestry + Source + Process context', () => { + const why = parseWitrResult(JSON.stringify(WITR_PID_JSON)) + assert.equal(why.engine, 'witr') + assert.equal(why.target.type, 'pid') + assert.equal(why.process.pid, 3068) + assert.equal(why.process.command, 'powershell.exe') + assert.equal(why.process.user, 'TAITRES\\9206') + assert.equal(why.process.gitRepo, 'unlock-file') + assert.equal(why.process.gitBranch, 'master') + assert.equal(why.workingDir, 'C:\\home\\unlock-file\\') + assert.equal(why.source.type, 'shell') + assert.equal(why.source.name, 'pwsh.exe') + assert.equal(why.sourceLine, 'pwsh.exe (shell)') + assert.equal(why.ancestryText, '(unknown) (pid 13568) → Explorer.EXE (pid 13760) → Xiaomi MiMo.exe (pid 17864) → powershell.exe (pid 3068)') + assert.equal(why.sockets.length, 1) + assert.equal(why.sockets[0].port, 38765) + assert.deepEqual(why.warnings, []) + }) + + it('parses file-target Result', () => { + const why = parseWitrResult(WITR_FILE_JSON) + assert.equal(why.target.type, 'file') + assert.ok(why.target.value.indexOf('witr-lock-test.txt') >= 0) + assert.equal(why.process.pid, 3068) + }) + + it('parses short ancestry-only array', () => { + const why = parseWitrResult([ + { PID: 4, Command: 'System' }, + { PID: 20, Command: 'python' } + ]) + assert.equal(why.engine, 'witr') + assert.equal(why.ancestryText, 'System (pid 4) → python (pid 20)') + }) + + it('parses warnings-only JSON', () => { + const why = parseWitrResult(WITR_WARNINGS_JSON) + assert.equal(why.process.pid, 28696) + assert.deepEqual(why.warnings, ['Process is running as root']) + }) + + it('returns null on garbage', () => { + assert.equal(parseWitrResult('not json'), null) + assert.equal(parseWitrResult(''), null) + }) +}) + +describe('parseWitrJsonStream', () => { + it('parses a single Result document', () => { + const results = parseWitrJsonStream(JSON.stringify(WITR_PID_JSON)) + assert.equal(results.length, 1) + assert.equal(results[0].process.pid, 3068) + }) + + it('parses concatenated multi-target documents', () => { + const stream = JSON.stringify(WITR_PID_JSON) + '\n' + JSON.stringify(WITR_FILE_JSON) + const results = parseWitrJsonStream(stream) + assert.equal(results.length, 2) + }) +}) + +describe('parseWitrMultipleTargets', () => { + // 真实样本:witr --file 遇到多个占用者时退出码 4,输出的是人类可读列表而非 JSON + const MULTI = [ + 'Multiple matching processes found:', + '', + '[1] powershell.exe (pid 43396)', + ' powershell.exe -NoProfile -NonInteractive -Command "$fs=[System.IO.File]::Open(\'C:\\tmp\\a.txt\',\\"Open\\")"', + '[2] powershell.exe (pid 38476)', + ' powershell.exe -NoProfile -Command "..."', + '', + 'Re-run with:', + ' witr --pid ', + '' + ].join('\n') + + it('extracts every pid from the prose list', () => { + const targets = parseWitrMultipleTargets(MULTI) + assert.equal(targets.length, 2) + assert.deepEqual(targets.map(t => t.pid), [43396, 38476]) + assert.equal(targets[0].name, 'powershell.exe') + }) + + it('ignores the re-run hint and duplicated pids', () => { + const withDup = MULTI + '\n[3] powershell.exe (pid 43396)\n' + const targets = parseWitrMultipleTargets(withDup) + assert.deepEqual(targets.map(t => t.pid), [43396, 38476]) + }) + + it('returns nothing for ordinary JSON / empty output', () => { + assert.deepEqual(parseWitrMultipleTargets(JSON.stringify(WITR_PID_JSON)), []) + assert.deepEqual(parseWitrMultipleTargets(''), []) + }) +}) + +describe('parseNativeWhy', () => { + it('parses PowerShell why JSON', () => { + const raw = JSON.stringify({ + ancestry: [ + { pid: 4, name: 'System', cmd: '', ppid: 0, exe: '' }, + { pid: 50, name: 'pwsh.exe', cmd: 'pwsh', ppid: 4, exe: '' }, + { pid: 80, name: 'node.exe', cmd: 'node app.js', ppid: 50, exe: 'C:\\node\\node.exe' } + ], + source: { type: 'shell', name: 'pwsh.exe', description: 'Interactive shell session' }, + warnings: [], + workingDir: '' + }) + const why = parseNativeWhy(raw) + assert.equal(why.engine, 'native') + assert.equal(why.ancestryText, 'System (pid 4) → pwsh.exe (pid 50) → node.exe (pid 80)') + assert.equal(why.sourceLine, 'pwsh.exe (shell)') + }) +}) + +describe('splitJsonObjects', () => { + it('splits concatenated JSON objects', () => { + const objs = splitJsonObjects('{"a":1}\n{"b":{"c":2}}') + assert.equal(objs.length, 2) + assert.equal(objs[0].a, 1) + assert.equal(objs[1].b.c, 2) + }) +}) + +describe('buildNativeWhyScript', () => { + it('embeds pid and produces PowerShell', () => { + const script = buildNativeWhyScript(1234) + assert.match(script, /\$targetPid = 1234/) + assert.match(script, /ConvertTo-Json/) + assert.match(script, /Win32_Process/) + }) +}) diff --git a/plugins/unlock/public/preload/witr-install.js b/plugins/unlock/public/preload/witr-install.js new file mode 100644 index 000000000..8e5f5495d --- /dev/null +++ b/plugins/unlock/public/preload/witr-install.js @@ -0,0 +1,308 @@ +const { spawn, execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') + +const INSTALL_TIMEOUT_MS = 120000 +const RELEASE_BASE = 'https://github.com/pranshuparmar/witr/releases/latest/download/' + +let _installPromise = null +let _debugLog = [] + +function debugPush(msg) { + _debugLog.push(msg) +} + +function getDebugLog() { + const logs = _debugLog + _debugLog = [] + return logs +} + +/** Paths inside .asar cannot be spawn()'d — Electron can read them, not exec them. */ +function isAsarPath(p) { + return /(^|[\\/])[^\s\\/]+\.asar([\\/]|$)/i.test(String(p || '')) +} + +/** + * Writable real-FS directory for the extracted/downloaded witr.exe. + * NEVER inside a .asar (spawn would ENOENT). + */ +function runtimeToolsDir() { + const base = + process.env.LOCALAPPDATA || + process.env.APPDATA || + process.env.TEMP || + os.tmpdir() + return path.join(base, 'unlock-file', 'tools') +} + +function runtimeWitrPath() { + return path.join(runtimeToolsDir(), 'witr.exe') +} + +/** Bundled source inside the plugin package (may live in asar — copy, don't spawn). */ +function bundledWitrPaths() { + return [ + path.join(__dirname, 'tools', 'witr.exe'), + path.join(__dirname, 'witr.exe'), + path.join(__dirname, '..', '..', 'tools', 'witr.exe') + ] +} + +function toolsDir() { + return runtimeToolsDir() +} + +function toolsWitrPath() { + return runtimeWitrPath() +} + +function releaseZipName() { + const arch = String(process.env.PROCESSOR_ARCHITECTURE || process.arch || '') + return /arm/i.test(arch) ? 'witr-windows-arm64.zip' : 'witr-windows-amd64.zip' +} + +function isSpawnableExe(p) { + if (!p || isAsarPath(p)) return false + try { + if (!fs.existsSync(p)) return false + const st = fs.statSync(p) + return st.isFile() && st.size > 0 + } catch (e) { + return false + } +} + +function findOnPath() { + try { + const whereOut = execFileSync('where.exe', ['witr'], { + encoding: 'utf8', + windowsHide: true, + timeout: 3000, + stdio: ['ignore', 'pipe', 'ignore'] + }) + const lines = whereOut.split(/\r?\n/).map(function (s) { return s.trim() }).filter(Boolean) + for (let i = 0; i < lines.length; i++) { + if (isSpawnableExe(lines[i])) return lines[i] + } + } catch (e) {} + // winget default location + const winget = path.join(process.env.LOCALAPPDATA || '', 'witr', 'bin', 'witr.exe') + if (isSpawnableExe(winget)) return winget + return null +} + +/** + * Sync lookup of a *spawnable* witr.exe (real filesystem, not inside asar). + */ +function findWitrBinSync() { + const runtime = runtimeWitrPath() + if (isSpawnableExe(runtime)) return runtime + + // Dev / unpacked plugin: tools next to this file may already be on real disk + const bundled = bundledWitrPaths() + for (let i = 0; i < bundled.length; i++) { + if (isSpawnableExe(bundled[i])) return bundled[i] + } + + return findOnPath() +} + +/** Source bytes we can copy out of asar (or real bundled file). */ +function findBundledSource() { + const bundled = bundledWitrPaths() + for (let i = 0; i < bundled.length; i++) { + try { + if (fs.existsSync(bundled[i])) { + const st = fs.statSync(bundled[i]) + if (st.isFile() && st.size > 0) return bundled[i] + } + } catch (e) {} + } + return null +} + +/** + * Copy witr.exe out of asar (or plugin dir) to a real spawnable path. + * @returns {string|null} + */ +function extractBundledWitr() { + const src = findBundledSource() + if (!src) { + debugPush('[witr-install] no bundled witr source found') + return null + } + const dest = runtimeWitrPath() + if (isSpawnableExe(dest)) return dest + + try { + fs.mkdirSync(runtimeToolsDir(), { recursive: true }) + } catch (e) { + debugPush('[witr-install] mkdir failed: ' + e.message) + return null + } + + try { + // read/write works with asar virtual FS; spawn does not + const buf = fs.readFileSync(src) + fs.writeFileSync(dest, buf) + debugPush('[witr-install] extracted ' + src + ' → ' + dest + ' (' + buf.length + ' bytes)') + } catch (e) { + debugPush('[witr-install] extract failed: ' + e.message) + try { + fs.copyFileSync(src, dest) + debugPush('[witr-install] copied ' + src + ' → ' + dest) + } catch (e2) { + debugPush('[witr-install] copy failed: ' + e2.message) + return null + } + } + + return isSpawnableExe(dest) ? dest : null +} + +function runPowerShellDownload(url, dest) { + const destPs = String(dest).replace(/'/g, "''") + const urlPs = String(url).replace(/'/g, "''") + const script = [ + '$ErrorActionPreference = "Stop"', + '$ProgressPreference = "SilentlyContinue"', + '[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12', + '$zip = Join-Path $env:TEMP ("witr-install-" + [guid]::NewGuid().ToString() + ".zip")', + '$extract = Join-Path $env:TEMP ("witr-extract-" + [guid]::NewGuid().ToString())', + 'try {', + ' Invoke-WebRequest -Uri \'' + urlPs + '\' -OutFile $zip -UseBasicParsing', + ' Expand-Archive -Path $zip -DestinationPath $extract -Force', + ' $exe = Get-ChildItem -Path $extract -Filter "witr.exe" -Recurse | Select-Object -First 1', + ' if (-not $exe) { throw "witr.exe not found in archive" }', + ' $dir = Split-Path -Parent \'' + destPs + '\'', + ' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }', + ' Copy-Item $exe.FullName \'' + destPs + '\' -Force', + ' Write-Output ("installed " + \'' + destPs + '\')', + '} finally {', + ' Remove-Item $zip -Force -ErrorAction SilentlyContinue', + ' Remove-Item $extract -Recurse -Force -ErrorAction SilentlyContinue', + '}' + ].join('\n') + + return new Promise(function (resolve) { + const proc = spawn('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script + ], { windowsHide: true, timeout: INSTALL_TIMEOUT_MS }) + + let stdout = '' + let stderr = '' + proc.stdout.setEncoding('utf8') + proc.stderr.setEncoding('utf8') + proc.stdout.on('data', function (d) { stdout += d }) + proc.stderr.on('data', function (d) { stderr += d }) + proc.on('error', function (err) { + resolve({ ok: false, stdout: '', stderr: err.message }) + }) + proc.on('close', function (code) { + resolve({ ok: code === 0, stdout: stdout, stderr: stderr }) + }) + }) +} + +/** + * Download witr.exe into the real runtime tools dir (never asar). + */ +function downloadWitr() { + const dest = runtimeWitrPath() + const zipName = releaseZipName() + const url = RELEASE_BASE + zipName + debugPush('[witr-install] downloading ' + url) + + try { + fs.mkdirSync(runtimeToolsDir(), { recursive: true }) + } catch (e) { + return Promise.reject(new Error('无法创建 tools 目录: ' + e.message)) + } + + return runPowerShellDownload(url, dest).then(function (res) { + if (!res.ok || !isSpawnableExe(dest)) { + const why = (res.stderr || res.stdout || ('exit failed')).trim().slice(0, 200) + throw new Error('下载 witr 失败: ' + why) + } + const size = fs.statSync(dest).size + debugPush('[witr-install] ok ' + dest + ' (' + size + ' bytes)') + return dest + }) +} + +/** + * Find a spawnable witr, extracting from the plugin package if needed, + * then downloading / winget as fallback. Concurrent callers share one install. + * @returns {Promise} + */ +function ensureWitr() { + const existing = findWitrBinSync() + if (existing) return Promise.resolve(existing) + + if (_installPromise) return _installPromise + + _installPromise = Promise.resolve().then(function () { + // 1) extract from asar / plugin bundle → real path + const extracted = extractBundledWitr() + if (extracted) return extracted + + // 2) download from GitHub releases + return downloadWitr().catch(function (err) { + debugPush('[witr-install] zip download failed: ' + err.message + ' — trying winget') + return tryWingetInstall().then(function (viaWinget) { + if (viaWinget) return viaWinget + throw err + }) + }) + }).then(function (bin) { + return bin + }).catch(function (err) { + debugPush('[witr-install] auto-install failed: ' + err.message) + return null + }).finally(function () { + _installPromise = null + }) + + return _installPromise +} + +function tryWingetInstall() { + return new Promise(function (resolve) { + const proc = spawn('winget', [ + 'install', '-e', '--id', 'PranshuParmar.witr', + '--accept-package-agreements', '--accept-source-agreements', '--silent' + ], { windowsHide: true, timeout: INSTALL_TIMEOUT_MS }) + + let stderr = '' + proc.stderr.setEncoding('utf8') + proc.stderr.on('data', function (d) { stderr += d }) + proc.on('error', function () { resolve(null) }) + proc.on('close', function () { + const found = findOnPath() + if (found) { + debugPush('[witr-install] winget installed: ' + found) + resolve(found) + } else { + resolve(null) + } + }) + }) +} + +module.exports = { + isAsarPath, + isSpawnableExe, + findWitrBinSync, + findBundledSource, + extractBundledWitr, + downloadWitr, + ensureWitr, + runtimeToolsDir, + runtimeWitrPath, + toolsDir, + toolsWitrPath, + releaseZipName, + getDebugLog +} diff --git a/plugins/unlock/public/preload/witr-install.test.js b/plugins/unlock/public/preload/witr-install.test.js new file mode 100644 index 000000000..42c6e7772 --- /dev/null +++ b/plugins/unlock/public/preload/witr-install.test.js @@ -0,0 +1,57 @@ +const assert = require('node:assert/strict') +const { describe, it } = require('node:test') +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') + +const install = require('./witr-install') + +describe('witr-install asar safety', () => { + it('isAsarPath detects plugin package paths', () => { + assert.equal(install.isAsarPath('C:\\Users\\x\\.ztools\\plugins\\unlock-1.0.0.asar\\preload\\tools\\witr.exe'), true) + assert.equal(install.isAsarPath('C:\\Users\\x\\.ztools\\plugins\\unlock-1.0.0-b33a16f8.asar\\preload\\tools\\witr.exe'), true) + assert.equal(install.isAsarPath('C:\\home\\unlock-file\\public\\preload\\tools\\witr.exe'), false) + assert.equal(install.isAsarPath('C:\\Users\\x\\AppData\\Local\\unlock-file\\tools\\witr.exe'), false) + }) + + it('runtimeToolsDir is outside asar and writable base', () => { + const dir = install.runtimeToolsDir() + assert.equal(install.isAsarPath(dir), false) + assert.ok(dir.endsWith(path.join('unlock-file', 'tools'))) + }) + + it('findWitrBinSync never returns an asar path', () => { + const found = install.findWitrBinSync() + if (found) { + assert.equal(install.isAsarPath(found), false, 'spawnable witr must not be inside asar') + assert.ok(fs.existsSync(found)) + } else { + assert.equal(found, null) + } + }) +}) + +describe('witr-install ensure', () => { + it('ensureWitr resolves a spawnable path or null (never throws)', async () => { + const bin = await install.ensureWitr() + assert.ok(bin === null || typeof bin === 'string') + if (bin) { + assert.equal(install.isAsarPath(bin), false) + assert.ok(fs.existsSync(bin)) + } + }) +}) + +describe('witr-install paths', () => { + it('toolsWitrPath points at runtime tools dir', () => { + const p = install.toolsWitrPath() + assert.ok(p.endsWith('witr.exe')) + assert.equal(install.isAsarPath(p), false) + assert.ok(p.indexOf('unlock-file') >= 0) + }) + + it('releaseZipName is windows zip for this host', () => { + const z = install.releaseZipName() + assert.match(z, /^witr-windows-(amd64|arm64)\.zip$/) + }) +}) diff --git a/plugins/unlock/scripts/install-witr.js b/plugins/unlock/scripts/install-witr.js new file mode 100644 index 000000000..460b48361 --- /dev/null +++ b/plugins/unlock/scripts/install-witr.js @@ -0,0 +1,45 @@ +import { createRequire } from 'node:module' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { existsSync, statSync } from 'node:fs' + +const require = createRequire(import.meta.url) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const install = require(path.join(__dirname, '..', 'public', 'preload', 'witr-install.js')) + +// Prefer extracting the bundled copy (works offline); else download to real runtime dir. +const force = process.argv.includes('--force') +const runtimeDest = install.runtimeWitrPath() + +if (existsSync(runtimeDest) && !force) { + console.log('[install-witr] already present:', runtimeDest) + process.exit(0) +} + +if (!force) { + const extracted = install.extractBundledWitr() + if (extracted) { + console.log('[install-witr] extracted bundled →', extracted, statSync(extracted).size, 'bytes') + process.exit(0) + } +} + +const zipName = install.releaseZipName() +console.log('[install-witr] downloading', zipName, '→', runtimeDest) + +install.downloadWitr() + .then((bin) => { + console.log('[install-witr] ok', bin, statSync(bin).size, 'bytes') + process.exit(0) + }) + .catch((err) => { + console.warn('[install-witr] zip failed:', err.message) + return install.ensureWitr().then((bin) => { + if (bin) { + console.log('[install-witr] ok via fallback', bin) + process.exit(0) + } + console.warn('[install-witr] failed (optional). Manual: winget install PranshuParmar.witr') + process.exit(0) + }) + }) \ No newline at end of file diff --git a/plugins/unlock/src/Unlock/PortTab.vue b/plugins/unlock/src/Unlock/PortTab.vue index a017b3818..7c1fb2956 100644 --- a/plugins/unlock/src/Unlock/PortTab.vue +++ b/plugins/unlock/src/Unlock/PortTab.vue @@ -1,6 +1,6 @@ @@ -93,28 +112,31 @@ async function handleKillAll() { type="number" min="1" max="65535" - placeholder="端口号 (如 8080)" + placeholder="端口号,例如 8080" @keyup.enter="handleScan" /> - + + +
+ 先看清楚端口被谁监听、它是怎么跑起来的,再决定是否结束进程
{{ loading }}
-
{{ error }}
+
{{ status.text }}
- 找到 {{ entries.length }} 条记录 + {{ entries.length }} 条监听记录 + >全部结束
- + @@ -127,6 +149,9 @@ async function handleKillAll() { @@ -137,7 +162,7 @@ async function handleKillAll() { :disabled="!!loading" class="kill-btn" @click="handleKill(entry)" - >kill + >结束进程 @@ -154,8 +179,14 @@ async function handleKillAll() { .input[type=number]::-webkit-inner-spin-button { display: none; } .btn { padding: 4px 12px; border: 1px solid var(--border-color, #555); border-radius: 4px; background: transparent; cursor: pointer; font-size: 13px; color: var(--text-color, #e0e0e0); } .btn:hover { background: var(--hover-color, #333); } +.hint-row { margin-top: 8px; } +.hint-text { font-size: 12px; color: var(--text-secondary, #888); } .loading { margin-top: 12px; text-align: center; color: var(--text-secondary, #aaa); } -.error { margin-top: 12px; padding: 10px; border-radius: 6px; background: #fff2f0; color: #cf1322; font-size: 13px; } +.status { margin-top: 12px; padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; border-left: 3px solid transparent; } +.status.success { background: rgba(82, 196, 26, 0.12); color: #95de64; border-left-color: #52c41a; } +.status.info { background: rgba(66, 184, 131, 0.10); color: #9fd8bd; border-left-color: #42b883; } +.status.warning { background: rgba(250, 173, 20, 0.12); color: #ffd666; border-left-color: #faad14; } +.status.error { background: rgba(255, 77, 79, 0.12); color: #ff9c9e; border-left-color: #ff4d4f; } .table-wrap { margin-top: 12px; overflow-x: auto; } .kill-all-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; margin-bottom: 8px; } .kill-all-bar .count { font-size: 13px; color: #aaa; } @@ -164,13 +195,15 @@ async function handleKillAll() { .kill-all-btn:disabled { opacity: 0.4; cursor: not-allowed; } .port-table { width: 100%; border-collapse: collapse; font-size: 13px; } .port-table th { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border-color, #444); color: var(--text-secondary, #999); font-weight: 600; font-size: 11px; letter-spacing: 0.5px; } -.port-table td { padding: 8px 10px; border-bottom: 1px solid var(--border-color, #333); } +.port-table td { padding: 8px 10px; border-bottom: 1px solid var(--border-color, #333); vertical-align: top; } .proc-name { font-weight: 600; } -.proc-path { font-size: 11px; color: var(--text-secondary, #999); word-break: break-all; max-width: 200px; } +.proc-path { font-size: 11px; color: var(--text-secondary, #999); word-break: break-all; max-width: 220px; } +.why-chain { margin-top: 4px; font-size: 11px; font-family: ui-monospace, Consolas, monospace; color: var(--primary-color, #42b883); word-break: break-all; max-width: 280px; } +.why-source { margin-top: 2px; font-size: 11px; color: var(--text-secondary, #888); } .proto { display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; } .proto.tcp { background: #096dd9; color: #fff; } .proto.udp { background: #722ed1; color: #fff; } -.kill-btn { padding: 4px 10px; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; background: #fff1f0; color: #cf1322; } +.kill-btn { padding: 4px 10px; border: none; border-radius: 4px; font-size: 12px; cursor: pointer; background: #fff1f0; color: #cf1322; white-space: nowrap; } .kill-btn:hover:not(:disabled) { background: #ffccc7; } .kill-btn:disabled { opacity: 0.4; cursor: not-allowed; } diff --git a/plugins/unlock/src/Unlock/ShredderTab.vue b/plugins/unlock/src/Unlock/ShredderTab.vue index 94834db31..0d0402eea 100644 --- a/plugins/unlock/src/Unlock/ShredderTab.vue +++ b/plugins/unlock/src/Unlock/ShredderTab.vue @@ -1,6 +1,6 @@ @@ -145,7 +166,7 @@ function handleCancelUnlock() { @@ -162,25 +183,32 @@ function handleCancelUnlock() { +
+ 粉碎会先用随机数据覆写文件内容再删除,无法恢复;删除只移除文件本身 +
+
{{ loading }}
-
{{ result }}
-
{{ error }}
+
{{ status.text }}
-
文件被占用
+
文件正被占用
- 以下 {{ lockedProcesses.length }} 个进程正在占用该文件。是否结束这些进程并继续{{ mode === 'shred' ? '粉碎' : '删除' }}? + 下面 {{ lockedProcesses.length }} 个程序正在使用这个文件。结束它们后即可继续{{ actionName() }},未保存的内容可能会丢失。
- {{ proc.name }} - PID: {{ proc.pid }} +
+ {{ proc.name }} + PID {{ proc.pid }} +
+
{{ proc.why.ancestryText }}
+
承载 Windows 服务:{{ proc.service }}
@@ -204,20 +232,27 @@ function handleCancelUnlock() { .mode-select { margin-top: 12px; display: flex; gap: 16px; } .mode-select label { display: flex; align-items: center; gap: 4px; font-size: 14px; cursor: pointer; color: var(--text-secondary, #999); padding: 4px 10px; border-radius: 4px; border: 1px solid transparent; } .mode-select label.active { color: var(--primary-color, #42b883); border-color: var(--primary-color, #42b883); } +.hint-row { margin-top: 8px; } +.hint-text { font-size: 12px; color: var(--text-secondary, #888); } .start-btn { margin-top: 12px; padding: 8px 20px; border: none; border-radius: 4px; font-size: 14px; cursor: pointer; background: var(--primary-color, #42b883); color: #fff; } .start-btn:disabled { opacity: 0.5; cursor: not-allowed; } .loading { margin-top: 12px; text-align: center; color: var(--text-secondary, #aaa); } -.success { margin-top: 12px; padding: 10px; border-radius: 6px; background: #f6ffed; color: #389e0d; font-size: 13px; } -.error { margin-top: 12px; padding: 10px; border-radius: 6px; background: #fff2f0; color: #cf1322; font-size: 13px; } +.status { margin-top: 12px; padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; border-left: 3px solid transparent; } +.status.success { background: rgba(82, 196, 26, 0.12); color: #95de64; border-left-color: #52c41a; } +.status.info { background: rgba(66, 184, 131, 0.10); color: #9fd8bd; border-left-color: #42b883; } +.status.warning { background: rgba(250, 173, 20, 0.12); color: #ffd666; border-left-color: #faad14; } +.status.error { background: rgba(255, 77, 79, 0.12); color: #ff9c9e; border-left-color: #ff4d4f; } .lock-confirm-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; border-radius: 8px; } -.lock-confirm-dialog { background: var(--card-bg, #2a2a2a); border-radius: 10px; padding: 20px; max-width: 90%; width: 360px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); } +.lock-confirm-dialog { background: var(--card-bg, #2a2a2a); border-radius: 10px; padding: 20px; max-width: 90%; width: 380px; box-shadow: 0 4px 20px rgba(0,0,0,0.3); } .lock-confirm-title { font-size: 16px; font-weight: 700; color: var(--text-color, #e0e0e0); margin-bottom: 8px; } .lock-confirm-desc { font-size: 13px; color: var(--text-secondary, #aaa); line-height: 1.6; margin-bottom: 14px; } -.lock-proc-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; max-height: 160px; overflow-y: auto; } -.lock-proc-item { display: flex; justify-content: space-between; align-items: center; padding: 8px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; } +.lock-proc-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; max-height: 200px; overflow-y: auto; } +.lock-proc-item { padding: 8px 10px; background: rgba(255,255,255,0.05); border-radius: 6px; } +.lock-proc-main { display: flex; justify-content: space-between; align-items: center; } .lock-proc-name { font-size: 13px; font-weight: 600; color: var(--text-color, #e0e0e0); } .lock-proc-pid { font-size: 12px; color: var(--text-secondary, #999); } +.lock-proc-why { margin-top: 4px; font-size: 11px; color: var(--text-secondary, #888); font-family: ui-monospace, Consolas, monospace; word-break: break-all; } .lock-confirm-actions { display: flex; gap: 10px; justify-content: flex-end; } .lock-cancel-btn { padding: 6px 16px; border: 1px solid var(--border-color, #555); border-radius: 4px; background: transparent; color: var(--text-color, #e0e0e0); font-size: 13px; cursor: pointer; } .lock-cancel-btn:hover { background: var(--hover-color, #333); } diff --git a/plugins/unlock/src/Unlock/UnlockTab.vue b/plugins/unlock/src/Unlock/UnlockTab.vue index ac426f317..3ff735907 100644 --- a/plugins/unlock/src/Unlock/UnlockTab.vue +++ b/plugins/unlock/src/Unlock/UnlockTab.vue @@ -1,6 +1,6 @@ @@ -145,42 +236,75 @@ async function handleKillAll() {
-
松开鼠标即可扫描
+
+ {{ whyEngineLabel }} + 先看清是谁在占用、它为什么在运行,再决定是否结束进程 +
+ +
松开鼠标即可检查
{{ loading }}
-
{{ error }}
+ +
{{ status.text }}
- 找到 {{ processes.length }} 个进程 + + {{ processes.length }} 个占用进程 + · {{ engineLabel }} + + >全部结束
-
+
{{ proc.name }} - PID: {{ proc.pid }} + PID {{ proc.pid }}
{{ proc.exePath }}
+ +
+
它是怎么跑起来的
+
{{ proc.why.ancestryText }}
+
+ 来源:{{ sourceLabel(proc.why) }} + {{ detectLabel(proc.detectSource) }} +
+
工作目录:{{ workingDirLabel(proc.why) }}
+
代码仓库:{{ gitLabel(proc.why) }}
+
运行用户:{{ userLabel(proc.why) }}
+
启动时间:{{ startedLabel(proc.why) }}
+
提示:{{ w }}
+
+ +
+ 该进程承载 Windows 服务「{{ proc.service }}」。直接结束可能会让服务自动重启,建议先停止该服务再操作。 +
{{ proc.reason }}
-
- {{ proc.cmdLine.substring(0, 100) }}{{ proc.cmdLine.length > 100 ? '...' : '' }} +
+
-
+ +
+ {{ stateLabel }} +
+
+ 点击「浏览」选择文件,或直接按回车开始检查。 +
@@ -193,24 +317,44 @@ async function handleKillAll() { .input::placeholder { color: var(--text-secondary, #888); } .btn { padding: 4px 12px; border: 1px solid var(--border-color, #555); border-radius: 4px; background: transparent; cursor: pointer; font-size: 13px; color: var(--text-color, #e0e0e0); } .btn:hover { background: var(--hover-color, #333); } +.hint-row { display: flex; align-items: center; gap: 8px; margin-top: 8px; } +.engine-tag { font-size: 11px; padding: 2px 8px; border-radius: 10px; background: rgba(66, 184, 131, 0.15); color: var(--primary-color, #42b883); font-weight: 600; } +.hint-text { font-size: 12px; color: var(--text-secondary, #888); } .loading { margin-top: 12px; text-align: center; color: var(--text-secondary, #aaa); } -.error { margin-top: 12px; padding: 10px; border-radius: 6px; background: #fff2f0; color: #cf1322; font-size: 13px; } +.idle-hint { margin-top: 12px; text-align: center; font-size: 13px; color: var(--text-secondary, #888); } +.status { margin-top: 12px; padding: 10px 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; border-left: 3px solid transparent; } +.status.success { background: rgba(82, 196, 26, 0.12); color: #95de64; border-left-color: #52c41a; } +.status.info { background: rgba(66, 184, 131, 0.10); color: #9fd8bd; border-left-color: #42b883; } +.status.warning { background: rgba(250, 173, 20, 0.12); color: #ffd666; border-left-color: #faad14; } +.status.error { background: rgba(255, 77, 79, 0.12); color: #ff9c9e; border-left-color: #ff4d4f; } +.state-chip { display: inline-block; margin-top: 12px; padding: 4px 10px; border-radius: 10px; font-size: 12px; } +.state-chip.ok { background: rgba(82, 196, 26, 0.15); color: #95de64; } +.state-chip.warn { background: rgba(250, 173, 20, 0.15); color: #ffd666; } +.state-chip.bad { background: rgba(255, 77, 79, 0.15); color: #ff9c9e; } .results { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; } .card { padding: 12px; border: 1px solid var(--border-color, #444); border-radius: 8px; background: var(--card-bg, #2a2a2a); } .info { display: flex; justify-content: space-between; align-items: center; } .name { font-weight: 600; font-size: 14px; } .pid { font-size: 12px; color: var(--text-secondary, #999); } .exe-path { margin-top: 4px; font-size: 12px; color: var(--text-secondary, #999); word-break: break-all; } -.kill-btn { margin-top: 8px; padding: 6px 14px; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; background: #fff1f0; color: #cf1322; } +.why-block { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(66, 184, 131, 0.08); border-left: 3px solid var(--primary-color, #42b883); } +.why-label { font-size: 11px; letter-spacing: 0.4px; color: var(--primary-color, #42b883); font-weight: 700; margin-bottom: 4px; } +.why-chain { font-size: 12px; line-height: 1.5; color: var(--text-color, #e0e0e0); word-break: break-all; font-family: ui-monospace, Consolas, monospace; } +.why-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; } +.why-source { font-size: 11px; padding: 1px 6px; border-radius: 3px; background: rgba(66, 184, 131, 0.2); color: var(--primary-color, #42b883); } +.why-detect { font-size: 11px; padding: 1px 6px; border-radius: 3px; background: rgba(255, 255, 255, 0.08); color: var(--text-secondary, #999); } +.why-cwd { margin-top: 4px; font-size: 11px; color: var(--text-secondary, #888); word-break: break-all; } +.why-warn { margin-top: 4px; font-size: 11px; color: #faad14; } +.service-hint { margin-top: 8px; padding: 8px 10px; border-radius: 6px; background: rgba(250, 173, 20, 0.10); color: #ffd666; font-size: 12px; line-height: 1.6; } +.card-actions { margin-top: 8px; display: flex; justify-content: flex-end; } +.kill-btn { padding: 6px 14px; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; background: #fff1f0; color: #cf1322; } .kill-btn:hover:not(:disabled) { background: #ffccc7; } .kill-btn:disabled { opacity: 0.4; cursor: not-allowed; } .kill-all-bar { display: flex; justify-content: space-between; align-items: center; padding: 10px 12px; background: #2a2a2a; border: 1px solid #444; border-radius: 8px; margin-bottom: 8px; } .kill-all-bar .count { font-size: 13px; color: #aaa; } +.kill-all-bar .engine { color: #777; } .kill-all-btn { padding: 6px 16px; border: none; border-radius: 4px; font-size: 13px; cursor: pointer; background: #ff4d4f; color: white; font-weight: 600; } .kill-all-btn:hover:not(:disabled) { background: #ff7875; } .kill-all-btn:disabled { opacity: 0.4; cursor: not-allowed; } -.card.suspicious { border-color: #faad14; background: #fffbe6; } -.card.suspicious .name { color: #d48806; } -.reason { margin-top: 4px; font-size: 12px; color: #d48806; font-style: italic; } -.cmd-line { margin-top: 4px; font-size: 11px; color: #888; font-family: monospace; background: #1a1a1a; padding: 4px 6px; border-radius: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.reason { margin-top: 4px; font-size: 12px; color: var(--text-secondary, #999); } diff --git a/plugins/unlock/src/Unlock/index.vue b/plugins/unlock/src/Unlock/index.vue index e96ff68e5..565fc2fca 100644 --- a/plugins/unlock/src/Unlock/index.vue +++ b/plugins/unlock/src/Unlock/index.vue @@ -60,7 +60,7 @@ function flushDebugLog() { function toggleDebug() { showDebug.value = !showDebug.value } onMounted(function () { - addLog('插件已加载') + addLog('插件已就绪') if (props.enterAction) { processEnterAction(props.enterAction) } @@ -115,7 +115,7 @@ watch(
- 调试日志 ({{ logs.length }}) + 运行记录(排查问题时展开) {{ showDebug ? '▼' : '▶' }}
diff --git a/plugins/unlock/src/env.d.ts b/plugins/unlock/src/env.d.ts index b06c4895e..b3804593d 100644 --- a/plugins/unlock/src/env.d.ts +++ b/plugins/unlock/src/env.d.ts @@ -9,19 +9,122 @@ declare module '*.vue' { export type TabType = 'unlock' | 'shredder' | 'port' +export interface WhySource { + type: string + name: string + description: string + unitFile?: string + details?: Record | null +} + +export interface AncestryNode { + pid: number + name: string + ppid?: number + exe?: string + cmd?: string +} + +export interface WitrSocket { + port: number + address: string + state: string + protocol: string +} + +/** Full witr-style Result: causal chain + source + context. */ +export interface WhyInfo { + engine: 'witr' | 'native' + target?: { type: string; value: string } + resolvedTarget?: string + process?: { + pid: number + ppid: number + command: string + cmdLine: string + exe: string + startedAt?: string + user?: string + workingDir?: string + gitRepo?: string + gitBranch?: string + container?: string + service?: string + health?: string + deleted?: boolean + } + restartCount?: number + ancestry: AncestryNode[] + ancestryText: string + source: WhySource + /** Human form: `Name (type)` e.g. `pwsh.exe (shell)` */ + sourceLine?: string + warnings: string[] + workingDir: string + cmdLine: string + gitRepo?: string + gitBranch?: string + user?: string + startedAt?: string + sockets?: WitrSocket[] + children?: AncestryNode[] +} + export interface ProcessInfo { pid: number name: string exePath: string + /** How the holder was confirmed */ + detectSource?: 'witr' | 'resourcemanager' | string + confidence?: 'high' | 'medium' | 'low' source?: string reason?: string cmdLine?: string + user?: string + startedAt?: string + /** Windows 服务短名(重启管理器可提供) */ + service?: string score?: number + why?: WhyInfo +} + +/** 提示级别:决定前端提示条的颜色与语气 */ +export type MessageLevel = 'success' | 'info' | 'warning' | 'error' + +export interface LockScanResult { + locked: boolean | null + /** 文件当前能否被独占打开;null = 无法确认(例如文件夹) */ + blocked?: boolean | null + /** 占用者数量 */ + holders?: number + /** 路径状态:locked / free / directory / missing / unknown */ + state?: string + processes: ProcessInfo[] + note?: string + noteLevel?: MessageLevel + engine: string } export interface OperationResult { success: boolean message: string + level?: MessageLevel +} + +export interface KillResult extends OperationResult { + /** 结束进程后是否确认占用已解除;null = 无法确认 */ + released?: boolean | null + needsAdmin?: boolean + alreadyGone?: boolean + killed?: number + failed?: number +} + +export interface ReleaseVerifyResult { + released: boolean | null + state: string + attempts: number + elapsedMs: number } export interface PortInfo { @@ -32,18 +135,30 @@ export interface PortInfo { state: string localAddress: string localPort: number + why?: WhyInfo } export interface ShredderResult extends OperationResult { filesProcessed?: number locked?: boolean + /** 失败原因是权限不足(此时不必去查找占用进程) */ + permissionDenied?: boolean } export interface Services { - findLockingProcesses: (filePath: string) => Promise - killProcess: (pid: number) => Promise + findLockingProcesses: (filePath: string) => Promise + killProcess: (pid: number, name?: string) => Promise + /** 结束单个占用进程并验证占用是否真的解除 */ + killLockingProcess: (filePath: string, pid: number, name?: string) => Promise + /** 一键结束全部占用进程并统一验证 */ + killAllLockingProcesses: (filePath: string, procs: ProcessInfo[]) => Promise + verifyRelease: (filePath: string) => Promise + killPortProcess: (port: number, pid: number, name?: string) => Promise + killAllPortProcesses: (port: number, entries: PortInfo[]) => Promise shredPath: (filePath: string, mode: 'delete' | 'shred') => Promise findPortProcess: (port: number) => Promise + explainPid: (pid: number) => Promise + getWhyEngine: () => 'witr' | 'native' getDebugLog: () => string[] getPathForFile: (file: File) => string }
进程进程 / 启动链 PID 协议 状态
{{ entry.processName }}
{{ entry.exePath }}
+
{{ entry.why.ancestryText }}
+
来源:{{ sourceLabel(entry.why) }}
+
工作目录:{{ entry.why.workingDir }}
{{ entry.pid }} {{ entry.protocol }}