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(