From a92ffff10a0f7757327595754c6e3a42655957d2 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 00:08:54 +0800 Subject: [PATCH 01/17] fix(host): prefer pwsh for the Windows directory picker and force DPI awareness The win32 branch now spawns pwsh.exe (PowerShell 7) first and falls back to powershell.exe (Windows PowerShell 5.1) only when pwsh is missing (ENOENT), mirroring the Zenity-KDialog fallback. PowerShell 7 renders the modern IFileDialog folder picker; the 5.1 fallback keeps the legacy tree functional. Both runtimes execute the identical script, which opts the process into system DPI awareness (SetProcessDPIAware) before any window exists, fixing the blurry bitmap-stretched dialog on scaled displays. --- .../src/native-picker.ts | 14 +++++++ .../tests/native-picker.spec.ts | 42 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 2c8e236acc..079211f5a3 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -64,8 +64,15 @@ export async function pickNativeDirectory( } if (platform === 'win32') { + // PowerShell 7 renders the modern IFileDialog folder picker, while Windows + // PowerShell 5.1's FolderBrowserDialog is hardwired to the legacy + // SHBrowseForFolder tree; prefer pwsh and fall back only when it is absent. + // Both hosts spawn DPI-unaware, so the script opts the process into system + // DPI awareness before any window is created. const script = [ "$ErrorActionPreference = 'Stop'", + "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'", + '[DpiAware]::SetProcessDPIAware() | Out-Null', 'Add-Type -AssemblyName System.Windows.Forms', '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', "$dialog.Description = 'Select Workspace Directory'", @@ -76,6 +83,13 @@ export async function pickNativeDirectory( ' [Console]::WriteLine($dialog.SelectedPath)', '}', ].join('; ') + try { + const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + if (!isMissingCommand(error)) throw error + } const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) return outputPath(result.stdout) } diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 87e25ff877..8d26c6ab36 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -46,28 +46,62 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => { + it('prefers pwsh for the Windows folder dialog and maps empty output to cancellation', async () => { const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') expect(run).toHaveBeenCalledWith( - 'powershell.exe', + 'pwsh.exe', expect.arrayContaining(['-NoProfile', '-STA', '-Command']), expect.any(AbortSignal), ) - expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'") + const script = run.mock.calls[0]?.[1].at(-1) + expect(script).toContain("$ErrorActionPreference = 'Stop'") + expect(script).toContain('SetProcessDPIAware') run.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') }) + it('falls back to Windows PowerShell 5.1 only when pwsh is missing', async () => { + const run = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\fallback') + expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) + // Both runtimes execute the identical script, so DPI awareness holds either way. + expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1)) + + const cancelled = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled })).resolves.toBeNull() + + const failed = vi.fn() + .mockRejectedValueOnce(failure('ENOENT')) + .mockRejectedValueOnce(failure(2)) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed })).rejects.toThrow('command failed') + + const brokenPwsh = vi.fn(async () => { throw failure(7) }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: brokenPwsh })).rejects.toThrow('command failed') + expect(brokenPwsh).toHaveBeenCalledOnce() + }) + + it('does not fall back when the caller aborted the pwsh spawn', async () => { + const abort = new AbortController() + abort.abort(new Error('closed')) + const run = vi.fn(async () => { throw failure('ENOENT') }) + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })).rejects.toThrow('command failed') + expect(run).toHaveBeenCalledOnce() + }) + it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, 'C:\\work\\default\r\n', '') }) await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') const [command, args, options] = execFileMock.mock.calls[0]! - expect(command).toBe('powershell.exe') + expect(command).toBe('pwsh.exe') expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) From 5c515896653b42cf61fa5c62d76c82d33d27a735 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 00:08:58 +0800 Subject: [PATCH 02/17] docs(picker): record the pwsh-first DPI-aware Windows picker fix README pairs document the pwsh-preferred adapter and the PowerShell 7 requirement for the modern dialog; the 2026-07-27 picker note's Windows adapter fact is updated in place, and a new bug-fix note records the defect, the fallback decision, and the DPI awareness rationale. --- ...26-08-01-windows-picker-pwsh-dpi.i18n.yaml | 6 +++++ .../2026-08-01-windows-picker-pwsh-dpi.md | 26 +++++++++++++++++++ .../2026-08-01-windows-picker-pwsh-dpi.zh.md | 26 +++++++++++++++++++ ...ative-workspace-directory-picker.i18n.yaml | 4 +-- ...07-27-native-workspace-directory-picker.md | 2 +- ...27-native-workspace-directory-picker.zh.md | 2 +- .../directory-picker-native/README.i18n.yaml | 4 +-- .../host/directory-picker-native/README.md | 3 ++- .../host/directory-picker-native/README.zh.md | 3 ++- 9 files changed, 68 insertions(+), 8 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml new file mode 100644 index 0000000000..f4619714d1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-08-01-windows-picker-pwsh-dpi.md +2026-08-01-windows-picker-pwsh-dpi.md: 2c90821be3e4800d624cb2f54dcd6661756784bc +2026-08-01-windows-picker-pwsh-dpi.zh.md: ff8a535af396ed5ede51cbd3c69c1944d28ce95e diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md new file mode 100644 index 0000000000..2c90821be3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md @@ -0,0 +1,26 @@ +# Agent Note: Windows directory picker prefers pwsh and forces DPI awareness + +Status: implemented + +English | [中文](2026-08-01-windows-picker-pwsh-dpi.zh.md) + +## Problem + +The Windows branch of the native directory picker spawned Windows PowerShell 5.1's `FolderBrowserDialog`, which .NET Framework hardwires to the legacy `SHBrowseForFolder` tree dialog: no address bar, search, or quick access. The same process is DPI-unaware (`powershell.exe` declares no DPI awareness), so on scaled displays Windows renders the dialog at 96 DPI and bitmap-stretches it — blurry text and soft edges. Both defects were visible at once on any display above 100 % scaling. + +## Decision + +The win32 branch in `packages/host/directory-picker-native` now spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) only when pwsh is missing (`ENOENT`), mirroring the Zenity→KDialog fallback. PowerShell 7's WinForms `FolderBrowserDialog` supports `AutoUpgradeEnabled` (added in .NET Core 3.0, absent from .NET Framework) and renders the modern Explorer-style folder picker. Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). + +## Alternatives considered + +- **Require PowerShell 7.** Rejected: pwsh is not a Windows built-in, so machines without it would lose the only workspace-creation route; the 5.1 fallback keeps the dialog functional, and DPI is corrected there too. +- **Import `resolvePwshPath` from `dsh-pwsh-local`.** Rejected for this change: a host GUI package importing from a bash-executor package is a cross-seam coupling, and PATH-based `execFile` resolution plus `ENOENT` fallback already covers the practical installs (Program Files, Store aliases); single-source resolution remains a follow-up if the two consumers drift. +- **Set DPI awareness in the harness process.** Rejected: DPI awareness is per-process, and the dialog lives in a spawned child that inherits nothing from the parent's absent declaration. +- **Per-monitor v2 (`SetProcessDpiAwarenessContext`).** Deferred: system-aware is the ceiling .NET Framework WinForms supports, the shell dialog handles per-monitor rendering itself on modern Windows, and one call keeps both runtimes on a single code path. + +## Consequences + +- Machines with PowerShell 7 get the modern folder picker; 5.1-only machines keep the legacy tree — now sharp — and the package README's Known Limitations documents the gap. +- No new packages or runtime dependencies; the fallback reuses the existing `ENOENT` classification and abort propagation. +- The command boundary (`DirectoryPickerRunner`) pins the spawn order and script content in unit tests; real dialog rendering remains a manual Windows check, as before. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md new file mode 100644 index 0000000000..ff8a535af3 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md @@ -0,0 +1,26 @@ +# Agent Note: Windows 目录选择器优先 pwsh 并强制 DPI awareness + +Status: implemented + +[English](2026-08-01-windows-picker-pwsh-dpi.md) | 中文 + +## 问题 + +原生目录选择器的 Windows 分支原先启动 Windows PowerShell 5.1 的 `FolderBrowserDialog`,而 .NET Framework 将其硬编码为旧版 `SHBrowseForFolder` 树形对话框:没有地址栏、搜索或快速访问。同一进程又是 DPI-unaware 的(`powershell.exe` 未声明任何 DPI awareness),因此在缩放显示器上,Windows 会以 96 DPI 渲染该对话框再位图拉伸——文字模糊、边缘发虚。任何超过 100% 缩放的显示器上,两个缺陷同时可见。 + +## 决策 + +`packages/host/directory-picker-native` 的 win32 分支现在先启动 `pwsh.exe`(PowerShell 7),仅当 pwsh 缺失(`ENOENT`)时才回退到 `powershell.exe`(Windows PowerShell 5.1),与 Zenity→KDialog 的回退方式一致。PowerShell 7 的 WinForms `FolderBrowserDialog` 支持 `AutoUpgradeEnabled`(.NET Core 3.0 加入;.NET Framework 没有),呈现现代资源管理器风格文件夹选择器。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 + +## 考虑过的替代方案 + +- **强制要求 PowerShell 7。** 否决:pwsh 并非 Windows 内置,没有它的机器将失去唯一的工作区创建路径;5.1 回退保持对话框可用,且 DPI 在那里同样被修正。 +- **从 `dsh-pwsh-local` 导入 `resolvePwshPath`。** 本变更否决:host GUI 包依赖 bash 执行器包是跨 seam 耦合;PATH 上的 `execFile` 解析加 `ENOENT` 回退已覆盖实际安装形态(Program Files、Store 别名);若两个消费者日后漂移,单一来源解析留作后续。 +- **在 harness 进程内设置 DPI awareness。** 否决:DPI awareness 是进程级的,而对话框位于派生的子进程中,不会继承父进程缺失的声明。 +- **Per-monitor v2(`SetProcessDpiAwarenessContext`)。** 暂缓:system-aware 是 .NET Framework WinForms 的上限,现代 Windows 中 shell 对话框自身处理 per-monitor 渲染,且一次调用让两个运行时共用一条代码路径。 + +## 后果 + +- 装有 PowerShell 7 的机器获得现代文件夹选择器;只有 5.1 的机器保留旧版树——但现在清晰了——包 README 的已知限制记录了该差距。 +- 无新增包或运行时依赖;回退复用既有的 `ENOENT` 分类与中止传播。 +- 命令边界(`DirectoryPickerRunner`)在单元测试中固定启动顺序与脚本内容;真实对话框渲染仍与以前一样属于手动 Windows 检查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 1faf10a4c8..12cb856fe2 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f -2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6 +2026-07-27-native-workspace-directory-picker.md: c18b4263d4e97290d69ac229e7423558bdb4c3b1 +2026-07-27-native-workspace-directory-picker.zh.md: 7267516d7eea4b3cfecc2ca8f18305896eaffede diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 98f9dc9bed..c18b4263d4 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -30,7 +30,7 @@ The native dialog RPC is accepted only from a loopback socket with same-origin b Platform adapters invoke native tools without a shell: - macOS: `osascript` and the system folder chooser. -- Windows: PowerShell in STA mode and `FolderBrowserDialog`. +- Windows: `pwsh` (PowerShell 7) in STA mode with a Windows PowerShell 5.1 fallback, always DPI-aware ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index ca765778fa..7267516d7e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -30,7 +30,7 @@ Status: implemented 平台适配器不经 shell,直接调用原生工具: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。 +- Windows:采用 STA 模式的 `pwsh`(PowerShell 7),并以 Windows PowerShell 5.1 回退,且始终 DPI aware(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index e798bd6471..9abdfb129a 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8 -README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f +README.md: ab4326fed886e9bb2fa550ae9865550eed7c2583 +README.zh.md: cb2e067d340df1696e1b1195ec99f6d63509cc20 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0b54c651d4..ab4326fed8 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, `pwsh` (PowerShell 7) with a Windows PowerShell 5.1 fallback on Windows — the dialog script opts the process into system DPI awareness — and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). @@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). +- **Windows needs PowerShell 7 for the modern picker** — `pwsh` renders the Explorer-style folder dialog; a machine with only Windows PowerShell 5.1 falls back to the legacy folder tree, DPI-corrected but not the modern UI. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index e5ac2762a6..cb2e067d34 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用 `pwsh`(PowerShell 7)并以 Windows PowerShell 5.1 回退——对话框脚本会把进程设为系统 DPI aware——Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 @@ -17,3 +17,4 @@ ## 已知限制与延期工作 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 +- **Windows 需要 PowerShell 7 才能使用现代选择器**——`pwsh` 呈现资源管理器风格的文件夹对话框;只有 Windows PowerShell 5.1 的机器会回退到旧版文件夹树,DPI 已修正,但界面不是现代的。 From da1b1ff87db705d5cfc1afbdc25c1b919437652f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 2 Aug 2026 00:23:30 +0800 Subject: [PATCH 03/17] fix(host): drop the folder-dialog Description both picker modes render badly .NET 10's modern FolderBrowserDialog renders Description as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed white box; the property is dropped entirely and a regression assertion pins its absence. --- .../bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml | 4 ++-- .../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md | 2 +- .../bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md | 2 +- packages/host/directory-picker-native/src/native-picker.ts | 5 +++-- .../host/directory-picker-native/tests/native-picker.spec.ts | 2 ++ 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml index f4619714d1..76100a958d 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.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-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: 2c90821be3e4800d624cb2f54dcd6661756784bc -2026-08-01-windows-picker-pwsh-dpi.zh.md: ff8a535af396ed5ede51cbd3c69c1944d28ce95e +2026-08-01-windows-picker-pwsh-dpi.md: 0ca413f575b5e2805f29b899e638844916e72573 +2026-08-01-windows-picker-pwsh-dpi.zh.md: 6842acfc1e1bd9f3342a2bedb878fe2df40449df diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md index 2c90821be3..0ca413f575 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md @@ -10,7 +10,7 @@ The Windows branch of the native directory picker spawned Windows PowerShell 5.1 ## Decision -The win32 branch in `packages/host/directory-picker-native` now spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) only when pwsh is missing (`ENOENT`), mirroring the Zenity→KDialog fallback. PowerShell 7's WinForms `FolderBrowserDialog` supports `AutoUpgradeEnabled` (added in .NET Core 3.0, absent from .NET Framework) and renders the modern Explorer-style folder picker. Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). +The win32 branch in `packages/host/directory-picker-native` now spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) only when pwsh is missing (`ENOENT`), mirroring the Zenity→KDialog fallback. PowerShell 7's WinForms `FolderBrowserDialog` supports `AutoUpgradeEnabled` (added in .NET Core 3.0, absent from .NET Framework) and renders the modern Explorer-style folder picker. Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: .NET 10's modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md index ff8a535af3..6842acfc1e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`packages/host/directory-picker-native` 的 win32 分支现在先启动 `pwsh.exe`(PowerShell 7),仅当 pwsh 缺失(`ENOENT`)时才回退到 `powershell.exe`(Windows PowerShell 5.1),与 Zenity→KDialog 的回退方式一致。PowerShell 7 的 WinForms `FolderBrowserDialog` 支持 `AutoUpgradeEnabled`(.NET Core 3.0 加入;.NET Framework 没有),呈现现代资源管理器风格文件夹选择器。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 +`packages/host/directory-picker-native` 的 win32 分支现在先启动 `pwsh.exe`(PowerShell 7),仅当 pwsh 缺失(`ENOENT`)时才回退到 `powershell.exe`(Windows PowerShell 5.1),与 Zenity→KDialog 的回退方式一致。PowerShell 7 的 WinForms `FolderBrowserDialog` 支持 `AutoUpgradeEnabled`(.NET Core 3.0 加入;.NET Framework 没有),呈现现代资源管理器风格文件夹选择器。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:.NET 10 的现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 ## 考虑过的替代方案 diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 079211f5a3..0cc12fb38e 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -68,14 +68,15 @@ export async function pickNativeDirectory( // PowerShell 5.1's FolderBrowserDialog is hardwired to the legacy // SHBrowseForFolder tree; prefer pwsh and fall back only when it is absent. // Both hosts spawn DPI-unaware, so the script opts the process into system - // DPI awareness before any window is created. + // DPI awareness before any window is created. No Description is set: the + // modern dialog renders it as a bottom strip and the classic dialog as an + // unthemed box. const script = [ "$ErrorActionPreference = 'Stop'", "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'", '[DpiAware]::SetProcessDPIAware() | Out-Null', 'Add-Type -AssemblyName System.Windows.Forms', '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', - "$dialog.Description = 'Select Workspace Directory'", '$dialog.ShowNewFolderButton = $true', '$result = $dialog.ShowDialog()', 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 8d26c6ab36..707a5cafe4 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -57,6 +57,8 @@ describe('native directory picker', () => { const script = run.mock.calls[0]?.[1].at(-1) expect(script).toContain("$ErrorActionPreference = 'Stop'") expect(script).toContain('SetProcessDPIAware') + // Description renders as a bottom strip (modern) / unthemed box (classic); never set it. + expect(script).not.toContain('Description') run.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) From 089f4dfad8f8bc9be6c2f4656732665d9cc7dd27 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 00:06:47 +0800 Subject: [PATCH 04/17] feat(picker): open the Win32 folder dialog in-process over koffi The modern IFileOpenDialog becomes the primary win32 tier: a koffi-driven COM conversation on a worker_threads worker (the modal Show never blocks the host event loop), per-monitor-v2 DPI via SetThreadDpiAwarenessContext, and abort service by re-posting WM_CLOSE to the dialog thread's windows, with terminate+unref as the last resort (Node cannot interrupt a thread blocked in native code, and such a worker must never hold the process open). The PowerShell chain stays as the fallback tier with its trigger widened from ENOENT to any pwsh failure, closing the review-flagged PowerShell 6 regression (no WinForms: exit 1, not ENOENT, so 5.1 never ran). Layering keeps per-file coverage honest on every host: pure sequencing and the driver test against fakes anywhere; the bindings run against a mocked koffi COM world (the session-persistence-jsonl technique); POSIX hosts drive the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The smoke joins processBoundTests: a worker blocked in a native modal wedges the threads pool's teardown, while a fork contains it. The worker bundles as its own CJS tsdown entry (workflow-workerthread's pattern; no TLA), and the host module is imported statically so the node-half bundle stays chunk-free. Built-plane and real-COM behavior verified on native Windows: standalone probes for the source worker, the built CJS worker, and the driver's abort path all open and close the real dialog. Agent Notes: new implemented/feature/2026-08-02-win32-in-process-folder-dialog (bilingual) owns the decision; the DPI note is re-scoped to the fallback tier it now describes and its AutoUpgradeEnabled attribution corrected (.NET Core 3.0 rewrote FolderBrowserDialog; the opt-out arrived in .NET 6). --- ...26-08-01-windows-picker-pwsh-dpi.i18n.yaml | 4 +- .../2026-08-01-windows-picker-pwsh-dpi.md | 2 +- .../2026-08-01-windows-picker-pwsh-dpi.zh.md | 2 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 6 + ...26-08-02-win32-in-process-folder-dialog.md | 26 ++ ...08-02-win32-in-process-folder-dialog.zh.md | 26 ++ .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 5 +- .../host/directory-picker-native/README.zh.md | 5 +- .../host/directory-picker-native/package.json | 7 +- .../src/native-picker.ts | 31 +- .../src/win32-dialog-bindings.ts | 157 ++++++++++ .../src/win32-dialog-host.ts | 36 +++ .../src/win32-dialog-logic.ts | 117 ++++++++ .../src/win32-dialog-worker.ts | 37 +++ .../src/win32-dialog.ts | 128 ++++++++ .../tests/native-picker.spec.ts | 85 ++++-- .../tests/win32-dialog-bindings.spec.ts | 284 ++++++++++++++++++ .../tests/win32-dialog-logic.spec.ts | 90 ++++++ .../tests/win32-dialog.spec.ts | 136 +++++++++ .../directory-picker-native/tsdown.config.ts | 17 +- pnpm-lock.yaml | 6 + vitest.config.ts | 4 + 23 files changed, 1174 insertions(+), 41 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md create mode 100644 .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md create mode 100644 packages/host/directory-picker-native/src/win32-dialog-bindings.ts create mode 100644 packages/host/directory-picker-native/src/win32-dialog-host.ts create mode 100644 packages/host/directory-picker-native/src/win32-dialog-logic.ts create mode 100644 packages/host/directory-picker-native/src/win32-dialog-worker.ts create mode 100644 packages/host/directory-picker-native/src/win32-dialog.ts create mode 100644 packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts create mode 100644 packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts create mode 100644 packages/host/directory-picker-native/tests/win32-dialog.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml index 76100a958d..d9f46441ac 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.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-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: 0ca413f575b5e2805f29b899e638844916e72573 -2026-08-01-windows-picker-pwsh-dpi.zh.md: 6842acfc1e1bd9f3342a2bedb878fe2df40449df +2026-08-01-windows-picker-pwsh-dpi.md: 1d9fd0a1b445a77b478f033d56169d6166c2b1bf +2026-08-01-windows-picker-pwsh-dpi.zh.md: 245991e7c8d081f3c91724c0dae1142d80883c1d diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md index 0ca413f575..1d9fd0a1b4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md @@ -10,7 +10,7 @@ The Windows branch of the native directory picker spawned Windows PowerShell 5.1 ## Decision -The win32 branch in `packages/host/directory-picker-native` now spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) only when pwsh is missing (`ENOENT`), mirroring the Zenity→KDialog fallback. PowerShell 7's WinForms `FolderBrowserDialog` supports `AutoUpgradeEnabled` (added in .NET Core 3.0, absent from .NET Framework) and renders the modern Explorer-style folder picker. Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: .NET 10's modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). +The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog (see the [in-process folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md)): the win32 branch spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) on ANY pwsh failure — a resolvable PowerShell 6 has no WinForms and exits 1, not `ENOENT`, and 5.1 ships with every Windows. PowerShell 7 renders the modern Explorer-style folder picker because .NET Core 3.0 rewrote `FolderBrowserDialog` over `IFileDialog` (unconditionally; the later `AutoUpgradeEnabled` opt-out arrived in .NET 6 and the script never sets it). Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: .NET 10's modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md index 6842acfc1e..245991e7c8 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -`packages/host/directory-picker-native` 的 win32 分支现在先启动 `pwsh.exe`(PowerShell 7),仅当 pwsh 缺失(`ENOENT`)时才回退到 `powershell.exe`(Windows PowerShell 5.1),与 Zenity→KDialog 的回退方式一致。PowerShell 7 的 WinForms `FolderBrowserDialog` 支持 `AutoUpgradeEnabled`(.NET Core 3.0 加入;.NET Framework 没有),呈现现代资源管理器风格文件夹选择器。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:.NET 10 的现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 +PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程内文件夹对话框 Note](../feature/2026-08-02-win32-in-process-folder-dialog.md)):win32 分支先启动 `pwsh.exe`(PowerShell 7),并在 pwsh 的任何失败上回退到 `powershell.exe`(Windows PowerShell 5.1)——可解析的 PowerShell 6 没有 WinForms,以退出码 1 而非 `ENOENT` 失败,而 5.1 每台 Windows 都自带。PowerShell 7 呈现现代资源管理器风格选择器,是因为 .NET Core 3.0 用 `IFileDialog` 重写了 `FolderBrowserDialog`(无条件生效;更晚的 `AutoUpgradeEnabled` 退出开关到 .NET 6 才加入,脚本从未设置它)。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:.NET 10 的现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml new file mode 100644 index 0000000000..a3bdfc2a97 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/feature/2026-08-02-win32-in-process-folder-dialog.md +2026-08-02-win32-in-process-folder-dialog.md: fa896d198913f58b22f9186696daec27026bb50f +2026-08-02-win32-in-process-folder-dialog.zh.md: 31077d8d6a3907d955180fda290f92c4cf41e5b9 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md new file mode 100644 index 0000000000..fa896d1989 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -0,0 +1,26 @@ +# Agent Note: Win32 folder picker moves in-process over koffi + +Status: implemented + +English | [中文](2026-08-02-win32-in-process-folder-dialog.zh.md) + +## Problem + +The Windows directory picker's primary tier was a spawned PowerShell script around WinForms `FolderBrowserDialog`: the modern dialog only where PowerShell 7 happens to be installed, a review-flagged regression where PowerShell 6 resolves but has no WinForms (exit 1 is not `ENOENT`, so the 5.1 fallback never ran), a `SetProcessDPIAware` ceiling of system DPI, and a picker whose behavior depended on which shells a machine ships rather than on Windows itself. + +## Decision + +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into per-monitor-v2 DPI (`SetThreadDpiAwarenessContext`), a strict upgrade over the script's system-DPI ceiling. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. + +## Alternatives considered + +- **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that `worker_threads` + koffi express in TypeScript. +- **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. +- **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. + +## Consequences + +- Every Windows machine gets the modern dialog with per-monitor-v2 DPI, PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is an in-process crash risk contained to the worker thread and caught by the win32 smoke before shipping. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md new file mode 100644 index 0000000000..31077d8d6a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -0,0 +1,26 @@ +# Agent Note:Win32 文件夹选择器经 koffi 移入进程内 + +Status: implemented + +[English](2026-08-02-win32-in-process-folder-dialog.md) | 中文 + +## 问题 + +Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` 的外部 PowerShell 脚本:只有恰好安装了 PowerShell 7 的机器才有现代对话框;review 指出的回归——PowerShell 6 可解析却没有 WinForms(退出码 1 而非 `ENOENT`,5.1 回退永远不会触发);`SetProcessDPIAware` 只有系统 DPI 的上限;选择器的行为取决于机器装了哪些 shell,而不是取决于 Windows 本身。 + +## 决策 + +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用 per-monitor-v2 DPI(`SetThreadDpiAwarenessContext`),严格优于脚本的系统 DPI 上限。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 + +## 考虑过的替代方案 + +- **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而 `worker_threads` + koffi 用 TypeScript 就能表达。 +- **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 +- **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 + +## 后果 + +- 每台 Windows 机器都得到带 per-monitor-v2 DPI 的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是被限制在 worker 线程内的进程内崩溃风险,并在交付前被 win32 冒烟捕获。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 9abdfb129a..acf7f85d88 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: ab4326fed886e9bb2fa550ae9865550eed7c2583 -README.zh.md: cb2e067d340df1696e1b1195ec99f6d63509cc20 +README.md: 0d0fe8d3a049d6fbc47eee314f9782352651247d +README.zh.md: 82f51976afe2e57699c1bd62d11142106b082e9b diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index ab4326fed8..0d0fe8d3a0 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, `pwsh` (PowerShell 7) with a Windows PowerShell 5.1 fallback on Windows — the dialog script opts the process into system DPI awareness — and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with per-monitor-v2 DPI awareness, aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). @@ -17,4 +17,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). -- **Windows needs PowerShell 7 for the modern picker** — `pwsh` renders the Explorer-style folder dialog; a machine with only Windows PowerShell 5.1 falls back to the legacy folder tree, DPI-corrected but not the modern UI. +- **The Windows fallback chain degrades the dialog** — the in-process picker is the modern Explorer-style dialog; where koffi cannot drive COM the PowerShell tiers take over, and a machine that only reaches Windows PowerShell 5.1 gets the legacy folder tree, DPI-corrected but not the modern UI. +- **A wedged abort can leak one dialog thread** — when `WM_CLOSE` never lands (the dialog window was never created), the driver terminates and unrefs the worker; Node cannot interrupt a thread blocked in the native modal call, so that thread lives until process exit. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index cb2e067d34..82f51976af 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用 `pwsh`(PowerShell 7)并以 Windows PowerShell 5.1 回退——对话框脚本会把进程设为系统 DPI aware——Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,带 per-monitor-v2 DPI 感知,中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 @@ -17,4 +17,5 @@ ## 已知限制与延期工作 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 -- **Windows 需要 PowerShell 7 才能使用现代选择器**——`pwsh` 呈现资源管理器风格的文件夹对话框;只有 Windows PowerShell 5.1 的机器会回退到旧版文件夹树,DPI 已修正,但界面不是现代的。 +- **Windows 回退链会降级对话框**——进程内选择器就是现代资源管理器风格对话框;koffi 无法驱动 COM 时由 PowerShell 层级接手,最终只到达 Windows PowerShell 5.1 的机器得到旧版文件夹树,DPI 已修正,但界面不是现代的。 +- **卡死的中止可能泄漏一个对话框线程**——当 `WM_CLOSE` 始终投递不到(对话框窗口从未创建)时,driver 会 terminate 并 unref 该 worker;Node 无法打断阻塞在原生模态调用里的线程,因此该线程会存活到进程退出。 diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index bafe18e09f..49033cdcf4 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -26,6 +26,7 @@ "lib/index.js", "lib/invariant.js", "lib/client.js", + "lib/win32-dialog-worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" @@ -33,7 +34,8 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", - "@deepseek-ai/dsh-native-command": "workspace:^" + "@deepseek-ai/dsh-native-command": "workspace:^", + "koffi": "^3.1.0" }, "peerDependencies": { "@deepseek-ai/dsh-client-runtime": "^0.0.1", @@ -50,7 +52,8 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "cordis": "^4.0.0-rc.7", - "react": "^18.2.0" + "react": "^18.2.0", + "tsx": "^4.19.2" }, "dshClient": { "inject": [ diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 0cc12fb38e..6cabf44ddb 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -1,6 +1,7 @@ /** Cross-platform native single-directory chooser behind the native backend's capability. */ import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' +import { pickWin32Directory } from './win32-dialog.ts' /** Testable command boundary; native implementations never invoke a shell. */ export type DirectoryPickerRunner = NativeCommandRunner @@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner export interface DirectoryPickerInternals { platform?: NodeJS.Platform run?: DirectoryPickerRunner + /** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */ + pickWin32Dialog?: (signal: AbortSignal) => Promise } function outputPath(stdout: string): string | null { @@ -64,13 +67,26 @@ export async function pickNativeDirectory( } if (platform === 'win32') { - // PowerShell 7 renders the modern IFileDialog folder picker, while Windows - // PowerShell 5.1's FolderBrowserDialog is hardwired to the legacy - // SHBrowseForFolder tree; prefer pwsh and fall back only when it is absent. - // Both hosts spawn DPI-unaware, so the script opts the process into system - // DPI awareness before any window is created. No Description is set: the - // modern dialog renders it as a bottom strip and the classic dialog as an - // unthemed box. + // Primary: the in-process koffi-backed IFileOpenDialog worker — the modern + // picker with per-monitor-v2 DPI, no PowerShell dependency, and abort + // support. Any non-abort failure (koffi unavailable, ancient Windows, COM + // refusal) falls back to the PowerShell chain below. + const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory + try { + return await pickDialog(signal) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + } + + // PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder + // picker, while Windows PowerShell 5.1's FolderBrowserDialog is hardwired + // to the legacy SHBrowseForFolder tree. Prefer pwsh, but ANY pwsh failure + // falls back to 5.1 (which every Windows ships): a resolvable pwsh can + // still be unable to deliver the dialog — PowerShell 6 has no WinForms, + // so its Add-Type exits 1, not ENOENT. Both hosts spawn DPI-unaware, so + // the script opts the process into system DPI awareness before any window + // is created. No Description is set: the modern dialog renders it as a + // bottom strip and the classic dialog as an unthemed box. const script = [ "$ErrorActionPreference = 'Stop'", "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'", @@ -89,7 +105,6 @@ export async function pickNativeDirectory( return outputPath(result.stdout) } catch (error: unknown) { rethrowIfAborted(signal, error) - if (!isMissingCommand(error)) throw error } const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) return outputPath(result.stdout) diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts new file mode 100644 index 0000000000..a9b625812c --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -0,0 +1,157 @@ +/** + * koffi-backed Win32 bindings for the folder dialog: the COM vtable calls + * behind {@link Win32DialogBindings} plus the cross-thread window closer the + * driver uses to service aborts. Loaded lazily and only on win32 (the dialog + * worker and the driver's abort path), so non-Windows processes never load + * koffi — the same containment as the repo's other `win32.ts` modules. + * + * The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and + * IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is + * frozen Windows ABI since Vista; slots are offsets into the vtable at the + * object's first pointer. + */ + +import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts' + +interface KoffiFunction { (...args: unknown[]): unknown } +interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction } +interface Koffi { + load(path: string): KoffiLibrary + proto(declaration: string): unknown + pointer(type: unknown): unknown + call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown + decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown + register(fn: (...args: unknown[]) => unknown, type: unknown): unknown + unregister(callback: unknown): void +} + +const COINIT_APARTMENTTHREADED = 0x2 +const CLSCTX_INPROC_SERVER = 0x1 +const SIGDN_FILESYSPATH = 0x80058000 | 0 +const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 +const WM_CLOSE = 0x10 + +/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */ +const SLOT_RELEASE = 2 +const SLOT_SHOW = 3 +const SLOT_SET_OPTIONS = 9 +const SLOT_SET_TITLE = 17 +const SLOT_GET_RESULT = 20 +/** IShellItem vtable slot for `GetDisplayName`. */ +const SLOT_GET_DISPLAY_NAME = 5 + +/** + * Encode a canonical GUID string as its 16 little-endian bytes. + * @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form. + * @returns the in-memory GUID bytes CoCreateInstance expects. + */ +function guidBytes(text: string): Buffer { + const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray + const bytes = Buffer.alloc(16) + bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0) + bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4) + bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6) + Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8) + return bytes +} + +const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7') +const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960') + +/** + * Load koffi and expose the dialog bindings for this thread. + * @returns the bindings {@link runFolderDialog} sequences against. + */ +export async function loadWin32DialogBindings(): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const ole32 = koffi.load('ole32.dll') + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + + const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32']) + const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *']) + const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *']) + const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) + + const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)') + const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)') + const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)') + const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)') + const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)') + const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)') + + /** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */ + const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => { + const vtable = koffi.decode(self, 'void *') + const fn = koffi.decode(vtable, slot * 8, 'void *') + return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number + } + + return { + setThreadDpiAwareness: () => { + try { + const setThreadDpiAwarenessContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) + setThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + } catch { + // SetThreadDpiAwarenessContext absent (Windows 10 pre-1703): the + // dialog renders at system DPI; nothing else can fail here because + // user32 itself loaded above. + } + }, + coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, + currentThreadId: () => getCurrentThreadId() as number, + createFolderDialog: (): Win32FolderDialog => { + const out = Buffer.alloc(8) + const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number + if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`) + const dialog = koffi.decode(out, 'void *') + return { + setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options), + setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title), + show: () => method(dialog, SLOT_SHOW, protoShow)(null), + resultPath: () => { + const itemOut: unknown[] = [null] + const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut) + if (gotItem < 0) return { hr: gotItem } + const item = itemOut[0] + try { + const nameOut: unknown[] = [null] + const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut) + if (gotName < 0) return { hr: gotName } + const path = koffi.decode(nameOut[0], 'str16') as string + coTaskMemFree(nameOut[0]) + return { hr: gotName, path } + } finally { + method(item, SLOT_RELEASE, protoRelease)() + } + }, + release: () => { + method(dialog, SLOT_RELEASE, protoRelease)() + }, + } + }, + } +} + +/** + * Post `WM_CLOSE` to every window of a native thread — the driver's abort + * lever against the worker blocked inside `Show`, after which `Show` returns + * `HRESULT_CANCELLED` and the worker unwinds normally. + * @param threadId - the dialog thread's native id (from the `showing` notice). + */ +export async function closeThreadWindows(threadId: number): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const user32 = koffi.load('user32.dll') + const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) + const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr']) + const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') + const callback = koffi.register((hwnd: unknown) => { + postMessageW(hwnd, WM_CLOSE, 0, 0) + return 1 + }, koffi.pointer(protoEnumProc)) + try { + enumThreadWindows(threadId, callback, 0) + } finally { + koffi.unregister(callback) + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts new file mode 100644 index 0000000000..cfaf07cc46 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -0,0 +1,36 @@ +/** + * Real-process half of the Win32 dialog driver: spawn the dialog worker + * (source or built plane) and close a dialog thread's windows. Loaded lazily + * and only on the win32 default path, so non-Windows processes never touch + * worker or koffi machinery; the driver's logic is tested against fakes of + * this surface instead. + */ + +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' + +/** + * Spawn the dialog worker. Built consumers load the bundled CJS worker next + * to this module; unbuilt (source) consumers bootstrap tsx inside the worker + * first, mirroring `dsh-workflow-workerthread`'s host. + * @param data - the worker payload (dialog title). + * @returns the spawned worker thread. + */ +export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { + /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ + if (!import.meta.url.endsWith('.ts')) { + return new Worker(fileURLToPath(new URL('./win32-dialog-worker.cjs', import.meta.url)), { workerData: data }) + } + const workerEntry = new URL('./win32-dialog-worker.ts', import.meta.url) + const bootstrap = [ + `import { register as registerEsm } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}`, + `import { register as registerCjs } from ${JSON.stringify(import.meta.resolve('tsx/cjs/api'))}`, + 'registerCjs()', + 'registerEsm()', + `await import(${JSON.stringify(workerEntry.href)})`, + ].join('\n') + return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data }) +} + +export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts new file mode 100644 index 0000000000..cba0ca9f24 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -0,0 +1,117 @@ +/** + * Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM + * conversation over an injectable bindings seam, so every outcome path + * (selection, cancellation, HRESULT failure, cleanup ordering) is testable on + * any platform. The koffi-backed bindings live in + * `win32-dialog-bindings.ts`, which only a real win32 process ever loads. + */ + +/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */ +export const HRESULT_CANCELLED = 0x800704c7 | 0 + +/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */ +export const FOS_PICKFOLDERS = 0x20 +/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */ +export const FOS_FORCEFILESYSTEM = 0x40 +/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */ +export const FOS_NOCHANGEDIR = 0x8 + +/** One created folder dialog: the vtable calls the sequencing needs. */ +export interface Win32FolderDialog { + /** + * `IFileDialog::SetOptions`. + * @param options - the `FOS_*` flag union to apply. + * @returns the call's HRESULT. + */ + setOptions(options: number): number + /** + * `IFileDialog::SetTitle`. + * @param title - the dialog title text. + * @returns the call's HRESULT. + */ + setTitle(title: string): number + /** + * `IModalWindow::Show` with no owner window; blocks the calling thread + * until the user selects or dismisses. + * @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal). + */ + show(): number + /** + * `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`, + * releasing the shell item and freeing the COM string. + * @returns the call chain's HRESULT and, on success, the selected path. + */ + resultPath(): { hr: number; path?: string } + /** Release the dialog's COM reference. */ + release(): void +} + +/** The thread-level native surface the dialog sequencing runs against. */ +export interface Win32DialogBindings { + /** + * Best-effort per-monitor-v2 DPI opt-in for the calling thread. Absent + * before Windows 10 1703; implementations swallow only that absence, so an + * old host merely renders the dialog at system DPI. + */ + setThreadDpiAwareness(): void + /** + * `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread. + * @returns the call's HRESULT (`S_FALSE` re-entry is still a success). + */ + coInitializeSta(): number + /** + * `CoCreateInstance(CLSID_FileOpenDialog)`. + * @returns the created dialog surface; throws when creation fails. + */ + createFolderDialog(): Win32FolderDialog + /** + * `GetCurrentThreadId` — the native id a driver needs to close this + * thread's windows from outside. + * @returns the calling thread's native id. + */ + currentThreadId(): number +} + +/** + * Throw when an HRESULT signals failure. + * @param hr - the HRESULT to check. + * @param what - the failing call's name for the error message. + * @returns the (successful) HRESULT unchanged. + */ +function check(hr: number, what: string): number { + if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`) + return hr +} + +/** + * Run one modal folder-picker conversation on the calling thread: DPI opt-in, + * STA init, dialog creation, `Show`, and result extraction, releasing the + * dialog on every path. + * @param bindings - the native surface (koffi-backed in production, fakes in tests). + * @param title - the dialog title text. + * @param onShowing - called with the native thread id immediately before the + * blocking `Show`, so a driver on another thread can close the dialog. + * @returns the selected filesystem path, or null when the user cancels. + */ +export function runFolderDialog( + bindings: Win32DialogBindings, + title: string, + onShowing: (threadId: number) => void, +): string | null { + bindings.setThreadDpiAwareness() + check(bindings.coInitializeSta(), 'CoInitializeEx') + const dialog = bindings.createFolderDialog() + try { + check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions') + check(dialog.setTitle(title), 'SetTitle') + onShowing(bindings.currentThreadId()) + const shown = dialog.show() + if (shown === HRESULT_CANCELLED) return null + check(shown, 'Show') + const result = dialog.resultPath() + check(result.hr, 'GetResult') + return result.path as string + } finally { + dialog.release() + } +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts new file mode 100644 index 0000000000..2e4ef64f6c --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -0,0 +1,37 @@ +/** + * Worker entry for the Win32 folder dialog: blocks THIS thread inside the + * modal `Show` so the host event loop stays live, reporting over the message + * port. Protocol: `{kind:'showing',threadId}` right before the blocking call + * (the driver's abort lever needs the native thread id), then exactly one of + * `{kind:'done',path}` or `{kind:'error',message}`. + */ + +import { parentPort, workerData } from 'node:worker_threads' +import { loadWin32DialogBindings } from './win32-dialog-bindings.ts' +import { runFolderDialog } from './win32-dialog-logic.ts' + +/** The driver-to-worker payload: the dialog title. */ +export interface Win32DialogWorkerData { title: string } + +/** One notice or outcome posted back to the driver. */ +export type Win32DialogWorkerMessage = + | { kind: 'showing'; threadId: number } + | { kind: 'done'; path: string | null } + | { kind: 'error'; message: string } + +const port = parentPort +if (port === null) throw new Error('win32-dialog-worker must run as a worker thread') +const { title } = workerData as Win32DialogWorkerData + +// No top-level await: the built worker ships as CJS (pkg's VFS Worker hook +// compiles that format), which cannot carry TLA. +void (async () => { + try { + const bindings = await loadWin32DialogBindings() + const path = runFolderDialog(bindings, title, (threadId) =>{ port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) }) + port.postMessage({ kind: 'done', path } satisfies Win32DialogWorkerMessage) + } catch (error: unknown) { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error) + port.postMessage({ kind: 'error', message } satisfies Win32DialogWorkerMessage) + } +})() diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts new file mode 100644 index 0000000000..40b31e8d57 --- /dev/null +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -0,0 +1,128 @@ +/** + * Main-thread driver for the Win32 folder dialog: spawns the dialog worker + * (which blocks inside the modal `Show`), maps its message protocol onto a + * promise, and services aborts by posting `WM_CLOSE` to the dialog thread's + * windows until the worker reports back. The real worker/window surface is + * injectable so every driver path is testable on any platform. + */ + +import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' +import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' + +/** The worker surface the driver drives (satisfied by `node:worker_threads`). */ +export interface Win32DialogWorkerLike { + /** + * Subscribe to a worker event. + * @param event - `message`, `error`, or `exit`. + * @param listener - the event consumer. + */ + on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown + on(event: 'error', listener: (error: Error) => void): unknown + on(event: 'exit', listener: (code: number) => void): unknown + /** + * Force-stop the worker; the abort path's last resort when `WM_CLOSE` + * never lands (e.g. the dialog window was never created). + * @returns settles when the thread is gone. + */ + terminate(): Promise + /** + * Release the event-loop reference. Called once the pick settles so a + * worker stuck in the native modal call (terminate cannot interrupt + * native code) never blocks process exit. + */ + unref?(): void +} + +/** Injectable process surface for deterministic driver tests. */ +export interface Win32DialogInternals { + /** Replaces the real worker spawn (`win32-dialog-host.ts`). */ + spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike + /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ + closeThreadWindows?: (threadId: number) => Promise + /** Abort-service cadence override so tests never wait wall-clock time. */ + closeRetryMs?: number +} + +/** The dialog title every host shows. */ +export const DIALOG_TITLE = 'Select Workspace Directory' + +/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */ +const CLOSE_RETRY_MS = 150 +/** Abort-service attempts before force-terminating the worker. */ +const CLOSE_MAX_ATTEMPTS = 20 + +/** + * Open the modern Win32 folder picker off the event loop. + * @param signal - caller lifetime; abort closes the dialog and rejects. + * @param internals - worker/window seams for deterministic tests. + * @returns the selected path, or null when the user cancels. + */ +export async function pickWin32Directory( + signal: AbortSignal, + internals: Win32DialogInternals = {}, +): Promise { + if (signal.aborted) throw new Error('native directory picker aborted') + const spawnWorker = internals.spawnWorker ?? spawnDialogWorker + const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows + const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS + + const worker = spawnWorker({ title: DIALOG_TITLE }) + let dialogThreadId: number | undefined + let closeTimer: NodeJS.Timeout | undefined + let settled = false + + return await new Promise((resolve, reject) => { + const settle = (outcome: () => void): void => { + if (settled) return + settled = true + if (closeTimer !== undefined) clearInterval(closeTimer) + signal.removeEventListener('abort', onAbort) + worker.unref?.() + outcome() + } + + const serviceAbort = (): void => { + let attempts = 0 + // The `showing` notice precedes the blocking `Show`, so the very first + // WM_CLOSE can race the window's creation; re-post until the worker + // reports back, then force-terminate as a last resort. + closeTimer = setInterval(() => { + attempts += 1 + if (attempts > CLOSE_MAX_ATTEMPTS) { + settle(() => { + void worker.terminate() + reject(new Error('native directory picker aborted (dialog unresponsive; worker terminated)')) + }) + return + } + void closeWindows(dialogThreadId as number).catch(() => undefined) + }, closeRetryMs) + void closeWindows(dialogThreadId as number).catch(() => undefined) + } + + const onAbort = (): void => { + if (dialogThreadId !== undefined) serviceAbort() + // Not shown yet: the `showing` handler below starts the service loop. + } + signal.addEventListener('abort', onAbort, { once: true }) + + worker.on('message', (message: Win32DialogWorkerMessage) => { + switch (message.kind) { + case 'showing': + dialogThreadId = message.threadId + if (signal.aborted) serviceAbort() + return + case 'done': + settle(() => { + if (signal.aborted) reject(new Error('native directory picker aborted')) + else resolve(message.path) + }) + return + case 'error': + settle(() =>{ reject(new Error(`win32 folder dialog failed: ${message.message}`)) }) + } + }) + worker.on('error', (error: Error) =>{ settle(() =>{ reject(error) }) }) + worker.on('exit', () =>{ settle(() =>{ reject(new Error('win32 folder dialog worker exited before reporting a result')) }) }) + }) +} diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 707a5cafe4..7c51d552d4 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -23,6 +23,9 @@ function failure(code: string | number, stderr = ''): Error { const signal = () => new AbortController().signal +/** The PowerShell chain is reachable only when the in-process dialog fails. */ +const noDialog = async (): Promise => { throw new Error('dialog unavailable') } + describe('native directory picker', () => { it('uses the macOS folder chooser and maps user cancellation to null', async () => { const run = vi.fn(async () => ({ stdout: '/Users/test/project/\n', stderr: '' })) @@ -46,9 +49,18 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('prefers pwsh for the Windows folder dialog and maps empty output to cancellation', async () => { + it('prefers the in-process Win32 dialog and never spawns PowerShell when it answers', async () => { + const run = vi.fn() + const pickWin32Dialog = vi.fn(async (): Promise => 'C:\\work\\selected') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected') + pickWin32Dialog.mockResolvedValueOnce(null) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull() + expect(run).not.toHaveBeenCalled() + }) + + it('falls back to pwsh when the dialog is unavailable and maps empty output to cancellation', async () => { const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\project') expect(run).toHaveBeenCalledWith( 'pwsh.exe', expect.arrayContaining(['-NoProfile', '-STA', '-Command']), @@ -60,48 +72,70 @@ describe('native directory picker', () => { // Description renders as a bottom strip (modern) / unthemed box (classic); never set it. expect(script).not.toContain('Description') run.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull() - run.mockRejectedValueOnce(failure(1, 'Add-Type failed')) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBeNull() }) - it('falls back to Windows PowerShell 5.1 only when pwsh is missing', async () => { + it('falls back to Windows PowerShell 5.1 whenever pwsh cannot deliver the dialog', async () => { const run = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\fallback') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\fallback') expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) // Both runtimes execute the identical script, so DPI awareness holds either way. expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1)) + // A resolvable pwsh that cannot deliver the dialog (PowerShell 6: no + // WinForms, Add-Type exits 1 - not ENOENT) reaches 5.1 all the same. + const pwsh6 = vi.fn() + .mockRejectedValueOnce(failure(1, "Cannot load assembly 'System.Windows.Forms'")) + .mockResolvedValueOnce({ stdout: 'C:\\work\\legacy\r\n', stderr: '' }) + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: pwsh6, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\legacy') + expect(pwsh6.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) + const cancelled = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled })).resolves.toBeNull() + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull() const failed = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockRejectedValueOnce(failure(2)) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed })).rejects.toThrow('command failed') - - const brokenPwsh = vi.fn(async () => { throw failure(7) }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: brokenPwsh })).rejects.toThrow('command failed') - expect(brokenPwsh).toHaveBeenCalledOnce() + await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog })).rejects.toThrow('command failed') }) - it('does not fall back when the caller aborted the pwsh spawn', async () => { + it('wires the real Win32 dialog as the default tier', async () => { + // A pre-aborted signal makes the DEFAULT dialog deterministic on every + // host: pickWin32Directory throws before spawning any worker or window. + const abort = new AbortController() + abort.abort() + const run = vi.fn() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })) + .rejects.toThrow('native directory picker aborted') + expect(run).not.toHaveBeenCalled() + }) + + it('does not fall back when the caller aborted the dialog or the pwsh spawn', async () => { const abort = new AbortController() abort.abort(new Error('closed')) - const run = vi.fn(async () => { throw failure('ENOENT') }) - await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })).rejects.toThrow('command failed') - expect(run).toHaveBeenCalledOnce() + const run = vi.fn() + await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() + + const liveThenAborted = new AbortController() + const abortingRun = vi.fn(async () => { + liveThenAborted.abort(new Error('closed')) + throw failure('ENOENT') + }) + await expect(pickNativeDirectory(liveThenAborted.signal, { platform: 'win32', run: abortingRun, pickWin32Dialog: noDialog })) + .rejects.toThrow('command failed') + expect(abortingRun).toHaveBeenCalledOnce() }) it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(null, 'C:\\work\\default\r\n', '') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default') + await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\default') const [command, args, options] = execFileMock.mock.calls[0]! expect(command).toBe('pwsh.exe') expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) @@ -109,19 +143,30 @@ describe('native directory picker', () => { expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) + // Both chain tiers fail: pwsh's code-7 failure now reaches 5.1, whose + // failure is the one the caller sees. + const pwshError = Object.assign(new Error('pwsh failed'), { code: 7 }) const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { + callback(pwshError, '', 'no WinForms') + }) execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(commandError, 'partial output', 'failure details') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({ + await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).rejects.toMatchObject({ message: 'powershell failed', cause: commandError, code: 7, stdout: 'partial output', stderr: 'failure details', }) + expect(execFileMock.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'pwsh.exe', 'powershell.exe']) }) it('uses the current process platform when no platform override is supplied', async () => { + // Deterministic on every host: the win32 tier answers from the dialog, + // the POSIX tiers from the command runner. const run = vi.fn(async () => ({ stdout: '/default/platform\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform') + const pickWin32Dialog = async (): Promise => 'C:\\default\\platform' + const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform' + await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected) }) it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts new file mode 100644 index 0000000000..d23b43011a --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -0,0 +1,284 @@ +/** + * The koffi-backed bindings against a mocked `koffi` module (the same + * technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory + * COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch, + * result extraction, memory hygiene, and the WM_CLOSE poster covered on every + * host. The worker entry is exercised the same way with a mocked + * `node:worker_threads`. Real-COM behavior is pinned by the win32-only smoke + * in win32-dialog.spec.ts. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 +const WM_CLOSE = 0x10 + +interface ComWorld { + coInitHr: number + coCreateHr: number + showHr: number + getResultHr: number + getDisplayNameHr: number + hasThreadDpi: boolean + enumThrows: boolean + path: string + titles: string[] + options: number[] + dpiContexts: unknown[] + freed: unknown[] + released: string[] + posted: { hwnd: unknown; message: number }[] + registered: number + unregistered: number +} + +function comWorld(overrides: Partial = {}): ComWorld { + return { + coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0, + hasThreadDpi: true, enumThrows: false, + path: 'C:\\选中\\directory', + titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], + registered: 0, unregistered: 0, + ...overrides, + } +} + +/** Sentinel pointer objects standing in for native addresses. */ +interface FakePtr { kind: string; [key: string]: unknown } + +function installFakeKoffi(world: ComWorld): void { + const dialogPtr: FakePtr = { kind: 'dialog' } + const itemPtr: FakePtr = { kind: 'item' } + const namePtr: FakePtr = { kind: 'name', text: world.path } + const outBuffers = new Map() + + const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => { + if (self.kind === 'dialog') { + switch (slot) { + case 9: world.options.push(args[0] as number); return 0 + case 17: world.titles.push(args[0] as string); return 0 + case 3: return world.showHr + case 20: { + if (world.getResultHr < 0) return world.getResultHr + ;(args[0] as unknown[])[0] = itemPtr + return 0 + } + case 2: world.released.push('dialog'); return 0 + default: throw new Error(`unexpected dialog slot ${slot}`) + } + } + switch (slot) { + case 5: { + if (world.getDisplayNameHr < 0) return world.getDisplayNameHr + ;(args[1] as unknown[])[0] = namePtr + return 0 + } + case 2: world.released.push('item'); return 0 + default: throw new Error(`unexpected item slot ${slot}`) + } + } + + vi.doMock('koffi', () => ({ + default: { + load: (dll: string) => ({ + func: (_convention: string, name: string, _result: string, _args: string[]) => { + switch (name) { + case 'CoInitializeEx': return () => world.coInitHr + case 'CoCreateInstance': return (...args: unknown[]) => { + if (world.coCreateHr < 0) return world.coCreateHr + outBuffers.set(args[4], dialogPtr) + return 0 + } + case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) } + case 'GetCurrentThreadId': return () => 31337 + case 'SetThreadDpiAwarenessContext': { + if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`) + return (context: unknown) => { world.dpiContexts.push(context); return null } + } + case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => { + if (world.enumThrows) throw new Error('EnumThreadWindows refused') + callback.fn({ kind: 'hwnd', n: 1 }, lparam) + callback.fn({ kind: 'hwnd', n: 2 }, lparam) + return 1 + } + case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 } + default: throw new Error(`unexpected native import ${dll}/${name}`) + } + }, + }), + proto: (declaration: string) => ({ declaration }), + pointer: (type: unknown) => type, + register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, + unregister: () => { world.unregistered += 1 }, + decode: (value: unknown, offsetOrType: unknown): unknown => { + if (offsetOrType === 'str16') return (value as FakePtr).text + if (typeof offsetOrType === 'number') { + // Vtable slot read: hand back a callable-reference sentinel. + const owner = (value as { owner: FakePtr }).owner + return { call: (args: unknown[]) => dispatch(owner, offsetOrType / 8, args) } + } + // decode(x, 'void *'): out-buffer read or vtable read. + if (outBuffers.has(value)) return outBuffers.get(value) + return { owner: value as FakePtr } + }, + call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args), + }, + })) +} + +async function loadBindingsModule(): Promise { + return await import('../src/win32-dialog-bindings.ts') +} + +afterEach(() => { + vi.doUnmock('koffi') + vi.doUnmock('node:worker_threads') + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() +}) + +describe('loadWin32DialogBindings over the fake COM world', () => { + it('drives the full selection conversation with memory hygiene', async () => { + const world = comWorld() + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + const showing = vi.fn() + + expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4]) + expect(world.titles).toEqual(['选择工作区目录']) + expect(world.options).toHaveLength(1) + expect(showing).toHaveBeenCalledWith(31337) + expect(world.freed).toHaveLength(1) + expect(world.released).toEqual(['item', 'dialog']) + }) + + it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => { + const world = comWorld({ showHr: HRESULT_CANCELLED, hasThreadDpi: false, coInitHr: 1 }) + installFakeKoffi(world) + const { loadWin32DialogBindings } = await loadBindingsModule() + const bindings = await loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(world.dpiContexts).toEqual([]) + expect(world.released).toEqual(['dialog']) + }) + + it('surfaces creation and extraction failures as HRESULT errors', async () => { + const creationWorld = comWorld({ coCreateHr: E_FAIL }) + installFakeKoffi(creationWorld) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005') + + vi.doUnmock('koffi') + vi.resetModules() + const resultWorld = comWorld({ getResultHr: E_FAIL }) + installFakeKoffi(resultWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + expect(resultWorld.released).toEqual(['dialog']) + + vi.doUnmock('koffi') + vi.resetModules() + const nameWorld = comWorld({ getDisplayNameHr: E_FAIL }) + installFakeKoffi(nameWorld) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed') + // The shell item is released even when its display name cannot be read. + expect(nameWorld.released).toEqual(['item', 'dialog']) + expect(nameWorld.freed).toHaveLength(0) + }) +}) + +describe('closeThreadWindows over the fake COM world', () => { + it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => { + const world = comWorld() + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await closeThreadWindows(777) + expect(world.posted).toEqual([ + { hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE }, + { hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE }, + ]) + expect(world.registered).toBe(1) + expect(world.unregistered).toBe(1) + }) + + it('unregisters the callback even when the enumeration itself throws', async () => { + const world = comWorld({ enumThrows: true }) + installFakeKoffi(world) + const { closeThreadWindows } = await loadBindingsModule() + await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused') + expect(world.unregistered).toBe(1) + }) +}) + +describe('the worker entry over a mocked thread boundary', () => { + it('posts showing then done for a completed conversation', async () => { + const posted: unknown[] = [] + vi.doMock('node:worker_threads', () => ({ + parentPort: { postMessage: (message: unknown) => posted.push(message) }, + workerData: { title: 'Pick' }, + })) + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => ({ + setThreadDpiAwareness: () => undefined, + coInitializeSta: () => 0, + currentThreadId: () => 11, + createFolderDialog: () => ({ + setOptions: () => 0, + setTitle: () => 0, + show: () => 0, + resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }), + release: () => undefined, + }), + }), + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toEqual([ + { kind: 'showing', threadId: 11 }, + { kind: 'done', path: 'C:\\from-worker' }, + ]) + }) + + it('posts the failure message when the native surface cannot load', async () => { + const posted: { kind: string; message?: string }[] = [] + vi.doMock('node:worker_threads', () => ({ + parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, + workerData: { title: 'Pick' }, + })) + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw new Error('no ole32 here') }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted).toHaveLength(1) + expect(posted[0]?.kind).toBe('error') + expect(posted[0]?.message).toContain('no ole32 here') + }) + + it('stringifies stackless and non-Error failures', async () => { + const stackless = new Error('bare message') + delete stackless.stack + for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) { + vi.doUnmock('node:worker_threads') + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() + const posted: { kind: string; message?: string }[] = [] + vi.doMock('node:worker_threads', () => ({ + parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, + workerData: { title: 'Pick' }, + })) + vi.doMock('../src/win32-dialog-bindings.ts', () => ({ + loadWin32DialogBindings: async () => { throw thrown }, + })) + await import('../src/win32-dialog-worker.ts') + expect(posted[0]?.message).toBe(expected) + } + }) + + it('refuses to run outside a worker thread', async () => { + vi.doMock('node:worker_threads', () => ({ parentPort: null, workerData: undefined })) + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a worker thread') + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts new file mode 100644 index 0000000000..c24a79ebc1 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts @@ -0,0 +1,90 @@ +/** + * The COM conversation's sequencing against fake bindings: outcome mapping + * (selection / cancellation / HRESULT failures at every step) and the + * release-on-every-path guarantee, all platform-independent. + */ + +import { describe, expect, it, vi } from 'vitest' +import { + FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED, + runFolderDialog, type Win32DialogBindings, type Win32FolderDialog, +} from '../src/win32-dialog-logic.ts' + +const E_FAIL = 0x80004005 | 0 + +interface FakeWorld { + bindings: Win32DialogBindings + dpi: ReturnType + createDialog: ReturnType + dialog: { + setOptions: ReturnType + setTitle: ReturnType + show: ReturnType + resultPath: ReturnType + release: ReturnType + } +} + +function world(overrides: Partial = {}, coInit = 0): FakeWorld { + const dialog = { + setOptions: vi.fn(() => 0), + setTitle: vi.fn(() => 0), + show: vi.fn(() => 0), + resultPath: vi.fn(() => ({ hr: 0, path: 'C:\\picked\\目录' })), + release: vi.fn(), + ...overrides, + } + const dpi = vi.fn() + const createDialog = vi.fn(() => dialog) + const bindings: Win32DialogBindings = { + setThreadDpiAwareness: dpi, + coInitializeSta: vi.fn(() => coInit), + createFolderDialog: createDialog, + currentThreadId: vi.fn(() => 4242), + } + return { bindings, dpi, createDialog, dialog: dialog as FakeWorld['dialog'] } +} + +describe('runFolderDialog', () => { + it('sequences DPI, STA, options, title, show, and result extraction', () => { + const { bindings, dpi, dialog } = world() + const showing = vi.fn() + expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录') + expect(dpi).toHaveBeenCalledOnce() + expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR) + expect(dialog.setTitle).toHaveBeenCalledWith('Pick') + expect(showing).toHaveBeenCalledWith(4242) + expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number) + expect(dialog.release).toHaveBeenCalledOnce() + }) + + it('maps the cancelled HRESULT to null and still releases the dialog', () => { + const { bindings, dialog } = world({ show: vi.fn(() => HRESULT_CANCELLED) }) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() + expect(dialog.resultPath).not.toHaveBeenCalled() + expect(dialog.release).toHaveBeenCalledOnce() + }) + + it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => { + const { bindings } = world({}, 1) + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录') + }) + + it('throws on a failing CoInitializeEx without creating a dialog', () => { + const { bindings, createDialog } = world({}, E_FAIL) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005') + expect(createDialog).not.toHaveBeenCalled() + }) + + it.each([ + ['SetOptions', { setOptions: vi.fn(() => E_FAIL) }], + ['SetTitle', { setTitle: vi.fn(() => E_FAIL) }], + ['Show', { show: vi.fn(() => E_FAIL) }], + ['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }], + ] satisfies [string, Partial][])('releases the dialog when %s fails', (what, overrides) => { + const { bindings, dialog } = world(overrides) + expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`) + expect(dialog.release).toHaveBeenCalledOnce() + void bindings + }) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts new file mode 100644 index 0000000000..33e2fa60a1 --- /dev/null +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -0,0 +1,136 @@ +/** + * Driver tests: the worker message protocol mapped onto the promise, the + * WM_CLOSE abort service (including the show-race retry and the terminate + * last resort) against fakes, plus the real spawn plumbing — POSIX hosts + * prove the default path rejects cleanly (koffi cannot load ole32 there), + * and win32 hosts briefly open and auto-abort a real dialog. + */ + +import { EventEmitter } from 'node:events' +import { describe, expect, it, vi } from 'vitest' +import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +class FakeWorker extends EventEmitter implements Win32DialogWorkerLike { + terminate = vi.fn(async () => 0) + post(message: Win32DialogWorkerMessage): void { + this.emit('message', message) + } +} + +interface Harness { + worker: FakeWorker + internals: Win32DialogInternals + close: ReturnType +} + +function harness(overrides: Partial = {}): Harness { + const worker = new FakeWorker() + const close = vi.fn(async () => undefined) + return { + worker, + close, + internals: { spawnWorker: () => worker, closeThreadWindows: close, closeRetryMs: 1, ...overrides }, + } +} + +const live = (): AbortSignal => new AbortController().signal + +describe('pickWin32Directory', () => { + it('resolves the selected path and the cancellation null', async () => { + const first = harness() + const picked = pickWin32Directory(live(), first.internals) + first.worker.post({ kind: 'showing', threadId: 7 }) + first.worker.post({ kind: 'done', path: 'C:\\picked' }) + await expect(picked).resolves.toBe('C:\\picked') + expect(first.close).not.toHaveBeenCalled() + + const second = harness() + const cancelled = pickWin32Directory(live(), second.internals) + second.worker.post({ kind: 'done', path: null }) + await expect(cancelled).resolves.toBeNull() + }) + + it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { + const reported = harness() + const failing = pickWin32Directory(live(), reported.internals) + reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' }) + await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed') + + const crashed = harness() + const crashing = pickWin32Directory(live(), crashed.internals) + crashed.worker.emit('error', new Error('worker blew up')) + await expect(crashing).rejects.toThrow('worker blew up') + + const silent = harness() + const exiting = pickWin32Directory(live(), silent.internals) + silent.worker.emit('exit', 0) + await expect(exiting).rejects.toThrow('exited before reporting a result') + }) + + it('settles once: a late exit after the result is inert', async () => { + const { worker, internals } = harness() + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'done', path: 'C:\\once' }) + worker.emit('exit', 0) + await expect(picked).resolves.toBe('C:\\once') + }) + + it('throws immediately on an already-aborted signal without spawning', async () => { + const spawnWorker = vi.fn() + const controller = new AbortController() + controller.abort() + await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined })) + .rejects.toThrow('native directory picker aborted') + expect(spawnWorker).not.toHaveBeenCalled() + }) + + it('services an abort by closing the dialog thread windows until the worker reports', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = pickWin32Directory(controller.signal, internals) + worker.post({ kind: 'showing', threadId: 99 }) + controller.abort() + await vi.waitFor(() =>{ expect(close).toHaveBeenCalledWith(99) }) + worker.post({ kind: 'done', path: null }) + await expect(picked).rejects.toThrow('native directory picker aborted') + }) + + it('starts the close service on the showing notice when the abort came first', async () => { + const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) + const { worker, internals } = harness({ closeThreadWindows: closeFailures }) + const controller = new AbortController() + const picked = pickWin32Directory(controller.signal, internals) + controller.abort() + expect(closeFailures).not.toHaveBeenCalled() + worker.post({ kind: 'showing', threadId: 12 }) + await vi.waitFor(() =>{ expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) + worker.post({ kind: 'done', path: null }) + await expect(picked).rejects.toThrow('native directory picker aborted') + }) + + it('terminates an unresponsive worker after the close budget', async () => { + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = pickWin32Directory(controller.signal, internals) + worker.post({ kind: 'showing', threadId: 5 }) + controller.abort() + await expect(picked).rejects.toThrow('dialog unresponsive; worker terminated') + expect(worker.terminate).toHaveBeenCalledOnce() + expect(close.mock.calls.length).toBeGreaterThan(10) + }) + + // POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped + // worker spawns, loads koffi, fails to load ole32.dll, and reports the error. + it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => { + await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed') + }, 30_000) + + // win32 hosts run the true COM smoke instead: a real dialog opens briefly + // and the abort service closes it (the same lever a disconnecting client pulls). + it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { + const controller = new AbortController() + setTimeout(() =>{ controller.abort() }, 400) + await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 4f280f8112..3ab92526e4 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -1,3 +1,18 @@ import { clientBundle } from '../../client/tsdown.client.ts' -export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']) +// The Win32 dialog worker builds as its own CJS entry (mirroring +// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining +// the dialog logic while koffi stays an external native require. +export default [ + ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), + { + entry: ['lib/types/win32-dialog-worker.js'], + outDir: 'lib', + format: ['cjs'] as ['cjs'], + platform: 'node' as const, + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, + }, +] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e1225f57f..ef0c42656d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3643,6 +3643,9 @@ importers: '@deepseek-ai/dsh-native-command': specifier: workspace:^ version: link:../../util/native-command + koffi: + specifier: ^3.1.0 + version: 3.1.1 devDependencies: '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -3665,6 +3668,9 @@ importers: react: specifier: ^18.2.0 version: 18.3.1 + tsx: + specifier: ^4.19.2 + version: 4.22.4 packages/host/webserver: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 2909fb7459..58d4a9837f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -73,6 +73,10 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ + // Spawns a nested worker that blocks in a native modal dialog on win32; + // under the threads pool the dialog thread outlives the test worker and + // wedges pool teardown, while a fork contains it. + 'packages/host/directory-picker-native/tests/win32-dialog.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', From 8e0880e30341d56ede8158fe69baa16805895852 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 00:11:23 +0800 Subject: [PATCH 05/17] test(picker): attach abort expectations before driving the close-budget race On a fast host the 1ms close budget can exhaust and reject between waitFor ticks; a rejection with no listener yet counted as an unhandled error in the Linux run. --- .../tests/win32-dialog.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 33e2fa60a1..e3ea00d4a1 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -88,25 +88,29 @@ describe('pickWin32Directory', () => { it('services an abort by closing the dialog thread windows until the worker reports', async () => { const { worker, internals, close } = harness() const controller = new AbortController() - const picked = pickWin32Directory(controller.signal, internals) + // Attach the expectation BEFORE driving the race: on a fast host the + // close budget can exhaust (and reject) between waitFor ticks, and a + // rejection with no listener yet would count as unhandled. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') worker.post({ kind: 'showing', threadId: 99 }) controller.abort() await vi.waitFor(() =>{ expect(close).toHaveBeenCalledWith(99) }) worker.post({ kind: 'done', path: null }) - await expect(picked).rejects.toThrow('native directory picker aborted') + await picked }) it('starts the close service on the showing notice when the abort came first', async () => { const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) const { worker, internals } = harness({ closeThreadWindows: closeFailures }) const controller = new AbortController() - const picked = pickWin32Directory(controller.signal, internals) + // Attached before the race for the same unhandled-rejection reason above. + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') controller.abort() expect(closeFailures).not.toHaveBeenCalled() worker.post({ kind: 'showing', threadId: 12 }) await vi.waitFor(() =>{ expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) worker.post({ kind: 'done', path: null }) - await expect(picked).rejects.toThrow('native directory picker aborted') + await picked }) it('terminates an unresponsive worker after the close budget', async () => { From fed3149ac4a9937ceff105be75945be81828b366 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 00:41:47 +0800 Subject: [PATCH 06/17] fix(picker): ship the dialog worker as the constrained ./worker artifact The workspace files constraint keys worker bundles on the ./worker export (lib/worker.cjs, the workflow-workerthread shape); the descriptive source entry stays win32-dialog-worker.ts and tsdown renames the bundle. --- packages/host/directory-picker-native/package.json | 6 +++++- .../host/directory-picker-native/src/win32-dialog-host.ts | 2 +- packages/host/directory-picker-native/tsdown.config.ts | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 49033cdcf4..72e1d1be45 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -19,14 +19,18 @@ "types": "./lib/types/client/index.d.ts", "default": "./lib/client.js" }, + "./worker": { + "types": "./lib/types/win32-dialog-worker.d.ts", + "default": "./lib/worker.cjs" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/worker.cjs", "lib/client.js", - "lib/win32-dialog-worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index cfaf07cc46..781cbc4b24 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -20,7 +20,7 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ if (!import.meta.url.endsWith('.ts')) { - return new Worker(fileURLToPath(new URL('./win32-dialog-worker.cjs', import.meta.url)), { workerData: data }) + return new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)), { workerData: data }) } const workerEntry = new URL('./win32-dialog-worker.ts', import.meta.url) const bootstrap = [ diff --git a/packages/host/directory-picker-native/tsdown.config.ts b/packages/host/directory-picker-native/tsdown.config.ts index 3ab92526e4..529fb7b2ac 100644 --- a/packages/host/directory-picker-native/tsdown.config.ts +++ b/packages/host/directory-picker-native/tsdown.config.ts @@ -6,7 +6,9 @@ import { clientBundle } from '../../client/tsdown.client.ts' export default [ ...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']), { - entry: ['lib/types/win32-dialog-worker.js'], + // The artifact is lib/worker.cjs (the ./worker export the workspace + // constraint keys on), bundled from the descriptive source entry. + entry: { worker: 'lib/types/win32-dialog-worker.js' }, outDir: 'lib', format: ['cjs'] as ['cjs'], platform: 'node' as const, From 8500a2165823de9c9f4332c98ae5a9010a384ef1 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 20:40:10 +0800 Subject: [PATCH 07/17] fix(picker): pointer-width vtable offsets, COM apartment pairing, unconditional abort budget, and the full failure chain Review round two on the in-process dialog: - Vtable slots and out-pointers use koffi.sizeof('void *') instead of a hardcoded 8 - win32-ia32 (which Node and koffi both ship) would have read method pointers from the wrong address and crashed in-process before any fallback could run. - runFolderDialog pairs every successful (incl. S_FALSE) CoInitializeEx with CoUninitialize in the outermost finally, releasing the dialog first; a failed init is deliberately unpaired. Pinned across fake-bindings and mocked-koffi suites. - The abort close budget starts unconditionally: a worker hung before the showing notice (koffi import or COM init) now ends in terminate instead of a dangling promise; WM_CLOSE posting still waits for the thread id. - A triple miss (dialog + pwsh + 5.1) surfaces an AggregateError carrying all three causes - the in-process tier's reason was previously unrecoverable from the final PowerShell error. - The stray '=>{ ' formatter artifacts are normalized to real blocks. Both stale note claims from the review are fixed: the DPI note's Consequences no longer claims an ENOENT classification or zero new dependencies, and the 2026-07-27 picker note's Windows bullet now names the in-process primary and keeps the PowerShell chain as fallback (both languages, pairings re-recorded). --- ...26-08-01-windows-picker-pwsh-dpi.i18n.yaml | 4 +- .../2026-08-01-windows-picker-pwsh-dpi.md | 2 +- .../2026-08-01-windows-picker-pwsh-dpi.zh.md | 2 +- ...ative-workspace-directory-picker.i18n.yaml | 4 +- ...07-27-native-workspace-directory-picker.md | 4 +- ...27-native-workspace-directory-picker.zh.md | 4 +- .../src/native-picker.ts | 19 +++++++++- .../src/win32-dialog-bindings.ts | 12 +++++- .../src/win32-dialog-logic.ts | 34 +++++++++++------ .../src/win32-dialog-worker.ts | 4 +- .../src/win32-dialog.ts | 37 ++++++++++++++----- .../tests/native-picker.spec.ts | 12 +++++- .../tests/win32-dialog-bindings.spec.ts | 8 +++- .../tests/win32-dialog-logic.spec.ts | 27 +++++++++----- .../tests/win32-dialog.spec.ts | 24 ++++++++++-- 15 files changed, 147 insertions(+), 50 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml index d9f46441ac..9a86e1f729 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.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-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: 1d9fd0a1b445a77b478f033d56169d6166c2b1bf -2026-08-01-windows-picker-pwsh-dpi.zh.md: 245991e7c8d081f3c91724c0dae1142d80883c1d +2026-08-01-windows-picker-pwsh-dpi.md: a941d5ea6e150d74fa2fa4dbd93b7e6b58a78eff +2026-08-01-windows-picker-pwsh-dpi.zh.md: 8383240c219d701aa9a8728cf1a7fb7f3a7c5433 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md index 1d9fd0a1b4..a941d5ea6e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md @@ -22,5 +22,5 @@ The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog ## Consequences - Machines with PowerShell 7 get the modern folder picker; 5.1-only machines keep the legacy tree — now sharp — and the package README's Known Limitations documents the gap. -- No new packages or runtime dependencies; the fallback reuses the existing `ENOENT` classification and abort propagation. +- The PowerShell chain itself adds no packages or dependencies (koffi and tsx arrived with the in-process primary and belong to its note); the pwsh→5.1 hop triggers on ANY non-abort pwsh failure — no `ENOENT` classification remains on the win32 path — while abort propagation is unchanged. - The command boundary (`DirectoryPickerRunner`) pins the spawn order and script content in unit tests; real dialog rendering remains a manual Windows check, as before. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md index 245991e7c8..8383240c21 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md @@ -22,5 +22,5 @@ PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程 ## 后果 - 装有 PowerShell 7 的机器获得现代文件夹选择器;只有 5.1 的机器保留旧版树——但现在清晰了——包 README 的已知限制记录了该差距。 -- 无新增包或运行时依赖;回退复用既有的 `ENOENT` 分类与中止传播。 +- PowerShell 链本身不新增任何包或依赖(koffi 与 tsx 随进程内主层引入,归属其 Note);pwsh→5.1 的跳转在 pwsh 的任何非中止失败上触发——win32 路径上已不存在 `ENOENT` 分类——中止传播不变。 - 命令边界(`DirectoryPickerRunner`)在单元测试中固定启动顺序与脚本内容;真实对话框渲染仍与以前一样属于手动 Windows 检查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index 12cb856fe2..e177c663cc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: c18b4263d4e97290d69ac229e7423558bdb4c3b1 -2026-07-27-native-workspace-directory-picker.zh.md: 7267516d7eea4b3cfecc2ca8f18305896eaffede +2026-07-27-native-workspace-directory-picker.md: 45fa77b5519179e006f9109846a1602e6e22a6e2 +2026-07-27-native-workspace-directory-picker.zh.md: 2d6800d20b1f0dfe0b20ac9a5c90037599ece32a diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index c18b4263d4..45fa77b551 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -27,10 +27,10 @@ The workspace manager must upsert the returned workspace before the selection ca The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process. -Platform adapters invoke native tools without a shell: +Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: `pwsh` (PowerShell 7) in STA mode with a Windows PowerShell 5.1 fallback, always DPI-aware ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). +- Windows: the in-process koffi `IFileOpenDialog` worker with per-monitor-v2 DPI ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the PowerShell chain (`pwsh` in STA mode, then Windows PowerShell 5.1, both DPI-corrected) remains the fallback ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index 7267516d7e..2d6800d20b 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell,直接调用原生工具: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是进程内 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:采用 STA 模式的 `pwsh`(PowerShell 7),并以 Windows PowerShell 5.1 回退,且始终 DPI aware(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 +- Windows:进程内 koffi `IFileOpenDialog` worker,带 per-monitor-v2 DPI(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));PowerShell 链(STA 模式的 `pwsh`,再到 Windows PowerShell 5.1,均已修正 DPI)保留为回退(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 6cabf44ddb..4b4d75fe32 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -72,10 +72,12 @@ export async function pickNativeDirectory( // support. Any non-abort failure (koffi unavailable, ancient Windows, COM // refusal) falls back to the PowerShell chain below. const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory + let dialogError: unknown try { return await pickDialog(signal) } catch (error: unknown) { rethrowIfAborted(signal, error) + dialogError = error } // PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder @@ -100,14 +102,27 @@ export async function pickNativeDirectory( ' [Console]::WriteLine($dialog.SelectedPath)', '}', ].join('; ') + let pwshError: unknown try { const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal) return outputPath(result.stdout) } catch (error: unknown) { rethrowIfAborted(signal, error) + pwshError = error + } + try { + const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) + return outputPath(result.stdout) + } catch (error: unknown) { + rethrowIfAborted(signal, error) + // Triple miss: every tier failed. Surface all three causes — the + // in-process dialog's reason is otherwise unrecoverable from the last + // PowerShell error alone. + throw new AggregateError( + [dialogError, pwshError, error], + 'native directory picker failed: the in-process dialog and both PowerShell hosts failed', + ) } - const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) } if (platform === 'linux') { diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index a9b625812c..dc797a712e 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -23,6 +23,7 @@ interface Koffi { decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown register(fn: (...args: unknown[]) => unknown, type: unknown): unknown unregister(callback: unknown): void + sizeof(type: string): number } const COINIT_APARTMENTTHREADED = 0x2 @@ -68,7 +69,11 @@ export async function loadWin32DialogBindings(): Promise { const user32 = koffi.load('user32.dll') const kernel32 = koffi.load('kernel32.dll') + // Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64, + // 4 on ia32 — koffi reports the running process's width. + const pointerSize = koffi.sizeof('void *') const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32']) + const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', []) const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *']) const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *']) const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) @@ -83,7 +88,7 @@ export async function loadWin32DialogBindings(): Promise { /** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */ const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => { const vtable = koffi.decode(self, 'void *') - const fn = koffi.decode(vtable, slot * 8, 'void *') + const fn = koffi.decode(vtable, slot * pointerSize, 'void *') return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number } @@ -99,9 +104,12 @@ export async function loadWin32DialogBindings(): Promise { } }, coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, + coUninitialize: () => { + coUninitialize() + }, currentThreadId: () => getCurrentThreadId() as number, createFolderDialog: (): Win32FolderDialog => { - const out = Buffer.alloc(8) + const out = Buffer.alloc(pointerSize) const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`) const dialog = koffi.decode(out, 'void *') diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts index cba0ca9f24..65be1149dc 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-logic.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -59,6 +59,12 @@ export interface Win32DialogBindings { * @returns the call's HRESULT (`S_FALSE` re-entry is still a success). */ coInitializeSta(): number + /** + * `CoUninitialize` on the calling thread — COM requires one pairing call + * for every successful (including `S_FALSE`) `CoInitializeEx`, even on a + * thread that exits right after the conversation. + */ + coUninitialize(): void /** * `CoCreateInstance(CLSID_FileOpenDialog)`. * @returns the created dialog surface; throws when creation fails. @@ -100,18 +106,24 @@ export function runFolderDialog( ): string | null { bindings.setThreadDpiAwareness() check(bindings.coInitializeSta(), 'CoInitializeEx') - const dialog = bindings.createFolderDialog() + // From here the apartment is initialized (S_OK or S_FALSE) and must be + // uninitialized exactly once on every path. try { - check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions') - check(dialog.setTitle(title), 'SetTitle') - onShowing(bindings.currentThreadId()) - const shown = dialog.show() - if (shown === HRESULT_CANCELLED) return null - check(shown, 'Show') - const result = dialog.resultPath() - check(result.hr, 'GetResult') - return result.path as string + const dialog = bindings.createFolderDialog() + try { + check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions') + check(dialog.setTitle(title), 'SetTitle') + onShowing(bindings.currentThreadId()) + const shown = dialog.show() + if (shown === HRESULT_CANCELLED) return null + check(shown, 'Show') + const result = dialog.resultPath() + check(result.hr, 'GetResult') + return result.path as string + } finally { + dialog.release() + } } finally { - dialog.release() + bindings.coUninitialize() } } diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts index 2e4ef64f6c..e978d305f3 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-worker.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -28,7 +28,9 @@ const { title } = workerData as Win32DialogWorkerData void (async () => { try { const bindings = await loadWin32DialogBindings() - const path = runFolderDialog(bindings, title, (threadId) =>{ port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) }) + const path = runFolderDialog(bindings, title, (threadId) => { + port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) + }) port.postMessage({ kind: 'done', path } satisfies Win32DialogWorkerMessage) } catch (error: unknown) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error) diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index 40b31e8d57..b97c3659fe 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -81,11 +81,20 @@ export async function pickWin32Directory( outcome() } + const postClose = (): void => { + // Before `showing` there is no window to close; the budget below still + // runs so a worker that never reports cannot dangle the pick. + if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) + } + + // Sole caller: the once-registered abort listener, so no re-entry guard. const serviceAbort = (): void => { let attempts = 0 // The `showing` notice precedes the blocking `Show`, so the very first // WM_CLOSE can race the window's creation; re-post until the worker - // reports back, then force-terminate as a last resort. + // reports back, then force-terminate as a last resort. The budget is + // unconditional — an abort before `showing` (worker hung in koffi or + // COM init) still ends in terminate instead of a dangling promise. closeTimer = setInterval(() => { attempts += 1 if (attempts > CLOSE_MAX_ATTEMPTS) { @@ -95,14 +104,13 @@ export async function pickWin32Directory( }) return } - void closeWindows(dialogThreadId as number).catch(() => undefined) + postClose() }, closeRetryMs) - void closeWindows(dialogThreadId as number).catch(() => undefined) + postClose() } const onAbort = (): void => { - if (dialogThreadId !== undefined) serviceAbort() - // Not shown yet: the `showing` handler below starts the service loop. + serviceAbort() } signal.addEventListener('abort', onAbort, { once: true }) @@ -110,7 +118,8 @@ export async function pickWin32Directory( switch (message.kind) { case 'showing': dialogThreadId = message.threadId - if (signal.aborted) serviceAbort() + // An abort that raced ahead of this notice now has a window to hit. + if (signal.aborted) postClose() return case 'done': settle(() => { @@ -119,10 +128,20 @@ export async function pickWin32Directory( }) return case 'error': - settle(() =>{ reject(new Error(`win32 folder dialog failed: ${message.message}`)) }) + settle(() => { + reject(new Error(`win32 folder dialog failed: ${message.message}`)) + }) } }) - worker.on('error', (error: Error) =>{ settle(() =>{ reject(error) }) }) - worker.on('exit', () =>{ settle(() =>{ reject(new Error('win32 folder dialog worker exited before reporting a result')) }) }) + worker.on('error', (error: Error) => { + settle(() => { + reject(error) + }) + }) + worker.on('exit', () => { + settle(() => { + reject(new Error('win32 folder dialog worker exited before reporting a result')) + }) + }) }) } diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 7c51d552d4..64732e4e17 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -97,10 +97,16 @@ describe('native directory picker', () => { .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull() + // Triple miss: the surfaced AggregateError carries all three causes, + // including the otherwise-lost in-process dialog failure. const failed = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) .mockRejectedValueOnce(failure(2)) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog })).rejects.toThrow('command failed') + const tripleMiss = await pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) + expect(tripleMiss.message).toContain('the in-process dialog and both PowerShell hosts failed') + expect((tripleMiss.errors[0] as Error).message).toBe('dialog unavailable') + expect((tripleMiss.errors[2] as Error).message).toContain('command failed') }) it('wires the real Win32 dialog as the default tier', async () => { @@ -153,7 +159,9 @@ describe('native directory picker', () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { callback(commandError, 'partial output', 'failure details') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).rejects.toMatchObject({ + const surfaced = await pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) + expect(surfaced.errors[2]).toMatchObject({ message: 'powershell failed', cause: commandError, code: 7, stdout: 'partial output', stderr: 'failure details', }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index d23b43011a..799c24a062 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -31,6 +31,7 @@ interface ComWorld { posted: { hwnd: unknown; message: number }[] registered: number unregistered: number + uninitialized: number } function comWorld(overrides: Partial = {}): ComWorld { @@ -39,7 +40,7 @@ function comWorld(overrides: Partial = {}): ComWorld { hasThreadDpi: true, enumThrows: false, path: 'C:\\选中\\directory', titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], - registered: 0, unregistered: 0, + registered: 0, unregistered: 0, uninitialized: 0, ...overrides, } } @@ -85,6 +86,7 @@ function installFakeKoffi(world: ComWorld): void { func: (_convention: string, name: string, _result: string, _args: string[]) => { switch (name) { case 'CoInitializeEx': return () => world.coInitHr + case 'CoUninitialize': return () => { world.uninitialized += 1 } case 'CoCreateInstance': return (...args: unknown[]) => { if (world.coCreateHr < 0) return world.coCreateHr outBuffers.set(args[4], dialogPtr) @@ -109,6 +111,7 @@ function installFakeKoffi(world: ComWorld): void { }), proto: (declaration: string) => ({ declaration }), pointer: (type: unknown) => type, + sizeof: (type: string) => { void type; return 8 }, register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, unregister: () => { world.unregistered += 1 }, decode: (value: unknown, offsetOrType: unknown): unknown => { @@ -153,6 +156,7 @@ describe('loadWin32DialogBindings over the fake COM world', () => { expect(showing).toHaveBeenCalledWith(31337) expect(world.freed).toHaveLength(1) expect(world.released).toEqual(['item', 'dialog']) + expect(world.uninitialized).toBe(1) }) it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => { @@ -163,6 +167,7 @@ describe('loadWin32DialogBindings over the fake COM world', () => { expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() expect(world.dpiContexts).toEqual([]) expect(world.released).toEqual(['dialog']) + expect(world.uninitialized).toBe(1) }) it('surfaces creation and extraction failures as HRESULT errors', async () => { @@ -225,6 +230,7 @@ describe('the worker entry over a mocked thread boundary', () => { loadWin32DialogBindings: async () => ({ setThreadDpiAwareness: () => undefined, coInitializeSta: () => 0, + coUninitialize: () => undefined, currentThreadId: () => 11, createFolderDialog: () => ({ setOptions: () => 0, diff --git a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts index c24a79ebc1..c214245de6 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts @@ -16,6 +16,7 @@ interface FakeWorld { bindings: Win32DialogBindings dpi: ReturnType createDialog: ReturnType + uninitialize: ReturnType dialog: { setOptions: ReturnType setTitle: ReturnType @@ -36,21 +37,25 @@ function world(overrides: Partial = {}, coInit = 0): FakeWorl } const dpi = vi.fn() const createDialog = vi.fn(() => dialog) + const uninitialize = vi.fn() const bindings: Win32DialogBindings = { setThreadDpiAwareness: dpi, coInitializeSta: vi.fn(() => coInit), + coUninitialize: uninitialize, createFolderDialog: createDialog, currentThreadId: vi.fn(() => 4242), } - return { bindings, dpi, createDialog, dialog: dialog as FakeWorld['dialog'] } + return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] } } describe('runFolderDialog', () => { - it('sequences DPI, STA, options, title, show, and result extraction', () => { - const { bindings, dpi, dialog } = world() + it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => { + const { bindings, dpi, dialog, uninitialize } = world() const showing = vi.fn() expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录') expect(dpi).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() + expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number) expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR) expect(dialog.setTitle).toHaveBeenCalledWith('Pick') expect(showing).toHaveBeenCalledWith(4242) @@ -58,11 +63,12 @@ describe('runFolderDialog', () => { expect(dialog.release).toHaveBeenCalledOnce() }) - it('maps the cancelled HRESULT to null and still releases the dialog', () => { - const { bindings, dialog } = world({ show: vi.fn(() => HRESULT_CANCELLED) }) + it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => { + const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) }) expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() expect(dialog.resultPath).not.toHaveBeenCalled() expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() }) it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => { @@ -70,10 +76,12 @@ describe('runFolderDialog', () => { expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录') }) - it('throws on a failing CoInitializeEx without creating a dialog', () => { - const { bindings, createDialog } = world({}, E_FAIL) + it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => { + const { bindings, createDialog, uninitialize } = world({}, E_FAIL) expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005') expect(createDialog).not.toHaveBeenCalled() + // A failed CoInitializeEx must NOT be paired with CoUninitialize. + expect(uninitialize).not.toHaveBeenCalled() }) it.each([ @@ -81,10 +89,11 @@ describe('runFolderDialog', () => { ['SetTitle', { setTitle: vi.fn(() => E_FAIL) }], ['Show', { show: vi.fn(() => E_FAIL) }], ['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }], - ] satisfies [string, Partial][])('releases the dialog when %s fails', (what, overrides) => { - const { bindings, dialog } = world(overrides) + ] satisfies [string, Partial][])('releases the dialog and apartment when %s fails', (what, overrides) => { + const { bindings, dialog, uninitialize } = world(overrides) expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`) expect(dialog.release).toHaveBeenCalledOnce() + expect(uninitialize).toHaveBeenCalledOnce() void bindings }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index e3ea00d4a1..598386b1a7 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -94,7 +94,9 @@ describe('pickWin32Directory', () => { const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') worker.post({ kind: 'showing', threadId: 99 }) controller.abort() - await vi.waitFor(() =>{ expect(close).toHaveBeenCalledWith(99) }) + await vi.waitFor(() => { + expect(close).toHaveBeenCalledWith(99) + }) worker.post({ kind: 'done', path: null }) await picked }) @@ -108,11 +110,25 @@ describe('pickWin32Directory', () => { controller.abort() expect(closeFailures).not.toHaveBeenCalled() worker.post({ kind: 'showing', threadId: 12 }) - await vi.waitFor(() =>{ expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) + await vi.waitFor(() => { + expect(closeFailures.mock.calls.length).toBeGreaterThan(1) + }) worker.post({ kind: 'done', path: null }) await picked }) + it('terminates a worker that never reports showing after an abort', async () => { + // The budget runs without a thread id (nothing to WM_CLOSE yet), so a + // worker hung before `showing` cannot dangle the pick. + const { worker, internals, close } = harness() + const controller = new AbortController() + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker terminated') + controller.abort() + await picked + expect(worker.terminate).toHaveBeenCalledOnce() + expect(close).not.toHaveBeenCalled() + }) + it('terminates an unresponsive worker after the close budget', async () => { const { worker, internals, close } = harness() const controller = new AbortController() @@ -134,7 +150,9 @@ describe('pickWin32Directory', () => { // and the abort service closes it (the same lever a disconnecting client pulls). it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => { const controller = new AbortController() - setTimeout(() =>{ controller.abort() }, 400) + setTimeout(() => { + controller.abort() + }, 400) await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted') }, 30_000) }) From e182f032309b5b0ea238e63888f150a2f34bc48a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 21:42:05 +0800 Subject: [PATCH 08/17] fix(picker): cascade thread DPI contexts and harden the round-three review points - setThreadDpiAwareness checks SetThreadDpiAwarenessContext's return value and cascades per-monitor-v2 -> per-monitor -> system-aware; DPI stays a deliberate cosmetic best-effort - a host accepting none (or lacking the API, pre-1607) still gets the modern dialog instead of a downgrade to the legacy fallback chain over a cosmetic concern. - The mocked-koffi world now uses a distinctive 4-byte pointer width and rejects mis-sized out-buffers and mis-divided vtable offsets, so a regression to hardcoded 8s fails the suite (the ia32 bug class). - A keyless built-worker e2e guard loads lib/worker.cjs under plain worker_threads on POSIX (the workflow-workerthread shape). - The 'loaded lazily' module claims are reworded to attribute laziness to the dynamic import('koffi') calls, and the discarded close-attempt rejection is named at its catch. --- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 +- ...26-08-02-win32-in-process-folder-dialog.md | 4 +- ...08-02-win32-in-process-folder-dialog.zh.md | 4 +- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../src/win32-dialog-bindings.ts | 34 ++++++++--- .../src/win32-dialog-host.ts | 9 +-- .../src/win32-dialog-logic.ts | 9 ++- .../src/win32-dialog.ts | 4 +- .../tests/built-worker.e2e.ts | 31 ++++++++++ .../tests/win32-dialog-bindings.spec.ts | 56 ++++++++++++++++--- 12 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 packages/host/directory-picker-native/tests/built-worker.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index a3bdfc2a97..656ee92a65 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: fa896d198913f58b22f9186696daec27026bb50f -2026-08-02-win32-in-process-folder-dialog.zh.md: 31077d8d6a3907d955180fda290f92c4cf41e5b9 +2026-08-02-win32-in-process-folder-dialog.md: 96bc213ea7cddef6223aa3be69e56688dc9c4724 +2026-08-02-win32-in-process-folder-dialog.zh.md: a3dfad2ef73cb28ae8c4c47b6380740aab6de5c3 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index fa896d1989..96bc213ea7 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -10,7 +10,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into per-monitor-v2 DPI (`SetThreadDpiAwarenessContext`), a strict upgrade over the script's system-DPI ceiling. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. ## Alternatives considered @@ -21,6 +21,6 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Consequences -- Every Windows machine gets the modern dialog with per-monitor-v2 DPI, PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. - Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. - The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is an in-process crash risk contained to the worker thread and caught by the win32 smoke before shipping. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index 31077d8d6a..a3dfad2ef7 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -10,7 +10,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用 per-monitor-v2 DPI(`SetThreadDpiAwarenessContext`),严格优于脚本的系统 DPI 上限。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 ## 考虑过的替代方案 @@ -21,6 +21,6 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 后果 -- 每台 Windows 机器都得到带 per-monitor-v2 DPI 的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 - 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 - 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是被限制在 worker 线程内的进程内崩溃风险,并在交付前被 win32 冒烟捕获。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index acf7f85d88..60f534e83b 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: 0d0fe8d3a049d6fbc47eee314f9782352651247d -README.zh.md: 82f51976afe2e57699c1bd62d11142106b082e9b +README.md: d48622dead56cce842e0bef0207079ff85b22588 +README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0d0fe8d3a0..d48622dead 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with per-monitor-v2 DPI awareness, aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 82f51976af..33cb11b4e7 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,带 per-monitor-v2 DPI 感知,中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index dc797a712e..03980af2a4 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -1,9 +1,10 @@ /** * koffi-backed Win32 bindings for the folder dialog: the COM vtable calls * behind {@link Win32DialogBindings} plus the cross-thread window closer the - * driver uses to service aborts. Loaded lazily and only on win32 (the dialog - * worker and the driver's abort path), so non-Windows processes never load - * koffi — the same containment as the repo's other `win32.ts` modules. + * driver uses to service aborts. The module loads on every platform; koffi + * itself is imported lazily inside each function, so non-Windows processes + * never load it — the same containment as the repo's other `win32.ts` + * modules. * * The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and * IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is @@ -29,7 +30,14 @@ interface Koffi { const COINIT_APARTMENTTHREADED = 0x2 const CLSCTX_INPROC_SERVER = 0x1 const SIGDN_FILESYSPATH = 0x80058000 | 0 -const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 +/** + * Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10 + * 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext` + * returns NULL for an unsupported context instead of throwing, so the caller + * cascades to the best one the host accepts; DPI stays a cosmetic + * best-effort — an unsupported host still gets the modern dialog. + */ +const DPI_AWARENESS_CONTEXTS = [-4, -3, -2] const WM_CLOSE = 0x10 /** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */ @@ -94,14 +102,22 @@ export async function loadWin32DialogBindings(): Promise { return { setThreadDpiAwareness: () => { + let setContext: KoffiFunction try { - const setThreadDpiAwarenessContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) - setThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) } catch { - // SetThreadDpiAwarenessContext absent (Windows 10 pre-1703): the - // dialog renders at system DPI; nothing else can fail here because - // user32 itself loaded above. + // Symbol absent (pre-1607 Windows): no per-thread DPI control exists. + // Proceed anyway — the cost is a blurry dialog above 100 % scaling on + // museum hosts, and the modern picker still beats dropping to the + // legacy 5.1 tree over a cosmetic concern. + return } + for (const context of DPI_AWARENESS_CONTEXTS) { + if (setContext(context) !== null) return + } + // Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol + // exists); if a host ever refuses everything, the dialog still works — + // just without a DPI opt-in. }, coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, coUninitialize: () => { diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index 781cbc4b24..ff02a93105 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -1,9 +1,10 @@ /** * Real-process half of the Win32 dialog driver: spawn the dialog worker - * (source or built plane) and close a dialog thread's windows. Loaded lazily - * and only on the win32 default path, so non-Windows processes never touch - * worker or koffi machinery; the driver's logic is tested against fakes of - * this surface instead. + * (source or built plane) and close a dialog thread's windows. The module + * itself loads everywhere (the import chain from native-picker.ts is + * static); what stays win32-only is koffi, imported dynamically inside the + * bindings' functions. The driver's logic is tested against fakes of this + * surface instead. */ import { fileURLToPath } from 'node:url' diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts index 65be1149dc..aa9d1445c4 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-logic.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -49,9 +49,12 @@ export interface Win32FolderDialog { /** The thread-level native surface the dialog sequencing runs against. */ export interface Win32DialogBindings { /** - * Best-effort per-monitor-v2 DPI opt-in for the calling thread. Absent - * before Windows 10 1703; implementations swallow only that absence, so an - * old host merely renders the dialog at system DPI. + * Opt the calling thread into the best supported DPI awareness + * (per-monitor-v2, then per-monitor, then system-aware), checking each + * call's result. Best-effort on purpose: a host accepting none of them + * (or lacking the API, pre-1607) still shows the modern dialog — possibly + * blurry above 100 % scaling — because a cosmetic degradation must not + * cost the tier. */ setThreadDpiAwareness(): void /** diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index b97c3659fe..a3aee2268a 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -83,7 +83,9 @@ export async function pickWin32Directory( const postClose = (): void => { // Before `showing` there is no window to close; the budget below still - // runs so a worker that never reports cannot dangle the pick. + // runs so a worker that never reports cannot dangle the pick. A + // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is + // discarded: the interval retries it and terminate is the backstop. if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..03ae060f77 --- /dev/null +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -0,0 +1,31 @@ +/** + * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker + * shape): plain `worker_threads` loads `lib/worker.cjs` and the bundle reaches + * its real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would open), + * where the win32-only smoke in win32-dialog.spec.ts covers the source plane + * instead. Skips until a build produces the artifact. + */ + +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { describe, expect, it } from 'vitest' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) + +describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { + it('loads under plain worker_threads and reports the native-surface failure', async () => { + const message = await new Promise((resolve, reject) => { + const worker = new Worker(builtWorker, { workerData: { title: 'Built-artifact guard' } }) + worker.on('message', resolve) + worker.on('error', reject) + worker.on('exit', (code) => { + reject(new Error(`worker exited (${code}) before reporting`)) + }) + }) + expect(message.kind).toBe('error') + expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i) + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index 799c24a062..d29fbad7a0 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -13,6 +13,12 @@ import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts const E_FAIL = 0x80004005 | 0 const WM_CLOSE = 0x10 +/** + * Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer + * sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against + * this width (the win32-ia32 bug class). + */ +const FAKE_POINTER_SIZE = 4 interface ComWorld { coInitHr: number @@ -21,6 +27,8 @@ interface ComWorld { getResultHr: number getDisplayNameHr: number hasThreadDpi: boolean + /** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */ + supportedDpiContexts: number[] enumThrows: boolean path: string titles: string[] @@ -37,7 +45,7 @@ interface ComWorld { function comWorld(overrides: Partial = {}): ComWorld { return { coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0, - hasThreadDpi: true, enumThrows: false, + hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false, path: 'C:\\选中\\directory', titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], registered: 0, unregistered: 0, uninitialized: 0, @@ -89,6 +97,10 @@ function installFakeKoffi(world: ComWorld): void { case 'CoUninitialize': return () => { world.uninitialized += 1 } case 'CoCreateInstance': return (...args: unknown[]) => { if (world.coCreateHr < 0) return world.coCreateHr + // The out-pointer must be allocated at the fake's pointer width. + if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) { + throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`) + } outBuffers.set(args[4], dialogPtr) return 0 } @@ -96,7 +108,10 @@ function installFakeKoffi(world: ComWorld): void { case 'GetCurrentThreadId': return () => 31337 case 'SetThreadDpiAwarenessContext': { if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`) - return (context: unknown) => { world.dpiContexts.push(context); return null } + return (context: unknown) => { + world.dpiContexts.push(context) + return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null + } } case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => { if (world.enumThrows) throw new Error('EnumThreadWindows refused') @@ -111,15 +126,16 @@ function installFakeKoffi(world: ComWorld): void { }), proto: (declaration: string) => ({ declaration }), pointer: (type: unknown) => type, - sizeof: (type: string) => { void type; return 8 }, + sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, unregister: () => { world.unregistered += 1 }, decode: (value: unknown, offsetOrType: unknown): unknown => { if (offsetOrType === 'str16') return (value as FakePtr).text if (typeof offsetOrType === 'number') { - // Vtable slot read: hand back a callable-reference sentinel. + // Vtable slot read: offsets must be multiples of the fake width. + if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`) const owner = (value as { owner: FakePtr }).owner - return { call: (args: unknown[]) => dispatch(owner, offsetOrType / 8, args) } + return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) } } // decode(x, 'void *'): out-buffer read or vtable read. if (outBuffers.has(value)) return outBuffers.get(value) @@ -159,17 +175,41 @@ describe('loadWin32DialogBindings over the fake COM world', () => { expect(world.uninitialized).toBe(1) }) - it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => { - const world = comWorld({ showHr: HRESULT_CANCELLED, hasThreadDpi: false, coInitHr: 1 }) + it('maps dismissal and the S_FALSE CoInitializeEx', async () => { + const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 }) installFakeKoffi(world) const { loadWin32DialogBindings } = await loadBindingsModule() const bindings = await loadWin32DialogBindings() expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() - expect(world.dpiContexts).toEqual([]) expect(world.released).toEqual(['dialog']) expect(world.uninitialized).toBe(1) }) + it('cascades DPI contexts to the first the host accepts', async () => { + const world = comWorld({ supportedDpiContexts: [-3] }) + installFakeKoffi(world) + const bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4, -3]) + }) + + it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => { + // DPI is a cosmetic best-effort: the modern dialog still opens. + const rejecting = comWorld({ supportedDpiContexts: [] }) + installFakeKoffi(rejecting) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(rejecting.dpiContexts).toEqual([-4, -3, -2]) + + vi.doUnmock('koffi') + vi.resetModules() + const preThreadDpi = comWorld({ hasThreadDpi: false }) + installFakeKoffi(preThreadDpi) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(preThreadDpi.dpiContexts).toEqual([]) + }) + it('surfaces creation and extraction failures as HRESULT errors', async () => { const creationWorld = comWorld({ coCreateHr: E_FAIL }) installFakeKoffi(creationWorld) From 16b77081004dbd333073c9663a2546de4668d32f Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 21:54:50 +0800 Subject: [PATCH 09/17] chore(knip): declare directory-picker-native's e2e entry The package-wide default only knows spec files; the new built-worker e2e guard needs the workflow-workerthread-style entry declaration. --- knip.json | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/knip.json b/knip.json index fd941b03ca..46fbff979e 100644 --- a/knip.json +++ b/knip.json @@ -76,6 +76,16 @@ "tests/**/*.ts" ] }, + "packages/host/directory-picker-native": { + "entry": [ + "tests/**/*.spec.{ts,tsx}", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.{ts,tsx}" + ] + }, "packages/client/web-ui": { "entry": [ "tests/**/*.spec.{ts,tsx}" From e234a3a27483ea5b1a1ac26aab61e0c31113488a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:19:17 +0800 Subject: [PATCH 10/17] docs(host-directory-picker-native): describe the in-process IFileOpenDialog primary in the module header The @module header still described the Windows adapter as the pre-PR 'STA PowerShell FolderBrowserDialog' while the README and Agent Notes document the koffi IFileOpenDialog primary with the PowerShell chain as fallback; mirror the README's platform summary. --- packages/host/directory-picker-native/src/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index f131c8bb0c..7e253ee634 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -1,10 +1,12 @@ /** * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host - * display per pick (macOS `osascript`, Windows STA PowerShell - * `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable - * when the operator sits at the host's screen; remote deployments compose the - * browse backend instead. + * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; + * Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM + * conversation on a worker thread — and falls back to a PowerShell-hosted + * dialog (`pwsh`, then Windows PowerShell 5.1) when that native surface is + * unavailable). Only viable when the operator sits at the host's screen; + * remote deployments compose the browse backend instead. * @module @deepseek-ai/dsh-host-directory-picker-native */ From 020ce50414e85f42686054c74a6923e324538d1e Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:36:11 +0800 Subject: [PATCH 11/17] fix(picker): correct crash-isolation and DPI claims, wire the built-worker guard, and tidy round-four nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-four review's v6 pass found three factual gaps and the v5 pass two nits. Correct them before merge: - The in-process note claimed a koffi signature mistake is 'contained to the worker thread' — worker_threads share the process, so a native access violation takes down the whole Node process with no PowerShell fallback. State the real blast radius and record the deferred pkg-VFS worker-spawn arm in Consequences (both languages, pairing re-recorded). - The 2026-07-27 picker note claimed unconditional 'per-monitor-v2 DPI'; PMv2-less hosts (Server 2016 / Win10 1607) cascade to per-monitor or system-aware. Say 'the best thread DPI awareness the host accepts' (both languages, pairing re-recorded). - built-worker.e2e.ts was not in any keyless gate (vitest.e2e config is not part of the default unit run and builtBinSmokeGate's explicit list missed it), so lib/worker.cjs load regressions passed keyless CI. Add it to builtBinSmokeGate alongside the workflow-workerthread sibling. - Remove the dead trailing 'void bindings' in win32-dialog-logic.spec.ts and give native-picker.spec.ts the sibling module header it lacked. --- .../2026-07-27-native-workspace-directory-picker.i18n.yaml | 4 ++-- .../2026-07-27-native-workspace-directory-picker.md | 2 +- .../2026-07-27-native-workspace-directory-picker.zh.md | 2 +- .../2026-08-02-win32-in-process-folder-dialog.i18n.yaml | 4 ++-- .../feature/2026-08-02-win32-in-process-folder-dialog.md | 3 ++- .../2026-08-02-win32-in-process-folder-dialog.zh.md | 3 ++- .../directory-picker-native/tests/native-picker.spec.ts | 7 +++++++ .../tests/win32-dialog-logic.spec.ts | 1 - scripts/run-gates.ts | 1 + 9 files changed, 18 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index e177c663cc..e49bbe59cc 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 45fa77b5519179e006f9109846a1602e6e22a6e2 -2026-07-27-native-workspace-directory-picker.zh.md: 2d6800d20b1f0dfe0b20ac9a5c90037599ece32a +2026-07-27-native-workspace-directory-picker.md: 452ec60371558de79dbff964a12d96d2150dc6f6 +2026-07-27-native-workspace-directory-picker.zh.md: c3e6b8825e78201ce791cff51869aade67d30a5e diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 45fa77b551..452ec60371 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -30,7 +30,7 @@ The native dialog RPC is accepted only from a loopback socket with same-origin b Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: the in-process koffi `IFileOpenDialog` worker with per-monitor-v2 DPI ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the PowerShell chain (`pwsh` in STA mode, then Windows PowerShell 5.1, both DPI-corrected) remains the fallback ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). +- Windows: the in-process koffi `IFileOpenDialog` worker with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the PowerShell chain (`pwsh` in STA mode, then Windows PowerShell 5.1, both DPI-corrected) remains the fallback ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index 2d6800d20b..c3e6b8825e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -30,7 +30,7 @@ Status: implemented 平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是进程内 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:进程内 koffi `IFileOpenDialog` worker,带 per-monitor-v2 DPI(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));PowerShell 链(STA 模式的 `pwsh`,再到 Windows PowerShell 5.1,均已修正 DPI)保留为回退(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 +- Windows:进程内 koffi `IFileOpenDialog` worker,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));PowerShell 链(STA 模式的 `pwsh`,再到 Windows PowerShell 5.1,均已修正 DPI)保留为回退(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 656ee92a65..b3a1d1d640 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: 96bc213ea7cddef6223aa3be69e56688dc9c4724 -2026-08-02-win32-in-process-folder-dialog.zh.md: a3dfad2ef73cb28ae8c4c47b6380740aab6de5c3 +2026-08-02-win32-in-process-folder-dialog.md: e18ecd1d2e79ec39a265d3d93913beaa7530e645 +2026-08-02-win32-in-process-folder-dialog.zh.md: f146d61f6378062886db732e6884594d56b3d170 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index 96bc213ea7..e18ecd1d2e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -23,4 +23,5 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou - Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. - Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. -- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is an in-process crash risk contained to the worker thread and caught by the win32 smoke before shipping. +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is a native-crash risk that can take down the whole Node process — `worker_threads` share the process, so an access violation is not contained to the worker and no PowerShell fallback runs. The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary VFS arm — resolution of `./worker.cjs` inside a pkg snapshot — is not exercised by any automated test: the source worker and the built `lib/worker.cjs` under plain Node are covered, and the VFS-specific spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index a3dfad2ef7..f146d61f63 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -23,4 +23,5 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` - 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 - 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 -- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是被限制在 worker 线程内的进程内崩溃风险,并在交付前被 win32 冒烟捕获。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是可能拖垮整个 Node 进程的原生崩溃风险——`worker_threads` 与主线程共享进程,访问冲突不会只局限在 worker 内,也不会进入 PowerShell 回退。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的 VFS 臂——在 pkg 快照内解析 `./worker.cjs`——不受任何自动化测试覆盖:源码 worker 与普通 Node 下构建出的 `lib/worker.cjs` 已被覆盖,VFS 专属的 spawn 推迟到 Windows CI 路线图。 diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 64732e4e17..24c33e473e 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,3 +1,10 @@ +/** + * Native picker tier selection and the execFile adapter: the in-process + * dialog primary, the pwsh → Windows PowerShell 5.1 fallback chain (any + * non-abort pwsh failure cascades), the abort-never-falls-through rule, and + * the triple-miss AggregateError carrying the dialog/pwsh/5.1 causes. + */ + type ExecFileCallback = ( error: (Error & { code?: string | number }) | null, stdout: string, diff --git a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts index c214245de6..718c93c2c0 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-logic.spec.ts @@ -94,6 +94,5 @@ describe('runFolderDialog', () => { expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`) expect(dialog.release).toHaveBeenCalledOnce() expect(uninitialize).toHaveBeenCalledOnce() - void bindings }) }) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..238c513433 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -597,6 +597,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'apps/cli/tests/built-bin.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', + 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node From f4095ee3eb2e45ff2bf8f13e612f43105a4fe379 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 23:58:57 +0800 Subject: [PATCH 12/17] fix(picker): end the closed worker-message switch in assertNever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver's switch over Win32DialogWorkerMessage handled all three current kinds but had no default, so a fourth kind added to the worker protocol would compile cleanly and silently no-op — settle() never called and the pick dangles until worker exit. Add the local assertNever backstop (the command-compact shape; this package does not depend on dsh-llm for the helper) and the return the error case needs to avoid falling into it. Round-five review finding. --- .../host/directory-picker-native/src/win32-dialog.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index a3aee2268a..15de82373e 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -51,6 +51,13 @@ const CLOSE_RETRY_MS = 150 /** Abort-service attempts before force-terminating the worker. */ const CLOSE_MAX_ATTEMPTS = 20 +/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */ +/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */ +function assertNever(value: never): never { + throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`) +} +/* v8 ignore stop */ + /** * Open the modern Win32 folder picker off the event loop. * @param signal - caller lifetime; abort closes the dialog and rejects. @@ -133,6 +140,10 @@ export async function pickWin32Directory( settle(() => { reject(new Error(`win32 folder dialog failed: ${message.message}`)) }) + return + /* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */ + default: + assertNever(message) } }) worker.on('error', (error: Error) => { From 8923ae2ab844d69d3c6c21229304907029b72746 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 4 Aug 2026 00:00:31 +0800 Subject: [PATCH 13/17] docs(picker): drop the invented .NET 10 version specificity from the DPI note The Description paragraph attributed the modern dialog's bottom-strip rendering to '.NET 10', an unverifiable version the code comment deliberately avoids (the same invented-version class flagged in round one). Say 'the modern FolderBrowserDialog' on both language sides; pairing re-recorded. Round-five review finding. --- .../bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml | 4 ++-- .../implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md | 2 +- .../bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml index 9a86e1f729..d57f65ad1c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.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-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: a941d5ea6e150d74fa2fa4dbd93b7e6b58a78eff -2026-08-01-windows-picker-pwsh-dpi.zh.md: 8383240c219d701aa9a8728cf1a7fb7f3a7c5433 +2026-08-01-windows-picker-pwsh-dpi.md: 28630660d1370826c1997be342727175adb081ce +2026-08-01-windows-picker-pwsh-dpi.zh.md: 2be0231032898022cb3d54494fb06b86902b0c66 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md index a941d5ea6e..28630660d1 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md @@ -10,7 +10,7 @@ The Windows branch of the native directory picker spawned Windows PowerShell 5.1 ## Decision -The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog (see the [in-process folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md)): the win32 branch spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) on ANY pwsh failure — a resolvable PowerShell 6 has no WinForms and exits 1, not `ENOENT`, and 5.1 ships with every Windows. PowerShell 7 renders the modern Explorer-style folder picker because .NET Core 3.0 rewrote `FolderBrowserDialog` over `IFileDialog` (unconditionally; the later `AutoUpgradeEnabled` opt-out arrived in .NET 6 and the script never sets it). Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: .NET 10's modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). +The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog (see the [in-process folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md)): the win32 branch spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) on ANY pwsh failure — a resolvable PowerShell 6 has no WinForms and exits 1, not `ENOENT`, and 5.1 ships with every Windows. PowerShell 7 renders the modern Explorer-style folder picker because .NET Core 3.0 rewrote `FolderBrowserDialog` over `IFileDialog` (unconditionally; the later `AutoUpgradeEnabled` opt-out arrived in .NET 6 and the script never sets it). Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: the modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md index 8383240c21..2be0231032 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程内文件夹对话框 Note](../feature/2026-08-02-win32-in-process-folder-dialog.md)):win32 分支先启动 `pwsh.exe`(PowerShell 7),并在 pwsh 的任何失败上回退到 `powershell.exe`(Windows PowerShell 5.1)——可解析的 PowerShell 6 没有 WinForms,以退出码 1 而非 `ENOENT` 失败,而 5.1 每台 Windows 都自带。PowerShell 7 呈现现代资源管理器风格选择器,是因为 .NET Core 3.0 用 `IFileDialog` 重写了 `FolderBrowserDialog`(无条件生效;更晚的 `AutoUpgradeEnabled` 退出开关到 .NET 6 才加入,脚本从未设置它)。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:.NET 10 的现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 +PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程内文件夹对话框 Note](../feature/2026-08-02-win32-in-process-folder-dialog.md)):win32 分支先启动 `pwsh.exe`(PowerShell 7),并在 pwsh 的任何失败上回退到 `powershell.exe`(Windows PowerShell 5.1)——可解析的 PowerShell 6 没有 WinForms,以退出码 1 而非 `ENOENT` 失败,而 5.1 每台 Windows 都自带。PowerShell 7 呈现现代资源管理器风格选择器,是因为 .NET Core 3.0 用 `IFileDialog` 重写了 `FolderBrowserDialog`(无条件生效;更晚的 `AutoUpgradeEnabled` 退出开关到 .NET 6 才加入,脚本从未设置它)。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 ## 考虑过的替代方案 From bc9171337ae5298ab1cd8cf94d312e26c1c56b92 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 4 Aug 2026 00:09:13 +0800 Subject: [PATCH 14/17] fix(picker): raise the worker-thread dialog to the foreground on showing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The koffi redesign moved the dialog from a spawned child process (which inherits a foreground-activation right from the spawning app) onto a worker thread of the same process, so Windows shows the dialog without activating it — it opens behind the app with a taskbar flash. The app has no native HWND to hand the seam, so raise from the driver: on the 'showing' notice (the worker posts it right before Show, before the dialog window exists), attach this thread's input queue to the dialog thread's, call SetForegroundWindow on its top-level window, and detach — retried on the close cadence until the window appears, stopped on settle/abort/success, never blocking the pick. Injectable seam mirrors closeThreadWindows; driver tests pin the raise and its retry; the in-process note records the mechanism (both languages, pairing re-recorded). --- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 +- ...26-08-02-win32-in-process-folder-dialog.md | 2 +- ...08-02-win32-in-process-folder-dialog.zh.md | 2 +- .../src/win32-dialog-bindings.ts | 41 ++++++++++++++ .../src/win32-dialog-host.ts | 2 +- .../src/win32-dialog.ts | 32 ++++++++++- .../tests/win32-dialog.spec.ts | 53 ++++++++++++++++--- 7 files changed, 124 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index b3a1d1d640..9f428ccd78 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: e18ecd1d2e79ec39a265d3d93913beaa7530e645 -2026-08-02-win32-in-process-folder-dialog.zh.md: f146d61f6378062886db732e6884594d56b3d170 +2026-08-02-win32-in-process-folder-dialog.md: c7a6602836c618e855c799b72017aa9232eda968 +2026-08-02-win32-in-process-folder-dialog.zh.md: a67a22625dcd674b2a63b6125b3d31704e90eabc diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index e18ecd1d2e..c7a6602836 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -10,7 +10,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). A window on a worker input queue would otherwise be shown without activation, so the driver also raises the dialog to the foreground once the worker reports `showing` — attaching input queues and calling `SetForegroundWindow`, retried on the close cadence until the window (created inside `Show`) exists. The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index f146d61f63..a67a22625d 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -10,7 +10,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 输入队列上的窗口默认只会被显示而不会被激活,因此 driver 还会在 worker 上报 `showing` 后把对话框抬升到前台——附加输入队列并调用 `SetForegroundWindow`,按关闭节奏重试直到 `Show` 内创建的窗口出现。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 ## 考虑过的替代方案 diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index 03980af2a4..e6ee2f2e5d 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -179,3 +179,44 @@ export async function closeThreadWindows(threadId: number): Promise { koffi.unregister(callback) } } + +/** + * Bring a native thread's top-level window to the foreground. The dialog + * runs on a worker input queue, so Windows shows it without activating it + * (the app's main thread holds foreground association); the driver calls + * this on the `showing` notice: attach this thread's input queue to the + * dialog thread's, `SetForegroundWindow`, and detach. Returns whether the + * thread had a window to raise — the dialog window is created inside + * `Show`, after the `showing` notice, so callers retry until it exists. + * @param threadId - the dialog thread's native id (from the `showing` notice). + * @returns true when a window was found and raised. + */ +export async function raiseDialogWindow(threadId: number): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) + const attachThreadInput = user32.func('__stdcall', 'AttachThreadInput', 'int', ['uint32', 'uint32', 'int']) + const setForegroundWindow = user32.func('__stdcall', 'SetForegroundWindow', 'int', ['void *']) + const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) + const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') + let target: unknown + const callback = koffi.register((hwnd: unknown) => { + if (target === undefined) target = hwnd + return 0 // stop after the first (top-level) window + }, koffi.pointer(protoEnumProc)) + try { + enumThreadWindows(threadId, callback, 0) + } finally { + koffi.unregister(callback) + } + if (target === undefined) return false + const self = getCurrentThreadId() + try { + attachThreadInput(self, threadId, 1) + setForegroundWindow(target) + } finally { + attachThreadInput(self, threadId, 0) + } + return true +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index ff02a93105..21089be150 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -34,4 +34,4 @@ export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data }) } -export { closeThreadWindows } from './win32-dialog-bindings.ts' +export { closeThreadWindows, raiseDialogWindow } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index 15de82373e..0d406d0bfd 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -6,7 +6,11 @@ * injectable so every driver path is testable on any platform. */ -import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' +import { + closeThreadWindows as hostCloseThreadWindows, + raiseDialogWindow as hostRaiseDialogWindow, + spawnDialogWorker, +} from './win32-dialog-host.ts' import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' /** The worker surface the driver drives (satisfied by `node:worker_threads`). */ @@ -39,6 +43,8 @@ export interface Win32DialogInternals { spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ closeThreadWindows?: (threadId: number) => Promise + /** Replaces the real foreground raise (`win32-dialog-host.ts`). */ + raiseDialogWindow?: (threadId: number) => Promise /** Abort-service cadence override so tests never wait wall-clock time. */ closeRetryMs?: number } @@ -71,11 +77,13 @@ export async function pickWin32Directory( if (signal.aborted) throw new Error('native directory picker aborted') const spawnWorker = internals.spawnWorker ?? spawnDialogWorker const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows + const raiseWindow = internals.raiseDialogWindow ?? hostRaiseDialogWindow const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS const worker = spawnWorker({ title: DIALOG_TITLE }) let dialogThreadId: number | undefined let closeTimer: NodeJS.Timeout | undefined + let raiseTimer: NodeJS.Timeout | undefined let settled = false return await new Promise((resolve, reject) => { @@ -83,6 +91,7 @@ export async function pickWin32Directory( if (settled) return settled = true if (closeTimer !== undefined) clearInterval(closeTimer) + if (raiseTimer !== undefined) clearInterval(raiseTimer) signal.removeEventListener('abort', onAbort) worker.unref?.() outcome() @@ -96,6 +105,26 @@ export async function pickWin32Directory( if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } + // The `showing` notice precedes the blocking `Show`, so the dialog + // window does not exist yet; re-enumerate on the close cadence until it + // does and raise it — a window on a worker input queue is otherwise + // shown without activation. Stops on settle, abort, or a successful + // raise; a failing raise (e.g. koffi absent) never blocks the pick. + const startRaise = (): void => { + const attempt = (): void => { + if (settled || signal.aborted || dialogThreadId === undefined) return + void raiseWindow(dialogThreadId) + .then((raised) => { + if (raised || settled || signal.aborted) { + if (raiseTimer !== undefined) clearInterval(raiseTimer) + } + }) + .catch(() => undefined) + } + attempt() + raiseTimer = setInterval(attempt, closeRetryMs) + } + // Sole caller: the once-registered abort listener, so no re-entry guard. const serviceAbort = (): void => { let attempts = 0 @@ -129,6 +158,7 @@ export async function pickWin32Directory( dialogThreadId = message.threadId // An abort that raced ahead of this notice now has a window to hit. if (signal.aborted) postClose() + else startRaise() return case 'done': settle(() => { diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 598386b1a7..72ff1b41d1 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,9 +1,11 @@ /** * Driver tests: the worker message protocol mapped onto the promise, the - * WM_CLOSE abort service (including the show-race retry and the terminate - * last resort) against fakes, plus the real spawn plumbing — POSIX hosts - * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts briefly open and auto-abort a real dialog. + * foreground raise after the `showing` notice (retried until the dialog + * window exists), the WM_CLOSE abort service (including the show-race + * retry and the terminate last resort) against fakes, plus the real spawn + * plumbing — POSIX hosts prove the default path rejects cleanly (koffi + * cannot load ole32 there), and win32 hosts briefly open and auto-abort a + * real dialog. */ import { EventEmitter } from 'node:events' @@ -22,15 +24,24 @@ interface Harness { worker: FakeWorker internals: Win32DialogInternals close: ReturnType + raise: ReturnType } function harness(overrides: Partial = {}): Harness { const worker = new FakeWorker() const close = vi.fn(async () => undefined) + const raise = vi.fn(async () => true) return { worker, close, - internals: { spawnWorker: () => worker, closeThreadWindows: close, closeRetryMs: 1, ...overrides }, + raise, + internals: { + spawnWorker: () => worker, + closeThreadWindows: close, + raiseDialogWindow: raise, + closeRetryMs: 1, + ...overrides, + }, } } @@ -51,6 +62,35 @@ describe('pickWin32Directory', () => { await expect(cancelled).resolves.toBeNull() }) + it('raises the dialog window to the foreground after the showing notice', async () => { + const { worker, internals, raise } = harness() + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'showing', threadId: 7 }) + worker.post({ kind: 'done', path: 'C:\\raised' }) + await expect(picked).resolves.toBe('C:\\raised') + expect(raise).toHaveBeenCalledWith(7) + }) + + it('retries the raise until the dialog window exists, then stops', async () => { + const { worker, internals, raise } = harness() + // The window is created inside `Show`, after the `showing` notice, so + // the first attempts find nothing; once a window is reported, the raise + // must stop retrying. + raise.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true) + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'showing', threadId: 12 }) + await vi.waitFor(() => { + expect(raise.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + const callsAfterRaised = await new Promise((resolve) => { + setTimeout(() =>{ resolve(raise.mock.calls.length); }, 20) + }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(raise.mock.calls.length).toBe(callsAfterRaised) + worker.post({ kind: 'done', path: 'C:\\raised' }) + await expect(picked).resolves.toBe('C:\\raised') + }) + it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { const reported = harness() const failing = pickWin32Directory(live(), reported.internals) @@ -103,7 +143,7 @@ describe('pickWin32Directory', () => { it('starts the close service on the showing notice when the abort came first', async () => { const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) - const { worker, internals } = harness({ closeThreadWindows: closeFailures }) + const { worker, internals, raise } = harness({ closeThreadWindows: closeFailures }) const controller = new AbortController() // Attached before the race for the same unhandled-rejection reason above. const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') @@ -113,6 +153,7 @@ describe('pickWin32Directory', () => { await vi.waitFor(() => { expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) + expect(raise).not.toHaveBeenCalled() worker.post({ kind: 'done', path: null }) await picked }) From 4201eaed3f5fb8574a3dfb087a96e460fcff761b Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 4 Aug 2026 01:40:57 +0800 Subject: [PATCH 15/17] refactor(picker): drive the Win32 dialog from a spawned child process The koffi IFileOpenDialog conversation runs in a spawned child process instead of a worker thread: the dialog is the child's first window, so Windows activates it without a foreground call, and a native fault stays contained to the child. The driver maps the child's message protocol onto a promise and services aborts by posting WM_CLOSE to the dialog thread's windows, killing the child when the close budget is exhausted. The built worker ships as lib/worker.cjs (the ./worker export) under plain node, and win32-dialog.spec.ts returns to the thread-safe pool. --- .../src/win32-dialog-bindings.ts | 57 ++++---------- .../src/win32-dialog-host.ts | 38 +++++----- .../src/win32-dialog-worker.ts | 43 +++++++---- .../src/win32-dialog.ts | 75 ++++++------------- .../tests/built-worker.e2e.ts | 25 ++++--- .../tests/win32-dialog-bindings.spec.ts | 68 +++++++++++------ .../tests/win32-dialog.spec.ts | 62 ++++----------- vitest.config.ts | 4 - 8 files changed, 153 insertions(+), 219 deletions(-) diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index e6ee2f2e5d..654bbc5a74 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -25,6 +25,20 @@ interface Koffi { register(fn: (...args: unknown[]) => unknown, type: unknown): unknown unregister(callback: unknown): void sizeof(type: string): number + view(ref: unknown, len: number): ArrayBuffer +} + +/** + * Read a NUL-terminated UTF-16 string at a native address. koffi's + * `_Out_ void **` out-params surface a raw address, and + * `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash + * on real Windows — so view the memory directly instead. + */ +function readUtf16(koffi: Koffi, address: unknown): string { + const bytes = Buffer.from(koffi.view(address, 32768)) + let end = 0 + while (end + 1 < bytes.length && bytes[end] !== 0) end += 2 + return bytes.toString('utf16le', 0, end) } const COINIT_APARTMENTTHREADED = 0x2 @@ -142,7 +156,7 @@ export async function loadWin32DialogBindings(): Promise { const nameOut: unknown[] = [null] const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut) if (gotName < 0) return { hr: gotName } - const path = koffi.decode(nameOut[0], 'str16') as string + const path = readUtf16(koffi, nameOut[0]) coTaskMemFree(nameOut[0]) return { hr: gotName, path } } finally { @@ -179,44 +193,3 @@ export async function closeThreadWindows(threadId: number): Promise { koffi.unregister(callback) } } - -/** - * Bring a native thread's top-level window to the foreground. The dialog - * runs on a worker input queue, so Windows shows it without activating it - * (the app's main thread holds foreground association); the driver calls - * this on the `showing` notice: attach this thread's input queue to the - * dialog thread's, `SetForegroundWindow`, and detach. Returns whether the - * thread had a window to raise — the dialog window is created inside - * `Show`, after the `showing` notice, so callers retry until it exists. - * @param threadId - the dialog thread's native id (from the `showing` notice). - * @returns true when a window was found and raised. - */ -export async function raiseDialogWindow(threadId: number): Promise { - const koffi = (await import('koffi')).default as unknown as Koffi - const user32 = koffi.load('user32.dll') - const kernel32 = koffi.load('kernel32.dll') - const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) - const attachThreadInput = user32.func('__stdcall', 'AttachThreadInput', 'int', ['uint32', 'uint32', 'int']) - const setForegroundWindow = user32.func('__stdcall', 'SetForegroundWindow', 'int', ['void *']) - const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) - const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') - let target: unknown - const callback = koffi.register((hwnd: unknown) => { - if (target === undefined) target = hwnd - return 0 // stop after the first (top-level) window - }, koffi.pointer(protoEnumProc)) - try { - enumThreadWindows(threadId, callback, 0) - } finally { - koffi.unregister(callback) - } - if (target === undefined) return false - const self = getCurrentThreadId() - try { - attachThreadInput(self, threadId, 1) - setForegroundWindow(target) - } finally { - attachThreadInput(self, threadId, 0) - } - return true -} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index 21089be150..7a60ab05ed 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -1,37 +1,33 @@ /** - * Real-process half of the Win32 dialog driver: spawn the dialog worker - * (source or built plane) and close a dialog thread's windows. The module - * itself loads everywhere (the import chain from native-picker.ts is + * Real-process half of the Win32 dialog driver: spawn the dialog child + * process (source or built plane) and close a dialog thread's windows. The + * module itself loads everywhere (the import chain from native-picker.ts is * static); what stays win32-only is koffi, imported dynamically inside the * bindings' functions. The driver's logic is tested against fakes of this * surface instead. */ +import { spawn, type StdioOptions } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Worker } from 'node:worker_threads' import type { Win32DialogWorkerData } from './win32-dialog-worker.ts' /** - * Spawn the dialog worker. Built consumers load the bundled CJS worker next - * to this module; unbuilt (source) consumers bootstrap tsx inside the worker - * first, mirroring `dsh-workflow-workerthread`'s host. - * @param data - the worker payload (dialog title). - * @returns the spawned worker thread. + * Spawn the dialog child process. Built consumers launch the bundled CJS + * entry next to this module under plain node; unbuilt (source) consumers + * bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is + * the child's first window, so Windows activates it without a foreground + * call. + * @param data - the child payload (dialog title). + * @returns the spawned child process. */ -export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { +export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType { + const env = { ...process.env, DSH_DIALOG_TITLE: data.title } + const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc'] /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */ if (!import.meta.url.endsWith('.ts')) { - return new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)), { workerData: data }) + return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true }) } - const workerEntry = new URL('./win32-dialog-worker.ts', import.meta.url) - const bootstrap = [ - `import { register as registerEsm } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}`, - `import { register as registerCjs } from ${JSON.stringify(import.meta.resolve('tsx/cjs/api'))}`, - 'registerCjs()', - 'registerEsm()', - `await import(${JSON.stringify(workerEntry.href)})`, - ].join('\n') - return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data }) + return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true }) } -export { closeThreadWindows, raiseDialogWindow } from './win32-dialog-bindings.ts' +export { closeThreadWindows } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog-worker.ts b/packages/host/directory-picker-native/src/win32-dialog-worker.ts index e978d305f3..0b422b3ca7 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-worker.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-worker.ts @@ -1,16 +1,18 @@ /** - * Worker entry for the Win32 folder dialog: blocks THIS thread inside the - * modal `Show` so the host event loop stays live, reporting over the message - * port. Protocol: `{kind:'showing',threadId}` right before the blocking call - * (the driver's abort lever needs the native thread id), then exactly one of - * `{kind:'done',path}` or `{kind:'error',message}`. + * Child-process entry for the Win32 folder dialog: blocks THIS process + * inside the modal `Show` so the host event loop stays live, reporting over + * the IPC channel. Spawned as a child process (not a worker thread) so the + * dialog is the process's first window and Windows activates it without a + * manual foreground call. Protocol: `{kind:'showing',threadId}` right + * before the blocking call (the driver's abort lever needs the native + * thread id), then exactly one of `{kind:'done',path}` or + * `{kind:'error',message}`. */ -import { parentPort, workerData } from 'node:worker_threads' import { loadWin32DialogBindings } from './win32-dialog-bindings.ts' import { runFolderDialog } from './win32-dialog-logic.ts' -/** The driver-to-worker payload: the dialog title. */ +/** The driver-to-child payload: the dialog title (passed via env). */ export interface Win32DialogWorkerData { title: string } /** One notice or outcome posted back to the driver. */ @@ -19,21 +21,32 @@ export type Win32DialogWorkerMessage = | { kind: 'done'; path: string | null } | { kind: 'error'; message: string } -const port = parentPort -if (port === null) throw new Error('win32-dialog-worker must run as a worker thread') -const { title } = workerData as Win32DialogWorkerData +const title = process.env.DSH_DIALOG_TITLE ?? '' +if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required') +if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel') +// node's internal `send` reads `this.connected`, so bind the receiver. +const send = process.send.bind(process) -// No top-level await: the built worker ships as CJS (pkg's VFS Worker hook -// compiles that format), which cannot carry TLA. +const post = (message: Win32DialogWorkerMessage): void => { + // Flush before closing the channel; the process exits when the loop drains. + /* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */ + send(message, () => { if (process.connected) process.disconnect() }) +} + +// A settled driver (or a dead parent) must not orphan a dialog still on screen. +/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */ +process.on('disconnect', () => process.exit(0)) + +// No top-level await: the built worker ships as CJS, which cannot carry TLA. void (async () => { try { const bindings = await loadWin32DialogBindings() const path = runFolderDialog(bindings, title, (threadId) => { - port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) + post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) }) - port.postMessage({ kind: 'done', path } satisfies Win32DialogWorkerMessage) + post({ kind: 'done', path } satisfies Win32DialogWorkerMessage) } catch (error: unknown) { const message = error instanceof Error ? (error.stack ?? error.message) : String(error) - port.postMessage({ kind: 'error', message } satisfies Win32DialogWorkerMessage) + post({ kind: 'error', message } satisfies Win32DialogWorkerMessage) } })() diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index 0d406d0bfd..247d9a1733 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -1,22 +1,18 @@ /** - * Main-thread driver for the Win32 folder dialog: spawns the dialog worker - * (which blocks inside the modal `Show`), maps its message protocol onto a - * promise, and services aborts by posting `WM_CLOSE` to the dialog thread's - * windows until the worker reports back. The real worker/window surface is - * injectable so every driver path is testable on any platform. + * Main-thread driver for the Win32 folder dialog: spawns the dialog child + * process (which blocks inside the modal `Show`), maps its message protocol + * onto a promise, and services aborts by posting `WM_CLOSE` to the dialog + * thread's windows until the child reports back. The real process/window + * surface is injectable so every driver path is testable on any platform. */ -import { - closeThreadWindows as hostCloseThreadWindows, - raiseDialogWindow as hostRaiseDialogWindow, - spawnDialogWorker, -} from './win32-dialog-host.ts' +import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' -/** The worker surface the driver drives (satisfied by `node:worker_threads`). */ +/** The child-process surface the driver drives (satisfied by `node:child_process`). */ export interface Win32DialogWorkerLike { /** - * Subscribe to a worker event. + * Subscribe to a child-process event. * @param event - `message`, `error`, or `exit`. * @param listener - the event consumer. */ @@ -24,27 +20,24 @@ export interface Win32DialogWorkerLike { on(event: 'error', listener: (error: Error) => void): unknown on(event: 'exit', listener: (code: number) => void): unknown /** - * Force-stop the worker; the abort path's last resort when `WM_CLOSE` + * Force-stop the child; the abort path's last resort when `WM_CLOSE` * never lands (e.g. the dialog window was never created). - * @returns settles when the thread is gone. + * @returns whether a kill signal was delivered. */ - terminate(): Promise + kill(): boolean /** * Release the event-loop reference. Called once the pick settles so a - * worker stuck in the native modal call (terminate cannot interrupt - * native code) never blocks process exit. + * child stuck in the native modal call never blocks process exit. */ unref?(): void } /** Injectable process surface for deterministic driver tests. */ export interface Win32DialogInternals { - /** Replaces the real worker spawn (`win32-dialog-host.ts`). */ + /** Replaces the real child spawn (`win32-dialog-host.ts`). */ spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ closeThreadWindows?: (threadId: number) => Promise - /** Replaces the real foreground raise (`win32-dialog-host.ts`). */ - raiseDialogWindow?: (threadId: number) => Promise /** Abort-service cadence override so tests never wait wall-clock time. */ closeRetryMs?: number } @@ -77,13 +70,11 @@ export async function pickWin32Directory( if (signal.aborted) throw new Error('native directory picker aborted') const spawnWorker = internals.spawnWorker ?? spawnDialogWorker const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows - const raiseWindow = internals.raiseDialogWindow ?? hostRaiseDialogWindow const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS - const worker = spawnWorker({ title: DIALOG_TITLE }) + const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE }) let dialogThreadId: number | undefined let closeTimer: NodeJS.Timeout | undefined - let raiseTimer: NodeJS.Timeout | undefined let settled = false return await new Promise((resolve, reject) => { @@ -91,7 +82,6 @@ export async function pickWin32Directory( if (settled) return settled = true if (closeTimer !== undefined) clearInterval(closeTimer) - if (raiseTimer !== undefined) clearInterval(raiseTimer) signal.removeEventListener('abort', onAbort) worker.unref?.() outcome() @@ -99,46 +89,26 @@ export async function pickWin32Directory( const postClose = (): void => { // Before `showing` there is no window to close; the budget below still - // runs so a worker that never reports cannot dangle the pick. A + // runs so a child that never reports cannot dangle the pick. A // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is - // discarded: the interval retries it and terminate is the backstop. + // discarded: the interval retries it and kill is the backstop. if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } - // The `showing` notice precedes the blocking `Show`, so the dialog - // window does not exist yet; re-enumerate on the close cadence until it - // does and raise it — a window on a worker input queue is otherwise - // shown without activation. Stops on settle, abort, or a successful - // raise; a failing raise (e.g. koffi absent) never blocks the pick. - const startRaise = (): void => { - const attempt = (): void => { - if (settled || signal.aborted || dialogThreadId === undefined) return - void raiseWindow(dialogThreadId) - .then((raised) => { - if (raised || settled || signal.aborted) { - if (raiseTimer !== undefined) clearInterval(raiseTimer) - } - }) - .catch(() => undefined) - } - attempt() - raiseTimer = setInterval(attempt, closeRetryMs) - } - // Sole caller: the once-registered abort listener, so no re-entry guard. const serviceAbort = (): void => { let attempts = 0 // The `showing` notice precedes the blocking `Show`, so the very first - // WM_CLOSE can race the window's creation; re-post until the worker - // reports back, then force-terminate as a last resort. The budget is - // unconditional — an abort before `showing` (worker hung in koffi or - // COM init) still ends in terminate instead of a dangling promise. + // WM_CLOSE can race the window's creation; re-post until the child + // reports back, then force-kill as a last resort. The budget is + // unconditional — an abort before `showing` (child hung in koffi or + // COM init) still ends in kill instead of a dangling promise. closeTimer = setInterval(() => { attempts += 1 if (attempts > CLOSE_MAX_ATTEMPTS) { settle(() => { - void worker.terminate() - reject(new Error('native directory picker aborted (dialog unresponsive; worker terminated)')) + worker.kill() + reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)')) }) return } @@ -158,7 +128,6 @@ export async function pickWin32Directory( dialogThreadId = message.threadId // An abort that raced ahead of this notice now has a window to hit. if (signal.aborted) postClose() - else startRaise() return case 'done': settle(() => { diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts index 03ae060f77..2c3b793a7d 100644 --- a/packages/host/directory-picker-native/tests/built-worker.e2e.ts +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -1,27 +1,30 @@ /** * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker - * shape): plain `worker_threads` loads `lib/worker.cjs` and the bundle reaches - * its real koffi requires. POSIX hosts prove the load path end to end through - * the deterministic ole32 rejection; win32 skips (a real dialog would open), - * where the win32-only smoke in win32-dialog.spec.ts covers the source plane - * instead. Skips until a build produces the artifact. + * shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its + * real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would + * open), where the win32-only smoke in win32-dialog.spec.ts covers the + * source plane instead. Skips until a build produces the artifact. */ +import { spawn } from 'node:child_process' import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' -import { Worker } from 'node:worker_threads' import { describe, expect, it } from 'vitest' import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { - it('loads under plain worker_threads and reports the native-surface failure', async () => { + it('loads under plain node and reports the native-surface failure', async () => { const message = await new Promise((resolve, reject) => { - const worker = new Worker(builtWorker, { workerData: { title: 'Built-artifact guard' } }) - worker.on('message', resolve) - worker.on('error', reject) - worker.on('exit', (code) => { + const child = spawn(process.execPath, [builtWorker], { + env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' }, + stdio: ['ignore', 'inherit', 'inherit', 'ipc'], + }) + child.on('message', resolve) + child.on('error', reject) + child.on('exit', (code) => { reject(new Error(`worker exited (${code}) before reporting`)) }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index d29fbad7a0..9403bb7e36 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -3,9 +3,9 @@ * technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory * COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch, * result extraction, memory hygiene, and the WM_CLOSE poster covered on every - * host. The worker entry is exercised the same way with a mocked - * `node:worker_threads`. Real-COM behavior is pinned by the win32-only smoke - * in win32-dialog.spec.ts. + * host. The worker entry is exercised the same way with a mocked process + * boundary (env title + `process.send`). Real-COM behavior is pinned by the + * win32-only smoke in win32-dialog.spec.ts. */ import { afterEach, describe, expect, it, vi } from 'vitest' @@ -127,6 +127,11 @@ function installFakeKoffi(world: ComWorld): void { proto: (declaration: string) => ({ declaration }), pointer: (type: unknown) => type, sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, + view: (value: unknown, len: number): ArrayBuffer => { + const bytes = Buffer.alloc(len) + bytes.write((value as FakePtr).text as string, 'utf16le') + return bytes.buffer + }, register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, unregister: () => { world.unregistered += 1 }, decode: (value: unknown, offsetOrType: unknown): unknown => { @@ -259,13 +264,31 @@ describe('closeThreadWindows over the fake COM world', () => { }) }) -describe('the worker entry over a mocked thread boundary', () => { +describe('the worker entry over a mocked process boundary', () => { + const originalSend = process.send?.bind(process) + const originalTitle = process.env.DSH_DIALOG_TITLE + + const installBoundary = (): { posted: { kind: string; message?: string }[] } => { + const posted: { kind: string; message?: string }[] = [] + process.env.DSH_DIALOG_TITLE = 'Pick' + ;(process as { send?: unknown }).send = (message: { kind: string }, callback?: () => void) => { + posted.push(message) + callback?.() + } + return { posted } + } + + afterEach(() => { + delete (process as { send?: unknown }).send + if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend + if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE + else process.env.DSH_DIALOG_TITLE = originalTitle + vi.doUnmock('../src/win32-dialog-bindings.ts') + vi.resetModules() + }) + it('posts showing then done for a completed conversation', async () => { - const posted: unknown[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: unknown) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => ({ setThreadDpiAwareness: () => undefined, @@ -289,11 +312,7 @@ describe('the worker entry over a mocked thread boundary', () => { }) it('posts the failure message when the native surface cannot load', async () => { - const posted: { kind: string; message?: string }[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => { throw new Error('no ole32 here') }, })) @@ -307,14 +326,8 @@ describe('the worker entry over a mocked thread boundary', () => { const stackless = new Error('bare message') delete stackless.stack for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) { - vi.doUnmock('node:worker_threads') - vi.doUnmock('../src/win32-dialog-bindings.ts') vi.resetModules() - const posted: { kind: string; message?: string }[] = [] - vi.doMock('node:worker_threads', () => ({ - parentPort: { postMessage: (message: { kind: string }) => posted.push(message) }, - workerData: { title: 'Pick' }, - })) + const { posted } = installBoundary() vi.doMock('../src/win32-dialog-bindings.ts', () => ({ loadWin32DialogBindings: async () => { throw thrown }, })) @@ -323,8 +336,15 @@ describe('the worker entry over a mocked thread boundary', () => { } }) - it('refuses to run outside a worker thread', async () => { - vi.doMock('node:worker_threads', () => ({ parentPort: null, workerData: undefined })) - await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a worker thread') + it('refuses to run without the dialog title', async () => { + delete process.env.DSH_DIALOG_TITLE + ;(process as { send?: unknown }).send = () => true + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required') + }) + + it('refuses to run outside a child process', async () => { + process.env.DSH_DIALOG_TITLE = 'Pick' + delete (process as { send?: unknown }).send + await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process') }) }) diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 72ff1b41d1..8e7d6951b8 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,11 +1,9 @@ /** - * Driver tests: the worker message protocol mapped onto the promise, the - * foreground raise after the `showing` notice (retried until the dialog - * window exists), the WM_CLOSE abort service (including the show-race - * retry and the terminate last resort) against fakes, plus the real spawn - * plumbing — POSIX hosts prove the default path rejects cleanly (koffi - * cannot load ole32 there), and win32 hosts briefly open and auto-abort a - * real dialog. + * Driver tests: the child-process message protocol mapped onto the promise, + * the WM_CLOSE abort service (including the show-race retry and the kill + * last resort) against fakes, plus the real spawn plumbing — POSIX hosts + * prove the default path rejects cleanly (koffi cannot load ole32 there), + * and win32 hosts briefly open and auto-abort a real dialog. */ import { EventEmitter } from 'node:events' @@ -14,7 +12,7 @@ import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLi import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' class FakeWorker extends EventEmitter implements Win32DialogWorkerLike { - terminate = vi.fn(async () => 0) + kill = vi.fn(() => true) post(message: Win32DialogWorkerMessage): void { this.emit('message', message) } @@ -24,21 +22,17 @@ interface Harness { worker: FakeWorker internals: Win32DialogInternals close: ReturnType - raise: ReturnType } function harness(overrides: Partial = {}): Harness { const worker = new FakeWorker() const close = vi.fn(async () => undefined) - const raise = vi.fn(async () => true) return { worker, close, - raise, internals: { spawnWorker: () => worker, closeThreadWindows: close, - raiseDialogWindow: raise, closeRetryMs: 1, ...overrides, }, @@ -62,35 +56,6 @@ describe('pickWin32Directory', () => { await expect(cancelled).resolves.toBeNull() }) - it('raises the dialog window to the foreground after the showing notice', async () => { - const { worker, internals, raise } = harness() - const picked = pickWin32Directory(live(), internals) - worker.post({ kind: 'showing', threadId: 7 }) - worker.post({ kind: 'done', path: 'C:\\raised' }) - await expect(picked).resolves.toBe('C:\\raised') - expect(raise).toHaveBeenCalledWith(7) - }) - - it('retries the raise until the dialog window exists, then stops', async () => { - const { worker, internals, raise } = harness() - // The window is created inside `Show`, after the `showing` notice, so - // the first attempts find nothing; once a window is reported, the raise - // must stop retrying. - raise.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true) - const picked = pickWin32Directory(live(), internals) - worker.post({ kind: 'showing', threadId: 12 }) - await vi.waitFor(() => { - expect(raise.mock.calls.length).toBeGreaterThanOrEqual(2) - }) - const callsAfterRaised = await new Promise((resolve) => { - setTimeout(() =>{ resolve(raise.mock.calls.length); }, 20) - }) - await new Promise(resolve => setTimeout(resolve, 20)) - expect(raise.mock.calls.length).toBe(callsAfterRaised) - worker.post({ kind: 'done', path: 'C:\\raised' }) - await expect(picked).resolves.toBe('C:\\raised') - }) - it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { const reported = harness() const failing = pickWin32Directory(live(), reported.internals) @@ -143,7 +108,7 @@ describe('pickWin32Directory', () => { it('starts the close service on the showing notice when the abort came first', async () => { const closeFailures = vi.fn(async () => { throw new Error('window not there yet') }) - const { worker, internals, raise } = harness({ closeThreadWindows: closeFailures }) + const { worker, internals } = harness({ closeThreadWindows: closeFailures }) const controller = new AbortController() // Attached before the race for the same unhandled-rejection reason above. const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted') @@ -153,31 +118,30 @@ describe('pickWin32Directory', () => { await vi.waitFor(() => { expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) - expect(raise).not.toHaveBeenCalled() worker.post({ kind: 'done', path: null }) await picked }) - it('terminates a worker that never reports showing after an abort', async () => { + it('kills a worker that never reports showing after an abort', async () => { // The budget runs without a thread id (nothing to WM_CLOSE yet), so a // worker hung before `showing` cannot dangle the pick. const { worker, internals, close } = harness() const controller = new AbortController() - const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker terminated') + const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed') controller.abort() await picked - expect(worker.terminate).toHaveBeenCalledOnce() + expect(worker.kill).toHaveBeenCalledOnce() expect(close).not.toHaveBeenCalled() }) - it('terminates an unresponsive worker after the close budget', async () => { + it('kills an unresponsive worker after the close budget', async () => { const { worker, internals, close } = harness() const controller = new AbortController() const picked = pickWin32Directory(controller.signal, internals) worker.post({ kind: 'showing', threadId: 5 }) controller.abort() - await expect(picked).rejects.toThrow('dialog unresponsive; worker terminated') - expect(worker.terminate).toHaveBeenCalledOnce() + await expect(picked).rejects.toThrow('dialog unresponsive; worker killed') + expect(worker.kill).toHaveBeenCalledOnce() expect(close.mock.calls.length).toBeGreaterThan(10) }) diff --git a/vitest.config.ts b/vitest.config.ts index 58d4a9837f..2909fb7459 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -73,10 +73,6 @@ const coverageExemptExcludes = coverageExemptRaw === '1' // that worker threads cannot isolate reliably under aggregate gate contention. // Keep the narrow exception in forks while the rest of the inventory avoids per-file processes. const processBoundTests = [ - // Spawns a nested worker that blocks in a native modal dialog on win32; - // under the threads pool the dialog thread outlives the test worker and - // wedges pool teardown, while a fork contains it. - 'packages/host/directory-picker-native/tests/win32-dialog.spec.ts', 'packages/subprocess/subprocess-local/tests/spawn.spec.ts', 'packages/context/time-context/tests/time-context.spec.ts', 'packages/llm/llm-pi-ai/tests/adapter.spec.ts', From 4cb5f328bb47baad89ffb02acb7a36d3e355cf69 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 4 Aug 2026 01:41:07 +0800 Subject: [PATCH 16/17] refactor(picker): drop the Windows PowerShell fallback chain The win32 tier is exactly the koffi IFileOpenDialog child process; any failure surfaces as-is. The pwsh -> Windows PowerShell 5.1 cascade, the shared WinForms script, and the triple-miss AggregateError are deleted: koffi is a packaged dependency whose availability the install guarantees, so no mechanism fallback exists (the browse backend remains the fallback at the composition level). The pwsh-first DPI picker-fix note is consolidated into a new simplification note recording the reversal. --- .../2026-08-01-windows-picker-pwsh-dpi.md | 26 ----- .../2026-08-01-windows-picker-pwsh-dpi.zh.md | 26 ----- ...ative-workspace-directory-picker.i18n.yaml | 4 +- ...07-27-native-workspace-directory-picker.md | 2 +- ...27-native-workspace-directory-picker.zh.md | 4 +- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 +- ...26-08-02-win32-in-process-folder-dialog.md | 14 +-- ...08-02-win32-in-process-folder-dialog.zh.md | 14 +-- ...dows-powershell-picker-fallback.i18n.yaml} | 6 +- ...drop-windows-powershell-picker-fallback.md | 38 ++++++ ...p-windows-powershell-picker-fallback.zh.md | 38 ++++++ .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 5 +- .../host/directory-picker-native/README.zh.md | 5 +- .../host/directory-picker-native/src/index.ts | 9 +- .../src/native-picker.ts | 61 +--------- .../tests/native-picker.spec.ts | 108 +++++------------- 17 files changed, 143 insertions(+), 225 deletions(-) delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md delete mode 100644 .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md rename .agents/notes/implemented/{bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml => simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml} (52%) create mode 100644 .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md create mode 100644 .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md deleted file mode 100644 index 28630660d1..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: Windows directory picker prefers pwsh and forces DPI awareness - -Status: implemented - -English | [中文](2026-08-01-windows-picker-pwsh-dpi.zh.md) - -## Problem - -The Windows branch of the native directory picker spawned Windows PowerShell 5.1's `FolderBrowserDialog`, which .NET Framework hardwires to the legacy `SHBrowseForFolder` tree dialog: no address bar, search, or quick access. The same process is DPI-unaware (`powershell.exe` declares no DPI awareness), so on scaled displays Windows renders the dialog at 96 DPI and bitmap-stretches it — blurry text and soft edges. Both defects were visible at once on any display above 100 % scaling. - -## Decision - -The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog (see the [in-process folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md)): the win32 branch spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) on ANY pwsh failure — a resolvable PowerShell 6 has no WinForms and exits 1, not `ENOENT`, and 5.1 ships with every Windows. PowerShell 7 renders the modern Explorer-style folder picker because .NET Core 3.0 rewrote `FolderBrowserDialog` over `IFileDialog` (unconditionally; the later `AutoUpgradeEnabled` opt-out arrived in .NET 6 and the script never sets it). Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: the modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). - -## Alternatives considered - -- **Require PowerShell 7.** Rejected: pwsh is not a Windows built-in, so machines without it would lose the only workspace-creation route; the 5.1 fallback keeps the dialog functional, and DPI is corrected there too. -- **Import `resolvePwshPath` from `dsh-pwsh-local`.** Rejected for this change: a host GUI package importing from a bash-executor package is a cross-seam coupling, and PATH-based `execFile` resolution plus `ENOENT` fallback already covers the practical installs (Program Files, Store aliases); single-source resolution remains a follow-up if the two consumers drift. -- **Set DPI awareness in the harness process.** Rejected: DPI awareness is per-process, and the dialog lives in a spawned child that inherits nothing from the parent's absent declaration. -- **Per-monitor v2 (`SetProcessDpiAwarenessContext`).** Deferred: system-aware is the ceiling .NET Framework WinForms supports, the shell dialog handles per-monitor rendering itself on modern Windows, and one call keeps both runtimes on a single code path. - -## Consequences - -- Machines with PowerShell 7 get the modern folder picker; 5.1-only machines keep the legacy tree — now sharp — and the package README's Known Limitations documents the gap. -- The PowerShell chain itself adds no packages or dependencies (koffi and tsx arrived with the in-process primary and belong to its note); the pwsh→5.1 hop triggers on ANY non-abort pwsh failure — no `ENOENT` classification remains on the win32 path — while abort propagation is unchanged. -- The command boundary (`DirectoryPickerRunner`) pins the spawn order and script content in unit tests; real dialog rendering remains a manual Windows check, as before. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md deleted file mode 100644 index 2be0231032..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: Windows 目录选择器优先 pwsh 并强制 DPI awareness - -Status: implemented - -[English](2026-08-01-windows-picker-pwsh-dpi.md) | 中文 - -## 问题 - -原生目录选择器的 Windows 分支原先启动 Windows PowerShell 5.1 的 `FolderBrowserDialog`,而 .NET Framework 将其硬编码为旧版 `SHBrowseForFolder` 树形对话框:没有地址栏、搜索或快速访问。同一进程又是 DPI-unaware 的(`powershell.exe` 未声明任何 DPI awareness),因此在缩放显示器上,Windows 会以 96 DPI 渲染该对话框再位图拉伸——文字模糊、边缘发虚。任何超过 100% 缩放的显示器上,两个缺陷同时可见。 - -## 决策 - -PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程内文件夹对话框 Note](../feature/2026-08-02-win32-in-process-folder-dialog.md)):win32 分支先启动 `pwsh.exe`(PowerShell 7),并在 pwsh 的任何失败上回退到 `powershell.exe`(Windows PowerShell 5.1)——可解析的 PowerShell 6 没有 WinForms,以退出码 1 而非 `ENOENT` 失败,而 5.1 每台 Windows 都自带。PowerShell 7 呈现现代资源管理器风格选择器,是因为 .NET Core 3.0 用 `IFileDialog` 重写了 `FolderBrowserDialog`(无条件生效;更晚的 `AutoUpgradeEnabled` 退出开关到 .NET 6 才加入,脚本从未设置它)。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 - -## 考虑过的替代方案 - -- **强制要求 PowerShell 7。** 否决:pwsh 并非 Windows 内置,没有它的机器将失去唯一的工作区创建路径;5.1 回退保持对话框可用,且 DPI 在那里同样被修正。 -- **从 `dsh-pwsh-local` 导入 `resolvePwshPath`。** 本变更否决:host GUI 包依赖 bash 执行器包是跨 seam 耦合;PATH 上的 `execFile` 解析加 `ENOENT` 回退已覆盖实际安装形态(Program Files、Store 别名);若两个消费者日后漂移,单一来源解析留作后续。 -- **在 harness 进程内设置 DPI awareness。** 否决:DPI awareness 是进程级的,而对话框位于派生的子进程中,不会继承父进程缺失的声明。 -- **Per-monitor v2(`SetProcessDpiAwarenessContext`)。** 暂缓:system-aware 是 .NET Framework WinForms 的上限,现代 Windows 中 shell 对话框自身处理 per-monitor 渲染,且一次调用让两个运行时共用一条代码路径。 - -## 后果 - -- 装有 PowerShell 7 的机器获得现代文件夹选择器;只有 5.1 的机器保留旧版树——但现在清晰了——包 README 的已知限制记录了该差距。 -- PowerShell 链本身不新增任何包或依赖(koffi 与 tsx 随进程内主层引入,归属其 Note);pwsh→5.1 的跳转在 pwsh 的任何非中止失败上触发——win32 路径上已不存在 `ENOENT` 分类——中止传播不变。 -- 命令边界(`DirectoryPickerRunner`)在单元测试中固定启动顺序与脚本内容;真实对话框渲染仍与以前一样属于手动 Windows 检查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index e49bbe59cc..dade9b5d90 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.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/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 452ec60371558de79dbff964a12d96d2150dc6f6 -2026-07-27-native-workspace-directory-picker.zh.md: c3e6b8825e78201ce791cff51869aade67d30a5e +2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2 +2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 452ec60371..a36f7b239a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -30,7 +30,7 @@ The native dialog RPC is accepted only from a loopback socket with same-origin b Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: the in-process koffi `IFileOpenDialog` worker with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the PowerShell chain (`pwsh` in STA mode, then Windows PowerShell 5.1, both DPI-corrected) remains the fallback ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). +- Windows: the koffi `IFileOpenDialog` child process with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the tier has no fallback — failures surface as-is ([PowerShell chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index c3e6b8825e..bb3e2fc6f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是进程内 COM 会话: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:进程内 koffi `IFileOpenDialog` worker,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));PowerShell 链(STA 模式的 `pwsh`,再到 Windows PowerShell 5.1,均已修正 DPI)保留为回退(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 +- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 9f428ccd78..2ec7925a3e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.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/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: c7a6602836c618e855c799b72017aa9232eda968 -2026-08-02-win32-in-process-folder-dialog.zh.md: a67a22625dcd674b2a63b6125b3d31704e90eabc +2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 +2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index c7a6602836..91a1ed0d7b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -1,4 +1,4 @@ -# Agent Note: Win32 folder picker moves in-process over koffi +# Agent Note: Win32 folder picker moves to koffi in a child process Status: implemented @@ -10,18 +10,18 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). A window on a worker input queue would otherwise be shown without activation, so the driver also raises the dialog to the foreground once the worker reports `showing` — attaching input queues and calling `SetForegroundWindow`, retried on the close cadence until the window (created inside `Show`) exists. The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs in a spawned child process so the modal `Show` never blocks the host event loop; the child posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), killing the child when the close budget is exhausted. The dialog is the child's first window, so Windows activates it without a foreground call. The child thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The PowerShell chain that preceded this tier is gone (see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)): the tier has no fallback. ## Alternatives considered - **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. -- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that `worker_threads` + koffi express in TypeScript. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. - **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. - **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. ## Consequences -- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. -- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. -- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is a native-crash risk that can take down the whole Node process — `worker_threads` share the process, so an access violation is not contained to the worker and no PowerShell fallback runs. The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. -- The packaged-binary VFS arm — resolution of `./worker.cjs` inside a pkg snapshot — is not exercised by any automated test: the source worker and the built `lib/worker.cjs` under plain Node are covered, and the VFS-specific spawn remains deferred to the Windows CI roadmap. +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index a67a22625d..6b90dc1c5f 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Win32 文件夹选择器经 koffi 移入进程内 +# Agent Note:Win32 文件夹选择器迁至 koffi 子进程 Status: implemented @@ -10,18 +10,18 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 输入队列上的窗口默认只会被显示而不会被激活,因此 driver 还会在 worker 上报 `showing` 后把对话框抬升到前台——附加输入队列并调用 `SetForegroundWindow`,按关闭节奏重试直到 `Show` 内创建的窗口出现。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 spawn 出的子进程中,模态 `Show` 永不阻塞宿主事件循环;子进程在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,关闭预算耗尽时 kill 子进程。对话框是子进程的第一个窗口,Windows 会自动激活它,无需手动前台调用。子进程线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。先于本层存在的 PowerShell 链已被删除(见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)):该层无回退。 ## 考虑过的替代方案 - **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 -- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而 `worker_threads` + koffi 用 TypeScript 就能表达。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 - **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 - **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 ## 后果 -- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 -- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 -- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是可能拖垮整个 Node 进程的原生崩溃风险——`worker_threads` 与主线程共享进程,访问冲突不会只局限在 worker 内,也不会进入 PowerShell 回退。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 -- 打包二进制的 VFS 臂——在 pkg 快照内解析 `./worker.cjs`——不受任何自动化测试覆盖:源码 worker 与普通 Node 下构建出的 `lib/worker.cjs` 已被覆盖,VFS 专属的 spawn 推迟到 Windows CI 路线图。 +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾)。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的臂——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码平面与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml index d57f65ad1c..344dd2bf6c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # 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-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: 28630660d1370826c1997be342727175adb081ce -2026-08-01-windows-picker-pwsh-dpi.zh.md: 2be0231032898022cb3d54494fb06b86902b0c66 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md +2026-08-04-drop-windows-powershell-picker-fallback.md: 619afd31d9ec78cdb8565e29fa942b7db8749365 +2026-08-04-drop-windows-powershell-picker-fallback.zh.md: e14904db46a955d4cf40da195a39bf62cbef96ff diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md new file mode 100644 index 0000000000..619afd31d9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md @@ -0,0 +1,38 @@ +# Agent Note: Drop the Windows PowerShell picker fallback + +Status: implemented + +English | [中文](2026-08-04-drop-windows-powershell-picker-fallback.zh.md) + +## Problem + +The win32 branch of the native directory picker kept a two-tier PowerShell fallback under the koffi `IFileOpenDialog` child process: `pwsh.exe` first, then `powershell.exe` (Windows PowerShell 5.1), both running the same WinForms script with a `SetProcessDPIAware` opt-in. The chain existed to keep a working chooser when the koffi tier was "unavailable", but every trigger it plausibly protected was a failure of our own packaging or deployment, not of the operating system: + +- koffi's native binary ships as an ordinary optional dependency (`@koromix/koffi-win32-x64`, no install script); a host that installs the package at all has the binary, and a host that cannot install it fails the package install loudly — the fallback code never loads either. +- "Ancient Windows" cannot occur: the Node versions this repo supports run on Windows generations far newer than the Vista-era `IFileOpenDialog` ABI the dialog needs. +- A koffi/COM defect crashes only the dialog child process (crash isolation); the correct response to our own bug is a surfaced failure, not a silent downgrade to a legacy dialog. + +The chain also cost real complexity: two spawn tiers running one identical script, a fallback trigger widened from `ENOENT` to any pwsh failure to close the PowerShell 6 (no WinForms) regression, a triple-miss `AggregateError` carrying all three causes, and per-tier abort re-checks. The seam already owns the only fallback that matters — the `browse` backend at the composition level, chosen once at boot by `directory-picker-auto`. + +## Decision + +The win32 tier is exactly the koffi `IFileOpenDialog` child process; any failure surfaces as-is with no fallback. The PowerShell chain — the `pwsh` → Windows PowerShell 5.1 cascade, the DPI-corrected WinForms script, the `AggregateError` aggregation — is deleted, and `pickNativeDirectory`'s win32 branch is a single call. `dsh-native-command` remains a dependency for the POSIX tiers. + +The fallback criterion the rest of the package already followed now applies uniformly: a fallback tier exists only for tools the OS/desktop environment provides and may omit (`zenity` → `kdialog` on Linux, which the boot-time probe also samples); tools our own package ships (`koffi`) fail loud. macOS `osascript` stays fallback-free as before. + +This change consolidates and deletes the pwsh-first DPI picker-fix note: its decision is fully reversed here, and its preserved rationale no longer guides future work on a koffi-only tier. What it kept that was real: PowerShell 7 renders the modern `IFileDialog`-based folder picker where 5.1's `FolderBrowserDialog` is hardwired to the legacy `SHBrowseForFolder` tree; the script's `SetProcessDPIAware` corrected the spawn's system-DPI ceiling; the pwsh→5.1 hop existed because a resolvable PowerShell 6 has no WinForms (exit 1, not `ENOENT`). Its rejected alternatives (requiring PowerShell 7, importing `resolvePwshPath`, setting DPI awareness in the harness process) are moot with the chain gone. + +## Alternatives considered + +**Keep the chain but drop the pwsh quality tier (`koffi` → Windows PowerShell 5.1).** Rejected: the remaining tier still defends our own packaged dependency, still costs the script, the widened trigger, and the aggregation, and still hides our own vtable/COM defects behind a legacy dialog. The criterion "fallback only for externally provided tools" admits no Windows tier at all. + +**Keep the chain as-is.** Rejected: it was the only two-level runtime fallback in the picker surface, its triggers were deployment-side failures that fail loud anyway, and it degraded a failed pick into an `AggregateError` whose most actionable entry was a PowerShell host. + +**Fall back to `browse` at runtime when the native pick fails.** Rejected: the seam's flow holes are `single`-kind and the `-auto` composition already picks one backend at boot; a runtime cross-kind hop would double-mount both backends and blur the capability boundary. + +## Consequences + +- The win32 picker's failure surface is one error from one tier; callers see the real cause (koffi load failure, COM refusal, dialog crash) instead of a chain-aggregated error. +- `pwsh`/`powershell.exe` are no longer invoked by this package; the WinForms script, its `SetProcessDPIAware` correction, and the `-STA` flags are gone with them. +- Tests shrink accordingly: the pwsh/5.1 cascade and triple-miss cases are replaced by one "failure surfaces with no fallback" case; the default-adapter test now drives the Linux tier. +- Reintroduction condition: a future win32 mechanism outside our packaging chain (a system-provided dialog host we do not ship) would justify a single fallback tier under the same criterion. diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md new file mode 100644 index 0000000000..e14904db46 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md @@ -0,0 +1,38 @@ +# Agent Note:删除 Windows PowerShell 选择器回退 + +Status: implemented + +[English](2026-08-04-drop-windows-powershell-picker-fallback.md) | 中文 + +## Problem + +原生目录选择器的 win32 分支在 koffi `IFileOpenDialog` 子进程之下保留了一条两级 PowerShell 回退:先 `pwsh.exe`,再 `powershell.exe`(Windows PowerShell 5.1),两者运行同一个带 `SetProcessDPIAware` 开关的 WinForms 脚本。该链的存在是为了在 koffi 层"不可用"时仍能给出一个可用的选择器,但它可能保护的每一个触发条件都是我们自己打包或部署的失败,而不是操作系统的: + +- koffi 的原生二进制作为普通 optional 依赖(`@koromix/koffi-win32-x64`,无 install script)分发;能装上该包的宿主就一定有二进制,装不上的宿主会在安装期大声失败——回退代码同样不会加载。 +- "上古 Windows"不可能出现:本仓库支持的 Node 版本运行在远比 Vista 时代 `IFileOpenDialog` ABI 新的 Windows 世代上。 +- koffi/COM 缺陷只崩对话框子进程(crash isolation);对我们自己 bug 的正确反应是上报失败,而不是静默降级到旧版对话框。 + +这条链还付出了真实的复杂度:两个 spawn 层运行同一脚本、把回退触发从 `ENOENT` 拓宽为 pwsh 的任何失败以关闭 PowerShell 6(无 WinForms)回归、携带全部三个原因的三连败 `AggregateError`,以及每层的 abort 重检。seam 早已拥有唯一重要的回退——组合层面的 `browse` 后端,由 `directory-picker-auto` 在启动时选择一次。 + +## Decision + +win32 层恰好就是 koffi `IFileOpenDialog` 子进程;任何失败原样上报,无回退。PowerShell 链——`pwsh` → Windows PowerShell 5.1 级联、DPI 修正的 WinForms 脚本、`AggregateError` 聚合——被删除,`pickNativeDirectory` 的 win32 分支成为单次调用。`dsh-native-command` 仍为 POSIX 层保留依赖。 + +本包其余部分早已遵循的回退判据现在统一适用:回退层只存在于操作系统/桌面环境提供且可能缺失的工具(Linux 的 `zenity` → `kdialog`,启动探针同样采样它们);我们自己打包的工具(`koffi`)失败即大声报错。macOS `osascript` 与之前一样保持无回退。 + +本次变更合并并删除了 pwsh 优先的 DPI 选择器修复 Note:其决策在此被完全反转,其保留的 rationale 对只含 koffi 的层不再指导未来工作。其中真实的部分:PowerShell 7 呈现基于 `IFileDialog` 的现代文件夹选择器,而 5.1 的 `FolderBrowserDialog` 被硬连到旧版 `SHBrowseForFolder` 树;脚本的 `SetProcessDPIAware` 修正了 spawn 的系统 DPI 上限;pwsh→5.1 的跳转存在是因为可解析的 PowerShell 6 没有 WinForms(退出码 1,而非 `ENOENT`)。其被拒绝的替代方案(要求 PowerShell 7、导入 `resolvePwshPath`、在 harness 进程设置 DPI 感知)随链删除而失去意义。 + +## Alternatives considered + +**保留链但去掉 pwsh 质量层(`koffi` → Windows PowerShell 5.1)。** 拒绝:剩下的层仍在为我们自己打包的依赖辩护,仍要付出脚本、拓宽的触发与聚合的代价,仍会把我们自己的 vtable/COM 缺陷藏到旧版对话框后面。"仅对外部提供的工具回退"的判据不接受任何 Windows 层。 + +**原样保留链。** 拒绝:它是选择器面上唯一的二级运行时回退,其触发条件是本就大声失败的部署侧失败,并且它把失败的 pick 降级成一个最具可操作性的条目是 PowerShell 宿主的 `AggregateError`。 + +**原生 pick 失败时在运行时回退到 `browse`。** 拒绝:seam 的流程洞是 `single` kind,`-auto` 组合已在启动时选择一个后端;运行时跨 kind 跳转会双挂两个后端并模糊能力边界。 + +## Consequences + +- win32 选择器的失败面是来自单一层的一个错误;调用方看到真实原因(koffi 加载失败、COM 拒绝、对话框崩溃),而不是链式聚合的错误。 +- 本包不再调用 `pwsh`/`powershell.exe`;WinForms 脚本、其 `SetProcessDPIAware` 修正与 `-STA` 标志随之消失。 +- 测试相应缩减:pwsh/5.1 级联与三连败用例被一个"失败原样上报、无回退"用例取代;默认适配器测试改驱动 Linux 层。 +- 重新引入条件:未来出现在我们打包链之外的 win32 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 60f534e83b..c1b47710a7 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/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/host/directory-picker-native/README.md -README.md: d48622dead56cce842e0bef0207079ff85b22588 -README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31 +README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9 +README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index d48622dead..3d270af441 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). @@ -17,5 +17,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). -- **The Windows fallback chain degrades the dialog** — the in-process picker is the modern Explorer-style dialog; where koffi cannot drive COM the PowerShell tiers take over, and a machine that only reaches Windows PowerShell 5.1 gets the legacy folder tree, DPI-corrected but not the modern UI. -- **A wedged abort can leak one dialog thread** — when `WM_CLOSE` never lands (the dialog window was never created), the driver terminates and unrefs the worker; Node cannot interrupt a thread blocked in the native modal call, so that thread lives until process exit. +- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 33cb11b4e7..b4a3d91b68 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 @@ -17,5 +17,4 @@ ## 已知限制与延期工作 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 -- **Windows 回退链会降级对话框**——进程内选择器就是现代资源管理器风格对话框;koffi 无法驱动 COM 时由 PowerShell 层级接手,最终只到达 Windows PowerShell 5.1 的机器得到旧版文件夹树,DPI 已修正,但界面不是现代的。 -- **卡死的中止可能泄漏一个对话框线程**——当 `WM_CLOSE` 始终投递不到(对话框窗口从未创建)时,driver 会 terminate 并 unref 该 worker;Node 无法打断阻塞在原生模态调用里的线程,因此该线程会存活到进程退出。 +- **Windows 没有机制级回退**——子进程选择器是唯一层级:koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。 diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index 7e253ee634..3a7e3bd05f 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -2,11 +2,10 @@ * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; - * Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM - * conversation on a worker thread — and falls back to a PowerShell-hosted - * dialog (`pwsh`, then Windows PowerShell 5.1) when that native surface is - * unavailable). Only viable when the operator sits at the host's screen; - * remote deployments compose the browse backend instead. + * Windows opens the modern `IFileOpenDialog` in a spawned child process — a + * koffi-driven COM conversation on the child's main thread). Only viable when + * the operator sits at the host's screen; remote deployments compose the + * browse backend instead. * @module @deepseek-ai/dsh-host-directory-picker-native */ diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 4b4d75fe32..e25b04ce6c 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -67,62 +67,13 @@ export async function pickNativeDirectory( } if (platform === 'win32') { - // Primary: the in-process koffi-backed IFileOpenDialog worker — the modern - // picker with per-monitor-v2 DPI, no PowerShell dependency, and abort - // support. Any non-abort failure (koffi unavailable, ancient Windows, COM - // refusal) falls back to the PowerShell chain below. + // The koffi-backed IFileOpenDialog child process — the modern picker with + // per-monitor-v2 DPI and abort support. koffi is a packaged dependency + // whose availability the install guarantees, so there is no fallback + // tier: any failure surfaces as-is (the former PowerShell chain was + // removed — see the simplification Agent Note). const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory - let dialogError: unknown - try { - return await pickDialog(signal) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - dialogError = error - } - - // PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder - // picker, while Windows PowerShell 5.1's FolderBrowserDialog is hardwired - // to the legacy SHBrowseForFolder tree. Prefer pwsh, but ANY pwsh failure - // falls back to 5.1 (which every Windows ships): a resolvable pwsh can - // still be unable to deliver the dialog — PowerShell 6 has no WinForms, - // so its Add-Type exits 1, not ENOENT. Both hosts spawn DPI-unaware, so - // the script opts the process into system DPI awareness before any window - // is created. No Description is set: the modern dialog renders it as a - // bottom strip and the classic dialog as an unthemed box. - const script = [ - "$ErrorActionPreference = 'Stop'", - "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'", - '[DpiAware]::SetProcessDPIAware() | Out-Null', - 'Add-Type -AssemblyName System.Windows.Forms', - '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', - '$dialog.ShowNewFolderButton = $true', - '$result = $dialog.ShowDialog()', - 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', - ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - ' [Console]::WriteLine($dialog.SelectedPath)', - '}', - ].join('; ') - let pwshError: unknown - try { - const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - pwshError = error - } - try { - const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - // Triple miss: every tier failed. Surface all three causes — the - // in-process dialog's reason is otherwise unrecoverable from the last - // PowerShell error alone. - throw new AggregateError( - [dialogError, pwshError, error], - 'native directory picker failed: the in-process dialog and both PowerShell hosts failed', - ) - } + return await pickDialog(signal) } if (platform === 'linux') { diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 24c33e473e..66f4845b4b 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,8 +1,7 @@ /** - * Native picker tier selection and the execFile adapter: the in-process - * dialog primary, the pwsh → Windows PowerShell 5.1 fallback chain (any - * non-abort pwsh failure cascades), the abort-never-falls-through rule, and - * the triple-miss AggregateError carrying the dialog/pwsh/5.1 causes. + * Native picker tier selection and the execFile adapter: the Win32 dialog + * primary (failures surface as-is, no fallback tier), the abort rule, and + * the POSIX command tiers (osascript, Zenity → KDialog). */ type ExecFileCallback = ( @@ -30,7 +29,7 @@ function failure(code: string | number, stderr = ''): Error { const signal = () => new AbortController().signal -/** The PowerShell chain is reachable only when the in-process dialog fails. */ +/** A Win32 dialog that always fails — the no-fallback case. */ const noDialog = async (): Promise => { throw new Error('dialog unavailable') } describe('native directory picker', () => { @@ -56,7 +55,7 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('prefers the in-process Win32 dialog and never spawns PowerShell when it answers', async () => { + it('uses the Win32 dialog and never spawns a command when it answers', async () => { const run = vi.fn() const pickWin32Dialog = vi.fn(async (): Promise => 'C:\\work\\selected') await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected') @@ -65,55 +64,11 @@ describe('native directory picker', () => { expect(run).not.toHaveBeenCalled() }) - it('falls back to pwsh when the dialog is unavailable and maps empty output to cancellation', async () => { - const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\project') - expect(run).toHaveBeenCalledWith( - 'pwsh.exe', - expect.arrayContaining(['-NoProfile', '-STA', '-Command']), - expect.any(AbortSignal), - ) - const script = run.mock.calls[0]?.[1].at(-1) - expect(script).toContain("$ErrorActionPreference = 'Stop'") - expect(script).toContain('SetProcessDPIAware') - // Description renders as a bottom strip (modern) / unthemed box (classic); never set it. - expect(script).not.toContain('Description') - run.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBeNull() - }) - - it('falls back to Windows PowerShell 5.1 whenever pwsh cannot deliver the dialog', async () => { + it('surfaces the Win32 dialog failure with no fallback', async () => { const run = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\fallback') - expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) - // Both runtimes execute the identical script, so DPI awareness holds either way. - expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1)) - - // A resolvable pwsh that cannot deliver the dialog (PowerShell 6: no - // WinForms, Add-Type exits 1 - not ENOENT) reaches 5.1 all the same. - const pwsh6 = vi.fn() - .mockRejectedValueOnce(failure(1, "Cannot load assembly 'System.Windows.Forms'")) - .mockResolvedValueOnce({ stdout: 'C:\\work\\legacy\r\n', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: pwsh6, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\legacy') - expect(pwsh6.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) - - const cancelled = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull() - - // Triple miss: the surfaced AggregateError carries all three causes, - // including the otherwise-lost in-process dialog failure. - const failed = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockRejectedValueOnce(failure(2)) - const tripleMiss = await pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog }) - .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) - expect(tripleMiss.message).toContain('the in-process dialog and both PowerShell hosts failed') - expect((tripleMiss.errors[0] as Error).message).toBe('dialog unavailable') - expect((tripleMiss.errors[2] as Error).message).toContain('command failed') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })) + .rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() }) it('wires the real Win32 dialog as the default tier', async () => { @@ -127,52 +82,38 @@ describe('native directory picker', () => { expect(run).not.toHaveBeenCalled() }) - it('does not fall back when the caller aborted the dialog or the pwsh spawn', async () => { + it('does not fall back when the caller aborted the dialog', async () => { const abort = new AbortController() abort.abort(new Error('closed')) const run = vi.fn() await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable') expect(run).not.toHaveBeenCalled() - - const liveThenAborted = new AbortController() - const abortingRun = vi.fn(async () => { - liveThenAborted.abort(new Error('closed')) - throw failure('ENOENT') - }) - await expect(pickNativeDirectory(liveThenAborted.signal, { platform: 'win32', run: abortingRun, pickWin32Dialog: noDialog })) - .rejects.toThrow('command failed') - expect(abortingRun).toHaveBeenCalledOnce() }) it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'C:\\work\\default\r\n', '') + callback(null, '/home/test/project\n', '') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\default') + await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project') const [command, args, options] = execFileMock.mock.calls[0]! - expect(command).toBe('pwsh.exe') - expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(command).toBe('zenity') + expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory'])) expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) - // Both chain tiers fail: pwsh's code-7 failure now reaches 5.1, whose - // failure is the one the caller sees. - const pwshError = Object.assign(new Error('pwsh failed'), { code: 7 }) - const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + // A non-cancellation command failure surfaces as-is with its cause and + // captured stdio attached; no tier masks or rewraps it. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(pwshError, '', 'no WinForms') + callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details') }) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(commandError, 'partial output', 'failure details') - }) - const surfaced = await pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog }) - .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) - expect(surfaced.errors[2]).toMatchObject({ - message: 'powershell failed', cause: commandError, code: 7, + const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error) + expect(surfaced).toMatchObject({ + message: 'zenity failed', code: 7, stdout: 'partial output', stderr: 'failure details', }) - expect(execFileMock.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'pwsh.exe', 'powershell.exe']) + expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error) }) it('uses the current process platform when no platform override is supplied', async () => { @@ -184,6 +125,11 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected) }) + it('maps empty command output to cancellation', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull() + }) + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { const run = vi.fn() .mockRejectedValueOnce(failure('ENOENT')) From 00621f92d29a4b22b925da27ced6169cb886bd43 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Tue, 4 Aug 2026 01:49:05 +0800 Subject: [PATCH 17/17] fix(picker): keep the worker-boundary mock off the vitest IPC channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mocked process.send consumed vitest's own fork-pool IPC messages and immediately ran the post callback, whose disconnect() severed the test worker's channel (process.connected is true under forks) — the whole spec's results vanished and win32-dialog-bindings.ts/win32-dialog-worker.ts fell to near-zero coverage on CI. The mock now records without invoking the callback or disconnecting; the real close lifecycle stays with built-worker.e2e.ts. Verified under both the threads and forks pools. --- .../tests/win32-dialog-bindings.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index 9403bb7e36..b8ff4c3f1a 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -271,9 +271,13 @@ describe('the worker entry over a mocked process boundary', () => { const installBoundary = (): { posted: { kind: string; message?: string }[] } => { const posted: { kind: string; message?: string }[] = [] process.env.DSH_DIALOG_TITLE = 'Pick' - ;(process as { send?: unknown }).send = (message: { kind: string }, callback?: () => void) => { + // Never invoke the post callback: it runs the worker's disconnect(), and + // this process is IPC-connected under the forks pool — severing vitest's + // own channel would kill the test worker. The real close lifecycle + // belongs to built-worker.e2e.ts. + ;(process as { send?: unknown }).send = (message: { kind: string }) => { posted.push(message) - callback?.() + return true } return { posted } }