fix(native-command): transport the Windows adapter as a script file
Resolving packaged-app icons grew the embedded C# until the generated -EncodedCommand line reached 32,883 characters for a 222-character path, past the 32767-character CreateProcess limit, so association queries stopped starting for long multibyte paths. Write the adapter to a private temporary script and pass it with -File instead; the command line no longer grows with the path. The temporary directory is removed once the call settles. The icon fallback also reads the handler name the caller already fetched instead of calling GetName again, and the README records that icon visibility depends on resolving the Shell's indirect reference.
This commit is contained in:
1 parent
64795b4879
commit
f35db51bee
5 files changed
+67
-23
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -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` 命令时可在该会话内解析关联,目录失败也会以非零退出码报出。该会话下的兜底尚未实现。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -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<IHandler> 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 })
|
||||
}
|
||||
}
|
||||
@@ -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<runner.NativeCommandRunner>(async () => ({ stdout: JSON.stringify([application]), stderr: '' }))
|
||||
const scripts: string[] = []
|
||||
const run = vi.fn<runner.NativeCommandRunner>(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<runner.NativeCommandRunner>(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 () => {
|
||||
|
||||
Reference in new issue
Block a user