diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md deleted file mode 100644 index 28630660d1..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: Windows directory picker prefers pwsh and forces DPI awareness - -Status: implemented - -English | [中文](2026-08-01-windows-picker-pwsh-dpi.zh.md) - -## Problem - -The Windows branch of the native directory picker spawned Windows PowerShell 5.1's `FolderBrowserDialog`, which .NET Framework hardwires to the legacy `SHBrowseForFolder` tree dialog: no address bar, search, or quick access. The same process is DPI-unaware (`powershell.exe` declares no DPI awareness), so on scaled displays Windows renders the dialog at 96 DPI and bitmap-stretches it — blurry text and soft edges. Both defects were visible at once on any display above 100 % scaling. - -## Decision - -The PowerShell chain is now the FALLBACK tier below the in-process koffi dialog (see the [in-process folder dialog note](../feature/2026-08-02-win32-in-process-folder-dialog.md)): the win32 branch spawns `pwsh.exe` (PowerShell 7) first and falls back to `powershell.exe` (Windows PowerShell 5.1) on ANY pwsh failure — a resolvable PowerShell 6 has no WinForms and exits 1, not `ENOENT`, and 5.1 ships with every Windows. PowerShell 7 renders the modern Explorer-style folder picker because .NET Core 3.0 rewrote `FolderBrowserDialog` over `IFileDialog` (unconditionally; the later `AutoUpgradeEnabled` opt-out arrived in .NET 6 and the script never sets it). Both runtimes execute the identical script, which calls `SetProcessDPIAware()` (user32) before any window exists, so the dialog is system-DPI-aware no matter which host serves it. The script sets no `Description`: the modern `FolderBrowserDialog` renders it as a bottom strip above the folder input, and the 5.1 classic dialog as an unthemed box, so the property is dropped entirely. `-STA` stays explicit for both, and the fallback keeps the seam's cancellation/failure contract (`null` on cancel, a retryable error otherwise). The host-boundary, RPC trust, and cancellation decisions stay with the [picker feature note](../feature/2026-07-27-native-workspace-directory-picker.md). - -## Alternatives considered - -- **Require PowerShell 7.** Rejected: pwsh is not a Windows built-in, so machines without it would lose the only workspace-creation route; the 5.1 fallback keeps the dialog functional, and DPI is corrected there too. -- **Import `resolvePwshPath` from `dsh-pwsh-local`.** Rejected for this change: a host GUI package importing from a bash-executor package is a cross-seam coupling, and PATH-based `execFile` resolution plus `ENOENT` fallback already covers the practical installs (Program Files, Store aliases); single-source resolution remains a follow-up if the two consumers drift. -- **Set DPI awareness in the harness process.** Rejected: DPI awareness is per-process, and the dialog lives in a spawned child that inherits nothing from the parent's absent declaration. -- **Per-monitor v2 (`SetProcessDpiAwarenessContext`).** Deferred: system-aware is the ceiling .NET Framework WinForms supports, the shell dialog handles per-monitor rendering itself on modern Windows, and one call keeps both runtimes on a single code path. - -## Consequences - -- Machines with PowerShell 7 get the modern folder picker; 5.1-only machines keep the legacy tree — now sharp — and the package README's Known Limitations documents the gap. -- The PowerShell chain itself adds no packages or dependencies (koffi and tsx arrived with the in-process primary and belong to its note); the pwsh→5.1 hop triggers on ANY non-abort pwsh failure — no `ENOENT` classification remains on the win32 path — while abort propagation is unchanged. -- The command boundary (`DirectoryPickerRunner`) pins the spawn order and script content in unit tests; real dialog rendering remains a manual Windows check, as before. diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md b/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md deleted file mode 100644 index 2be0231032..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.zh.md +++ /dev/null @@ -1,26 +0,0 @@ -# Agent Note: Windows 目录选择器优先 pwsh 并强制 DPI awareness - -Status: implemented - -[English](2026-08-01-windows-picker-pwsh-dpi.md) | 中文 - -## 问题 - -原生目录选择器的 Windows 分支原先启动 Windows PowerShell 5.1 的 `FolderBrowserDialog`,而 .NET Framework 将其硬编码为旧版 `SHBrowseForFolder` 树形对话框:没有地址栏、搜索或快速访问。同一进程又是 DPI-unaware 的(`powershell.exe` 未声明任何 DPI awareness),因此在缩放显示器上,Windows 会以 96 DPI 渲染该对话框再位图拉伸——文字模糊、边缘发虚。任何超过 100% 缩放的显示器上,两个缺陷同时可见。 - -## 决策 - -PowerShell 链现在是进程内 koffi 对话框之下的回退层(见[进程内文件夹对话框 Note](../feature/2026-08-02-win32-in-process-folder-dialog.md)):win32 分支先启动 `pwsh.exe`(PowerShell 7),并在 pwsh 的任何失败上回退到 `powershell.exe`(Windows PowerShell 5.1)——可解析的 PowerShell 6 没有 WinForms,以退出码 1 而非 `ENOENT` 失败,而 5.1 每台 Windows 都自带。PowerShell 7 呈现现代资源管理器风格选择器,是因为 .NET Core 3.0 用 `IFileDialog` 重写了 `FolderBrowserDialog`(无条件生效;更晚的 `AutoUpgradeEnabled` 退出开关到 .NET 6 才加入,脚本从未设置它)。两个运行时执行完全相同的脚本,脚本在任何窗口存在前调用 `SetProcessDPIAware()`(user32),因此无论由哪个宿主服务,对话框都系统 DPI aware。脚本不设置 `Description`:现代 `FolderBrowserDialog` 会把它渲染成文件夹输入框上方的一条底带,5.1 经典对话框则渲染成未主题化的色块,因此该属性被整体移除。两个运行时都显式保留 `-STA`;回退维持 seam 的取消/失败契约(取消返回 `null`,其余为可重试错误)。宿主边界、RPC 信任与取消决策仍归[选择器功能 Note](../feature/2026-07-27-native-workspace-directory-picker.md)所有。 - -## 考虑过的替代方案 - -- **强制要求 PowerShell 7。** 否决:pwsh 并非 Windows 内置,没有它的机器将失去唯一的工作区创建路径;5.1 回退保持对话框可用,且 DPI 在那里同样被修正。 -- **从 `dsh-pwsh-local` 导入 `resolvePwshPath`。** 本变更否决:host GUI 包依赖 bash 执行器包是跨 seam 耦合;PATH 上的 `execFile` 解析加 `ENOENT` 回退已覆盖实际安装形态(Program Files、Store 别名);若两个消费者日后漂移,单一来源解析留作后续。 -- **在 harness 进程内设置 DPI awareness。** 否决:DPI awareness 是进程级的,而对话框位于派生的子进程中,不会继承父进程缺失的声明。 -- **Per-monitor v2(`SetProcessDpiAwarenessContext`)。** 暂缓:system-aware 是 .NET Framework WinForms 的上限,现代 Windows 中 shell 对话框自身处理 per-monitor 渲染,且一次调用让两个运行时共用一条代码路径。 - -## 后果 - -- 装有 PowerShell 7 的机器获得现代文件夹选择器;只有 5.1 的机器保留旧版树——但现在清晰了——包 README 的已知限制记录了该差距。 -- PowerShell 链本身不新增任何包或依赖(koffi 与 tsx 随进程内主层引入,归属其 Note);pwsh→5.1 的跳转在 pwsh 的任何非中止失败上触发——win32 路径上已不存在 `ENOENT` 分类——中止传播不变。 -- 命令边界(`DirectoryPickerRunner`)在单元测试中固定启动顺序与脚本内容;真实对话框渲染仍与以前一样属于手动 Windows 检查。 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml index e49bbe59cc..dade9b5d90 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md -2026-07-27-native-workspace-directory-picker.md: 452ec60371558de79dbff964a12d96d2150dc6f6 -2026-07-27-native-workspace-directory-picker.zh.md: c3e6b8825e78201ce791cff51869aade67d30a5e +2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2 +2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8 diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md index 452ec60371..a36f7b239a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md @@ -30,7 +30,7 @@ The native dialog RPC is accepted only from a loopback socket with same-origin b Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows: - macOS: `osascript` and the system folder chooser. -- Windows: the in-process koffi `IFileOpenDialog` worker with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the PowerShell chain (`pwsh` in STA mode, then Windows PowerShell 5.1, both DPI-corrected) remains the fallback ([picker fix](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)). +- Windows: the koffi `IFileOpenDialog` child process with the best thread DPI awareness the host accepts (per-monitor-v2 when available; PMv2-less hosts cascade to per-monitor or system-aware) ([in-process dialog note](2026-08-02-win32-in-process-folder-dialog.md)); the tier has no fallback — failures surface as-is ([PowerShell chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). - Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md index c3e6b8825e..bb3e2fc6f7 100644 --- a/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.zh.md @@ -27,10 +27,10 @@ Status: implemented 只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。 -平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是进程内 COM 会话: +平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话: - macOS:`osascript` 和系统文件夹选择器。 -- Windows:进程内 koffi `IFileOpenDialog` worker,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));PowerShell 链(STA 模式的 `pwsh`,再到 Windows PowerShell 5.1,均已修正 DPI)保留为回退(见[选择器修复](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))。 +- Windows:koffi `IFileOpenDialog` 子进程,使用宿主接受的最佳线程 DPI 感知(可用时为 per-monitor-v2;不支持 PMv2 的主机级联到 per-monitor 或 system-aware)(见[进程内对话框 Note](2026-08-02-win32-in-process-folder-dialog.md));该层无回退——失败原样上报(见[PowerShell 链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。 - Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index 9f428ccd78..2ec7925a3e 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: c7a6602836c618e855c799b72017aa9232eda968 -2026-08-02-win32-in-process-folder-dialog.zh.md: a67a22625dcd674b2a63b6125b3d31704e90eabc +2026-08-02-win32-in-process-folder-dialog.md: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82 +2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index c7a6602836..91a1ed0d7b 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -1,4 +1,4 @@ -# Agent Note: Win32 folder picker moves in-process over koffi +# Agent Note: Win32 folder picker moves to koffi in a child process Status: implemented @@ -10,18 +10,18 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). A window on a worker input queue would otherwise be shown without activation, so the driver also raises the dialog to the foreground once the worker reports `showing` — attaching input queues and calling `SetForegroundWindow`, retried on the close cadence until the window (created inside `Show`) exists. The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs in a spawned child process so the modal `Show` never blocks the host event loop; the child posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), killing the child when the close budget is exhausted. The dialog is the child's first window, so Windows activates it without a foreground call. The child thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. The PowerShell chain that preceded this tier is gone (see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)): the tier has no fallback. ## Alternatives considered - **A prebuilt native helper (`native/` family like `node-addon-landlock-run`).** Rejected: a mirror repository, an npm package family, MSVC provisioning, and a release handoff — all to ship ~150 lines of C the repository cannot exercise on CI (no real-Windows lane); koffi delivers the same COM surface with zero new supply chain. -- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that `worker_threads` + koffi express in TypeScript. +- **An N-API in-process addon.** Rejected for the same CI/toolchain reasons plus owned C++ for STA threading and message pumping that a child process + koffi express in TypeScript. - **Keep PowerShell primary and probe versions.** Rejected: the picker stays hostage to shell packaging (6 vs 7, Store aliases, profiles), and 5.1's legacy dialog remains the floor wherever pwsh is absent; the fallback-trigger widening alone was accepted into the fallback tier instead. - **Blocking the main thread for the modal call.** Rejected outright: the web host must keep serving RPC while the dialog is open. ## Consequences -- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. -- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. -- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is a native-crash risk that can take down the whole Node process — `worker_threads` share the process, so an access violation is not contained to the worker and no PowerShell fallback runs. The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. -- The packaged-binary VFS arm — resolution of `./worker.cjs` inside a pkg snapshot — is not exercised by any automated test: the source worker and the built `lib/worker.cjs` under plain Node are covered, and the VFS-specific spawn remains deferred to the Windows CI roadmap. +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not. +- Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind). +- The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake risks a native access violation, contained to the dialog child process — the host Node process survives and the failure surfaces as-is (no fallback tier; see the [chain removal](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)). The mocked-koffi ABI pins and the real win32 smoke exist to catch such mistakes before shipping. +- The packaged-binary arm — the packaged executable spawning itself as the dialog entry — is not exercised by any automated test: the source plane and the built `lib/worker.cjs` under plain node are covered, and the packaged spawn remains deferred to the Windows CI roadmap. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index a67a22625d..6b90dc1c5f 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -1,4 +1,4 @@ -# Agent Note:Win32 文件夹选择器经 koffi 移入进程内 +# Agent Note:Win32 文件夹选择器迁至 koffi 子进程 Status: implemented @@ -10,18 +10,18 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 输入队列上的窗口默认只会被显示而不会被激活,因此 driver 还会在 worker 上报 `showing` 后把对话框抬升到前台——附加输入队列并调用 `SetForegroundWindow`,按关闭节奏重试直到 `Show` 内创建的窗口出现。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 spawn 出的子进程中,模态 `Show` 永不阻塞宿主事件循环;子进程在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,关闭预算耗尽时 kill 子进程。对话框是子进程的第一个窗口,Windows 会自动激活它,无需手动前台调用。子进程线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。先于本层存在的 PowerShell 链已被删除(见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md)):该层无回退。 ## 考虑过的替代方案 - **预编译原生助手(`native/` 家族,如 `node-addon-landlock-run`)。** 否决:镜像仓库、npm 包家族、MSVC 供给和发布交接——只为交付约 150 行 CI 无法执行的 C(没有真 Windows 通道);koffi 以零新增供应链提供同一 COM 面。 -- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而 `worker_threads` + koffi 用 TypeScript 就能表达。 +- **N-API 进程内插件。** 否决:同样的 CI/工具链原因,另加需要自有 C++ 处理 STA 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。 - **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。 - **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。 ## 后果 -- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 -- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 -- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是可能拖垮整个 Node 进程的原生崩溃风险——`worker_threads` 与主线程共享进程,访问冲突不会只局限在 worker 内,也不会进入 PowerShell 回退。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 -- 打包二进制的 VFS 臂——在 pkg 快照内解析 `./worker.cjs`——不受任何自动化测试覆盖:源码 worker 与普通 Node 下构建出的 `lib/worker.cjs` 已被覆盖,VFS 专属的 spawn 推迟到 Windows CI 路线图。 +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell。 +- 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾)。 +- 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误可能引发原生访问冲突,但被限制在对话框子进程内——宿主 Node 进程存活,失败原样上报(无回退层;见[链删除](../simplification/2026-08-04-drop-windows-powershell-picker-fallback.md))。mocked-koffi 的 ABI 钉与真实 win32 冒烟正是为了在交付前捕获这类错误。 +- 打包二进制的臂——打包后的可执行文件以对话框入口形式自我 spawn——不受任何自动化测试覆盖:源码平面与普通 node 下构建出的 `lib/worker.cjs` 已被覆盖,打包 spawn 推迟到 Windows CI 路线图。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml similarity index 52% rename from .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml index d57f65ad1c..344dd2bf6c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-01-windows-picker-pwsh-dpi.md -2026-08-01-windows-picker-pwsh-dpi.md: 28630660d1370826c1997be342727175adb081ce -2026-08-01-windows-picker-pwsh-dpi.zh.md: 2be0231032898022cb3d54494fb06b86902b0c66 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md +2026-08-04-drop-windows-powershell-picker-fallback.md: 619afd31d9ec78cdb8565e29fa942b7db8749365 +2026-08-04-drop-windows-powershell-picker-fallback.zh.md: e14904db46a955d4cf40da195a39bf62cbef96ff diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md new file mode 100644 index 0000000000..619afd31d9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.md @@ -0,0 +1,38 @@ +# Agent Note: Drop the Windows PowerShell picker fallback + +Status: implemented + +English | [中文](2026-08-04-drop-windows-powershell-picker-fallback.zh.md) + +## Problem + +The win32 branch of the native directory picker kept a two-tier PowerShell fallback under the koffi `IFileOpenDialog` child process: `pwsh.exe` first, then `powershell.exe` (Windows PowerShell 5.1), both running the same WinForms script with a `SetProcessDPIAware` opt-in. The chain existed to keep a working chooser when the koffi tier was "unavailable", but every trigger it plausibly protected was a failure of our own packaging or deployment, not of the operating system: + +- koffi's native binary ships as an ordinary optional dependency (`@koromix/koffi-win32-x64`, no install script); a host that installs the package at all has the binary, and a host that cannot install it fails the package install loudly — the fallback code never loads either. +- "Ancient Windows" cannot occur: the Node versions this repo supports run on Windows generations far newer than the Vista-era `IFileOpenDialog` ABI the dialog needs. +- A koffi/COM defect crashes only the dialog child process (crash isolation); the correct response to our own bug is a surfaced failure, not a silent downgrade to a legacy dialog. + +The chain also cost real complexity: two spawn tiers running one identical script, a fallback trigger widened from `ENOENT` to any pwsh failure to close the PowerShell 6 (no WinForms) regression, a triple-miss `AggregateError` carrying all three causes, and per-tier abort re-checks. The seam already owns the only fallback that matters — the `browse` backend at the composition level, chosen once at boot by `directory-picker-auto`. + +## Decision + +The win32 tier is exactly the koffi `IFileOpenDialog` child process; any failure surfaces as-is with no fallback. The PowerShell chain — the `pwsh` → Windows PowerShell 5.1 cascade, the DPI-corrected WinForms script, the `AggregateError` aggregation — is deleted, and `pickNativeDirectory`'s win32 branch is a single call. `dsh-native-command` remains a dependency for the POSIX tiers. + +The fallback criterion the rest of the package already followed now applies uniformly: a fallback tier exists only for tools the OS/desktop environment provides and may omit (`zenity` → `kdialog` on Linux, which the boot-time probe also samples); tools our own package ships (`koffi`) fail loud. macOS `osascript` stays fallback-free as before. + +This change consolidates and deletes the pwsh-first DPI picker-fix note: its decision is fully reversed here, and its preserved rationale no longer guides future work on a koffi-only tier. What it kept that was real: PowerShell 7 renders the modern `IFileDialog`-based folder picker where 5.1's `FolderBrowserDialog` is hardwired to the legacy `SHBrowseForFolder` tree; the script's `SetProcessDPIAware` corrected the spawn's system-DPI ceiling; the pwsh→5.1 hop existed because a resolvable PowerShell 6 has no WinForms (exit 1, not `ENOENT`). Its rejected alternatives (requiring PowerShell 7, importing `resolvePwshPath`, setting DPI awareness in the harness process) are moot with the chain gone. + +## Alternatives considered + +**Keep the chain but drop the pwsh quality tier (`koffi` → Windows PowerShell 5.1).** Rejected: the remaining tier still defends our own packaged dependency, still costs the script, the widened trigger, and the aggregation, and still hides our own vtable/COM defects behind a legacy dialog. The criterion "fallback only for externally provided tools" admits no Windows tier at all. + +**Keep the chain as-is.** Rejected: it was the only two-level runtime fallback in the picker surface, its triggers were deployment-side failures that fail loud anyway, and it degraded a failed pick into an `AggregateError` whose most actionable entry was a PowerShell host. + +**Fall back to `browse` at runtime when the native pick fails.** Rejected: the seam's flow holes are `single`-kind and the `-auto` composition already picks one backend at boot; a runtime cross-kind hop would double-mount both backends and blur the capability boundary. + +## Consequences + +- The win32 picker's failure surface is one error from one tier; callers see the real cause (koffi load failure, COM refusal, dialog crash) instead of a chain-aggregated error. +- `pwsh`/`powershell.exe` are no longer invoked by this package; the WinForms script, its `SetProcessDPIAware` correction, and the `-STA` flags are gone with them. +- Tests shrink accordingly: the pwsh/5.1 cascade and triple-miss cases are replaced by one "failure surfaces with no fallback" case; the default-adapter test now drives the Linux tier. +- Reintroduction condition: a future win32 mechanism outside our packaging chain (a system-provided dialog host we do not ship) would justify a single fallback tier under the same criterion. diff --git a/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md new file mode 100644 index 0000000000..e14904db46 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-04-drop-windows-powershell-picker-fallback.zh.md @@ -0,0 +1,38 @@ +# Agent Note:删除 Windows PowerShell 选择器回退 + +Status: implemented + +[English](2026-08-04-drop-windows-powershell-picker-fallback.md) | 中文 + +## Problem + +原生目录选择器的 win32 分支在 koffi `IFileOpenDialog` 子进程之下保留了一条两级 PowerShell 回退:先 `pwsh.exe`,再 `powershell.exe`(Windows PowerShell 5.1),两者运行同一个带 `SetProcessDPIAware` 开关的 WinForms 脚本。该链的存在是为了在 koffi 层"不可用"时仍能给出一个可用的选择器,但它可能保护的每一个触发条件都是我们自己打包或部署的失败,而不是操作系统的: + +- koffi 的原生二进制作为普通 optional 依赖(`@koromix/koffi-win32-x64`,无 install script)分发;能装上该包的宿主就一定有二进制,装不上的宿主会在安装期大声失败——回退代码同样不会加载。 +- "上古 Windows"不可能出现:本仓库支持的 Node 版本运行在远比 Vista 时代 `IFileOpenDialog` ABI 新的 Windows 世代上。 +- koffi/COM 缺陷只崩对话框子进程(crash isolation);对我们自己 bug 的正确反应是上报失败,而不是静默降级到旧版对话框。 + +这条链还付出了真实的复杂度:两个 spawn 层运行同一脚本、把回退触发从 `ENOENT` 拓宽为 pwsh 的任何失败以关闭 PowerShell 6(无 WinForms)回归、携带全部三个原因的三连败 `AggregateError`,以及每层的 abort 重检。seam 早已拥有唯一重要的回退——组合层面的 `browse` 后端,由 `directory-picker-auto` 在启动时选择一次。 + +## Decision + +win32 层恰好就是 koffi `IFileOpenDialog` 子进程;任何失败原样上报,无回退。PowerShell 链——`pwsh` → Windows PowerShell 5.1 级联、DPI 修正的 WinForms 脚本、`AggregateError` 聚合——被删除,`pickNativeDirectory` 的 win32 分支成为单次调用。`dsh-native-command` 仍为 POSIX 层保留依赖。 + +本包其余部分早已遵循的回退判据现在统一适用:回退层只存在于操作系统/桌面环境提供且可能缺失的工具(Linux 的 `zenity` → `kdialog`,启动探针同样采样它们);我们自己打包的工具(`koffi`)失败即大声报错。macOS `osascript` 与之前一样保持无回退。 + +本次变更合并并删除了 pwsh 优先的 DPI 选择器修复 Note:其决策在此被完全反转,其保留的 rationale 对只含 koffi 的层不再指导未来工作。其中真实的部分:PowerShell 7 呈现基于 `IFileDialog` 的现代文件夹选择器,而 5.1 的 `FolderBrowserDialog` 被硬连到旧版 `SHBrowseForFolder` 树;脚本的 `SetProcessDPIAware` 修正了 spawn 的系统 DPI 上限;pwsh→5.1 的跳转存在是因为可解析的 PowerShell 6 没有 WinForms(退出码 1,而非 `ENOENT`)。其被拒绝的替代方案(要求 PowerShell 7、导入 `resolvePwshPath`、在 harness 进程设置 DPI 感知)随链删除而失去意义。 + +## Alternatives considered + +**保留链但去掉 pwsh 质量层(`koffi` → Windows PowerShell 5.1)。** 拒绝:剩下的层仍在为我们自己打包的依赖辩护,仍要付出脚本、拓宽的触发与聚合的代价,仍会把我们自己的 vtable/COM 缺陷藏到旧版对话框后面。"仅对外部提供的工具回退"的判据不接受任何 Windows 层。 + +**原样保留链。** 拒绝:它是选择器面上唯一的二级运行时回退,其触发条件是本就大声失败的部署侧失败,并且它把失败的 pick 降级成一个最具可操作性的条目是 PowerShell 宿主的 `AggregateError`。 + +**原生 pick 失败时在运行时回退到 `browse`。** 拒绝:seam 的流程洞是 `single` kind,`-auto` 组合已在启动时选择一个后端;运行时跨 kind 跳转会双挂两个后端并模糊能力边界。 + +## Consequences + +- win32 选择器的失败面是来自单一层的一个错误;调用方看到真实原因(koffi 加载失败、COM 拒绝、对话框崩溃),而不是链式聚合的错误。 +- 本包不再调用 `pwsh`/`powershell.exe`;WinForms 脚本、其 `SetProcessDPIAware` 修正与 `-STA` 标志随之消失。 +- 测试相应缩减:pwsh/5.1 级联与三连败用例被一个"失败原样上报、无回退"用例取代;默认适配器测试改驱动 Linux 层。 +- 重新引入条件:未来出现在我们打包链之外的 win32 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index 60f534e83b..c1b47710a7 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: d48622dead56cce842e0bef0207079ff85b22588 -README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31 +README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9 +README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index d48622dead..3d270af441 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). @@ -17,5 +17,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level). -- **The Windows fallback chain degrades the dialog** — the in-process picker is the modern Explorer-style dialog; where koffi cannot drive COM the PowerShell tiers take over, and a machine that only reaches Windows PowerShell 5.1 gets the legacy folder tree, DPI-corrected but not the modern UI. -- **A wedged abort can leak one dialog thread** — when `WM_CLOSE` never lands (the dialog window was never created), the driver terminates and unrefs the worker; Node cannot interrupt a thread blocked in the native modal call, so that thread lives until process exit. +- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level. diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 33cb11b4e7..b4a3d91b68 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 @@ -17,5 +17,4 @@ ## 已知限制与延期工作 - **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。 -- **Windows 回退链会降级对话框**——进程内选择器就是现代资源管理器风格对话框;koffi 无法驱动 COM 时由 PowerShell 层级接手,最终只到达 Windows PowerShell 5.1 的机器得到旧版文件夹树,DPI 已修正,但界面不是现代的。 -- **卡死的中止可能泄漏一个对话框线程**——当 `WM_CLOSE` 始终投递不到(对话框窗口从未创建)时,driver 会 terminate 并 unref 该 worker;Node 无法打断阻塞在原生模态调用里的线程,因此该线程会存活到进程退出。 +- **Windows 没有机制级回退**——子进程选择器是唯一层级:koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。 diff --git a/packages/host/directory-picker-native/src/index.ts b/packages/host/directory-picker-native/src/index.ts index 7e253ee634..3a7e3bd05f 100644 --- a/packages/host/directory-picker-native/src/index.ts +++ b/packages/host/directory-picker-native/src/index.ts @@ -2,11 +2,10 @@ * Native backend of the directory-picker seam: registers `ctx.directoryPicker` * with the `native` capability, opening one native OS chooser on the host * display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback; - * Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM - * conversation on a worker thread — and falls back to a PowerShell-hosted - * dialog (`pwsh`, then Windows PowerShell 5.1) when that native surface is - * unavailable). Only viable when the operator sits at the host's screen; - * remote deployments compose the browse backend instead. + * Windows opens the modern `IFileOpenDialog` in a spawned child process — a + * koffi-driven COM conversation on the child's main thread). Only viable when + * the operator sits at the host's screen; remote deployments compose the + * browse backend instead. * @module @deepseek-ai/dsh-host-directory-picker-native */ diff --git a/packages/host/directory-picker-native/src/native-picker.ts b/packages/host/directory-picker-native/src/native-picker.ts index 4b4d75fe32..e25b04ce6c 100644 --- a/packages/host/directory-picker-native/src/native-picker.ts +++ b/packages/host/directory-picker-native/src/native-picker.ts @@ -67,62 +67,13 @@ export async function pickNativeDirectory( } if (platform === 'win32') { - // Primary: the in-process koffi-backed IFileOpenDialog worker — the modern - // picker with per-monitor-v2 DPI, no PowerShell dependency, and abort - // support. Any non-abort failure (koffi unavailable, ancient Windows, COM - // refusal) falls back to the PowerShell chain below. + // The koffi-backed IFileOpenDialog child process — the modern picker with + // per-monitor-v2 DPI and abort support. koffi is a packaged dependency + // whose availability the install guarantees, so there is no fallback + // tier: any failure surfaces as-is (the former PowerShell chain was + // removed — see the simplification Agent Note). const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory - let dialogError: unknown - try { - return await pickDialog(signal) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - dialogError = error - } - - // PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder - // picker, while Windows PowerShell 5.1's FolderBrowserDialog is hardwired - // to the legacy SHBrowseForFolder tree. Prefer pwsh, but ANY pwsh failure - // falls back to 5.1 (which every Windows ships): a resolvable pwsh can - // still be unable to deliver the dialog — PowerShell 6 has no WinForms, - // so its Add-Type exits 1, not ENOENT. Both hosts spawn DPI-unaware, so - // the script opts the process into system DPI awareness before any window - // is created. No Description is set: the modern dialog renders it as a - // bottom strip and the classic dialog as an unthemed box. - const script = [ - "$ErrorActionPreference = 'Stop'", - "Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'", - '[DpiAware]::SetProcessDPIAware() | Out-Null', - 'Add-Type -AssemblyName System.Windows.Forms', - '$dialog = New-Object System.Windows.Forms.FolderBrowserDialog', - '$dialog.ShowNewFolderButton = $true', - '$result = $dialog.ShowDialog()', - 'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {', - ' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8', - ' [Console]::WriteLine($dialog.SelectedPath)', - '}', - ].join('; ') - let pwshError: unknown - try { - const result = await run('pwsh.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - pwshError = error - } - try { - const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal) - return outputPath(result.stdout) - } catch (error: unknown) { - rethrowIfAborted(signal, error) - // Triple miss: every tier failed. Surface all three causes — the - // in-process dialog's reason is otherwise unrecoverable from the last - // PowerShell error alone. - throw new AggregateError( - [dialogError, pwshError, error], - 'native directory picker failed: the in-process dialog and both PowerShell hosts failed', - ) - } + return await pickDialog(signal) } if (platform === 'linux') { diff --git a/packages/host/directory-picker-native/tests/native-picker.spec.ts b/packages/host/directory-picker-native/tests/native-picker.spec.ts index 24c33e473e..66f4845b4b 100644 --- a/packages/host/directory-picker-native/tests/native-picker.spec.ts +++ b/packages/host/directory-picker-native/tests/native-picker.spec.ts @@ -1,8 +1,7 @@ /** - * Native picker tier selection and the execFile adapter: the in-process - * dialog primary, the pwsh → Windows PowerShell 5.1 fallback chain (any - * non-abort pwsh failure cascades), the abort-never-falls-through rule, and - * the triple-miss AggregateError carrying the dialog/pwsh/5.1 causes. + * Native picker tier selection and the execFile adapter: the Win32 dialog + * primary (failures surface as-is, no fallback tier), the abort rule, and + * the POSIX command tiers (osascript, Zenity → KDialog). */ type ExecFileCallback = ( @@ -30,7 +29,7 @@ function failure(code: string | number, stderr = ''): Error { const signal = () => new AbortController().signal -/** The PowerShell chain is reachable only when the in-process dialog fails. */ +/** A Win32 dialog that always fails — the no-fallback case. */ const noDialog = async (): Promise => { throw new Error('dialog unavailable') } describe('native directory picker', () => { @@ -56,7 +55,7 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason) }) - it('prefers the in-process Win32 dialog and never spawns PowerShell when it answers', async () => { + it('uses the Win32 dialog and never spawns a command when it answers', async () => { const run = vi.fn() const pickWin32Dialog = vi.fn(async (): Promise => 'C:\\work\\selected') await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected') @@ -65,55 +64,11 @@ describe('native directory picker', () => { expect(run).not.toHaveBeenCalled() }) - it('falls back to pwsh when the dialog is unavailable and maps empty output to cancellation', async () => { - const run = vi.fn(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' })) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\project') - expect(run).toHaveBeenCalledWith( - 'pwsh.exe', - expect.arrayContaining(['-NoProfile', '-STA', '-Command']), - expect.any(AbortSignal), - ) - const script = run.mock.calls[0]?.[1].at(-1) - expect(script).toContain("$ErrorActionPreference = 'Stop'") - expect(script).toContain('SetProcessDPIAware') - // Description renders as a bottom strip (modern) / unthemed box (classic); never set it. - expect(script).not.toContain('Description') - run.mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBeNull() - }) - - it('falls back to Windows PowerShell 5.1 whenever pwsh cannot deliver the dialog', async () => { + it('surfaces the Win32 dialog failure with no fallback', async () => { const run = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\fallback') - expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) - // Both runtimes execute the identical script, so DPI awareness holds either way. - expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1)) - - // A resolvable pwsh that cannot deliver the dialog (PowerShell 6: no - // WinForms, Add-Type exits 1 - not ENOENT) reaches 5.1 all the same. - const pwsh6 = vi.fn() - .mockRejectedValueOnce(failure(1, "Cannot load assembly 'System.Windows.Forms'")) - .mockResolvedValueOnce({ stdout: 'C:\\work\\legacy\r\n', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: pwsh6, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\legacy') - expect(pwsh6.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe']) - - const cancelled = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockResolvedValueOnce({ stdout: '', stderr: '' }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull() - - // Triple miss: the surfaced AggregateError carries all three causes, - // including the otherwise-lost in-process dialog failure. - const failed = vi.fn() - .mockRejectedValueOnce(failure('ENOENT')) - .mockRejectedValueOnce(failure(2)) - const tripleMiss = await pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog }) - .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) - expect(tripleMiss.message).toContain('the in-process dialog and both PowerShell hosts failed') - expect((tripleMiss.errors[0] as Error).message).toBe('dialog unavailable') - expect((tripleMiss.errors[2] as Error).message).toContain('command failed') + await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })) + .rejects.toThrow('dialog unavailable') + expect(run).not.toHaveBeenCalled() }) it('wires the real Win32 dialog as the default tier', async () => { @@ -127,52 +82,38 @@ describe('native directory picker', () => { expect(run).not.toHaveBeenCalled() }) - it('does not fall back when the caller aborted the dialog or the pwsh spawn', async () => { + it('does not fall back when the caller aborted the dialog', async () => { const abort = new AbortController() abort.abort(new Error('closed')) const run = vi.fn() await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable') expect(run).not.toHaveBeenCalled() - - const liveThenAborted = new AbortController() - const abortingRun = vi.fn(async () => { - liveThenAborted.abort(new Error('closed')) - throw failure('ENOENT') - }) - await expect(pickNativeDirectory(liveThenAborted.signal, { platform: 'win32', run: abortingRun, pickWin32Dialog: noDialog })) - .rejects.toThrow('command failed') - expect(abortingRun).toHaveBeenCalledOnce() }) it('runs the default command adapter without a shell and preserves command failures', async () => { execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(null, 'C:\\work\\default\r\n', '') + callback(null, '/home/test/project\n', '') }) - await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\default') + await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project') const [command, args, options] = execFileMock.mock.calls[0]! - expect(command).toBe('pwsh.exe') - expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command'])) + expect(command).toBe('zenity') + expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory'])) expect(options.encoding).toBe('utf8') expect(options.windowsHide).toBe(true) expect(options.signal).toBeInstanceOf(AbortSignal) - // Both chain tiers fail: pwsh's code-7 failure now reaches 5.1, whose - // failure is the one the caller sees. - const pwshError = Object.assign(new Error('pwsh failed'), { code: 7 }) - const commandError = Object.assign(new Error('powershell failed'), { code: 7 }) + // A non-cancellation command failure surfaces as-is with its cause and + // captured stdio attached; no tier masks or rewraps it. execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(pwshError, '', 'no WinForms') + callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details') }) - execFileMock.mockImplementationOnce((_command, _args, _options, callback) => { - callback(commandError, 'partial output', 'failure details') - }) - const surfaced = await pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog }) - .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as AggregateError) - expect(surfaced.errors[2]).toMatchObject({ - message: 'powershell failed', cause: commandError, code: 7, + const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' }) + .then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error) + expect(surfaced).toMatchObject({ + message: 'zenity failed', code: 7, stdout: 'partial output', stderr: 'failure details', }) - expect(execFileMock.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'pwsh.exe', 'powershell.exe']) + expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error) }) it('uses the current process platform when no platform override is supplied', async () => { @@ -184,6 +125,11 @@ describe('native directory picker', () => { await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected) }) + it('maps empty command output to cancellation', async () => { + const run = vi.fn(async () => ({ stdout: '', stderr: '' })) + await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull() + }) + it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => { const run = vi.fn() .mockRejectedValueOnce(failure('ENOENT'))