fix(windows): recover orphaned keyboard focus after native VST editor closes
Addon CI / addon (arm64, macos-14, mac) (push) Has been cancelled
Addon CI / addon (x64, ubuntu-22.04, linux) (push) Has been cancelled
Addon CI / addon (x64, windows-latest, win) (push) Has been cancelled
Ship CI / CI (push) Has been cancelled

Users reported that while in Rig Builder, suddenly every text box in the
app stops accepting input, app-wide, until they hit the Windows key.

Cause: on Windows, VST editors open as top-level windows owned by the
out-of-process vst-host sandbox (the WndProc crash fix). When that
child-process window closes while holding keyboard focus — the user
clicks its X, or a chain rebuild tears editors down — Windows leaves the
Electron window looking active while the OS keyboard focus points at
nothing: Chromium still gets pointer events but no keys. The Windows key
"fix" works because it forces an OS foreground cycle.

Fix: preload.ts installs a win32-only capture-phase pointerdown watchdog.
A click landing while document.hasFocus() is false means the OS won't
type into the window being clicked — it asks main via window:recoverFocus
to run win.blur() + win.focus() (blur first: in the stale state Electron
may believe the window is already focused, so a bare focus() can no-op).
The user's first click inside the dead window self-heals it, covering
every trigger path without having to enumerate them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jafz2001
2026-07-21 15:36:16 -04:00
co-authored by Claude Fable 5
parent 4b1a7db70e
commit 575f26fa5b
2 changed files with 46 additions and 0 deletions
+18
View File
@@ -568,6 +568,24 @@ function createWindow(port: number): void {
return mainWindow.webContents.isAudioMuted();
});
// Keyboard-focus recovery (Windows). A native VST editor window — owned by
// the out-of-process vst-host sandbox since the WndProc crash fix — can
// close while it holds keyboard focus (user clicks its X, or a chain
// rebuild tears it down). Windows then leaves this window LOOKING active
// while the OS keyboard focus points at nothing: every text box in the app
// goes dead until an OS-level foreground cycle (users found the Windows
// key un-sticks it). The renderer detects the state — a click landing
// while document.hasFocus() is false (see preload.ts watchdog) — and asks
// us to run that same cycle programmatically. blur() first: in the stale
// state Electron may still believe the window is focused, so a bare
// focus() can no-op.
ipcMain.on('window:recoverFocus', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (!win || win.isDestroyed() || !win.isVisible() || win.isMinimized()) return;
win.blur();
win.focus();
});
const serverUrl = `http://127.0.0.1:${port}`;
// Clear the Chromium HTTP cache before the first load. The server
+28
View File
@@ -637,4 +637,32 @@ if (isMainFrame) {
contextBridge.exposeInMainWorld('slopsmithDesktop', feedBackDesktopApi);
}
// Keyboard-focus recovery watchdog (Windows). When the sandbox vst-host's
// native editor window closes while focused, the OS keyboard focus can be
// left orphaned: this window still looks active but document.hasFocus() is
// false and NO text box in the app receives keys until a foreground cycle
// (the "hit the Windows key and typing works again" reports, always starting
// in Rig Builder — the one place native editor windows open). Pointer events
// still arrive without keyboard focus, so the user's first click inside the
// dead window is the recovery signal: a pointerdown while document.hasFocus()
// is false means the OS won't type into the window being interacted with —
// ask main to run the blur()/focus() cycle. Capture phase so no
// stopPropagation hides the click; throttled in case focus is contended.
if (isMainFrame && process.platform === 'win32') {
// preload.ts compiles without the DOM lib (see isMainFrame): reach the
// frame's window/document through globalThis with minimal local shapes.
const g = globalThis as unknown as {
addEventListener?: (type: string, cb: () => void, capture?: boolean) => void;
document?: { hasFocus?: () => boolean };
};
let lastFocusRecovery = 0;
g.addEventListener?.('pointerdown', () => {
if (g.document?.hasFocus?.()) return;
const now = Date.now();
if (now - lastFocusRecovery < 1000) return;
lastFocusRecovery = now;
ipcRenderer.send('window:recoverFocus');
}, true);
}
export {};