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