mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: native web view for plugins in the desktop app
This commit is contained in:
1 parent
895488d400
commit
b22b3679e4
12 files changed
+641
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -217,6 +217,8 @@ const ALLOWED = [
|
||||
"Meter",
|
||||
"MetricCard",
|
||||
"MiniStat",
|
||||
"NativeView",
|
||||
"NativeViewProps",
|
||||
"NumberField",
|
||||
"OPEN_PLUGINS_EVENT",
|
||||
"OpenTabRecord",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user