Merge remote-tracking branch 'origin/master' into worktree/open-settings-config-file

# Conflicts:
#	packages/client/connection/README.i18n.yaml
#	packages/client/connection/README.md
#	packages/client/connection/README.zh.md
This commit is contained in:
Yichen Jiang
2026-08-05 11:05:52 +08:00
487 changed files with 10605 additions and 1736 deletions

View File

@@ -34,9 +34,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {

View File

@@ -1,5 +1,5 @@
/**
* events domain contract: signatures and frame unions for the two SSE
* events domain contract: signatures and frame unions for the two logical
* streams. Four-quadrant: streams yield the narrow form `RpcRequest<Frame>` (server-request
* view) — rpcId must be exposed to the business layer, because responses to answerable frames
* (approval/question requested) echo it; for pure pushes it identifies that one push.
@@ -42,7 +42,7 @@ export interface QueuedInboxItem {
message: Message
}
/** Streaming face of the contract: the two SSE stream openers (mux + host). */
/** Streaming face of the contract: the two logical stream openers (mux + host). */
export interface EventsApi {
/**
* All-session aggregated mux stream. On open, emits a subscribed control frame for every

View File

@@ -1,7 +1,7 @@
/**
* apiproxy contract-layer barrel. api/ has zero Node dependencies and is
* importable from the browser; the TS interfaces are the authoritative contract, HTTP/SSE are
* merely physical channels (four-quadrant message model).
* importable from the browser; the TS interfaces are the authoritative contract, while HTTP,
* WebSocket, and in-process SSE are merely physical channels (four-quadrant message model).
*/
import type { SessionsApi } from './sessions.ts'

View File

@@ -1,7 +1,7 @@
/**
* Four-quadrant RPC message model. Channels and messages are
* decoupled: HTTP is the client→server physical channel, SSE the server→client one; logical
* messages are channel-independent, and the wire full form is a four-member discriminated union.
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
* WebSocket, and in-process SSE are physical carriers, while logical messages
* are channel-independent and form a four-member discriminated union.
* api/ contract layer: zero Node dependencies, importable from the browser.
*/
@@ -147,7 +147,7 @@ export interface ServerResponse {
}
/**
* Message initiated by the server (wire carrier: SSE frame). Answerable interactions
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
* response is expected is determined statically by method (a strict dichotomy, no third kind).

View File

@@ -1,6 +1,6 @@
/**
* Client side of the fetch carrier. AbstractApiClient holds every protocol invariant: rpcId minting,
* four-quadrant envelope wrap/unwrap, zod parsing, SSE frame decoding, and the payload-direct
* four-quadrant envelope wrap/unwrap, zod parsing, in-process SSE frame decoding, and the payload-direct
* IApiClient domain methods (business code never mints). Platform differences ride two aspects:
* abstract doFetch (transport) + overridable onEnvelope (tap). ApiProxy (the impl face) is untouched.
*/
@@ -70,8 +70,8 @@ import {
* Bounded calls merge it with the instance timeout via AbortSignal.any; user-paced calls
* carry only that external signal. In both cases the signal rides beside the request, never
* on the wire, like the stream signatures.
* Stream methods accept an optional onOpen callback: it fires once the SSE transport is
* readable (response headers received, before any frame) — the "stream established" signal
* Stream methods accept an optional onOpen callback: it fires once the physical transport is
* readable (before any frame) — the "stream established" signal
* connection controllers need for the readiness handshake. Generators are lazy, so the
* underlying fetch (and therefore onOpen) only happens once iteration starts.
* Relationship: ApiProxy is the narrow-form signature contract the impl side implements;

View File

@@ -5,7 +5,7 @@
* platform subclasses on the client side), and the host-side implementation
* (api-proxy.ts: createApiProxy + the ApiProxyService gateway plugin providing
* `ctx.apiProxy`). Transport-agnostic by design: this package registers no
* routes — carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves.
* routes — physical carriers wrap `ctx.apiProxy` themselves.
*/
import { resolve } from 'node:path'

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -26,9 +26,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"dependencies": {

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: 0b54c651d4f5382021d0f8832ab4f1146b7652c8
README.zh.md: e5ac2762a691a16a7e6d9d6dd9aefc70a59dcd4f
README.md: 3d270af441bd251c126c8fb3c3d2d7aec95655c9
README.zh.md: b4a3d91b68c285aad7911ba711348e36ffc7a4c8

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, an STA PowerShell `FolderBrowserDialog` on Windows, and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
@@ -17,3 +17,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
- **Windows has no mechanism fallback** — the child-process picker is the only tier: koffi is a packaged dependency whose availability the install guarantees, so a failed pick (COM refusal, dialog crash) surfaces the failure instead of degrading to a PowerShell-hosted dialog (the former `pwsh` → Windows PowerShell 5.1 chain was removed). The browse backend remains the fallback at the composition level.

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 使用以 STA 模式运行的 PowerShell `FolderBrowserDialog`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**`NativeDirectoryPicker``native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2中止时向对话框线程投递 `WM_CLOSE`。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
**双面包**browser half`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind
@@ -17,3 +17,4 @@
## 已知限制与延期工作
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是 browse 后端)。
- **Windows 没有机制级回退**——子进程选择器是唯一层级koffi 是打包依赖,其可用性由安装保证,因此一次失败的 pickCOM 拒绝、对话框崩溃)直接上报失败,不会降级到 PowerShell 承载的对话框(原有的 `pwsh` → Windows PowerShell 5.1 链已删除)。组合层面的回退仍是 browse 后端。

View File

@@ -19,21 +19,25 @@
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./worker": {
"types": "./lib/types/win32-dialog-worker.d.ts",
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/worker.cjs",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"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 +54,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,9 +1,10 @@
/**
* Native backend of the directory-picker seam: registers `ctx.directoryPicker`
* with the `native` capability, opening one native OS chooser on the host
* display per pick (macOS `osascript`, Windows STA PowerShell
* `FolderBrowserDialog`, Linux Zenity with a KDialog fallback). Only viable
* when the operator sits at the host's screen; remote deployments compose the
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
* koffi-driven COM conversation on the child's main thread). Only viable when
* the operator sits at the host's screen; remote deployments compose the
* browse backend instead.
* @module @deepseek-ai/dsh-host-directory-picker-native
*/

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,20 +67,13 @@ export async function pickNativeDirectory(
}
if (platform === 'win32') {
const script = [
"$ErrorActionPreference = 'Stop'",
'Add-Type -AssemblyName System.Windows.Forms',
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
"$dialog.Description = 'Select Workspace Directory'",
'$dialog.ShowNewFolderButton = $true',
'$result = $dialog.ShowDialog()',
'if ($result -eq [System.Windows.Forms.DialogResult]::OK) {',
' [Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
' [Console]::WriteLine($dialog.SelectedPath)',
'}',
].join('; ')
const result = await run('powershell.exe', ['-NoProfile', '-STA', '-Command', script], signal)
return outputPath(result.stdout)
// The koffi-backed IFileOpenDialog child process — the modern picker with
// per-monitor-v2 DPI and abort support. koffi is a packaged dependency
// whose availability the install guarantees, so there is no fallback
// tier: any failure surfaces as-is (the former PowerShell chain was
// removed — see the simplification Agent Note).
const pickDialog = internals.pickWin32Dialog ?? pickWin32Directory
return await pickDialog(signal)
}
if (platform === 'linux') {

View File

@@ -0,0 +1,195 @@
/**
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
* driver uses to service aborts. The module loads on every platform; koffi
* itself is imported lazily inside each function, so non-Windows processes
* never load it — the same containment as the repo's other `win32.ts`
* modules.
*
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
* frozen Windows ABI since Vista; slots are offsets into the vtable at the
* object's first pointer.
*/
import type { Win32DialogBindings, Win32FolderDialog } from './win32-dialog-logic.ts'
interface KoffiFunction { (...args: unknown[]): unknown }
interface KoffiLibrary { func(convention: string, name: string, result: string, args: string[]): KoffiFunction }
interface Koffi {
load(path: string): KoffiLibrary
proto(declaration: string): unknown
pointer(type: unknown): unknown
call(pointer: unknown, proto: unknown, ...args: unknown[]): unknown
decode(value: unknown, offsetOrType: unknown, type?: unknown): unknown
register(fn: (...args: unknown[]) => unknown, type: unknown): unknown
unregister(callback: unknown): void
sizeof(type: string): number
view(ref: unknown, len: number): ArrayBuffer
}
/**
* Read a NUL-terminated UTF-16 string at a native address. koffi's
* `_Out_ void **` out-params surface a raw address, and
* `koffi.decode(addr, 'str16')` would dereference it as a pointer — crash
* on real Windows — so view the memory directly instead.
*/
function readUtf16(koffi: Koffi, address: unknown): string {
const bytes = Buffer.from(koffi.view(address, 32768))
let end = 0
while (end + 1 < bytes.length && bytes[end] !== 0) end += 2
return bytes.toString('utf16le', 0, end)
}
const COINIT_APARTMENTTHREADED = 0x2
const CLSCTX_INPROC_SERVER = 0x1
const SIGDN_FILESYSPATH = 0x80058000 | 0
/**
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
* returns NULL for an unsupported context instead of throwing, so the caller
* cascades to the best one the host accepts; DPI stays a cosmetic
* best-effort — an unsupported host still gets the modern dialog.
*/
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
const WM_CLOSE = 0x10
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
const SLOT_RELEASE = 2
const SLOT_SHOW = 3
const SLOT_SET_OPTIONS = 9
const SLOT_SET_TITLE = 17
const SLOT_GET_RESULT = 20
/** IShellItem vtable slot for `GetDisplayName`. */
const SLOT_GET_DISPLAY_NAME = 5
/**
* Encode a canonical GUID string as its 16 little-endian bytes.
* @param text - the `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` form.
* @returns the in-memory GUID bytes CoCreateInstance expects.
*/
function guidBytes(text: string): Buffer {
const match = /^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i.exec(text) as RegExpExecArray
const bytes = Buffer.alloc(16)
bytes.writeUInt32LE(parseInt(match[1] as string, 16), 0)
bytes.writeUInt16LE(parseInt(match[2] as string, 16), 4)
bytes.writeUInt16LE(parseInt(match[3] as string, 16), 6)
Buffer.from((match[4] as string) + (match[5] as string), 'hex').copy(bytes, 8)
return bytes
}
const CLSID_FILE_OPEN_DIALOG = guidBytes('dc1c5a9c-e88a-4dde-a5a1-60f82a20aef7')
const IID_IFILE_OPEN_DIALOG = guidBytes('d57c7288-d4ad-4768-be02-9d969532d960')
/**
* Load koffi and expose the dialog bindings for this thread.
* @returns the bindings {@link runFolderDialog} sequences against.
*/
export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
const koffi = (await import('koffi')).default as unknown as Koffi
const ole32 = koffi.load('ole32.dll')
const user32 = koffi.load('user32.dll')
const kernel32 = koffi.load('kernel32.dll')
// Vtable slots and out-pointers are pointer-width offsets: 8 on x64/arm64,
// 4 on ia32 — koffi reports the running process's width.
const pointerSize = koffi.sizeof('void *')
const coInitializeEx = ole32.func('__stdcall', 'CoInitializeEx', 'int32', ['void *', 'uint32'])
const coUninitialize = ole32.func('__stdcall', 'CoUninitialize', 'void', [])
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
const protoSetTitle = koffi.proto('int32 __stdcall DshDialogSetTitle(void *self, str16 title)')
const protoGetResult = koffi.proto('int32 __stdcall DshDialogGetResult(void *self, _Out_ void **item)')
const protoGetDisplayName = koffi.proto('int32 __stdcall DshItemGetDisplayName(void *self, int32 form, _Out_ void **name)')
const protoRelease = koffi.proto('uint32 __stdcall DshComRelease(void *self)')
/** Bind vtable slot `slot` of COM object `self` to a caller through `proto`. */
const method = (self: unknown, slot: number, proto: unknown): (...args: unknown[]) => number => {
const vtable = koffi.decode(self, 'void *')
const fn = koffi.decode(vtable, slot * pointerSize, 'void *')
return (...args: unknown[]) => koffi.call(fn, proto, self, ...args) as number
}
return {
setThreadDpiAwareness: () => {
let setContext: KoffiFunction
try {
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
} catch {
// Symbol absent (pre-1607 Windows): no per-thread DPI control exists.
// Proceed anyway — the cost is a blurry dialog above 100 % scaling on
// museum hosts, and the modern picker still beats dropping to the
// legacy 5.1 tree over a cosmetic concern.
return
}
for (const context of DPI_AWARENESS_CONTEXTS) {
if (setContext(context) !== null) return
}
// Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol
// exists); if a host ever refuses everything, the dialog still works —
// just without a DPI opt-in.
},
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
coUninitialize: () => {
coUninitialize()
},
currentThreadId: () => getCurrentThreadId() as number,
createFolderDialog: (): Win32FolderDialog => {
const out = Buffer.alloc(pointerSize)
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
if (created < 0) throw new Error(`CoCreateInstance(FileOpenDialog) failed: HRESULT 0x${(created >>> 0).toString(16)}`)
const dialog = koffi.decode(out, 'void *')
return {
setOptions: options => method(dialog, SLOT_SET_OPTIONS, protoSetOptions)(options),
setTitle: title => method(dialog, SLOT_SET_TITLE, protoSetTitle)(title),
show: () => method(dialog, SLOT_SHOW, protoShow)(null),
resultPath: () => {
const itemOut: unknown[] = [null]
const gotItem = method(dialog, SLOT_GET_RESULT, protoGetResult)(itemOut)
if (gotItem < 0) return { hr: gotItem }
const item = itemOut[0]
try {
const nameOut: unknown[] = [null]
const gotName = method(item, SLOT_GET_DISPLAY_NAME, protoGetDisplayName)(SIGDN_FILESYSPATH, nameOut)
if (gotName < 0) return { hr: gotName }
const path = readUtf16(koffi, nameOut[0])
coTaskMemFree(nameOut[0])
return { hr: gotName, path }
} finally {
method(item, SLOT_RELEASE, protoRelease)()
}
},
release: () => {
method(dialog, SLOT_RELEASE, protoRelease)()
},
}
},
}
}
/**
* Post `WM_CLOSE` to every window of a native thread — the driver's abort
* lever against the worker blocked inside `Show`, after which `Show` returns
* `HRESULT_CANCELLED` and the worker unwinds normally.
* @param threadId - the dialog thread's native id (from the `showing` notice).
*/
export async function closeThreadWindows(threadId: number): Promise<void> {
const koffi = (await import('koffi')).default as unknown as Koffi
const user32 = koffi.load('user32.dll')
const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr'])
const postMessageW = user32.func('__stdcall', 'PostMessageW', 'int', ['void *', 'uint32', 'uintptr', 'intptr'])
const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)')
const callback = koffi.register((hwnd: unknown) => {
postMessageW(hwnd, WM_CLOSE, 0, 0)
return 1
}, koffi.pointer(protoEnumProc))
try {
enumThreadWindows(threadId, callback, 0)
} finally {
koffi.unregister(callback)
}
}

View File

@@ -0,0 +1,33 @@
/**
* Real-process half of the Win32 dialog driver: spawn the dialog child
* process (source or built plane) and close a dialog thread's windows. The
* module itself loads everywhere (the import chain from native-picker.ts is
* static); what stays win32-only is koffi, imported dynamically inside the
* bindings' functions. The driver's logic is tested against fakes of this
* surface instead.
*/
import { spawn, type StdioOptions } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
/**
* Spawn the dialog child process. Built consumers launch the bundled CJS
* entry next to this module under plain node; unbuilt (source) consumers
* bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
* the child's first window, so Windows activates it without a foreground
* call.
* @param data - the child payload (dialog title).
* @returns the spawned child process.
*/
export function spawnDialogWorker(data: Win32DialogWorkerData): ReturnType<typeof spawn> {
const env = { ...process.env, DSH_DIALOG_TITLE: data.title }
const stdio: StdioOptions = ['ignore', 'inherit', 'inherit', 'ipc']
/* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/) */
if (!import.meta.url.endsWith('.ts')) {
return spawn(process.execPath, [fileURLToPath(new URL('./worker.cjs', import.meta.url))], { env, stdio, windowsHide: true })
}
return spawn(process.execPath, ['--import', import.meta.resolve('tsx/esm'), fileURLToPath(new URL('./win32-dialog-worker.ts', import.meta.url))], { env, stdio, windowsHide: true })
}
export { closeThreadWindows } from './win32-dialog-bindings.ts'

View File

@@ -0,0 +1,132 @@
/**
* Pure sequencing of the Win32 `IFileOpenDialog` folder-picker COM
* conversation over an injectable bindings seam, so every outcome path
* (selection, cancellation, HRESULT failure, cleanup ordering) is testable on
* any platform. The koffi-backed bindings live in
* `win32-dialog-bindings.ts`, which only a real win32 process ever loads.
*/
/** `HRESULT_FROM_WIN32(ERROR_CANCELLED)`: the user dismissed the dialog. */
export const HRESULT_CANCELLED = 0x800704c7 | 0
/** `FOS_PICKFOLDERS`: the dialog selects directories, not files. */
export const FOS_PICKFOLDERS = 0x20
/** `FOS_FORCEFILESYSTEM`: only results with a filesystem path can be chosen. */
export const FOS_FORCEFILESYSTEM = 0x40
/** `FOS_NOCHANGEDIR`: never mutate the process working directory. */
export const FOS_NOCHANGEDIR = 0x8
/** One created folder dialog: the vtable calls the sequencing needs. */
export interface Win32FolderDialog {
/**
* `IFileDialog::SetOptions`.
* @param options - the `FOS_*` flag union to apply.
* @returns the call's HRESULT.
*/
setOptions(options: number): number
/**
* `IFileDialog::SetTitle`.
* @param title - the dialog title text.
* @returns the call's HRESULT.
*/
setTitle(title: string): number
/**
* `IModalWindow::Show` with no owner window; blocks the calling thread
* until the user selects or dismisses.
* @returns the call's HRESULT (`HRESULT_CANCELLED` on dismissal).
*/
show(): number
/**
* `IFileDialog::GetResult` + `IShellItem::GetDisplayName(SIGDN_FILESYSPATH)`,
* releasing the shell item and freeing the COM string.
* @returns the call chain's HRESULT and, on success, the selected path.
*/
resultPath(): { hr: number; path?: string }
/** Release the dialog's COM reference. */
release(): void
}
/** The thread-level native surface the dialog sequencing runs against. */
export interface Win32DialogBindings {
/**
* Opt the calling thread into the best supported DPI awareness
* (per-monitor-v2, then per-monitor, then system-aware), checking each
* call's result. Best-effort on purpose: a host accepting none of them
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
* blurry above 100 % scaling — because a cosmetic degradation must not
* cost the tier.
*/
setThreadDpiAwareness(): void
/**
* `CoInitializeEx(COINIT_APARTMENTTHREADED)` on the calling thread.
* @returns the call's HRESULT (`S_FALSE` re-entry is still a success).
*/
coInitializeSta(): number
/**
* `CoUninitialize` on the calling thread — COM requires one pairing call
* for every successful (including `S_FALSE`) `CoInitializeEx`, even on a
* thread that exits right after the conversation.
*/
coUninitialize(): void
/**
* `CoCreateInstance(CLSID_FileOpenDialog)`.
* @returns the created dialog surface; throws when creation fails.
*/
createFolderDialog(): Win32FolderDialog
/**
* `GetCurrentThreadId` — the native id a driver needs to close this
* thread's windows from outside.
* @returns the calling thread's native id.
*/
currentThreadId(): number
}
/**
* Throw when an HRESULT signals failure.
* @param hr - the HRESULT to check.
* @param what - the failing call's name for the error message.
* @returns the (successful) HRESULT unchanged.
*/
function check(hr: number, what: string): number {
if (hr < 0) throw new Error(`${what} failed: HRESULT 0x${(hr >>> 0).toString(16)}`)
return hr
}
/**
* Run one modal folder-picker conversation on the calling thread: DPI opt-in,
* STA init, dialog creation, `Show`, and result extraction, releasing the
* dialog on every path.
* @param bindings - the native surface (koffi-backed in production, fakes in tests).
* @param title - the dialog title text.
* @param onShowing - called with the native thread id immediately before the
* blocking `Show`, so a driver on another thread can close the dialog.
* @returns the selected filesystem path, or null when the user cancels.
*/
export function runFolderDialog(
bindings: Win32DialogBindings,
title: string,
onShowing: (threadId: number) => void,
): string | null {
bindings.setThreadDpiAwareness()
check(bindings.coInitializeSta(), 'CoInitializeEx')
// From here the apartment is initialized (S_OK or S_FALSE) and must be
// uninitialized exactly once on every path.
try {
const dialog = bindings.createFolderDialog()
try {
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
check(dialog.setTitle(title), 'SetTitle')
onShowing(bindings.currentThreadId())
const shown = dialog.show()
if (shown === HRESULT_CANCELLED) return null
check(shown, 'Show')
const result = dialog.resultPath()
check(result.hr, 'GetResult')
return result.path as string
} finally {
dialog.release()
}
} finally {
bindings.coUninitialize()
}
}

View File

@@ -0,0 +1,52 @@
/**
* Child-process entry for the Win32 folder dialog: blocks THIS process
* inside the modal `Show` so the host event loop stays live, reporting over
* the IPC channel. Spawned as a child process (not a worker thread) so the
* dialog is the process's first window and Windows activates it without a
* manual foreground call. Protocol: `{kind:'showing',threadId}` right
* before the blocking call (the driver's abort lever needs the native
* thread id), then exactly one of `{kind:'done',path}` or
* `{kind:'error',message}`.
*/
import { loadWin32DialogBindings } from './win32-dialog-bindings.ts'
import { runFolderDialog } from './win32-dialog-logic.ts'
/** The driver-to-child payload: the dialog title (passed via env). */
export interface Win32DialogWorkerData { title: string }
/** One notice or outcome posted back to the driver. */
export type Win32DialogWorkerMessage =
| { kind: 'showing'; threadId: number }
| { kind: 'done'; path: string | null }
| { kind: 'error'; message: string }
const title = process.env.DSH_DIALOG_TITLE ?? ''
if (title === '') throw new Error('win32-dialog-worker: DSH_DIALOG_TITLE is required')
if (process.send === undefined) throw new Error('win32-dialog-worker must run as a child process with an IPC channel')
// node's internal `send` reads `this.connected`, so bind the receiver.
const send = process.send.bind(process)
const post = (message: Win32DialogWorkerMessage): void => {
// Flush before closing the channel; the process exits when the loop drains.
/* v8 ignore next 3 -- disconnect needs a live IPC channel the unit lane must not sever (built-worker.e2e.ts owns the real close path). */
send(message, () => { if (process.connected) process.disconnect() })
}
// A settled driver (or a dead parent) must not orphan a dialog still on screen.
/* v8 ignore next 3 -- the handler exits(0), which would kill the unit lane; built-worker.e2e.ts owns the real disconnect lifecycle. */
process.on('disconnect', () => process.exit(0))
// No top-level await: the built worker ships as CJS, which cannot carry TLA.
void (async () => {
try {
const bindings = await loadWin32DialogBindings()
const path = runFolderDialog(bindings, title, (threadId) => {
post({ kind: 'showing', threadId } satisfies Win32DialogWorkerMessage)
})
post({ kind: 'done', path } satisfies Win32DialogWorkerMessage)
} catch (error: unknown) {
const message = error instanceof Error ? (error.stack ?? error.message) : String(error)
post({ kind: 'error', message } satisfies Win32DialogWorkerMessage)
}
})()

View File

@@ -0,0 +1,159 @@
/**
* Main-thread driver for the Win32 folder dialog: spawns the dialog child
* process (which blocks inside the modal `Show`), maps its message protocol
* onto a promise, and services aborts by posting `WM_CLOSE` to the dialog
* thread's windows until the child reports back. The real process/window
* surface is injectable so every driver path is testable on any platform.
*/
import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts'
import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts'
/** The child-process surface the driver drives (satisfied by `node:child_process`). */
export interface Win32DialogWorkerLike {
/**
* Subscribe to a child-process event.
* @param event - `message`, `error`, or `exit`.
* @param listener - the event consumer.
*/
on(event: 'message', listener: (message: Win32DialogWorkerMessage) => void): unknown
on(event: 'error', listener: (error: Error) => void): unknown
on(event: 'exit', listener: (code: number) => void): unknown
/**
* Force-stop the child; the abort path's last resort when `WM_CLOSE`
* never lands (e.g. the dialog window was never created).
* @returns whether a kill signal was delivered.
*/
kill(): boolean
/**
* Release the event-loop reference. Called once the pick settles so a
* child stuck in the native modal call never blocks process exit.
*/
unref?(): void
}
/** Injectable process surface for deterministic driver tests. */
export interface Win32DialogInternals {
/** Replaces the real child spawn (`win32-dialog-host.ts`). */
spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike
/** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */
closeThreadWindows?: (threadId: number) => Promise<void>
/** Abort-service cadence override so tests never wait wall-clock time. */
closeRetryMs?: number
}
/** The dialog title every host shows. */
export const DIALOG_TITLE = 'Select Workspace Directory'
/** `WM_CLOSE` re-post cadence while an abort waits for the worker to unwind. */
const CLOSE_RETRY_MS = 150
/** Abort-service attempts before force-terminating the worker. */
const CLOSE_MAX_ATTEMPTS = 20
/** Fail loudly if the closed worker-to-driver union gains an unhandled member. */
/* v8 ignore start -- closed-union backstop; unreachable without a TypeScript contract violation */
function assertNever(value: never): never {
throw new TypeError(`unknown win32 dialog worker message kind: ${String(value)}`)
}
/* v8 ignore stop */
/**
* Open the modern Win32 folder picker off the event loop.
* @param signal - caller lifetime; abort closes the dialog and rejects.
* @param internals - worker/window seams for deterministic tests.
* @returns the selected path, or null when the user cancels.
*/
export async function pickWin32Directory(
signal: AbortSignal,
internals: Win32DialogInternals = {},
): Promise<string | null> {
if (signal.aborted) throw new Error('native directory picker aborted')
const spawnWorker = internals.spawnWorker ?? spawnDialogWorker
const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows
const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS
const worker: Win32DialogWorkerLike = spawnWorker({ title: DIALOG_TITLE })
let dialogThreadId: number | undefined
let closeTimer: NodeJS.Timeout | undefined
let settled = false
return await new Promise<string | null>((resolve, reject) => {
const settle = (outcome: () => void): void => {
if (settled) return
settled = true
if (closeTimer !== undefined) clearInterval(closeTimer)
signal.removeEventListener('abort', onAbort)
worker.unref?.()
outcome()
}
const postClose = (): void => {
// Before `showing` there is no window to close; the budget below still
// runs so a child that never reports cannot dangle the pick. A
// rejected close attempt (EnumThreadWindows/PostMessageW refusing) is
// discarded: the interval retries it and kill is the backstop.
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
}
// Sole caller: the once-registered abort listener, so no re-entry guard.
const serviceAbort = (): void => {
let attempts = 0
// The `showing` notice precedes the blocking `Show`, so the very first
// WM_CLOSE can race the window's creation; re-post until the child
// reports back, then force-kill as a last resort. The budget is
// unconditional — an abort before `showing` (child hung in koffi or
// COM init) still ends in kill instead of a dangling promise.
closeTimer = setInterval(() => {
attempts += 1
if (attempts > CLOSE_MAX_ATTEMPTS) {
settle(() => {
worker.kill()
reject(new Error('native directory picker aborted (dialog unresponsive; worker killed)'))
})
return
}
postClose()
}, closeRetryMs)
postClose()
}
const onAbort = (): void => {
serviceAbort()
}
signal.addEventListener('abort', onAbort, { once: true })
worker.on('message', (message: Win32DialogWorkerMessage) => {
switch (message.kind) {
case 'showing':
dialogThreadId = message.threadId
// An abort that raced ahead of this notice now has a window to hit.
if (signal.aborted) postClose()
return
case 'done':
settle(() => {
if (signal.aborted) reject(new Error('native directory picker aborted'))
else resolve(message.path)
})
return
case 'error':
settle(() => {
reject(new Error(`win32 folder dialog failed: ${message.message}`))
})
return
/* v8 ignore next 2 -- closed worker-owned union; a fourth kind becomes a compile error */
default:
assertNever(message)
}
})
worker.on('error', (error: Error) => {
settle(() => {
reject(error)
})
})
worker.on('exit', () => {
settle(() => {
reject(new Error('win32 folder dialog worker exited before reporting a result'))
})
})
})
}

View File

@@ -0,0 +1,34 @@
/**
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
* shape): plain `node` runs `lib/worker.cjs` and the bundle reaches its
* real koffi requires. POSIX hosts prove the load path end to end through
* the deterministic ole32 rejection; win32 skips (a real dialog would
* open), where the win32-only smoke in win32-dialog.spec.ts covers the
* source plane instead. Skips until a build produces the artifact.
*/
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url))
describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => {
it('loads under plain node and reports the native-surface failure', async () => {
const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => {
const child = spawn(process.execPath, [builtWorker], {
env: { ...process.env, DSH_DIALOG_TITLE: 'Built-artifact guard' },
stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
})
child.on('message', resolve)
child.on('error', reject)
child.on('exit', (code) => {
reject(new Error(`worker exited (${code}) before reporting`))
})
})
expect(message.kind).toBe('error')
expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i)
}, 30_000)
})

View File

@@ -1,3 +1,9 @@
/**
* Native picker tier selection and the execFile adapter: the Win32 dialog
* primary (failures surface as-is, no fallback tier), the abort rule, and
* the POSIX command tiers (osascript, Zenity → KDialog).
*/
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
@@ -23,6 +29,9 @@ function failure(code: string | number, stderr = ''): Error {
const signal = () => new AbortController().signal
/** A Win32 dialog that always fails — the no-fallback case. */
const noDialog = async (): Promise<string | null> => { throw new Error('dialog unavailable') }
describe('native directory picker', () => {
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,46 +55,79 @@ describe('native directory picker', () => {
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('uses the Windows STA folder dialog 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')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
expect.any(AbortSignal),
)
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
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')
it('uses the Win32 dialog and never spawns a command 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('surfaces the Win32 dialog failure with no fallback', async () => {
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(signal(), { platform: 'win32', run, pickWin32Dialog: noDialog }))
.rejects.toThrow('dialog unavailable')
expect(run).not.toHaveBeenCalled()
})
it('wires the real Win32 dialog as the default tier', async () => {
// A pre-aborted signal makes the DEFAULT dialog deterministic on every
// host: pickWin32Directory throws before spawning any worker or window.
const abort = new AbortController()
abort.abort()
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run }))
.rejects.toThrow('native directory picker aborted')
expect(run).not.toHaveBeenCalled()
})
it('does not fall back when the caller aborted the dialog', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>()
await expect(pickNativeDirectory(abort.signal, { platform: 'win32', run, pickWin32Dialog: noDialog })).rejects.toThrow('dialog unavailable')
expect(run).not.toHaveBeenCalled()
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
callback(null, '/home/test/project\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
await expect(pickNativeDirectory(signal(), { platform: 'linux' })).resolves.toBe('/home/test/project')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('powershell.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
expect(command).toBe('zenity')
expect(args).toEqual(expect.arrayContaining(['--file-selection', '--directory']))
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
// A non-cancellation command failure surfaces as-is with its cause and
// captured stdio attached; no tier masks or rewraps it.
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
callback(Object.assign(new Error('zenity failed'), { code: 7 }), 'partial output', 'failure details')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
message: 'powershell failed', cause: commandError, code: 7,
const surfaced = await pickNativeDirectory(signal(), { platform: 'linux' })
.then(() => { throw new Error('expected rejection') }, (error: unknown) => error as Error)
expect(surfaced).toMatchObject({
message: 'zenity failed', code: 7,
stdout: 'partial output', stderr: 'failure details',
})
expect((surfaced as { cause?: unknown }).cause).toBeInstanceOf(Error)
})
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('maps empty command output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBeNull()
})
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {

View File

@@ -0,0 +1,354 @@
/**
* The koffi-backed bindings against a mocked `koffi` module (the same
* technique as dsh-session-persistence-jsonl's win32 suite): a small in-memory
* COM world stands in for ole32/user32/kernel32, keeping the vtable dispatch,
* result extraction, memory hygiene, and the WM_CLOSE poster covered on every
* host. The worker entry is exercised the same way with a mocked process
* boundary (env title + `process.send`). Real-COM behavior is pinned by the
* win32-only smoke in win32-dialog.spec.ts.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts'
const E_FAIL = 0x80004005 | 0
const WM_CLOSE = 0x10
/**
* Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer
* sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against
* this width (the win32-ia32 bug class).
*/
const FAKE_POINTER_SIZE = 4
interface ComWorld {
coInitHr: number
coCreateHr: number
showHr: number
getResultHr: number
getDisplayNameHr: number
hasThreadDpi: boolean
/** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */
supportedDpiContexts: number[]
enumThrows: boolean
path: string
titles: string[]
options: number[]
dpiContexts: unknown[]
freed: unknown[]
released: string[]
posted: { hwnd: unknown; message: number }[]
registered: number
unregistered: number
uninitialized: number
}
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
return {
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false,
path: 'C:\\选中\\directory',
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
registered: 0, unregistered: 0, uninitialized: 0,
...overrides,
}
}
/** Sentinel pointer objects standing in for native addresses. */
interface FakePtr { kind: string; [key: string]: unknown }
function installFakeKoffi(world: ComWorld): void {
const dialogPtr: FakePtr = { kind: 'dialog' }
const itemPtr: FakePtr = { kind: 'item' }
const namePtr: FakePtr = { kind: 'name', text: world.path }
const outBuffers = new Map<unknown, FakePtr>()
const dispatch = (self: FakePtr, slot: number, args: unknown[]): number => {
if (self.kind === 'dialog') {
switch (slot) {
case 9: world.options.push(args[0] as number); return 0
case 17: world.titles.push(args[0] as string); return 0
case 3: return world.showHr
case 20: {
if (world.getResultHr < 0) return world.getResultHr
;(args[0] as unknown[])[0] = itemPtr
return 0
}
case 2: world.released.push('dialog'); return 0
default: throw new Error(`unexpected dialog slot ${slot}`)
}
}
switch (slot) {
case 5: {
if (world.getDisplayNameHr < 0) return world.getDisplayNameHr
;(args[1] as unknown[])[0] = namePtr
return 0
}
case 2: world.released.push('item'); return 0
default: throw new Error(`unexpected item slot ${slot}`)
}
}
vi.doMock('koffi', () => ({
default: {
load: (dll: string) => ({
func: (_convention: string, name: string, _result: string, _args: string[]) => {
switch (name) {
case 'CoInitializeEx': return () => world.coInitHr
case 'CoUninitialize': return () => { world.uninitialized += 1 }
case 'CoCreateInstance': return (...args: unknown[]) => {
if (world.coCreateHr < 0) return world.coCreateHr
// The out-pointer must be allocated at the fake's pointer width.
if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) {
throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`)
}
outBuffers.set(args[4], dialogPtr)
return 0
}
case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
case 'GetCurrentThreadId': return () => 31337
case 'SetThreadDpiAwarenessContext': {
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
return (context: unknown) => {
world.dpiContexts.push(context)
return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null
}
}
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
callback.fn({ kind: 'hwnd', n: 1 }, lparam)
callback.fn({ kind: 'hwnd', n: 2 }, lparam)
return 1
}
case 'PostMessageW': return (hwnd: unknown, message: number) => { world.posted.push({ hwnd, message }); return 1 }
default: throw new Error(`unexpected native import ${dll}/${name}`)
}
},
}),
proto: (declaration: string) => ({ declaration }),
pointer: (type: unknown) => type,
sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE },
view: (value: unknown, len: number): ArrayBuffer => {
const bytes = Buffer.alloc(len)
bytes.write((value as FakePtr).text as string, 'utf16le')
return bytes.buffer
},
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
unregister: () => { world.unregistered += 1 },
decode: (value: unknown, offsetOrType: unknown): unknown => {
if (offsetOrType === 'str16') return (value as FakePtr).text
if (typeof offsetOrType === 'number') {
// Vtable slot read: offsets must be multiples of the fake width.
if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`)
const owner = (value as { owner: FakePtr }).owner
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) }
}
// decode(x, 'void *'): out-buffer read or vtable read.
if (outBuffers.has(value)) return outBuffers.get(value)
return { owner: value as FakePtr }
},
call: (fn: { call: (args: unknown[]) => number }, _proto: unknown, _self: unknown, ...args: unknown[]) => fn.call(args),
},
}))
}
async function loadBindingsModule(): Promise<typeof import('../src/win32-dialog-bindings.ts')> {
return await import('../src/win32-dialog-bindings.ts')
}
afterEach(() => {
vi.doUnmock('koffi')
vi.doUnmock('node:worker_threads')
vi.doUnmock('../src/win32-dialog-bindings.ts')
vi.resetModules()
})
describe('loadWin32DialogBindings over the fake COM world', () => {
it('drives the full selection conversation with memory hygiene', async () => {
const world = comWorld()
installFakeKoffi(world)
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
const showing = vi.fn()
expect(runFolderDialog(bindings, '选择工作区目录', showing)).toBe('C:\\选中\\directory')
expect(world.dpiContexts).toEqual([-4])
expect(world.titles).toEqual(['选择工作区目录'])
expect(world.options).toHaveLength(1)
expect(showing).toHaveBeenCalledWith(31337)
expect(world.freed).toHaveLength(1)
expect(world.released).toEqual(['item', 'dialog'])
expect(world.uninitialized).toBe(1)
})
it('maps dismissal and the S_FALSE CoInitializeEx', async () => {
const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 })
installFakeKoffi(world)
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(world.released).toEqual(['dialog'])
expect(world.uninitialized).toBe(1)
})
it('cascades DPI contexts to the first the host accepts', async () => {
const world = comWorld({ supportedDpiContexts: [-3] })
installFakeKoffi(world)
const bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(world.dpiContexts).toEqual([-4, -3])
})
it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => {
// DPI is a cosmetic best-effort: the modern dialog still opens.
const rejecting = comWorld({ supportedDpiContexts: [] })
installFakeKoffi(rejecting)
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(rejecting.dpiContexts).toEqual([-4, -3, -2])
vi.doUnmock('koffi')
vi.resetModules()
const preThreadDpi = comWorld({ hasThreadDpi: false })
installFakeKoffi(preThreadDpi)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
expect(preThreadDpi.dpiContexts).toEqual([])
})
it('surfaces creation and extraction failures as HRESULT errors', async () => {
const creationWorld = comWorld({ coCreateHr: E_FAIL })
installFakeKoffi(creationWorld)
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => bindings.createFolderDialog()).toThrow('CoCreateInstance(FileOpenDialog) failed: HRESULT 0x80004005')
vi.doUnmock('koffi')
vi.resetModules()
const resultWorld = comWorld({ getResultHr: E_FAIL })
installFakeKoffi(resultWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
expect(resultWorld.released).toEqual(['dialog'])
vi.doUnmock('koffi')
vi.resetModules()
const nameWorld = comWorld({ getDisplayNameHr: E_FAIL })
installFakeKoffi(nameWorld)
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
expect(() => runFolderDialog(bindings, 'Pick', vi.fn())).toThrow('GetResult failed')
// The shell item is released even when its display name cannot be read.
expect(nameWorld.released).toEqual(['item', 'dialog'])
expect(nameWorld.freed).toHaveLength(0)
})
})
describe('closeThreadWindows over the fake COM world', () => {
it('posts WM_CLOSE to every window of the thread and unregisters the callback', async () => {
const world = comWorld()
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await closeThreadWindows(777)
expect(world.posted).toEqual([
{ hwnd: { kind: 'hwnd', n: 1 }, message: WM_CLOSE },
{ hwnd: { kind: 'hwnd', n: 2 }, message: WM_CLOSE },
])
expect(world.registered).toBe(1)
expect(world.unregistered).toBe(1)
})
it('unregisters the callback even when the enumeration itself throws', async () => {
const world = comWorld({ enumThrows: true })
installFakeKoffi(world)
const { closeThreadWindows } = await loadBindingsModule()
await expect(closeThreadWindows(777)).rejects.toThrow('EnumThreadWindows refused')
expect(world.unregistered).toBe(1)
})
})
describe('the worker entry over a mocked process boundary', () => {
const originalSend = process.send?.bind(process)
const originalTitle = process.env.DSH_DIALOG_TITLE
const installBoundary = (): { posted: { kind: string; message?: string }[] } => {
const posted: { kind: string; message?: string }[] = []
process.env.DSH_DIALOG_TITLE = 'Pick'
// Never invoke the post callback: it runs the worker's disconnect(), and
// this process is IPC-connected under the forks pool — severing vitest's
// own channel would kill the test worker. The real close lifecycle
// belongs to built-worker.e2e.ts.
;(process as { send?: unknown }).send = (message: { kind: string }) => {
posted.push(message)
return true
}
return { posted }
}
afterEach(() => {
delete (process as { send?: unknown }).send
if (originalSend !== undefined) (process as { send?: unknown }).send = originalSend
if (originalTitle === undefined) delete process.env.DSH_DIALOG_TITLE
else process.env.DSH_DIALOG_TITLE = originalTitle
vi.doUnmock('../src/win32-dialog-bindings.ts')
vi.resetModules()
})
it('posts showing then done for a completed conversation', async () => {
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => ({
setThreadDpiAwareness: () => undefined,
coInitializeSta: () => 0,
coUninitialize: () => undefined,
currentThreadId: () => 11,
createFolderDialog: () => ({
setOptions: () => 0,
setTitle: () => 0,
show: () => 0,
resultPath: () => ({ hr: 0, path: 'C:\\from-worker' }),
release: () => undefined,
}),
}),
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toEqual([
{ kind: 'showing', threadId: 11 },
{ kind: 'done', path: 'C:\\from-worker' },
])
})
it('posts the failure message when the native surface cannot load', async () => {
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw new Error('no ole32 here') },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted).toHaveLength(1)
expect(posted[0]?.kind).toBe('error')
expect(posted[0]?.message).toContain('no ole32 here')
})
it('stringifies stackless and non-Error failures', async () => {
const stackless = new Error('bare message')
delete stackless.stack
for (const [thrown, expected] of [[stackless, 'bare message'], ['plain refusal', 'plain refusal']] as const) {
vi.resetModules()
const { posted } = installBoundary()
vi.doMock('../src/win32-dialog-bindings.ts', () => ({
loadWin32DialogBindings: async () => { throw thrown },
}))
await import('../src/win32-dialog-worker.ts')
expect(posted[0]?.message).toBe(expected)
}
})
it('refuses to run without the dialog title', async () => {
delete process.env.DSH_DIALOG_TITLE
;(process as { send?: unknown }).send = () => true
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('DSH_DIALOG_TITLE is required')
})
it('refuses to run outside a child process', async () => {
process.env.DSH_DIALOG_TITLE = 'Pick'
delete (process as { send?: unknown }).send
await expect(import('../src/win32-dialog-worker.ts')).rejects.toThrow('must run as a child process')
})
})

View File

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

View File

@@ -0,0 +1,163 @@
/**
* Driver tests: the child-process message protocol mapped onto the promise,
* the WM_CLOSE abort service (including the show-race retry and the kill
* last resort) against fakes, plus the real spawn plumbing — POSIX hosts
* prove the default path rejects cleanly (koffi cannot load ole32 there),
* and win32 hosts briefly open and auto-abort a real dialog.
*/
import { EventEmitter } from 'node:events'
import { describe, expect, it, vi } from 'vitest'
import { pickWin32Directory, type Win32DialogInternals, type Win32DialogWorkerLike } from '../src/win32-dialog.ts'
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
class FakeWorker extends EventEmitter implements Win32DialogWorkerLike {
kill = vi.fn(() => true)
post(message: Win32DialogWorkerMessage): void {
this.emit('message', message)
}
}
interface Harness {
worker: FakeWorker
internals: Win32DialogInternals
close: ReturnType<typeof vi.fn>
}
function harness(overrides: Partial<Win32DialogInternals> = {}): Harness {
const worker = new FakeWorker()
const close = vi.fn(async () => undefined)
return {
worker,
close,
internals: {
spawnWorker: () => worker,
closeThreadWindows: close,
closeRetryMs: 1,
...overrides,
},
}
}
const live = (): AbortSignal => new AbortController().signal
describe('pickWin32Directory', () => {
it('resolves the selected path and the cancellation null', async () => {
const first = harness()
const picked = pickWin32Directory(live(), first.internals)
first.worker.post({ kind: 'showing', threadId: 7 })
first.worker.post({ kind: 'done', path: 'C:\\picked' })
await expect(picked).resolves.toBe('C:\\picked')
expect(first.close).not.toHaveBeenCalled()
const second = harness()
const cancelled = pickWin32Directory(live(), second.internals)
second.worker.post({ kind: 'done', path: null })
await expect(cancelled).resolves.toBeNull()
})
it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => {
const reported = harness()
const failing = pickWin32Directory(live(), reported.internals)
reported.worker.post({ kind: 'error', message: 'CoCreateInstance failed' })
await expect(failing).rejects.toThrow('win32 folder dialog failed: CoCreateInstance failed')
const crashed = harness()
const crashing = pickWin32Directory(live(), crashed.internals)
crashed.worker.emit('error', new Error('worker blew up'))
await expect(crashing).rejects.toThrow('worker blew up')
const silent = harness()
const exiting = pickWin32Directory(live(), silent.internals)
silent.worker.emit('exit', 0)
await expect(exiting).rejects.toThrow('exited before reporting a result')
})
it('settles once: a late exit after the result is inert', async () => {
const { worker, internals } = harness()
const picked = pickWin32Directory(live(), internals)
worker.post({ kind: 'done', path: 'C:\\once' })
worker.emit('exit', 0)
await expect(picked).resolves.toBe('C:\\once')
})
it('throws immediately on an already-aborted signal without spawning', async () => {
const spawnWorker = vi.fn()
const controller = new AbortController()
controller.abort()
await expect(pickWin32Directory(controller.signal, { spawnWorker, closeThreadWindows: async () => undefined }))
.rejects.toThrow('native directory picker aborted')
expect(spawnWorker).not.toHaveBeenCalled()
})
it('services an abort by closing the dialog thread windows until the worker reports', async () => {
const { worker, internals, close } = harness()
const controller = new AbortController()
// Attach the expectation BEFORE driving the race: on a fast host the
// close budget can exhaust (and reject) between waitFor ticks, and a
// rejection with no listener yet would count as unhandled.
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
worker.post({ kind: 'showing', threadId: 99 })
controller.abort()
await vi.waitFor(() => {
expect(close).toHaveBeenCalledWith(99)
})
worker.post({ kind: 'done', path: null })
await picked
})
it('starts the close service on the showing notice when the abort came first', async () => {
const closeFailures = vi.fn(async () => { throw new Error('window not there yet') })
const { worker, internals } = harness({ closeThreadWindows: closeFailures })
const controller = new AbortController()
// Attached before the race for the same unhandled-rejection reason above.
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('native directory picker aborted')
controller.abort()
expect(closeFailures).not.toHaveBeenCalled()
worker.post({ kind: 'showing', threadId: 12 })
await vi.waitFor(() => {
expect(closeFailures.mock.calls.length).toBeGreaterThan(1)
})
worker.post({ kind: 'done', path: null })
await picked
})
it('kills a worker that never reports showing after an abort', async () => {
// The budget runs without a thread id (nothing to WM_CLOSE yet), so a
// worker hung before `showing` cannot dangle the pick.
const { worker, internals, close } = harness()
const controller = new AbortController()
const picked = expect(pickWin32Directory(controller.signal, internals)).rejects.toThrow('dialog unresponsive; worker killed')
controller.abort()
await picked
expect(worker.kill).toHaveBeenCalledOnce()
expect(close).not.toHaveBeenCalled()
})
it('kills an unresponsive worker after the close budget', async () => {
const { worker, internals, close } = harness()
const controller = new AbortController()
const picked = pickWin32Directory(controller.signal, internals)
worker.post({ kind: 'showing', threadId: 5 })
controller.abort()
await expect(picked).rejects.toThrow('dialog unresponsive; worker killed')
expect(worker.kill).toHaveBeenCalledOnce()
expect(close.mock.calls.length).toBeGreaterThan(10)
})
// POSIX hosts exercise the REAL default plumbing end to end: the tsx-bootstrapped
// worker spawns, loads koffi, fails to load ole32.dll, and reports the error.
it.skipIf(process.platform === 'win32')('rejects through the real worker where the Win32 surface is unavailable', async () => {
await expect(pickWin32Directory(live())).rejects.toThrow('win32 folder dialog failed')
}, 30_000)
// win32 hosts run the true COM smoke instead: a real dialog opens briefly
// and the abort service closes it (the same lever a disconnecting client pulls).
it.skipIf(process.platform !== 'win32')('opens and abort-closes a real dialog', async () => {
const controller = new AbortController()
setTimeout(() => {
controller.abort()
}, 400)
await expect(pickWin32Directory(controller.signal)).rejects.toThrow('native directory picker aborted')
}, 30_000)
})

View File

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

View File

@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

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/webserver/README.md
README.md: c3c7b222683bc7731a6c21f2fffd325225099bab
README.zh.md: 99c0560eb74dc8076772ba1deef3034000f5f0db
README.md: 196f350d87c5322cd3e9cda6e40587d35acd08c4
README.zh.md: 0ae0470eab0aae2f6b539404621c611d95827977

View File

@@ -2,17 +2,17 @@
English | [中文](README.zh.md)
Plain HTTP route-registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer` `register(route)` adds a named `exact`/`prefix` route (duplicate `(kind, path)` throws: route patterns are a composition-level contract, so a collision is a misconfiguration; the returned disposer removes the route), `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). The match order is fixed exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, non-GET/HEAD is 405. Registration order carries no request-facing semantics.
Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port, distIndex}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `tapIndex(transform)` adds an index.html transform applied in registration order, `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the static dist fallback with the locked semantics: traversal outside the dist root is 403, any miss falls back to `index.html` with HTTP 200 (SPA routing), unknown extensions ship as octet-stream, and non-GET/HEAD is 405. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
The package knows no harness concepts: the `/api` bridge is the connection plugin's route, plugin bundles and the HMR event stream are the modules/hmr plugins' routes. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
The package knows no harness concepts: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, while plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure); `distIndex` is an assembly fact the composing app resolves and injects, never self-resolved (dist location is workspace knowledge of the app). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. Disposal pairs `close()` with `closeAllConnections()` because held-open responses (SSE) never end on their own.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
In development, the client-plugin registry synchronously captures each built bundle's stat baseline before it returns, then polls those baselines and re-hashes changed content. Each rescan stages its candidate table, graph, and watch map before publishing them, so a baseline failure preserves the prior graph. An immediate rebuild therefore cannot disappear into an asynchronously established watch baseline; a rename window marks the path dirty, retains the last successful baseline, and forces a re-hash when the bundle reappears even with identical metadata.
## Model Experience
None, as the package is a pure HTTP carrier between the browser and the routes other plugins register; nothing here reaches a model request.
None, as the package is a Web carrier between the browser and the HTTP/upgrade routes other plugins register; nothing here reaches a model request.
#### KV Cache effect

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
朴素的 HTTP 路由注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` 路由;重复的 `(kind, path)` 会抛错,因为路由模式是组合层契约,冲突即配置错误;返回的 disposer 会移除该路由`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。匹配顺序固定不变:先在整张表中匹配精确路由,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。注册顺序不承载任何面向请求的语义。
Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port, distIndex}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer``register(route)` 添加具名的 `exact``prefix` HTTP route`registerUpgrade(route)` 添加精确 pathname 的 upgrade route同一张表内的重复路径会抛错因为 route 模式是组合层契约,冲突即配置错误;两者返回的 disposer 会移除注册`tapIndex(transform)` 添加按注册顺序应用的 index.html 转换,`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后回退到静态 dist并遵循固定语义越出 dist 根目录的遍历返回 403任何未命中项都以 HTTP 200 回退到 `index.html`SPA 路由),未知扩展名按 octet-stream 提供GETHEAD 之外的方法返回 405。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
该包不了解任何 harness 概念:`/api` 桥接是 connection 插件的路由,插件 bundle 与 HMR热模块替换事件流则是 moduleshmr 插件的路由`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
该包不了解任何 harness 概念:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR热模块替换事件流则是 moduleshmr 插件的 route。upgrade handler 拥有协议握手与连接内容webserver 只交付原始 socket 与 request`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放);`distIndex` 是由组合应用解析并注入的组装事实,绝不会自行解析,因为 dist 位置属于应用的工作区知识。该服务器只服务 Web浏览器形态Electron 通过 `file://` 加载 dist并经 IPC 桥接承载 fetch而不使用本服务器。该包从不打印内容URL 行属于 shell。
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理请求时抛错例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。资源释放会 `close()``closeAllConnections()` 配对,因为一直保持打开的 SSEServer-Sent Events响应不会自行结束
监听失败EADDRINUSE……会从激活过程抛出以 bind 诊断使 Loader 组合 reject失败的候选 fiber 会被 dispose资源释放。处理 HTTP 请求时抛错(例如格式错误的百分号转义传入 `decodeURIComponent`,或客户端在请求体传输中途断开)时,服务器会响应 400若响应头已经发出则销毁 socket并记录 warning但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()``closeAllConnections()`,销毁所有受跟踪的升级 socket并仅在 HTTP server 与这些 socket 均已关闭后返回
在开发环境中,客户端插件注册表会在返回前同步捕获每个已构建 bundle 的 stat 基线,随后轮询这些基线,并在内容变化后重新计算哈希。每次重新扫描都会先暂存候选表、图和监听 map再统一发布因此基线失败会保留先前的图。这样即时重建不会消失在异步建立的监听基线中重命名窗口会把路径标记为脏保留最近一次成功基线并在 bundle 重新出现时强制重新计算哈希,即使其元数据完全相同也不例外。
## 模型体验
无。该包只是浏览器与其他插件所注册路由之间的纯 HTTP 载体,其中没有任何内容会进入模型请求。
无。该包只是浏览器与其他插件所注册 HTTPupgrade route 之间的 Web 载体,其中没有任何内容会进入模型请求。
#### KV 缓存影响

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-host-webserver",
"description": "Plain HTTP route-registration plugin: named-route registry (webServer service) + index transform taps + static dist fallback; knows no harness concepts",
"description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -21,9 +21,7 @@
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {

View File

@@ -1,10 +1,9 @@
/**
* @deepseek-ai/dsh-host-webserver — plain HTTP route-registration plugin: a
* node:http server plus the `httpServer` service (named-route registry + index
* transform taps + static dist fallback). Knows no harness concepts — every
* feature surface (API bridge, plugin bundles, SSE) is a route some other
* plugin registers. Web (browser) shape only — Electron loads dist over
* file:// and carries fetch over an IPC bridge, not this server. This package
* @deepseek-ai/dsh-host-webserver — Web route-registration plugin: a node:http
* server plus the `httpServer` service (HTTP and upgrade route registries,
* index transform taps, and static dist fallback). Knows no harness concepts;
* feature plugins own every registered protocol. Web shape only — Electron
* loads dist over file:// and carries fetch over an IPC bridge. This package
* never prints: the URL line belongs to the shell.
*/
@@ -12,6 +11,7 @@ import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse, Server } from 'node:http'
import { readFile } from 'node:fs/promises'
import type { AddressInfo } from 'node:net'
import type { Duplex } from 'node:stream'
import { dirname } from 'node:path'
import { Context, Service } from 'cordis'
import z from 'schemastery'
@@ -35,6 +35,14 @@ export interface WebRoute {
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration. */
export interface WebUpgradeRoute {
/** Absolute pathname, no trailing slash. */
path: string
/** Owns protocol negotiation and the upgraded socket after dispatch. */
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** Gateway config: listen address plus the static dist anchor (injected by the composing app, never self-resolved). */
export interface Config {
/** Listen host; the two supported values are loopback and all-interfaces. */
@@ -61,6 +69,8 @@ export class HttpServerService extends Service {
private readonly exact = new Map<string, WebRoute>()
private readonly prefixes = new Map<string, WebRoute>()
private readonly upgrades = new Map<string, WebUpgradeRoute>()
private readonly upgradedSockets = new Set<Duplex>()
private readonly indexTaps: ((html: string) => string)[] = []
private readonly distRoot: string
private readonly distIndex: string
@@ -98,6 +108,20 @@ export class HttpServerService extends Service {
return () => { table.delete(route.path) }
}
/**
* Register an exact-path HTTP upgrade route. Duplicate paths throw because
* one socket can have only one protocol owner.
* @param route - pathname and handler owning negotiation plus socket use.
* @returns the disposer removing the route.
*/
registerUpgrade(route: WebUpgradeRoute): () => void {
if (this.upgrades.has(route.path)) {
throw new Error(`webserver: duplicate upgrade route "${route.path}"`)
}
this.upgrades.set(route.path, route)
return () => { this.upgrades.delete(route.path) }
}
/**
* Register an index.html transform, applied to every index response in
* registration order.
@@ -147,6 +171,40 @@ export class HttpServerService extends Service {
res.end()
})
})
this.server.on('upgrade', (req, socket, head) => {
const onError = (error: Error): void => {
this.ctx.logger.warn(error)
socket.destroy()
}
socket.on('error', onError)
socket.once('close', () => {
socket.off('error', onError)
this.upgradedSockets.delete(socket)
})
let route: WebUpgradeRoute | undefined
try {
/* v8 ignore next -- node:http always sets url on server requests. */
route = this.upgrades.get(new URL(req.url ?? '/', 'http://x').pathname)
} catch (error) {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
return
}
if (route === undefined) {
socket.destroy()
return
}
this.upgradedSockets.add(socket)
try {
Promise.resolve(route.handler(req, socket, head)).catch((error: unknown) => {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
})
} catch (error) {
this.ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
socket.destroy()
}
})
await new Promise<void>((resolve, reject) => {
this.server.once('error', reject)
@@ -158,12 +216,19 @@ export class HttpServerService extends Service {
})
})
// close + closeAllConnections: held-open responses (SSE) never end on
// their own; without the force-close, close() would hang teardown.
this.ctx.effect(() => () => new Promise<void>((resolve) => {
this.server.close(() => { resolve() })
// Node does not include upgraded sockets in closeAllConnections(), so the
// service tracks and destroys them as part of the same ownership boundary.
this.ctx.effect(() => async () => {
const serverClosed = new Promise<void>((resolve) => {
this.server.close(() => { resolve() })
})
this.server.closeAllConnections()
}), 'httpServer.listen')
const upgradedClosed = [...this.upgradedSockets].map(socket => new Promise<void>((resolve) => {
socket.once('close', () => { resolve() })
socket.destroy()
}))
await Promise.all([serverClosed, ...upgradedClosed])
}, 'httpServer.listen')
}
/** Longest-prefix-wins over the prefix table after an exact-table miss. */

View File

@@ -15,7 +15,7 @@ export const name = 'host-webserver-invariant'
export const inject = ['invariants']
/**
* Owned relation: route registrations and their disposers must stay
* Owned relation: HTTP and upgrade route registrations and their disposers must stay
* symmetric — after the owning fiber of a registered route unloads, the
* route table must no longer answer for its path (a stale route would keep
* serving a disposed plugin's handler). Checked on every fiber teardown
@@ -26,7 +26,10 @@ export const inject = ['invariants']
const install: InvariantInstaller = (ctx, fail) => {
ctx.on('internal/plugin', () => {
const server = ctx.get('httpServer') as
| { register(route: { kind: 'exact'; path: string; handler: () => void }): () => void }
| {
register(route: { kind: 'exact'; path: string; handler: () => void }): () => void
registerUpgrade(route: { path: string; handler: () => void }): () => void
}
| undefined
if (server === undefined) return // no webserver row in this composition
// Register/dispose probe on a reserved path: if dispose leaves the route
@@ -37,8 +40,11 @@ const install: InvariantInstaller = (ctx, fail) => {
try {
server.register(probe)()
server.register(probe)()
const upgradeProbe = { path: '/__dsh_invariant_upgrade_probe__', handler: () => {} }
server.registerUpgrade(upgradeProbe)()
server.registerUpgrade(upgradeProbe)()
} catch {
fail('httpServer.register() disposer left the route registered — route table and fiber lifecycles diverged')
fail('httpServer route disposer left a route registered — route tables and fiber lifecycles diverged')
}
}, { global: true })
}

View File

@@ -7,6 +7,8 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir } from 'node:fs/promises'
import { once } from 'node:events'
import { connect } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
@@ -72,6 +74,24 @@ async function request(port: number, path: string, init?: RequestInit): Promise<
return { status: response.status, body: (await response.text()).slice(0, 80) }
}
/** Open one raw upgrade request and return after the handler writes its response. */
async function upgrade(port: number, path: string): Promise<ReturnType<typeof connect>> {
const socket = connect(port, '127.0.0.1')
await once(socket, 'connect')
const response = once(socket, 'data')
socket.write([
`GET ${path} HTTP/1.1`,
`Host: 127.0.0.1:${String(port)}`,
'Connection: Upgrade',
'Upgrade: dsh-test',
'',
'',
].join('\r\n'))
const [data] = await response as [Buffer]
expect(String(data)).toContain('101 Switching Protocols')
return socket
}
describe('real Loader composition', () => {
// Real-Loader composition resolves workspace packages through tsx at test
// time; first resolution after the host/client program split is slow enough
@@ -131,8 +151,51 @@ describe('real Loader composition', () => {
expect((await request(port, '/once')).body).toContain('shell') // back to the SPA fallback
expect(() => server.register({ kind: 'exact', path: '/once', handler: () => {} })).not.toThrow()
// Teardown: fiber dispose closes the socket and severs held connections.
// Upgrade routes match exact pathnames, reject duplicate ownership, and
// become registrable again after disposal. The accepted socket stays open
// so the teardown assertion also covers upgraded-connection ownership.
let upgradedServerClosed = false
const disposeUpgrade = server.registerUpgrade({
path: '/events',
handler: (_req, socket) => {
socket.once('close', () => { upgradedServerClosed = true })
socket.write('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: dsh-test\r\n\r\n')
},
})
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} }))
.toThrow(/duplicate upgrade route/)
const upgraded = await upgrade(port, '/events?stream=mux')
disposeUpgrade()
expect(() => server.registerUpgrade({ path: '/events', handler: () => {} })).not.toThrow()
// The webserver contains raw-socket errors even before an upgrade handler
// has installed its protocol implementation.
server.registerUpgrade({
path: '/upgrade-error',
handler: async (_req, socket) => {
await Promise.resolve()
socket.destroy(new Error('test upgrade transport failure'))
},
})
const failedUpgrade = connect(port, '127.0.0.1')
failedUpgrade.on('error', () => { /* The server-side reset is the fixture outcome. */ })
await once(failedUpgrade, 'connect')
const failedUpgradeClosed = once(failedUpgrade, 'close')
failedUpgrade.write([
'GET /upgrade-error HTTP/1.1',
`Host: 127.0.0.1:${String(port)}`,
'Connection: Upgrade',
'Upgrade: dsh-test',
'',
'',
].join('\r\n'))
await failedUpgradeClosed
expect(await request(port, '/probe')).toMatchObject({ status: 200, body: 'EXACT' })
// Teardown closes both ordinary and upgraded sockets before it resolves.
await loaded.fiber.dispose()
expect(upgradedServerClosed).toBe(true)
upgraded.destroy()
await expect(request(port, '/probe')).rejects.toThrow()
})