Merge pull request #3698 from deepseek-harness/feat/picker-alt-foreground

fix(picker): foreground-activate the Windows native folder dialog via a synthesized Alt press
This commit is contained in:
Huanqi Cao
2026-09-09 14:28:23 +08:00
committed by GitHub
13 changed files with 130 additions and 15 deletions
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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/bug-fix/2026-09-07-win32-picker-foreground-alt-key.md
2026-09-07-win32-picker-foreground-alt-key.md: cd1bae56845c74bf8fb0b3c0e6b8e6bab8032daf
2026-09-07-win32-picker-foreground-alt-key.zh.md: 8cbacaee97892b2ba6feb3d0250212fa9d393376
@@ -0,0 +1,25 @@
# Agent Note: Foreground activation for the Win32 picker via a synthesized Alt press
Status: implemented
English | [中文](2026-09-07-win32-picker-foreground-alt-key.zh.md)
## Problem
The web GUI host picks a workspace directory through the native Win32 folder dialog, which runs in a child process the host spawns (issue #3543). Windows grants the foreground only to the foreground process, to a process it started, or to a process that received recent input; a child of a background server process qualifies for none of these, so the dialog that `Show` opens sits behind every visible window even though it is the child's first window. The first-window activation assumption behind the spawn design ([archived feature note](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md)) holds only when the spawner chain owns the console foreground, as in a console-launched CLI.
## Decision
`runFolderDialog` calls a new `pressAltForForeground` binding between the `showing` notice and the blocking `Show`. The binding synthesizes one Alt press (`keybd_event` with `VK_MENU`, down then up) on the dialog thread, which makes Windows count this process as the most recent input owner — one of the documented grounds for foreground activation — so the dialog window `Show` creates activates as foreground. The bindings module already loads koffi's `user32`, so the change adds one function fetch and two invocations. The press is unconditional on Windows. When the process already holds foreground rights (a console-launched CLI), the dialog activates anyway and the press is inert; the window focused at that moment still receives the lone Alt and may briefly highlight its menu bar. Environments that suppress injected input (secure desktops, restricted remote sessions, an elevated foreground window) leave the dialog behind other windows, and the package README records that limit.
## Alternatives considered
**Custom URL protocol with a browser click gesture.** Draft PR #3544 granted the foreground by navigating the foreground browser to a registered `dsh-picker://` URL, which makes the shell launch the dialog process as a foreground descendant. The grant is deterministic by design, but the mechanism spans registry and VBS launcher files, a protocol entry point, a picker-result HTTP route with per-boot tokens, and a first-use browser confirmation, and it adds a server route the browser can reach. The synthesized press removes that entire surface.
**AllowSetForegroundWindow from the clicker.** The API must be called by the current foreground process — the browser — and may name only one permitted process; the spawner cannot invoke it on the browser's behalf.
**AttachThreadInput to the focused thread.** Attaching the dialog thread to the focused window's thread also bypasses the foreground restriction and avoids the keystroke side effect, but it is equally undocumented, needs the focused window's thread id at show time, and fails when the focused window belongs to a higher-integrity process; it was not prototyped.
## Consequences
The picker keeps its single spawned-child design and gains foreground behavior in the background-host case at the cost of one koffi call pair. The bindings spec pins the Alt down/up sequence and its position immediately before `Show` over the fake COM world; the logic spec pins the full showing → press → `Show` order. The Windows CI lane still opens and abort-closes a real dialog with the press present but asserts no activation. Validation on a Windows 11 machine with the foreground lock forced to its maximum reproduced the failure without the press (dialog behind other windows) and the foreground dialog with it in five of five repeat runs; Windows 10 is unverified. Synthesized input is consumed asynchronously by the raw input thread, so the activation grant is in principle race-prone; no miss appeared across the repeat runs, and fragile environments stay a documented package limitation rather than a second mechanism, because the browse backend remains the composition-level answer where native picking cannot be trusted.
@@ -0,0 +1,25 @@
# Agent Note: 通过合成的 Alt 按键让 Win32 选择器获得前台激活
Status: implemented
[English](2026-09-07-win32-picker-foreground-alt-key.md) | 中文
## Problem
web GUI 宿主通过原生 Win32 文件夹对话框选择工作区目录,对话框运行在宿主 spawn 的子进程中(issue #3543)。Windows 只把前台授予前台进程、由前台进程启动的进程或最近收到输入的进程;后台服务器进程的子进程三者都不满足,因此 `Show` 打开的对话框即使属于子进程的首个窗口,也会落在所有可见窗口之后。spawn 设计背后的"首窗口即激活"假设([归档功能记录](../../archived/feature/2026-08-02-win32-in-process-folder-dialog.md))只在 spawn 链持有控制台前台时成立,例如控制台启动的 CLI。
## Decision
`runFolderDialog``showing` 通知与阻塞式 `Show` 之间调用新增的 `pressAltForForeground` 绑定。该绑定在对话框线程上合成一次 Alt 按键(`keybd_event` 携带 `VK_MENU`,先按下后抬起),使 Windows 把该进程计为最近的输入所有者——文档记载的允许前台激活的理由之一——于是 `Show` 创建的对话框窗口以前台方式激活。bindings 模块已经加载 koffi 的 `user32`,因此改动只增加一次函数获取与两次调用。该按键在 Windows 上无条件执行。当进程已经持有前台权利(控制台启动的 CLI)时,对话框本来就会激活,按键不起作用;此刻获得焦点的窗口仍会收到这一次单独的 Alt,可能短暂高亮其菜单栏。在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍会落在其他窗口后面,包 README 记录了该限制。
## Alternatives considered
**自定义 URL 协议加浏览器点击手势。** 草稿 PR #3544 通过让前台浏览器导航到已注册的 `dsh-picker://` URL 来授予前台,使 shell 把对话框进程作为前台进程的后代启动。该授权按设计具有确定性,但机制横跨注册表与 VBS 启动器文件、协议入口点、携带每次启动令牌的 picker-result HTTP 路由,以及首次使用时的浏览器确认,还增加了一条浏览器可达的服务端路由。合成按键删除了整个这一面。
**由点击方调用 AllowSetForegroundWindow。** 该 API 必须由当前前台进程——浏览器——调用,并且只能点名一个被允许的进程;spawner 无法代替浏览器调用它。
**对聚焦线程 AttachThreadInput。** 把对话框线程附着到焦点窗口所属线程同样能绕过前台限制且没有按键副作用,但同样没有文档契约,需要在显示时取得焦点窗口的线程 id,并在焦点窗口属于更高完整性进程时失败;未做原型验证。
## Consequences
选择器保持单一 spawn 子进程设计,并在后台宿主场景获得前台行为,代价是一次 koffi 调用对。bindings spec 在假 COM 世界上固定了 Alt 按下/抬起序列及其紧邻 `Show` 之前的位置;logic spec 固定完整的 showing → press → `Show` 顺序。Windows CI lane 仍会真实打开并中止关闭一个对话框(按键存在),但不断言激活。在一台把前台锁强制到最大值的 Windows 11 机器上做了验证:不加按键复现失败(对话框在其他窗口后面),加上按键后对话框获得前台,五轮重复全部成功;Windows 10 尚未验证。合成输入由 raw input thread 异步消费,因此激活授权原则上存在竞争窗口;重复运行中未出现错过,脆弱环境仍是记录的包限制而非第二套机制,因为浏览后端仍是原生选择不可信场景在组合层面的答案。
@@ -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: a432bdc19175c690287745cc9c2df004d03b7478
README.zh.md: 98ea9a033dbf6720fa08fbb068e243371998dab6
README.md: 3f980831848965b976e32b0e5ef25abb77fe4e6e
README.zh.md: 66f77a0b715aee8b4087dd03d4d4eea69d39bb09
@@ -53,7 +53,7 @@ The backend is a thin service over a platform chooser: `NativeDirectoryPicker` r
### Platform mechanics
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 a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread.
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 a spawned child process — a koffi-driven COM conversation on the child's main thread with the best thread DPI awareness the host accepts (per-monitor-v2 first), aborted by posting `WM_CLOSE` to the dialog thread. Immediately before `Show`, the child synthesizes one Alt press through `keybd_event`, which lets the dialog activate as the foreground window even when a background host process spawned the child.
### Source map
@@ -98,6 +98,7 @@ These limits define when the native interaction is unavailable or fragile. They
- **Linux requires desktop tooling** — with neither Zenity nor KDialog installed, `pick` rejects with an actionable error; it does not fall back to a typed-path prompt (the browse backend is that fallback at the composition level).
- **Windows has no mechanism fallback** — the child-process picker through packaged koffi is the only native tier, so a COM refusal or dialog crash surfaces the failure; the browse backend remains the fallback at the composition level.
- **Windows foreground grant relies on injected input** — the child synthesizes an Alt press before `Show` so the dialog can take the foreground from a background host; where synthesized input is suppressed (secure desktops, restricted remote sessions, an elevated foreground window), the dialog may still open behind other windows. The technique is validated on Windows 11 only.
<a id="dev-note"></a>
### Dev Note
@@ -53,7 +53,7 @@ kind: "package-reference"
### 平台机制
平台工具不经 shell 调用:macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE`
平台工具不经 shell 调用:macOS 使用 `osascript`Linux 使用 Zenity 并以 KDialog 回退;调用方的中止信号会终止原生进程。Windows 在 spawn 的子进程中打开现代 `IFileOpenDialog`——由 koffi 在子进程主线程上驱动的 COM 会话,采用宿主接受的最佳线程 DPI 感知(优先 per-monitor-v2),中止时向对话框线程投递 `WM_CLOSE``Show` 之前,子进程立即通过 `keybd_event` 合成一次 Alt 按键,让对话框即使由后台宿主进程 spawn 也能激活为前台窗口。
### 源码地图
@@ -98,6 +98,7 @@ kind: "package-reference"
- **Linux 依赖桌面工具**——Zenity 与 KDialog 均未安装时,`pick` 以包含解决建议的错误拒绝;它不会回退为手输路径提示(组合层面的回退是浏览后端)。
- **Windows 没有机制级回退**——通过打包依赖 koffi 运行的子进程选择器是唯一原生层级,因此 COM 拒绝或对话框崩溃会直接上报失败;组合层面的回退仍是浏览后端。
- **Windows 前台授权依赖注入的输入**——子进程在 `Show` 之前合成一次 Alt 按键,对话框才能从后台宿主取得前台;在合成输入被抑制的环境(安全桌面、受限远程会话、提权前台窗口)中,对话框仍可能在其他窗口后面打开。该技术仅在 Windows 11 上验证过。
<a id="dev-note"></a>
### 开发备注
@@ -3,7 +3,9 @@
* with the `native` capability, opening one native OS chooser on the host
* display per pick (macOS `osascript`, Linux Zenity with a KDialog fallback;
* Windows opens the modern `IFileOpenDialog` in a spawned child process — a
* koffi-driven COM conversation on the child's main thread). Only viable when
* koffi-driven COM conversation on the child's main thread, preceded by a
* synthesized Alt press so the dialog activates as foreground even when a
* background host spawned the child). Only viable when
* the operator sits at the host's screen; remote deployments compose the
* browse backend instead.
* @module @deepseek-ai/dsh-host-directory-picker-native
@@ -55,6 +55,10 @@ const SIGDN_FILESYSPATH = 0x80058000 | 0
*/
const DPI_AWARENESS_CONTEXTS = [-4, -3, -2]
const WM_CLOSE = 0x10
/** `VK_MENU`: the synthesized Alt press's virtual key. */
const VK_MENU = 0x12
/** `KEYEVENTF_KEYUP`: the synthesized Alt press's release flag. */
const KEYEVENTF_KEYUP = 0x2
/** IFileOpenDialog vtable slots (IUnknown 0-2, IModalWindow 3, IFileDialog 4+). */
const SLOT_RELEASE = 2
@@ -101,6 +105,7 @@ export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
const coCreateInstance = ole32.func('__stdcall', 'CoCreateInstance', 'int32', ['void *', 'void *', 'uint32', 'void *', 'void *'])
const coTaskMemFree = ole32.func('__stdcall', 'CoTaskMemFree', 'void', ['void *'])
const getCurrentThreadId = kernel32.func('__stdcall', 'GetCurrentThreadId', 'uint32', [])
const keybdEvent = user32.func('__stdcall', 'keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'uintptr'])
const protoShow = koffi.proto('int32 __stdcall DshDialogShow(void *self, void *owner)')
const protoSetOptions = koffi.proto('int32 __stdcall DshDialogSetOptions(void *self, uint32 options)')
@@ -140,6 +145,10 @@ export async function loadWin32DialogBindings(): Promise<Win32DialogBindings> {
coUninitialize()
},
currentThreadId: () => getCurrentThreadId() as number,
pressAltForForeground: () => {
keybdEvent(VK_MENU, 0, 0, 0)
keybdEvent(VK_MENU, 0, KEYEVENTF_KEYUP, 0)
},
createFolderDialog: (): Win32FolderDialog => {
const out = Buffer.alloc(pointerSize)
const created = coCreateInstance(CLSID_FILE_OPEN_DIALOG, null, CLSCTX_INPROC_SERVER, IID_IFILE_OPEN_DIALOG, out) as number
@@ -14,9 +14,10 @@ import type { Win32DialogWorkerData } from './win32-dialog-worker.ts'
/**
* Spawn the dialog child process. Built consumers launch the bundled CJS
* entry next to this module under plain node; unbuilt (source) consumers
* bootstrap tsx first, mirroring the dsh CLI's source launch. The dialog is
* the child's first window, so Windows activates it without a foreground
* call.
* bootstrap tsx first, mirroring the dsh CLI's source launch. The child
* opens its dialog as foreground on its own: `runFolderDialog` synthesizes
* an Alt press before `Show`, which matters when a background host spawned
* the child.
* @param data - the child payload (dialog title).
* @returns the spawned child process.
*/
@@ -79,6 +79,20 @@ export interface Win32DialogBindings {
* @returns the calling thread's native id.
*/
currentThreadId(): number
/**
* Make the dialog that `Show` is about to create able to take the
* foreground. Windows grants activation only to the foreground process,
* to a process it started, or to a process that received recent input; a
* worker spawned by a background host (the web GUI server) qualifies for
* none, so the dialog would open behind every other window. Synthesizing
* one Alt press (down, then up) through `keybd_event` counts this process
* as the most recent input owner — a community-documented technique with
* no documented contract. Call immediately before `Show`. When the
* process already holds foreground rights (a console-launched CLI), the
* press is inert, but the focused window still receives the lone Alt and
* may briefly highlight its menu bar before the dialog activates.
*/
pressAltForForeground(): void
}
/**
@@ -117,6 +131,7 @@ export function runFolderDialog(
check(dialog.setOptions(FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_NOCHANGEDIR), 'SetOptions')
check(dialog.setTitle(title), 'SetTitle')
onShowing(bindings.currentThreadId())
bindings.pressAltForForeground()
const shown = dialog.show()
if (shown === HRESULT_CANCELLED) return null
check(shown, 'Show')
@@ -1,9 +1,12 @@
/**
* Child-process entry for the Win32 folder dialog: blocks THIS process
* inside the modal `Show` so the host event loop stays live, reporting over
* the IPC channel. Spawned as a child process (not a worker thread) so the
* dialog is the process's first window and Windows activates it without a
* manual foreground call. Protocol: `{kind:'showing',threadId}` right
* the IPC channel. Spawned as a child process (not a worker thread) so a
* native fault stays contained and the modal call never wedges the host.
* A background host (the web GUI server) leaves this process without
* foreground rights, so `runFolderDialog` synthesizes an Alt press
* immediately before `Show` and the dialog then activates as foreground.
* Protocol: `{kind:'showing',threadId}` right
* before the blocking call (the driver's abort lever needs the native
* thread id), then exactly one of `{kind:'done',path}` or
* `{kind:'error',message}`.
@@ -38,6 +38,10 @@ interface ComWorld {
registered: number
unregistered: number
uninitialized: number
/** The synthesized keybd_event calls, in order. */
keyEvents: { vk: number; flags: number }[]
/** Cross-cutting call trace shared by keybd_event and the dialog Show slot. */
nativeOrder: string[]
}
function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
@@ -48,6 +52,7 @@ function comWorld(overrides: Partial<ComWorld> = {}): ComWorld {
titles: [], options: [], dpiContexts: [], freed: [], released: [], posted: [],
str16PointerSizes: [],
registered: 0, unregistered: 0, uninitialized: 0,
keyEvents: [], nativeOrder: [],
...overrides,
}
}
@@ -77,7 +82,7 @@ function installFakeKoffi(world: ComWorld, options: {
switch (slot) {
case 9: world.options.push(args[0] as number); return 0
case 17: world.titles.push(args[0] as string); return 0
case 3: return world.showHr
case 3: world.nativeOrder.push('show'); return world.showHr
case 20: {
if (world.getResultHr < 0) return world.getResultHr
;(args[0] as unknown[])[0] = itemPtr
@@ -116,6 +121,10 @@ function installFakeKoffi(world: ComWorld, options: {
}
case 'CoTaskMemFree': return (ptr: unknown) => { world.freed.push(ptr) }
case 'GetCurrentThreadId': return () => 31337
case 'keybd_event': return (vk: number, _scan: number, flags: number, _extra: unknown) => {
world.keyEvents.push({ vk, flags })
world.nativeOrder.push(flags === 0 ? 'alt-down' : 'alt-up')
}
case 'SetThreadDpiAwarenessContext': {
if (!world.hasThreadDpi) throw new Error(`${dll}: SetThreadDpiAwarenessContext not found`)
return (context: unknown) => {
@@ -188,6 +197,14 @@ describe('loadWin32DialogBindings over the fake COM world', () => {
expect(world.titles).toEqual(['选择工作区目录'])
expect(world.options).toHaveLength(1)
expect(showing).toHaveBeenCalledWith(31337)
// One synthesized Alt press (down, then up) immediately precedes Show, so
// the dialog's activation attempt finds this process as the recent-input
// owner.
expect(world.keyEvents).toEqual([
{ vk: 0x12, flags: 0 },
{ vk: 0x12, flags: 2 },
])
expect(world.nativeOrder.slice(-3)).toEqual(['alt-down', 'alt-up', 'show'])
expect(world.freed).toHaveLength(1)
expect(world.str16PointerSizes).toEqual([FAKE_POINTER_SIZE])
expect(world.released).toEqual(['item', 'dialog'])
@@ -244,6 +261,7 @@ describe('loadWin32DialogBindings over the fake COM world', () => {
const { loadWin32DialogBindings } = await loadBindingsModule()
const bindings = await loadWin32DialogBindings()
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(world.keyEvents).toHaveLength(2)
expect(world.released).toEqual(['dialog'])
expect(world.uninitialized).toBe(1)
})
@@ -357,6 +375,7 @@ describe('the worker entry over a mocked process boundary', () => {
coInitializeSta: () => 0,
coUninitialize: () => undefined,
currentThreadId: () => 11,
pressAltForForeground: () => undefined,
createFolderDialog: () => ({
setOptions: () => 0,
setTitle: () => 0,
@@ -15,6 +15,7 @@ const E_FAIL = 0x80004005 | 0
interface FakeWorld {
bindings: Win32DialogBindings
dpi: ReturnType<typeof vi.fn>
pressAlt: ReturnType<typeof vi.fn>
createDialog: ReturnType<typeof vi.fn>
uninitialize: ReturnType<typeof vi.fn>
dialog: {
@@ -36,6 +37,7 @@ function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorl
...overrides,
}
const dpi = vi.fn()
const pressAlt = vi.fn()
const createDialog = vi.fn(() => dialog)
const uninitialize = vi.fn()
const bindings: Win32DialogBindings = {
@@ -44,13 +46,14 @@ function world(overrides: Partial<Win32FolderDialog> = {}, coInit = 0): FakeWorl
coUninitialize: uninitialize,
createFolderDialog: createDialog,
currentThreadId: vi.fn(() => 4242),
pressAltForForeground: pressAlt,
}
return { bindings, dpi, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
return { bindings, dpi, pressAlt, createDialog, uninitialize, dialog: dialog as FakeWorld['dialog'] }
}
describe('runFolderDialog', () => {
it('sequences DPI, STA, options, title, show, result extraction, and apartment teardown', () => {
const { bindings, dpi, dialog, uninitialize } = world()
const { bindings, dpi, pressAlt, dialog, uninitialize } = world()
const showing = vi.fn()
expect(runFolderDialog(bindings, 'Pick', showing)).toBe('C:\\picked\\目录')
expect(dpi).toHaveBeenCalledOnce()
@@ -60,12 +63,17 @@ describe('runFolderDialog', () => {
expect(dialog.setTitle).toHaveBeenCalledWith('Pick')
expect(showing).toHaveBeenCalledWith(4242)
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
// The foreground press sits between the showing notice and the blocking Show.
expect(pressAlt).toHaveBeenCalledOnce()
expect(showing.mock.invocationCallOrder[0]).toBeLessThan(pressAlt.mock.invocationCallOrder[0] as number)
expect(pressAlt.mock.invocationCallOrder[0]).toBeLessThan(dialog.show.mock.invocationCallOrder[0] as number)
expect(dialog.release).toHaveBeenCalledOnce()
})
it('maps the cancelled HRESULT to null and still releases the dialog and apartment', () => {
const { bindings, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
const { bindings, pressAlt, dialog, uninitialize } = world({ show: vi.fn(() => HRESULT_CANCELLED) })
expect(runFolderDialog(bindings, 'Pick', vi.fn())).toBeNull()
expect(pressAlt).toHaveBeenCalledOnce()
expect(dialog.resultPath).not.toHaveBeenCalled()
expect(dialog.release).toHaveBeenCalledOnce()
expect(uninitialize).toHaveBeenCalledOnce()