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',