From b22b3679e432444d70d6fa226d04c651ed6c50e2 Mon Sep 17 00:00:00 2001
From: LukeGus
Date: Mon, 19 Oct 2026 21:57:48 -0500
Subject: [PATCH] feat: native web view for plugins in the desktop app
---
CHANGELOG.md | 1 +
electron/embedded-view.cjs | 165 ++++++++++++++++++
electron/main.cjs | 38 ++++
electron/preload.js | 10 ++
packages/plugin-sdk/CHANGELOG.md | 1 +
packages/plugin-sdk/PLUGIN-GUIDE.md | 3 +
packages/plugin-sdk/ui.api.txt | 2 +
scripts/check-sdk-ui-exports.cjs | 2 +
.../tests/electron/embedded-view.test.ts | 164 +++++++++++++++++
src/ui/plugin-host/NativeView.tsx | 158 +++++++++++++++++
src/ui/plugin-host/sdk-ui.ts | 1 +
src/ui/tests/plugin-host/native-view.test.tsx | 96 ++++++++++
12 files changed, 641 insertions(+)
create mode 100644 electron/embedded-view.cjs
create mode 100644 src/backend/tests/electron/embedded-view.test.ts
create mode 100644 src/ui/plugin-host/NativeView.tsx
create mode 100644 src/ui/tests/plugin-host/native-view.test.tsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 065327c33..cd7d61048 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -29,6 +29,7 @@
- Pasting a security key file now explains how to use it instead of failing to decode it
- Plugins can add a panel that sits beside a host tab, like a terminal. You can open, resize and move it
- The desktop app can link to a server that requires a client certificate (mutual TLS). It uses a PFX or P12 file for sync and the certificate in your system store for the app window
+- Plugins can show a web page in a native view in the desktop app, for sites that do not load in an iframe
### Changed
diff --git a/electron/embedded-view.cjs b/electron/embedded-view.cjs
new file mode 100644
index 000000000..c6967fa4d
--- /dev/null
+++ b/electron/embedded-view.cjs
@@ -0,0 +1,165 @@
+const { isolatedUrl } = require("./isolated-window.cjs");
+
+function cleanBounds(bounds) {
+ const value = (n) => Math.max(0, Math.round(Number(n) || 0));
+ return {
+ x: value(bounds?.x),
+ y: value(bounds?.y),
+ width: value(bounds?.width),
+ height: value(bounds?.height),
+ };
+}
+
+/**
+ * Native web views drawn inside the main window, for pages that do not work
+ * in a sandboxed iframe. Each one is a WebContentsView in its own
+ * non-persistent session, with the same limits as an isolated window: no
+ * permissions, no downloads, http(s) and ws(s) only. The renderer owns the
+ * placement and sends the bounds of the element the view should cover.
+ */
+function createEmbeddedViews({ WebContentsView, session, getMainWindow }) {
+ const views = new Map();
+
+ function destroy(id) {
+ const entry = views.get(id);
+ if (!entry) return;
+ views.delete(id);
+ const main = getMainWindow();
+ if (main && !main.isDestroyed()) {
+ try {
+ main.contentView.removeChildView(entry.view);
+ } catch {
+ /* already detached */
+ }
+ }
+ if (!entry.view.webContents.isDestroyed()) entry.view.webContents.close();
+ void Promise.allSettled([
+ entry.session.clearStorageData(),
+ entry.session.clearCache(),
+ entry.session.closeAllConnections(),
+ ]);
+ }
+
+ function destroyAll() {
+ for (const id of [...views.keys()]) destroy(id);
+ }
+
+ function open(options = {}) {
+ const main = getMainWindow();
+ if (!main) throw new Error("No window to attach a view to");
+ const id = String(options.id || "");
+ if (!id) throw new Error("A view needs an id");
+ const url = isolatedUrl(options.url);
+ const existing = views.get(id);
+ if (existing) {
+ if (
+ existing.url === url.href &&
+ existing.ignoreCert === !!options.ignoreCert
+ ) {
+ setBounds(id, options.bounds);
+ setVisible(id, options.visible !== false);
+ return { success: true };
+ }
+ destroy(id);
+ }
+
+ const isolated = session.fromPartition(
+ `embedded-view-${id}-${Date.now()}`,
+ {
+ cache: false,
+ },
+ );
+ isolated.setPermissionRequestHandler((_c, _p, callback) => callback(false));
+ isolated.setPermissionCheckHandler(() => false);
+ isolated.on("will-download", (event) => event.preventDefault());
+ isolated.webRequest.onBeforeRequest((details, callback) => {
+ const protocol = new URL(details.url).protocol;
+ callback({
+ cancel: !["http:", "https:", "ws:", "wss:"].includes(protocol),
+ });
+ });
+
+ const view = new WebContentsView({
+ webPreferences: {
+ session: isolated,
+ sandbox: true,
+ contextIsolation: true,
+ nodeIntegration: false,
+ webviewTag: false,
+ webSecurity: true,
+ },
+ });
+ const canNavigate = (target) => {
+ try {
+ return target === "about:blank" || !!isolatedUrl(target);
+ } catch {
+ return false;
+ }
+ };
+ for (const name of ["will-navigate", "will-redirect"]) {
+ view.webContents.on(name, (event, target) => {
+ if (!canNavigate(target)) event.preventDefault();
+ });
+ }
+ view.webContents.setWindowOpenHandler(({ url: target }) =>
+ canNavigate(target)
+ ? {
+ action: "allow",
+ overrideBrowserWindowOptions: {
+ webPreferences: { session: isolated, sandbox: true },
+ },
+ }
+ : { action: "deny" },
+ );
+
+ views.set(id, {
+ view,
+ session: isolated,
+ url: url.href,
+ origin: url.origin,
+ ignoreCert: !!options.ignoreCert,
+ });
+ main.contentView.addChildView(view);
+ setBounds(id, options.bounds);
+ setVisible(id, options.visible !== false);
+ view.webContents.loadURL(url.href).catch(() => {});
+ return { success: true };
+ }
+
+ function setBounds(id, bounds) {
+ const entry = views.get(id);
+ if (entry) entry.view.setBounds(cleanBounds(bounds));
+ }
+
+ function setVisible(id, visible) {
+ const entry = views.get(id);
+ if (entry) entry.view.setVisible(!!visible);
+ }
+
+ function handleCertificateError(event, contents, url, callback) {
+ for (const entry of views.values()) {
+ if (entry.view.webContents !== contents) continue;
+ event.preventDefault();
+ let allowed = false;
+ try {
+ allowed = entry.ignoreCert && new URL(url).origin === entry.origin;
+ } catch {
+ /* malformed URL is refused */
+ }
+ callback(allowed);
+ return true;
+ }
+ return false;
+ }
+
+ return {
+ open,
+ setBounds,
+ setVisible,
+ destroy,
+ destroyAll,
+ handleCertificateError,
+ };
+}
+
+module.exports = { createEmbeddedViews, cleanBounds };
diff --git a/electron/main.cjs b/electron/main.cjs
index 80fb7930a..34c744719 100644
--- a/electron/main.cjs
+++ b/electron/main.cjs
@@ -1,6 +1,7 @@
const {
app,
BrowserWindow,
+ WebContentsView,
shell,
ipcMain,
dialog,
@@ -497,12 +498,38 @@ app.commandLine.appendSwitch("--enable-features=NetworkService");
let mainWindow = null;
const { createIsolatedWindows } = require("./isolated-window.cjs");
+const { createEmbeddedViews } = require("./embedded-view.cjs");
const isolatedWindows = createIsolatedWindows({
BrowserWindow,
session,
getMainWindow: () => mainWindow,
});
+const embeddedViews = createEmbeddedViews({
+ WebContentsView,
+ session,
+ getMainWindow: () => mainWindow,
+});
+
+// Only the app's own page may place native views, never a frame inside it.
+function fromMainFrame(event) {
+ return !!mainWindow && event.senderFrame === mainWindow.webContents.mainFrame;
+}
+
+ipcMain.handle("embedded-view-open", (event, options) => {
+ if (!fromMainFrame(event)) throw new Error("Not allowed");
+ return embeddedViews.open(options);
+});
+ipcMain.handle("embedded-view-bounds", (event, id, bounds) => {
+ if (fromMainFrame(event)) embeddedViews.setBounds(String(id), bounds);
+});
+ipcMain.handle("embedded-view-visible", (event, id, visible) => {
+ if (fromMainFrame(event)) embeddedViews.setVisible(String(id), visible);
+});
+ipcMain.handle("embedded-view-close", (event, id) => {
+ if (fromMainFrame(event)) embeddedViews.destroy(String(id));
+});
+
// Requests from the embedded backend over the fork IPC channel (the other
// direction from the "shutdown" message main already sends it). A plugin's
// ctx.desktop.openIsolatedWindow is the one caller today.
@@ -731,6 +758,10 @@ app.on(
isolatedWindows.handleCertificateError(event, _webContents, url, callback)
)
return;
+ if (
+ embeddedViews.handleCertificateError(event, _webContents, url, callback)
+ )
+ return;
if (isWebEndpointCertificateAllowed(url)) {
event.preventDefault();
logToFile("Allowed invalid certificate for configured web endpoint", {
@@ -1508,7 +1539,14 @@ function createWindow() {
}
});
+ mainWindow.webContents.on("did-start-navigation", (details) => {
+ if (details.isMainFrame && !details.isSameDocument) {
+ embeddedViews.destroyAll();
+ }
+ });
+
mainWindow.on("closed", () => {
+ embeddedViews.destroyAll();
mainWindow = null;
});
diff --git a/electron/preload.js b/electron/preload.js
index afef50c22..8bc56bc56 100644
--- a/electron/preload.js
+++ b/electron/preload.js
@@ -80,6 +80,16 @@ contextBridge.exposeInMainWorld("electronAPI", {
ipcRenderer.invoke("start-drag-to-desktop", dragData),
cleanupTempFile: (tempId) => ipcRenderer.invoke("cleanup-temp-file", tempId),
+ // Native web views placed over an element of the page (WebContentsView).
+ embeddedView: {
+ open: (options) => ipcRenderer.invoke("embedded-view-open", options),
+ setBounds: (id, bounds) =>
+ ipcRenderer.invoke("embedded-view-bounds", id, bounds),
+ setVisible: (id, visible) =>
+ ipcRenderer.invoke("embedded-view-visible", id, visible),
+ close: (id) => ipcRenderer.invoke("embedded-view-close", id),
+ },
+
invoke: invokeAllowed,
});
diff --git a/packages/plugin-sdk/CHANGELOG.md b/packages/plugin-sdk/CHANGELOG.md
index 2bd566464..4c76081be 100644
--- a/packages/plugin-sdk/CHANGELOG.md
+++ b/packages/plugin-sdk/CHANGELOG.md
@@ -7,6 +7,7 @@ Plugin API 1.9.0.
### Added
- `app.registerCompanion` and `app.toggleCompanion`, so a plugin can add a panel beside any host tab. The user opens, resizes and moves it, and the tab keeps running
+- `NativeView` in `@termix-ssh/plugin-sdk/ui`, which shows a web page in a native view in the desktop app, for sites that do not work in an iframe
## 1.0.16
diff --git a/packages/plugin-sdk/PLUGIN-GUIDE.md b/packages/plugin-sdk/PLUGIN-GUIDE.md
index 5553f7d24..9341f28d7 100644
--- a/packages/plugin-sdk/PLUGIN-GUIDE.md
+++ b/packages/plugin-sdk/PLUGIN-GUIDE.md
@@ -631,6 +631,9 @@ Build UI from `@termix-ssh/plugin-sdk/ui` so it matches Termix and follows the t
exports with props is `ui.api.txt` in the Termix repo
(https://github.com/Termix-SSH/Termix/blob/main/packages/plugin-sdk/ui.api.txt).
+- Web pages that break in an iframe: `NativeView` (props `url`, `ignoreCert`, `hidden`, `fallback`) shows the
+ page in a native view in the desktop app. It fills its element, hides while a dialog or menu is open and renders
+ `fallback` in a browser.
- Square: panels, cards and containers have no rounded corners. Small controls may stay rounded.
- Flat: borders, not shadows. Use theme tokens like `border-border`, `bg-background`,
`text-muted-foreground`, `text-destructive`. One accent, `text-accent-brand`. Don't add colors.
diff --git a/packages/plugin-sdk/ui.api.txt b/packages/plugin-sdk/ui.api.txt
index 62fec6f18..a2e578d7c 100644
--- a/packages/plugin-sdk/ui.api.txt
+++ b/packages/plugin-sdk/ui.api.txt
@@ -196,6 +196,8 @@ ManagerSearch (value): ({ value, onChange, placeholder, count, extra, }: { value
Meter (value): ({ percent, className, }: { percent: number; className?: string; }) => Element
MetricCard (value): ({ title, icon, action, children, bodyClassName, scroll, scrollMax, }: { title: string; icon: ReactNode; action?: ReactNode; children: ReactNode; bodyClassName?: string; scroll?: boolean; scrollMax?: number; }) => Element
MiniStat (value): ({ caption, value, valueClassName, className, }: { caption: ReactNode; value: ReactNode; valueClassName?: string; className?: string; }) => Element
+NativeView (value): ({ url, ignoreCert, hidden, fallback, className, style, }: NativeViewProps) => Element
+NativeViewProps (type): NativeViewProps
NumberField (value): ({ label, value, onChange, placeholder, hint, error, min, max, disabled, className, }: { label: string; value: number; onChange: (value: number) => void; placeholder?: string; hint?: ReactNode; error?: string; min?: number; max?: number; disabled?: boolean; className?: string; }) => Element
OPEN_PLUGINS_EVENT (value): "termix:open-plugins"
OpenTabRecord (type): OpenTabRecord
diff --git a/scripts/check-sdk-ui-exports.cjs b/scripts/check-sdk-ui-exports.cjs
index bd2a540cb..bf020c3ef 100644
--- a/scripts/check-sdk-ui-exports.cjs
+++ b/scripts/check-sdk-ui-exports.cjs
@@ -217,6 +217,8 @@ const ALLOWED = [
"Meter",
"MetricCard",
"MiniStat",
+ "NativeView",
+ "NativeViewProps",
"NumberField",
"OPEN_PLUGINS_EVENT",
"OpenTabRecord",
diff --git a/src/backend/tests/electron/embedded-view.test.ts b/src/backend/tests/electron/embedded-view.test.ts
new file mode 100644
index 000000000..69bf03210
--- /dev/null
+++ b/src/backend/tests/electron/embedded-view.test.ts
@@ -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 }) {
+ views.push(this);
+ }
+}
+
+const bounds = { x: 10.4, y: 20.6, width: 300, height: 200 };
+let contentView: {
+ addChildView: ReturnType;
+ removeChildView: ReturnType;
+};
+let session: EventEmitter & Record>;
+let manager: ReturnType;
+
+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,
+ });
+ });
+});
diff --git a/src/ui/plugin-host/NativeView.tsx b/src/ui/plugin-host/NativeView.tsx
new file mode 100644
index 000000000..ce26a7d6e
--- /dev/null
+++ b/src/ui/plugin-host/NativeView.tsx
@@ -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;
+ setBounds: (id: string, bounds: Bounds) => Promise;
+ setVisible: (id: string, visible: boolean) => Promise;
+ close: (id: string) => Promise;
+}
+
+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(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 ;
+}
diff --git a/src/ui/plugin-host/sdk-ui.ts b/src/ui/plugin-host/sdk-ui.ts
index c6a9db34b..77857bb29 100644
--- a/src/ui/plugin-host/sdk-ui.ts
+++ b/src/ui/plugin-host/sdk-ui.ts
@@ -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,
diff --git a/src/ui/tests/plugin-host/native-view.test.tsx b/src/ui/tests/plugin-host/native-view.test.tsx
new file mode 100644
index 000000000..c81dd340e
--- /dev/null
+++ b/src/ui/tests/plugin-host/native-view.test.tsx
@@ -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(
+ iframe here
} />,
+ );
+ expect(getByText("iframe here")).toBeTruthy();
+ });
+
+ it("opens a native view over its element and closes it on unmount", async () => {
+ useDesktop();
+ const { unmount } = render(
+ ,
+ );
+ 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();
+ await flush();
+ const id = api.open.mock.calls[0][0].id;
+ rerender();
+ await flush();
+ expect(api.setVisible).toHaveBeenLastCalledWith(id, false);
+ rerender();
+ await flush();
+ expect(api.setVisible).toHaveBeenLastCalledWith(id, true);
+ });
+
+ it("hides the view while a dialog is open", async () => {
+ useDesktop();
+ render();
+ 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();
+ });
+});