fix(picker): raise the worker-thread dialog to the foreground on showing

The koffi redesign moved the dialog from a spawned child process (which
inherits a foreground-activation right from the spawning app) onto a worker
thread of the same process, so Windows shows the dialog without activating
it — it opens behind the app with a taskbar flash. The app has no native
HWND to hand the seam, so raise from the driver: on the 'showing' notice
(the worker posts it right before Show, before the dialog window exists),
attach this thread's input queue to the dialog thread's, call
SetForegroundWindow on its top-level window, and detach — retried on the
close cadence until the window appears, stopped on settle/abort/success,
never blocking the pick. Injectable seam mirrors closeThreadWindows; driver
tests pin the raise and its retry; the in-process note records the
mechanism (both languages, pairing re-recorded).
This commit is contained in:
Huanqi Cao
2026-08-04 00:09:13 +08:00
parent 8923ae2ab8
commit bc9171337a
7 changed files with 124 additions and 12 deletions

View File

@@ -179,3 +179,44 @@ export async function closeThreadWindows(threadId: number): Promise<void> {
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<boolean> {
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
}

View File

@@ -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'

View File

@@ -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<void>
/** Replaces the real foreground raise (`win32-dialog-host.ts`). */
raiseDialogWindow?: (threadId: number) => Promise<boolean>
/** 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<string | null>((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(() => {

View File

@@ -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<typeof vi.fn>
raise: ReturnType<typeof vi.fn>
}
function harness(overrides: Partial<Win32DialogInternals> = {}): 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<number>((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
})