From e182f032309b5b0ea238e63888f150a2f34bc48a Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 3 Aug 2026 21:42:05 +0800 Subject: [PATCH] fix(picker): cascade thread DPI contexts and harden the round-three review points - setThreadDpiAwareness checks SetThreadDpiAwarenessContext's return value and cascades per-monitor-v2 -> per-monitor -> system-aware; DPI stays a deliberate cosmetic best-effort - a host accepting none (or lacking the API, pre-1607) still gets the modern dialog instead of a downgrade to the legacy fallback chain over a cosmetic concern. - The mocked-koffi world now uses a distinctive 4-byte pointer width and rejects mis-sized out-buffers and mis-divided vtable offsets, so a regression to hardcoded 8s fails the suite (the ia32 bug class). - A keyless built-worker e2e guard loads lib/worker.cjs under plain worker_threads on POSIX (the workflow-workerthread shape). - The 'loaded lazily' module claims are reworded to attribute laziness to the dynamic import('koffi') calls, and the discarded close-attempt rejection is named at its catch. --- ...2-win32-in-process-folder-dialog.i18n.yaml | 4 +- ...26-08-02-win32-in-process-folder-dialog.md | 4 +- ...08-02-win32-in-process-folder-dialog.zh.md | 4 +- .../directory-picker-native/README.i18n.yaml | 4 +- .../host/directory-picker-native/README.md | 2 +- .../host/directory-picker-native/README.zh.md | 2 +- .../src/win32-dialog-bindings.ts | 34 ++++++++--- .../src/win32-dialog-host.ts | 9 +-- .../src/win32-dialog-logic.ts | 9 ++- .../src/win32-dialog.ts | 4 +- .../tests/built-worker.e2e.ts | 31 ++++++++++ .../tests/win32-dialog-bindings.spec.ts | 56 ++++++++++++++++--- 12 files changed, 128 insertions(+), 35 deletions(-) create mode 100644 packages/host/directory-picker-native/tests/built-worker.e2e.ts diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index a3bdfc2a97..656ee92a65 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: fa896d198913f58b22f9186696daec27026bb50f -2026-08-02-win32-in-process-folder-dialog.zh.md: 31077d8d6a3907d955180fda290f92c4cf41e5b9 +2026-08-02-win32-in-process-folder-dialog.md: 96bc213ea7cddef6223aa3be69e56688dc9c4724 +2026-08-02-win32-in-process-folder-dialog.zh.md: a3dfad2ef73cb28ae8c4c47b6380740aab6de5c3 diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index fa896d1989..96bc213ea7 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -10,7 +10,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into per-monitor-v2 DPI (`SetThreadDpiAwarenessContext`), a strict upgrade over the script's system-DPI ceiling. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. ## Alternatives considered @@ -21,6 +21,6 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Consequences -- Every Windows machine gets the modern dialog with per-monitor-v2 DPI, PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. +- Every Windows machine gets the modern dialog with the best DPI awareness it supports (per-monitor-v2 on 1703+), PowerShell installed or not; the PowerShell tiers only serve hosts where koffi cannot drive COM. - Real dialog rendering and the selection path stay a manual Windows check (the auto-close smoke proves open/abort/unwind); a wedged abort can leak one dialog thread until process exit, documented in the package README. - The COM vtable slots and GUIDs used are frozen Windows ABI (Vista); a koffi signature mistake is an in-process crash risk contained to the worker thread and caught by the win32 smoke before shipping. diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index 31077d8d6a..a3dfad2ef7 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -10,7 +10,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用 per-monitor-v2 DPI(`SetThreadDpiAwarenessContext`),严格优于脚本的系统 DPI 上限。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 ## 考虑过的替代方案 @@ -21,6 +21,6 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 后果 -- 每台 Windows 机器都得到带 per-monitor-v2 DPI 的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 +- 每台 Windows 机器都得到带其所支持的最佳 DPI 感知(1703+ 为 per-monitor-v2)的现代对话框,无论是否安装 PowerShell;PowerShell 层只服务 koffi 无法驱动 COM 的主机。 - 真实对话框渲染与选中路径仍是手动 Windows 检查(自动关闭冒烟证明打开/中止/收尾);卡死的中止可能泄漏一个对话框线程直到进程退出,已记录于包 README。 - 所用 COM vtable 槽位与 GUID 是冻结的 Windows ABI(Vista 起);koffi 签名错误是被限制在 worker 线程内的进程内崩溃风险,并在交付前被 win32 冒烟捕获。 diff --git a/packages/host/directory-picker-native/README.i18n.yaml b/packages/host/directory-picker-native/README.i18n.yaml index acf7f85d88..60f534e83b 100644 --- a/packages/host/directory-picker-native/README.i18n.yaml +++ b/packages/host/directory-picker-native/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md -README.md: 0d0fe8d3a049d6fbc47eee314f9782352651247d -README.zh.md: 82f51976afe2e57699c1bd62d11142106b082e9b +README.md: d48622dead56cce842e0bef0207079ff85b22588 +README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31 diff --git a/packages/host/directory-picker-native/README.md b/packages/host/directory-picker-native/README.md index 0d0fe8d3a0..d48622dead 100644 --- a/packages/host/directory-picker-native/README.md +++ b/packages/host/directory-picker-native/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with per-monitor-v2 DPI awareness, aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). +The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md). **Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). diff --git a/packages/host/directory-picker-native/README.zh.md b/packages/host/directory-picker-native/README.zh.md index 82f51976af..33cb11b4e7 100644 --- a/packages/host/directory-picker-native/README.zh.md +++ b/packages/host/directory-picker-native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,带 per-monitor-v2 DPI 感知,中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 +[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。 **双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index dc797a712e..03980af2a4 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -1,9 +1,10 @@ /** * koffi-backed Win32 bindings for the folder dialog: the COM vtable calls * behind {@link Win32DialogBindings} plus the cross-thread window closer the - * driver uses to service aborts. Loaded lazily and only on win32 (the dialog - * worker and the driver's abort path), so non-Windows processes never load - * koffi — the same containment as the repo's other `win32.ts` modules. + * driver uses to service aborts. The module loads on every platform; koffi + * itself is imported lazily inside each function, so non-Windows processes + * never load it — the same containment as the repo's other `win32.ts` + * modules. * * The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and * IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is @@ -29,7 +30,14 @@ interface Koffi { const COINIT_APARTMENTTHREADED = 0x2 const CLSCTX_INPROC_SERVER = 0x1 const SIGDN_FILESYSPATH = 0x80058000 | 0 -const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4 +/** + * Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10 + * 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext` + * returns NULL for an unsupported context instead of throwing, so the caller + * cascades to the best one the host accepts; DPI stays a cosmetic + * best-effort — an unsupported host still gets the modern dialog. + */ +const DPI_AWARENESS_CONTEXTS = [-4, -3, -2] const WM_CLOSE = 0x10 /** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */ @@ -94,14 +102,22 @@ export async function loadWin32DialogBindings(): Promise { return { setThreadDpiAwareness: () => { + let setContext: KoffiFunction try { - const setThreadDpiAwarenessContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) - setThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr']) } catch { - // SetThreadDpiAwarenessContext absent (Windows 10 pre-1703): the - // dialog renders at system DPI; nothing else can fail here because - // user32 itself loaded above. + // Symbol absent (pre-1607 Windows): no per-thread DPI control exists. + // Proceed anyway — the cost is a blurry dialog above 100 % scaling on + // museum hosts, and the modern picker still beats dropping to the + // legacy 5.1 tree over a cosmetic concern. + return } + for (const context of DPI_AWARENESS_CONTEXTS) { + if (setContext(context) !== null) return + } + // Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol + // exists); if a host ever refuses everything, the dialog still works — + // just without a DPI opt-in. }, coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number, coUninitialize: () => { diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index 781cbc4b24..ff02a93105 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -1,9 +1,10 @@ /** * Real-process half of the Win32 dialog driver: spawn the dialog worker - * (source or built plane) and close a dialog thread's windows. Loaded lazily - * and only on the win32 default path, so non-Windows processes never touch - * worker or koffi machinery; the driver's logic is tested against fakes of - * this surface instead. + * (source or built plane) and close a dialog thread's windows. The module + * itself loads everywhere (the import chain from native-picker.ts is + * static); what stays win32-only is koffi, imported dynamically inside the + * bindings' functions. The driver's logic is tested against fakes of this + * surface instead. */ import { fileURLToPath } from 'node:url' diff --git a/packages/host/directory-picker-native/src/win32-dialog-logic.ts b/packages/host/directory-picker-native/src/win32-dialog-logic.ts index 65be1149dc..aa9d1445c4 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-logic.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-logic.ts @@ -49,9 +49,12 @@ export interface Win32FolderDialog { /** The thread-level native surface the dialog sequencing runs against. */ export interface Win32DialogBindings { /** - * Best-effort per-monitor-v2 DPI opt-in for the calling thread. Absent - * before Windows 10 1703; implementations swallow only that absence, so an - * old host merely renders the dialog at system DPI. + * Opt the calling thread into the best supported DPI awareness + * (per-monitor-v2, then per-monitor, then system-aware), checking each + * call's result. Best-effort on purpose: a host accepting none of them + * (or lacking the API, pre-1607) still shows the modern dialog — possibly + * blurry above 100 % scaling — because a cosmetic degradation must not + * cost the tier. */ setThreadDpiAwareness(): void /** diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index b97c3659fe..a3aee2268a 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -83,7 +83,9 @@ export async function pickWin32Directory( const postClose = (): void => { // Before `showing` there is no window to close; the budget below still - // runs so a worker that never reports cannot dangle the pick. + // runs so a worker that never reports cannot dangle the pick. A + // rejected close attempt (EnumThreadWindows/PostMessageW refusing) is + // discarded: the interval retries it and terminate is the backstop. if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } diff --git a/packages/host/directory-picker-native/tests/built-worker.e2e.ts b/packages/host/directory-picker-native/tests/built-worker.e2e.ts new file mode 100644 index 0000000000..03ae060f77 --- /dev/null +++ b/packages/host/directory-picker-native/tests/built-worker.e2e.ts @@ -0,0 +1,31 @@ +/** + * Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker + * shape): plain `worker_threads` loads `lib/worker.cjs` and the bundle reaches + * its real koffi requires. POSIX hosts prove the load path end to end through + * the deterministic ole32 rejection; win32 skips (a real dialog would open), + * where the win32-only smoke in win32-dialog.spec.ts covers the source plane + * instead. Skips until a build produces the artifact. + */ + +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { Worker } from 'node:worker_threads' +import { describe, expect, it } from 'vitest' +import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts' + +const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url)) + +describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => { + it('loads under plain worker_threads and reports the native-surface failure', async () => { + const message = await new Promise((resolve, reject) => { + const worker = new Worker(builtWorker, { workerData: { title: 'Built-artifact guard' } }) + worker.on('message', resolve) + worker.on('error', reject) + worker.on('exit', (code) => { + reject(new Error(`worker exited (${code}) before reporting`)) + }) + }) + expect(message.kind).toBe('error') + expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i) + }, 30_000) +}) diff --git a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts index 799c24a062..d29fbad7a0 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog-bindings.spec.ts @@ -13,6 +13,12 @@ import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts const E_FAIL = 0x80004005 | 0 const WM_CLOSE = 0x10 +/** + * Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer + * sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against + * this width (the win32-ia32 bug class). + */ +const FAKE_POINTER_SIZE = 4 interface ComWorld { coInitHr: number @@ -21,6 +27,8 @@ interface ComWorld { getResultHr: number getDisplayNameHr: number hasThreadDpi: boolean + /** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */ + supportedDpiContexts: number[] enumThrows: boolean path: string titles: string[] @@ -37,7 +45,7 @@ interface ComWorld { function comWorld(overrides: Partial = {}): ComWorld { return { coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0, - hasThreadDpi: true, enumThrows: false, + hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false, path: 'C:\\选中\\directory', titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [], registered: 0, unregistered: 0, uninitialized: 0, @@ -89,6 +97,10 @@ function installFakeKoffi(world: ComWorld): void { case 'CoUninitialize': return () => { world.uninitialized += 1 } case 'CoCreateInstance': return (...args: unknown[]) => { if (world.coCreateHr < 0) return world.coCreateHr + // The out-pointer must be allocated at the fake's pointer width. + if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) { + throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`) + } outBuffers.set(args[4], dialogPtr) return 0 } @@ -96,7 +108,10 @@ function installFakeKoffi(world: ComWorld): void { case 'GetCurrentThreadId': return () => 31337 case 'SetThreadDpiAwarenessContext': { if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`) - return (context: unknown) => { world.dpiContexts.push(context); return null } + return (context: unknown) => { + world.dpiContexts.push(context) + return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null + } } case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => { if (world.enumThrows) throw new Error('EnumThreadWindows refused') @@ -111,15 +126,16 @@ function installFakeKoffi(world: ComWorld): void { }), proto: (declaration: string) => ({ declaration }), pointer: (type: unknown) => type, - sizeof: (type: string) => { void type; return 8 }, + sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE }, register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } }, unregister: () => { world.unregistered += 1 }, decode: (value: unknown, offsetOrType: unknown): unknown => { if (offsetOrType === 'str16') return (value as FakePtr).text if (typeof offsetOrType === 'number') { - // Vtable slot read: hand back a callable-reference sentinel. + // Vtable slot read: offsets must be multiples of the fake width. + if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`) const owner = (value as { owner: FakePtr }).owner - return { call: (args: unknown[]) => dispatch(owner, offsetOrType / 8, args) } + return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) } } // decode(x, 'void *'): out-buffer read or vtable read. if (outBuffers.has(value)) return outBuffers.get(value) @@ -159,17 +175,41 @@ describe('loadWin32DialogBindings over the fake COM world', () => { expect(world.uninitialized).toBe(1) }) - it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => { - const world = comWorld({ showHr: HRESULT_CANCELLED, hasThreadDpi: false, coInitHr: 1 }) + it('maps dismissal and the S_FALSE CoInitializeEx', async () => { + const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 }) installFakeKoffi(world) const { loadWin32DialogBindings } = await loadBindingsModule() const bindings = await loadWin32DialogBindings() expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull() - expect(world.dpiContexts).toEqual([]) expect(world.released).toEqual(['dialog']) expect(world.uninitialized).toBe(1) }) + it('cascades DPI contexts to the first the host accepts', async () => { + const world = comWorld({ supportedDpiContexts: [-3] }) + installFakeKoffi(world) + const bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(world.dpiContexts).toEqual([-4, -3]) + }) + + it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => { + // DPI is a cosmetic best-effort: the modern dialog still opens. + const rejecting = comWorld({ supportedDpiContexts: [] }) + installFakeKoffi(rejecting) + let bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(rejecting.dpiContexts).toEqual([-4, -3, -2]) + + vi.doUnmock('koffi') + vi.resetModules() + const preThreadDpi = comWorld({ hasThreadDpi: false }) + installFakeKoffi(preThreadDpi) + bindings = await (await loadBindingsModule()).loadWin32DialogBindings() + expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory') + expect(preThreadDpi.dpiContexts).toEqual([]) + }) + it('surfaces creation and extraction failures as HRESULT errors', async () => { const creationWorld = comWorld({ coCreateHr: E_FAIL }) installFakeKoffi(creationWorld)