dca3ee3484
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
234 lines
7.5 KiB
TypeScript
234 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useLayoutEffect, 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";
|
|
import {
|
|
createWebAppStartupNonce,
|
|
createWebAppStartupProbe,
|
|
parseWebAppStartupResult,
|
|
WEB_APP_STARTUP_TIMEOUT_MS,
|
|
type WebAppStartupFailureReason,
|
|
} from "./web-app-startup";
|
|
|
|
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?: (reason: WebAppStartupFailureReason) => void;
|
|
};
|
|
|
|
type FrameState = "loading" | "ready" | "unavailable";
|
|
|
|
type IframeReference = { current: HTMLIFrameElement | null };
|
|
|
|
type WebAppFrameLifecycleProps = {
|
|
runtimeUrl?: string | null;
|
|
iframeRef: IframeReference;
|
|
sendAppearance: () => void;
|
|
onLoad?: () => void;
|
|
onError?: (reason: WebAppStartupFailureReason) => void;
|
|
};
|
|
|
|
type WebAppStartupOutcome =
|
|
| { result: "ready" }
|
|
| { result: "failed"; reason: WebAppStartupFailureReason };
|
|
|
|
type StartupAttempt = {
|
|
nonce: string;
|
|
frameWindow: Window | null;
|
|
settled: boolean;
|
|
timer: ReturnType<typeof setTimeout> | null;
|
|
};
|
|
|
|
function useWebAppStartupMessageListener({
|
|
runtimeUrl,
|
|
iframeRef,
|
|
attemptRef,
|
|
finishAttempt,
|
|
}: {
|
|
runtimeUrl?: string | null;
|
|
iframeRef: IframeReference;
|
|
attemptRef: { current: StartupAttempt | null };
|
|
finishAttempt: (outcome: WebAppStartupOutcome) => void;
|
|
}) {
|
|
useLayoutEffect(() => {
|
|
if (!runtimeUrl) return;
|
|
const attempt: StartupAttempt = {
|
|
nonce: createWebAppStartupNonce(),
|
|
frameWindow: null,
|
|
settled: false,
|
|
timer: null,
|
|
};
|
|
attemptRef.current = attempt;
|
|
attempt.timer = setTimeout(
|
|
() => finishAttempt({ result: "failed", reason: "timeout" }),
|
|
WEB_APP_STARTUP_TIMEOUT_MS,
|
|
);
|
|
|
|
const handleStartupMessage = (event: MessageEvent<unknown>) => {
|
|
if (attemptRef.current !== attempt || attempt.settled) return;
|
|
const currentFrameWindow = iframeRef.current?.contentWindow;
|
|
if (
|
|
!currentFrameWindow ||
|
|
attempt.frameWindow !== currentFrameWindow ||
|
|
event.source !== currentFrameWindow
|
|
) {
|
|
return;
|
|
}
|
|
const result = parseWebAppStartupResult(event.data, attempt.nonce);
|
|
if (!result) return;
|
|
finishAttempt(
|
|
result.result === "ready" ? { result: "ready" } : { result: "failed", reason: result.code },
|
|
);
|
|
};
|
|
window.addEventListener("message", handleStartupMessage);
|
|
return () => {
|
|
window.removeEventListener("message", handleStartupMessage);
|
|
if (attempt.timer !== null) clearTimeout(attempt.timer);
|
|
attempt.timer = null;
|
|
if (attemptRef.current === attempt) attemptRef.current = null;
|
|
};
|
|
}, [finishAttempt, iframeRef, runtimeUrl]);
|
|
}
|
|
|
|
function useWebAppFrameLifecycle({
|
|
runtimeUrl,
|
|
iframeRef,
|
|
sendAppearance,
|
|
onLoad,
|
|
onError,
|
|
}: WebAppFrameLifecycleProps) {
|
|
const [frameState, setFrameState] = useState<FrameState>(runtimeUrl ? "loading" : "unavailable");
|
|
const frameReadyRef = useRef(false);
|
|
const onLoadRef = useRef(onLoad);
|
|
const onErrorRef = useRef(onError);
|
|
const sendAppearanceRef = useRef(sendAppearance);
|
|
const attemptRef = useRef<StartupAttempt | null>(null);
|
|
|
|
useEffect(() => {
|
|
onLoadRef.current = onLoad;
|
|
onErrorRef.current = onError;
|
|
sendAppearanceRef.current = sendAppearance;
|
|
}, [onError, onLoad, sendAppearance]);
|
|
|
|
useLayoutEffect(() => {
|
|
frameReadyRef.current = false;
|
|
setFrameState(runtimeUrl ? "loading" : "unavailable");
|
|
attemptRef.current = null;
|
|
}, [runtimeUrl]);
|
|
|
|
useEffect(() => {
|
|
if (frameReadyRef.current) sendAppearance();
|
|
}, [sendAppearance]);
|
|
|
|
const finishAttempt = useCallback((outcome: WebAppStartupOutcome) => {
|
|
const attempt = attemptRef.current;
|
|
if (!attempt || attempt.settled) return;
|
|
attempt.settled = true;
|
|
if (attempt.timer !== null) {
|
|
clearTimeout(attempt.timer);
|
|
attempt.timer = null;
|
|
}
|
|
if (outcome.result === "failed") {
|
|
frameReadyRef.current = false;
|
|
setFrameState("unavailable");
|
|
onErrorRef.current?.(outcome.reason);
|
|
return;
|
|
}
|
|
frameReadyRef.current = true;
|
|
sendAppearanceRef.current();
|
|
window.requestAnimationFrame(() => {
|
|
if (attemptRef.current !== attempt) return;
|
|
setFrameState("ready");
|
|
onLoadRef.current?.();
|
|
});
|
|
}, []);
|
|
|
|
useWebAppStartupMessageListener({ runtimeUrl, iframeRef, attemptRef, finishAttempt });
|
|
|
|
const handleLoad = useCallback(() => {
|
|
const attempt = attemptRef.current;
|
|
const frameWindow = iframeRef.current?.contentWindow;
|
|
if (!attempt || attempt.settled || !frameWindow) return;
|
|
attempt.frameWindow = frameWindow;
|
|
sendAppearanceRef.current();
|
|
frameWindow.postMessage(createWebAppStartupProbe(attempt.nonce), "*");
|
|
}, [iframeRef]);
|
|
|
|
return { frameState, handleLoad };
|
|
}
|
|
|
|
/**
|
|
* Hosts a static plugin web application without bringing it into the SPA
|
|
* process. The sandbox preserves same-origin requests while omitting 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 iframeRef = useRef<HTMLIFrameElement | null>(null);
|
|
|
|
const sendAppearance = useCallback(() => {
|
|
const target = iframeRef.current?.contentWindow;
|
|
if (!target) return;
|
|
target.postMessage(resolveWebAppAppearance(document, resolvedTheme), "*");
|
|
}, [resolvedTheme]);
|
|
|
|
const { frameState, handleLoad } = useWebAppFrameLifecycle({
|
|
runtimeUrl,
|
|
iframeRef,
|
|
sendAppearance,
|
|
onLoad,
|
|
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 && frameState !== "unavailable" ? (
|
|
// No onError here: React never attaches a DOM "error" listener for iframe/object/embed
|
|
// (it only listens for "load"), so a document-load failure is unobservable from the
|
|
// frame element itself. It surfaces only through the guest's own postMessage handshake
|
|
// (the "document_error" reason) or the startup timeout.
|
|
<iframe
|
|
key={runtimeUrl}
|
|
title={title}
|
|
src={runtimeUrl}
|
|
ref={iframeRef}
|
|
sandbox="allow-scripts allow-forms allow-same-origin"
|
|
referrerPolicy="no-referrer"
|
|
loading="eager"
|
|
className="block h-full min-h-0 w-full min-w-0 flex-1 border-0"
|
|
onLoad={handleLoad}
|
|
/>
|
|
) : 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>
|
|
);
|
|
}
|