fix(desktop): mouse back/forward and reload never act on a hidden session's Browser
After a session switch, Chromium still tracked the hidden session's kept Browser guest as the focused webContents, so main's focused-guest gestures (mouse back/forward app-command, swipe, Ctrl/Cmd+R) navigated or reloaded a page the user could not see. The keep-alive host also left focus on an inert hidden guest, where keystrokes could still land. - A keep-alive host going hidden blurs whatever inside it holds focus. - The preview pane tells main when its guest leaves / returns to the screen (hermes:preview-guest-hidden; only the embedding renderer is believed), and commandFocusedGuest (moved to electron/preview-guest-offscreen.ts) skips a hidden guest so the gesture falls through to the renderer's visible pane.
This commit is contained in:
@@ -448,6 +448,7 @@ import { readPreUpdateBackupEnabled } from './pre-update-backup-config'
|
||||
import { capturePreviewContents } from './preview-capture'
|
||||
import { onPreviewWatchOwnerDestroyed, sendPreviewFileChangedToOwner } from './preview-file-watch'
|
||||
import { hasClosePreviewFlag, previewGuestInputAction } from './preview-guest-escape'
|
||||
import { commandFocusedGuest, notePreviewGuestHidden } from './preview-guest-offscreen'
|
||||
import { PreviewReachRegistry } from './preview-reach'
|
||||
import { previewHttpUrlTarget } from './preview-url-target'
|
||||
import {
|
||||
@@ -6564,47 +6565,6 @@ function sendClosePreviewRequested() {
|
||||
webContents.send('hermes:close-preview-requested')
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a browser gesture on the guest page the user is actually in, if any.
|
||||
*
|
||||
* A `<webview>` guest is its own out-of-process webContents: pointer and focus
|
||||
* events inside the page never reach the host document, so NOTHING in the
|
||||
* renderer — not `document.activeElement`, not the layout tree's hover/focus
|
||||
* ladder — can see that the user is in there. Main can: Electron tracks the
|
||||
* focused webContents across processes, which is the definition of a runtime
|
||||
* fact it owns.
|
||||
*
|
||||
* Returns false when focus is in the app's own chrome, where the renderer is
|
||||
* the one that knows which pane is active.
|
||||
*/
|
||||
function commandFocusedGuest(command: 'back' | 'forward' | 'reload'): boolean {
|
||||
const focused = electronWebContents.getFocusedWebContents()
|
||||
|
||||
if (!focused || focused.isDestroyed() || focused.getType() !== 'webview') {
|
||||
return false
|
||||
}
|
||||
|
||||
const history = focused.navigationHistory
|
||||
|
||||
if (command === 'reload') {
|
||||
focused.reload()
|
||||
} else if (command === 'back') {
|
||||
if (!history.canGoBack()) {
|
||||
return true
|
||||
}
|
||||
|
||||
history.goBack()
|
||||
} else {
|
||||
if (!history.canGoForward()) {
|
||||
return true
|
||||
}
|
||||
|
||||
history.goForward()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the renderer to run a browser-navigation gesture on its focused preview
|
||||
* pane. `reload` also has an app-level fallback (reload the window); `back` and
|
||||
@@ -6613,7 +6573,7 @@ function commandFocusedGuest(command: 'back' | 'forward' | 'reload'): boolean {
|
||||
function sendPreviewNavCommand(command: 'back' | 'forward' | 'reload') {
|
||||
// The user is inside the page itself — main is the only party that can see
|
||||
// that, so act here and never round-trip.
|
||||
if (commandFocusedGuest(command)) {
|
||||
if (commandFocusedGuest(command, electronWebContents.getFocusedWebContents())) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -17165,6 +17125,16 @@ ipcMain.on('hermes:previewShortcutActive', (_event, active) => {
|
||||
previewShortcutActive = Boolean(active)
|
||||
})
|
||||
|
||||
// The host renderer reports which of its Browser guests are off screen (a
|
||||
// hidden session's kept-alive page), so focused-guest gestures skip them.
|
||||
ipcMain.on('hermes:preview-guest-hidden', (event, payload) => {
|
||||
notePreviewGuestHidden(
|
||||
event.sender,
|
||||
electronWebContents.fromId(Number(payload?.webContentsId)),
|
||||
Boolean(payload?.hidden)
|
||||
)
|
||||
})
|
||||
|
||||
ipcMain.on('hermes:f12ShortcutActive', (event, active) => {
|
||||
if (active) {
|
||||
f12ShortcutActiveWindows.add(event.sender.id)
|
||||
|
||||
@@ -407,6 +407,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
return () => ipcRenderer.removeListener('hermes:f12-shortcut', listener)
|
||||
},
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
setPreviewGuestHidden: (webContentsId, hidden) =>
|
||||
ipcRenderer.send('hermes:preview-guest-hidden', { webContentsId, hidden: Boolean(hidden) }),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
mcpOauth: {
|
||||
// One-shot loopback listener for MCP OAuth against remote backends: bind
|
||||
|
||||
76
apps/desktop/electron/preview-guest-offscreen.test.ts
Normal file
76
apps/desktop/electron/preview-guest-offscreen.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { commandFocusedGuest, notePreviewGuestHidden } from './preview-guest-offscreen'
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function fakeContents(type: 'webview' | 'window', hostWebContents?: unknown) {
|
||||
const history = {
|
||||
canGoBack: vi.fn(() => true),
|
||||
canGoForward: vi.fn(() => true),
|
||||
goBack: vi.fn(),
|
||||
goForward: vi.fn()
|
||||
}
|
||||
|
||||
return {
|
||||
id: nextId++,
|
||||
hostWebContents,
|
||||
navigationHistory: history,
|
||||
reload: vi.fn(),
|
||||
getType: () => type,
|
||||
isDestroyed: () => false,
|
||||
once: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
type Fake = ReturnType<typeof fakeContents>
|
||||
const asContents = (fake: Fake) => fake as never
|
||||
|
||||
describe('focused preview guest gestures', () => {
|
||||
it('navigates the focused guest its host reports on screen', () => {
|
||||
const host = fakeContents('window')
|
||||
const guest = fakeContents('webview', host)
|
||||
|
||||
notePreviewGuestHidden(asContents(host), asContents(guest), false)
|
||||
|
||||
expect(commandFocusedGuest('back', asContents(guest))).toBe(true)
|
||||
expect(guest.navigationHistory.goBack).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// A hidden session's kept Browser is still Chromium's focused webContents
|
||||
// after the switch: a mouse back on the session now on screen must not walk
|
||||
// that page's history (or reload it) — it falls through to the renderer.
|
||||
it('leaves a focused guest its host reported hidden untouched', () => {
|
||||
const host = fakeContents('window')
|
||||
const guest = fakeContents('webview', host)
|
||||
|
||||
notePreviewGuestHidden(asContents(host), asContents(guest), true)
|
||||
|
||||
expect(commandFocusedGuest('back', asContents(guest))).toBe(false)
|
||||
expect(commandFocusedGuest('forward', asContents(guest))).toBe(false)
|
||||
expect(commandFocusedGuest('reload', asContents(guest))).toBe(false)
|
||||
expect(guest.navigationHistory.goBack).not.toHaveBeenCalled()
|
||||
expect(guest.navigationHistory.goForward).not.toHaveBeenCalled()
|
||||
expect(guest.reload).not.toHaveBeenCalled()
|
||||
|
||||
// Shown again: the user is back in it.
|
||||
notePreviewGuestHidden(asContents(host), asContents(guest), false)
|
||||
expect(commandFocusedGuest('back', asContents(guest))).toBe(true)
|
||||
expect(guest.navigationHistory.goBack).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('takes visibility only from the renderer that embeds the guest', () => {
|
||||
const host = fakeContents('window')
|
||||
const stranger = fakeContents('window')
|
||||
const guest = fakeContents('webview', host)
|
||||
|
||||
notePreviewGuestHidden(asContents(stranger), asContents(guest), true)
|
||||
|
||||
expect(commandFocusedGuest('back', asContents(guest))).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves app chrome focus to the renderer', () => {
|
||||
expect(commandFocusedGuest('back', asContents(fakeContents('window')))).toBe(false)
|
||||
expect(commandFocusedGuest('back', null)).toBe(false)
|
||||
})
|
||||
})
|
||||
80
apps/desktop/electron/preview-guest-offscreen.ts
Normal file
80
apps/desktop/electron/preview-guest-offscreen.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Preview guests that are mounted but OFF SCREEN.
|
||||
*
|
||||
* A hidden session's Browser stays mounted (hidden, inert) so returning to
|
||||
* that session shows the same page. Chromium still tracks its guest as the
|
||||
* focused webContents after the switch — nothing on the host side moves a
|
||||
* guest's webContents focus when its `<webview>` is hidden — so a gesture
|
||||
* that acts on "the focused guest" (mouse back/forward, swipe, ⌘R) would
|
||||
* navigate a page the user cannot see, in a session they have left.
|
||||
*
|
||||
* Only the host renderer knows which `<webview>` is on screen (its layout
|
||||
* tree, not Chromium, decides), so it reports each guest's visibility here
|
||||
* and main skips a hidden one; the gesture then falls through to the
|
||||
* renderer, which routes it to the visible pane.
|
||||
*/
|
||||
|
||||
import type { WebContents } from 'electron'
|
||||
|
||||
type GuestCommand = 'back' | 'forward' | 'reload'
|
||||
|
||||
const hiddenGuests = new Set<number>()
|
||||
const watchedGuests = new WeakSet<WebContents>()
|
||||
|
||||
/** Record what the host renderer `sender` reports for one of ITS guests. A
|
||||
* renderer may only speak for the guests it embeds. */
|
||||
export function notePreviewGuestHidden(sender: WebContents, guest: null | undefined | WebContents, hidden: boolean) {
|
||||
if (!guest || guest.isDestroyed() || guest.getType() !== 'webview' || guest.hostWebContents !== sender) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!hidden) {
|
||||
hiddenGuests.delete(guest.id)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
hiddenGuests.add(guest.id)
|
||||
|
||||
if (!watchedGuests.has(guest)) {
|
||||
watchedGuests.add(guest)
|
||||
guest.once('destroyed', () => hiddenGuests.delete(guest.id))
|
||||
}
|
||||
}
|
||||
|
||||
export function isPreviewGuestHidden(guest: WebContents): boolean {
|
||||
return hiddenGuests.has(guest.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a browser gesture on the guest page the user is actually in, if any.
|
||||
*
|
||||
* A `<webview>` guest is its own out-of-process webContents: pointer and focus
|
||||
* events inside the page never reach the host document, so NOTHING in the
|
||||
* renderer — not `document.activeElement`, not the layout tree's hover/focus
|
||||
* ladder — can see that the user is in there. Main can: Electron tracks the
|
||||
* focused webContents across processes. A guest its host reported hidden is
|
||||
* not "the page the user is in", whatever Chromium's focus says.
|
||||
*
|
||||
* Returns false when focus is in the app's own chrome (or on a hidden guest),
|
||||
* where the renderer is the one that knows which pane is active.
|
||||
*/
|
||||
export function commandFocusedGuest(command: GuestCommand, focused: null | undefined | WebContents): boolean {
|
||||
if (!focused || focused.isDestroyed() || focused.getType() !== 'webview' || isPreviewGuestHidden(focused)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const history = focused.navigationHistory
|
||||
|
||||
if (command === 'reload') {
|
||||
focused.reload()
|
||||
} else if (command === 'back') {
|
||||
if (history.canGoBack()) {
|
||||
history.goBack()
|
||||
}
|
||||
} else if (history.canGoForward()) {
|
||||
history.goForward()
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* A Browser guest that is mounted but OFF SCREEN — an inactive tab, a
|
||||
* collapsed side, or a hidden session's kept-alive page — must not act like
|
||||
* the page the user is on.
|
||||
*
|
||||
* Chromium keeps tracking a hidden guest as the focused webContents (nothing
|
||||
* moves a guest's focus when its `<webview>` is hidden), so main's
|
||||
* focused-guest gestures (mouse back/forward, swipe, ⌘R) would navigate it.
|
||||
* Only this renderer knows what is on screen, so it tells main.
|
||||
*/
|
||||
|
||||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
|
||||
|
||||
export interface OffscreenGuest {
|
||||
getWebContentsId?: () => number
|
||||
}
|
||||
|
||||
/** The guest's webContents id, or null before attach / after removal (Electron
|
||||
* throws rather than returning nothing). */
|
||||
function guestId(webview: null | OffscreenGuest): null | number {
|
||||
try {
|
||||
return webview?.getWebContentsId?.() ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function usePreviewGuestOffscreen(webviewRef: RefObject<null | OffscreenGuest>): void {
|
||||
const hidden = !usePaneVisible()
|
||||
|
||||
useEffect(() => {
|
||||
const id = guestId(webviewRef.current)
|
||||
|
||||
if (id !== null) {
|
||||
window.hermesDesktop?.setPreviewGuestHidden?.(id, hidden)
|
||||
}
|
||||
}, [hidden, webviewRef])
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { onComposerAttachImagesRequest } from '@/app/chat/composer/focus'
|
||||
import { PaneVisibleContext } from '@/components/pane-shell/pane-visibility'
|
||||
import { $previewTabs, closeRightRail, openPreview, previewTabId } from '@/store/preview'
|
||||
import { $connection, $selectedStoredSessionId } from '@/store/session'
|
||||
|
||||
@@ -888,6 +889,62 @@ describe('PreviewPane guest external handoff', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('PreviewPane off-screen guest', () => {
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
|
||||
const target = {
|
||||
kind: 'url',
|
||||
label: 'Preview',
|
||||
source: 'http://localhost:8502',
|
||||
url: 'http://localhost:8502'
|
||||
} as const
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
|
||||
if (initialHermesDesktop) {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
} else {
|
||||
delete desktopWindow.hermesDesktop
|
||||
}
|
||||
})
|
||||
|
||||
// Chromium keeps a hidden guest as the focused webContents, so main's
|
||||
// mouse back / ⌘R would act on a page the user cannot see unless the pane
|
||||
// tells main the guest left the screen — and that it came back.
|
||||
it('tells main when its guest leaves the screen and when it returns', async () => {
|
||||
const setPreviewGuestHidden = vi.fn()
|
||||
desktopWindow.hermesDesktop = { setPreviewGuestHidden } as unknown as Window['hermesDesktop']
|
||||
|
||||
const pane = (visible: boolean) => (
|
||||
<PaneVisibleContext value={visible}>
|
||||
<PreviewPane tabId="url:offscreen" target={target} />
|
||||
</PaneVisibleContext>
|
||||
)
|
||||
|
||||
let rendered!: ReturnType<typeof render>
|
||||
|
||||
await act(async () => {
|
||||
rendered = render(pane(true))
|
||||
})
|
||||
|
||||
const webview = rendered.container.querySelector('webview') as HTMLElement & { getWebContentsId?: () => number }
|
||||
webview.getWebContentsId = () => 41
|
||||
setPreviewGuestHidden.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
rendered.rerender(pane(false))
|
||||
})
|
||||
expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, true)
|
||||
|
||||
await act(async () => {
|
||||
rendered.rerender(pane(true))
|
||||
})
|
||||
expect(setPreviewGuestHidden).toHaveBeenLastCalledWith(41, false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PreviewPane local HTML Render|Source toggle', () => {
|
||||
const target = {
|
||||
kind: 'file' as const,
|
||||
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
import { type ConsoleEntry } from './preview-console-state'
|
||||
import { previewConsoleState } from './preview-console-store'
|
||||
import { LocalFilePreview, PreviewEmptyState, PreviewModeSwitcher } from './preview-file'
|
||||
import { usePreviewGuestOffscreen } from './preview-guest-offscreen'
|
||||
import { type PreviewInputEvent, registerPreviewInput, toWebviewInputSpace } from './preview-input'
|
||||
import { PREVIEW_BROWSER_ATTR, registerPreviewNav } from './preview-nav'
|
||||
import { registerPreviewPageReader } from './preview-reader'
|
||||
@@ -275,6 +276,7 @@ export function PreviewPane({
|
||||
const lastRestartEventRef = useRef('')
|
||||
const previewContentRef = useRef<HTMLDivElement | null>(null)
|
||||
const webviewRef = useRef<PreviewWebview | null>(null)
|
||||
usePreviewGuestOffscreen(webviewRef)
|
||||
const previewServerRestart = useStore($previewServerRestart)
|
||||
const consoleHeight = useStore(consoleState.$height)
|
||||
const consoleOpen = useStore(consoleState.$open)
|
||||
|
||||
@@ -189,6 +189,36 @@ it('keeps a live body continuously connected across replacement IDs, ancestry an
|
||||
expect(disconnected).not.toContain(page)
|
||||
})
|
||||
|
||||
// `inert` does not move focus off an element that already holds it: a hidden
|
||||
// session's kept Browser would keep taking the keystrokes meant for the chat
|
||||
// now on screen. A host going hidden hands focus back to the document.
|
||||
it('releases focus held inside a live body when its host is hidden', () => {
|
||||
registerPane('live')
|
||||
registerPane('plain', false)
|
||||
setTree(group(['live'], { id: 'shown' }))
|
||||
render(<LayoutTreeRoot />)
|
||||
const page = guest()!
|
||||
const input = page.querySelector('input')!
|
||||
const doc = page.ownerDocument
|
||||
|
||||
input.focus()
|
||||
expect(doc.activeElement).toBe(input)
|
||||
|
||||
setTree(group(['plain'], { id: 'other-session' }))
|
||||
expect(guest()).toBe(page)
|
||||
expect(page.closest('[data-pane-hidden]')?.hasAttribute('inert')).toBe(true)
|
||||
expect(page.contains(doc.activeElement)).toBe(false)
|
||||
|
||||
// Focus elsewhere is not the hidden host's to take: hiding again leaves it.
|
||||
setTree(group(['live'], { id: 'shown-again' }))
|
||||
const outside = doc.createElement('button')
|
||||
doc.body.append(outside)
|
||||
outside.focus()
|
||||
setTree(group(['plain'], { id: 'other-session-again' }))
|
||||
expect(doc.activeElement).toBe(outside)
|
||||
outside.remove()
|
||||
})
|
||||
|
||||
it('destroys removed contributions and does not revive their old activation on re-registration', () => {
|
||||
const remove = registerPane('live')
|
||||
registerPane('plain', false)
|
||||
|
||||
@@ -125,6 +125,18 @@ const KeepAlivePaneHost = memo(function KeepAlivePaneHost({ pane, placement, epo
|
||||
|
||||
useResizeObserver(rememberSize, ref)
|
||||
|
||||
// A host going hidden gives up focus. `inert` alone leaves the focused
|
||||
// element (a webview guest) as activeElement, so keystrokes would keep
|
||||
// landing in a page the user can no longer see — a hidden session's kept
|
||||
// Browser. Blurring hands focus back to the document.
|
||||
useLayoutEffect(() => {
|
||||
const active = document.activeElement
|
||||
|
||||
if (!placement.visible && active instanceof HTMLElement && ref.current?.contains(active)) {
|
||||
active.blur()
|
||||
}
|
||||
}, [placement.visible])
|
||||
|
||||
return (
|
||||
<div
|
||||
{...hiddenPaneProps(!placement.visible)}
|
||||
|
||||
2
apps/desktop/src/global.d.ts
vendored
2
apps/desktop/src/global.d.ts
vendored
@@ -407,6 +407,8 @@ declare global {
|
||||
}) => void
|
||||
) => () => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
/** Tell main a preview guest is off screen, so focused-guest gestures skip it. */
|
||||
setPreviewGuestHidden?: (webContentsId: number, hidden: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
onExternalOpenFailed?: (callback: (payload: ExternalOpenFailedPayload) => void) => () => void
|
||||
/** One-shot loopback callback listener for MCP OAuth against remote
|
||||
|
||||
Reference in New Issue
Block a user