From 2b9923fedea05efea0a26bab3ba6ded4e943d6ac Mon Sep 17 00:00:00 2001 From: mektpoy Date: Wed, 23 Sep 2026 12:33:31 +0800 Subject: [PATCH] fix(native-command): escape both Explorer field separators and keep non-ASCII literal On Windows 11 ARM64 build 26200 Explorer splits its own command line at commas and equals signs and rejects percent-encoded non-ASCII in a file URI, falling back to the Documents folder in both cases without reporting it. `explorerTarget()` now escapes `=` next to `,` and decodes Node's non-ASCII escapes back to the literal characters the shell resolves, so the two rules stay in one place. The Agent Note and the target-encoding table record the measured behavior. --- ...ws-open-through-shell-resolution.i18n.yaml | 4 +-- ...2-windows-open-through-shell-resolution.md | 4 +-- ...indows-open-through-shell-resolution.zh.md | 4 +-- .../util/native-command/src/path-opener.ts | 25 ++++++++++++------- .../native-command/tests/path-opener.spec.ts | 21 ++++++++++------ 5 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml index 491f5edba4..420cb47036 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md -2026-09-22-windows-open-through-shell-resolution.md: 50caa066613ec372cbaef720e0bae575f800a162 -2026-09-22-windows-open-through-shell-resolution.zh.md: a7c7e4eeeb08e2c6e74fb0e24268ca4ea650d335 +2026-09-22-windows-open-through-shell-resolution.md: 7bfe559e7f26b96fa37d3bbed44f0403d4e51b59 +2026-09-22-windows-open-through-shell-resolution.zh.md: ff33455da1653f9d24f9ddcc21c1594e8875bb5e diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md index 50caa06661..7bfe559e7f 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.md @@ -12,7 +12,7 @@ That machine records the `.yml` default only in `HKCU\Software\Microsoft\Windows ## Decision -`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas percent-encoded, because Explorer parses its own command line and splits it at commas — a raw path containing one opens a different target without reporting it. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. +`openWindowsPath` hands the path to `explorer.exe` as one argv element, and `revealNativePath` reaches the same shell through the shared `runExplorer` helper. Both encode the target as a file URI with its commas and equals signs percent-encoded, because Explorer parses its own command line and splits fields at both — a raw path containing either opens a different target without reporting it. Nothing else is escaped: Explorer rejects percent-encoded non-ASCII in a file URI and falls back to Documents, so those characters reach it literally. Explorer performs the default-application resolution a double-click performs, so the shell's answer — including a default recorded only in the newer per-user record — selects the application, and a machine with no handler at all gets Explorer's own "How do you want to open this file?" chooser instead of silence. `runExplorer` holds the one Explorer invocation rule for both operations: exit code 1 is the handoff Explorer returns after passing the request to the desktop process already running, and it resolves the caller's promise; every other failure and every cancellation still rejects. The helper replaced the PowerShell command string, and with it `powershellLiteral` and its single-quote doubling: a path crosses the process boundary as an argv element, which removes the shell-layer escaping rather than the encoding Explorer's own parser requires. `explorerTarget` owns that one encoding step for both intents. @@ -40,4 +40,4 @@ Directories take the same path. The rejected alternative recorded in [the open-i ## Verification -`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window, and reveal's `/select,` highlight stays unverified. +`path-opener.spec.ts` pins the encoded file URI Explorer receives for both intents, the accepted delegate exit code 1 for an open and a reveal, and the preserved failure and cancellation. `resolver.spec.ts` pins the open-in-app route's Windows command. Native desktop verification stays on Windows, per the package's test policy. On Windows 11 ARM64 build 26200 an independent pass drove the module's own `openNativeAssociatedPath`, `openNativePath`, and `openNativeTextFile` from a sha256-matched copy beside an association probe that recorded every argument the shell passed: each comma-bearing target arrived complete as `%2C`, a plain file opened, and a directory named for a comma opened as a window rooted there. The negative control — the same file as a file URI whose comma is left raw — invoked no association and opened the user's Documents folder, and still exited 1. Exit 1 therefore records the delegation rather than the outcome, so accepting it cannot turn a wrong target into a success; the encoding is what keeps the path whole. Nothing here observes a window. A later pass on the same build read the shell's own window and selection state instead of pixels: the encoded `/select,` target selected the named file; an unencoded `=` opened Documents instead of the file; and a target whose non-ASCII characters stay percent-encoded opened Documents where the literal characters opened the file. diff --git a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md index a7c7e4eeeb..ff33455da1 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-22-windows-open-through-shell-resolution.zh.md @@ -12,7 +12,7 @@ Windows 桌面客户端设置页的「打开配置文件」会准备当前 profi ## 决策 -`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对逗号做百分号编码,因为资源管理器自己解析命令行并以逗号分段——含逗号的原始路径会打开另一个目标且不作任何提示。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 +`openWindowsPath` 将路径作为**一个** argv 元素交给 `explorer.exe`,`revealNativePath` 经共享的 `runExplorer` 帮助函数走到同一个 shell。两者都把目标编码成文件 URI 并对**逗号与等号**做百分号编码,因为资源管理器自己解析命令行并以这两个字符分段——含其中任一字符的原始路径都会打开另一个目标且不作任何提示。其余字符不做转义:资源管理器会拒绝 file URI 里百分号编码的非 ASCII 并回落到「文档」,因此这些字符以字面形式交给它。资源管理器执行的是双击所执行的默认应用解析,因此由 shell 的答案选择应用——包括只记录在较新的按用户记录里的默认值;而完全没有处理程序的机器会得到资源管理器自己的「你要如何打开这个文件?」选择框,而不是毫无反应。 `runExplorer` 为两种操作持有唯一一条 Explorer 调用规则:退出码 1 是 Explorer 把请求转交给已在运行的桌面进程后返回的交接码,它照常兑现调用方的 Promise;其余失败和取消仍然报错。该帮助函数取代了 PowerShell 命令字符串,同时删掉了 `powershellLiteral` 及其单引号翻倍逻辑:路径以 argv 元素跨进程边界,去掉的是 shell 层转义,而不是资源管理器自身解析所需的编码;该编码由 `explorerTarget` 为两种意图统一持有。 @@ -40,4 +40,4 @@ Windows 对 HTML 与 SVG 仍然无法命名浏览器,因此 `openInBrowser` ## 验证 -`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现,选中路径的 `/select,` 高亮仍未验证。 +`path-opener.spec.ts` 固定两种意图交给 Explorer 的编码文件 URI、打开与选中都可接受的交接退出码 1,以及仍然报错的普通失败与取消。`resolver.spec.ts` 固定 open-in-app 路由在 Windows 上的命令。按该包测试策略,原生桌面验证留在 Windows。在 Windows 11 ARM64 build 26200 上,一次独立验证用 sha256 与源码一致的副本直接驱动模块自身的 `openNativeAssociatedPath`、`openNativePath`、`openNativeTextFile`,旁边挂一个记录 shell 实收参数的探针关联:每个含逗号的目标都以 `%2C` 完整到达、普通文件正常打开、名为 `dir,comma` 的目录打开为根目录正确的窗口。反证——同一个文件以逗号未编码的 file URI 交给 `explorer.exe`——没有触发任何关联、打开了用户的「文档」,而且**同样返回退出码 1**。因此退出码 1 记录的是委派而非结果,接受它不会把错误目标变成成功;真正保住路径完整的是那段编码。此处不观测窗口是否出现。同一 build 的后续一轮改为直接读取 shell 自己的窗口与选中状态:编码后的 `/select,` 目标选中了目标文件;未编码的 `=` 会打开「文档」而不是文件;非 ASCII 字符保持百分号编码时同样打开「文档」,改为字面字符则正常打开。 diff --git a/packages/util/native-command/src/path-opener.ts b/packages/util/native-command/src/path-opener.ts index 303cfc41e2..67bbc35902 100644 --- a/packages/util/native-command/src/path-opener.ts +++ b/packages/util/native-command/src/path-opener.ts @@ -100,19 +100,26 @@ function isWsl(internals: PathOpenerInternals): boolean { /** * Encode one Windows path as the target Explorer can receive intact. * - * Explorer parses its own command line and treats commas as field separators, - * so a path handed over raw loses everything after its first comma and the - * shell opens a different target without reporting it. A file URI has `,` - * percent-encoded and keeps whitespace, and Explorer resolves it to the shell - * item the path names. Node resolves the path before encoding it, so a verbatim - * `\\?\` or `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC - * URI; a `\\.\` device path keeps that same UNC handling and names a device - * rather than a shell item, which this opener does not open. + * Explorer parses its own command line and splits fields at commas and equals + * signs, so a raw path loses everything after the first separator and the shell + * opens a different target without reporting it; both separators are escaped. + * Nothing else is: Explorer rejects percent-encoded non-ASCII in a file URI and + * opens the user's Documents folder instead, while it resolves the literal + * characters, so Node's non-ASCII escapes are decoded back and its ASCII escapes + * stand. Node resolves the path before encoding it, so a verbatim `\\?\` or + * `\\?\UNC\` prefix reaches Explorer as the ordinary drive or UNC URI; a `\\.\` + * device path keeps that same UNC handling and names a device rather than a + * shell item, which this opener does not open. * @param windowsPath - path already translated for the Windows desktop. * @returns the target for an open, or the object of a `/select,` reveal. */ function explorerTarget(windowsPath: string): string { - return pathToFileURL(windowsPath, { windows: true }).href.replaceAll(',', '%2C') + const href = pathToFileURL(windowsPath, { windows: true }).href + // A run of escapes whose every byte starts above ASCII is one non-ASCII character. + return href + .replace(/(?:%[89A-F][0-9A-F])+/gi, escaped => decodeURIComponent(escaped)) + .replaceAll(',', '%2C') + .replaceAll('=', '%3D') } /** diff --git a/packages/util/native-command/tests/path-opener.spec.ts b/packages/util/native-command/tests/path-opener.spec.ts index d4996539f2..2b5c32456b 100644 --- a/packages/util/native-command/tests/path-opener.spec.ts +++ b/packages/util/native-command/tests/path-opener.spec.ts @@ -81,15 +81,20 @@ describe('native path opener', () => { }) it.each([ - // Explorer parses its own command line and splits it at commas, and libuv - // quotes an argument only when it holds a space, so the encoded file URI is - // what keeps the whole path in one field. The URI encoding covers the comma, - // spaces, the percent sign, and non-ASCII; the apostrophe and ampersand stay - // literal because no shell interprets them. UNC takes the same encoding. + // Explorer parses its own command line and splits fields at commas and + // equals signs, and libuv quotes an argument only when it holds a space, so + // the encoded file URI is what keeps the whole path in one field. Only those + // two separators are escaped on top of the URI's own encoding: Explorer + // rejects percent-encoded non-ASCII and opens Documents instead, while it + // resolves the literal characters, so `报告` stays literal here. UNC takes + // the same encoding. ["C:\\work\\o'reilly, & 100%.txt", "file:///C:/work/o'reilly%2C%20&%20100%25.txt"], ['C:\\work\\plain.txt', 'file:///C:/work/plain.txt'], ['C:\\work\\my report.txt', 'file:///C:/work/my%20report.txt'], - ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['C:\\work\\key=value.txt', 'file:///C:/work/key%3Dvalue.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/报告%2C%23%25.txt'], + ['C:\\目录\\a,b=c.txt', 'file:///C:/目录/a%2Cb%3Dc.txt'], + ['C:\\工作\\报告 2026.txt', 'file:///C:/工作/报告%202026.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], // Node resolves the path before encoding it, so a verbatim prefix is not // part of the target: `\\?\` and `\\?\UNC\` arrive as the ordinary drive or @@ -372,7 +377,7 @@ describe('native file manager', () => { expect(nativeFileManager(internals)).toBe('explorer') await revealNativePath('/mnt/c/work/报告.txt', signal(), internals) expect(run.mock.calls.map(([cmd, args]) => [cmd, args])).toEqual([ - ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/%E6%8A%A5%E5%91%8A.txt']], + ['wslpath', ['-w', '/mnt/c/work/报告.txt']], ['explorer.exe', ['/select,', 'file:///C:/work/报告.txt']], ]) }) @@ -449,7 +454,7 @@ it('preserves a non-delegate Explorer failure when opening', async () => { }) it.each([ - ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/%E6%8A%A5%E5%91%8A%2C%23%25.txt'], + ['C:\\my files\\报告,#%.txt', 'file:///C:/my%20files/报告%2C%23%25.txt'], ['\\\\server\\share\\a,b.txt', 'file://server/share/a%2Cb.txt'], ])('preserves special characters in the Explorer target %s', async (path, target) => { const run = vi.fn().mockResolvedValue({ stdout: '', stderr: '' })