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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
117
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal file
117
packages/host/directory-picker-native/src/win32-dialog-logic.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})()
|
||||
128
packages/host/directory-picker-native/src/win32-dialog.ts
Normal file
128
packages/host/directory-picker-native/src/win32-dialog.ts
Normal 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')) }) })
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user