DEV Community

Daming Wu
Daming Wu

Posted on Fully Autonomous

Why PID equality breaks WebView2 focus detection—and how to verify focus safely

A desktop dictation tool has a deceptively dangerous job: it waits while speech recognition runs, then writes text into another application's focused control. If focus changes during that delay, the text may land in a chat, terminal, or password field the user never intended.

In VocalCode I capture a focus token when recording starts and validate it again immediately before insertion. On Windows that token includes the foreground HWND and the UI Automation runtime ID of the exact focused element.

The conservative check that was still wrong

My original code also required IUIAutomationElement::CurrentProcessId to equal the PID that owns GetForegroundWindow().

That works for ordinary Win32 controls. It fails in Chromium, Electron, and WebView2 applications because the top-level HWND belongs to the host process while the focused text control may be provided by a separate renderer process. The control is legitimate, but PID equality rejects it.

The mistake was treating process identity as proof of visual/window membership.

Use the native window tree as the membership authority

The replacement algorithm is:

  1. Capture GetForegroundWindow() and reject a null HWND.
  2. Ask UI Automation for the focused element and capture its non-empty runtime ID.
  3. Walk upward with the raw-view tree walker until an element exposes CurrentNativeWindowHandle.
  4. Resolve GetAncestor(native_hwnd, GA_ROOT).
  5. Require that root HWND to equal the captured foreground HWND.
  6. Re-read both the foreground window and the focused UIA element after the ancestor walk to close the race.
  7. At delivery time, recapture the token and require the foreground HWND, renderer PID, and runtime ID to match the token captured at recording start.

In simplified Rust, the pure membership decision looks like this:

struct FocusCandidate<'a> {
    foreground_window: u64,
    foreground_process_id: u32,
    element_process_id: u32,
    native_window: u64,
    native_root_window: u64,
    runtime_id: &'a [i32],
}

fn is_safe(c: &FocusCandidate<'_>) -> bool {
    c.foreground_window != 0
        && c.foreground_process_id != 0
        && c.element_process_id != 0
        && c.native_window != 0
        && c.native_root_window == c.foreground_window
        && !c.runtime_id.is_empty()
}
Enter fullscreen mode Exit fullscreen mode

Notice what is absent: element_process_id == foreground_process_id. The renderer PID remains part of the identity token, but it is not used as the membership authority.

Closing the race matters

Walking a remote UIA provider can take time. During that walk the user can switch windows or controls. After verifying ancestry, the implementation therefore checks GetForegroundWindow() again and uses IUIAutomation::CompareElements against a freshly fetched focused element. At insertion time the complete token is validated again.

A different field in the same WebView is not considered equivalent.

Tests that caught the boundary

The pure predicate and token comparison make the important cases straightforward to test:

  • a same-process Win32 input is accepted;
  • a cross-process WebView2 input whose native root is the foreground window is accepted;
  • an element rooted in another foreground window is rejected;
  • changing either the window or exact runtime ID while speech recognition runs is rejected.

The broader lesson is that modern desktop UI process boundaries do not necessarily match window boundaries. If the safety question is “does this control belong to the user's foreground window?”, verify that relationship in the window tree and preserve the exact control identity across the asynchronous operation.

I found this while building VocalCode, an on-device push-to-talk utility for AI coding workflows. Founder disclosure: it is my product. The 30-day no-card build is at https://vocalcode.app/ if you want to test the behavior in real Win32, Chrome/Electron, or WebView2 fields.

Top comments (0)