fix(picker): cascade thread DPI contexts and harden the round-three review points
- setThreadDpiAwareness checks SetThreadDpiAwarenessContext's return value
and cascades per-monitor-v2 -> per-monitor -> system-aware; DPI stays a
deliberate cosmetic best-effort - a host accepting none (or lacking the
API, pre-1607) still gets the modern dialog instead of a downgrade to the
legacy fallback chain over a cosmetic concern.
- The mocked-koffi world now uses a distinctive 4-byte pointer width and
rejects mis-sized out-buffers and mis-divided vtable offsets, so a
regression to hardcoded 8s fails the suite (the ia32 bug class).
- A keyless built-worker e2e guard loads lib/worker.cjs under plain
worker_threads on POSIX (the workflow-workerthread shape).
- The 'loaded lazily' module claims are reworded to attribute laziness to
the dynamic import('koffi') calls, and the discarded close-attempt
rejection is named at its catch.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/directory-picker-native/README.md
|
||||
README.md: 0d0fe8d3a049d6fbc47eee314f9782352651247d
|
||||
README.zh.md: 82f51976afe2e57699c1bd62d11142106b082e9b
|
||||
README.md: d48622dead56cce842e0bef0207079ff85b22588
|
||||
README.zh.md: 33cb11b4e747b2d98fc1bf179a52e095dcc9bc31
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with per-monitor-v2 DPI awareness, aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
The **native-OS-chooser backend** of the [directory-picker seam](../directory-picker/README.md): `NativeDirectoryPicker` registers `ctx.directoryPicker` with the `native` capability, whose `pick(signal)` opens one native chooser per call and resolves the chosen absolute path (`null` on cancel). Platform tools run without a shell: `osascript` on macOS and Zenity with a KDialog fallback on Linux; the caller's abort terminates the native process. Windows opens the modern `IFileOpenDialog` in-process — a koffi-driven COM conversation on a worker thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread — and falls back to a PowerShell-hosted dialog (`pwsh`, then Windows PowerShell 5.1, which every Windows ships) whenever that native surface is unavailable; a resolvable `pwsh` that cannot deliver the dialog (PowerShell 6 has no WinForms) falls through the same way. Only viable when the operator sits at the host's display — remote deployments compose [`-browse`](../directory-picker-browse/README.md) instead. The command boundary (`DirectoryPickerRunner`) and platform facts are injectable for deterministic tests. The shared no-shell subprocess runner lives in [`dsh-native-command`](../../util/native-command/README.md).
|
||||
|
||||
**Dual-face package**: the browser half (`./client`) registers a renderless flow occupant into [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes — each `open` request drives `host.pickDirectory` and reports the one outcome (picked path / cancel / failure) through the hole's owner conversation. One cordis.yml row therefore composes both sides of the native interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,带 per-monitor-v2 DPI 感知,中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
[目录选择 seam](../directory-picker/README.md) 的**原生 OS 选择器后端**:`NativeDirectoryPicker` 以 `native` 能力注册 `ctx.directoryPicker`,其 `pick(signal)` 每次调用打开一个原生选择器并解析出所选绝对路径(取消时为 `null`)。平台工具不经 shell 调用:macOS 使用 `osascript`,Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在进程内打开现代 `IFileOpenDialog`——由 koffi 在 worker 线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`——当该原生面不可用时回退到 PowerShell 承载的对话框(先 `pwsh`,再回退到每台 Windows 都自带的 Windows PowerShell 5.1);可解析但无法呈现对话框的 `pwsh`(PowerShell 6 没有 WinForms)同样落入该回退。只有操作者坐在宿主屏幕前时才可用——远程部署应组合 [`-browse`](../directory-picker-browse/README.md)。命令边界(`DirectoryPickerRunner`)与平台事实可注入,便于确定性测试。共享的免 shell 子进程运行器位于 [`dsh-native-command`](../../util/native-command/README.md)。
|
||||
|
||||
**双面包**:browser half(`./client`)向 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞注册一个无渲染的流程占用者——每次 `open` 请求驱动 `host.pickDirectory`,并经洞的 owner 会话上报唯一结果(所选路径/取消/失败)。因此一行 cordis.yml 同时组合原生交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* koffi-backed Win32 bindings for the folder dialog: the COM vtable calls
|
||||
* behind {@link Win32DialogBindings} plus the cross-thread window closer the
|
||||
* driver uses to service aborts. Loaded lazily and only on win32 (the dialog
|
||||
* worker and the driver's abort path), so non-Windows processes never load
|
||||
* koffi — the same containment as the repo's other `win32.ts` modules.
|
||||
* driver uses to service aborts. The module loads on every platform; koffi
|
||||
* itself is imported lazily inside each function, so non-Windows processes
|
||||
* never load it — the same containment as the repo's other `win32.ts`
|
||||
* modules.
|
||||
*
|
||||
* The COM surface used here (IModalWindow/IFileDialog/IFileOpenDialog and
|
||||
* IShellItem vtable order, the GUIDs, `FOS_*` and `SIGDN_FILESYSPATH`) is
|
||||
@@ -29,7 +30,14 @@ interface Koffi {
|
||||
const COINIT_APARTMENTTHREADED = 0x2
|
||||
const CLSCTX_INPROC_SERVER = 0x1
|
||||
const SIGDN_FILESYSPATH = 0x80058000 | 0
|
||||
const DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4
|
||||
/**
|
||||
* Thread DPI awareness contexts, best first: per-monitor-v2 (Windows 10
|
||||
* 1703+), per-monitor (1607+), then system-aware. `SetThreadDpiAwarenessContext`
|
||||
* returns NULL for an unsupported context instead of throwing, so the caller
|
||||
* cascades to the best one the host accepts; DPI stays a cosmetic
|
||||
* best-effort — an unsupported host still gets the modern dialog.
|
||||
*/
|
||||
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
|
||||
const WM_CLOSE = 0x10
|
||||
|
||||
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
|
||||
@@ -94,14 +102,22 @@ export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
|
||||
|
||||
return {
|
||||
setThreadDpiAwareness: () => {
|
||||
let setContext: KoffiFunction
|
||||
try {
|
||||
const setThreadDpiAwarenessContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
|
||||
setThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2)
|
||||
setContext = user32.func('__stdcall', 'SetThreadDpiAwarenessContext', 'void *', ['intptr'])
|
||||
} catch {
|
||||
// SetThreadDpiAwarenessContext absent (Windows 10 pre-1703): the
|
||||
// dialog renders at system DPI; nothing else can fail here because
|
||||
// user32 itself loaded above.
|
||||
// Symbol absent (pre-1607 Windows): no per-thread DPI control exists.
|
||||
// Proceed anyway — the cost is a blurry dialog above 100 % scaling on
|
||||
// museum hosts, and the modern picker still beats dropping to the
|
||||
// legacy 5.1 tree over a cosmetic concern.
|
||||
return
|
||||
}
|
||||
for (const context of DPI_AWARENESS_CONTEXTS) {
|
||||
if (setContext(context) !== null) return
|
||||
}
|
||||
// Unreachable in practice (SYSTEM_AWARE is accepted wherever the symbol
|
||||
// exists); if a host ever refuses everything, the dialog still works —
|
||||
// just without a DPI opt-in.
|
||||
},
|
||||
coInitializeSta: () => coInitializeEx(null, COINIT_APARTMENTTHREADED) as number,
|
||||
coUninitialize: () => {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Real-process half of the Win32 dialog driver: spawn the dialog worker
|
||||
* (source or built plane) and close a dialog thread's windows. Loaded lazily
|
||||
* and only on the win32 default path, so non-Windows processes never touch
|
||||
* worker or koffi machinery; the driver's logic is tested against fakes of
|
||||
* this surface instead.
|
||||
* (source or built plane) and close a dialog thread's windows. The module
|
||||
* itself loads everywhere (the import chain from native-picker.ts is
|
||||
* static); what stays win32-only is koffi, imported dynamically inside the
|
||||
* bindings' functions. The driver's logic is tested against fakes of this
|
||||
* surface instead.
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
@@ -49,9 +49,12 @@ export interface Win32FolderDialog {
|
||||
/** The thread-level native surface the dialog sequencing runs against. */
|
||||
export interface Win32DialogBindings {
|
||||
/**
|
||||
* Best-effort per-monitor-v2 DPI opt-in for the calling thread. Absent
|
||||
* before Windows 10 1703; implementations swallow only that absence, so an
|
||||
* old host merely renders the dialog at system DPI.
|
||||
* Opt the calling thread into the best supported DPI awareness
|
||||
* (per-monitor-v2, then per-monitor, then system-aware), checking each
|
||||
* call's result. Best-effort on purpose: a host accepting none of them
|
||||
* (or lacking the API, pre-1607) still shows the modern dialog — possibly
|
||||
* blurry above 100 % scaling — because a cosmetic degradation must not
|
||||
* cost the tier.
|
||||
*/
|
||||
setThreadDpiAwareness(): void
|
||||
/**
|
||||
|
||||
@@ -83,7 +83,9 @@ export async function pickWin32Directory(
|
||||
|
||||
const postClose = (): void => {
|
||||
// Before `showing` there is no window to close; the budget below still
|
||||
// runs so a worker that never reports cannot dangle the pick.
|
||||
// runs so a worker that never reports cannot dangle the pick. A
|
||||
// rejected close attempt (EnumThreadWindows/PostMessageW refusing) is
|
||||
// discarded: the interval retries it and terminate is the backstop.
|
||||
if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Keyless built-artifact guard (the `dsh-workflow-workerthread` built-worker
|
||||
* shape): plain `worker_threads` loads `lib/worker.cjs` and the bundle reaches
|
||||
* its real koffi requires. POSIX hosts prove the load path end to end through
|
||||
* the deterministic ole32 rejection; win32 skips (a real dialog would open),
|
||||
* where the win32-only smoke in win32-dialog.spec.ts covers the source plane
|
||||
* instead. Skips until a build produces the artifact.
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Win32DialogWorkerMessage } from '../src/win32-dialog-worker.ts'
|
||||
|
||||
const builtWorker = fileURLToPath(new URL('../lib/worker.cjs', import.meta.url))
|
||||
|
||||
describe.skipIf(!existsSync(builtWorker) || process.platform === 'win32')('built dialog worker (lib/worker.cjs)', () => {
|
||||
it('loads under plain worker_threads and reports the native-surface failure', async () => {
|
||||
const message = await new Promise<Win32DialogWorkerMessage>((resolve, reject) => {
|
||||
const worker = new Worker(builtWorker, { workerData: { title: 'Built-artifact guard' } })
|
||||
worker.on('message', resolve)
|
||||
worker.on('error', reject)
|
||||
worker.on('exit', (code) => {
|
||||
reject(new Error(`worker exited (${code}) before reporting`))
|
||||
})
|
||||
})
|
||||
expect(message.kind).toBe('error')
|
||||
expect((message as { kind: 'error'; message: string }).message).toMatch(/ole32|koffi/i)
|
||||
}, 30_000)
|
||||
})
|
||||
@@ -13,6 +13,12 @@ import { HRESULT_CANCELLED, runFolderDialog } from '../src/win32-dialog-logic.ts
|
||||
|
||||
const E_FAIL = 0x80004005 | 0
|
||||
const WM_CLOSE = 0x10
|
||||
/**
|
||||
* Deliberately NOT 8: the bindings must derive vtable offsets and out-buffer
|
||||
* sizes from koffi.sizeof('void *'), and a hardcoded 8 anywhere fails against
|
||||
* this width (the win32-ia32 bug class).
|
||||
*/
|
||||
const FAKE_POINTER_SIZE = 4
|
||||
|
||||
interface ComWorld {
|
||||
coInitHr: number
|
||||
@@ -21,6 +27,8 @@ interface ComWorld {
|
||||
getResultHr: number
|
||||
getDisplayNameHr: number
|
||||
hasThreadDpi: boolean
|
||||
/** Contexts `SetThreadDpiAwarenessContext` accepts; others return NULL. */
|
||||
supportedDpiContexts: number[]
|
||||
enumThrows: boolean
|
||||
path: string
|
||||
titles: string[]
|
||||
@@ -37,7 +45,7 @@ interface ComWorld {
|
||||
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
|
||||
return {
|
||||
coInitHr: 0, coCreateHr: 0, showHr: 0, getResultHr: 0, getDisplayNameHr: 0,
|
||||
hasThreadDpi: true, enumThrows: false,
|
||||
hasThreadDpi: true, supportedDpiContexts: [-4], enumThrows: false,
|
||||
path: 'C:\\选中\\directory',
|
||||
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
|
||||
registered: 0, unregistered: 0, uninitialized: 0,
|
||||
@@ -89,6 +97,10 @@ function installFakeKoffi(world: ComWorld): void {
|
||||
case 'CoUninitialize': return () => { world.uninitialized += 1 }
|
||||
case 'CoCreateInstance': return (...args: unknown[]) => {
|
||||
if (world.coCreateHr < 0) return world.coCreateHr
|
||||
// The out-pointer must be allocated at the fake's pointer width.
|
||||
if ((args[4] as Buffer).length !== FAKE_POINTER_SIZE) {
|
||||
throw new Error(`CoCreateInstance out buffer must be ${FAKE_POINTER_SIZE} bytes`)
|
||||
}
|
||||
outBuffers.set(args[4], dialogPtr)
|
||||
return 0
|
||||
}
|
||||
@@ -96,7 +108,10 @@ function installFakeKoffi(world: ComWorld): void {
|
||||
case 'GetCurrentThreadId': return () => 31337
|
||||
case 'SetThreadDpiAwarenessContext': {
|
||||
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
|
||||
return (context: unknown) => { world.dpiContexts.push(context); return null }
|
||||
return (context: unknown) => {
|
||||
world.dpiContexts.push(context)
|
||||
return world.supportedDpiContexts.includes(context as number) ? { kind: 'previous-context' } : null
|
||||
}
|
||||
}
|
||||
case 'EnumThreadWindows': return (_tid: unknown, callback: { fn: (hwnd: unknown, lparam: unknown) => number }, lparam: unknown) => {
|
||||
if (world.enumThrows) throw new Error('EnumThreadWindows refused')
|
||||
@@ -111,15 +126,16 @@ function installFakeKoffi(world: ComWorld): void {
|
||||
}),
|
||||
proto: (declaration: string) => ({ declaration }),
|
||||
pointer: (type: unknown) => type,
|
||||
sizeof: (type: string) => { void type; return 8 },
|
||||
sizeof: (type: string) => { void type; return FAKE_POINTER_SIZE },
|
||||
register: (fn: (hwnd: unknown, lparam: unknown) => number) => { world.registered += 1; return { fn } },
|
||||
unregister: () => { world.unregistered += 1 },
|
||||
decode: (value: unknown, offsetOrType: unknown): unknown => {
|
||||
if (offsetOrType === 'str16') return (value as FakePtr).text
|
||||
if (typeof offsetOrType === 'number') {
|
||||
// Vtable slot read: hand back a callable-reference sentinel.
|
||||
// Vtable slot read: offsets must be multiples of the fake width.
|
||||
if (offsetOrType % FAKE_POINTER_SIZE !== 0) throw new Error(`vtable offset ${offsetOrType} is not pointer-aligned`)
|
||||
const owner = (value as { owner: FakePtr }).owner
|
||||
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / 8, args) }
|
||||
return { call: (args: unknown[]) => dispatch(owner, offsetOrType / FAKE_POINTER_SIZE, args) }
|
||||
}
|
||||
// decode(x, 'void *'): out-buffer read or vtable read.
|
||||
if (outBuffers.has(value)) return outBuffers.get(value)
|
||||
@@ -159,17 +175,41 @@ describe('loadWin32DialogBindings over the fake COM world', () => {
|
||||
expect(world.uninitialized).toBe(1)
|
||||
})
|
||||
|
||||
it('maps dismissal, missing DPI support, and the S_FALSE CoInitializeEx', async () => {
|
||||
const world = comWorld({ showHr: HRESULT_CANCELLED, hasThreadDpi: false, coInitHr: 1 })
|
||||
it('maps dismissal and the S_FALSE CoInitializeEx', async () => {
|
||||
const world = comWorld({ showHr: HRESULT_CANCELLED, coInitHr: 1 })
|
||||
installFakeKoffi(world)
|
||||
const { loadWin32DialogBindings } = await loadBindingsModule()
|
||||
const bindings = await loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
|
||||
expect(world.dpiContexts).toEqual([])
|
||||
expect(world.released).toEqual(['dialog'])
|
||||
expect(world.uninitialized).toBe(1)
|
||||
})
|
||||
|
||||
it('cascades DPI contexts to the first the host accepts', async () => {
|
||||
const world = comWorld({ supportedDpiContexts: [-3] })
|
||||
installFakeKoffi(world)
|
||||
const bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(world.dpiContexts).toEqual([-4, -3])
|
||||
})
|
||||
|
||||
it('keeps the tier when no DPI context is accepted or the symbol is absent', async () => {
|
||||
// DPI is a cosmetic best-effort: the modern dialog still opens.
|
||||
const rejecting = comWorld({ supportedDpiContexts: [] })
|
||||
installFakeKoffi(rejecting)
|
||||
let bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(rejecting.dpiContexts).toEqual([-4, -3, -2])
|
||||
|
||||
vi.doUnmock('koffi')
|
||||
vi.resetModules()
|
||||
const preThreadDpi = comWorld({ hasThreadDpi: false })
|
||||
installFakeKoffi(preThreadDpi)
|
||||
bindings = await (await loadBindingsModule()).loadWin32DialogBindings()
|
||||
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBe('C:\\选中\\directory')
|
||||
expect(preThreadDpi.dpiContexts).toEqual([])
|
||||
})
|
||||
|
||||
it('surfaces creation and extraction failures as HRESULT errors', async () => {
|
||||
const creationWorld = comWorld({ coCreateHr: E_FAIL })
|
||||
installFakeKoffi(creationWorld)
|
||||
|
||||
Reference in New Issue
Block a user