Merge pull request #1127 from deepseek-harness/feat/windows-picker-pwsh
fix(host): drive the Windows directory picker from a koffi IFileOpenDialog child process
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-native-workspace-directory-picker.md
|
||||||
2026-07-27-native-workspace-directory-picker.md: 98f9dc9bed5358e816d4324462d5ea7657f9007f
|
2026-07-27-native-workspace-directory-picker.md: a36f7b239a9115fe5eb33472ec5084818a66e9f2
|
||||||
2026-07-27-native-workspace-directory-picker.zh.md: ca765778fae734fd47a05652aea7021328ed4ab6
|
2026-07-27-native-workspace-directory-picker.zh.md: bb3e2fc6f7c77c8ace97e53435297326f937e4e8
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ The workspace manager must upsert the returned workspace before the selection ca
|
|||||||
|
|
||||||
The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process.
|
The native dialog RPC is accepted only from a loopback socket with same-origin browser metadata. The RPC does not use the default 30-second request timeout because a system dialog may remain open indefinitely; caller and connection aborts still propagate to the platform process.
|
||||||
|
|
||||||
Platform adapters invoke native tools without a shell:
|
Platform adapters open the dialog without a shell — spawned native tools on POSIX, an in-process COM conversation on Windows:
|
||||||
|
|
||||||
- macOS: `osascript` and the system folder chooser.
|
- macOS: `osascript` and the system folder chooser.
|
||||||
- Windows: PowerShell in STA mode and `FolderBrowserDialog`.
|
- 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.
|
- Linux: `zenity`, with `kdialog` as a fallback when Zenity is unavailable.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|||||||
@@ -27,10 +27,10 @@ Status: implemented
|
|||||||
|
|
||||||
只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。
|
只有来自回环套接字、且携带同源浏览器元数据的请求才能调用原生对话框 RPC。该 RPC 不使用默认的 30 秒请求超时,因为系统对话框可能无限期保持打开;调用方中止或连接中止仍会传递至平台进程。
|
||||||
|
|
||||||
平台适配器不经 shell,直接调用原生工具:
|
平台适配器不经 shell 打开对话框——POSIX 上 spawn 原生工具,Windows 上是子进程 COM 会话:
|
||||||
|
|
||||||
- macOS:`osascript` 和系统文件夹选择器。
|
- macOS:`osascript` 和系统文件夹选择器。
|
||||||
- Windows:采用 STA 模式的 PowerShell 和 `FolderBrowserDialog`。
|
- 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`。
|
- Linux:使用 `zenity`;Zenity 不可用时回退到 `kdialog`。
|
||||||
|
|
||||||
## 考虑过的替代方案
|
## 考虑过的替代方案
|
||||||
|
|||||||
@@ -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: 91a1ed0d7b1c1938a5e038ce36f1ca90bf3c9e82
|
||||||
|
2026-08-02-win32-in-process-folder-dialog.zh.md: 6b90dc1c5fa0042b3e2bcbea8ed554f1f0ea2acf
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Agent Note: Win32 folder picker moves to koffi in a child process
|
||||||
|
|
||||||
|
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 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 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.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# 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 会话运行在 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 线程与消息泵,而子进程 + koffi 用 TypeScript 就能表达。
|
||||||
|
- **保留 PowerShell 为主层并探测版本。** 否决:选择器仍被 shell 打包形态挟持(6 与 7、Store 别名、profile),且没有 pwsh 的机器地板仍是 5.1 的旧版对话框;仅把回退触发条件的拓宽吸收进回退层。
|
||||||
|
- **在主线程上阻塞模态调用。** 直接否决:对话框打开期间 web 宿主必须继续服务 RPC。
|
||||||
|
|
||||||
|
## 后果
|
||||||
|
|
||||||
|
- 每台 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 路线图。
|
||||||
@@ -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/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
|
||||||
@@ -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.
|
||||||
@@ -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 机制(我们不随包分发的系统提供的对话框宿主)才值得在同一判据下保留一层回退。
|
||||||
10
knip.json
10
knip.json
@@ -76,6 +76,16 @@
|
|||||||
"tests/**/*.ts"
|
"tests/**/*.ts"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"packages/host/directory-picker-native": {
|
||||||
|
"entry": [
|
||||||
|
"tests/**/*.spec.{ts,tsx}",
|
||||||
|
"tests/**/*.e2e.ts"
|
||||||
|
],
|
||||||
|
"project": [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"tests/**/*.{ts,tsx}"
|
||||||
|
]
|
||||||
|
},
|
||||||
"packages/client/web-ui": {
|
"packages/client/web-ui": {
|
||||||
"entry": [
|
"entry": [
|
||||||
"tests/**/*.spec.{ts,tsx}"
|
"tests/**/*.spec.{ts,tsx}"
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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
|
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
|
||||||
README.md: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
|
README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9
|
||||||
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f
|
README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
English | [中文](README.zh.md)
|
English | [中文](README.zh.md)
|
||||||
|
|
||||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS 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).
|
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request.
|
|||||||
## Known Limitations and Deferred Work
|
## 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).
|
- **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 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.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
[English](README.md) | 中文
|
[English](README.md) | 中文
|
||||||
|
|
||||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Windows 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,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)。
|
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||||
|
|
||||||
@@ -17,3 +17,4 @@
|
|||||||
## 已知限制与延期工作
|
## 已知限制与延期工作
|
||||||
|
|
||||||
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
|
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
|
||||||
|
- **Windows 没有机制级回退**——子进程选择器是唯一层级:koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pick(COM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。
|
||||||
|
|||||||
@@ -19,12 +19,17 @@
|
|||||||
"types": "./lib/types/client/index.d.ts",
|
"types": "./lib/types/client/index.d.ts",
|
||||||
"default": "./lib/client.js"
|
"default": "./lib/client.js"
|
||||||
},
|
},
|
||||||
|
"./worker": {
|
||||||
|
"types": "./lib/types/win32-dialog-worker.d.ts",
|
||||||
|
"default": "./lib/worker.cjs"
|
||||||
|
},
|
||||||
"./src/*": "./src/*",
|
"./src/*": "./src/*",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib/index.js",
|
"lib/index.js",
|
||||||
"lib/invariant.js",
|
"lib/invariant.js",
|
||||||
|
"lib/worker.cjs",
|
||||||
"lib/client.js",
|
"lib/client.js",
|
||||||
"lib/types/**/*.d.ts",
|
"lib/types/**/*.d.ts",
|
||||||
"lib/types/**/*.d.ts.map",
|
"lib/types/**/*.d.ts.map",
|
||||||
@@ -33,7 +38,8 @@
|
|||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||||
"@deepseek-ai/dsh-native-command": "workspace:^"
|
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||||
|
"koffi": "^3.1.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
|
||||||
@@ -50,7 +56,8 @@
|
|||||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||||
"@types/react": "~18.3.1",
|
"@types/react": "~18.3.1",
|
||||||
"cordis": "^4.0.0-rc.7",
|
"cordis": "^4.0.0-rc.7",
|
||||||
"react": "^18.2.0"
|
"react": "^18.2.0",
|
||||||
|
"tsx": "^4.19.2"
|
||||||
},
|
},
|
||||||
"dshClient": {
|
"dshClient": {
|
||||||
"inject": [
|
"inject": [
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
|
||||||
* with the `native` capability, opening one native OS chooser on the host
|
* with the `native` capability, opening one native OS chooser on the host
|
||||||
* display per pick (macOS `osascript`, Windows STA PowerShell
|
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
|
||||||
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
|
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
|
||||||
* when the operator sits at the host's screen; remote deployments compose the
|
* 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.
|
* browse backend instead.
|
||||||
* @module @deepseek-ai/dsh-host-directory-picker-native
|
* @module @deepseek-ai/dsh-host-directory-picker-native
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
/** Cross-platform native single-directory chooser behind the native backend's capability. */
|
||||||
|
|
||||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
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. */
|
/** Testable command boundary; native implementations never invoke a shell. */
|
||||||
export type DirectoryPickerRunner = NativeCommandRunner
|
export type DirectoryPickerRunner = NativeCommandRunner
|
||||||
@@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner
|
|||||||
export interface DirectoryPickerInternals {
|
export interface DirectoryPickerInternals {
|
||||||
platform?: NodeJS.Platform
|
platform?: NodeJS.Platform
|
||||||
run?: DirectoryPickerRunner
|
run?: DirectoryPickerRunner
|
||||||
|
/** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */
|
||||||
|
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
function outputPath(stdout: string): string | null {
|
function outputPath(stdout: string): string | null {
|
||||||
@@ -64,20 +67,13 @@ export async function pickNativeDirectory(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === 'win32') {
|
||||||
const script = [
|
// The koffi-backed IFileOpenDialog child process — the modern picker with
|
||||||
"$ErrorActionPreference = 'Stop'",
|
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
|
||||||
'Add-Type -AssemblyName System.Windows.Forms',
|
// whose availability the install guarantees, so there is no fallback
|
||||||
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
// tier: any failure surfaces as-is (the former PowerShell chain was
|
||||||
"$dialog.Description = 'Select Workspace Directory'",
|
// removed — see the simplification Agent Note).
|
||||||
'$dialog.ShowNewFolderButton = $true',
|
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
|
||||||
'$result = $dialog.ShowDialog()',
|
return await pickDialog(signal)
|
||||||
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
|
|
||||||
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
|
|
||||||
' [Console]::WriteLine($dialog.SelectedPath)',
|
|
||||||
'}',
|
|
||||||
].join('; ')
|
|
||||||
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
|
|
||||||
return outputPath(result.stdout)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (platform === 'linux') {
|
if (platform === 'linux') {
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* 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. The module loads on every platform; koffi
|
||||||
|
* itself is imported lazily inside each function, so non-Windows processes
|
||||||
|
* never load it — the same containment as the repo's other `win32.ts`
|
||||||
|
* modules.
|
||||||
|
*
|
||||||
|
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
|
||||||
|
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
|
||||||
|
* 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
|
||||||
|
sizeof(type: string): number
|
||||||
|
view(ref: unknown, len: number): ArrayBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a NUL-terminated UTF-16 string at a native address. koffi's
|
||||||
|
* `_Out_ void **` out-params surface a raw address, and
|
||||||
|
* `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash
|
||||||
|
* on real Windows — so view the memory directly instead.
|
||||||
|
*/
|
||||||
|
function readUtf16(koffi: Koffi, address: unknown): string {
|
||||||
|
const bytes = Buffer.from(koffi.view(address, 32768))
|
||||||
|
let end = 0
|
||||||
|
while (end + 1 < bytes.length && bytes[end] !== 0) end += 2
|
||||||
|
return bytes.toString('utf16le', 0, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
const COINIT_APARTMENTTHREADED = 0x2
|
||||||
|
const CLSCTX_INPROC_SERVER = 0x1
|
||||||
|
const SIGDN_FILESYSPATH = 0x80058000 | 0
|
||||||
|
/**
|
||||||
|
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
|
||||||
|
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
|
||||||
|
* returns NULL for an unsupported context instead of throwing, so the caller
|
||||||
|
* cascades to the best one the host accepts; DPI stays a cosmetic
|
||||||
|
* best-effort — an unsupported host still gets the modern dialog.
|
||||||
|
*/
|
||||||
|
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
|
||||||
|
const WM_CLOSE = 0x10
|
||||||
|
|
||||||
|
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
|
||||||
|
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<Win32DialogBindings> {
|
||||||
|
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')
|
||||||
|
|
||||||
|
// Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64,
|
||||||
|
// 4 on ia32 — koffi reports the running process's width.
|
||||||
|
const pointerSize = koffi.sizeof('void *')
|
||||||
|
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32'])
|
||||||
|
const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', [])
|
||||||
|
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
|
||||||
|
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
|
||||||
|
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
|
||||||
|
|
||||||
|
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 * pointerSize, 'void *')
|
||||||
|
return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setThreadDpiAwareness: () => {
|
||||||
|
let setContext: KoffiFunction
|
||||||
|
try {
|
||||||
|
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
|
||||||
|
} catch {
|
||||||
|
// Symbol absent (pre-1607 Windows): no per-thread DPI control exists.
|
||||||
|
// Proceed anyway — the cost is a blurry dialog above 100 % scaling on
|
||||||
|
// museum hosts, and the modern picker still beats dropping to the
|
||||||
|
// legacy 5.1 tree over a cosmetic concern.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const context of DPI_AWARENESS_CONTEXTS) {
|
||||||
|
if (setContext(context) !== null) return
|
||||||
|
}
|
||||||
|
// Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol
|
||||||
|
// exists); if a host ever refuses everything, the dialog still works —
|
||||||
|
// just without a DPI opt-in.
|
||||||
|
},
|
||||||
|
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
|
||||||
|
coUninitialize: () => {
|
||||||
|
coUninitialize()
|
||||||
|
},
|
||||||
|
currentThreadId: () => getCurrentThreadId() as number,
|
||||||
|
createFolderDialog: (): Win32FolderDialog => {
|
||||||
|
const out = Buffer.alloc(pointerSize)
|
||||||
|
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
|
||||||
|
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`)
|
||||||
|
const dialog = koffi.decode(out, 'void *')
|
||||||
|
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 = readUtf16(koffi, nameOut[0])
|
||||||
|
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<void> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Real-process half of the Win32 dialog driver: spawn the dialog child
|
||||||
|
* process (source or built plane) and close a dialog thread's windows. The
|
||||||
|
* module itself loads everywhere (the import chain from native-picker.ts is
|
||||||
|
* static); what stays win32-only is koffi, imported dynamically inside the
|
||||||
|
* bindings' functions. The driver's logic is tested against fakes of this
|
||||||
|
* surface instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn, type StdioOptions } from 'node:child_process'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spawn the dialog child process. Built consumers launch the bundled CJS
|
||||||
|
* entry next to this module under plain node; unbuilt (source) consumers
|
||||||
|
* bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
|
||||||
|
* the child's first window, so Windows activates it without a foreground
|
||||||
|
* call.
|
||||||
|
* @param data - the child payload (dialog title).
|
||||||
|
* @returns the spawned child process.
|
||||||
|
*/
|
||||||
|
export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn> {
|
||||||
|
const env = { ...process.env, DSH_DIALOG_TITLE: data.title }
|
||||||
|
const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc']
|
||||||
|
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
|
||||||
|
if (!import.meta.url.endsWith('.ts')) {
|
||||||
|
return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true })
|
||||||
|
}
|
||||||
|
return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
export { closeThreadWindows } from './win32-dialog-bindings.ts'
|
||||||
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
132
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
/**
|
||||||
|
* Opt the calling thread into the best supported DPI awareness
|
||||||
|
* (per-monitor-v2, then per-monitor, then system-aware), checking each
|
||||||
|
* call's result. Best-effort on purpose: a host accepting none of them
|
||||||
|
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
|
||||||
|
* blurry above 100 % scaling — because a cosmetic degradation must not
|
||||||
|
* cost the tier.
|
||||||
|
*/
|
||||||
|
setThreadDpiAwareness(): void
|
||||||
|
/**
|
||||||
|
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
|
||||||
|
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
|
||||||
|
*/
|
||||||
|
coInitializeSta(): number
|
||||||
|
/**
|
||||||
|
* `CoUninitialize` on the calling thread — COM requires one pairing call
|
||||||
|
* for every successful (including `S_FALSE`) `CoInitializeEx`, even on a
|
||||||
|
* thread that exits right after the conversation.
|
||||||
|
*/
|
||||||
|
coUninitialize(): void
|
||||||
|
/**
|
||||||
|
* `CoCreateInstance(CLSID_FileOpenDialog)`.
|
||||||
|
* @returns the created dialog surface; throws when creation fails.
|
||||||
|
*/
|
||||||
|
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')
|
||||||
|
// From here the apartment is initialized (S_OK or S_FALSE) and must be
|
||||||
|
// uninitialized exactly once on every path.
|
||||||
|
try {
|
||||||
|
const dialog = bindings.createFolderDialog()
|
||||||
|
try {
|
||||||
|
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
|
||||||
|
check(dialog.setTitle(title), 'SetTitle')
|
||||||
|
onShowing(bindings.currentThreadId())
|
||||||
|
const shown = dialog.show()
|
||||||
|
if (shown === HRESULT_CANCELLED) return null
|
||||||
|
check(shown, 'Show')
|
||||||
|
const result = dialog.resultPath()
|
||||||
|
check(result.hr, 'GetResult')
|
||||||
|
return result.path as string
|
||||||
|
} finally {
|
||||||
|
dialog.release()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
bindings.coUninitialize()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Child-process entry for the Win32 folder dialog: blocks THIS process
|
||||||
|
* inside the modal `Show` so the host event loop stays live, reporting over
|
||||||
|
* the IPC channel. Spawned as a child process (not a worker thread) so the
|
||||||
|
* dialog is the process's first window and Windows activates it without a
|
||||||
|
* manual foreground call. Protocol: `{kind:'showing',threadId}` right
|
||||||
|
* before the blocking call (the driver's abort lever needs the native
|
||||||
|
* thread id), then exactly one of `{kind:'done',path}` or
|
||||||
|
* `{kind:'error',message}`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadWin32DialogBindings } from './win32-dialog-bindings.ts'
|
||||||
|
import { runFolderDialog } from './win32-dialog-logic.ts'
|
||||||
|
|
||||||
|
/** The driver-to-child payload: the dialog title (passed via env). */
|
||||||
|
export interface Win32DialogWorkerData { title: string }
|
||||||
|
|
||||||
|
/** One notice or outcome posted back to the driver. */
|
||||||
|
export type Win32DialogWorkerMessage =
|
||||||
|
| { kind: 'showing'; threadId: number }
|
||||||
|
| { kind: 'done'; path: string | null }
|
||||||
|
| { kind: 'error'; message: string }
|
||||||
|
|
||||||
|
const title = process.env.DSH_DIALOG_TITLE ?? ''
|
||||||
|
if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required')
|
||||||
|
if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel')
|
||||||
|
// node's internal `send` reads `this.connected`, so bind the receiver.
|
||||||
|
const send = process.send.bind(process)
|
||||||
|
|
||||||
|
const post = (message: Win32DialogWorkerMessage): void => {
|
||||||
|
// Flush before closing the channel; the process exits when the loop drains.
|
||||||
|
/* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */
|
||||||
|
send(message, () => { if (process.connected) process.disconnect() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// A settled driver (or a dead parent) must not orphan a dialog still on screen.
|
||||||
|
/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */
|
||||||
|
process.on('disconnect', () => process.exit(0))
|
||||||
|
|
||||||
|
// No top-level await: the built worker ships as CJS, which cannot carry TLA.
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const bindings = await loadWin32DialogBindings()
|
||||||
|
const path = runFolderDialog(bindings, title, (threadId) => {
|
||||||
|
post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage)
|
||||||
|
})
|
||||||
|
post({ kind: 'done', path } satisfies Win32DialogWorkerMessage)
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
|
||||||
|
post({ kind: 'error', message } satisfies Win32DialogWorkerMessage)
|
||||||
|
}
|
||||||
|
})()
|
||||||
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
159
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
|
||||||
|
* process (which blocks inside the modal `Show`), maps its message protocol
|
||||||
|
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
|
||||||
|
* thread's windows until the child reports back. The real process/window
|
||||||
|
* surface is injectable so every driver path is testable on any platform.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts'
|
||||||
|
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
|
||||||
|
|
||||||
|
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
|
||||||
|
export interface Win32DialogWorkerLike {
|
||||||
|
/**
|
||||||
|
* Subscribe to a child-process 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 child; the abort path's last resort when `WM_CLOSE`
|
||||||
|
* never lands (e.g. the dialog window was never created).
|
||||||
|
* @returns whether a kill signal was delivered.
|
||||||
|
*/
|
||||||
|
kill(): boolean
|
||||||
|
/**
|
||||||
|
* Release the event-loop reference. Called once the pick settles so a
|
||||||
|
* child stuck in the native modal call never blocks process exit.
|
||||||
|
*/
|
||||||
|
unref?(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Injectable process surface for deterministic driver tests. */
|
||||||
|
export interface Win32DialogInternals {
|
||||||
|
/** Replaces the real child spawn (`win32-dialog-host.ts`). */
|
||||||
|
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike
|
||||||
|
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
|
||||||
|
closeThreadWindows?: (threadId: number) => Promise<void>
|
||||||
|
/** 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
|
||||||
|
|
||||||
|
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
|
||||||
|
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
|
||||||
|
function assertNever(value: never): never {
|
||||||
|
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`)
|
||||||
|
}
|
||||||
|
/* v8 ignore stop */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the modern Win32 folder picker off the event loop.
|
||||||
|
* @param signal - caller lifetime; abort closes the dialog and rejects.
|
||||||
|
* @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<string | null> {
|
||||||
|
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: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE })
|
||||||
|
let dialogThreadId: number | undefined
|
||||||
|
let closeTimer: NodeJS.Timeout | undefined
|
||||||
|
let settled = false
|
||||||
|
|
||||||
|
return await new Promise<string | null>((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 postClose = (): void => {
|
||||||
|
// Before `showing` there is no window to close; the budget below still
|
||||||
|
// runs so a child that never reports cannot dangle the pick. A
|
||||||
|
// rejected close attempt (EnumThreadWindows/PostMessageW refusing) is
|
||||||
|
// discarded: the interval retries it and kill is the backstop.
|
||||||
|
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sole caller: the once-registered abort listener, so no re-entry guard.
|
||||||
|
const serviceAbort = (): void => {
|
||||||
|
let attempts = 0
|
||||||
|
// The `showing` notice precedes the blocking `Show`, so the very first
|
||||||
|
// WM_CLOSE can race the window's creation; re-post until the child
|
||||||
|
// reports back, then force-kill as a last resort. The budget is
|
||||||
|
// unconditional — an abort before `showing` (child hung in koffi or
|
||||||
|
// COM init) still ends in kill instead of a dangling promise.
|
||||||
|
closeTimer = setInterval(() => {
|
||||||
|
attempts += 1
|
||||||
|
if (attempts > CLOSE_MAX_ATTEMPTS) {
|
||||||
|
settle(() => {
|
||||||
|
worker.kill()
|
||||||
|
reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)'))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
postClose()
|
||||||
|
}, closeRetryMs)
|
||||||
|
postClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAbort = (): void => {
|
||||||
|
serviceAbort()
|
||||||
|
}
|
||||||
|
signal.addEventListener('abort', onAbort, { once: true })
|
||||||
|
|
||||||
|
worker.on('message', (message: Win32DialogWorkerMessage) => {
|
||||||
|
switch (message.kind) {
|
||||||
|
case 'showing':
|
||||||
|
dialogThreadId = message.threadId
|
||||||
|
// An abort that raced ahead of this notice now has a window to hit.
|
||||||
|
if (signal.aborted) postClose()
|
||||||
|
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}`))
|
||||||
|
})
|
||||||
|
return
|
||||||
|
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
|
||||||
|
default:
|
||||||
|
assertNever(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'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
|
||||||
|
* shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its
|
||||||
|
* real koffi requires. POSIX hosts prove the load path end to end through
|
||||||
|
* the deterministic ole32 rejection; win32 skips (a real dialog would
|
||||||
|
* open), where the win32-only smoke in win32-dialog.spec.ts covers the
|
||||||
|
* source plane instead. Skips until a build produces the artifact.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
|
||||||
|
|
||||||
|
const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url))
|
||||||
|
|
||||||
|
describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => {
|
||||||
|
it('loads under plain node and reports the native-surface failure', async () => {
|
||||||
|
const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, [builtWorker], {
|
||||||
|
env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' },
|
||||||
|
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
|
||||||
|
})
|
||||||
|
child.on('message', resolve)
|
||||||
|
child.on('error', reject)
|
||||||
|
child.on('exit', (code) => {
|
||||||
|
reject(new Error(`worker exited (${code}) before reporting`))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(message.kind).toBe('error')
|
||||||
|
expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i)
|
||||||
|
}, 30_000)
|
||||||
|
})
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* 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 = (
|
type ExecFileCallback = (
|
||||||
error: (Error & { code?: string | number }) | null,
|
error: (Error & { code?: string | number }) | null,
|
||||||
stdout: string,
|
stdout: string,
|
||||||
@@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error {
|
|||||||
|
|
||||||
const signal = () => new AbortController().signal
|
const signal = () => new AbortController().signal
|
||||||
|
|
||||||
|
/** A Win32 dialog that always fails — the no-fallback case. */
|
||||||
|
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
|
||||||
|
|
||||||
describe('native directory picker', () => {
|
describe('native directory picker', () => {
|
||||||
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
|
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
|
||||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
|
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
|
||||||
@@ -46,46 +55,79 @@ describe('native directory picker', () => {
|
|||||||
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
|
it('uses the Win32 dialog and never spawns a command when it answers', async () => {
|
||||||
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
|
const run = vi.fn<DirectoryPickerRunner>()
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
|
const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
|
||||||
expect(run).toHaveBeenCalledWith(
|
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
|
||||||
'powershell.exe',
|
pickWin32Dialog.mockResolvedValueOnce(null)
|
||||||
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
|
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull()
|
||||||
expect.any(AbortSignal),
|
expect(run).not.toHaveBeenCalled()
|
||||||
)
|
})
|
||||||
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
|
|
||||||
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
|
it('surfaces the Win32 dialog failure with no fallback', async () => {
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
|
const run = vi.fn<DirectoryPickerRunner>()
|
||||||
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
|
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
|
.rejects.toThrow('dialog unavailable')
|
||||||
|
expect(run).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
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<DirectoryPickerRunner>()
|
||||||
|
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', async () => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
abort.abort(new Error('closed'))
|
||||||
|
const run = vi.fn<DirectoryPickerRunner>()
|
||||||
|
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
|
||||||
|
expect(run).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
it('runs the default command adapter without a shell and preserves command failures', async () => {
|
||||||
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
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' })).resolves.toBe('C:\\work\\default')
|
await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
|
||||||
const [command, args, options] = execFileMock.mock.calls[0]!
|
const [command, args, options] = execFileMock.mock.calls[0]!
|
||||||
expect(command).toBe('powershell.exe')
|
expect(command).toBe('zenity')
|
||||||
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
|
expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
|
||||||
expect(options.encoding).toBe('utf8')
|
expect(options.encoding).toBe('utf8')
|
||||||
expect(options.windowsHide).toBe(true)
|
expect(options.windowsHide).toBe(true)
|
||||||
expect(options.signal).toBeInstanceOf(AbortSignal)
|
expect(options.signal).toBeInstanceOf(AbortSignal)
|
||||||
|
|
||||||
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) => {
|
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
|
||||||
callback(commandError, 'partial output', 'failure details')
|
callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
|
||||||
})
|
})
|
||||||
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
|
const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
|
||||||
message: 'powershell failed', cause: commandError, code: 7,
|
.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',
|
stdout: 'partial output', stderr: 'failure details',
|
||||||
})
|
})
|
||||||
|
expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('uses the current process platform when no platform override is supplied', async () => {
|
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<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
|
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
|
||||||
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
|
const pickWin32Dialog = async (): Promise<string | null> => 'C:\\default\\platform'
|
||||||
|
const expected = process.platform === 'win32' ? 'C:\\default\\platform' : '/default/platform'
|
||||||
|
await expect(pickNativeDirectory(signal(), { run, pickWin32Dialog })).resolves.toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps empty command output to cancellation', async () => {
|
||||||
|
const run = vi.fn<DirectoryPickerRunner>(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 () => {
|
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,354 @@
|
|||||||
|
/**
|
||||||
|
* 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 process
|
||||||
|
* boundary (env title + `process.send`). Real-COM behavior is pinned by the
|
||||||
|
* win32-only smoke in win32-dialog.spec.ts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts'
|
||||||
|
|
||||||
|
const E_FAIL = 0x80004005 | 0
|
||||||
|
const WM_CLOSE = 0x10
|
||||||
|
/**
|
||||||
|
* Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer
|
||||||
|
* sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against
|
||||||
|
* this width (the win32-ia32 bug class).
|
||||||
|
*/
|
||||||
|
const FAKE_POINTER_SIZE = 4
|
||||||
|
|
||||||
|
interface ComWorld {
|
||||||
|
coInitHr: number
|
||||||
|
coCreateHr: number
|
||||||
|
showHr: number
|
||||||
|
getResultHr: number
|
||||||
|
getDisplayNameHr: number
|
||||||
|
hasThreadDpi: boolean
|
||||||
|
/** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */
|
||||||
|
supportedDpiContexts: number[]
|
||||||
|
enumThrows: boolean
|
||||||
|
path: string
|
||||||
|
titles: string[]
|
||||||
|
options: number[]
|
||||||
|
dpiContexts: unknown[]
|
||||||
|
freed: unknown[]
|
||||||
|
released: string[]
|
||||||
|
posted: { hwnd: unknown; message: number }[]
|
||||||
|
registered: number
|
||||||
|
unregistered: number
|
||||||
|
uninitialized: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
|
||||||
|
return {
|
||||||
|
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
|
||||||
|
hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false,
|
||||||
|
path: 'C:\\选中\\directory',
|
||||||
|
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
|
||||||
|
registered: 0, unregistered: 0, uninitialized: 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<unknown, FakePtr>()
|
||||||
|
|
||||||
|
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 'CoUninitialize': return () => { world.uninitialized += 1 }
|
||||||
|
case 'CoCreateInstance': return (...args: unknown[]) => {
|
||||||
|
if (world.coCreateHr < 0) return world.coCreateHr
|
||||||
|
// The out-pointer must be allocated at the fake's pointer width.
|
||||||
|
if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) {
|
||||||
|
throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`)
|
||||||
|
}
|
||||||
|
outBuffers.set(args[4], dialogPtr)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
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 world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
|
||||||
|
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
|
||||||
|
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,
|
||||||
|
sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE },
|
||||||
|
view: (value: unknown, len: number): ArrayBuffer => {
|
||||||
|
const bytes = Buffer.alloc(len)
|
||||||
|
bytes.write((value as FakePtr).text as string, 'utf16le')
|
||||||
|
return bytes.buffer
|
||||||
|
},
|
||||||
|
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
|
||||||
|
unregister: () => { world.unregistered += 1 },
|
||||||
|
decode: (value: unknown, offsetOrType: unknown): unknown => {
|
||||||
|
if (offsetOrType === 'str16') return (value as FakePtr).text
|
||||||
|
if (typeof offsetOrType === 'number') {
|
||||||
|
// Vtable slot read: offsets must be multiples of the fake width.
|
||||||
|
if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`)
|
||||||
|
const owner = (value as { owner: FakePtr }).owner
|
||||||
|
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, 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<typeof import('../src/win32-dialog-bindings.ts')> {
|
||||||
|
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'])
|
||||||
|
expect(world.uninitialized).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps dismissal and the S_FALSE CoInitializeEx', async () => {
|
||||||
|
const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 })
|
||||||
|
installFakeKoffi(world)
|
||||||
|
const { loadWin32DialogBindings } = await loadBindingsModule()
|
||||||
|
const bindings = await loadWin32DialogBindings()
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
||||||
|
expect(world.released).toEqual(['dialog'])
|
||||||
|
expect(world.uninitialized).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cascades DPI contexts to the first the host accepts', async () => {
|
||||||
|
const world = comWorld({ supportedDpiContexts: [-3] })
|
||||||
|
installFakeKoffi(world)
|
||||||
|
const bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||||
|
expect(world.dpiContexts).toEqual([-4, -3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => {
|
||||||
|
// DPI is a cosmetic best-effort: the modern dialog still opens.
|
||||||
|
const rejecting = comWorld({ supportedDpiContexts: [] })
|
||||||
|
installFakeKoffi(rejecting)
|
||||||
|
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||||
|
expect(rejecting.dpiContexts).toEqual([-4, -3, -2])
|
||||||
|
|
||||||
|
vi.doUnmock('koffi')
|
||||||
|
vi.resetModules()
|
||||||
|
const preThreadDpi = comWorld({ hasThreadDpi: false })
|
||||||
|
installFakeKoffi(preThreadDpi)
|
||||||
|
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||||
|
expect(preThreadDpi.dpiContexts).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('surfaces creation and extraction failures as HRESULT errors', async () => {
|
||||||
|
const creationWorld = comWorld({ coCreateHr: E_FAIL })
|
||||||
|
installFakeKoffi(creationWorld)
|
||||||
|
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 process boundary', () => {
|
||||||
|
const originalSend = process.send?.bind(process)
|
||||||
|
const originalTitle = process.env.DSH_DIALOG_TITLE
|
||||||
|
|
||||||
|
const installBoundary = (): { posted: { kind: string; message?: string }[] } => {
|
||||||
|
const posted: { kind: string; message?: string }[] = []
|
||||||
|
process.env.DSH_DIALOG_TITLE = 'Pick'
|
||||||
|
// Never invoke the post callback: it runs the worker's disconnect(), and
|
||||||
|
// this process is IPC-connected under the forks pool — severing vitest's
|
||||||
|
// own channel would kill the test worker. The real close lifecycle
|
||||||
|
// belongs to built-worker.e2e.ts.
|
||||||
|
;(process as { send?: unknown }).send = (message: { kind: string }) => {
|
||||||
|
posted.push(message)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return { posted }
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete (process as { send?: unknown }).send
|
||||||
|
if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend
|
||||||
|
if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE
|
||||||
|
else process.env.DSH_DIALOG_TITLE = originalTitle
|
||||||
|
vi.doUnmock('../src/win32-dialog-bindings.ts')
|
||||||
|
vi.resetModules()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('posts showing then done for a completed conversation', async () => {
|
||||||
|
const { posted } = installBoundary()
|
||||||
|
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
|
||||||
|
loadWin32DialogBindings: async () => ({
|
||||||
|
setThreadDpiAwareness: () => undefined,
|
||||||
|
coInitializeSta: () => 0,
|
||||||
|
coUninitialize: () => undefined,
|
||||||
|
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 } = installBoundary()
|
||||||
|
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.resetModules()
|
||||||
|
const { posted } = installBoundary()
|
||||||
|
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 without the dialog title', async () => {
|
||||||
|
delete process.env.DSH_DIALOG_TITLE
|
||||||
|
;(process as { send?: unknown }).send = () => true
|
||||||
|
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to run outside a child process', async () => {
|
||||||
|
process.env.DSH_DIALOG_TITLE = 'Pick'
|
||||||
|
delete (process as { send?: unknown }).send
|
||||||
|
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* 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<typeof vi.fn>
|
||||||
|
createDialog: ReturnType<typeof vi.fn>
|
||||||
|
uninitialize: ReturnType<typeof vi.fn>
|
||||||
|
dialog: {
|
||||||
|
setOptions: ReturnType<typeof vi.fn>
|
||||||
|
setTitle: ReturnType<typeof vi.fn>
|
||||||
|
show: ReturnType<typeof vi.fn>
|
||||||
|
resultPath: ReturnType<typeof vi.fn>
|
||||||
|
release: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function world(overrides: Partial<Win32FolderDialog> = {}, 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 uninitialize = vi.fn()
|
||||||
|
const bindings: Win32DialogBindings = {
|
||||||
|
setThreadDpiAwareness: dpi,
|
||||||
|
coInitializeSta: vi.fn(() => coInit),
|
||||||
|
coUninitialize: uninitialize,
|
||||||
|
createFolderDialog: createDialog,
|
||||||
|
currentThreadId: vi.fn(() => 4242),
|
||||||
|
}
|
||||||
|
return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('runFolderDialog', () => {
|
||||||
|
it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => {
|
||||||
|
const { bindings, dpi, dialog, uninitialize } = world()
|
||||||
|
const showing = vi.fn()
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
|
||||||
|
expect(dpi).toHaveBeenCalledOnce()
|
||||||
|
expect(uninitialize).toHaveBeenCalledOnce()
|
||||||
|
expect(dialog.release.mock.invocationCallOrder[0]).toBeLessThan(uninitialize.mock.invocationCallOrder[0] as number)
|
||||||
|
expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR)
|
||||||
|
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
|
||||||
|
expect(showing).toHaveBeenCalledWith(4242)
|
||||||
|
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 and apartment', () => {
|
||||||
|
const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
||||||
|
expect(dialog.resultPath).not.toHaveBeenCalled()
|
||||||
|
expect(dialog.release).toHaveBeenCalledOnce()
|
||||||
|
expect(uninitialize).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => {
|
||||||
|
const { bindings } = world({}, 1)
|
||||||
|
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws on a failing CoInitializeEx without creating a dialog or uninitializing', () => {
|
||||||
|
const { bindings, createDialog, uninitialize } = world({}, E_FAIL)
|
||||||
|
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005')
|
||||||
|
expect(createDialog).not.toHaveBeenCalled()
|
||||||
|
// A failed CoInitializeEx must NOT be paired with CoUninitialize.
|
||||||
|
expect(uninitialize).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['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<Win32FolderDialog>][])('releases the dialog and apartment when %s fails', (what, overrides) => {
|
||||||
|
const { bindings, dialog, uninitialize } = world(overrides)
|
||||||
|
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`)
|
||||||
|
expect(dialog.release).toHaveBeenCalledOnce()
|
||||||
|
expect(uninitialize).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal file
163
packages/host/directory-picker-native/tests/win32-dialog.spec.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* Driver tests: the child-process message protocol mapped onto the promise,
|
||||||
|
* the WM_CLOSE abort service (including the show-race retry and the kill
|
||||||
|
* last resort) against fakes, plus the real spawn plumbing — POSIX hosts
|
||||||
|
* prove the default path rejects cleanly (koffi cannot load ole32 there),
|
||||||
|
* and win32 hosts briefly open and auto-abort a real dialog.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events'
|
||||||
|
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 {
|
||||||
|
kill = vi.fn(() => true)
|
||||||
|
post(message: Win32DialogWorkerMessage): void {
|
||||||
|
this.emit('message', message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
worker: FakeWorker
|
||||||
|
internals: Win32DialogInternals
|
||||||
|
close: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(overrides: Partial<Win32DialogInternals> = {}): 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()
|
||||||
|
// Attach the expectation BEFORE driving the race: on a fast host the
|
||||||
|
// close budget can exhaust (and reject) between waitFor ticks, and a
|
||||||
|
// rejection with no listener yet would count as unhandled.
|
||||||
|
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
|
||||||
|
worker.post({ kind: 'showing', threadId: 99 })
|
||||||
|
controller.abort()
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(close).toHaveBeenCalledWith(99)
|
||||||
|
})
|
||||||
|
worker.post({ kind: 'done', path: null })
|
||||||
|
await picked
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts the close service on the showing notice when the abort came first', async () => {
|
||||||
|
const closeFailures = vi.fn(async () => { throw new Error('window not there yet') })
|
||||||
|
const { worker, internals } = harness({ closeThreadWindows: closeFailures })
|
||||||
|
const controller = new AbortController()
|
||||||
|
// Attached before the race for the same unhandled-rejection reason above.
|
||||||
|
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
|
||||||
|
controller.abort()
|
||||||
|
expect(closeFailures).not.toHaveBeenCalled()
|
||||||
|
worker.post({ kind: 'showing', threadId: 12 })
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(closeFailures.mock.calls.length).toBeGreaterThan(1)
|
||||||
|
})
|
||||||
|
worker.post({ kind: 'done', path: null })
|
||||||
|
await picked
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kills a worker that never reports showing after an abort', async () => {
|
||||||
|
// The budget runs without a thread id (nothing to WM_CLOSE yet), so a
|
||||||
|
// worker hung before `showing` cannot dangle the pick.
|
||||||
|
const { worker, internals, close } = harness()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed')
|
||||||
|
controller.abort()
|
||||||
|
await picked
|
||||||
|
expect(worker.kill).toHaveBeenCalledOnce()
|
||||||
|
expect(close).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('kills an unresponsive worker after the close budget', async () => {
|
||||||
|
const { worker, internals, close } = harness()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const picked = pickWin32Directory(controller.signal, internals)
|
||||||
|
worker.post({ kind: 'showing', threadId: 5 })
|
||||||
|
controller.abort()
|
||||||
|
await expect(picked).rejects.toThrow('dialog unresponsive; worker killed')
|
||||||
|
expect(worker.kill).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)
|
||||||
|
})
|
||||||
@@ -1,3 +1,20 @@
|
|||||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
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']),
|
||||||
|
{
|
||||||
|
// The artifact is lib/worker.cjs (the ./worker export the workspace
|
||||||
|
// constraint keys on), bundled from the descriptive source entry.
|
||||||
|
entry: { worker: 'lib/types/win32-dialog-worker.js' },
|
||||||
|
outDir: 'lib',
|
||||||
|
format: ['cjs'] as ['cjs'],
|
||||||
|
platform: 'node' as const,
|
||||||
|
target: 'es2024',
|
||||||
|
fixedExtension: false,
|
||||||
|
dts: false,
|
||||||
|
clean: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -3643,6 +3643,9 @@ importers:
|
|||||||
'@deepseek-ai/dsh-native-command':
|
'@deepseek-ai/dsh-native-command':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../../util/native-command
|
version: link:../../util/native-command
|
||||||
|
koffi:
|
||||||
|
specifier: ^3.1.0
|
||||||
|
version: 3.1.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@deepseek-ai/dsh-client-runtime':
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
@@ -3665,6 +3668,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.19.2
|
||||||
|
version: 4.22.4
|
||||||
|
|
||||||
packages/host/webserver:
|
packages/host/webserver:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
@@ -597,6 +597,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
|
|||||||
'apps/cli/tests/built-bin.e2e.ts',
|
'apps/cli/tests/built-bin.e2e.ts',
|
||||||
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
'packages/examples/cli-demo/tests/built-bin.e2e.ts',
|
||||||
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
'packages/examples/acp-demo/tests/built-bin.e2e.ts',
|
||||||
|
'packages/host/directory-picker-native/tests/built-worker.e2e.ts',
|
||||||
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts',
|
||||||
// The worker-entry packages' built bundles: the only automated proof
|
// The worker-entry packages' built bundles: the only automated proof
|
||||||
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
// that lib/index.js resolves its sibling lib/worker.cjs under plain node
|
||||||
|
|||||||
Reference in New Issue
Block a user