Keep browser input from submitting Paseo prompts (#1982)
* 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. * fix(desktop): tighten browser shortcut validation * fix(desktop): respect browser shortcut ownership * fix(desktop): retain browser keys across windows * fix(desktop): scope browser webviews by host * Reshape browser shortcut ownership Use the browser webview registry as the single guest identity authority, scope browser operations to their host window, publish chord continuations only while pending, and restore focus to the originating browser after command center dismissal. * Fix host-scoped browser shortcut follow-ups * Fix browser keyboard review follow-ups * Fix browser keyboard lifecycle regressions * Fix browser shortcut review regressions * Fix desktop browser review regressions * Fix browser shortcut frame regressions * Fix rebase integration regressions * Preserve browser-native shortcut ownership * Isolate browser shortcuts and automation by context
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { Rectangle } from "electron";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import type { TabImage } from "./service.js";
|
||||
import { adaptWebContents } from "./ipc.js";
|
||||
import { adaptWebContents, HostSnapshotEngineRegistry } 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,30 @@ class FakeWebContents {
|
||||
}
|
||||
|
||||
describe("browser automation IPC adapter", () => {
|
||||
test("isolates snapshot refs by host window and releases them on destruction", () => {
|
||||
const registry = new HostSnapshotEngineRegistry();
|
||||
const firstHost = new FakeHostWebContents(1);
|
||||
const secondHost = new FakeHostWebContents(2);
|
||||
|
||||
const firstEngine = registry.get(firstHost);
|
||||
expect(registry.get(firstHost)).toBe(firstEngine);
|
||||
expect(registry.get(secondHost)).not.toBe(firstEngine);
|
||||
|
||||
firstHost.destroy();
|
||||
expect(registry.get(new FakeHostWebContents(1))).not.toBe(firstEngine);
|
||||
});
|
||||
|
||||
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 +248,7 @@ describe("browser automation IPC adapter", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
contents.destroy();
|
||||
expect(() => contents.destroy()).not.toThrow();
|
||||
|
||||
expect(tab.getConsoleMessages?.()).toEqual([]);
|
||||
});
|
||||
@@ -536,6 +573,21 @@ describe("browser automation IPC adapter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
class FakeHostWebContents {
|
||||
private destroyedListener: (() => void) | null = null;
|
||||
|
||||
public constructor(public readonly id: number) {}
|
||||
|
||||
public once(event: "destroyed", listener: () => void): void {
|
||||
expect(event).toBe("destroyed");
|
||||
this.destroyedListener = listener;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.destroyedListener?.();
|
||||
}
|
||||
}
|
||||
|
||||
async function flushMicrotasks(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
@@ -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,
|
||||
@@ -16,11 +17,12 @@ import {
|
||||
promptShimRestoreScript,
|
||||
} from "./dialog-handling.js";
|
||||
import { executeAutomationCommand } from "./service.js";
|
||||
import { BrowserSnapshotEngine } from "./snapshot-engine.js";
|
||||
import {
|
||||
listRegisteredPaseoBrowserIds,
|
||||
listRegisteredPaseoBrowserIdsForWorkspace,
|
||||
getPaseoBrowserWebContents,
|
||||
getWorkspaceActivePaseoBrowserId,
|
||||
getPaseoBrowserWebContentsForHostWindow,
|
||||
getWorkspaceActivePaseoBrowserIdForHostWindow,
|
||||
getPaseoBrowserWorkspaceId,
|
||||
} from "../browser-webviews/index.js";
|
||||
|
||||
@@ -34,6 +36,36 @@ interface IpcHandlerRegistry {
|
||||
handle(channel: string, listener: (event: unknown, ...args: unknown[]) => unknown): void;
|
||||
}
|
||||
|
||||
interface HostWebContents {
|
||||
readonly id: number;
|
||||
once(event: "destroyed", listener: () => void): void;
|
||||
}
|
||||
|
||||
export class HostSnapshotEngineRegistry {
|
||||
private readonly entries = new Map<
|
||||
number,
|
||||
{ hostContents: HostWebContents; snapshotEngine: BrowserSnapshotEngine }
|
||||
>();
|
||||
|
||||
public get(hostContents: HostWebContents): BrowserSnapshotEngine {
|
||||
const existing = this.entries.get(hostContents.id);
|
||||
if (existing) {
|
||||
return existing.snapshotEngine;
|
||||
}
|
||||
const snapshotEngine = new BrowserSnapshotEngine();
|
||||
const entry = { hostContents, snapshotEngine };
|
||||
this.entries.set(hostContents.id, entry);
|
||||
hostContents.once("destroyed", () => {
|
||||
if (this.entries.get(hostContents.id) === entry) {
|
||||
this.entries.delete(hostContents.id);
|
||||
}
|
||||
});
|
||||
return snapshotEngine;
|
||||
}
|
||||
}
|
||||
|
||||
const hostSnapshotEngines = new HostSnapshotEngineRegistry();
|
||||
|
||||
interface WebContentsDebugger {
|
||||
isAttached(): boolean;
|
||||
attach(protocolVersion?: string): void;
|
||||
@@ -74,14 +106,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 +129,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 +152,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 +192,7 @@ class DialogMonitor {
|
||||
|
||||
public constructor(
|
||||
private readonly contents: BrowserAutomationWebContents,
|
||||
private readonly contentsId: number,
|
||||
private readonly cdpQueue: CdpSessionQueue,
|
||||
) {}
|
||||
|
||||
@@ -168,7 +205,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: [] };
|
||||
@@ -337,24 +374,38 @@ function normalizeConsoleMessage(input: {
|
||||
};
|
||||
}
|
||||
|
||||
function createRegistry(): BrowserRegistry {
|
||||
function createRegistry(hostWebContentsId: number): BrowserRegistry {
|
||||
return {
|
||||
listRegisteredBrowserIds: listRegisteredPaseoBrowserIds,
|
||||
listRegisteredBrowserIdsForWorkspace: listRegisteredPaseoBrowserIdsForWorkspace,
|
||||
getTabContents(browserId: string): TabContents | null {
|
||||
const contents = getPaseoBrowserWebContents(browserId);
|
||||
const contents = getPaseoBrowserWebContentsForHostWindow(browserId, hostWebContentsId);
|
||||
return contents ? adaptWebContents(contents) : null;
|
||||
},
|
||||
getBrowserWorkspaceId: getPaseoBrowserWorkspaceId,
|
||||
getWorkspaceActiveBrowserId: getWorkspaceActivePaseoBrowserId,
|
||||
getWorkspaceActiveBrowserId(workspaceId: string): string | null {
|
||||
return getWorkspaceActivePaseoBrowserIdForHostWindow(workspaceId, hostWebContentsId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function registerBrowserAutomationIpc(options?: { ipc?: IpcHandlerRegistry }): void {
|
||||
const ipc = options?.ipc ?? ipcMain;
|
||||
const registry = createRegistry();
|
||||
|
||||
ipc.handle("paseo:browser:execute-automation-command", async (_event, rawRequest: unknown) => {
|
||||
ipc.handle("paseo:browser:execute-automation-command", async (event, rawRequest: unknown) => {
|
||||
const hostContents = (event as { sender?: HostWebContents }).sender;
|
||||
const hostWebContentsId = hostContents?.id;
|
||||
if (!hostContents || typeof hostWebContentsId !== "number") {
|
||||
return {
|
||||
requestId: readRequestId(rawRequest),
|
||||
ok: false as const,
|
||||
error: {
|
||||
code: "browser_unsupported" as const,
|
||||
message: "Browser automation requires a host window.",
|
||||
},
|
||||
};
|
||||
}
|
||||
const registry = createRegistry(hostWebContentsId);
|
||||
const parsed = BrowserAutomationExecuteRequestSchema.safeParse(rawRequest);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
@@ -367,7 +418,9 @@ export function registerBrowserAutomationIpc(options?: { ipc?: IpcHandlerRegistr
|
||||
},
|
||||
};
|
||||
}
|
||||
return executeAutomationCommand(parsed.data, registry);
|
||||
return executeAutomationCommand(parsed.data, registry, {
|
||||
snapshotEngine: hostSnapshotEngines.get(hostContents),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
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", ["keyDown", "char", "keyUp"]],
|
||||
["Z", "Z", ["keyDown", "char", "keyUp"]],
|
||||
["ArrowDown", "Down", ["keyDown", "keyUp"]],
|
||||
])(
|
||||
"sends %s as Electron key code %s with unhandled redispatch disabled",
|
||||
(key, keyCode, types) => {
|
||||
const events: IsolatedKeyboardInputEvent[] = [];
|
||||
|
||||
await dispatchTrustedKey(async (command, params) => {
|
||||
commands.push({ command, ...(params ? { params } : {}) });
|
||||
return {};
|
||||
dispatchTrustedKey((event) => {
|
||||
events.push(event);
|
||||
}, key);
|
||||
|
||||
expect(events).toEqual(
|
||||
types.map((type) => ({
|
||||
type,
|
||||
keyCode,
|
||||
skipIfUnhandled: true,
|
||||
})),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("inserts a named Space keypress", () => {
|
||||
const events: IsolatedKeyboardInputEvent[] = [];
|
||||
|
||||
dispatchTrustedKey((event) => {
|
||||
events.push(event);
|
||||
}, "Space");
|
||||
|
||||
expect(commands).toEqual([
|
||||
{
|
||||
command: "Input.dispatchKeyEvent",
|
||||
params: {
|
||||
type: "keyDown",
|
||||
key: " ",
|
||||
code: "Space",
|
||||
windowsVirtualKeyCode: 32,
|
||||
nativeVirtualKeyCode: 32,
|
||||
text: " ",
|
||||
unmodifiedText: " ",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "Input.dispatchKeyEvent",
|
||||
params: {
|
||||
type: "keyUp",
|
||||
key: " ",
|
||||
code: "Space",
|
||||
windowsVirtualKeyCode: 32,
|
||||
nativeVirtualKeyCode: 32,
|
||||
},
|
||||
},
|
||||
expect(events).toEqual([
|
||||
{ type: "keyDown", keyCode: "Space", skipIfUnhandled: true },
|
||||
{ type: "char", keyCode: " ", skipIfUnhandled: true },
|
||||
{ type: "keyUp", keyCode: "Space", skipIfUnhandled: true },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "char" | "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,43 +161,31 @@ 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", {
|
||||
type: "keyDown",
|
||||
key: definition.key,
|
||||
code: definition.code,
|
||||
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||
...(definition.text ? { text: definition.text, unmodifiedText: definition.text } : {}),
|
||||
});
|
||||
await send("Input.dispatchKeyEvent", {
|
||||
type: "keyUp",
|
||||
key: definition.key,
|
||||
code: definition.code,
|
||||
windowsVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||
nativeVirtualKeyCode: definition.windowsVirtualKeyCode,
|
||||
});
|
||||
}
|
||||
|
||||
function keyDefinition(key: string): {
|
||||
key: string;
|
||||
code: string;
|
||||
windowsVirtualKeyCode: number;
|
||||
text?: string;
|
||||
} {
|
||||
const special = SPECIAL_KEY_DEFINITIONS[key];
|
||||
if (special) {
|
||||
return special;
|
||||
export function dispatchTrustedKey(send: KeyboardInputSender, key: string): void {
|
||||
const keyCode = ELECTRON_KEY_CODE_ALIASES[key] ?? key;
|
||||
let character: string | null = null;
|
||||
if (key === "Space") {
|
||||
character = " ";
|
||||
} else if (key.length === 1) {
|
||||
character = key;
|
||||
}
|
||||
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 } : {}),
|
||||
};
|
||||
send({
|
||||
type: "keyDown",
|
||||
keyCode,
|
||||
skipIfUnhandled: true,
|
||||
});
|
||||
if (character !== null) {
|
||||
send({
|
||||
type: "char",
|
||||
keyCode: character,
|
||||
skipIfUnhandled: true,
|
||||
});
|
||||
}
|
||||
send({
|
||||
type: "keyUp",
|
||||
keyCode,
|
||||
skipIfUnhandled: true,
|
||||
});
|
||||
}
|
||||
|
||||
function modifierMask(modifiers: InputModifier[] | undefined): number {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { ipcRenderer } from "electron";
|
||||
import type { BrowserKeyboardPolicy, BrowserShortcutPrefix } from "./policy.js";
|
||||
|
||||
const POLICY_CHANNEL = "paseo:browser-keyboard-policy";
|
||||
const POLICY_REQUEST_CHANNEL = "paseo:browser-keyboard-policy-request";
|
||||
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 {
|
||||
const editable = isEditableTarget(event.target);
|
||||
return policy.some((prefix) => {
|
||||
if (
|
||||
prefix.alt !== event.altKey ||
|
||||
prefix.control !== event.ctrlKey ||
|
||||
prefix.meta !== event.metaKey ||
|
||||
prefix.shift !== event.shiftKey ||
|
||||
(prefix.editable === false && editable) ||
|
||||
(prefix.repeat === false && event.repeat)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (prefix.key === undefined) {
|
||||
return matchesCode(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) && matchesCode(prefix.code, event.code);
|
||||
});
|
||||
}
|
||||
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const element = target as HTMLElement;
|
||||
if (element.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
const tag = element.tagName.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || tag === "select";
|
||||
}
|
||||
|
||||
function matchesCode(prefixCode: string, eventCode: string): boolean {
|
||||
if (prefixCode !== "Digit") {
|
||||
return prefixCode === eventCode;
|
||||
}
|
||||
return /^(?:Digit|Numpad)[1-9]$/.test(eventCode);
|
||||
}
|
||||
|
||||
function stageShortcutForward(event: KeyboardEvent): void {
|
||||
if (!event.isTrusted || event.defaultPrevented || !browserId || !matchesPolicy(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shortcutBrowserId = browserId;
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(completedEvent) => {
|
||||
if (completedEvent !== event || completedEvent.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
completedEvent.preventDefault();
|
||||
ipcRenderer.send(SHORTCUT_INPUT_CHANNEL, {
|
||||
alt: completedEvent.altKey,
|
||||
browserId: shortcutBrowserId,
|
||||
code: completedEvent.code,
|
||||
control: completedEvent.ctrlKey,
|
||||
key: completedEvent.key,
|
||||
meta: completedEvent.metaKey,
|
||||
repeat: completedEvent.repeat,
|
||||
shift: completedEvent.shiftKey,
|
||||
});
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", stageShortcutForward, { capture: true });
|
||||
|
||||
ipcRenderer.on(POLICY_CHANNEL, (_event, value: BrowserKeyboardPolicyPayload) => {
|
||||
if (!value || typeof value.browserId !== "string" || !Array.isArray(value.prefixes)) {
|
||||
return;
|
||||
}
|
||||
browserId = value.browserId;
|
||||
policy = value.prefixes;
|
||||
});
|
||||
|
||||
ipcRenderer.send(POLICY_REQUEST_CHANNEL);
|
||||
@@ -0,0 +1,333 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { PaseoBrowserWebviewRegistry } from "../browser-webviews/registry.js";
|
||||
import { BrowserKeyboard } from "./index.js";
|
||||
|
||||
interface SentMessage {
|
||||
channel: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
class FakeBrowserContents {
|
||||
public readonly mainFrame = {
|
||||
framesInSubtree: [
|
||||
{
|
||||
detached: false,
|
||||
send: (channel: string, payload: unknown) => this.send(channel, payload),
|
||||
},
|
||||
],
|
||||
};
|
||||
public readonly ignoredMenuShortcuts: boolean[] = [];
|
||||
public readonly reloads: string[] = [];
|
||||
public readonly sent: SentMessage[] = [];
|
||||
private destroyed = false;
|
||||
private readonly destroyedListeners: Array<() => void> = [];
|
||||
private domReadyListener: (() => 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.destroyedListeners.push(listener);
|
||||
}
|
||||
|
||||
public on(event: "did-finish-load", listener: () => void): void;
|
||||
public on(event: "dom-ready", listener: () => void): void;
|
||||
public on(
|
||||
event: "before-input-event",
|
||||
listener: (event: { preventDefault(): void }, input: Electron.Input) => void,
|
||||
): void;
|
||||
public on(
|
||||
event: "did-finish-load" | "dom-ready" | "before-input-event",
|
||||
listener: (() => void) | ((event: { preventDefault(): void }, input: Electron.Input) => void),
|
||||
): void {
|
||||
if (event === "did-finish-load") {
|
||||
this.finishLoadListener = listener as () => void;
|
||||
return;
|
||||
}
|
||||
if (event === "dom-ready") {
|
||||
this.domReadyListener = 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;
|
||||
for (const listener of this.destroyedListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
public finishLoad(): void {
|
||||
this.finishLoadListener?.();
|
||||
}
|
||||
|
||||
public domReady(): void {
|
||||
this.domReadyListener?.();
|
||||
}
|
||||
|
||||
public input(input: Electron.Input): boolean {
|
||||
let wasPrevented = false;
|
||||
this.inputListener?.(
|
||||
{
|
||||
preventDefault: () => {
|
||||
wasPrevented = true;
|
||||
},
|
||||
},
|
||||
input,
|
||||
);
|
||||
return wasPrevented;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function createBrowserKeyboard() {
|
||||
const registry = new PaseoBrowserWebviewRegistry();
|
||||
const keyboard = new BrowserKeyboard(registry);
|
||||
|
||||
function attach(input: {
|
||||
browserId: string;
|
||||
contents: FakeBrowserContents;
|
||||
hostContents: FakeBrowserContents;
|
||||
}): void {
|
||||
const webContentsId = input.contents.id;
|
||||
registry.registerWebContents({
|
||||
browserId: input.browserId,
|
||||
hostWebContentsId: input.hostContents.id,
|
||||
webContentsId,
|
||||
});
|
||||
input.contents.once("destroyed", () => registry.unregisterWebContents(webContentsId));
|
||||
keyboard.attach(input);
|
||||
}
|
||||
|
||||
return { attach, keyboard };
|
||||
}
|
||||
|
||||
describe("BrowserKeyboard", () => {
|
||||
test("forwards a validated guest shortcut to its host", () => {
|
||||
const { attach, keyboard } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(51);
|
||||
const host = new FakeBrowserContents(52);
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
|
||||
keyboard.forwardShortcutInput(guest, {
|
||||
alt: false,
|
||||
browserId: "browser-a",
|
||||
code: "KeyB",
|
||||
control: true,
|
||||
key: "b",
|
||||
meta: false,
|
||||
repeat: false,
|
||||
shift: false,
|
||||
});
|
||||
|
||||
expect(host.sent).toEqual([
|
||||
{
|
||||
channel: "paseo:event:browser-shortcut-input",
|
||||
payload: {
|
||||
alt: false,
|
||||
browserId: "browser-a",
|
||||
code: "KeyB",
|
||||
control: true,
|
||||
key: "b",
|
||||
meta: false,
|
||||
repeat: false,
|
||||
shift: false,
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("republishes the latest shortcut policy when the next guest document is ready", () => {
|
||||
const { attach, keyboard } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(61);
|
||||
const host = new FakeBrowserContents(62);
|
||||
const initialPolicy = {
|
||||
menuPrefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "KeyB",
|
||||
control: true,
|
||||
meta: false,
|
||||
repeat: false as const,
|
||||
shift: false,
|
||||
},
|
||||
],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "KeyB",
|
||||
control: true,
|
||||
meta: false,
|
||||
repeat: false as const,
|
||||
shift: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const latestPolicy = { menuPrefixes: [], prefixes: [] };
|
||||
keyboard.publish(host.id, initialPolicy);
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
keyboard.publish(host.id, latestPolicy);
|
||||
|
||||
guest.domReady();
|
||||
|
||||
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" },
|
||||
},
|
||||
]);
|
||||
|
||||
guest.finishLoad();
|
||||
expect(guest.sent).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("ignores guest lifecycle events after the host is destroyed", () => {
|
||||
const { attach, keyboard } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(71);
|
||||
const host = new FakeBrowserContents(72);
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
keyboard.publish(host.id, { menuPrefixes: [], prefixes: [] });
|
||||
|
||||
host.destroy();
|
||||
guest.domReady();
|
||||
|
||||
expect(guest.sent).toEqual([
|
||||
{
|
||||
channel: "paseo:browser-keyboard-policy",
|
||||
payload: { browserId: "browser-a", menuPrefixes: [], prefixes: [] },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("owns browser chrome shortcuts and leaves customizable shortcuts to policy", () => {
|
||||
const { attach } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(81);
|
||||
const host = new FakeBrowserContents(82);
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
const command = process.platform === "darwin" ? { meta: true } : { control: true };
|
||||
|
||||
const reservedWasPrevented = guest.input(electronInput({ ...command, code: "KeyL", key: "l" }));
|
||||
const customizableWasPrevented = guest.input(
|
||||
electronInput({ ...command, code: "KeyT", key: "t" }),
|
||||
);
|
||||
const enterWasPrevented = guest.input(electronInput({ code: "Enter", key: "Enter" }));
|
||||
|
||||
expect(reservedWasPrevented).toBe(true);
|
||||
expect(customizableWasPrevented).toBe(false);
|
||||
expect(enterWasPrevented).toBe(false);
|
||||
expect(guest.ignoredMenuShortcuts).toEqual([false, false, true]);
|
||||
expect(host.sent).toEqual([
|
||||
{
|
||||
channel: "paseo:event:browser-shortcut",
|
||||
payload: { action: "focus-url", browserId: "browser-a" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("keeps policy-owned shortcuts out of the application menu without preempting the page", () => {
|
||||
const { attach, keyboard } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(91);
|
||||
const host = new FakeBrowserContents(92);
|
||||
keyboard.publish(host.id, {
|
||||
menuPrefixes: [
|
||||
{ alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false },
|
||||
],
|
||||
prefixes: [
|
||||
{ alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false },
|
||||
],
|
||||
});
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
|
||||
const wasPrevented = guest.input(electronInput({ code: "KeyW", control: true, key: "w" }));
|
||||
|
||||
expect(wasPrevented).toBe(false);
|
||||
expect(guest.ignoredMenuShortcuts).toEqual([true]);
|
||||
});
|
||||
|
||||
test("keeps idle policy shortcuts out of the application menu while a chord is pending", () => {
|
||||
const { attach, keyboard } = createBrowserKeyboard();
|
||||
const guest = new FakeBrowserContents(101);
|
||||
const host = new FakeBrowserContents(102);
|
||||
keyboard.publish(host.id, {
|
||||
menuPrefixes: [
|
||||
{ alt: false, code: "KeyW", control: true, meta: false, repeat: false, shift: false },
|
||||
],
|
||||
prefixes: [
|
||||
{ alt: false, code: "F11", control: true, meta: false, repeat: false, shift: false },
|
||||
],
|
||||
});
|
||||
attach({ browserId: "browser-a", contents: guest, hostContents: host });
|
||||
|
||||
const wasPrevented = guest.input(electronInput({ code: "KeyW", control: true, key: "w" }));
|
||||
|
||||
expect(wasPrevented).toBe(false);
|
||||
expect(guest.ignoredMenuShortcuts).toEqual([true]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,257 @@
|
||||
import { ipcMain } from "electron";
|
||||
import { PaseoBrowserWebviewRegistry } from "../browser-webviews/registry.js";
|
||||
import {
|
||||
type BrowserKeyboardPolicy,
|
||||
classifyBrowserReservedShortcut,
|
||||
matchesBrowserShortcutPrefixes,
|
||||
matchesBrowserShortcutPolicy,
|
||||
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 POLICY_REQUEST_CHANNEL = "paseo:browser-keyboard-policy-request";
|
||||
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 {
|
||||
readonly mainFrame: {
|
||||
readonly framesInSubtree: ReadonlyArray<{
|
||||
readonly detached: boolean;
|
||||
send(channel: string, ...args: unknown[]): void;
|
||||
}>;
|
||||
};
|
||||
isDestroyed(): boolean;
|
||||
isLoadingMainFrame(): boolean;
|
||||
on(event: "dom-ready", 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;
|
||||
setIgnoreMenuShortcuts(ignore: boolean): void;
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
interface BrowserKeyboardHostContents extends BrowserKeyboardContentsIdentity {
|
||||
isDestroyed(): boolean;
|
||||
send(channel: string, ...args: unknown[]): void;
|
||||
}
|
||||
|
||||
interface BrowserKeyboardGuest {
|
||||
contents: BrowserKeyboardGuestContents;
|
||||
hostContents: BrowserKeyboardHostContents;
|
||||
}
|
||||
|
||||
export class BrowserKeyboard {
|
||||
private readonly attachedGuestsByWebContentsId = new Map<number, BrowserKeyboardGuest>();
|
||||
private readonly policiesByHostWebContentsId = new Map<number, BrowserKeyboardPolicy>();
|
||||
|
||||
public constructor(private readonly browserRegistry: PaseoBrowserWebviewRegistry) {}
|
||||
|
||||
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);
|
||||
});
|
||||
ipcMain.on(POLICY_REQUEST_CHANNEL, (event) => {
|
||||
const payload = this.policyForGuest(event.sender);
|
||||
if (payload) {
|
||||
event.reply(POLICY_OUTPUT_CHANNEL, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public attach(input: {
|
||||
contents: BrowserKeyboardGuestContents;
|
||||
hostContents: BrowserKeyboardHostContents;
|
||||
}): void {
|
||||
const webContentsId = input.contents.id;
|
||||
const registration = this.browserRegistry.getRegistrationForWebContents(webContentsId);
|
||||
if (!registration || registration.hostWebContentsId !== input.hostContents.id) {
|
||||
return;
|
||||
}
|
||||
const guest: BrowserKeyboardGuest = input;
|
||||
this.attachedGuestsByWebContentsId.set(webContentsId, guest);
|
||||
|
||||
input.contents.once("destroyed", () => {
|
||||
if (this.attachedGuestsByWebContentsId.get(webContentsId) === guest) {
|
||||
this.attachedGuestsByWebContentsId.delete(webContentsId);
|
||||
}
|
||||
});
|
||||
input.contents.on("dom-ready", () => {
|
||||
const currentRegistration = this.registrationForGuest(webContentsId, guest);
|
||||
if (!currentRegistration) {
|
||||
return;
|
||||
}
|
||||
const policy = this.policiesByHostWebContentsId.get(currentRegistration.hostWebContentsId);
|
||||
if (policy) {
|
||||
this.sendPolicy(guest, currentRegistration.browserId, policy);
|
||||
}
|
||||
});
|
||||
input.contents.on("before-input-event", (event, keyboardInput) => {
|
||||
const currentRegistration = this.registrationForGuest(webContentsId, guest);
|
||||
if (currentRegistration) {
|
||||
this.handleGuestInput(guest, currentRegistration, event, keyboardInput);
|
||||
}
|
||||
});
|
||||
|
||||
const policy = this.policiesByHostWebContentsId.get(registration.hostWebContentsId);
|
||||
if (policy) {
|
||||
this.sendPolicy(guest, registration.browserId, policy);
|
||||
}
|
||||
}
|
||||
|
||||
public publish(hostWebContentsId: number, rawPolicy: unknown): void {
|
||||
const policy = parseBrowserKeyboardPolicy(rawPolicy);
|
||||
if (!policy) {
|
||||
return;
|
||||
}
|
||||
this.policiesByHostWebContentsId.set(hostWebContentsId, policy);
|
||||
for (const [webContentsId, guest] of this.attachedGuestsByWebContentsId) {
|
||||
const registration = this.registrationForGuest(webContentsId, guest);
|
||||
if (registration?.hostWebContentsId === hostWebContentsId) {
|
||||
this.sendPolicy(guest, registration.browserId, policy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public forwardShortcutInput(contents: BrowserKeyboardContentsIdentity, rawInput: unknown): void {
|
||||
const input = parseBrowserShortcutInput(rawInput);
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
const guest = this.attachedGuestsByWebContentsId.get(contents.id);
|
||||
if (!guest) {
|
||||
return;
|
||||
}
|
||||
const registration = this.registrationForGuest(contents.id, guest);
|
||||
if (
|
||||
!registration ||
|
||||
registration.browserId !== input.browserId ||
|
||||
guest.hostContents.isDestroyed()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
guest.hostContents.send(SHORTCUT_OUTPUT_CHANNEL, input);
|
||||
}
|
||||
|
||||
public detachHost(hostWebContentsId: number): void {
|
||||
this.policiesByHostWebContentsId.delete(hostWebContentsId);
|
||||
}
|
||||
|
||||
private handleGuestInput(
|
||||
guest: BrowserKeyboardGuest,
|
||||
registration: { browserId: string; hostWebContentsId: number },
|
||||
event: BrowserKeyboardInputEvent,
|
||||
input: Electron.Input,
|
||||
): void {
|
||||
const policy = this.policiesByHostWebContentsId.get(registration.hostWebContentsId);
|
||||
const matchInput = {
|
||||
alt: input.alt,
|
||||
code: input.code,
|
||||
control: input.control,
|
||||
key: input.key,
|
||||
meta: input.meta,
|
||||
repeat: input.isAutoRepeat,
|
||||
shift: input.shift,
|
||||
};
|
||||
const belongsToBrowserPolicy =
|
||||
policy !== undefined && matchesBrowserShortcutPolicy(policy, matchInput);
|
||||
const belongsToMenuPolicy =
|
||||
policy !== undefined && matchesBrowserShortcutPrefixes(policy.menuPrefixes, matchInput);
|
||||
guest.contents.setIgnoreMenuShortcuts(
|
||||
(!input.control && !input.meta) || belongsToBrowserPolicy || belongsToMenuPolicy,
|
||||
);
|
||||
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":
|
||||
event.preventDefault();
|
||||
if (!guest.hostContents.isDestroyed()) {
|
||||
guest.hostContents.send(RESERVED_SHORTCUT_OUTPUT_CHANNEL, {
|
||||
action: reservedShortcut,
|
||||
browserId: registration.browserId,
|
||||
});
|
||||
}
|
||||
return;
|
||||
case null:
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private registrationForGuest(
|
||||
webContentsId: number,
|
||||
guest: BrowserKeyboardGuest,
|
||||
): { browserId: string; hostWebContentsId: number } | null {
|
||||
if (this.attachedGuestsByWebContentsId.get(webContentsId) !== guest) {
|
||||
return null;
|
||||
}
|
||||
if (guest.hostContents.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
const registration = this.browserRegistry.getRegistrationForWebContents(webContentsId);
|
||||
return registration?.hostWebContentsId === guest.hostContents.id ? registration : null;
|
||||
}
|
||||
|
||||
private sendPolicy(
|
||||
guest: BrowserKeyboardGuest,
|
||||
browserId: string,
|
||||
policy: BrowserKeyboardPolicy,
|
||||
): void {
|
||||
if (guest.contents.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const payload = { ...policy, browserId };
|
||||
for (const frame of guest.contents.mainFrame.framesInSubtree) {
|
||||
if (!frame.detached) {
|
||||
frame.send(POLICY_OUTPUT_CHANNEL, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private policyForGuest(
|
||||
contents: BrowserKeyboardContentsIdentity,
|
||||
): (BrowserKeyboardPolicy & { browserId: string }) | null {
|
||||
const guest = this.attachedGuestsByWebContentsId.get(contents.id);
|
||||
if (!guest) {
|
||||
return null;
|
||||
}
|
||||
const registration = this.registrationForGuest(contents.id, guest);
|
||||
if (!registration) {
|
||||
return null;
|
||||
}
|
||||
const policy = this.policiesByHostWebContentsId.get(registration.hostWebContentsId);
|
||||
return policy ? { ...policy, browserId: registration.browserId } : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
classifyBrowserReservedShortcut,
|
||||
matchesBrowserShortcutPolicy,
|
||||
parseBrowserKeyboardPolicy,
|
||||
parseBrowserShortcutInput,
|
||||
} 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([null, "focus-url", "reload", "force-reload"]);
|
||||
expect(
|
||||
nonMacInputs.map((input) => classifyBrowserReservedShortcut(input, { isMac: false })),
|
||||
).toEqual([null, "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({
|
||||
menuPrefixes: [
|
||||
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
|
||||
],
|
||||
prefixes: [
|
||||
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
menuPrefixes: [
|
||||
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
|
||||
],
|
||||
prefixes: [
|
||||
{ code: "KeyB", control: true, meta: false, alt: false, repeat: false, shift: false },
|
||||
],
|
||||
});
|
||||
expect(parseBrowserKeyboardPolicy({ prefixes: [{ code: "KeyB", control: true }] })).toBeNull();
|
||||
});
|
||||
|
||||
test("rejects a false code fallback instead of treating it as absent", () => {
|
||||
expect(
|
||||
parseBrowserKeyboardPolicy({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "KeyB",
|
||||
codeFallback: false,
|
||||
control: true,
|
||||
meta: false,
|
||||
shift: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test("preserves editable exclusions and rejects permissive values", () => {
|
||||
expect(
|
||||
parseBrowserKeyboardPolicy({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "ArrowLeft",
|
||||
control: false,
|
||||
editable: false,
|
||||
meta: true,
|
||||
shift: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "ArrowLeft",
|
||||
control: false,
|
||||
editable: false,
|
||||
meta: true,
|
||||
shift: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(
|
||||
parseBrowserKeyboardPolicy({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "ArrowLeft",
|
||||
control: false,
|
||||
editable: true,
|
||||
meta: true,
|
||||
shift: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toBeNull();
|
||||
|
||||
const policy = parseBrowserKeyboardPolicy({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{
|
||||
alt: false,
|
||||
code: "ArrowLeft",
|
||||
control: false,
|
||||
editable: false,
|
||||
meta: true,
|
||||
shift: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
const input = {
|
||||
alt: false,
|
||||
code: "ArrowLeft",
|
||||
control: false,
|
||||
key: "ArrowLeft",
|
||||
meta: true,
|
||||
repeat: false,
|
||||
shift: true,
|
||||
};
|
||||
expect(matchesBrowserShortcutPolicy(policy!, { ...input, editable: false })).toBe(true);
|
||||
expect(matchesBrowserShortcutPolicy(policy!, { ...input, editable: true })).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps browser identities exact", () => {
|
||||
expect(
|
||||
parseBrowserShortcutInput({
|
||||
alt: false,
|
||||
browserId: " browser-1 ",
|
||||
code: "KeyB",
|
||||
control: true,
|
||||
key: "b",
|
||||
meta: false,
|
||||
shift: false,
|
||||
}),
|
||||
).toMatchObject({ browserId: " browser-1 " });
|
||||
});
|
||||
|
||||
test("matches digit shortcuts for the top row and numeric keypad", () => {
|
||||
const policy = parseBrowserKeyboardPolicy({
|
||||
menuPrefixes: [],
|
||||
prefixes: [
|
||||
{ alt: false, code: "Digit", control: true, meta: false, repeat: false, shift: false },
|
||||
],
|
||||
});
|
||||
expect(policy).not.toBeNull();
|
||||
|
||||
expect(
|
||||
matchesBrowserShortcutPolicy(policy!, {
|
||||
alt: false,
|
||||
code: "Digit3",
|
||||
control: true,
|
||||
key: "3",
|
||||
meta: false,
|
||||
repeat: false,
|
||||
shift: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBrowserShortcutPolicy(policy!, {
|
||||
alt: false,
|
||||
code: "Numpad3",
|
||||
control: true,
|
||||
key: "3",
|
||||
meta: false,
|
||||
repeat: false,
|
||||
shift: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
matchesBrowserShortcutPolicy(policy!, {
|
||||
alt: false,
|
||||
code: "Digit0",
|
||||
control: true,
|
||||
key: "0",
|
||||
meta: false,
|
||||
repeat: false,
|
||||
shift: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
export interface BrowserShortcutPrefix {
|
||||
alt: boolean;
|
||||
code: string;
|
||||
codeFallback?: true;
|
||||
control: boolean;
|
||||
editable?: false;
|
||||
key?: string;
|
||||
meta: boolean;
|
||||
repeat?: false;
|
||||
shift: boolean;
|
||||
shiftedKey?: string;
|
||||
}
|
||||
|
||||
export interface BrowserKeyboardPolicy {
|
||||
menuPrefixes: BrowserShortcutPrefix[];
|
||||
prefixes: BrowserShortcutPrefix[];
|
||||
}
|
||||
|
||||
export interface BrowserShortcutInput {
|
||||
alt: boolean;
|
||||
browserId: string;
|
||||
code: string;
|
||||
control: boolean;
|
||||
key: string;
|
||||
meta: boolean;
|
||||
repeat: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
export interface BrowserShortcutMatchInput {
|
||||
alt: boolean;
|
||||
code: string;
|
||||
control: boolean;
|
||||
editable?: boolean;
|
||||
key: string;
|
||||
meta: boolean;
|
||||
repeat: boolean;
|
||||
shift: boolean;
|
||||
}
|
||||
|
||||
export type BrowserReservedShortcut = "focus-url" | "reload" | "force-reload";
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function hasValidOptionalPrefixFields(value: Record<string, unknown>): boolean {
|
||||
return (
|
||||
(value.key === undefined || typeof value.key === "string") &&
|
||||
(value.shiftedKey === undefined || typeof value.shiftedKey === "string") &&
|
||||
(value.codeFallback === undefined || value.codeFallback === true) &&
|
||||
(value.editable === undefined || value.editable === false) &&
|
||||
(value.repeat === undefined || value.repeat === false)
|
||||
);
|
||||
}
|
||||
|
||||
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" ||
|
||||
!hasValidOptionalPrefixFields(value)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
alt: value.alt,
|
||||
code: value.code,
|
||||
...(value.codeFallback === true ? { codeFallback: true } : {}),
|
||||
control: value.control,
|
||||
...(value.editable === false ? { editable: false } : {}),
|
||||
...(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() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrefixes(value: unknown): BrowserShortcutPrefix[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const prefixes: BrowserShortcutPrefix[] = [];
|
||||
for (const entry of value) {
|
||||
const prefix = parsePrefix(entry);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
prefixes.push(prefix);
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
export function parseBrowserKeyboardPolicy(value: unknown): BrowserKeyboardPolicy | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
const menuPrefixes = parsePrefixes(value.menuPrefixes);
|
||||
const prefixes = parsePrefixes(value.prefixes);
|
||||
return menuPrefixes && prefixes ? { menuPrefixes, prefixes } : null;
|
||||
}
|
||||
|
||||
export function parseBrowserShortcutInput(value: unknown): BrowserShortcutInput | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
typeof value.browserId !== "string" ||
|
||||
value.browserId.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,
|
||||
code: value.code,
|
||||
control: value.control,
|
||||
key: value.key,
|
||||
meta: value.meta,
|
||||
repeat: value.repeat === true,
|
||||
shift: value.shift,
|
||||
};
|
||||
}
|
||||
|
||||
function matchesCode(prefixCode: string, inputCode: string): boolean {
|
||||
if (prefixCode !== "Digit") {
|
||||
return prefixCode === inputCode;
|
||||
}
|
||||
return /^(?:Digit|Numpad)[1-9]$/.test(inputCode);
|
||||
}
|
||||
|
||||
function matchesPrefix(prefix: BrowserShortcutPrefix, input: BrowserShortcutMatchInput): boolean {
|
||||
if (
|
||||
prefix.alt !== input.alt ||
|
||||
prefix.control !== input.control ||
|
||||
prefix.meta !== input.meta ||
|
||||
prefix.shift !== input.shift ||
|
||||
(prefix.editable === false && input.editable === true) ||
|
||||
(prefix.repeat === false && input.repeat)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (prefix.key === undefined) {
|
||||
return matchesCode(prefix.code, input.code);
|
||||
}
|
||||
const key = input.key.toLowerCase();
|
||||
if (key === prefix.key) {
|
||||
return true;
|
||||
}
|
||||
if (prefix.shift && prefix.shiftedKey !== undefined && key === prefix.shiftedKey) {
|
||||
return true;
|
||||
}
|
||||
return (prefix.alt || prefix.codeFallback === true) && matchesCode(prefix.code, input.code);
|
||||
}
|
||||
|
||||
export function matchesBrowserShortcutPrefixes(
|
||||
prefixes: BrowserShortcutPrefix[],
|
||||
input: BrowserShortcutMatchInput,
|
||||
): boolean {
|
||||
return prefixes.some((prefix) => matchesPrefix(prefix, input));
|
||||
}
|
||||
|
||||
export function matchesBrowserShortcutPolicy(
|
||||
policy: BrowserKeyboardPolicy,
|
||||
input: BrowserShortcutMatchInput,
|
||||
): boolean {
|
||||
return matchesBrowserShortcutPrefixes(policy.prefixes, input);
|
||||
}
|
||||
|
||||
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 === "l") return "focus-url";
|
||||
if (key !== "r") return null;
|
||||
return input.shift ? "force-reload" : "reload";
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
|
||||
@@ -51,15 +51,17 @@ 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,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { contextBridge, ipcRenderer, webUtils } from "electron";
|
||||
import type { BrowserKeyboardPolicy } from "./features/browser-keyboard/index.js";
|
||||
|
||||
// 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
|
||||
@@ -94,6 +95,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),
|
||||
|
||||
Reference in New Issue
Block a user