diff --git a/packages/util/native-command/README.i18n.yaml b/packages/util/native-command/README.i18n.yaml index 0313a056c5..0f50bd4a58 100644 --- a/packages/util/native-command/README.i18n.yaml +++ b/packages/util/native-command/README.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 packages/util/native-command/README.md -README.md: 5009792353f913df57fb90b352d52e7f7715a0d6 -README.zh.md: b214f5996d4f5a4b79cd9a698d09d7737815fd42 +README.md: 7d3aa8b8f35d483f9936440cb4cd35bf1e785f1a +README.zh.md: 672bbd6eba39ab88f1822562e7fc6a780d3c276e diff --git a/packages/util/native-command/README.md b/packages/util/native-command/README.md index 5009792353..7d3aa8b8f3 100644 --- a/packages/util/native-command/README.md +++ b/packages/util/native-command/README.md @@ -109,6 +109,7 @@ Linux association discovery and explicit launching require GIO. Missing native c These limits define when this runner is not the right tool. They are current package constraints, not a task backlog. - Command output uses Node’s `execFile` buffer limit; oversized replies reject. Use the subprocess capability for streaming output. +- A Windows handler icon appears only when the Shell resolves its indirect reference; an unresolved reference falls back to the handler executable, and a handler that names none — a packaged handler may report an AUMID or family name — yields no icon. - A Windows host needs an interactive desktop session. In a non-interactive session — a service, or a scheduled task without an interactive logon — `explorer.exe` invokes no file association and still exits 1 after about a second (an SSH session 0 on Windows 11 ARM64 build 26200), so an open reports success having opened nothing; a caller's abort signal turns that wait into a timeout rather than a hang. An `Invoke-Item` command resolves the association in-process there, and its directory failure arrives as a non-zero exit code. No fallback for that session is implemented. diff --git a/packages/util/native-command/README.zh.md b/packages/util/native-command/README.zh.md index b214f5996d..672bbd6eba 100644 --- a/packages/util/native-command/README.zh.md +++ b/packages/util/native-command/README.zh.md @@ -109,6 +109,7 @@ Linux 的关联查询和指定应用启动需要 GIO。缺少原生命令时查 这些限制说明本运行器何时不是合适的工具。它们是当前包约束,不是任务积压。 - 命令输出受 Node 的 `execFile` 缓冲上限约束,超过上限时拒绝结果。流式输出由 subprocess 能力处理。 +- Windows 关联处理器的图标只在 Shell 能解析其间接引用时显示;解析失败时回退到处理器的可执行文件,处理器没有给出可执行文件(打包处理器可能只报 AUMID 或家族名)时则没有图标。 - Windows 主机需要交互桌面会话。在非交互会话(服务,或没有交互登录的计划任务)中,`explorer.exe` 不调用任何文件关联,且仍在约 1 秒后以退出码 1 返回(Windows 11 ARM64 build 26200 的 SSH session 0 实测),于是「打开」会报告成功却什么都没打开;调用方的 abort 信号把这段等待变成超时而不是挂死。同一目标改用 `Invoke-Item` 命令时可在该会话内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。 diff --git a/packages/util/native-command/src/file-applications-windows.ts b/packages/util/native-command/src/file-applications-windows.ts index c9950875cd..6995ec0b8d 100644 --- a/packages/util/native-command/src/file-applications-windows.ts +++ b/packages/util/native-command/src/file-applications-windows.ts @@ -1,4 +1,7 @@ /** Windows Shell association queries and invocation; paths are encoded data, never PowerShell expressions. */ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import type { NativeCommandRunner } from './runner.ts' /** Shell interfaces are declared in their native vtable order; Invoke preserves packaged-app and DDE handling. */ @@ -155,10 +158,12 @@ public static class DshFileAssociations { if (small != IntPtr.Zero) DestroyIcon(small); } } - static string IconData(IHandler handler) { + // Best-effort fallback: some handlers return their executable path from GetName, but a + // packaged handler's name may be an AUMID or family name, which extracts no icon. + static string IconData(IHandler handler, string id) { string source = null; int index = 0; try { handler.GetIconLocation(out source, out index); } catch (Exception) { - // A handler without a drawable icon location falls back to its executable. + // A handler without a drawable icon location falls back to its name. } if (!String.IsNullOrEmpty(source)) { source = Environment.ExpandEnvironmentVariables(source); @@ -169,14 +174,9 @@ public static class DshFileAssociations { var icon = PngFromIconSource(source, index); if (icon != null) return icon; } - // Packaged handlers name the executable only in GetName; extract there too. - string executable = null; - try { handler.GetName(out executable); } catch (Exception) { - // A handler that names no executable has no icon to fall back to. - } - return String.IsNullOrEmpty(executable) || String.Equals(executable, source, StringComparison.OrdinalIgnoreCase) + return String.IsNullOrEmpty(id) || String.Equals(id, source, StringComparison.OrdinalIgnoreCase) ? null - : PngFromIconSource(executable, 0); + : PngFromIconSource(id, 0); } static void Visit(string path, Action visit) { var extension = Path.GetExtension(path); @@ -203,7 +203,7 @@ public static class DshFileAssociations { string id, name; handler.GetName(out id); handler.GetUIName(out name); if (!seen.Add(id)) return; - apps.Add(new Application { id = id, name = name, icon = IconData(handler), + apps.Add(new Application { id = id, name = name, icon = IconData(handler, id), @default = String.Equals(id, executable, StringComparison.OrdinalIgnoreCase) || String.Equals(id, appId, StringComparison.OrdinalIgnoreCase) }); }); return apps.ToArray(); @@ -236,7 +236,8 @@ public static class DshFileAssociations { }` /** - * Execute the Windows Shell adapter in a Unicode STA PowerShell process. + * Execute the Windows Shell adapter in a Unicode STA PowerShell process; the adapter + * source is a private temporary script file, removed once the call settles. * @param path - Windows file path, translated by the caller for WSL. * @param application - registered handler to invoke; null requests the application list. * @param signal - caller cancellation. @@ -257,8 +258,19 @@ $path = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedPa $application = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encodedApplication}')) ${application === null ? 'ConvertTo-Json -InputObject @([DshFileAssociations]::List($path)) -Depth 4 -Compress' : '[DshFileAssociations]::Open($path, $application)'} ` - const result = await run('powershell.exe', [ - '-NoProfile', '-NonInteractive', '-STA', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64'), - ], signal) - return result.stdout + // The script travels as a file, not as command-line data: -EncodedCommand grew with the + // embedded C# and overflowed the 32767-character CreateProcess limit for long paths. + // PowerShell 5.1 decodes a -File script as ANSI unless it carries a BOM; the BOM keeps + // the embedded UTF-8 source intact. + const directory = await mkdtemp(join(tmpdir(), 'dsh-native-command-')) + const scriptPath = join(directory, 'associations.ps1') + try { + await writeFile(scriptPath, `\uFEFF${script}`, 'utf8') + const result = await run('powershell.exe', [ + '-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, + ], signal) + return result.stdout + } finally { + await rm(directory, { recursive: true, force: true }) + } } diff --git a/packages/util/native-command/tests/file-applications.spec.ts b/packages/util/native-command/tests/file-applications.spec.ts index 37538b239c..658a44f32c 100644 --- a/packages/util/native-command/tests/file-applications.spec.ts +++ b/packages/util/native-command/tests/file-applications.spec.ts @@ -1,4 +1,5 @@ /** File association results and explicit handler authorization at the native command adapter. */ +import { readFile } from 'node:fs/promises' import { describe, expect, it, vi, onTestFinished } from 'vitest' import * as runner from '../src/runner.ts' import { nativeFileApplications, openNativeFileApplication } from '../src/file-applications.ts' @@ -118,16 +119,45 @@ it('uses the production command adapter and current platform when no override is it('encodes Windows query and invocation data separately from native adapter source', async () => { - const run = vi.fn(async () => ({ stdout: JSON.stringify([application]), stderr: '' })) + const scripts: string[] = [] + const run = vi.fn(async (_command, args) => { + scripts.push(await readFile(args.at(-1)!, 'utf8')) + return { stdout: JSON.stringify([application]), stderr: '' } + }) const path = "C:\\测试\\a'; write-host nope.mp3" expect(await nativeFileApplications(path, signal, { platform: 'win32', run })).toEqual([application]) - const query = Buffer.from(run.mock.calls[0]![1].at(-1)!, 'base64').toString('utf16le') - expect(query).toContain(Buffer.from(path).toString('base64')) - expect(query).not.toContain(path) - expect(query).toContain('::List($path)') + expect(scripts[0]!).toContain(Buffer.from(path).toString('base64')) + expect(scripts[0]!).not.toContain(path) + expect(scripts[0]!).toContain('::List($path)') await openNativeFileApplication(path, application.id, signal, { platform: 'win32', run }) - expect(Buffer.from(run.mock.calls[1]![1].at(-1)!, 'base64').toString('utf16le')).toContain('::Open($path, $application)') + expect(scripts[1]!).toContain('::Open($path, $application)') expect(run.mock.calls[1]![0]).toBe('powershell.exe') + expect(run.mock.calls[1]![1]).toEqual(['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-File', expect.any(String)]) + // PowerShell 5.1 reads a -File script as ANSI without the BOM. + expect(scripts[1]!).toMatch(/^\uFEFF/) +}) + +it('keeps the complete Windows command line below the CreateProcess limit for long multibyte paths', async () => { + const run = vi.fn(async () => ({ stdout: '[]', stderr: '' })) + const paths = [ + // 222 characters; this path measured 32883 characters of command line while the script travelled there. + `C:\\work\\${'测'.repeat(210)}.txt`, + `C:\\work\\${'测'.repeat(3000)}.txt`, + ] + for (const path of paths) await nativeFileApplications(path, signal, { platform: 'win32', run }) + const commandLines = run.mock.calls.map(call => [call[0], ...call[1]].join(' ')) + expect(commandLines).toHaveLength(paths.length) + commandLines.forEach((line, index) => { + const path = paths[index]! + // Windows CreateProcess rejects a command line over 32767 characters; the script and + // the encoded path are no longer part of the line, so it stays near 100 characters. + expect(line.length).toBeLessThan(1_000) + expect(line).toContain('powershell.exe') + expect(line).toContain('-File') + // The path travels as encoded data inside the script file, never on the command line. + expect(line).not.toContain(path) + expect(line).not.toContain(Buffer.from(path).toString('base64')) + }) }) it('uses the Windows desktop for WSL paths and rejects an empty translation', async () => {