DeepSeek Harness Windows 在资源管理器中显示没反应:三种根因与修复

故障排查发布于 2026-10-03作者: DeepSeek Plugin 插件市场
DeepSeek HarnessDSHrevealNativePathWindowsexplorer.exewindowsHide非 ASCII 路径前台锁
在 Windows 上点「在文件资源管理器中显示」像点死按钮:UI 报成功,资源管理器却不出现或只打开桌面。三个独立缺陷叠加——`windowsHide: true` 连 Explorer 自己的窗口也隐藏;中文路径被转成百分号编码的 file:// URL 使 /select, 无法解析;后台进程抢不到前台。

在 Windows 上点「在文件资源管理器中显示 / 打开目录」像点了个死按钮,是三个互相独立的缺陷叠在同一条代码路径上:第一,windowsHide: true 把 Explorer 自己的窗口也隐藏了——窗口被创建、坐标正确、只是 IsWindowVisible = false;第二,含中文的路径被 pathToFileURL() 转成百分号编码的 file:// URL,而 explorer.exe /select, 只认字面路径、不做解码,于是静默回退到桌面(WSL 宿主下回退到「此电脑」);第三,即便窗口可见了,后台进程抢不到 Windows 前台锁,窗口只出现在任务栏。前两个缺陷各自都足以让功能失效,且因为 Explorer 无论成败都返回退出码 1、而代码容忍了退出码 1,UI 永远显示「已请求」——这才是它「静默」的原因。

先分诊:别把三个缺陷当成一个

结论先行:症状都是「点了没反应」,但底层是三件独立的事,必须分开验证,否则你会在错误的层反复打转。 上报里最典型的一次误判是:只数「新出现了几个 Explorer 窗口」——两个 spawn 选项都恰好产生 1 个新窗口,于是看起来 windowsHide 无辜;真正能回答「窗口到底出现没有」的观察量是 IsWindowVisible(#6505)。

按下面四步分诊,就能确定你卡在哪一层:

  1. 看窗口是否被创建。触发后枚举 Shell.Application.Windows(),比对 HWND 差值。有新增 CabinetWClass 窗口说明「创建成功、只是不可见」,落在缺陷一。
  2. 看窗口的可见性,而不是数量。对新增窗口读 IsWindowVisible / WS_VISIBLE。上报实测:{ windowsHide: true } → 1 个窗口、可见 0 个;{ windowsHide: false } → 1 个窗口、可见 1 个(#6505)。
  3. 看落到了哪个目录。用 Document.Folder.Self.Path 读窗口实际打开的位置,而不是看标题栏。回退到桌面 / 「此电脑」= 缺陷二(路径形态问题);Windows 上回退桌面、WSL 上回退「此电脑」,是同一个根因在不同宿主下的两种表现(#7033)。
  4. 看它在不在前台。窗口可见但只多了个任务栏按钮、不弹到最前,落在缺陷三(前台锁)。

一个关键的干扰项必须先排除:SetForegroundWindow 返回 False 是症状,不是原因。隐藏的窗口本来就无法成为前台;而一个可见的窗口同样可能拒绝成为前台。所以「抢不到前台」这件事,不能反过来证明「窗口是隐藏的」——它们是两条独立的证据链(#6505)。

缺陷一:windowsHide 把 Explorer 自己的窗口也隐藏了

根因:revealNativePath 经由共享 runner 启动 explorer.exe,而该 runner 为了保证控制台工具不闪黑框,硬编码了 windowsHide: true。当「直接子进程」本身就是需要显示窗口的 GUI 启动器时,这个选项反过来把窗口本身隐藏了。

共享 runner 的形态(packages/util/native-command/src/runner.ts):

ts
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
  new Promise((resolve, reject) => {
    execFile(command, [...args], { encoding: 'utf8', signal, windowsHide: true }, (error, stdout, stderr) => { /* … */ })
  })

而调用点确实是用同一个 runner 直接拉起 Explorer 的(packages/util/native-command/src/path-opener.ts):

ts
// path-opener.ts —— explorer 分支
await run('explorer.exe', ['/select,', target], signal)

机制在 libuv 里:STARTF_USESHOWWINDOW 是被无条件设置的,windowsHide 只把 wShowWindow 的值从 SW_SHOWDEFAULT 换成 SW_HIDE。对控制台工具来说这正是要的效果;对 Explorer 来说,SW_HIDE 会沿着「Explorer 把调用方显示状态带给它创建或激活的窗口」这条链路传播下去,于是新窗口被隐藏(#6505)。

被隐藏的窗口并不会销毁,它会一直留在那里、从任务栏和 Alt+Tab 都够不到,每点一次就多堆一个——上报环境里累计到 7–8 个。要清理它们不必重启 Explorer:

powershell
$shell = New-Object -ComObject Shell.Application
foreach ($w in @($shell.Windows())) {
  if (-not ([WinApi]::IsWindowVisible([IntPtr][int64]$w.HWND))) { $w.Quit() }
}

修复:给 runner 增加一个可选的「可见」启动状态,只让 GUI 启动器使用它,控制台工具(PowerShell 图标提取、注册表探测、wslpath)继续用隐藏的 runner。

ts
export const runNativeCommandVisible: NativeCommandRunner = (command, args, signal) =>
  new Promise((resolve, reject) => {
    execFile(command, [...args], { encoding: 'utf8', signal, windowsHide: false }, (error, stdout, stderr) => { /* … */ })
  })
ts
// path-opener.ts —— 只有 explorer 分支换成可见 runner
await (internals.run ?? runNativeCommandVisible)('explorer.exe', ['/select,', target], signal)

保留 internals.run ?? 这层间接,现有测试注入的 runner 依旧优先,适配器测试无需改动。后来在 0.1.7-rc.2 的路线里,这个设计被正式化为每次调用可传参的形态,默认值保持 true,既有调用方行为不变:

ts
export interface NativeCommandOptions {
  /** 默认 true;子进程要创建用户窗口的调用方传 false。 */
  windowsHide?: boolean
}
export type NativeCommandRunner = (
  command: string,
  args: readonly string[],
  signal: AbortSignal,
  options?: NativeCommandOptions,
) => Promise<{ stdout: string; stderr: string }>

判断该不该隐藏,其实只有两个问题:这个子进程本身会不会弹窗?(Explorer、Electron 应用——绝不要隐藏。)它只是个 CLI 管道,或者它自己的子进程才会弹窗?(隐藏是对的。)仓库里 open-in-app/src/catalog.ts 已经写下了同一条规则:windowsHide 只保留给「由子进程自己打开可见 GUI」的 CLI 适配器(#6505)。

缺陷二:非 ASCII 路径被转成百分号编码的 file:// URL

根因:路径先被 pathToFileURL(windowsPath, { windows: true }).href 转成 file:// URI(中文被 UTF-8 百分号编码),再交给 explorer.exe /select,;而 /select, 的参数是字面文件路径、不做 URL 解码,于是定位失败并静默回退。

原始代码与注释:

ts
// Explorer parses commas itself; a file URI preserves commas and whitespace in the path.
const target = pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C')
try {
  await run('explorer.exe', ['/select,', target], signal)
} catch (error) {
  signal.throwIfAborted()
  // Explorer can exit 1 after delegating to the existing desktop process.
  if (!(error instanceof Error) || !('code' in error) || error.code !== 1) throw error
}

先做一次不用 DSH 的最小复现(PowerShell),你会立刻看到差别:

powershell
$f   = 'I:\工作文档\测试\report.docx'
$url = ([uri]$f).AbsoluteUri          # file:///I:/%E5%B7%A5%E4%BD%9C%E6%96%87%E6%A1%A3/...

Start-Process explorer.exe -ArgumentList '/select,', $url   # ✗ 没有选中,窗口停在桌面
Start-Process explorer.exe -ArgumentList '/select,', $f     # ✓ 打开目录并选中文件

上报给出的实测矩阵(选中与否用 Shell.Application.Windows().Document.FocusedItem.Path 判定,不看退出码):

/select, 之后的参数是否选中
I:\工作文档\…\完形高频固定搭配.docx(字面路径)✅
file:///I:/工作文档/…/完形高频固定搭配.docx(原始中文 URI)✅
file:///I:/%E5%B7%A5%E4%BD%9C…/%E9%85%8D.docx(百分号编码,现行代码)❌ 回退到桌面
file:///C:/Windows/win.ini(纯 ASCII URI)✅

看最后一行,就知道为什么这个问题长期没被发现:纯 ASCII 路径没有任何需要转义的东西,URI 形态恰好命中。而换成系统 ANSI 代码页(GBK/936)编码的中文同样失败,所以这不是 UTF-8 与 ACP 的解码差异——Explorer 只在 URI 携带的转义全是 ASCII 时才解析得了(#6259)。

修复方向有两个,第二个更好:

  1. 按「是否含逗号」而不是「是否 ASCII」分流。这里有一段重要的自我更正:最初的补丁用 /^[\x20-\x7E]*$/(是否纯 ASCII)来判断,但对照实测后确认——真正的判别条件是「路径里有没有逗号」,两者互相独立。ASCII 判别会让「非 ASCII 且含逗号」的路径仍然落进错分支。

    ts
    const target = /^[\x20-\x7E]*$/.test(windowsPath)
      ? pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C')
      : windowsPath
    

    而即便如此,含逗号 + 非 ASCII 的组合仍然无解:%2C 能解决逗号,%E5%B7%A5… 解决不了非 ASCII;四条组合里这一格是「两边都失败」。

  2. 交给 PowerShell,按字面路径转交(推荐)。这是 openWindowsPath() 在 Windows 上已经在用的启动器,不引入新机制:

    ts
    const reveal = `Start-Process explorer.exe -ArgumentList ('/select,"' + ${powershellLiteral(windowsPath)} + '"')`
    await run('powershell.exe', ['-NoProfile', '-Command', reveal], signal)
    

    实测覆盖了所有组合:

    路径结果
    C:\...\my files\报告,#%.txt(非 ASCII + 逗号 + # + % + 空格)✅
    C:\...\my files\a,b.txt(含逗号)✅
    I:\工作文档\...\完形高频固定搭配.docx(非 ASCII)✅
    C:\...\my files\plain name.txt(含空格)✅

    顺带还解决了一件事:以 PowerShell 作启动器时,退出码是可信的——不存在「Explorer 交接给桌面进程后退出 1」这种情况,于是那段容忍退出码 1 的逻辑可以删掉,真实失败终于能传到调用方,而不是渲染成 presented.revealed(已请求)。代价是 PowerShell 这一跳约 320 ms(冷)/ 174 ms(热)(#6259)。

一个会挡住修复的陷阱必须知道:测试 tests/path-opener.spec.ts:408-415 把「非 ASCII + 逗号」这条路径的完全百分号编码 URI 钉成了期望 argv。也就是说,无论用上面哪种修法,%E6%8A%A5… 这条断言都会变红。上报者对这条向量本身做了 Windows 实测:字面路径 ❌、被钉住的编码 URI ❌、只有 powershell.exe 的 verbatim 命令 '/select,"<字面路径>"' ✅。测试钉住的形态本身是不可解析的——不要因为测试变红就以为修复错了(#6259)。

缺陷三:窗口只在任务栏出现,不弹到前台

根因:Windows 前台锁拒绝后台进程抢前台;而即使绕开它,执行激活的进程一退出,焦点立刻被交还。所以抬升动作必须由一个生命周期长于这次点击的进程执行。

修好可见性之后,窗口会正常出现(IsWindowVisible = true、IsIconic = false),但不进入前台——只多出一个任务栏按钮。启动链是:

浏览器(前台) → HTTP → DSH 宿主(后台) → 子进程

上报把各种抬升手法都实测了一遍(浏览器保持前台):

抬升手法调用返回+1s / +4s 实测
裸 SetForegroundWindowFalse❌(副作用:把窗口弄回隐藏)
AttachThreadInput + SetForegroundWindowFalse❌
SetWindowPos(HWND_TOP)(只调 z 序)True❌ 观感无变化
HWND_TOPMOST → HWND_NOTOPMOSTTrue❌ 反而留下一个最小化窗口
最小化 → 还原 + SetForegroundWindow,进程随即退出True❌ 1 秒内焦点被交还
最小化 → 还原 + SetForegroundWindow,由常驻进程执行True✅ 前台稳定停在目标窗口

结论就一行:抬升必须由常驻进程执行。 而这正好是把它放进 DSH 宿主进程的理由——宿主常驻,所以「不随调用退出」,也不需要任何「保持激活」的花招。实现上用 DSH 已经随装的 koffi 直接绑定 user32,不起任何子进程(#8043):

ts
const koffi = (await import('koffi')).default
const lib = koffi.load('user32.dll')
// EnumWindows 找目标窗口:类名 CabinetWClass、标题前缀为 "<文件夹名> - "(前缀与系统语言无关)
lib.func('bool EnumWindows(void *lpEnumFunc, intptr_t lParam)')(callback, 0)
// 后台进程被前台锁拒绝,先最小化再还原——还原这一步才解锁激活
lib.func('bool ShowWindow(intptr_t h, int cmd)')(handle, 6)   // SW_MINIMIZE
await delay(140)
lib.func('bool ShowWindow(intptr_t h, int cmd)')(handle, 9)   // SW_RESTORE
await delay(90)
lib.func('bool SetForegroundWindow(intptr_t h)')(handle)

实测时序:窗口成为前台 555 ms、openNativePath 返回 662 ms(其中 EnumWindows 单次仅 2 ms、抬升动作约 230 ms),瓶颈回到 Explorer 自身创建窗口的耗时。这个抬升与「打开」并行发起、best-effort——抬升失败不影响已经成功的打开。

这里有一个非常难定位的实现陷阱:koffi 不允许重复定义同名类型。koffi.proto(...) 必须定义一次并缓存;如果在轮询循环里每次重新定义,第二次迭代就抛 Duplicate type name,而这个异常会被 best-effort 的 catch 吞掉,最终表现为「补丁明明生效了,窗口却完全没有被抬升」(#8043)。

边界说明:如果另一个程序正在主动争抢前台(上报中同机的一个交互式 TUI 控制台就会),任何启动器都压不住它。这属于系统级限制,不是这个缺陷的一部分。

修复、替代与排查注意事项

  • 桌面版不要照搬「改安装目录」的偏方。 桌面版把 dsh-native-command 打包进了 resources/app.asar(上报的 asar 条目 /dsh/node_modules/@deepseek-ai/dsh-native-command/lib/index.js,size 40419),而 exe 内嵌 INTEGRITY / ELECTRONASAR 资源。有报告在同版本上用等长原地改写(windowsHide: true → windowsHide: 0==1)后仍能正常启动,但这取决于 EnableEmbeddedAsarIntegrityValidation fuse 是否启用,别的分发渠道可能直接启动失败——只能当最后手段,且务必先备份 app.asar(#8043)。
  • npx 形态的补丁会在升级时静默失效。 补丁写在 %LOCALAPPDATA%\npm-cache\_npx\<hash>\node_modules\@deepseek-ai\dsh-native-command\lib\index.js,而 <hash> 是解析到的 dsh 版本的函数:从 0.1.5-rc.1 升到 0.1.5-rc.2 时出现了一个新目录,打过补丁的旧副本被留在旧的里面,缺陷无声复发(#7033)。
  • 两种宿主形态的旁证是对得上的。 同一台机器上,web profile(npm 全局 + 本地补丁)点「在文件资源管理器中显示」能正常弹窗并选中目标(路径含中文 dsh工作区 也正确),而 desktop profile(asar 内未打补丁)静默无反应——区分变量就是 windowsHide 与路径形态,不是退出码(#8043)。
  • 别再用「数窗口」判断修没修好。 隐藏与可见两种情况都恰好产生 1 个新窗口,数量完全一样。要断言,就读 IsWindowVisible;要断言定位对不对,就读 Document.Folder.Self.Path 与 SelectedItems(),而不是看窗口标题(回退也会产生一个标题看起来正常的窗口)。
  • 现有测试为什么抓不到它。 相关用例通过 PathOpenerInternals.run 注入假 runner,只断言 argv,从不观察窗口是否真的定位到了文件——注入点在「唯一有意义的可观察量」的上游。一个在 Windows 上断言 Shell.Application.Windows().Document.FocusedItem.Path 的用例,本可以捕获这个问题(#6259)。
  • 在修好之前,用走 PowerShell 的替代入口。 openWindowsPath() 用的是 Invoke-Item -LiteralPath,按字面路径传参,对中文目录是正确的——这也是上报里「用默认程序打开文件」能正常工作、而「在资源管理器中显示」不工作的原因。

来源:


排查这类「点了没反应」的问题,最费时间的往往不是修代码,而是确认「请求到底走到哪一步、有没有真的生效」。如果你想让这类宿主侧动作有可追溯的痕迹,可以装一个 DSH Plugin Hub——DeepSeek Harness 桌面端内置的官方插件市场,用于浏览、安装、卸载与更新插件;它的「已安装插件」列表里每行都带「在 Finder / 文件管理器中显示」的操作,方便直接定位插件目录,设置页还提供系统诊断与系统日志:

DSH Plugin Hub 已安装插件列表:每行展示版本与更新时间,并提供可更新、卸载与在文件管理器中显示

把插件的安装位置与宿主日志放在一起看,定位「是插件改了环境、还是宿主自己的问题」会快很多。

常见问题

在 Windows 上点「在文件资源管理器中显示」完全没反应,是我的电脑坏了吗?

不是。这是 @deepseek-ai/dsh-native-command 里 revealNativePath() 在 Windows 分支上的缺陷,而且不止一个:窗口其实被创建了,只是被 windowsHide: true 隐藏(IsWindowVisible = false),所以你看不到;如果路径含中文,还会因为被转成百分号编码的 file:// URL 而定位失败、静默回退到桌面或「此电脑」。请求本身会返回成功——Explorer 无论成功失败都退出码 1,而现有代码容忍这个退出码,所以 UI 上永远显示「已请求」。

为什么纯英文路径没问题,一换成中文目录就不行了?

因为缺陷只在「路径里有需要转义的字符」时暴露。当前代码用 pathToFileURL(windowsPath, { windows: true }).href 把路径转成 file:///D:/%E5%B7%A5...,而 explorer.exe /select, 只认字面文件路径、不做 URL 解码。ASCII 路径没有可转义字符,URI 形态恰好也能命中,所以现有单元测试覆盖不到——测试注入的是 run 接缝、只断言 argv,从不检查窗口是否真的定位到了文件。

窗口变成可见之后,为什么它只在任务栏出现、不会弹到最前面?

这是第二个独立问题——Windows 前台锁。启动链是「浏览器(前台)→ HTTP → DSH 宿主(后台)→ 子进程」,后台进程调用 SetForegroundWindow 会被系统拒绝(返回 False,任务栏只闪一下)。即使用「最小化 → 还原」绕开前台锁,只要执行激活的进程随即退出,焦点 1 秒内就被交还给上一个活动窗口。解决办法是让抬升动作由**生命周期长于这次点击的进程**执行,也就是放进常驻的 DSH 宿主进程里。

我能不能自己改安装目录里的文件把这个问题修掉?

npm / web 形态可以,桌面版要非常谨慎。web 侧改 @deepseek-ai/dsh-native-command/lib/index.js 里的 windowsHide: true 可行,但要注意两件事:① 如果你是 npx 启动的,补丁写在 _npx/<hash> 目录里,升级版本会出现新的 hash 目录、补丁被静默丢弃,缺陷自动复发;② 桌面版把 dsh-native-command 打包进了 resources/app.asar,其 exe 内嵌 INTEGRITY / ELECTRONASAR 资源。有报告在同版本上做等长原地改写后仍能正常启动,但这依赖 EnableEmbeddedAsarIntegrityValidation 这个 fuse 是否被启用,不同分发渠道可能直接启动失败——所以改 asar 只能算最后手段,且必须先备份。

官方修了吗?我现在能做什么?

截至本文所引报告,master 与 0.1.7-rc.2 上两处都还在(runner.ts 仍硬编码 windowsHide: true,path-opener.ts 仍把路径转成编码 URI),所以没有可以直接升级拿到的修复。当下可做的:① 用「打开所在位置」类替代入口(部分界面走 PowerShell 的 Invoke-Item -LiteralPath,它按字面路径传参,对中文目录是正确的);② 升级 DSH 时不要指望它被修好;③ 若你能改安装树,按本文的补丁自行改,并记住升级会覆盖。

相关术语

revealNativePath
「在文件资源管理器中显示 / Show in File Explorer」背后的实现,位于 `packages/util/native-command/src/path-opener.ts`。它按平台分派:Windows 走 `explorer.exe /select,`,macOS 走 `open -R`,其他走目录。Windows 分支同时踩了两个坑:把路径转成百分号编码的 `file://` URL,以及经由硬编码 `windowsHide: true` 的共享 runner 启动 Explorer。— https://github.com/deepseek-ai/deepseek-harness/discussions/7033
windowsHide 与 SW_HIDE
Node 的 `windowsHide: true` 映射到 libuv 的 `UV_PROCESS_WINDOWS_HIDE`。libuv 会在 `STARTUPINFO` 上**无条件**设置 `STARTF_USESHOWWINDOW`,该选项只把 `wShowWindow` 从 `SW_SHOWDEFAULT` 翻成 `SW_HIDE`。对一个「唯一目的就是显示窗口」的 GUI 启动器(Explorer)来说这是致命的:Explorer 会把调用方的显示状态传播给它创建或激活的窗口。— https://github.com/deepseek-ai/deepseek-harness/discussions/6505
/select, 与百分号编码
`explorer.exe /select,<路径>` 的定位参数是**字面文件路径**,不做 URL 解码。`pathToFileURL()` 会把非 ASCII 段按 UTF-8 编码成 `%E5%B7%A5...`,Explorer 解析不了,于是静默回退到默认文件夹(Windows 上是桌面,WSL 上是「此电脑」)。实测:字面路径 ✅、原始中文 URI ✅、百分号编码 URI ❌——只有「URI 里没有非 ASCII 转义」时才碰巧能用。— https://github.com/deepseek-ai/deepseek-harness/discussions/6259
Windows 前台锁(Foreground Lock)
Windows 只允许「当前前台进程」把窗口设为前台,后台进程调用 `SetForegroundWindow` 会被拒绝并返回 `False`,系统仅在任务栏闪一下。常见的绕法是先 `ShowWindow(SW_MINIMIZE)` 再 `ShowWindow(SW_RESTORE)` 再接 `SetForegroundWindow`;但绕开之后还有个陷阱——执行激活的进程一退出,焦点立刻交还给上一个活动窗口,所以抬升必须由一个常驻进程执行。— https://github.com/deepseek-ai/deepseek-harness/discussions/8043

来源