feat: native web view for plugins in the desktop app

This commit is contained in:
LukeGus committed 2026-10-19 21:57:48 -05:00
1 parent 895488d400
commit b22b3679e4
12 files changed
+641

No files matched your search

@@ -0,0 +1,164 @@
import { createRequire } from "node:module";
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { createEmbeddedViews, cleanBounds } = createRequire(import.meta.url)(
"../../../../electron/embedded-view.cjs",
);
const views: FakeView[] = [];
class FakeView {
webContents = Object.assign(new EventEmitter(), {
setWindowOpenHandler: vi.fn(),
loadURL: vi.fn().mockResolvedValue(undefined),
isDestroyed: () => false,
close: vi.fn(),
});
setBounds = vi.fn();
setVisible = vi.fn();
constructor(public options: { webPreferences: Record<string, unknown> }) {
views.push(this);
}
}
const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 };
let contentView: {
addChildView: ReturnType<typeof vi.fn>;
removeChildView: ReturnType<typeof vi.fn>;
};
let session: EventEmitter & Record<string, ReturnType<typeof vi.fn>>;
let manager: ReturnType<typeof createEmbeddedViews>;
beforeEach(() => {
views.length = 0;
contentView = { addChildView: vi.fn(), removeChildView: vi.fn() };
session = Object.assign(new EventEmitter(), {
setPermissionRequestHandler: vi.fn(),
setPermissionCheckHandler: vi.fn(),
clearStorageData: vi.fn().mockResolvedValue(undefined),
clearCache: vi.fn().mockResolvedValue(undefined),
closeAllConnections: vi.fn().mockResolvedValue(undefined),
webRequest: { onBeforeRequest: vi.fn() },
});
manager = createEmbeddedViews({
WebContentsView: FakeView,
session: { fromPartition: vi.fn(() => session) },
getMainWindow: () => ({ contentView, isDestroyed: () => false }),
});
});
describe("embedded views", () => {
it("opens a locked down view at the given bounds", () => {
manager.open({ id: "a", url: "https://pve.example:8006/", bounds });
const [view] = views;
expect(view.options.webPreferences).toMatchObject({
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
webviewTag: false,
});
expect(contentView.addChildView).toHaveBeenCalledWith(view);
expect(view.setBounds).toHaveBeenCalledWith({
x: 10,
y: 21,
width: 300,
height: 200,
});
expect(view.webContents.loadURL).toHaveBeenCalledWith(
"https://pve.example:8006/",
);
const handler = session.setPermissionRequestHandler.mock.calls[0][0];
const allow = vi.fn();
handler(null, "media", allow);
expect(allow).toHaveBeenCalledWith(false);
});
it("refuses urls that are not plain http or https", () => {
expect(() =>
manager.open({ id: "a", url: "file:///etc/passwd", bounds }),
).toThrow();
expect(() =>
manager.open({ id: "a", url: "https://u:p@host/", bounds }),
).toThrow();
});
it("moves and hides an open view, and closes it", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.setBounds("a", { x: 1, y: 2, width: 3, height: 4 });
manager.setVisible("a", false);
const [view] = views;
expect(view.setBounds).toHaveBeenLastCalledWith({
x: 1,
y: 2,
width: 3,
height: 4,
});
expect(view.setVisible).toHaveBeenLastCalledWith(false);
manager.destroy("a");
expect(contentView.removeChildView).toHaveBeenCalledWith(view);
expect(view.webContents.close).toHaveBeenCalled();
expect(session.clearStorageData).toHaveBeenCalled();
});
it("reuses a view for the same url and replaces it for a new one", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.open({ id: "a", url: "https://x.example/", bounds });
expect(views).toHaveLength(1);
manager.open({ id: "a", url: "https://y.example/", bounds });
expect(views).toHaveLength(2);
expect(contentView.removeChildView).toHaveBeenCalledWith(views[0]);
});
it("accepts an invalid certificate only for its own origin and only when asked", () => {
manager.open({
id: "a",
url: "https://x.example/",
ignoreCert: true,
bounds,
});
manager.open({ id: "b", url: "https://z.example/", bounds });
const check = (index: number, url: string) => {
const event = { preventDefault: vi.fn() };
const callback = vi.fn();
const handled = manager.handleCertificateError(
event,
views[index].webContents,
url,
callback,
);
return { handled, allowed: callback.mock.calls[0]?.[0] };
};
expect(check(0, "https://x.example/page")).toEqual({
handled: true,
allowed: true,
});
expect(check(0, "https://other.example/")).toEqual({
handled: true,
allowed: false,
});
expect(check(1, "https://z.example/")).toEqual({
handled: true,
allowed: false,
});
expect(
manager.handleCertificateError({}, {}, "https://x.example/", vi.fn()),
).toBe(false);
});
it("closes everything", () => {
manager.open({ id: "a", url: "https://x.example/", bounds });
manager.open({ id: "b", url: "https://y.example/", bounds });
manager.destroyAll();
expect(contentView.removeChildView).toHaveBeenCalledTimes(2);
});
it("keeps bounds as whole, non negative numbers", () => {
expect(cleanBounds({ x: -5, y: "x", width: 9.5, height: 1 })).toEqual({
x: 0,
y: 0,
width: 10,
height: 1,
});
});
});
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from "react";
interface Bounds {
x: number;
y: number;
width: number;
height: number;
}
interface EmbeddedViewApi {
open: (options: {
id: string;
url: string;
ignoreCert?: boolean;
bounds: Bounds;
visible: boolean;
}) => Promise<unknown>;
setBounds: (id: string, bounds: Bounds) => Promise<unknown>;
setVisible: (id: string, visible: boolean) => Promise<unknown>;
close: (id: string) => Promise<unknown>;
}
function embeddedViewApi(): EmbeddedViewApi | null {
if (typeof window === "undefined") return null;
const api = (
window as unknown as { electronAPI?: { embeddedView?: unknown } }
).electronAPI?.embeddedView;
return (api as EmbeddedViewApi | undefined) ?? null;
}
const OVERLAY = [
'[role="dialog"]',
'[role="alertdialog"]',
'[role="menu"]',
'[role="listbox"]',
"[data-radix-popper-content-wrapper]",
].join(",");
// A native view is drawn above the page, so anything the page opens over it
// has to hide it first.
function overlayOpen(host: HTMLElement): boolean {
return Array.from(document.querySelectorAll(OVERLAY)).some(
(node) => !host.contains(node),
);
}
export interface NativeViewProps {
url: string;
/** Accept an invalid TLS certificate from this URL's origin. */
ignoreCert?: boolean;
/** Hide the view, for example while the tab is not shown. */
hidden?: boolean;
/** Shown where the desktop app's native view is not available. */
fallback?: ReactNode;
className?: string;
style?: CSSProperties;
}
/**
* A page shown in a native view (desktop app only), for sites that do not
* work in a sandboxed iframe. It fills this element, hides itself when the
* element is not visible or a dialog or menu is open, and is closed on
* unmount. In a browser it renders `fallback`.
*/
export function NativeView({
url,
ignoreCert,
hidden,
fallback,
className,
style,
}: NativeViewProps) {
const host = useRef<HTMLDivElement>(null);
const api = embeddedViewApi();
const hiddenRef = useRef(!!hidden);
hiddenRef.current = !!hidden;
const sync = useRef<() => void>(() => {});
useEffect(() => {
const element = host.current;
const view = embeddedViewApi();
if (!element || !view) return;
const id = crypto.randomUUID();
let opened = false;
let lastBounds = "";
let lastVisible: boolean | null = null;
let frame = 0;
const update = () => {
frame = 0;
const rect = element.getBoundingClientRect();
const bounds = {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
};
const visible =
!hiddenRef.current &&
rect.width > 0 &&
rect.height > 0 &&
element.offsetParent !== null &&
!overlayOpen(element);
if (!opened) {
opened = true;
lastBounds = JSON.stringify(bounds);
lastVisible = visible;
void view.open({ id, url, ignoreCert, bounds, visible });
return;
}
const key = JSON.stringify(bounds);
if (key !== lastBounds) {
lastBounds = key;
void view.setBounds(id, bounds);
}
if (visible !== lastVisible) {
lastVisible = visible;
void view.setVisible(id, visible);
}
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(update);
};
sync.current = schedule;
const resize = new ResizeObserver(schedule);
resize.observe(element);
const mutations = new MutationObserver(schedule);
mutations.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["class", "style", "hidden", "data-state"],
});
window.addEventListener("resize", schedule);
window.addEventListener("scroll", schedule, true);
const timer = window.setInterval(schedule, 500);
schedule();
return () => {
sync.current = () => {};
resize.disconnect();
mutations.disconnect();
window.removeEventListener("resize", schedule);
window.removeEventListener("scroll", schedule, true);
window.clearInterval(timer);
if (frame) cancelAnimationFrame(frame);
void view.close(id);
};
}, [url, ignoreCert]);
useEffect(() => {
sync.current();
}, [hidden]);
if (!api) return <>{fallback ?? null}</>;
return <div ref={host} className={className} style={style} />;
}
+1
View File
@@ -723,6 +723,7 @@ export {
hasVisibleFields,
isFieldShown,
} from "@/settings/settings-fields-util";
export { NativeView, type NativeViewProps } from "./NativeView";
export { useShellController } from "@/shell/controller/ShellControllerProvider";
export {
type KeybindingActionDef,
@@ -0,0 +1,96 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { render } from "@testing-library/react";
import { NativeView } from "@/plugin-host/NativeView";
const api = {
open: vi.fn().mockResolvedValue(undefined),
setBounds: vi.fn().mockResolvedValue(undefined),
setVisible: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
};
const flush = () => new Promise((resolve) => setTimeout(resolve, 10));
function useDesktop() {
(window as unknown as { electronAPI: unknown }).electronAPI = {
embeddedView: api,
};
}
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
disconnect() {}
},
);
vi.stubGlobal("requestAnimationFrame", (cb: () => void) => setTimeout(cb, 0));
vi.stubGlobal("cancelAnimationFrame", (id: number) => clearTimeout(id));
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
configurable: true,
get: () => document.body,
});
HTMLElement.prototype.getBoundingClientRect = () =>
({ left: 5, top: 6, width: 400, height: 300 }) as DOMRect;
});
afterEach(() => {
delete (window as unknown as { electronAPI?: unknown }).electronAPI;
vi.unstubAllGlobals();
});
describe("NativeView", () => {
it("renders the fallback outside the desktop app", () => {
const { getByText } = render(
<NativeView url="https://x.example/" fallback={<p>iframe here</p>} />,
);
expect(getByText("iframe here")).toBeTruthy();
});
it("opens a native view over its element and closes it on unmount", async () => {
useDesktop();
const { unmount } = render(
<NativeView url="https://x.example/" ignoreCert />,
);
await flush();
expect(api.open).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://x.example/",
ignoreCert: true,
bounds: { x: 5, y: 6, width: 400, height: 300 },
visible: true,
}),
);
const id = api.open.mock.calls[0][0].id;
unmount();
expect(api.close).toHaveBeenCalledWith(id);
});
it("hides the view when hidden is set and shows it again", async () => {
useDesktop();
const { rerender } = render(<NativeView url="https://x.example/" />);
await flush();
const id = api.open.mock.calls[0][0].id;
rerender(<NativeView url="https://x.example/" hidden />);
await flush();
expect(api.setVisible).toHaveBeenLastCalledWith(id, false);
rerender(<NativeView url="https://x.example/" />);
await flush();
expect(api.setVisible).toHaveBeenLastCalledWith(id, true);
});
it("hides the view while a dialog is open", async () => {
useDesktop();
render(<NativeView url="https://x.example/" />);
await flush();
const id = api.open.mock.calls[0][0].id;
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
document.body.appendChild(dialog);
await flush();
expect(api.setVisible).toHaveBeenLastCalledWith(id, false);
dialog.remove();
});
});