fix(desktop): keep browser input out of the composer

Unhandled webview keys could be redispatched into the active host window, allowing agent Enter to submit a draft prompt. Give pages first refusal for ordinary shortcuts and contain automation at the guest boundary.
This commit is contained in:
Mohamed Boudra
2026-07-10 14:29:43 +02:00
parent a622860a3e
commit d93d6a268a
30 changed files with 2517 additions and 453 deletions

View File

@@ -141,8 +141,22 @@ Electron wrapper for macOS, Linux, and Windows.
> **In-app browser profile.** Every browser guest uses one stable persistent Electron session, so cookies, authentication, cache, and site storage are shared across tabs, workspaces, and desktop windows and survive tab or app closure. Browser identity is independent of that storage partition: after `did-attach`, the renderer explicitly registers its browser id, workspace id, and guest `WebContents` id, and main accepts the registration only when that guest belongs to the calling renderer and the shared profile. Settings > General > Clear browser data is the sole profile-deletion path; it clears the shared session and reloads live guests without deleting saved tabs or URLs.
>
> **In-app browser window opens.** Ordinary link opens, including Shift-clicked links, become Paseo workspace tabs. Script-created opens with popup features or a named window target and POST-backed opens remain secured Electron child windows in the shared browser profile, preserving `window.opener`, `postMessage`, named-window reuse, request bodies, and `window.close()` for OAuth, payment, and similar popup protocols. Unsupported URL schemes are denied before either path.
>
> **In-app browser ownership.** Each registered guest records its owning host window. The active browser is keyed by `(host window, workspace)`, and application-menu Reload / Force Reload resolve only within the window Electron supplies to the menu callback. A non-null active update must name a browser owned by that host; a null update clears only that host/workspace. Browser automation continues to target explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`.
>
> **Browser keyboard boundary.** Guest pages receive renderer-published shortcuts first. `Cmd/Ctrl+L` and `Cmd/Ctrl+R` are explicit guest-shell reservations; ordinary Paseo shortcuts run only after the page declines them. The sandboxed guest preload runs in every frame so focused iframes use the same boundary, while Node integration remains disabled. Human guest input disables Electron's menu fallback for plain keys. Agent-generated keys use guest `sendInputEvent` with `skipIfUnhandled`, so an unhandled Enter stops at the guest instead of reaching the host composer. Main selects the preload; it exposes no APIs to guest pages.
> **In-app browser targets are not yet per-window.** Browser webviews are still tracked by one process-global registry that keeps a single current `WebContents` per browser id. Human focus records the workspace-active browser for UI state and `list_tabs` reporting, while agent automation targets explicit browser ids returned by `browser_new_tab` or `browser_list_tabs`. Explicit attached-guest registration prevents concurrent windows from swapping different browser ids, but rendering the same saved browser tab in multiple windows can still make menu actions target the most recently registered guest. Making the registry window-scoped remains a follow-up.
```text
Human key -> guest WebContents
|-- Cmd/Ctrl+T/L/R ----------> reserved browser-shell action
`-- page keydown
|-- page prevents ------> page owns it
`-- published shortcut -> guest preload -> IPC(browserId) -> Paseo resolver
Agent browser_keypress -> guest sendInputEvent(skipIfUnhandled)
|-- guest handles ------------> page owns it
`-- guest does not handle ----> stop; never redispatch to the host window
```
### `packages/website` — Marketing site

View File

@@ -13,7 +13,12 @@ It validates the compositor behavior that unit tests cannot see:
- both viewport `capturePage` and full-page CDP screenshots return real pixels from
the permanent production parking state;
- guest background throttling can be disabled once at attach without per-capture
renderer coordination.
renderer coordination;
- the real-Electron host-composer sentinel proves guest Enter cannot submit a focused
host composer;
- the automation group loads the compiled production keyboard boundary and guest
preload, then proves a page-handled shortcut stays in the page while an unhandled
shortcut crosses once.
Run it with the repo Electron:
@@ -21,9 +26,11 @@ Run it with the repo Electron:
npm run capture-harness --workspace=@getpaseo/desktop
```
Run the browser automation fixture with:
Build the desktop main process before the automation group so its production guest
preload is available:
```bash
npm run build:main --workspace=@getpaseo/desktop
PASEO_CAPTURE_HARNESS_GROUP=automation npm run capture-harness --workspace=@getpaseo/desktop
```
@@ -43,6 +50,9 @@ ARIA-like snapshot text includes headings, static text, and controls; refs survi
`pushState` when the element still matches; same-URL rerenders stale old refs; and a
file-input ref can be resolved to a CDP backend node id for upload. It also verifies
page-context evaluation, including passing a resolved ref element as the function argument.
Keyboard containment runs last because the host-composer sentinel intentionally leaves
native focus in the host. It reuses an existing fixture button: adding a test-only control
changes the inline fixture geometry exercised by the earlier actionability checks.
On macOS the harness process must set `app.setActivationPolicy("accessory")` and
hide the Dock icon before creating any window. `showInactive()` only prevents window

View File

@@ -1,5 +1,6 @@
import { Platform } from "react-native";
import { getElectronHost } from "@/desktop/electron/host";
import type { BrowserKeyboardPolicy } from "@/keyboard/browser-shortcuts";
import type { SessionInboundMessage, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type BrowserAutomationExecuteRequest = Extract<
@@ -24,7 +25,6 @@ export interface DesktopDialogOpenOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
createDirectory?: boolean;
multiple?: boolean;
filters?: Array<{
name: string;
@@ -66,15 +66,13 @@ export interface DesktopEditorTargetDescriptor {
id: string;
label: string;
kind: "editor" | "file-manager";
icon: { kind: "image"; dataUrl: string } | { kind: "symbol"; name: "folder" | "terminal" };
}
export interface DesktopEditorOpenTargetInput {
editorId: string;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}
export interface DesktopEditorBridge {
@@ -122,10 +120,9 @@ export interface DesktopEventsBridge {
on?: (event: string, handler: (payload: unknown) => void) => Promise<() => void> | (() => void);
}
export interface DesktopBrowserShortcutEvent {
browserId?: string;
action: "focus-url";
}
export type DesktopBrowserShortcutEvent =
| { browserId?: string; action: "focus-url" }
| { browserId: string; action: "new-tab" };
export interface DesktopBrowserNewTabRequestEvent {
sourceBrowserId: string;
@@ -139,6 +136,7 @@ export interface DesktopAttachedBrowserRegistration {
}
export interface DesktopBrowserBridge {
setShortcutPolicy?: (input: BrowserKeyboardPolicy) => Promise<void>;
readonly profilePartition?: string;
registerAttachedBrowser?: (input: DesktopAttachedBrowserRegistration) => Promise<void>;
unregisterWorkspaceBrowser?: (browserId: string) => Promise<void>;

View File

@@ -7,11 +7,17 @@ import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store"
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import {
type ChordState,
type KeyboardShortcutInput,
resolveKeyboardShortcut,
buildEffectiveBindings,
getWorkspaceIndexJumpModifierKey,
} from "@/keyboard/keyboard-shortcuts";
import { resolveKeyboardFocusScope } from "@/keyboard/focus-scope";
import {
buildBrowserShortcutPolicy,
parseBrowserShortcutInput,
} from "@/keyboard/browser-shortcuts";
import type { KeyboardFocusScope, KeyboardShortcutPayload } from "@/keyboard/actions";
import {
routeKeyboardShortcut,
type ShortcutAction,
@@ -47,6 +53,8 @@ export function useKeyboardShortcuts({
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac";
const chordStateRef = useRef<ChordState>({
candidateIndices: [],
step: 0,
@@ -62,14 +70,27 @@ export function useKeyboardShortcuts({
}
}, [activeWorkspaceSelection]);
useEffect(() => {
if (!isDesktopApp) {
return;
}
const prefixes =
enabled && !isMobile
? buildBrowserShortcutPolicy({
bindings,
isMac,
isDesktop: isDesktopApp,
})
: [];
void getDesktopHost()?.browser?.setShortcutPolicy?.({ prefixes });
}, [bindings, enabled, isDesktopApp, isMac, isMobile]);
useEffect(() => {
if (!enabled) return;
if (isNative) return;
if (isMobile) return;
const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac";
// Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
// desktop Mac, Ctrl on desktop non-Mac). The store ORs altDown/cmdOrCtrlDown
@@ -106,7 +127,10 @@ export function useKeyboardShortcuts({
"cycle-theme": cycleTheme,
};
const performShortcutAction = (action: ShortcutAction, event: KeyboardEvent): boolean => {
const performShortcutAction = (
action: ShortcutAction,
event: KeyboardEvent | null,
): boolean => {
switch (action.kind) {
case "none":
return false;
@@ -137,7 +161,7 @@ export function useKeyboardShortcuts({
callbacksByName[action.name]?.();
return true;
case "command-center-toggle": {
if (action.nextOpen) {
if (action.nextOpen && event) {
captureCommandCenterFocusRestore(event);
}
useKeyboardShortcutsStore.getState().setCommandCenterOpen(action.nextOpen);
@@ -149,6 +173,80 @@ export function useKeyboardShortcuts({
}
};
const routeAndPerformShortcut = (input: {
action: string;
payload: KeyboardShortcutPayload;
domEvent: KeyboardEvent | null;
}): boolean => {
const store = useKeyboardShortcutsStore.getState();
const shortcutAction = routeKeyboardShortcut(
{ action: input.action, payload: input.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
return performShortcutAction(shortcutAction, input.domEvent);
};
const resolveAndPerformShortcut = (input: {
event: KeyboardShortcutInput;
focusScope: KeyboardFocusScope;
domEvent: KeyboardEvent | null;
}) => {
const store = useKeyboardShortcutsStore.getState();
const result = resolveKeyboardShortcut({
event: input.event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope: input.focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings,
});
chordStateRef.current = result.nextChordState;
if (result.preventDefault && input.domEvent) {
input.domEvent.preventDefault();
input.domEvent.stopPropagation();
}
if (!result.match) {
return;
}
const handled = routeAndPerformShortcut({
action: result.match.action,
payload: result.match.payload,
domEvent: input.domEvent,
});
if (!handled || !input.domEvent) {
return;
}
if (result.match.preventDefault) {
input.domEvent.preventDefault();
}
if (result.match.stopPropagation) {
input.domEvent.stopPropagation();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (!shouldHandle()) {
return;
@@ -180,60 +278,11 @@ export function useKeyboardShortcuts({
target: event.target,
commandCenterOpen: store.commandCenterOpen,
});
const result = resolveKeyboardShortcut({
resolveAndPerformShortcut({
event,
context: {
isMac,
isDesktop: isDesktopApp,
focusScope,
commandCenterOpen: store.commandCenterOpen,
},
chordState: chordStateRef.current,
onChordReset: () => {
chordStateRef.current = {
candidateIndices: [],
step: 0,
timeoutId: null,
};
},
bindings,
focusScope,
domEvent: event,
});
chordStateRef.current = result.nextChordState;
if (result.preventDefault) {
event.preventDefault();
event.stopPropagation();
}
if (!result.match) {
return;
}
const shortcutAction = routeKeyboardShortcut(
{ action: result.match.action, payload: result.match.payload },
{
pathname,
isMobile,
sidebarShortcutTargets: store.sidebarShortcutWorkspaceTargets,
navigationActiveWorkspace:
keyboardWorkspaceSelectionRef.current ?? activeWorkspaceSelection,
commandCenterOpen: store.commandCenterOpen,
shortcutsDialogOpen: store.shortcutsDialogOpen,
},
);
const handled = performShortcutAction(shortcutAction, event);
if (!handled) {
return;
}
if (result.match.preventDefault) {
event.preventDefault();
}
if (result.match.stopPropagation) {
event.stopPropagation();
}
};
const handleKeyUp = (event: KeyboardEvent) => {
@@ -252,22 +301,39 @@ export function useKeyboardShortcuts({
window.addEventListener("blur", handleBlurOrHide);
document.addEventListener("visibilitychange", handleBlurOrHide);
const forwardedKeySubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-forwarded-key", (payload) => {
if (!payload || typeof payload !== "object") return;
const p = payload as Record<string, unknown>;
if (typeof p.key !== "string") return;
window.dispatchEvent(
new KeyboardEvent("keydown", {
key: p.key,
code: typeof p.code === "string" ? p.code : "",
metaKey: p.meta === true,
ctrlKey: p.control === true,
shiftKey: p.shift === true,
altKey: p.alt === true,
bubbles: true,
}),
);
const browserShortcutSubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-shortcut-input", (payload) => {
const input = parseBrowserShortcutInput(payload);
if (!input) {
return;
}
resolveAndPerformShortcut({
event: input,
focusScope: "browser",
domEvent: null,
});
})
: null;
const browserReservedShortcutSubscription = isElectronRuntime()
? getDesktopHost()?.events?.on?.("browser-shortcut", (payload) => {
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
return;
}
if (!("action" in payload) || payload.action !== "new-tab") {
return;
}
if (
!("browserId" in payload) ||
typeof payload.browserId !== "string" ||
payload.browserId.length === 0
) {
return;
}
routeAndPerformShortcut({
action: "workspace.tab.new",
payload: null,
domEvent: null,
});
})
: null;
@@ -284,10 +350,15 @@ export function useKeyboardShortcuts({
window.removeEventListener("keyup", handleKeyUp, true);
window.removeEventListener("blur", handleBlurOrHide);
document.removeEventListener("visibilitychange", handleBlurOrHide);
if (typeof forwardedKeySubscription === "function") {
forwardedKeySubscription();
if (typeof browserShortcutSubscription === "function") {
browserShortcutSubscription();
} else {
void forwardedKeySubscription?.then((dispose) => dispose());
void browserShortcutSubscription?.then((dispose) => dispose());
}
if (typeof browserReservedShortcutSubscription === "function") {
browserReservedShortcutSubscription();
} else {
void browserReservedShortcutSubscription?.then((dispose) => dispose());
}
};
}, [
@@ -295,6 +366,8 @@ export function useKeyboardShortcuts({
cycleTheme,
enabled,
activeWorkspaceSelection,
isDesktopApp,
isMac,
isMobile,
openProjectPickerAction,
pathname,

View File

@@ -3,6 +3,7 @@ export type KeyboardFocusScope =
| "message-input"
| "command-center"
| "editable"
| "browser"
| "other";
export type MessageInputKeyboardActionKind =

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import { buildBrowserShortcutPolicy, parseBrowserShortcutInput } from "./browser-shortcuts";
import { buildEffectiveBindings } from "./keyboard-shortcuts";
describe("buildBrowserShortcutPolicy", () => {
it("publishes the effective browser page-first shortcut prefixes", () => {
const bindings = buildEffectiveBindings({
"workspace-tab-new-ctrl-t-non-mac": "Ctrl+Y",
"workspace-terminal-new-ctrl-shift-t-non-mac": "Ctrl+F12 Ctrl+F11",
});
const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy).toContainEqual({
alt: false,
code: "KeyY",
control: true,
key: "y",
meta: false,
shift: false,
});
expect(policy).toContainEqual({
alt: false,
code: "F12",
control: true,
meta: false,
shift: false,
});
expect(policy).toContainEqual({
alt: false,
code: "F11",
control: true,
meta: false,
shift: false,
});
});
it("rejects an entire chord when a continuation cannot cross the browser boundary", () => {
const bindings = buildEffectiveBindings({
"settings-toggle-ctrl-comma-non-mac": "Ctrl+F10 F9",
});
const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy).not.toContainEqual({
alt: false,
code: "F10",
control: true,
meta: false,
shift: false,
});
expect(policy).not.toContainEqual({
alt: false,
code: "F9",
control: false,
meta: false,
shift: false,
});
});
it("publishes Mod bindings for the current shortcut platform", () => {
const bindings = buildEffectiveBindings({
"workspace-tab-new-cmd-t-mac": "Mod+Y",
});
expect(buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true })).toContainEqual({
alt: false,
code: "KeyY",
control: false,
key: "y",
meta: true,
shift: false,
});
});
it("does not publish plain browser keys", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserShortcutPolicy({ bindings, isMac: false, isDesktop: true });
expect(policy).not.toContainEqual({
alt: false,
code: "Enter",
control: false,
meta: false,
shift: false,
});
expect(policy).not.toContainEqual({
alt: false,
code: "Slash",
control: false,
meta: false,
shift: true,
});
});
it("publishes Cmd+B with its logical key for non-QWERTY layouts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true });
expect(policy).toContainEqual({
alt: false,
code: "KeyB",
control: false,
key: "b",
meta: true,
shift: false,
});
});
it("publishes the physical code needed for macOS Option shortcuts", () => {
const bindings = buildEffectiveBindings({});
const policy = buildBrowserShortcutPolicy({ bindings, isMac: true, isDesktop: true });
expect(policy).toContainEqual({
alt: true,
code: "KeyT",
control: false,
key: "t",
meta: true,
shift: false,
});
});
});
describe("parseBrowserShortcutInput", () => {
it("normalizes browser shortcut input without losing its identity", () => {
expect(
parseBrowserShortcutInput({
browserId: "browser-1",
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
}),
).toEqual({
browserId: "browser-1",
key: "t",
code: "KeyT",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
repeat: false,
});
});
it.each([
{
name: "a missing browser identity",
payload: {
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
},
},
{
name: "a malformed repeat flag",
payload: {
browserId: "browser-1",
key: "t",
code: "KeyT",
meta: false,
control: true,
shift: false,
alt: false,
repeat: "yes",
},
},
])("rejects $name", ({ payload }) => {
expect(parseBrowserShortcutInput(payload)).toBeNull();
});
});

View File

@@ -0,0 +1,144 @@
import {
matchesKeyboardShortcutContext,
type KeyboardShortcutInput,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
import type { KeyCombo } from "./shortcut-string";
export interface BrowserShortcutPrefix {
alt: boolean;
code: string;
codeFallback?: true;
control: boolean;
key?: string;
meta: boolean;
repeat?: false;
shift: boolean;
shiftedKey?: string;
}
export interface BrowserShortcutInput extends KeyboardShortcutInput {
browserId: string;
}
interface BrowserShortcutPolicyInput {
bindings: readonly ParsedShortcutBinding[];
isMac: boolean;
isDesktop: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function parseBrowserShortcutInput(value: unknown): BrowserShortcutInput | null {
if (!isRecord(value)) {
return null;
}
const { browserId, code, key } = value;
if (typeof browserId !== "string" || browserId.length === 0) {
return null;
}
if (typeof code !== "string" || typeof key !== "string") {
return null;
}
if (
typeof value.alt !== "boolean" ||
typeof value.control !== "boolean" ||
typeof value.meta !== "boolean" ||
typeof value.shift !== "boolean" ||
(value.repeat !== undefined && typeof value.repeat !== "boolean")
) {
return null;
}
return {
browserId,
key,
code,
altKey: value.alt,
ctrlKey: value.control,
metaKey: value.meta,
shiftKey: value.shift,
repeat: value.repeat ?? false,
};
}
function prefixFromCombo(combo: KeyCombo, isMac: boolean): BrowserShortcutPrefix | null {
const prefix: BrowserShortcutPrefix = {
alt: combo.alt === true,
code: combo.code,
control: combo.ctrl === true || (!isMac && combo.mod === true),
meta: combo.meta === true || (isMac && combo.mod === true),
shift: combo.shift === true,
};
if (combo.codeFallback === true) {
prefix.codeFallback = true;
}
if (combo.key) {
prefix.key = combo.key;
}
if (combo.repeat === false) {
prefix.repeat = false;
}
if (combo.shiftedKey) {
prefix.shiftedKey = combo.shiftedKey;
}
return prefix.meta || prefix.control || prefix.alt ? prefix : null;
}
function prefixesFromBinding(
binding: ParsedShortcutBinding,
isMac: boolean,
): BrowserShortcutPrefix[] | null {
const prefixes: BrowserShortcutPrefix[] = [];
for (const combo of binding.parsedChord) {
const prefix = prefixFromCombo(combo, isMac);
if (!prefix) {
return null;
}
prefixes.push(prefix);
}
return prefixes;
}
function prefixKey(prefix: BrowserShortcutPrefix): string {
return [
prefix.code,
prefix.key ?? "",
prefix.shiftedKey ?? "",
prefix.codeFallback ?? "",
prefix.control,
prefix.meta,
prefix.alt,
prefix.shift,
prefix.repeat ?? "",
].join(":");
}
export function buildBrowserShortcutPolicy(
input: BrowserShortcutPolicyInput,
): BrowserShortcutPrefix[] {
const prefixes = new Map<string, BrowserShortcutPrefix>();
const context = {
isMac: input.isMac,
isDesktop: input.isDesktop,
focusScope: "browser" as const,
commandCenterOpen: false,
};
for (const binding of input.bindings) {
if (!matchesKeyboardShortcutContext(binding.when, context)) {
continue;
}
const bindingPrefixes = prefixesFromBinding(binding, input.isMac);
if (!bindingPrefixes) {
continue;
}
for (const prefix of bindingPrefixes) {
prefixes.set(prefixKey(prefix), prefix);
}
}
return [...prefixes.values()];
}

View File

@@ -7,10 +7,11 @@ import {
resolveKeyboardShortcut,
type ChordState,
type KeyboardShortcutContext,
type KeyboardShortcutInput,
type ParsedShortcutBinding,
} from "./keyboard-shortcuts";
function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
function keyboardInput(overrides: Partial<KeyboardShortcutInput>): KeyboardShortcutInput {
return {
key: "",
code: "",
@@ -20,7 +21,7 @@ function keyboardEvent(overrides: Partial<KeyboardEvent>): KeyboardEvent {
shiftKey: false,
repeat: false,
...overrides,
} as KeyboardEvent;
};
}
function shortcutContext(
@@ -44,14 +45,14 @@ function initialChordState(): ChordState {
}
function resolveShortcut(input: {
event: Partial<KeyboardEvent>;
event: Partial<KeyboardShortcutInput>;
context?: Partial<KeyboardShortcutContext>;
chordState?: ChordState;
onChordReset?: () => void;
bindings?: readonly ParsedShortcutBinding[];
}) {
return resolveKeyboardShortcut({
event: keyboardEvent(input.event),
event: keyboardInput(input.event),
context: shortcutContext(input.context),
chordState: input.chordState ?? initialChordState(),
onChordReset: input.onChordReset ?? (() => undefined),
@@ -60,7 +61,7 @@ function resolveShortcut(input: {
}
function expectShortcutResolution(input: {
event: Partial<KeyboardEvent>;
event: Partial<KeyboardShortcutInput>;
context?: Partial<KeyboardShortcutContext>;
action: string;
payload?: unknown;
@@ -83,7 +84,7 @@ function expectShortcutResolution(input: {
}
function expectNoShortcutResolution(input: {
event: Partial<KeyboardEvent>;
event: Partial<KeyboardShortcutInput>;
context?: Partial<KeyboardShortcutContext>;
}) {
const result = resolveShortcut({
@@ -98,7 +99,7 @@ function expectNoShortcutResolution(input: {
interface MatchingShortcutCase {
name: string;
event: Partial<KeyboardEvent>;
event: Partial<KeyboardShortcutInput>;
context?: Partial<KeyboardShortcutContext>;
action: string;
payload?: unknown;
@@ -108,7 +109,7 @@ interface MatchingShortcutCase {
interface NonMatchingShortcutCase {
name: string;
event: Partial<KeyboardEvent>;
event: Partial<KeyboardShortcutInput>;
context?: Partial<KeyboardShortcutContext>;
}
@@ -548,6 +549,14 @@ describe("keyboard-shortcuts", () => {
expect(secondResult.nextChordState).toEqual(initialChordState());
});
it("resolves a browser-origin shortcut with browser focus instead of host focus", () => {
expectShortcutResolution({
event: { key: "t", code: "KeyT", ctrlKey: true },
context: { isDesktop: true, focusScope: "browser" },
action: "workspace.tab.new",
});
});
it("schedules a chord reset timeout for advancing candidates", () => {
vi.useFakeTimers();

View File

@@ -18,6 +18,16 @@ export interface KeyboardShortcutContext {
commandCenterOpen: boolean;
}
export interface KeyboardShortcutInput {
key: string;
code: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
repeat: boolean;
}
export interface KeyboardShortcutMatch {
action: KeyboardActionId;
payload: KeyboardShortcutPayload;
@@ -1060,8 +1070,8 @@ export function buildEffectiveBindings(overrides: Record<string, string>): Parse
// --- Matching engine ---
function parseDigit(event: KeyboardEvent): number | null {
const code = event.code ?? "";
function parseDigit(event: KeyboardShortcutInput): number | null {
const code = event.code;
if (code.startsWith("Digit")) {
const value = Number(code.slice("Digit".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
@@ -1070,14 +1080,14 @@ function parseDigit(event: KeyboardEvent): number | null {
const value = Number(code.slice("Numpad".length));
return Number.isFinite(value) && value >= 1 && value <= 9 ? value : null;
}
const key = event.key ?? "";
const key = event.key;
if (key >= "1" && key <= "9") {
return Number(key);
}
return null;
}
function matchesKeyOrCode(combo: KeyCombo, event: KeyboardEvent): boolean {
function matchesKeyOrCode(combo: KeyCombo, event: KeyboardShortcutInput): boolean {
if (combo.key === undefined) {
return event.code === combo.code;
}
@@ -1095,7 +1105,7 @@ function matchesKeyOrCode(combo: KeyCombo, event: KeyboardEvent): boolean {
return combo.codeFallback === true && event.code === combo.code;
}
function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): boolean {
function matchesCombo(combo: KeyCombo, event: KeyboardShortcutInput, isMac: boolean): boolean {
if (combo.mod) {
if (isMac) {
if (!event.metaKey) return false;
@@ -1118,7 +1128,10 @@ function matchesCombo(combo: KeyCombo, event: KeyboardEvent, isMac: boolean): bo
return matchesKeyOrCode(combo, event);
}
function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutContext): boolean {
export function matchesKeyboardShortcutContext(
when: ShortcutWhen | undefined,
context: KeyboardShortcutContext,
): boolean {
if (!when) return true;
if (when.mac !== undefined && when.mac !== context.isMac) return false;
if (when.desktop !== undefined && when.desktop !== context.isDesktop) return false;
@@ -1136,7 +1149,7 @@ function matchesWhen(when: ShortcutWhen | undefined, context: KeyboardShortcutCo
function resolvePayload(
def: ShortcutPayloadDef | undefined,
event: KeyboardEvent,
event: KeyboardShortcutInput,
): KeyboardShortcutPayload {
if (!def) return null;
switch (def.type) {
@@ -1187,7 +1200,7 @@ function helpMatchesPlatform(
function buildMatchFromBinding(
binding: ParsedShortcutBinding,
event: KeyboardEvent,
event: KeyboardShortcutInput,
): KeyboardShortcutMatch {
return {
action: binding.action,
@@ -1198,7 +1211,7 @@ function buildMatchFromBinding(
}
function resolveInitialChordStep(input: {
event: KeyboardEvent;
event: KeyboardShortcutInput;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
@@ -1220,7 +1233,7 @@ function resolveInitialChordStep(input: {
if (!matchesCombo(firstCombo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
if (!matchesKeyboardShortcutContext(binding.when, context)) {
continue;
}
if (binding.parsedChord.length > 1) {
@@ -1252,7 +1265,7 @@ function resolveInitialChordStep(input: {
}
function resolveAdvancingChordStep(input: {
event: KeyboardEvent;
event: KeyboardShortcutInput;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;
@@ -1278,7 +1291,7 @@ function resolveAdvancingChordStep(input: {
if (!matchesCombo(combo, event, context.isMac)) {
continue;
}
if (!matchesWhen(binding.when, context)) {
if (!matchesKeyboardShortcutContext(binding.when, context)) {
continue;
}
if (chordState.step + 1 === binding.parsedChord.length) {
@@ -1317,7 +1330,7 @@ function resolveAdvancingChordStep(input: {
}
export function resolveKeyboardShortcut(input: {
event: KeyboardEvent;
event: KeyboardShortcutInput;
context: KeyboardShortcutContext;
chordState: ChordState;
onChordReset: () => void;

View File

@@ -38,12 +38,40 @@
</style>
</head>
<body>
<form id="host-composer-form">
<label for="host-composer">Host composer</label>
<input id="host-composer" name="host-composer" autocomplete="off" />
</form>
<div id="paseo-browser-resident-webviews" aria-hidden="true"></div>
<script>
const RESIDENT_VIEWPORT_WIDTH = 1280;
const RESIDENT_VIEWPORT_HEIGHT = 800;
const host = document.getElementById("paseo-browser-resident-webviews");
const hostComposer = document.getElementById("host-composer");
const hostComposerForm = document.getElementById("host-composer-form");
let hostComposerEnterEvents = 0;
let hostComposerSubmissions = 0;
let hostBrowserShortcutEvents = 0;
hostComposer.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
hostComposerEnterEvents += 1;
}
});
hostComposerForm.addEventListener("submit", (event) => {
event.preventDefault();
hostComposerSubmissions += 1;
});
window.addEventListener("keydown", (event) => {
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "b"
) {
hostBrowserShortcutEvents += 1;
}
});
const params = new URLSearchParams(window.location.search);
const requestedWebviewCount = params.has("webviewCount")
? Number(params.get("webviewCount"))
@@ -356,6 +384,23 @@
typeof webview.getWebContentsId === "function" ? webview.getWebContentsId() : null,
);
},
focusHostComposer() {
hostComposer.focus();
return document.activeElement?.id || null;
},
hostComposerState() {
return {
activeElementId: document.activeElement?.id || null,
enterEvents: hostComposerEnterEvents,
submissions: hostComposerSubmissions,
};
},
resetHostBrowserShortcutState() {
hostBrowserShortcutEvents = 0;
},
hostBrowserShortcutState() {
return { events: hostBrowserShortcutEvents };
},
addWebview(sourceUrl) {
return appendHarnessWebview(sourceUrl || params.get("targetUrl") || "bright.html");
},

View File

@@ -2,10 +2,24 @@ const fs = require("node:fs");
const fsp = require("node:fs/promises");
const http = require("node:http");
const path = require("node:path");
const { app, BrowserWindow, nativeImage, screen, session } = require("electron");
const { isDeepStrictEqual } = require("node:util");
const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, session } = require("electron");
const ROOT = __dirname;
const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out");
const PRODUCTION_BROWSER_KEYBOARD_DIR = path.join(
ROOT,
"..",
"dist",
"features",
"browser-keyboard",
);
const PRODUCTION_BROWSER_GUEST_PRELOAD_PATH = path.join(
PRODUCTION_BROWSER_KEYBOARD_DIR,
"guest-preload.js",
);
const PRODUCTION_BROWSER_KEYBOARD_PATH = path.join(PRODUCTION_BROWSER_KEYBOARD_DIR, "index.js");
const BROWSER_SHORTCUT_INPUT_CHANNEL = "paseo:browser-shortcut-input";
const VIEWPORT_WIDTH = 1280;
const VIEWPORT_HEIGHT = 800;
const FULL_PAGE_HEIGHT = 1600;
@@ -467,10 +481,23 @@ async function captureFullPageSequence(contents) {
return await captureFullPage(contents);
}
function installHarnessWebviewGuards(win) {
win.webContents.on("will-attach-webview", (_event, webPreferences) => {
function installHarnessWebviewGuards(win, options = {}) {
win.webContents.on("will-attach-webview", (_event, webPreferences, params) => {
webPreferences.nodeIntegration = false;
webPreferences.contextIsolation = true;
if (options.preloadPath) {
webPreferences.nodeIntegrationInSubFrames = false;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.sandbox = true;
webPreferences.webSecurity = true;
webPreferences.webviewTag = false;
webPreferences.allowRunningInsecureContent = false;
delete webPreferences.preload;
delete params.preload;
delete webPreferences.preloadURL;
delete params.preloadURL;
webPreferences.preload = options.preloadPath;
}
});
}
@@ -1200,6 +1227,24 @@ function automationFixtureUrl() {
</main>
<script>
window.fixtureLog = [];
window.preventBrowserShortcut = false;
document.addEventListener("keydown", (event) => {
if (
(event.metaKey || event.ctrlKey) &&
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "b"
) {
if (window.preventBrowserShortcut) {
event.preventDefault();
}
window.fixtureLog.push({
event: "shortcut-b",
defaultPrevented: event.defaultPrevented,
trusted: event.isTrusted,
});
}
});
document.getElementById("save").addEventListener("click", (event) => {
window.fixtureLog.push({
event: "click-save",
@@ -1223,6 +1268,9 @@ function automationFixtureUrl() {
document.getElementById("name").addEventListener("input", (event) => {
window.fixtureLog.push({ event: "input-name", trusted: event.isTrusted });
});
document.getElementById("name").addEventListener("keydown", (event) => {
window.fixtureLog.push({ event: "keydown-name", key: event.key, trusted: event.isTrusted });
});
document.getElementById("delayed").addEventListener("click", (event) => {
window.fixtureLog.push({ event: "click-delayed", trusted: event.isTrusted });
});
@@ -1497,6 +1545,34 @@ async function automationType(guest, refEntry, text) {
await send("Input.insertText", { text });
}
function sendContainedEnter(guest) {
guest.sendInputEvent({
type: "keyDown",
keyCode: "Enter",
// Prevent Electron from redispatching an unhandled guest key to the embedder.
skipIfUnhandled: true,
});
guest.sendInputEvent({
type: "keyUp",
keyCode: "Enter",
skipIfUnhandled: true,
});
}
function automationBrowserShortcut(guest) {
const modifiers = [process.platform === "darwin" ? "meta" : "control"];
guest.sendInputEvent({
type: "keyDown",
keyCode: "B",
modifiers,
});
guest.sendInputEvent({
type: "keyUp",
keyCode: "B",
modifiers,
});
}
async function automationEvaluate(guest, functionSource, refEntry) {
return guest.executeJavaScript(
String.raw`(async () => {
@@ -1641,8 +1717,183 @@ async function waitForAutomationLog(guest, predicate, label) {
fail(`automation log never observed ${label}`);
}
async function waitForBrowserShortcutInput(inputs, expectedCount) {
const deadline = Date.now() + 1000;
do {
if (inputs.length >= expectedCount) {
return;
}
await delay(25);
} while (Date.now() < deadline);
fail(`browser shortcut input count stayed at ${inputs.length}; expected ${expectedCount}`);
}
function installBrowserKeyboardSentinels() {
const previousApplicationMenu = Menu.getApplicationMenu();
const state = {
applicationMenuShortcutHits: 0,
guestId: null,
shortcutInputs: [],
};
const onShortcutInput = (event, input) => {
if (event.sender.id === state.guestId) {
state.shortcutInputs.push(input);
}
};
ipcMain.on(BROWSER_SHORTCUT_INPUT_CHANNEL, onShortcutInput);
Menu.setApplicationMenu(
Menu.buildFromTemplate([
{
label: "Capture harness",
submenu: [
{
label: "Browser shortcut sentinel",
accelerator: "CmdOrCtrl+B",
click: () => {
state.applicationMenuShortcutHits += 1;
},
},
],
},
]),
);
return {
state,
restore() {
ipcMain.removeListener(BROWSER_SHORTCUT_INPUT_CHANNEL, onShortcutInput);
Menu.setApplicationMenu(previousApplicationMenu);
},
};
}
async function verifyBrowserKeyboardIsolation({ guest, win, browserId, usesMeta, sentinel }) {
const checks = [];
const { shortcutInputs } = sentinel;
await win.webContents.executeJavaScript(
"window.captureHarness.resetHostBrowserShortcutState()",
true,
);
const pagePreventedTarget = await guest.executeJavaScript(
"window.preventBrowserShortcut = true; document.getElementById('save').focus(); document.activeElement.id",
true,
);
if (pagePreventedTarget !== "save") {
fail(`automation browser shortcut target was not focused: ${pagePreventedTarget}`);
}
automationBrowserShortcut(guest);
await waitForAutomationLog(
guest,
(entry) =>
entry.event === "shortcut-b" && entry.defaultPrevented === true && entry.trusted === true,
"page-prevented trusted browser shortcut",
);
await delay(100);
const pagePreventedHostState = await win.webContents.executeJavaScript(
"window.captureHarness.hostBrowserShortcutState()",
true,
);
if (
shortcutInputs.length !== 0 ||
pagePreventedHostState.events !== 0 ||
sentinel.applicationMenuShortcutHits !== 0
) {
fail(
`page-prevented browser shortcut escaped guest: inputs=${JSON.stringify(shortcutInputs)} host=${JSON.stringify(pagePreventedHostState)} menu=${sentinel.applicationMenuShortcutHits}`,
);
}
pass("automation page preventDefault keeps browser shortcut in the guest");
checks.push({ group: "automation", check: "browser-shortcut-page-prevented", pass: true });
const unhandledTarget = await guest.executeJavaScript(
"window.preventBrowserShortcut = false; document.getElementById('save').focus(); document.activeElement.id",
true,
);
if (unhandledTarget !== "save") {
fail(`automation unhandled browser shortcut target was not focused: ${unhandledTarget}`);
}
automationBrowserShortcut(guest);
await waitForAutomationLog(
guest,
(entry) =>
entry.event === "shortcut-b" && entry.defaultPrevented === false && entry.trusted === true,
"unhandled trusted browser shortcut",
);
await waitForBrowserShortcutInput(shortcutInputs, 1);
await delay(100);
const unhandledHostState = await win.webContents.executeJavaScript(
"window.captureHarness.hostBrowserShortcutState()",
true,
);
const expectedBrowserShortcutInput = {
alt: false,
browserId,
code: "KeyB",
control: !usesMeta,
key: "b",
meta: usesMeta,
repeat: false,
shift: false,
};
if (
shortcutInputs.length !== 1 ||
!isDeepStrictEqual(shortcutInputs[0], expectedBrowserShortcutInput) ||
unhandledHostState.events !== 0 ||
sentinel.applicationMenuShortcutHits !== 0
) {
fail(
`unhandled browser shortcut crossed boundary incorrectly: inputs=${JSON.stringify(shortcutInputs)} host=${JSON.stringify(unhandledHostState)} menu=${sentinel.applicationMenuShortcutHits}`,
);
}
pass("automation production guest preload forwards one page-unhandled browser shortcut");
checks.push({ group: "automation", check: "browser-shortcut-page-first-forward", pass: true });
const focusedGuestInput = await guest.executeJavaScript(
"document.getElementById('name').focus(); document.activeElement.id",
true,
);
const focusedHostInput = await win.webContents.executeJavaScript(
"window.captureHarness.focusHostComposer()",
true,
);
const retainedGuestInput = await guest.executeJavaScript("document.activeElement.id", true);
if (
focusedGuestInput !== "name" ||
focusedHostInput !== "host-composer" ||
retainedGuestInput !== "name"
) {
fail(
`automation focus setup guest=${JSON.stringify(focusedGuestInput)} host=${JSON.stringify(focusedHostInput)} retainedGuest=${JSON.stringify(retainedGuestInput)}`,
);
}
sendContainedEnter(guest);
await waitForAutomationLog(
guest,
(entry) => entry.event === "keydown-name" && entry.key === "Enter" && entry.trusted === true,
"trusted guest Enter",
);
const hostComposerState = await win.webContents.executeJavaScript(
"window.captureHarness.hostComposerState()",
true,
);
const expectedHostComposerState = {
activeElementId: "host-composer",
enterEvents: 0,
submissions: 0,
};
if (!isDeepStrictEqual(hostComposerState, expectedHostComposerState)) {
fail(`automation guest Enter reached host composer: ${JSON.stringify(hostComposerState)}`);
}
pass("automation guest Enter does not reach the active host composer");
checks.push({ group: "automation", check: "guest-enter-host-isolation", pass: true });
return checks;
}
async function runAutomationGroup() {
const results = [];
const { BrowserKeyboard } = require(PRODUCTION_BROWSER_KEYBOARD_PATH);
const browserKeyboard = new BrowserKeyboard();
const browserKeyboardSentinels = installBrowserKeyboardSentinels();
const handle = createInactiveHarnessWindow({
width: 1000,
height: 700,
@@ -1655,7 +1906,9 @@ async function runAutomationGroup() {
},
});
const { win } = handle;
installHarnessWebviewGuards(win);
installHarnessWebviewGuards(win, {
preloadPath: PRODUCTION_BROWSER_GUEST_PRELOAD_PATH,
});
const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true });
try {
await withTimeout(
@@ -1676,6 +1929,25 @@ async function runAutomationGroup() {
sourceUrl: automationFixtureUrl(),
});
await waitForGuestLoad(guest);
browserKeyboardSentinels.state.guestId = guest.id;
const browserId = "capture-harness-browser";
const usesMeta = process.platform === "darwin";
browserKeyboard.attach({ browserId, contents: guest, hostContents: win.webContents });
browserKeyboard.publish(win.webContents.id, {
prefixes: [
{
alt: false,
code: "KeyB",
control: !usesMeta,
key: "b",
meta: usesMeta,
repeat: false,
shift: false,
},
],
});
await delay(25);
const first = await guest.executeJavaScript(AUTOMATION_SNAPSHOT_PROBE, true);
assertAutomationSnapshot(first);
@@ -1910,6 +2182,15 @@ async function runAutomationGroup() {
pass("automation upload ref resolves to backendNodeId");
results.push({ group: "automation", check: "upload-backend-node", pass: true });
const browserKeyboardChecks = await verifyBrowserKeyboardIsolation({
guest,
win,
browserId,
usesMeta,
sentinel: browserKeyboardSentinels.state,
});
results.push(...browserKeyboardChecks);
// Resize is not harness-testable: the harness hosts webviews in the parked
// 1px resident host, and Electron does not propagate CSS-box resizes to a
// parked guest's capture surface (see docs/browser-capture-harness.md).
@@ -1922,6 +2203,8 @@ async function runAutomationGroup() {
);
return results;
} finally {
browserKeyboard.detachHost(win.webContents.id);
browserKeyboardSentinels.restore();
await closeHarnessWindow(win);
}
}

View File

@@ -2,6 +2,7 @@ import type { Rectangle } from "electron";
import { describe, expect, test, vi } from "vitest";
import type { TabImage } from "./service.js";
import { adaptWebContents } from "./ipc.js";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
class FakeImage implements TabImage {
public toPNG(): Uint8Array {
@@ -90,6 +91,7 @@ type ConsoleMessageListener = (
class FakeWebContents {
public readonly debugger = new FakeDebugger();
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
public readonly captures: Array<{
rect: Rectangle | undefined;
options: { stayHidden?: boolean } | undefined;
@@ -99,7 +101,14 @@ class FakeWebContents {
private destroyedListener: (() => void) | null = null;
public destroyed = false;
public constructor(public readonly id: number) {}
public constructor(private readonly webContentsId: number) {}
public get id(): number {
if (this.destroyed) {
throw new TypeError("Object has been destroyed");
}
return this.webContentsId;
}
public getURL(): string {
return "https://example.com";
@@ -149,6 +158,10 @@ class FakeWebContents {
this.invalidations.push("invalidate");
}
public sendInputEvent(event: IsolatedKeyboardInputEvent): void {
this.inputEvents.push(event);
}
public on(event: "console-message", listener: ConsoleMessageListener): void {
expect(event).toBe("console-message");
this.consoleMessageListener = listener;
@@ -178,6 +191,17 @@ class FakeWebContents {
}
describe("browser automation IPC adapter", () => {
test("sends contained keyboard input directly to the guest", () => {
const contents = new FakeWebContents(19);
const tab = adaptWebContents(contents);
tab.sendInputEvent({ type: "keyDown", keyCode: "Enter", skipIfUnhandled: true });
expect(contents.inputEvents).toEqual([
{ type: "keyDown", keyCode: "Enter", skipIfUnhandled: true },
]);
});
test("delegates viewport capture to the guest without a renderer prep bridge", async () => {
const contents = new FakeWebContents(20);
const tab = adaptWebContents(contents);
@@ -211,7 +235,7 @@ describe("browser automation IPC adapter", () => {
},
]);
contents.destroy();
expect(() => contents.destroy()).not.toThrow();
expect(tab.getConsoleMessages?.()).toEqual([]);
});

View File

@@ -6,6 +6,7 @@ import type {
BrowserAutomationDialogEvent,
} from "@getpaseo/protocol/browser-automation/rpc-schemas";
import type { TabContents, BrowserRegistry, TabImage } from "./service.js";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
import { CdpSessionQueue } from "./cdp-session-queue.js";
import {
dialogAcceptValue,
@@ -74,14 +75,16 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void;
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
}
export function adaptWebContents(contents: BrowserAutomationWebContents): TabContents {
observeConsoleMessages(contents);
const cdpQueue = getCdpQueue(contents.id);
const dialogMonitor = getDialogMonitor(contents, cdpQueue);
const contentsId = contents.id;
observeConsoleMessages(contents, contentsId);
const cdpQueue = getCdpQueue(contentsId);
const dialogMonitor = getDialogMonitor(contents, contentsId, cdpQueue);
return {
id: contents.id,
id: contentsId,
getURL: () => contents.getURL(),
getTitle: () => contents.getTitle(),
canGoBack: () => contents.canGoBack(),
@@ -95,7 +98,8 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
invalidate: () => contents.invalidate(),
getConsoleMessages: () => consoleMessagesByContentsId.get(contents.id) ?? [],
sendInputEvent: (event) => contents.sendInputEvent(event),
getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [],
captureDialogs: (task) => dialogMonitor.capture(task),
sendDebugCommand: (command: string, params?: Record<string, unknown>) =>
cdpQueue.run(async () => {
@@ -117,35 +121,36 @@ function getCdpQueue(contentsId: number): CdpSessionQueue {
return queue;
}
function observeConsoleMessages(contents: BrowserAutomationWebContents): void {
if (observedContentsIds.has(contents.id)) {
function observeConsoleMessages(contents: BrowserAutomationWebContents, contentsId: number): void {
if (observedContentsIds.has(contentsId)) {
return;
}
observedContentsIds.add(contents.id);
observedContentsIds.add(contentsId);
contents.on("console-message", (_event, level, message, line, sourceId) => {
const entry = normalizeConsoleMessage({ level, message, line, sourceId });
const messages = consoleMessagesByContentsId.get(contents.id) ?? [];
const messages = consoleMessagesByContentsId.get(contentsId) ?? [];
messages.push(entry);
consoleMessagesByContentsId.set(contents.id, messages.slice(-MAX_CONSOLE_MESSAGES_PER_TAB));
consoleMessagesByContentsId.set(contentsId, messages.slice(-MAX_CONSOLE_MESSAGES_PER_TAB));
});
contents.once("destroyed", () => {
observedContentsIds.delete(contents.id);
consoleMessagesByContentsId.delete(contents.id);
cdpQueuesByContentsId.delete(contents.id);
dialogMonitorsByContentsId.delete(contents.id);
observedContentsIds.delete(contentsId);
consoleMessagesByContentsId.delete(contentsId);
cdpQueuesByContentsId.delete(contentsId);
dialogMonitorsByContentsId.delete(contentsId);
});
}
function getDialogMonitor(
contents: BrowserAutomationWebContents,
contentsId: number,
cdpQueue: CdpSessionQueue,
): DialogMonitor {
const existing = dialogMonitorsByContentsId.get(contents.id);
const existing = dialogMonitorsByContentsId.get(contentsId);
if (existing) {
return existing;
}
const monitor = new DialogMonitor(contents, cdpQueue);
dialogMonitorsByContentsId.set(contents.id, monitor);
const monitor = new DialogMonitor(contents, contentsId, cdpQueue);
dialogMonitorsByContentsId.set(contentsId, monitor);
return monitor;
}
@@ -156,6 +161,7 @@ class DialogMonitor {
public constructor(
private readonly contents: BrowserAutomationWebContents,
private readonly contentsId: number,
private readonly cdpQueue: CdpSessionQueue,
) {}
@@ -168,7 +174,7 @@ class DialogMonitor {
await this.installPromptShim();
} catch (error) {
console.warn("[browser-automation] Dialog capture unavailable; running command without it", {
contentsId: this.contents.id,
contentsId: this.contentsId,
error,
});
return { result: await task(), dialogs: [] };

View File

@@ -10,6 +10,7 @@ import type {
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
import type { BrowserRegistry, TabContents, TabImage } from "./service.js";
import { executeAutomationCommand } from "./service.js";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
const BROWSER_A = "11111111-1111-4111-8111-111111111111";
const BROWSER_B = "22222222-2222-4222-8222-222222222222";
@@ -37,6 +38,7 @@ class FakeTab implements TabContents {
public readonly actions: string[] = [];
public readonly capturedViewports: Array<{ stayHidden?: boolean }> = [];
public readonly debugCommands: Array<{ command: string; params?: Record<string, unknown> }> = [];
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
private readonly captureStartWaiters: Array<() => void> = [];
private readonly deferredCaptures: Array<(image: TabImage) => void> = [];
@@ -223,6 +225,10 @@ class FakeTab implements TabContents {
return {};
}
public sendInputEvent(event: IsolatedKeyboardInputEvent): void {
this.inputEvents.push(event);
}
public waitForCaptureStart(count: number): Promise<void> {
if (this.capturedViewports.length >= count) {
return Promise.resolve();
@@ -1120,30 +1126,19 @@ describe("executeAutomationCommand", () => {
ok: true,
result: { command: "keypress", browserId: BROWSER_A, key: "Enter" },
});
expect(browser.tab.debugCommands).toEqual([
expect(browser.tab.inputEvents).toEqual([
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyDown",
key: "Enter",
code: "Enter",
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
text: "\r",
unmodifiedText: "\r",
},
type: "keyDown",
keyCode: "Enter",
skipIfUnhandled: true,
},
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyUp",
key: "Enter",
code: "Enter",
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
},
type: "keyUp",
keyCode: "Enter",
skipIfUnhandled: true,
},
]);
expect(browser.tab.debugCommands).toEqual([]);
});
test("keypress focuses a non-editable ref without clicking before the trusted key", async () => {
@@ -1163,22 +1158,44 @@ describe("executeAutomationCommand", () => {
result: { command: "keypress", browserId: BROWSER_A, key: "Enter", ref: "@e4", x: 40, y: 30 },
});
expect(containsScript(browser.tab, "element.focus({ preventScroll: true })")).toBe(true);
expect(browser.tab.debugCommands.map((entry) => entry.command)).toEqual([
"Input.dispatchKeyEvent",
"Input.dispatchKeyEvent",
]);
expect(browser.tab.debugCommands.at(-2)).toEqual({
command: "Input.dispatchKeyEvent",
params: {
expect(browser.tab.inputEvents).toEqual([
{
type: "keyDown",
key: "Enter",
code: "Enter",
windowsVirtualKeyCode: 13,
nativeVirtualKeyCode: 13,
text: "\r",
unmodifiedText: "\r",
keyCode: "Enter",
skipIfUnhandled: true,
},
{
type: "keyUp",
keyCode: "Enter",
skipIfUnhandled: true,
},
]);
expect(browser.tab.debugCommands).toEqual([]);
});
test("keypress reports unsupported when focusing an editable ref requires unavailable CDP", async () => {
const browser = new BrowserAutomationHarness();
browser.tab.snapshotNodes = formElements();
browser.tab.keypressTargetEditable = true;
requireSnapshotRefs(await browser.snapshot());
Object.defineProperty(browser.tab, "sendDebugCommand", { value: undefined });
await expect(
browser.execute({
command: "keypress",
args: { browserId: BROWSER_A, ref: "@e1", key: "Enter" },
}),
).resolves.toEqual({
requestId: "req-keypress",
ok: false,
error: {
code: "browser_unsupported",
message: "browser_keypress requires trusted browser input",
retryable: false,
},
});
expect(browser.tab.inputEvents).toEqual([]);
});
test("navigate loads the requested HTTP URL in the explicit tab", async () => {

View File

@@ -19,6 +19,7 @@ import {
dispatchTrustedScroll,
dispatchTrustedText,
type ClickInputOptions,
type IsolatedKeyboardInputEvent,
} from "./trusted-input.js";
export interface TabContents {
@@ -36,6 +37,7 @@ export interface TabContents {
reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
invalidate(): void;
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
getConsoleMessages?(): BrowserAutomationConsoleLogEntry[];
captureDialogs?<T>(
task: () => Promise<T>,
@@ -1066,13 +1068,6 @@ async function executeKeypress(
return target;
}
return withDialogCapture(target.contents, async () => {
if (!target.contents.sendDebugCommand) {
return fail(
requestId,
"browser_unsupported",
"browser_keypress requires trusted browser input",
);
}
let actionable: ActionabilityResult | null = null;
if (ref) {
const elementExpression = snapshotEngine.runtimeElementExpression({
@@ -1094,10 +1089,17 @@ async function executeKeypress(
return staleRefFailure(requestId, ref);
}
if (focused === "editable") {
if (!target.contents.sendDebugCommand) {
return fail(
requestId,
"browser_unsupported",
"browser_keypress requires trusted browser input",
);
}
await dispatchTrustedClick(cdpSender(target.contents), actionable.target.point);
}
}
await dispatchTrustedKey(cdpSender(target.contents), key);
dispatchTrustedKey((event) => target.contents.sendInputEvent(event), key);
return {
requestId,
ok: true,

View File

@@ -1,37 +1,30 @@
import { describe, expect, test } from "vitest";
import type { IsolatedKeyboardInputEvent } from "./trusted-input.js";
import { dispatchTrustedKey } from "./trusted-input.js";
describe("trusted browser input", () => {
test("Space dispatches a real space key event", async () => {
const commands: Array<{ command: string; params?: Record<string, unknown> }> = [];
test.each([
["a", "a"],
["Z", "Z"],
["Space", "Space"],
["ArrowDown", "Down"],
])("sends %s as Electron key code %s with unhandled redispatch disabled", (key, keyCode) => {
const events: IsolatedKeyboardInputEvent[] = [];
await dispatchTrustedKey(async (command, params) => {
commands.push({ command, ...(params ? { params } : {}) });
return {};
}, "Space");
dispatchTrustedKey((event) => {
events.push(event);
}, key);
expect(commands).toEqual([
expect(events).toEqual([
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyDown",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
text: " ",
unmodifiedText: " ",
},
type: "keyDown",
keyCode,
skipIfUnhandled: true,
},
{
command: "Input.dispatchKeyEvent",
params: {
type: "keyUp",
key: " ",
code: "Space",
windowsVirtualKeyCode: 32,
nativeVirtualKeyCode: 32,
},
type: "keyUp",
keyCode,
skipIfUnhandled: true,
},
]);
});

View File

@@ -1,3 +1,4 @@
import type { KeyboardInputEvent } from "electron";
import type { ActionablePoint } from "./actionability.js";
import type { CdpCommandSender } from "./cdp-session-queue.js";
@@ -17,24 +18,21 @@ const MODIFIER_MASKS: Record<InputModifier, number> = {
Shift: 8,
};
const SPECIAL_KEY_DEFINITIONS: Record<
string,
{ key: string; code: string; windowsVirtualKeyCode: number; text?: string }
> = {
Enter: { key: "Enter", code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
Space: { key: " ", code: "Space", windowsVirtualKeyCode: 32, text: " " },
Tab: { key: "Tab", code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
Escape: { key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 },
Backspace: { key: "Backspace", code: "Backspace", windowsVirtualKeyCode: 8 },
Delete: { key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 },
ArrowUp: { key: "ArrowUp", code: "ArrowUp", windowsVirtualKeyCode: 38 },
ArrowDown: { key: "ArrowDown", code: "ArrowDown", windowsVirtualKeyCode: 40 },
ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", windowsVirtualKeyCode: 37 },
ArrowRight: { key: "ArrowRight", code: "ArrowRight", windowsVirtualKeyCode: 39 },
Home: { key: "Home", code: "Home", windowsVirtualKeyCode: 36 },
End: { key: "End", code: "End", windowsVirtualKeyCode: 35 },
PageUp: { key: "PageUp", code: "PageUp", windowsVirtualKeyCode: 33 },
PageDown: { key: "PageDown", code: "PageDown", windowsVirtualKeyCode: 34 },
export interface IsolatedKeyboardInputEvent extends KeyboardInputEvent {
type: "keyDown" | "keyUp";
// Electron accepts this NativeWebKeyboardEvent flag even though its public
// TypeScript declarations omit it. It stops an unhandled webview key from
// being redispatched to the embedder's active DOM element or application menu.
skipIfUnhandled: true;
}
type KeyboardInputSender = (event: IsolatedKeyboardInputEvent) => void;
const ELECTRON_KEY_CODE_ALIASES: Record<string, string> = {
ArrowDown: "Down",
ArrowLeft: "Left",
ArrowRight: "Right",
ArrowUp: "Up",
};
export async function dispatchTrustedClick(
@@ -163,45 +161,20 @@ export async function dispatchTrustedText(send: CdpCommandSender, text: string):
await send("Input.insertText", { text });
}
export async function dispatchTrustedKey(send: CdpCommandSender, key: string): Promise<void> {
const definition = keyDefinition(key);
await send("Input.dispatchKeyEvent", {
export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void {
const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key;
send({
type: "keyDown",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
keyCode,
skipIfUnhandled: true,
});
await send("Input.dispatchKeyEvent", {
send({
type: "keyUp",
key: definition.key,
code: definition.code,
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
keyCode,
skipIfUnhandled: true,
});
}
function keyDefinition(key: string): {
key: string;
code: string;
windowsVirtualKeyCode: number;
text?: string;
} {
const special = SPECIAL_KEY_DEFINITIONS[key];
if (special) {
return special;
}
const text = key.length === 1 ? key : "";
const upper = text.toUpperCase();
return {
key,
code: upper ? `Key${upper}` : key,
windowsVirtualKeyCode: upper ? upper.charCodeAt(0) : 0,
...(text ? { text, unmodifiedText: text } : {}),
};
}
function modifierMask(modifiers: InputModifier[] | undefined): number {
return (modifiers ?? []).reduce((mask, modifier) => mask | MODIFIER_MASKS[modifier], 0);
}

View File

@@ -0,0 +1,78 @@
import { ipcRenderer } from "electron";
import type { BrowserKeyboardPolicy, BrowserShortcutPrefix } from "./policy.js";
const POLICY_CHANNEL = "paseo:browser-keyboard-policy";
const SHORTCUT_INPUT_CHANNEL = "paseo:browser-shortcut-input";
let browserId: string | null = null;
let policy: BrowserShortcutPrefix[] = [];
interface BrowserKeyboardPolicyPayload extends BrowserKeyboardPolicy {
browserId: string;
}
function matchesPolicy(event: KeyboardEvent): boolean {
return policy.some((prefix) => {
if (
prefix.alt !== event.altKey ||
prefix.control !== event.ctrlKey ||
prefix.meta !== event.metaKey ||
prefix.shift !== event.shiftKey ||
(prefix.repeat === false && event.repeat)
) {
return false;
}
if (prefix.key === undefined) {
return prefix.code === event.code;
}
const eventKey = event.key.toLowerCase();
if (eventKey === prefix.key) {
return true;
}
if (prefix.shift && prefix.shiftedKey !== undefined && eventKey === prefix.shiftedKey) {
return true;
}
return (prefix.alt || prefix.codeFallback === true) && prefix.code === event.code;
});
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof Element)) {
return false;
}
if (
target.closest("[contenteditable=true], [contenteditable=''], [contenteditable=plaintext-only]")
) {
return true;
}
return target.matches("input, textarea, select, [role=textbox]");
}
ipcRenderer.on(POLICY_CHANNEL, (_event, value: BrowserKeyboardPolicyPayload) => {
browserId = value.browserId;
policy = value.prefixes;
});
window.addEventListener("keydown", (event) => {
if (
!event.isTrusted ||
event.defaultPrevented ||
!browserId ||
isEditableTarget(event.target) ||
!matchesPolicy(event)
) {
return;
}
event.preventDefault();
event.stopPropagation();
ipcRenderer.send(SHORTCUT_INPUT_CHANNEL, {
alt: event.altKey,
browserId,
code: event.code,
control: event.ctrlKey,
key: event.key,
meta: event.metaKey,
repeat: event.repeat,
shift: event.shiftKey,
});
});

View File

@@ -0,0 +1,281 @@
import { describe, expect, test } from "vitest";
import { BrowserKeyboard } from "./index.js";
interface SentMessage {
channel: string;
payload: unknown;
}
class FakeBrowserContents {
public readonly ignoredMenuShortcuts: boolean[] = [];
public readonly reloads: string[] = [];
public readonly sent: SentMessage[] = [];
private destroyed = false;
private destroyedListener: (() => void) | null = null;
private finishLoadListener: (() => void) | null = null;
private inputListener:
| ((event: { preventDefault(): void }, input: Electron.Input) => void)
| null = null;
public constructor(private readonly webContentsId: number) {}
public get id(): number {
if (this.destroyed) {
throw new TypeError("Object has been destroyed");
}
return this.webContentsId;
}
public isDestroyed(): boolean {
return this.destroyed;
}
public isLoadingMainFrame(): boolean {
return false;
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
}
public on(event: "did-finish-load", listener: () => void): void;
public on(
event: "before-input-event",
listener: (event: { preventDefault(): void }, input: Electron.Input) => void,
): void;
public on(
event: "did-finish-load" | "before-input-event",
listener: (() => void) | ((event: { preventDefault(): void }, input: Electron.Input) => void),
): void {
if (event === "did-finish-load") {
this.finishLoadListener = listener as () => void;
return;
}
this.inputListener = listener as (
event: { preventDefault(): void },
input: Electron.Input,
) => void;
}
public send(channel: string, payload: unknown): void {
this.sent.push({ channel, payload });
}
public setIgnoreMenuShortcuts(ignore: boolean): void {
this.ignoredMenuShortcuts.push(ignore);
}
public stop(): void {
this.reloads.push("stop");
}
public reload(): void {
this.reloads.push("reload");
}
public reloadIgnoringCache(): void {
this.reloads.push("force-reload");
}
public destroy(): void {
this.destroyed = true;
this.destroyedListener?.();
}
public finishLoad(): void {
this.finishLoadListener?.();
}
public input(input: Electron.Input): boolean {
let wasPrevented = false;
this.inputListener?.(
{
preventDefault: () => {
wasPrevented = true;
},
},
input,
);
return wasPrevented;
}
}
function shortcutInput(browserId: string) {
return {
alt: false,
browserId,
code: "KeyB",
control: true,
key: "b",
meta: false,
repeat: false,
shift: false,
};
}
function electronInput(input: Partial<Electron.Input>): Electron.Input {
return {
alt: false,
code: "",
control: false,
isAutoRepeat: false,
isComposing: false,
key: "",
location: 0,
meta: false,
modifiers: [],
shift: false,
type: "keyDown",
...input,
};
}
describe("BrowserKeyboard", () => {
test("forgets a guest after Electron invalidates its wrapper", () => {
const keyboard = new BrowserKeyboard();
const guest = new FakeBrowserContents(41);
const host = new FakeBrowserContents(42);
const liveContentsWithSameId = new FakeBrowserContents(41);
keyboard.attach({ browserId: "browser-a", contents: guest, hostContents: host });
expect(() => guest.destroy()).not.toThrow();
keyboard.forwardShortcutInput(liveContentsWithSameId, shortcutInput("browser-a"));
expect(host.sent).toEqual([]);
});
test("does not let a stale destroy event detach a replacement guest", () => {
const keyboard = new BrowserKeyboard();
const staleGuest = new FakeBrowserContents(51);
const replacementGuest = new FakeBrowserContents(51);
const staleHost = new FakeBrowserContents(52);
const replacementHost = new FakeBrowserContents(53);
keyboard.attach({
browserId: "browser-a",
contents: staleGuest,
hostContents: staleHost,
});
keyboard.attach({
browserId: "browser-a",
contents: replacementGuest,
hostContents: replacementHost,
});
staleGuest.destroy();
keyboard.forwardShortcutInput(replacementGuest, shortcutInput("browser-a"));
expect(staleHost.sent).toEqual([]);
expect(replacementHost.sent).toEqual([
{
channel: "paseo:event:browser-shortcut-input",
payload: shortcutInput("browser-a"),
},
]);
});
test("accepts input only from the authoritative guest for a browser", () => {
const keyboard = new BrowserKeyboard();
const staleGuest = new FakeBrowserContents(54);
const currentGuest = new FakeBrowserContents(55);
const host = new FakeBrowserContents(56);
keyboard.attach({ browserId: "browser-a", contents: staleGuest, hostContents: host });
keyboard.attach({ browserId: "browser-a", contents: currentGuest, hostContents: host });
keyboard.forwardShortcutInput(staleGuest, shortcutInput("browser-a"));
keyboard.forwardShortcutInput(currentGuest, shortcutInput("browser-a"));
expect(host.sent).toEqual([
{
channel: "paseo:event:browser-shortcut-input",
payload: shortcutInput("browser-a"),
},
]);
});
test("resends the latest shortcut policy after every main-frame load", () => {
const keyboard = new BrowserKeyboard();
const guest = new FakeBrowserContents(61);
const host = new FakeBrowserContents(62);
const initialPolicy = {
prefixes: [
{
alt: false,
code: "KeyB",
control: true,
meta: false,
repeat: false as const,
shift: false,
},
],
};
const latestPolicy = { prefixes: [] };
keyboard.publish(host.id, initialPolicy);
keyboard.attach({ browserId: "browser-a", contents: guest, hostContents: host });
keyboard.publish(host.id, latestPolicy);
guest.finishLoad();
guest.finishLoad();
expect(guest.sent).toEqual([
{
channel: "paseo:browser-keyboard-policy",
payload: { ...initialPolicy, browserId: "browser-a" },
},
{
channel: "paseo:browser-keyboard-policy",
payload: { ...latestPolicy, browserId: "browser-a" },
},
{
channel: "paseo:browser-keyboard-policy",
payload: { ...latestPolicy, browserId: "browser-a" },
},
{
channel: "paseo:browser-keyboard-policy",
payload: { ...latestPolicy, browserId: "browser-a" },
},
]);
});
test("forgets policy and guests when their host window closes", () => {
const keyboard = new BrowserKeyboard();
const guest = new FakeBrowserContents(71);
const host = new FakeBrowserContents(72);
const policy = { prefixes: [] };
keyboard.publish(host.id, policy);
keyboard.attach({ browserId: "browser-a", contents: guest, hostContents: host });
keyboard.detachHost(host.id);
guest.finishLoad();
keyboard.forwardShortcutInput(guest, shortcutInput("browser-a"));
expect(guest.sent).toEqual([
{
channel: "paseo:browser-keyboard-policy",
payload: { ...policy, browserId: "browser-a" },
},
]);
expect(host.sent).toEqual([]);
});
test("owns reserved shortcuts and leaves plain guest input contained", () => {
const keyboard = new BrowserKeyboard();
const guest = new FakeBrowserContents(81);
const host = new FakeBrowserContents(82);
keyboard.attach({ browserId: "browser-a", contents: guest, hostContents: host });
const command = process.platform === "darwin" ? { meta: true } : { control: true };
const reservedWasPrevented = guest.input(electronInput({ ...command, code: "KeyT", key: "t" }));
const enterWasPrevented = guest.input(electronInput({ code: "Enter", key: "Enter" }));
expect(reservedWasPrevented).toBe(true);
expect(enterWasPrevented).toBe(false);
expect(guest.ignoredMenuShortcuts).toEqual([false, true]);
expect(host.sent).toEqual([
{
channel: "paseo:event:browser-shortcut",
payload: { action: "new-tab", browserId: "browser-a" },
},
]);
});
});

View File

@@ -0,0 +1,199 @@
import { ipcMain } from "electron";
import {
type BrowserKeyboardPolicy,
classifyBrowserReservedShortcut,
parseBrowserKeyboardPolicy,
parseBrowserShortcutInput,
} from "./policy.js";
export type { BrowserKeyboardPolicy } from "./policy.js";
const POLICY_INPUT_CHANNEL = "paseo:browser:set-shortcut-policy";
const POLICY_OUTPUT_CHANNEL = "paseo:browser-keyboard-policy";
const SHORTCUT_INPUT_CHANNEL = "paseo:browser-shortcut-input";
const SHORTCUT_OUTPUT_CHANNEL = "paseo:event:browser-shortcut-input";
const RESERVED_SHORTCUT_OUTPUT_CHANNEL = "paseo:event:browser-shortcut";
interface BrowserKeyboardContentsIdentity {
readonly id: number;
}
interface BrowserKeyboardInputEvent {
preventDefault(): void;
}
interface BrowserKeyboardGuestContents extends BrowserKeyboardContentsIdentity {
isDestroyed(): boolean;
isLoadingMainFrame(): boolean;
on(event: "did-finish-load", listener: () => void): void;
on(
event: "before-input-event",
listener: (event: BrowserKeyboardInputEvent, input: Electron.Input) => void,
): void;
once(event: "destroyed", listener: () => void): void;
reload(): void;
reloadIgnoringCache(): void;
send(channel: string, ...args: unknown[]): void;
setIgnoreMenuShortcuts(ignore: boolean): void;
stop(): void;
}
interface BrowserKeyboardHostContents extends BrowserKeyboardContentsIdentity {
isDestroyed(): boolean;
send(channel: string, ...args: unknown[]): void;
}
interface BrowserKeyboardGuest {
browserId: string;
contents: BrowserKeyboardGuestContents;
hostContents: BrowserKeyboardHostContents;
hostWebContentsId: number;
webContentsId: number;
}
export class BrowserKeyboard {
private readonly guestsByBrowserId = new Map<string, BrowserKeyboardGuest>();
private readonly guestsByWebContentsId = new Map<number, BrowserKeyboardGuest>();
private readonly policiesByHostWebContentsId = new Map<number, BrowserKeyboardPolicy>();
public registerIpc(): void {
ipcMain.handle(POLICY_INPUT_CHANNEL, (event, rawPolicy: unknown) => {
this.publish(event.sender.id, rawPolicy);
});
ipcMain.on(SHORTCUT_INPUT_CHANNEL, (event, rawInput: unknown) => {
this.forwardShortcutInput(event.sender, rawInput);
});
}
public attach(input: {
browserId: string;
contents: BrowserKeyboardGuestContents;
hostContents: BrowserKeyboardHostContents;
}): void {
const guest: BrowserKeyboardGuest = {
...input,
hostWebContentsId: input.hostContents.id,
webContentsId: input.contents.id,
};
const guestAtWebContentsId = this.guestsByWebContentsId.get(guest.webContentsId);
if (guestAtWebContentsId) {
this.detachGuest(guestAtWebContentsId);
}
const guestForBrowser = this.guestsByBrowserId.get(guest.browserId);
if (guestForBrowser) {
this.detachGuest(guestForBrowser);
}
this.guestsByBrowserId.set(guest.browserId, guest);
this.guestsByWebContentsId.set(guest.webContentsId, guest);
input.contents.once("destroyed", () => {
this.detachGuest(guest);
});
input.contents.on("did-finish-load", () => {
if (this.guestsByWebContentsId.get(guest.webContentsId) !== guest) {
return;
}
const policy = this.policiesByHostWebContentsId.get(guest.hostWebContentsId);
if (policy) {
this.sendPolicy(guest, policy);
}
});
input.contents.on("before-input-event", (event, keyboardInput) => {
if (this.guestsByWebContentsId.get(guest.webContentsId) === guest) {
this.handleGuestInput(guest, event, keyboardInput);
}
});
const policy = this.policiesByHostWebContentsId.get(guest.hostWebContentsId);
if (policy) {
this.sendPolicy(guest, policy);
}
}
public publish(hostWebContentsId: number, rawPolicy: unknown): void {
const policy = parseBrowserKeyboardPolicy(rawPolicy);
if (!policy) {
return;
}
this.policiesByHostWebContentsId.set(hostWebContentsId, policy);
for (const guest of this.guestsByWebContentsId.values()) {
if (guest.hostWebContentsId === hostWebContentsId) {
this.sendPolicy(guest, policy);
}
}
}
public forwardShortcutInput(contents: BrowserKeyboardContentsIdentity, rawInput: unknown): void {
const input = parseBrowserShortcutInput(rawInput);
if (!input) {
return;
}
const guest = this.guestsByWebContentsId.get(contents.id);
if (!guest || guest.browserId !== input.browserId || guest.hostContents.isDestroyed()) {
return;
}
guest.hostContents.send(SHORTCUT_OUTPUT_CHANNEL, input);
}
public detachHost(hostWebContentsId: number): void {
this.policiesByHostWebContentsId.delete(hostWebContentsId);
for (const guest of this.guestsByWebContentsId.values()) {
if (guest.hostWebContentsId === hostWebContentsId) {
this.detachGuest(guest);
}
}
}
private detachGuest(guest: BrowserKeyboardGuest): void {
if (this.guestsByWebContentsId.get(guest.webContentsId) === guest) {
this.guestsByWebContentsId.delete(guest.webContentsId);
}
if (this.guestsByBrowserId.get(guest.browserId) === guest) {
this.guestsByBrowserId.delete(guest.browserId);
}
}
private handleGuestInput(
guest: BrowserKeyboardGuest,
event: BrowserKeyboardInputEvent,
input: Electron.Input,
): void {
guest.contents.setIgnoreMenuShortcuts(!input.control && !input.meta);
const reservedShortcut = classifyBrowserReservedShortcut(input, {
isMac: process.platform === "darwin",
});
switch (reservedShortcut) {
case "force-reload":
event.preventDefault();
guest.contents.reloadIgnoringCache();
return;
case "reload":
event.preventDefault();
if (guest.contents.isLoadingMainFrame()) {
guest.contents.stop();
} else {
guest.contents.reload();
}
return;
case "focus-url":
case "new-tab":
event.preventDefault();
if (!guest.hostContents.isDestroyed()) {
guest.hostContents.send(RESERVED_SHORTCUT_OUTPUT_CHANNEL, {
action: reservedShortcut,
browserId: guest.browserId,
});
}
return;
case null:
return;
}
}
private sendPolicy(guest: BrowserKeyboardGuest, policy: BrowserKeyboardPolicy): void {
if (!guest.contents.isDestroyed()) {
guest.contents.send(POLICY_OUTPUT_CHANNEL, { ...policy, browserId: guest.browserId });
}
}
}

View File

@@ -0,0 +1,74 @@
import { describe, expect, test } from "vitest";
import { classifyBrowserReservedShortcut, parseBrowserKeyboardPolicy } from "./policy.js";
describe("browser keyboard policy", () => {
test("classifies shell-owned browser shortcuts for the current platform modifier", () => {
const macInputs = [
{ type: "keyDown", key: "t", meta: true, control: false, alt: false, shift: false },
{ type: "keyDown", key: "l", meta: true, control: false, alt: false, shift: false },
{ type: "keyDown", key: "r", meta: true, control: false, alt: false, shift: false },
{ type: "keyDown", key: "r", meta: true, control: false, alt: false, shift: true },
];
const nonMacInputs = macInputs.map((input) => ({
...input,
control: true,
meta: false,
}));
expect(
macInputs.map((input) => classifyBrowserReservedShortcut(input, { isMac: true })),
).toEqual(["new-tab", "focus-url", "reload", "force-reload"]);
expect(
nonMacInputs.map((input) => classifyBrowserReservedShortcut(input, { isMac: false })),
).toEqual(["new-tab", "focus-url", "reload", "force-reload"]);
});
test("rejects the wrong or ambiguous command modifier for reserved shortcuts", () => {
const input = {
type: "keyDown",
key: "t",
meta: false,
control: true,
alt: false,
shift: false,
};
expect(classifyBrowserReservedShortcut(input, { isMac: true })).toBeNull();
expect(
classifyBrowserReservedShortcut({ ...input, meta: true, control: false }, { isMac: false }),
).toBeNull();
expect(
classifyBrowserReservedShortcut({ ...input, meta: true, control: true }, { isMac: true }),
).toBeNull();
expect(
classifyBrowserReservedShortcut({ ...input, meta: true, control: true }, { isMac: false }),
).toBeNull();
expect(
classifyBrowserReservedShortcut(
{ ...input, key: "r", meta: true, control: false, alt: true },
{ isMac: true },
),
).toBeNull();
expect(
classifyBrowserReservedShortcut(
{ ...input, meta: true, control: false, shift: true },
{ isMac: true },
),
).toBeNull();
});
test("accepts only complete modifier prefixes from the host renderer", () => {
expect(
parseBrowserKeyboardPolicy({
prefixes: [
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
],
}),
).toEqual({
prefixes: [
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
],
});
expect(parseBrowserKeyboardPolicy({ prefixes: [{ code: "KeyB", control: true }] })).toBeNull();
});
});

View File

@@ -0,0 +1,132 @@
export interface BrowserShortcutPrefix {
alt: boolean;
code: string;
codeFallback?: boolean;
control: boolean;
key?: string;
meta: boolean;
repeat?: false;
shift: boolean;
shiftedKey?: string;
}
export interface BrowserKeyboardPolicy {
prefixes: BrowserShortcutPrefix[];
}
export interface BrowserShortcutInput {
alt: boolean;
browserId: string;
code: string;
control: boolean;
key: string;
meta: boolean;
repeat: boolean;
shift: boolean;
}
export type BrowserReservedShortcut = "new-tab" | "focus-url" | "reload" | "force-reload";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parsePrefix(value: unknown): BrowserShortcutPrefix | null {
if (!isRecord(value)) {
return null;
}
if (
typeof value.code !== "string" ||
value.code.length === 0 ||
typeof value.alt !== "boolean" ||
typeof value.control !== "boolean" ||
typeof value.meta !== "boolean" ||
typeof value.shift !== "boolean" ||
(value.key !== undefined && typeof value.key !== "string") ||
(value.shiftedKey !== undefined && typeof value.shiftedKey !== "string") ||
(value.codeFallback !== undefined && typeof value.codeFallback !== "boolean") ||
(value.repeat !== undefined && value.repeat !== false)
) {
return null;
}
return {
alt: value.alt,
code: value.code,
...(typeof value.codeFallback === "boolean" ? { codeFallback: value.codeFallback } : {}),
control: value.control,
...(typeof value.key === "string" ? { key: value.key.toLowerCase() } : {}),
meta: value.meta,
...(value.repeat === false ? { repeat: false } : {}),
shift: value.shift,
...(typeof value.shiftedKey === "string" ? { shiftedKey: value.shiftedKey.toLowerCase() } : {}),
};
}
export function parseBrowserKeyboardPolicy(value: unknown): BrowserKeyboardPolicy | null {
if (!isRecord(value) || !Array.isArray(value.prefixes)) {
return null;
}
const prefixes: BrowserShortcutPrefix[] = [];
for (const entry of value.prefixes) {
const prefix = parsePrefix(entry);
if (!prefix) {
return null;
}
prefixes.push(prefix);
}
return { prefixes };
}
export function parseBrowserShortcutInput(value: unknown): BrowserShortcutInput | null {
if (!isRecord(value)) {
return null;
}
if (
typeof value.browserId !== "string" ||
value.browserId.trim().length === 0 ||
typeof value.key !== "string" ||
typeof value.code !== "string" ||
typeof value.alt !== "boolean" ||
typeof value.control !== "boolean" ||
typeof value.meta !== "boolean" ||
typeof value.shift !== "boolean"
) {
return null;
}
return {
alt: value.alt,
browserId: value.browserId.trim(),
code: value.code,
control: value.control,
key: value.key,
meta: value.meta,
repeat: value.repeat === true,
shift: value.shift,
};
}
export function classifyBrowserReservedShortcut(
input: {
alt: boolean;
control: boolean;
key: string;
meta: boolean;
shift: boolean;
type: string;
},
platform: {
isMac: boolean;
},
): BrowserReservedShortcut | null {
const hasPlatformModifier = platform.isMac
? input.meta && !input.control
: input.control && !input.meta;
if (input.type !== "keyDown" || input.alt || !hasPlatformModifier) {
return null;
}
const key = input.key.toLowerCase();
if (!input.shift && key === "t") return "new-tab";
if (!input.shift && key === "l") return "focus-url";
if (key !== "r") return null;
return input.shift ? "force-reload" : "reload";
}

View File

@@ -6,6 +6,8 @@ import {
isPaseoBrowserWebviewAttach,
preparePaseoBrowserWebContents,
registerAttachedPaseoBrowser,
unregisterPaseoBrowser,
unregisterPaseoBrowserFromHost,
} from "./index.js";
class FakeRenderer {
@@ -82,6 +84,7 @@ describe("browser webview attachment", () => {
expect(registered).toBe(true);
expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-a");
expect(getPaseoBrowserWorkspaceId("browser-a")).toBe("workspace-a");
unregisterPaseoBrowser("browser-a");
});
test("rejects a guest hosted by another renderer", () => {
@@ -91,7 +94,7 @@ describe("browser webview attachment", () => {
const guest = new FakeBrowserGuest(201, owner, profileSession);
const registered = registerAttachedPaseoBrowser({
browserId: "browser-a",
browserId: "browser-rejected-owner",
workspaceId: "workspace-a",
webContentsId: guest.id,
sender: claimant,
@@ -109,7 +112,7 @@ describe("browser webview attachment", () => {
const guest = new FakeBrowserGuest(301, renderer, {});
const registered = registerAttachedPaseoBrowser({
browserId: "browser-a",
browserId: "browser-rejected-profile",
workspaceId: "workspace-a",
webContentsId: guest.id,
sender: renderer,
@@ -151,12 +154,43 @@ describe("browser webview attachment", () => {
expect(getPaseoBrowserIdForWebContents(firstGuest)).toBe("browser-first");
expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-second");
unregisterPaseoBrowser("browser-first");
unregisterPaseoBrowser("browser-second");
});
test("unregisters the same browser only from its requesting host", () => {
const profileSession = {};
const firstRenderer = new FakeRenderer(11);
const secondRenderer = new FakeRenderer(22);
const firstGuest = new FakeBrowserGuest(501, firstRenderer, profileSession);
const secondGuest = new FakeBrowserGuest(502, secondRenderer, profileSession);
for (const [renderer, guest] of [
[firstRenderer, firstGuest],
[secondRenderer, secondGuest],
] as const) {
registerAttachedPaseoBrowser({
browserId: "browser-shared-hosts",
workspaceId: "workspace-shared",
webContentsId: guest.id,
sender: renderer,
profileSession,
findWebContents: () => guest,
});
}
unregisterPaseoBrowserFromHost(firstRenderer.id, "browser-shared-hosts");
expect(getPaseoBrowserIdForWebContents(firstGuest)).toBeNull();
expect(getPaseoBrowserIdForWebContents(secondGuest)).toBe("browser-shared-hosts");
expect(getPaseoBrowserWorkspaceId("browser-shared-hosts")).toBe("workspace-shared");
unregisterPaseoBrowser("browser-shared-hosts");
});
test("prepares throttling once and removes registration when the guest is destroyed", () => {
const profileSession = {};
const renderer = new FakeRenderer(1);
const guest = new FakeBrowserGuest(501, renderer, profileSession);
const renderer = new FakeRenderer(31);
const guest = new FakeBrowserGuest(601, renderer, profileSession);
preparePaseoBrowserWebContents(guest);
registerAttachedPaseoBrowser({
browserId: "browser-cleanup",

View File

@@ -47,15 +47,18 @@ export function isPaseoBrowserWebviewAttach(input: { src?: string; partition?: s
}
export function listRegisteredPaseoBrowserIds(): string[] {
return browserRegistry
.listBrowserIds()
.filter((browserId) => getPaseoBrowserWebContents(browserId));
return browserRegistry.listBrowserIds();
}
export function getPaseoBrowserWebviewRegistry(): PaseoBrowserWebviewRegistry {
return browserRegistry;
}
export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void {
const webContentsId = contents.id;
contents.setBackgroundThrottling(false);
contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(contents.id);
browserRegistry.unregisterWebContents(webContentsId);
});
}
@@ -73,6 +76,7 @@ export function registerAttachedPaseoBrowser(input: RegisterAttachedBrowserInput
browserRegistry.registerWebContents({
webContentsId: input.webContentsId,
browserId: input.browserId,
hostWebContentsId: input.sender.id,
});
browserRegistry.registerWorkspace({
browserId: input.browserId,
@@ -94,17 +98,24 @@ export function unregisterPaseoBrowser(browserId: string): void {
browserRegistry.unregisterBrowser(browserId);
}
export function unregisterPaseoBrowserFromHost(hostWebContentsId: number, browserId: string): void {
browserRegistry.unregisterBrowserFromHost(hostWebContentsId, browserId);
}
export function unregisterPaseoBrowserHost(hostWebContentsId: number): void {
browserRegistry.unregisterHostWebContents(hostWebContentsId);
}
export function getPaseoBrowserWorkspaceId(browserId: string): string | null {
return browserRegistry.getWorkspaceId(browserId);
}
export function listRegisteredPaseoBrowserIdsForWorkspace(workspaceId: string): string[] {
return browserRegistry
.listBrowserIdsForWorkspace(workspaceId)
.filter((browserId) => getPaseoBrowserWebContents(browserId));
return browserRegistry.listBrowserIdsForWorkspace(workspaceId);
}
export function setWorkspaceActivePaseoBrowserId(input: {
hostWebContentsId: number;
workspaceId: string;
browserId: string | null;
}): void {
@@ -112,11 +123,24 @@ export function setWorkspaceActivePaseoBrowserId(input: {
}
export function getWorkspaceActivePaseoBrowserId(workspaceId: string): string | null {
return browserRegistry.getWorkspaceActiveBrowserId(workspaceId);
return browserRegistry.getMostRecentActiveBrowserIdForWorkspace(workspaceId);
}
export function getPaseoBrowserWebContents(browserId: string): WebContents | null {
const contentsId = browserRegistry.getWebContentsIdForBrowser(browserId);
export function getWorkspaceActivePaseoBrowserIdForHostWindow(
workspaceId: string,
hostWebContentsId: number,
): string | null {
return browserRegistry.getActiveBrowserIdForWorkspaceInHostWindow(hostWebContentsId, workspaceId);
}
export function getPaseoBrowserWebContentsForHostWindow(
browserId: string,
hostWebContentsId: number,
): WebContents | null {
const contentsId = browserRegistry.getWebContentsIdForBrowserInHostWindow(
hostWebContentsId,
browserId,
);
if (contentsId === null) {
return null;
}
@@ -128,9 +152,26 @@ export function getPaseoBrowserWebContents(browserId: string): WebContents | nul
return null;
}
export function getMostRecentWorkspaceActivePaseoBrowserWebContents(): WebContents | null {
const browserId = browserRegistry.getMostRecentWorkspaceActiveBrowserId();
return browserId ? getPaseoBrowserWebContents(browserId) : null;
export function getActivePaseoBrowserWebContentsForHostWindow(
hostWebContentsId: number,
): WebContents | null {
const browserId = browserRegistry.getActiveBrowserIdForHostWindow(hostWebContentsId);
if (!browserId) {
return null;
}
const contentsId = browserRegistry.getWebContentsIdForBrowserInHostWindow(
hostWebContentsId,
browserId,
);
if (contentsId === null) {
return null;
}
const contents = allWebContents.fromId(contentsId);
if (contents && !contents.isDestroyed()) {
return contents;
}
browserRegistry.unregisterWebContents(contentsId);
return null;
}
function preventUnsafeBrowserWebviewNavigation(

View File

@@ -2,42 +2,312 @@ import { describe, expect, it } from "vitest";
import { PaseoBrowserWebviewRegistry } from "./registry.js";
describe("PaseoBrowserWebviewRegistry", () => {
it("keeps one authoritative webContents target per browserId", () => {
it("keeps one authoritative webContents target per host and browser", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWebContents({
webContentsId: 1,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 2, browserId: "browser-a" });
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 2,
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getBrowserIdForWebContents(1)).toBeNull();
expect(registry.getBrowserIdForWebContents(2)).toBe("browser-a");
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
expect(registry.getRegistrationForWebContents(2)).toEqual({
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(2);
expect(registry.getWorkspaceId("browser-a")).toBe("workspace-a");
expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBe("browser-a");
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
});
it("ignores stale destroy events after a duplicate browserId moved", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 2, browserId: "browser-a" });
registry.registerWebContents({
webContentsId: 1,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 2,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.unregisterWebContents(1);
expect(registry.getWebContentsIdForBrowser("browser-a")).toBe(2);
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(2);
});
it("keeps one browser identity per webContents target", () => {
it("returns the active browser only from the requested host window", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({ webContentsId: 1, browserId: "browser-a" });
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.setWorkspaceActiveBrowser({ workspaceId: "workspace-a", browserId: "browser-a" });
registry.registerWebContents({ webContentsId: 1, browserId: "browser-b" });
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-first-window",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-second-window",
hostWebContentsId: 202,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-first-window",
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 202,
workspaceId: "workspace-a",
browserId: "browser-second-window",
});
expect(registry.getWebContentsIdForBrowser("browser-a")).toBeNull();
expect(registry.getWorkspaceId("browser-a")).toBeNull();
expect(registry.getWorkspaceActiveBrowserId("workspace-a")).toBeNull();
expect(registry.getBrowserIdForWebContents(1)).toBe("browser-b");
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-first-window");
expect(registry.getActiveBrowserIdForHostWindow(202)).toBe("browser-second-window");
expect(registry.getActiveBrowserIdForWorkspaceInHostWindow(101, "workspace-a")).toBe(
"browser-first-window",
);
expect(registry.getActiveBrowserIdForWorkspaceInHostWindow(202, "workspace-a")).toBe(
"browser-second-window",
);
});
it("keeps active updates and clears inside their owning host window", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-first-window",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-second-window",
hostWebContentsId: 202,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-first-window",
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 202,
workspaceId: "workspace-a",
browserId: "browser-second-window",
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-second-window",
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: null,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBeNull();
expect(registry.getActiveBrowserIdForHostWindow(202)).toBe("browser-second-window");
});
it("keeps same-browser active references in separate host windows", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 202,
workspaceId: "workspace-a",
browserId: "browser-a",
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
expect(registry.getActiveBrowserIdForHostWindow(202)).toBe("browser-a");
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBe(11);
expect(registry.getWebContentsIdForBrowserInHostWindow(202, "browser-a")).toBe(22);
});
it("removes only the closing host's same-browser guest", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
registry.unregisterHostWebContents(101);
expect(registry.getRegistrationForWebContents(11)).toBeNull();
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBeNull();
expect(registry.getRegistrationForWebContents(22)).toEqual({
browserId: "browser-a",
hostWebContentsId: 202,
});
expect(registry.getWebContentsIdForBrowserInHostWindow(202, "browser-a")).toBe(22);
});
it("unregisters a browser only from the requesting host", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
registry.registerWorkspace({ browserId: "browser-a", workspaceId: "workspace-a" });
registry.unregisterBrowserFromHost(101, "browser-a");
expect(registry.getWebContentsIdForBrowserInHostWindow(101, "browser-a")).toBeNull();
expect(registry.getWebContentsIdForBrowserInHostWindow(202, "browser-a")).toBe(22);
expect(registry.getWorkspaceId("browser-a")).toBe("workspace-a");
});
it("keeps another host's active browser when one guest is destroyed", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 202,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.unregisterWebContents(11);
expect(registry.getActiveBrowserIdForHostWindow(101)).toBeNull();
expect(registry.getActiveBrowserIdForHostWindow(202)).toBe("browser-a");
expect(registry.getWebContentsIdForBrowserInHostWindow(202, "browser-a")).toBe(22);
});
it("keeps the same-window active selection made before the guest attaches", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
});
it("keeps a pre-attach selection when another host attaches the same browser", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 202,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
expect(registry.getActiveBrowserIdForHostWindow(202)).toBeNull();
});
it("keeps a pre-attach selection when another host tears down the same browser", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.setWorkspaceActiveBrowser({
hostWebContentsId: 101,
workspaceId: "workspace-a",
browserId: "browser-a",
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
registry.unregisterWebContents(22);
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
expect(registry.getActiveBrowserIdForHostWindow(101)).toBe("browser-a");
});
it("reports when another host still owns the same browser", () => {
const registry = new PaseoBrowserWebviewRegistry();
registry.registerWebContents({
webContentsId: 11,
browserId: "browser-a",
hostWebContentsId: 101,
});
registry.registerWebContents({
webContentsId: 22,
browserId: "browser-a",
hostWebContentsId: 202,
});
expect(registry.hasBrowserInOtherHostWindow(101, "browser-a")).toBe(true);
expect(registry.hasBrowserInOtherHostWindow(202, "browser-a")).toBe(true);
expect(registry.hasBrowserInOtherHostWindow(101, "browser-b")).toBe(false);
registry.unregisterWebContents(22);
expect(registry.hasBrowserInOtherHostWindow(101, "browser-a")).toBe(false);
});
});

View File

@@ -3,59 +3,71 @@ export interface BrowserWorkspaceRegistration {
workspaceId: string;
}
export interface BrowserWebContentsRegistration {
browserId: string;
hostWebContentsId: number;
}
export class PaseoBrowserWebviewRegistry {
private readonly browserIdsByWebContentsId = new Map<number, string>();
private readonly webContentsIdsByBrowserId = new Map<string, number>();
private readonly registrationsByWebContentsId = new Map<number, BrowserWebContentsRegistration>();
private readonly webContentsIdsByHostAndBrowserId = new Map<string, number>();
private readonly workspaceIdsByBrowserId = new Map<string, string>();
private readonly activeBrowserIdsByWorkspaceId = new Map<string, string>();
private readonly activeBrowserIdsByHostWindow = new Map<number, Map<string, string>>();
public registerWebContents(input: { webContentsId: number; browserId: string }): void {
const previousBrowserId = this.browserIdsByWebContentsId.get(input.webContentsId) ?? null;
if (
previousBrowserId !== null &&
previousBrowserId !== input.browserId &&
this.webContentsIdsByBrowserId.get(previousBrowserId) === input.webContentsId
) {
this.webContentsIdsByBrowserId.delete(previousBrowserId);
this.workspaceIdsByBrowserId.delete(previousBrowserId);
this.deleteActiveBrowserReferences(previousBrowserId);
public registerWebContents(input: {
webContentsId: number;
browserId: string;
hostWebContentsId: number;
}): void {
const hostBrowserKey = this.hostBrowserKey(input.hostWebContentsId, input.browserId);
const replacedWebContentsId = this.webContentsIdsByHostAndBrowserId.get(hostBrowserKey);
if (replacedWebContentsId !== undefined && replacedWebContentsId !== input.webContentsId) {
this.removeWebContents(replacedWebContentsId, { preserveActiveBrowser: true });
}
if (this.registrationsByWebContentsId.has(input.webContentsId)) {
this.removeWebContents(input.webContentsId);
}
const previousWebContentsId = this.webContentsIdsByBrowserId.get(input.browserId) ?? null;
if (previousWebContentsId !== null && previousWebContentsId !== input.webContentsId) {
this.browserIdsByWebContentsId.delete(previousWebContentsId);
}
this.browserIdsByWebContentsId.set(input.webContentsId, input.browserId);
this.webContentsIdsByBrowserId.set(input.browserId, input.webContentsId);
this.registrationsByWebContentsId.set(input.webContentsId, {
browserId: input.browserId,
hostWebContentsId: input.hostWebContentsId,
});
this.webContentsIdsByHostAndBrowserId.set(hostBrowserKey, input.webContentsId);
}
public unregisterWebContents(webContentsId: number): void {
const browserId = this.browserIdsByWebContentsId.get(webContentsId) ?? null;
if (!browserId) {
if (!this.registrationsByWebContentsId.has(webContentsId)) {
return;
}
this.browserIdsByWebContentsId.delete(webContentsId);
if (this.webContentsIdsByBrowserId.get(browserId) !== webContentsId) {
return;
}
this.webContentsIdsByBrowserId.delete(browserId);
this.workspaceIdsByBrowserId.delete(browserId);
this.deleteActiveBrowserReferences(browserId);
this.removeWebContents(webContentsId);
}
public getBrowserIdForWebContents(webContentsId: number): string | null {
return this.browserIdsByWebContentsId.get(webContentsId) ?? null;
return this.registrationsByWebContentsId.get(webContentsId)?.browserId ?? null;
}
public getWebContentsIdForBrowser(browserId: string): number | null {
return this.webContentsIdsByBrowserId.get(browserId) ?? null;
public getRegistrationForWebContents(
webContentsId: number,
): BrowserWebContentsRegistration | null {
return this.registrationsByWebContentsId.get(webContentsId) ?? null;
}
public getWebContentsIdForBrowserInHostWindow(
hostWebContentsId: number,
browserId: string,
): number | null {
return (
this.webContentsIdsByHostAndBrowserId.get(
this.hostBrowserKey(hostWebContentsId, browserId),
) ?? null
);
}
public listBrowserIds(): string[] {
return Array.from(this.webContentsIdsByBrowserId.keys()).sort();
return Array.from(
new Set(Array.from(this.registrationsByWebContentsId.values(), ({ browserId }) => browserId)),
).sort();
}
public registerWorkspace(input: BrowserWorkspaceRegistration): void {
@@ -63,48 +75,176 @@ export class PaseoBrowserWebviewRegistry {
}
public unregisterBrowser(browserId: string): void {
const webContentsId = this.webContentsIdsByBrowserId.get(browserId) ?? null;
if (webContentsId !== null) {
this.browserIdsByWebContentsId.delete(webContentsId);
this.webContentsIdsByBrowserId.delete(browserId);
for (const [webContentsId, registration] of this.registrationsByWebContentsId) {
if (registration.browserId === browserId) {
this.registrationsByWebContentsId.delete(webContentsId);
this.webContentsIdsByHostAndBrowserId.delete(
this.hostBrowserKey(registration.hostWebContentsId, browserId),
);
}
}
this.workspaceIdsByBrowserId.delete(browserId);
this.deleteActiveBrowserReferences(browserId);
}
public unregisterBrowserFromHost(hostWebContentsId: number, browserId: string): void {
const webContentsId = this.getWebContentsIdForBrowserInHostWindow(hostWebContentsId, browserId);
if (webContentsId !== null) {
this.unregisterWebContents(webContentsId);
}
}
public getWorkspaceId(browserId: string): string | null {
return this.workspaceIdsByBrowserId.get(browserId) ?? null;
}
public hasBrowserInOtherHostWindow(hostWebContentsId: number, browserId: string): boolean {
for (const registration of this.registrationsByWebContentsId.values()) {
if (
registration.browserId === browserId &&
registration.hostWebContentsId !== hostWebContentsId
) {
return true;
}
}
return false;
}
public unregisterHostWebContents(hostWebContentsId: number): void {
for (const [webContentsId, registration] of this.registrationsByWebContentsId) {
if (registration.hostWebContentsId === hostWebContentsId) {
this.unregisterWebContents(webContentsId);
}
}
this.activeBrowserIdsByHostWindow.delete(hostWebContentsId);
}
public listBrowserIdsForWorkspace(workspaceId: string): string[] {
return this.listBrowserIds().filter(
(browserId) => this.workspaceIdsByBrowserId.get(browserId) === workspaceId,
);
}
public setWorkspaceActiveBrowser(input: { workspaceId: string; browserId: string | null }): void {
if (input.browserId) {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
this.activeBrowserIdsByWorkspaceId.delete(input.workspaceId);
this.activeBrowserIdsByWorkspaceId.set(input.workspaceId, input.browserId);
public setWorkspaceActiveBrowser(input: {
hostWebContentsId: number;
workspaceId: string;
browserId: string | null;
}): void {
if (input.browserId === null) {
const activeBrowserIdsByWorkspace = this.activeBrowserIdsByHostWindow.get(
input.hostWebContentsId,
);
if (!activeBrowserIdsByWorkspace) {
return;
}
activeBrowserIdsByWorkspace.delete(input.workspaceId);
if (activeBrowserIdsByWorkspace.size === 0) {
this.activeBrowserIdsByHostWindow.delete(input.hostWebContentsId);
}
return;
}
this.activeBrowserIdsByWorkspaceId.delete(input.workspaceId);
if (this.hasBrowser(input.browserId)) {
this.workspaceIdsByBrowserId.set(input.browserId, input.workspaceId);
}
const activeBrowserIdsByWorkspace =
this.activeBrowserIdsByHostWindow.get(input.hostWebContentsId) ?? new Map<string, string>();
activeBrowserIdsByWorkspace.delete(input.workspaceId);
activeBrowserIdsByWorkspace.set(input.workspaceId, input.browserId);
this.activeBrowserIdsByHostWindow.delete(input.hostWebContentsId);
this.activeBrowserIdsByHostWindow.set(input.hostWebContentsId, activeBrowserIdsByWorkspace);
}
public getWorkspaceActiveBrowserId(workspaceId: string): string | null {
return this.activeBrowserIdsByWorkspaceId.get(workspaceId) ?? null;
public getActiveBrowserIdForHostWindow(hostWebContentsId: number): string | null {
return (
Array.from(this.activeBrowserIdsByHostWindow.get(hostWebContentsId)?.values() ?? []).at(-1) ??
null
);
}
public getMostRecentWorkspaceActiveBrowserId(): string | null {
return Array.from(this.activeBrowserIdsByWorkspaceId.values()).at(-1) ?? null;
public getActiveBrowserIdForWorkspaceInHostWindow(
hostWebContentsId: number,
workspaceId: string,
): string | null {
return this.activeBrowserIdsByHostWindow.get(hostWebContentsId)?.get(workspaceId) ?? null;
}
public getMostRecentActiveBrowserIdForWorkspace(workspaceId: string): string | null {
const activeBrowserIdsByHostWindow = Array.from(this.activeBrowserIdsByHostWindow.values());
for (let index = activeBrowserIdsByHostWindow.length - 1; index >= 0; index -= 1) {
const browserId = activeBrowserIdsByHostWindow[index].get(workspaceId);
if (browserId) {
return browserId;
}
}
return null;
}
private deleteActiveBrowserReferences(browserId: string): void {
for (const [workspaceId, activeBrowserId] of this.activeBrowserIdsByWorkspaceId) {
if (activeBrowserId === browserId) {
this.activeBrowserIdsByWorkspaceId.delete(workspaceId);
for (const [hostWebContentsId, activeBrowserIdsByWorkspace] of this
.activeBrowserIdsByHostWindow) {
for (const [workspaceId, activeBrowserId] of activeBrowserIdsByWorkspace) {
if (activeBrowserId === browserId) {
activeBrowserIdsByWorkspace.delete(workspaceId);
}
}
if (activeBrowserIdsByWorkspace.size === 0) {
this.activeBrowserIdsByHostWindow.delete(hostWebContentsId);
}
}
}
private deleteActiveBrowserReferencesInHostWindow(
browserId: string,
hostWebContentsId: number,
): void {
const activeBrowserIdsByWorkspace = this.activeBrowserIdsByHostWindow.get(hostWebContentsId);
if (!activeBrowserIdsByWorkspace) {
return;
}
for (const [workspaceId, activeBrowserId] of activeBrowserIdsByWorkspace) {
if (activeBrowserId === browserId) {
activeBrowserIdsByWorkspace.delete(workspaceId);
}
}
if (activeBrowserIdsByWorkspace.size === 0) {
this.activeBrowserIdsByHostWindow.delete(hostWebContentsId);
}
}
private removeWebContents(
webContentsId: number,
options: { preserveActiveBrowser?: boolean } = {},
): void {
const registration = this.registrationsByWebContentsId.get(webContentsId);
if (!registration) {
return;
}
const { browserId, hostWebContentsId } = registration;
this.registrationsByWebContentsId.delete(webContentsId);
this.webContentsIdsByHostAndBrowserId.delete(this.hostBrowserKey(hostWebContentsId, browserId));
if (
!options.preserveActiveBrowser &&
!this.hasBrowserInHostWindow(browserId, hostWebContentsId)
) {
this.deleteActiveBrowserReferencesInHostWindow(browserId, hostWebContentsId);
}
}
private hasBrowser(browserId: string): boolean {
return Array.from(this.registrationsByWebContentsId.values()).some(
(registration) => registration.browserId === browserId,
);
}
private hasBrowserInHostWindow(browserId: string, hostWebContentsId: number): boolean {
return this.webContentsIdsByHostAndBrowserId.has(
this.hostBrowserKey(hostWebContentsId, browserId),
);
}
private hostBrowserKey(hostWebContentsId: number, browserId: string): string {
return `${hostWebContentsId}:${browserId}`;
}
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { reloadActiveBrowserOrWindow } from "./menu.js";
class FakeWebContents {
public readonly reloads: string[] = [];
public constructor(public readonly id: number) {}
public isLoadingMainFrame(): boolean {
return false;
}
public stop(): void {
this.reloads.push("stop");
}
public reload(): void {
this.reloads.push("reload");
}
public reloadIgnoringCache(): void {
this.reloads.push("force-reload");
}
}
class BrowserReloads {
public readonly firstWindow = { webContents: new FakeWebContents(101) };
public readonly secondWindow = { webContents: new FakeWebContents(202) };
public readonly firstBrowser = new FakeWebContents(11);
public readonly secondBrowser = new FakeWebContents(22);
public readonly resolvedHostWindowIds: number[] = [];
public activeBrowserForHostWindow(hostWebContentsId: number): FakeWebContents | null {
this.resolvedHostWindowIds.push(hostWebContentsId);
return hostWebContentsId === 101 ? this.firstBrowser : this.secondBrowser;
}
}
describe("reloadActiveBrowserOrWindow", () => {
it("reloads only the active browser belonging to the supplied window", () => {
const browserReloads = new BrowserReloads();
reloadActiveBrowserOrWindow({
win: browserReloads.firstWindow,
getActiveBrowserContentsForHostWindow:
browserReloads.activeBrowserForHostWindow.bind(browserReloads),
});
expect(browserReloads.resolvedHostWindowIds).toEqual([101]);
expect(browserReloads.firstBrowser.reloads).toEqual(["reload"]);
expect(browserReloads.secondBrowser.reloads).toEqual([]);
expect(browserReloads.firstWindow.webContents.reloads).toEqual([]);
});
it("force reloads only the active browser belonging to the supplied window", () => {
const browserReloads = new BrowserReloads();
reloadActiveBrowserOrWindow({
win: browserReloads.secondWindow,
getActiveBrowserContentsForHostWindow:
browserReloads.activeBrowserForHostWindow.bind(browserReloads),
ignoreCache: true,
});
expect(browserReloads.resolvedHostWindowIds).toEqual([202]);
expect(browserReloads.firstBrowser.reloads).toEqual([]);
expect(browserReloads.secondBrowser.reloads).toEqual(["force-reload"]);
expect(browserReloads.secondWindow.webContents.reloads).toEqual([]);
});
});

View File

@@ -1,5 +1,5 @@
import { app, Menu, BrowserWindow, ipcMain } from "electron";
import { getMostRecentWorkspaceActivePaseoBrowserWebContents } from "./browser-webviews/index.js";
import { getActivePaseoBrowserWebContentsForHostWindow } from "./browser-webviews/index.js";
interface ShowContextMenuInput {
kind?: "terminal";
@@ -19,14 +19,33 @@ function withBrowserWindow(
};
}
function getReloadTargetBrowserWebContents(): Electron.WebContents | null {
return getMostRecentWorkspaceActivePaseoBrowserWebContents();
interface ReloadableWebContents {
isLoadingMainFrame(): boolean;
stop(): void;
reload(): void;
reloadIgnoringCache(): void;
}
function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCache?: boolean }) {
const browserContents = getReloadTargetBrowserWebContents();
interface ReloadableWindow {
webContents: ReloadableWebContents & { id: number };
}
interface ReloadActiveBrowserOrWindowInput {
win: ReloadableWindow;
getActiveBrowserContentsForHostWindow: (
hostWebContentsId: number,
) => ReloadableWebContents | null;
ignoreCache?: boolean;
}
export function reloadActiveBrowserOrWindow({
win,
getActiveBrowserContentsForHostWindow,
ignoreCache = false,
}: ReloadActiveBrowserOrWindowInput): void {
const browserContents = getActiveBrowserContentsForHostWindow(win.webContents.id);
if (browserContents) {
if (options?.ignoreCache) {
if (ignoreCache) {
browserContents.reloadIgnoringCache();
return;
}
@@ -38,7 +57,7 @@ function reloadFocusedContentsOrWindow(win: BrowserWindow, options?: { ignoreCac
return;
}
if (options?.ignoreCache) {
if (ignoreCache) {
win.webContents.reloadIgnoringCache();
return;
}
@@ -127,14 +146,21 @@ function buildApplicationMenuTemplate(
label: "Reload",
accelerator: "CmdOrCtrl+R",
click: withBrowserWindow((win) => {
reloadFocusedContentsOrWindow(win);
reloadActiveBrowserOrWindow({
win,
getActiveBrowserContentsForHostWindow: getActivePaseoBrowserWebContentsForHostWindow,
});
}),
},
{
label: "Force Reload",
accelerator: "CmdOrCtrl+Shift+R",
click: withBrowserWindow((win) => {
reloadFocusedContentsOrWindow(win, { ignoreCache: true });
reloadActiveBrowserOrWindow({
win,
getActiveBrowserContentsForHostWindow: getActivePaseoBrowserWebContentsForHostWindow,
ignoreCache: true,
});
}),
},
{ role: "toggleDevTools" },

View File

@@ -45,21 +45,23 @@ import {
ensureNotificationCenterRegistration,
} from "./features/notifications.js";
import { registerOpenerHandlers } from "./features/opener.js";
import { registerEditorTargetHandlers } from "./features/editor-targets/ipc.js";
import { registerEditorTargetHandlers } from "./features/editor-targets.js";
import { setupApplicationMenu } from "./features/menu.js";
import {
BROWSER_NEW_TAB_REQUEST_EVENT,
decideBrowserWindowOpenRequest,
getPaseoBrowserIdForWebContents,
getPaseoBrowserWebContents,
getPaseoBrowserWebContentsForHostWindow,
getPaseoBrowserWebviewRegistry,
listRegisteredPaseoBrowserIds,
isPaseoBrowserWebviewAttach,
preparePaseoBrowserWebContents,
PendingBrowserWindowOpenRequests,
registerBrowserWebviewNavigationGuards,
unregisterPaseoBrowser,
unregisterPaseoBrowserFromHost,
registerAttachedPaseoBrowser,
setWorkspaceActivePaseoBrowserId,
unregisterPaseoBrowserHost,
} from "./features/browser-webviews/index.js";
import {
clearPaseoBrowserProfile,
@@ -85,6 +87,7 @@ import {
import { runDesktopStartup } from "./desktop-startup.js";
import { autoUpdateInstalledSkills } from "./integrations/skills/index.js";
import { registerBrowserAutomationIpc } from "./features/browser-automation/ipc.js";
import { BrowserKeyboard } from "./features/browser-keyboard/index.js";
const DEV_SERVER_URL = process.env.EXPO_DEV_URL ?? "http://localhost:8081";
const APP_SCHEME = "paseo";
@@ -93,35 +96,6 @@ const DISABLE_SINGLE_INSTANCE_LOCK = process.env.PASEO_DISABLE_SINGLE_INSTANCE_L
const APP_NAME = process.env.PASEO_TEST_APP_NAME?.trim() || "Paseo";
const pendingBrowserWindowOpenRequests = new PendingBrowserWindowOpenRequests();
const BROWSER_SHORTCUT_EVENT = "paseo:event:browser-shortcut";
const BROWSER_FORWARDED_KEY_EVENT = "paseo:event:browser-forwarded-key";
const FORWARDED_PASEO_SHORTCUT_KEYS = new Set([
"b",
"e",
"w",
"t",
"k",
"o",
"/",
"\\",
",",
".",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"enter",
"arrowleft",
"arrowright",
"arrowup",
"arrowdown",
]);
app.setName(APP_NAME);
interface AttachedBrowserInput {
@@ -169,29 +143,8 @@ function readActiveBrowserInput(
return { workspaceId: record.workspaceId.trim(), browserId: browserId || null };
}
function isBrowserRefreshInput(input: Electron.Input): boolean {
if (input.type !== "keyDown" || input.alt || input.shift) {
return false;
}
return (input.meta || input.control) && input.key.toLowerCase() === "r";
}
function isBrowserLocationInput(input: Electron.Input): boolean {
if (input.type !== "keyDown" || input.alt || input.shift) {
return false;
}
return (input.meta || input.control) && input.key.toLowerCase() === "l";
}
function isForwardablePaseoShortcutInput(input: Electron.Input): boolean {
if (input.type !== "keyDown") {
return false;
}
if (!input.meta && !input.control) {
return false;
}
return FORWARDED_PASEO_SHORTCUT_KEYS.has(input.key.toLowerCase());
}
const browserKeyboard = new BrowserKeyboard(getPaseoBrowserWebviewRegistry());
browserKeyboard.registerIpc();
function showBrowserWebviewContextMenu(
win: BrowserWindow,
@@ -432,6 +385,11 @@ ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) =>
if (!registered) {
throw new Error("Attached browser registration was rejected");
}
const guest = webContents.fromId(input.webContentsId);
if (!guest) {
throw new Error("Attached browser guest disappeared after registration");
}
browserKeyboard.attach({ contents: guest, hostContents: event.sender });
log.info("[browser-webview] registered", {
browserId: input.browserId,
webContentsId: input.webContentsId,
@@ -445,12 +403,18 @@ ipcMain.handle("paseo:browser:register-attached", (event, rawInput: unknown) =>
}
});
ipcMain.handle("paseo:browser:unregister-workspace-browser", async (_event, browserId: unknown) => {
ipcMain.handle("paseo:browser:unregister-workspace-browser", async (event, browserId: unknown) => {
if (typeof browserId === "string" && browserId.trim().length > 0) {
const normalizedBrowserId = browserId.trim();
unregisterPaseoBrowser(normalizedBrowserId);
const hasOtherHost = getPaseoBrowserWebviewRegistry().hasBrowserInOtherHostWindow(
event.sender.id,
normalizedBrowserId,
);
unregisterPaseoBrowserFromHost(event.sender.id, normalizedBrowserId);
// COMPAT(browserProfile): added in v0.1.108; remove after 2027-01-15.
const legacyProfile = getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId);
const legacyProfile = hasOtherHost
? null
: getLegacyPaseoBrowserProfileSession(session, normalizedBrowserId);
if (legacyProfile) {
try {
await clearPaseoBrowserProfile({
@@ -468,14 +432,14 @@ ipcMain.handle("paseo:browser:unregister-workspace-browser", async (_event, brow
}
});
ipcMain.handle("paseo:browser:set-workspace-active-browser", (_event, rawInput: unknown) => {
ipcMain.handle("paseo:browser:set-workspace-active-browser", (event, rawInput: unknown) => {
const input = readActiveBrowserInput(rawInput);
if (input) {
setWorkspaceActivePaseoBrowserId(input);
setWorkspaceActivePaseoBrowserId({ ...input, hostWebContentsId: event.sender.id });
}
});
ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => {
ipcMain.handle("paseo:browser:open-devtools", (event, browserId: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
const result = {
ok: false,
@@ -486,7 +450,7 @@ ipcMain.handle("paseo:browser:open-devtools", (_event, browserId: unknown) => {
log.warn("[browser-devtools] open-devtools.invalid", result);
return result;
}
const contents = getPaseoBrowserWebContents(browserId);
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id);
if (!contents) {
const result = {
ok: false,
@@ -537,11 +501,11 @@ ipcMain.handle("paseo:browser:clear-profile", async (_event, rawLegacyBrowserIds
ipcMain.handle(
"paseo:browser:capture-element",
async (_event, browserId: unknown, rect: unknown) => {
async (event, browserId: unknown, rect: unknown) => {
if (typeof browserId !== "string" || browserId.trim().length === 0) {
return null;
}
const contents = getPaseoBrowserWebContents(browserId);
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, event.sender.id);
if (!contents || contents.isDestroyed()) {
return null;
}
@@ -622,6 +586,10 @@ function getPreloadPath(): string {
return path.join(__dirname, "preload.js");
}
function getBrowserKeyboardPreloadPath(): string {
return path.join(__dirname, "features", "browser-keyboard", "guest-preload.js");
}
function getAppDistDir(): string {
if (app.isPackaged) {
return path.join(process.resourcesPath, "app-dist");
@@ -725,6 +693,8 @@ async function createWindow(
pendingOpenProjectStore.set(webContentsId, options.pendingOpenProjectPath);
mainWindow.on("closed", () => {
pendingOpenProjectStore.delete(webContentsId);
unregisterPaseoBrowserHost(webContentsId);
browserKeyboard.detachHost(webContentsId);
});
if (devWorktreeName) {
@@ -748,7 +718,9 @@ async function createWindow(
return;
}
webPreferences.nodeIntegration = false;
webPreferences.nodeIntegrationInSubFrames = false;
// The sandboxed keyboard preload must run in every frame so focused iframes keep
// the same page-first shortcut boundary. Node integration remains disabled.
webPreferences.nodeIntegrationInSubFrames = true;
webPreferences.nodeIntegrationInWorker = false;
webPreferences.contextIsolation = true;
webPreferences.sandbox = true;
@@ -759,43 +731,13 @@ async function createWindow(
delete params.preload;
delete (webPreferences as { preloadURL?: string }).preloadURL;
delete (params as { preloadURL?: string }).preloadURL;
webPreferences.preload = getBrowserKeyboardPreloadPath();
});
mainWindow.webContents.on("did-attach-webview", (_event, contents) => {
preparePaseoBrowserWebContents(contents);
contents.once("destroyed", () => {
pendingBrowserWindowOpenRequests.delete(contents.id);
});
contents.on("before-input-event", (event, input) => {
if (isBrowserRefreshInput(input)) {
event.preventDefault();
if (contents.isLoadingMainFrame()) {
contents.stop();
} else {
contents.reload();
}
return;
}
if (isBrowserLocationInput(input)) {
event.preventDefault();
const focusedBrowserId = getPaseoBrowserIdForWebContents(contents);
mainWindow.webContents.send(BROWSER_SHORTCUT_EVENT, {
action: "focus-url",
...(focusedBrowserId ? { browserId: focusedBrowserId } : {}),
});
return;
}
if (isForwardablePaseoShortcutInput(input)) {
event.preventDefault();
mainWindow.webContents.send(BROWSER_FORWARDED_KEY_EVENT, {
key: input.key,
code: input.code,
meta: input.meta,
control: input.control,
shift: input.shift,
alt: input.alt,
});
}
});
installBrowserWindowOpenHandler({
contents,
sourceContents: contents,

View File

@@ -1,12 +1,6 @@
import { contextBridge, ipcRenderer, webUtils } from "electron";
// This preload runs in Electron's sandbox and is tsc-compiled (not bundled), so it MUST
// NOT emit any runtime module load other than "electron" — a require() of a local or
// third-party module throws and aborts the preload before exposeInMainWorld runs, leaving
// window.paseoDesktop undefined (the 0.1.108 regression, #2103). Keep this literal in sync
// with PASEO_BROWSER_PROFILE_PARTITION in features/browser-profile.ts; preload-sandbox.test.ts
// guards both the no-local-import rule and this drift. Type-only imports are fine (erased at emit).
const PASEO_BROWSER_PROFILE_PARTITION = "persist:paseo-browser";
import type { BrowserKeyboardPolicy } from "./features/browser-keyboard/index.js";
import { PASEO_BROWSER_PROFILE_PARTITION } from "./features/browser-profile.js";
type EventHandler = (payload: unknown) => void;
@@ -78,10 +72,9 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
listTargets: () => ipcRenderer.invoke("paseo:editor:listTargets"),
openTarget: (input: {
editorId: string;
workspacePath: string;
filePath?: string;
line?: number;
column?: number;
path: string;
cwd?: string;
mode?: "open" | "reveal";
}) => ipcRenderer.invoke("paseo:editor:openTarget", input),
},
webUtils: {
@@ -94,6 +87,8 @@ contextBridge.exposeInMainWorld("paseoDesktop", {
ipcRenderer.invoke("paseo:menu:set-capturing-shortcut", capturing),
},
browser: {
setShortcutPolicy: (input: BrowserKeyboardPolicy) =>
ipcRenderer.invoke("paseo:browser:set-shortcut-policy", input),
profilePartition: PASEO_BROWSER_PROFILE_PARTITION,
registerAttachedBrowser: (input: AttachedBrowserRegistration) =>
ipcRenderer.invoke("paseo:browser:register-attached", input),