Files
kandev/apps/web/components/plugins/web-app-frame.tsx
T
2026-09-05 18:44:43 +00:00

101 lines
3.2 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTheme } from "@/components/theme/app-theme";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { cn } from "@/lib/utils";
import { useTranslation } from "react-i18next";
import { resolveWebAppAppearance } from "./web-app-appearance";
type WebAppFrameProps = {
/** A short-lived capability URL returned by the backend. */
runtimeUrl?: string | null;
/** The host-owned accessible name. Runtime content is untrusted. */
title: string;
className?: string;
onLoad?: () => void;
onError?: () => void;
};
type FrameState = "loading" | "ready" | "unavailable";
/**
* Hosts a static plugin web application without bringing it into the SPA
* process. The sandbox deliberately omits allow-same-origin, top navigation,
* popups, downloads, and a privileged host bridge.
*/
export function WebAppFrame({ runtimeUrl, title, className, onLoad, onError }: WebAppFrameProps) {
const { isMobile } = useResponsiveBreakpoint();
const { t } = useTranslation();
const { resolvedTheme } = useTheme();
const [frameState, setFrameState] = useState<FrameState>(runtimeUrl ? "loading" : "unavailable");
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const frameReadyRef = useRef(false);
useEffect(() => {
frameReadyRef.current = false;
setFrameState(runtimeUrl ? "loading" : "unavailable");
}, [runtimeUrl]);
const sendAppearance = useCallback(() => {
const target = iframeRef.current?.contentWindow;
if (!target) return;
target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*");
}, [resolvedTheme]);
useEffect(() => {
if (frameReadyRef.current) sendAppearance();
}, [sendAppearance]);
const handleLoad = () => {
sendAppearance();
frameReadyRef.current = true;
onLoad?.();
window.requestAnimationFrame(() => setFrameState("ready"));
};
const handleError = () => {
frameReadyRef.current = false;
setFrameState("unavailable");
onError?.();
};
return (
<div
data-testid="web-app-frame"
data-frame-state={frameState}
data-mobile={isMobile ? "true" : "false"}
className={cn(
"relative flex min-h-0 min-w-0 flex-1 overflow-hidden",
isMobile && "pb-[env(safe-area-inset-bottom)]",
className,
)}
aria-busy={frameState === "loading"}
>
{runtimeUrl ? (
<iframe
key={runtimeUrl}
title={title}
src={runtimeUrl}
ref={iframeRef}
sandbox="allow-scripts allow-forms"
referrerPolicy="no-referrer"
loading="eager"
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
onLoad={handleLoad}
onError={handleError}
/>
) : null}
{frameState !== "ready" && (
<div
role={frameState === "unavailable" ? "alert" : "status"}
aria-live="polite"
className="pointer-events-none absolute inset-0 flex items-center justify-center bg-background/80 p-4 text-center text-sm text-muted-foreground"
>
{t(frameState === "unavailable" ? "plugins:webAppUnavailable" : "plugins:webAppLoading")}
</div>
)}
</div>
);
}