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).
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)所有。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 冒烟捕获。
|
||||
Reference in New Issue
Block a user