diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml index b3a1d1d640..9f428ccd78 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md -2026-08-02-win32-in-process-folder-dialog.md: e18ecd1d2e79ec39a265d3d93913beaa7530e645 -2026-08-02-win32-in-process-folder-dialog.zh.md: f146d61f6378062886db732e6884594d56b3d170 +2026-08-02-win32-in-process-folder-dialog.md: c7a6602836c618e855c799b72017aa9232eda968 +2026-08-02-win32-in-process-folder-dialog.zh.md: a67a22625dcd674b2a63b6125b3d31704e90eabc diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md index e18ecd1d2e..c7a6602836 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.md @@ -10,7 +10,7 @@ The Windows directory picker's primary tier was a spawned PowerShell script arou ## Decision -`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. +`packages/host/directory-picker-native` now opens `IFileOpenDialog` (`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`) in-process through koffi — already a workspace dependency for the repo's other `win32.ts` surfaces — as the primary win32 tier. The COM conversation runs on a `worker_threads` worker so the modal `Show` never blocks the host event loop; the worker posts its native thread id before blocking, and the driver services aborts by re-posting `WM_CLOSE` to that thread's windows (`EnumThreadWindows`), terminating and unrefing the worker only when the close budget is exhausted (Node cannot interrupt native calls, so an unclosable worker must never hold the process open). A window on a worker input queue would otherwise be shown without activation, so the driver also raises the dialog to the foreground once the worker reports `showing` — attaching input queues and calling `SetForegroundWindow`, retried on the close cadence until the window (created inside `Show`) exists. The worker thread opts into the best thread DPI awareness the host accepts (`SetThreadDpiAwarenessContext`, cascading per-monitor-v2 → per-monitor → system-aware with the return value checked), a strict upgrade over the script's system-DPI ceiling; DPI stays a cosmetic best-effort — a host accepting none of them still gets the modern dialog rather than a downgrade to the fallback chain. The module split keeps coverage honest on every host: `win32-dialog-logic.ts` (pure sequencing) and `win32-dialog.ts` (driver) test against fakes anywhere; `win32-dialog-bindings.ts` tests against a mocked `koffi` COM world (the `dsh-session-persistence-jsonl` technique); POSIX hosts run the real spawn plumbing to its koffi-load rejection; win32 hosts run a real open-and-abort-close smoke. That smoke lives in `processBoundTests`: under the threads pool a worker blocked in a native modal wedges pool teardown, while a fork contains it. The PowerShell chain (see the [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md)) stays as the fallback tier, its trigger widened from `ENOENT` to any pwsh failure, which also closes the PowerShell 6 regression. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md index f146d61f63..a67a22625d 100644 --- a/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-win32-in-process-folder-dialog.zh.md @@ -10,7 +10,7 @@ Windows 目录选择器的主层此前是围绕 WinForms `FolderBrowserDialog` ## 决策 -`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 +`packages/host/directory-picker-native` 现在经 koffi——它已是仓库其他 `win32.ts` 面的工作区依赖——在进程内打开 `IFileOpenDialog`(`FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR`),作为 win32 主层。COM 会话运行在 `worker_threads` worker 上,模态 `Show` 永不阻塞宿主事件循环;worker 在阻塞前上报其原生线程 id,driver 通过向该线程的窗口反复投递 `WM_CLOSE`(`EnumThreadWindows`)来服务中止,仅当关闭预算耗尽时才 terminate 并 unref worker(Node 无法打断原生调用,关不掉的 worker 决不能拖住进程退出)。worker 输入队列上的窗口默认只会被显示而不会被激活,因此 driver 还会在 worker 上报 `showing` 后把对话框抬升到前台——附加输入队列并调用 `SetForegroundWindow`,按关闭节奏重试直到 `Show` 内创建的窗口出现。worker 线程启用宿主接受的最佳线程 DPI 感知(`SetThreadDpiAwarenessContext`,按 per-monitor-v2 → per-monitor → system-aware 级联并检查返回值),严格优于脚本的系统 DPI 上限;DPI 保持为纯外观的 best-effort——全部不被接受的宿主仍得到现代对话框,而不会降级到回退链。模块切分让覆盖率在任何主机上都诚实:`win32-dialog-logic.ts`(纯时序)与 `win32-dialog.ts`(driver)在任何平台对假件测试;`win32-dialog-bindings.ts` 对 mock 的 `koffi` COM 世界测试(`dsh-session-persistence-jsonl` 的技法);POSIX 主机把真实 spawn 管道跑到 koffi 加载失败的拒绝;win32 主机跑真实的"打开并中止关闭"冒烟。该冒烟位于 `processBoundTests`:threads 池下阻塞在原生模态中的 worker 会卡死池的收尾,fork 则能容纳它。PowerShell 链(见 [DPI note](../bug-fix/2026-08-01-windows-picker-pwsh-dpi.md))保留为回退层,触发条件从 `ENOENT` 拓宽为 pwsh 的任何失败,同时关闭了 PowerShell 6 回归。 ## 考虑过的替代方案 diff --git a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts index 03980af2a4..e6ee2f2e5d 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-bindings.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-bindings.ts @@ -179,3 +179,44 @@ export async function closeThreadWindows(threadId: number): Promise { koffi.unregister(callback) } } + +/** + * Bring a native thread's top-level window to the foreground. The dialog + * runs on a worker input queue, so Windows shows it without activating it + * (the app's main thread holds foreground association); the driver calls + * this on the `showing` notice: attach this thread's input queue to the + * dialog thread's, `SetForegroundWindow`, and detach. Returns whether the + * thread had a window to raise — the dialog window is created inside + * `Show`, after the `showing` notice, so callers retry until it exists. + * @param threadId - the dialog thread's native id (from the `showing` notice). + * @returns true when a window was found and raised. + */ +export async function raiseDialogWindow(threadId: number): Promise { + const koffi = (await import('koffi')).default as unknown as Koffi + const user32 = koffi.load('user32.dll') + const kernel32 = koffi.load('kernel32.dll') + const enumThreadWindows = user32.func('__stdcall', 'EnumThreadWindows', 'int', ['uint32', 'void *', 'intptr']) + const attachThreadInput = user32.func('__stdcall', 'AttachThreadInput', 'int', ['uint32', 'uint32', 'int']) + const setForegroundWindow = user32.func('__stdcall', 'SetForegroundWindow', 'int', ['void *']) + const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', []) + const protoEnumProc = koffi.proto('int __stdcall DshEnumThreadWndProc(void *hwnd, intptr lparam)') + let target: unknown + const callback = koffi.register((hwnd: unknown) => { + if (target === undefined) target = hwnd + return 0 // stop after the first (top-level) window + }, koffi.pointer(protoEnumProc)) + try { + enumThreadWindows(threadId, callback, 0) + } finally { + koffi.unregister(callback) + } + if (target === undefined) return false + const self = getCurrentThreadId() + try { + attachThreadInput(self, threadId, 1) + setForegroundWindow(target) + } finally { + attachThreadInput(self, threadId, 0) + } + return true +} diff --git a/packages/host/directory-picker-native/src/win32-dialog-host.ts b/packages/host/directory-picker-native/src/win32-dialog-host.ts index ff02a93105..21089be150 100644 --- a/packages/host/directory-picker-native/src/win32-dialog-host.ts +++ b/packages/host/directory-picker-native/src/win32-dialog-host.ts @@ -34,4 +34,4 @@ export function spawnDialogWorker(data: Win32DialogWorkerData): Worker { return new Worker(new URL(`data:text/javascript,${encodeURIComponent(bootstrap)}`), { workerData: data }) } -export { closeThreadWindows } from './win32-dialog-bindings.ts' +export { closeThreadWindows, raiseDialogWindow } from './win32-dialog-bindings.ts' diff --git a/packages/host/directory-picker-native/src/win32-dialog.ts b/packages/host/directory-picker-native/src/win32-dialog.ts index 15de82373e..0d406d0bfd 100644 --- a/packages/host/directory-picker-native/src/win32-dialog.ts +++ b/packages/host/directory-picker-native/src/win32-dialog.ts @@ -6,7 +6,11 @@ * injectable so every driver path is testable on any platform. */ -import { closeThreadWindows as hostCloseThreadWindows, spawnDialogWorker } from './win32-dialog-host.ts' +import { + closeThreadWindows as hostCloseThreadWindows, + raiseDialogWindow as hostRaiseDialogWindow, + spawnDialogWorker, +} from './win32-dialog-host.ts' import type { Win32DialogWorkerData, Win32DialogWorkerMessage } from './win32-dialog-worker.ts' /** The worker surface the driver drives (satisfied by `node:worker_threads`). */ @@ -39,6 +43,8 @@ export interface Win32DialogInternals { spawnWorker?: (data: Win32DialogWorkerData) => Win32DialogWorkerLike /** Replaces the real `WM_CLOSE` poster (`win32-dialog-host.ts`). */ closeThreadWindows?: (threadId: number) => Promise + /** Replaces the real foreground raise (`win32-dialog-host.ts`). */ + raiseDialogWindow?: (threadId: number) => Promise /** Abort-service cadence override so tests never wait wall-clock time. */ closeRetryMs?: number } @@ -71,11 +77,13 @@ export async function pickWin32Directory( if (signal.aborted) throw new Error('native directory picker aborted') const spawnWorker = internals.spawnWorker ?? spawnDialogWorker const closeWindows = internals.closeThreadWindows ?? hostCloseThreadWindows + const raiseWindow = internals.raiseDialogWindow ?? hostRaiseDialogWindow const closeRetryMs = internals.closeRetryMs ?? CLOSE_RETRY_MS const worker = spawnWorker({ title: DIALOG_TITLE }) let dialogThreadId: number | undefined let closeTimer: NodeJS.Timeout | undefined + let raiseTimer: NodeJS.Timeout | undefined let settled = false return await new Promise((resolve, reject) => { @@ -83,6 +91,7 @@ export async function pickWin32Directory( if (settled) return settled = true if (closeTimer !== undefined) clearInterval(closeTimer) + if (raiseTimer !== undefined) clearInterval(raiseTimer) signal.removeEventListener('abort', onAbort) worker.unref?.() outcome() @@ -96,6 +105,26 @@ export async function pickWin32Directory( if (dialogThreadId !== undefined) void closeWindows(dialogThreadId).catch(() => undefined) } + // The `showing` notice precedes the blocking `Show`, so the dialog + // window does not exist yet; re-enumerate on the close cadence until it + // does and raise it — a window on a worker input queue is otherwise + // shown without activation. Stops on settle, abort, or a successful + // raise; a failing raise (e.g. koffi absent) never blocks the pick. + const startRaise = (): void => { + const attempt = (): void => { + if (settled || signal.aborted || dialogThreadId === undefined) return + void raiseWindow(dialogThreadId) + .then((raised) => { + if (raised || settled || signal.aborted) { + if (raiseTimer !== undefined) clearInterval(raiseTimer) + } + }) + .catch(() => undefined) + } + attempt() + raiseTimer = setInterval(attempt, closeRetryMs) + } + // Sole caller: the once-registered abort listener, so no re-entry guard. const serviceAbort = (): void => { let attempts = 0 @@ -129,6 +158,7 @@ export async function pickWin32Directory( dialogThreadId = message.threadId // An abort that raced ahead of this notice now has a window to hit. if (signal.aborted) postClose() + else startRaise() return case 'done': settle(() => { diff --git a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts index 598386b1a7..72ff1b41d1 100644 --- a/packages/host/directory-picker-native/tests/win32-dialog.spec.ts +++ b/packages/host/directory-picker-native/tests/win32-dialog.spec.ts @@ -1,9 +1,11 @@ /** * Driver tests: the worker message protocol mapped onto the promise, the - * WM_CLOSE abort service (including the show-race retry and the terminate - * last resort) against fakes, plus the real spawn plumbing — POSIX hosts - * prove the default path rejects cleanly (koffi cannot load ole32 there), - * and win32 hosts briefly open and auto-abort a real dialog. + * foreground raise after the `showing` notice (retried until the dialog + * window exists), the WM_CLOSE abort service (including the show-race + * retry and the terminate last resort) against fakes, plus the real spawn + * plumbing — POSIX hosts prove the default path rejects cleanly (koffi + * cannot load ole32 there), and win32 hosts briefly open and auto-abort a + * real dialog. */ import { EventEmitter } from 'node:events' @@ -22,15 +24,24 @@ interface Harness { worker: FakeWorker internals: Win32DialogInternals close: ReturnType + raise: ReturnType } function harness(overrides: Partial = {}): Harness { const worker = new FakeWorker() const close = vi.fn(async () => undefined) + const raise = vi.fn(async () => true) return { worker, close, - internals: { spawnWorker: () => worker, closeThreadWindows: close, closeRetryMs: 1, ...overrides }, + raise, + internals: { + spawnWorker: () => worker, + closeThreadWindows: close, + raiseDialogWindow: raise, + closeRetryMs: 1, + ...overrides, + }, } } @@ -51,6 +62,35 @@ describe('pickWin32Directory', () => { await expect(cancelled).resolves.toBeNull() }) + it('raises the dialog window to the foreground after the showing notice', async () => { + const { worker, internals, raise } = harness() + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'showing', threadId: 7 }) + worker.post({ kind: 'done', path: 'C:\\raised' }) + await expect(picked).resolves.toBe('C:\\raised') + expect(raise).toHaveBeenCalledWith(7) + }) + + it('retries the raise until the dialog window exists, then stops', async () => { + const { worker, internals, raise } = harness() + // The window is created inside `Show`, after the `showing` notice, so + // the first attempts find nothing; once a window is reported, the raise + // must stop retrying. + raise.mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true) + const picked = pickWin32Directory(live(), internals) + worker.post({ kind: 'showing', threadId: 12 }) + await vi.waitFor(() => { + expect(raise.mock.calls.length).toBeGreaterThanOrEqual(2) + }) + const callsAfterRaised = await new Promise((resolve) => { + setTimeout(() =>{ resolve(raise.mock.calls.length); }, 20) + }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(raise.mock.calls.length).toBe(callsAfterRaised) + worker.post({ kind: 'done', path: 'C:\\raised' }) + await expect(picked).resolves.toBe('C:\\raised') + }) + it('rejects on a reported dialog failure, a worker crash, and a silent exit', async () => { const reported = harness() const failing = pickWin32Directory(live(), reported.internals) @@ -103,7 +143,7 @@ describe('pickWin32Directory', () => { 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 { worker, internals, raise } = 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') @@ -113,6 +153,7 @@ describe('pickWin32Directory', () => { await vi.waitFor(() => { expect(closeFailures.mock.calls.length).toBeGreaterThan(1) }) + expect(raise).not.toHaveBeenCalled() worker.post({ kind: 'done', path: null }) await picked })