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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user