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 () => {