feat(picker): open the Win32 folder dialog in-process over koffi

The modern IFileOpenDialog becomes the primary win32 tier: a koffi-driven
COM conversation on a worker_threads worker (the modal Show never blocks
the host event loop), per-monitor-v2 DPI via SetThreadDpiAwarenessContext,
and abort service by re-posting WM_CLOSE to the dialog thread's windows,
with terminate+unref as the last resort (Node cannot interrupt a thread
blocked in native code, and such a worker must never hold the process open).

The PowerShell chain stays as the fallback tier with its trigger widened
from ENOENT to any pwsh failure, closing the review-flagged PowerShell 6
regression (no WinForms: exit 1, not ENOENT, so 5.1 never ran).

Layering keeps per-file coverage honest on every host: pure sequencing and
the driver test against fakes anywhere; the bindings run against a mocked
koffi COM world (the session-persistence-jsonl technique); POSIX hosts
drive the real spawn plumbing to its koffi-load rejection; win32 hosts run
a real open-and-abort-close smoke. The smoke joins processBoundTests: a
worker blocked in a native modal wedges the threads pool's teardown, while
a fork contains it. The worker bundles as its own CJS tsdown entry
(workflow-workerthread's pattern; no TLA), and the host module is imported
statically so the node-half bundle stays chunk-free.

Built-plane and real-COM behavior verified on native Windows: standalone
probes for the source worker, the built CJS worker, and the driver's abort
path all open and close the real dialog.

Agent Notes: new implemented/feature/2026-08-02-win32-in-process-folder-dialog
(bilingual) owns the decision; the DPI note is re-scoped to the fallback tier
it now describes and its AutoUpgradeEnabled attribution corrected (.NET Core
3.0 rewrote FolderBrowserDialog; the opt-out arrived in .NET 6).
This commit is contained in:
Huanqi Cao
2026-08-03 00:06:47 +08:00
parent da1b1ff87d
commit 089f4dfad8
23 changed files with 1174 additions and 41 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
README.md: ab4326fed886e9bb2fa550ae9865550eed7c2583
README.zh.md: cb2e067d340df1696e1b1195ec99f6d63509cc20
README.md: 0d0fe8d3a049d6fbc47eee314f9782352651247d
README.zh.md: 82f51976afe2e57699c1bd62d11142106b082e9b

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS, `pwsh` (PowerShell 7) with a Windows PowerShell 5.1 fallback on Windows — the dialog script opts the process into system DPI awareness — and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with per-monitor-v2 DPI awareness, aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
@@ -17,4 +17,5 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
- **Windows needs PowerShell 7 for the modern picker** — `pwsh` renders the Explorer-style folder dialog; a machine with only Windows PowerShell 5.1 falls back to the legacy folder tree, DPI-corrected but not the modern UI.
- **The Windows fallback chain degrades the dialog** — the in-process picker is the modern Explorer-style dialog; where koffi cannot drive COM the PowerShell tiers take over, and a machine that only reaches Windows PowerShell 5.1 gets the legacy folder tree, DPI-corrected but not the modern UI.
- **A wedged abort can leak one dialog thread** — when `WM_CLOSE` never lands (the dialog window was never created), the driver terminates and unrefs the worker; Node cannot interrupt a thread blocked in the native modal call, so that thread lives until process exit.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Windows 使用 `pwsh`PowerShell 7并以 Windows PowerShell 5.1 回退——对话框脚本会把进程设为系统 DPI aware——Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,带 per-monitor-v2 DPI 感知,中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`PowerShell 6 没有 WinForms同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
@@ -17,4 +17,5 @@
## 已知限制与延期工作
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
- **Windows 需要 PowerShell 7 才能使用现代选择器**——`pwsh` 呈现资源管理器风格的文件夹对话框;只有 Windows PowerShell 5.1 的机器会回退到旧版文件夹树DPI 已修正,但界面不是现代的。
- **Windows 回退链会降级对话框**——进程内选择器就是现代资源管理器风格对话框koffi 无法驱动 COM 时由 PowerShell 层级接手,最终只到达 Windows PowerShell 5.1 的机器到旧版文件夹树DPI 已修正,但界面不是现代的。
- **卡死的中止可能泄漏一个对话框线程**——当 `WM_CLOSE` 始终投递不到对话框窗口从未创建driver 会 terminate 并 unref 该 workerNode 无法打断阻塞在原生模态调用里的线程,因此该线程会存活到进程退出。

View File

@@ -26,6 +26,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/win32-dialog-worker.cjs",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -33,7 +34,8 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
"@deepseek-ai/dsh-native-command": "workspace:^"
"@deepseek-ai/dsh-native-command": "workspace:^",
"koffi": "^3.1.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
@@ -50,7 +52,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@types/react": "~18.3.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0"
"react": "^18.2.0",
"tsx": "^4.19.2"
},
"dshClient": {
"inject": [

View File

@@ -1,6 +1,7 @@
/** Cross-platform native single-directory chooser behind the native backend's capability. */
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
import { pickWin32Directory } from './win32-dialog.ts'
/** Testable command boundary; native implementations never invoke a shell. */
export type DirectoryPickerRunner = NativeCommandRunner
@@ -9,6 +10,8 @@ export type DirectoryPickerRunner = NativeCommandRunner
export interface DirectoryPickerInternals {
platform?: NodeJS.Platform
run?: DirectoryPickerRunner
/** Replaces the in-process Win32 dialog (`pickWin32Directory`) for deterministic tests. */
pickWin32Dialog?: (signal: AbortSignal) => Promise<string | null>
}
function outputPath(stdout: string): string | null {
@@ -64,13 +67,26 @@ export async function pickNativeDirectory(
}
if (platform === 'win32') {
// PowerShell 7 renders the modern IFileDialog folder picker, while Windows
// PowerShell 5.1's FolderBrowserDialog is hardwired to the legacy
// SHBrowseForFolder tree; prefer pwsh and fall back only when it is absent.
// Both hosts spawn DPI-unaware, so the script opts the process into system
// DPI awareness before any window is created. No Description is set: the
// modern dialog renders it as a bottom strip and the classic dialog as an
// unthemed box.
// Primary: the in-process koffi-backed IFileOpenDialog worker — the modern
// picker with per-monitor-v2 DPI, no PowerShell dependency, and abort
// support. Any non-abort failure (koffi unavailable, ancient Windows, COM
// refusal) falls back to the PowerShell chain below.
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
try {
return await pickDialog(signal)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
}
// PowerShell fallback: PowerShell 7 renders the modern IFileDialog folder
// picker, while Windows PowerShell 5.1's FolderBrowserDialog is hardwired
// to the legacy SHBrowseForFolder tree. Prefer pwsh, but ANY pwsh failure
// falls back to 5.1 (which every Windows ships): a resolvable pwsh can
// still be unable to deliver the dialog — PowerShell 6 has no WinForms,
// so its Add-Type exits 1, not ENOENT. Both hosts spawn DPI-unaware, so
// the script opts the process into system DPI awareness before any window
// is created. No Description is set: the modern dialog renders it as a
// bottom strip and the classic dialog as an unthemed box.
const script = [
"$ErrorActionPreference = 'Stop'",
"Add-Type -TypeDefinition 'using System; using System.Runtime.InteropServices; public static class DpiAware { [DllImport(\"user32.dll\")] public static extern bool SetProcessDPIAware(); }'",
@@ -89,7 +105,6 @@ export async function pickNativeDirectory(
return outputPath(result.stdout)
} catch (error: unknown) {
rethrowIfAborted(signal, error)
if (!isMissingCommand(error)) throw error
}
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)

View File

@@ -0,0 +1,157 @@
/**
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
* driver uses to service aborts. Loaded lazily and only on win32 (the dialog
* worker and the driver's abort path), so non-Windows processes never load
* koffi — the same containment as the repo's other `win32.ts` modules.
*
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
* object's first pointer.
*/
import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts'
interface KoffiFunction { (...args: unknown[]): unknown }
interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction }
interface Koffi {
load(path: string): KoffiLibrary
proto(declaration: string): unknown
pointer(type: unknown): unknown
call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown
decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown
register(fn: (...args: unknown[]) => unknown, type: unknown): unknown
unregister(callback: unknown): void
}
const COINIT_APARTMENTTHREADED = 0x2
const CLSCTX_INPROC_SERVER = 0x1
const SIGDN_FILESYSPATH = 0x80058000 | 0
const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
const WM_CLOSE = 0x10
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
const SLOT_RELEASE = 2
const SLOT_SHOW = 3
const SLOT_SET_OPTIONS = 9
const SLOT_SET_TITLE = 17
const SLOT_GET_RESULT = 20
/** IShellItem vtable slot for `GetDisplayName`. */
const SLOT_GET_DISPLAY_NAME = 5
/**
* Encode a canonical GUID string as its 16 little-endian bytes.
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
* @returns the in-memory GUID bytes CoCreateInstance expects.
*/
function guidBytes(text: string): Buffer {
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray
const bytes = Buffer.alloc(16)
bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0)
bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4)
bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6)
Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8)
return bytes
}
const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7')
const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960')
/**
* Load koffi and expose the dialog bindings for this thread.
* @returns the bindings {@link runFolderDialog} sequences against.
*/
export async function loadWin32DialogBindings(): Promise<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')
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32'])
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)')
const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)')
const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)')
const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)')
/** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */
const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => {
const vtable = koffi.decode(self, 'void *')
const fn = koffi.decode(vtable, slot * 8, 'void *')
return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number
}
return {
setThreadDpiAwareness: () => {
try {
const setThreadDpiAwarenessContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
setThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
} catch {
// SetThreadDpiAwarenessContext absent (Windows 10 pre-1703): the
// dialog renders at system DPI; nothing else can fail here because
// user32 itself loaded above.
}
},
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
currentThreadId: () => getCurrentThreadId() as number,
createFolderDialog: (): Win32FolderDialog => {
const out = Buffer.alloc(8)
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`)
const dialog = koffi.decode(out, 'void *')
return {
setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
resultPath: () => {
const itemOut: unknown[] = [null]
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut)
if (gotItem < 0) return { hr: gotItem }
const item = itemOut[0]
try {
const nameOut: unknown[] = [null]
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut)
if (gotName < 0) return { hr: gotName }
const path = koffi.decode(nameOut[0], 'str16') as string
coTaskMemFree(nameOut[0])
return { hr: gotName, path }
} finally {
method(item, SLOT_RELEASE, protoRelease)()
}
},
release: () => {
method(dialog, SLOT_RELEASE, protoRelease)()
},
}
},
}
}
/**
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
* lever against the worker blocked inside `Show`, after which `Show` returns
* `HRESULT_CANCELLED` and the worker unwinds normally.
* @param threadId - the dialog thread's native id (from the `showing` notice).
*/
export async function closeThreadWindows(threadId: number): Promise<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)
}
}

View File

@@ -0,0 +1,36 @@
/**
* Real-process half of the Win32 dialog driver: spawn the dialog worker
* (source or built plane) and close a dialog thread's windows. Loaded lazily
* and only on the win32 default path, so non-Windows processes never touch
* worker or koffi machinery; the driver's logic is tested against fakes of
* this surface instead.
*/
import { fileURLToPath } from 'node:url'
import { Worker } from 'node:worker_threads'
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
/**
* Spawn the dialog worker. Built consumers load the bundled CJS worker next
* to this module; unbuilt (source) consumers bootstrap tsx inside the worker
* first, mirroring `dsh-workflow-workerthread`'s host.
* @param data - the worker payload (dialog title).
* @returns the spawned worker thread.
*/
export function spawnDialogWorker(data: Win32DialogWorkerData): Worker {
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
if (!import.meta.url.endsWith('.ts')) {
return new Worker(fileURLToPath(new URL('./win32-dialog-worker.cjs', import.meta.url)), { workerData: data })
}
const workerEntry = new URL('./win32-dialog-worker.ts', import.meta.url)
const bootstrap = [
`import { register as registerEsm } from ${JSON.stringify(import.meta.resolve('tsx/esm/api'))}`,
`import { register as registerCjs } from ${JSON.stringify(import.meta.resolve('tsx/cjs/api'))}`,
'registerCjs()',
'registerEsm()',
`await import(${JSON.stringify(workerEntry.href)})`,
].join('\n')
return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data })
}
export { closeThreadWindows } from './win32-dialog-bindings.ts'

View File

@@ -0,0 +1,117 @@
/**
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
* conversation over an injectable bindings seam, so every outcome path
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
* any platform. The koffi-backed bindings live in
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.
*/
/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */
export const HRESULT_CANCELLED = 0x800704c7 | 0
/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */
export const FOS_PICKFOLDERS = 0x20
/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */
export const FOS_FORCEFILESYSTEM = 0x40
/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */
export const FOS_NOCHANGEDIR = 0x8
/** One created folder dialog: the vtable calls the sequencing needs. */
export interface Win32FolderDialog {
/**
* `IFileDialog::SetOptions`.
* @param options - the `FOS_*` flag union to apply.
* @returns the call's HRESULT.
*/
setOptions(options: number): number
/**
* `IFileDialog::SetTitle`.
* @param title - the dialog title text.
* @returns the call's HRESULT.
*/
setTitle(title: string): number
/**
* `IModalWindow::Show` with no owner window; blocks the calling thread
* until the user selects or dismisses.
* @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal).
*/
show(): number
/**
* `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`,
* releasing the shell item and freeing the COM string.
* @returns the call chain's HRESULT and, on success, the selected path.
*/
resultPath(): { hr: number; path?: string }
/** Release the dialog's COM reference. */
release(): void
}
/** The thread-level native surface the dialog sequencing runs against. */
export interface Win32DialogBindings {
/**
* Best-effort per-monitor-v2 DPI opt-in for the calling thread. Absent
* before Windows 10 1703; implementations swallow only that absence, so an
* old host merely renders the dialog at system DPI.
*/
setThreadDpiAwareness(): void
/**
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
*/
coInitializeSta(): number
/**
* `CoCreateInstance(CLSID_FileOpenDialog)`.
* @returns the created dialog surface; throws when creation fails.
*/
createFolderDialog(): Win32FolderDialog
/**
* `GetCurrentThreadId` — the native id a driver needs to close this
* thread's windows from outside.
* @returns the calling thread's native id.
*/
currentThreadId(): number
}
/**
* Throw when an HRESULT signals failure.
* @param hr - the HRESULT to check.
* @param what - the failing call's name for the error message.
* @returns the (successful) HRESULT unchanged.
*/
function check(hr: number, what: string): number {
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`)
return hr
}
/**
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
* STA init, dialog creation, `Show`, and result extraction, releasing the
* dialog on every path.
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
* @param title - the dialog title text.
* @param onShowing - called with the native thread id immediately before the
* blocking `Show`, so a driver on another thread can close the dialog.
* @returns the selected filesystem path, or null when the user cancels.
*/
export function runFolderDialog(
bindings: Win32DialogBindings,
title: string,
onShowing: (threadId: number) => void,
): string | null {
bindings.setThreadDpiAwareness()
check(bindings.coInitializeSta(), 'CoInitializeEx')
const dialog = bindings.createFolderDialog()
try {
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
check(dialog.setTitle(title), 'SetTitle')
onShowing(bindings.currentThreadId())
const shown = dialog.show()
if (shown === HRESULT_CANCELLED) return null
check(shown, 'Show')
const result = dialog.resultPath()
check(result.hr, 'GetResult')
return result.path as string
} finally {
dialog.release()
}
}

View File

@@ -0,0 +1,37 @@
/**
* Worker entry for the Win32 folder dialog: blocks THIS thread inside the
* modal `Show` so the host event loop stays live, reporting over the message
* port. Protocol: `{kind:'showing',threadId}` right before the blocking call
* (the driver's abort lever needs the native thread id), then exactly one of
* `{kind:'done',path}` or `{kind:'error',message}`.
*/
import { parentPort, workerData } from 'node:worker_threads'
import { loadWin32DialogBindings } from './win32-dialog-bindings.ts'
import { runFolderDialog } from './win32-dialog-logic.ts'
/** The driver-to-worker payload: the dialog title. */
export interface Win32DialogWorkerData { title: string }
/** One notice or outcome posted back to the driver. */
export type Win32DialogWorkerMessage =
| { kind: 'showing'; threadId: number }
| { kind: 'done'; path: string | null }
| { kind: 'error'; message: string }
const port = parentPort
if (port === null) throw new Error('win32-dialog-worker must run as a worker thread')
const { title } = workerData as Win32DialogWorkerData
// No top-level await: the built worker ships as CJS (pkg's VFS Worker hook
// compiles that format), which cannot carry TLA.
void (async () => {
try {
const bindings = await loadWin32DialogBindings()
const path = runFolderDialog(bindings, title, (threadId) =>{ port.postMessage({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage) })
port.postMessage({ kind: 'done', path } satisfies Win32DialogWorkerMessage)
} catch (error: unknown) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
port.postMessage({ kind: 'error', message } satisfies Win32DialogWorkerMessage)
}
})()

View File

@@ -0,0 +1,128 @@
/**
* Main-thread driver for the Win32 folder dialog: spawns the dialog worker
* (which blocks inside the modal `Show`), maps its message protocol onto a
* promise, and services aborts by posting `WM_CLOSE` to the dialog thread's
* windows until the worker reports back. The real worker/window surface is
* injectable so every driver path is testable on any platform.
*/
import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts'
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
/** The worker surface the driver drives (satisfied by `node:worker_threads`). */
export interface Win32DialogWorkerLike {
/**
* Subscribe to a worker event.
* @param event - `message`, `error`, or `exit`.
* @param listener - the event consumer.
*/
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown
on(event: 'error', listener: (error: Error) => void): unknown
on(event: 'exit', listener: (code: number) => void): unknown
/**
* Force-stop the worker; the abort path's last resort when `WM_CLOSE`
* never lands (e.g. the dialog window was never created).
* @returns settles when the thread is gone.
*/
terminate(): Promise<number>
/**
* Release the event-loop reference. Called once the pick settles so a
* worker stuck in the native modal call (terminate cannot interrupt
* native code) never blocks process exit.
*/
unref?(): void
}
/** Injectable process surface for deterministic driver tests. */
export interface Win32DialogInternals {
/** Replaces the real worker spawn (`win32-dialog-host.ts`). */
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
closeThreadWindows?: (threadId: number) => Promise<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
/**
* 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 = 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 serviceAbort = (): void => {
let attempts = 0
// The `showing` notice precedes the blocking `Show`, so the very first
// WM_CLOSE can race the window's creation; re-post until the worker
// reports back, then force-terminate as a last resort.
closeTimer = setInterval(() => {
attempts += 1
if (attempts > CLOSE_MAX_ATTEMPTS) {
settle(() => {
void worker.terminate()
reject(new Error('native directory picker aborted (dialog unresponsive; worker terminated)'))
})
return
}
void closeWindows(dialogThreadId as number).catch(() => undefined)
}, closeRetryMs)
void closeWindows(dialogThreadId as number).catch(() => undefined)
}
const onAbort = (): void => {
if (dialogThreadId !== undefined) serviceAbort()
// Not shown yet: the `showing` handler below starts the service loop.
}
signal.addEventListener('abort', onAbort, { once: true })
worker.on('message', (message: Win32DialogWorkerMessage) => {
switch (message.kind) {
case 'showing':
dialogThreadId = message.threadId
if (signal.aborted) serviceAbort()
return
case 'done':
settle(() => {
if (signal.aborted) reject(new Error('native directory picker aborted'))
else resolve(message.path)
})
return
case 'error':
settle(() =>{ reject(new Error(`win32 folder dialog failed: ${message.message}`)) })
}
})
worker.on('error', (error: Error) =>{ settle(() =>{ reject(error) }) })
worker.on('exit', () =>{ settle(() =>{ reject(new Error('win32 folder dialog worker exited before reporting a result')) }) })
})
}

View File

@@ -23,6 +23,9 @@ function failure(code: string | number, stderr = ''): Error {
const signal = () => new AbortController().signal
/** The PowerShell chain is reachable only when the in-process dialog fails. */
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
describe('native directory picker', () => {
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
@@ -46,9 +49,18 @@ describe('native directory picker', () => {
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('prefers pwsh for the Windows folder dialog and maps empty output to cancellation', async () => {
it('prefers the in-process Win32 dialog and never spawns PowerShell when it answers', async () => {
const run = vi.fn<DirectoryPickerRunner>()
const pickWin32Dialog = vi.fn(async (): Promise<string | null> => 'C:\\work\\selected')
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBe('C:\\work\\selected')
pickWin32Dialog.mockResolvedValueOnce(null)
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog })).resolves.toBeNull()
expect(run).not.toHaveBeenCalled()
})
it('falls back to pwsh when the dialog is unavailable and maps empty output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\project')
expect(run).toHaveBeenCalledWith(
'pwsh.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
@@ -60,48 +72,70 @@ describe('native directory picker', () => {
// Description renders as a bottom strip (modern) / unthemed box (classic); never set it.
expect(script).not.toContain('Description')
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBeNull()
})
it('falls back to Windows PowerShell 5.1 only when pwsh is missing', async () => {
it('falls back to Windows PowerShell 5.1 whenever pwsh cannot deliver the dialog', async () => {
const run = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: 'C:\\work\\fallback\r\n', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\fallback')
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\fallback')
expect(run.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
// Both runtimes execute the identical script, so DPI awareness holds either way.
expect(run.mock.calls[0]?.[1].at(-1)).toBe(run.mock.calls[1]?.[1].at(-1))
// A resolvable pwsh that cannot deliver the dialog (PowerShell 6: no
// WinForms, Add-Type exits 1 - not ENOENT) reaches 5.1 all the same.
const pwsh6 = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure(1, "Cannot load assembly 'System.Windows.Forms'"))
.mockResolvedValueOnce({ stdout: 'C:\\work\\legacy\r\n', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: pwsh6, pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\legacy')
expect(pwsh6.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'powershell.exe'])
const cancelled = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled })).resolves.toBeNull()
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: cancelled, pickWin32Dialog: noDialog })).resolves.toBeNull()
const failed = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(2))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed })).rejects.toThrow('command failed')
const brokenPwsh = vi.fn<DirectoryPickerRunner>(async () => { throw failure(7) })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: brokenPwsh })).rejects.toThrow('command failed')
expect(brokenPwsh).toHaveBeenCalledOnce()
await expect(pickNativeDirectory(signal(), { platform: 'win32', run: failed, pickWin32Dialog: noDialog })).rejects.toThrow('command failed')
})
it('does not fall back when the caller aborted the pwsh spawn', async () => {
it('wires the real Win32 dialog as the default tier', async () => {
// A pre-aborted signal makes the DEFAULT dialog deterministic on every
// host: pickWin32Directory throws before spawning any worker or window.
const abort = new AbortController()
abort.abort()
const run = vi.fn<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 or the pwsh spawn', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run })).rejects.toThrow('command failed')
expect(run).toHaveBeenCalledOnce()
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
expect(run).not.toHaveBeenCalled()
const liveThenAborted = new AbortController()
const abortingRun = vi.fn<DirectoryPickerRunner>(async () => {
liveThenAborted.abort(new Error('closed'))
throw failure('ENOENT')
})
await expect(pickNativeDirectory(liveThenAborted.signal, { platform: 'win32', run: abortingRun, pickWin32Dialog: noDialog }))
.rejects.toThrow('command failed')
expect(abortingRun).toHaveBeenCalledOnce()
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).resolves.toBe('C:\\work\\default')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('pwsh.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
@@ -109,19 +143,30 @@ describe('native directory picker', () => {
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
// Both chain tiers fail: pwsh's code-7 failure now reaches 5.1, whose
// failure is the one the caller sees.
const pwshError = Object.assign(new Error('pwsh failed'), { code: 7 })
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(pwshError, '', 'no WinForms')
})
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
await expect(pickNativeDirectory(signal(), { platform: 'win32', pickWin32Dialog: noDialog })).rejects.toMatchObject({
message: 'powershell failed', cause: commandError, code: 7,
stdout: 'partial output', stderr: 'failure details',
})
expect(execFileMock.mock.calls.map(call => call[0])).toEqual(['pwsh.exe', 'pwsh.exe', 'powershell.exe'])
})
it('uses the current process platform when no platform override is supplied', async () => {
// Deterministic on every host: the win32 tier answers from the dialog,
// the POSIX tiers from the command runner.
const run = vi.fn<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('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {

View File

@@ -0,0 +1,284 @@
/**
* The koffi-backed bindings against a mocked `koffi` module (the same
* technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory
* COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch,
* result extraction, memory hygiene, and the WM_CLOSE poster covered on every
* host. The worker entry is exercised the same way with a mocked
* `node:worker_threads`. Real-COM behavior is pinned by the win32-only smoke
* in win32-dialog.spec.ts.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts'
const E_FAIL = 0x80004005 | 0
const WM_CLOSE = 0x10
interface ComWorld {
coInitHr: number
coCreateHr: number
showHr: number
getResultHr: number
getDisplayNameHr: number
hasThreadDpi: boolean
enumThrows: boolean
path: string
titles: string[]
options: number[]
dpiContexts: unknown[]
freed: unknown[]
released: string[]
posted: { hwnd: unknown; message: number }[]
registered: number
unregistered: number
}
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
return {
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
hasThreadDpi: true, enumThrows: false,
path: 'C:\\选中\\directory',
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
registered: 0, unregistered: 0,
...overrides,
}
}
/** Sentinel pointer objects standing in for native addresses. */
interface FakePtr { kind: string; [key: string]: unknown }
function installFakeKoffi(world: ComWorld): void {
const dialogPtr: FakePtr = { kind: 'dialog' }
const itemPtr: FakePtr = { kind: 'item' }
const namePtr: FakePtr = { kind: 'name', text: world.path }
const outBuffers = new Map<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 'CoCreateInstance': return (...args: unknown[]) => {
if (world.coCreateHr < 0) return world.coCreateHr
outBuffers.set(args[4], dialogPtr)
return 0
}
case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
case 'GetCurrentThreadId': return () => 31337
case 'SetThreadDpiAwarenessContext': {
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
return (context: unknown) => { world.dpiContexts.push(context); return null }
}
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
callback.fn({ kind: 'hwnd', n: 1 }, lparam)
callback.fn({ kind: 'hwnd', n: 2 }, lparam)
return 1
}
case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 }
default: throw new Error(`unexpected native import ${dll}/${name}`)
}
},
}),
proto: (declaration: string) => ({ declaration }),
pointer: (type: unknown) => type,
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
unregister: () => { world.unregistered += 1 },
decode: (value: unknown, offsetOrType: unknown): unknown => {
if (offsetOrType === 'str16') return (value as FakePtr).text
if (typeof offsetOrType === 'number') {
// Vtable slot read: hand back a callable-reference sentinel.
const owner = (value as { owner: FakePtr }).owner
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / 8, args) }
}
// decode(x, 'void *'): out-buffer read or vtable read.
if (outBuffers.has(value)) return outBuffers.get(value)
return { owner: value as FakePtr }
},
call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args),
},
}))
}
async function loadBindingsModule(): Promise<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'])
})
it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => {
const world = comWorld({ showHr: HRESULT_CANCELLED, hasThreadDpi: false, coInitHr: 1 })
installFakeKoffi(world)
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(world.dpiContexts).toEqual([])
expect(world.released).toEqual(['dialog'])
})
it('surfaces creation and extraction failures as HRESULT errors', async () => {
const creationWorld = comWorld({ coCreateHr: E_FAIL })
installFakeKoffi(creationWorld)
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005')
vi.doUnmock('koffi')
vi.resetModules()
const resultWorld = comWorld({ getResultHr: E_FAIL })
installFakeKoffi(resultWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
expect(resultWorld.released).toEqual(['dialog'])
vi.doUnmock('koffi')
vi.resetModules()
const nameWorld = comWorld({ getDisplayNameHr: E_FAIL })
installFakeKoffi(nameWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
// The shell item is released even when its display name cannot be read.
expect(nameWorld.released).toEqual(['item', 'dialog'])
expect(nameWorld.freed).toHaveLength(0)
})
})
describe('closeThreadWindows over the fake COM world', () => {
it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => {
const world = comWorld()
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await closeThreadWindows(777)
expect(world.posted).toEqual([
{ hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE },
{ hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE },
])
expect(world.registered).toBe(1)
expect(world.unregistered).toBe(1)
})
it('unregisters the callback even when the enumeration itself throws', async () => {
const world = comWorld({ enumThrows: true })
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused')
expect(world.unregistered).toBe(1)
})
})
describe('the worker entry over a mocked thread boundary', () => {
it('posts showing then done for a completed conversation', async () => {
const posted: unknown[] = []
vi.doMock('node:worker_threads', () => ({
parentPort: { postMessage: (message: unknown) => posted.push(message) },
workerData: { title: 'Pick' },
}))
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => ({
setThreadDpiAwareness: () => undefined,
coInitializeSta: () => 0,
currentThreadId: () => 11,
createFolderDialog: () => ({
setOptions: () => 0,
setTitle: () => 0,
show: () => 0,
resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }),
release: () => undefined,
}),
}),
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toEqual([
{ kind: 'showing', threadId: 11 },
{ kind: 'done', path: 'C:\\from-worker' },
])
})
it('posts the failure message when the native surface cannot load', async () => {
const posted: { kind: string; message?: string }[] = []
vi.doMock('node:worker_threads', () => ({
parentPort: { postMessage: (message: { kind: string }) => posted.push(message) },
workerData: { title: 'Pick' },
}))
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw new Error('no ole32 here') },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toHaveLength(1)
expect(posted[0]?.kind).toBe('error')
expect(posted[0]?.message).toContain('no ole32 here')
})
it('stringifies stackless and non-Error failures', async () => {
const stackless = new Error('bare message')
delete stackless.stack
for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) {
vi.doUnmock('node:worker_threads')
vi.doUnmock('../src/win32-dialog-bindings.ts')
vi.resetModules()
const posted: { kind: string; message?: string }[] = []
vi.doMock('node:worker_threads', () => ({
parentPort: { postMessage: (message: { kind: string }) => posted.push(message) },
workerData: { title: 'Pick' },
}))
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw thrown },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted[0]?.message).toBe(expected)
}
})
it('refuses to run outside a worker thread', async () => {
vi.doMock('node:worker_threads', () => ({ parentPort: null, workerData: undefined }))
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a worker thread')
})
})

View File

@@ -0,0 +1,90 @@
/**
* The COM conversation's sequencing against fake bindings: outcome mapping
* (selection / cancellation / HRESULT failures at every step) and the
* release-on-every-path guarantee, all platform-independent.
*/
import { describe, expect, it, vi } from 'vitest'
import {
FOS_FORCEFILESYSTEM, FOS_NOCHANGEDIR, FOS_PICKFOLDERS, HRESULT_CANCELLED,
runFolderDialog, type Win32DialogBindings, type Win32FolderDialog,
} from '../src/win32-dialog-logic.ts'
const E_FAIL = 0x80004005 | 0
interface FakeWorld {
bindings: Win32DialogBindings
dpi: ReturnType<typeof vi.fn>
createDialog: 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 bindings: Win32DialogBindings = {
setThreadDpiAwareness: dpi,
coInitializeSta: vi.fn(() => coInit),
createFolderDialog: createDialog,
currentThreadId: vi.fn(() => 4242),
}
return { bindings, dpi, createDialog, dialog: dialog as FakeWorld['dialog'] }
}
describe('runFolderDialog', () => {
it('sequences DPI, STA, options, title, show, and result extraction', () => {
const { bindings, dpi, dialog } = world()
const showing = vi.fn()
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
expect(dpi).toHaveBeenCalledOnce()
expect(dialog.setOptions).toHaveBeenCalledWith(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR)
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
expect(showing).toHaveBeenCalledWith(4242)
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
expect(dialog.release).toHaveBeenCalledOnce()
})
it('maps the cancelled HRESULT to null and still releases the dialog', () => {
const { bindings, dialog } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(dialog.resultPath).not.toHaveBeenCalled()
expect(dialog.release).toHaveBeenCalledOnce()
})
it('accepts the S_FALSE re-entry HRESULT from CoInitializeEx', () => {
const { bindings } = world({}, 1)
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\picked\\目录')
})
it('throws on a failing CoInitializeEx without creating a dialog', () => {
const { bindings, createDialog } = world({}, E_FAIL)
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('CoInitializeEx failed: HRESULT 0x80004005')
expect(createDialog).not.toHaveBeenCalled()
})
it.each([
['SetOptions', { setOptions: vi.fn(() => E_FAIL) }],
['SetTitle', { setTitle: vi.fn(() => E_FAIL) }],
['Show', { show: vi.fn(() => E_FAIL) }],
['GetResult', { resultPath: vi.fn(() => ({ hr: E_FAIL })) }],
] satisfies [string, Partial<Win32FolderDialog>][])('releases the dialog when %s fails', (what, overrides) => {
const { bindings, dialog } = world(overrides)
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow(`${what} failed: HRESULT 0x80004005`)
expect(dialog.release).toHaveBeenCalledOnce()
void bindings
})
})

View File

@@ -0,0 +1,136 @@
/**
* Driver tests: the worker message protocol mapped onto the promise, the
* WM_CLOSE abort service (including the show-race retry and the terminate
* last resort) against fakes, plus the real spawn plumbing — POSIX hosts
* prove the default path rejects cleanly (koffi cannot load ole32 there),
* and win32 hosts briefly open and auto-abort a real dialog.
*/
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts'
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
class FakeWorker extends EventEmitter implements Win32DialogWorkerLike {
terminate = vi.fn(async () => 0)
post(message: Win32DialogWorkerMessage): void {
this.emit('message', message)
}
}
interface Harness {
worker: FakeWorker
internals: Win32DialogInternals
close: ReturnType<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()
const picked = pickWin32Directory(controller.signal, internals)
worker.post({ kind: 'showing', threadId: 99 })
controller.abort()
await vi.waitFor(() =>{ expect(close).toHaveBeenCalledWith(99) })
worker.post({ kind: 'done', path: null })
await expect(picked).rejects.toThrow('native directory picker aborted')
})
it('starts the close service on the showing notice when the abort came first', async () => {
const closeFailures = vi.fn(async () => { throw new Error('window not there yet') })
const { worker, internals } = harness({ closeThreadWindows: closeFailures })
const controller = new AbortController()
const picked = pickWin32Directory(controller.signal, internals)
controller.abort()
expect(closeFailures).not.toHaveBeenCalled()
worker.post({ kind: 'showing', threadId: 12 })
await vi.waitFor(() =>{ expect(closeFailures.mock.calls.length).toBeGreaterThan(1) })
worker.post({ kind: 'done', path: null })
await expect(picked).rejects.toThrow('native directory picker aborted')
})
it('terminates an unresponsive worker after the close budget', async () => {
const { worker, internals, close } = harness()
const controller = new AbortController()
const picked = pickWin32Directory(controller.signal, internals)
worker.post({ kind: 'showing', threadId: 5 })
controller.abort()
await expect(picked).rejects.toThrow('dialog unresponsive; worker terminated')
expect(worker.terminate).toHaveBeenCalledOnce()
expect(close.mock.calls.length).toBeGreaterThan(10)
})
// POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped
// worker spawns, loads koffi, fails to load ole32.dll, and reports the error.
it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => {
await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed')
}, 30_000)
// win32 hosts run the true COM smoke instead: a real dialog opens briefly
// and the abort service closes it (the same lever a disconnecting client pulls).
it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => {
const controller = new AbortController()
setTimeout(() =>{ controller.abort() }, 400)
await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted')
}, 30_000)
})

View File

@@ -1,3 +1,18 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js'])
// The Win32 dialog worker builds as its own CJS entry (mirroring
// dsh-workflow-workerthread's worker): path-loaded by the driver, inlining
// the dialog logic while koffi stays an external native require.
export default [
...clientBundle('@deepseek-ai/dsh-host-directory-picker-native', ['lib/types/index.js', 'lib/types/invariant.js']),
{
entry: ['lib/types/win32-dialog-worker.js'],
outDir: 'lib',
format: ['cjs'] as ['cjs'],
platform: 'node' as const,
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
]