From 8ed6ce5720a5a1f00b4514c19dd4e53a4b7091ce Mon Sep 17 00:00:00 2001 From: DOSAYGO Engineering Date: Thu, 19 Mar 2026 23:53:35 +0800 Subject: [PATCH] Add BrowserBox browser integration - Replace remote browser navigation with BrowserBox webview component - Bootstrap BrowserBox demo sessions from win9-5.com session API - Keep local iframe/directory browser path intact for local content - Remove COEP credentialless (blocks cross-origin BrowserBox iframe) - Update browserbox-webview.js with embedder-origin API Co-Authored-By: Claude Opus 4.6 --- components/apps/Browser/StyledBrowser.ts | 13 +- components/apps/Browser/browserboxSession.ts | 262 ++ components/apps/Browser/index.tsx | 1043 ++++-- next.config.js | 4 - public/browserbox-webview.js | 3457 ++++++++++++++++++ 5 files changed, 4499 insertions(+), 280 deletions(-) create mode 100644 components/apps/Browser/browserboxSession.ts create mode 100644 public/browserbox-webview.js diff --git a/components/apps/Browser/StyledBrowser.ts b/components/apps/Browser/StyledBrowser.ts index 6013b373df..2749cc8ea9 100644 --- a/components/apps/Browser/StyledBrowser.ts +++ b/components/apps/Browser/StyledBrowser.ts @@ -5,13 +5,24 @@ type StyledBrowserProps = { }; const StyledBrowser = styled.div` - iframe { + iframe, + .browserbox-host { background-color: ${({ $hasSrcDoc }) => ($hasSrcDoc ? "#fff" : "initial")}; border: 0; height: calc(100% - 42px - 37px); width: 100%; } + .browserbox-host { + background-color: #fff; + } + + .browserbox-host > browserbox-webview { + display: block; + height: 100%; + width: 100%; + } + nav { background-color: rgb(87 87 87); display: flex; diff --git a/components/apps/Browser/browserboxSession.ts b/components/apps/Browser/browserboxSession.ts new file mode 100644 index 0000000000..9e2a059263 --- /dev/null +++ b/components/apps/Browser/browserboxSession.ts @@ -0,0 +1,262 @@ +export const DEFAULT_BROWSERBOX_SESSION_API_BASE_URL = + process.env.NEXT_PUBLIC_BROWSERBOX_SESSION_API_BASE_URL?.trim() || + "https://win9-5.com"; + +export const BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH = "browserbox-webview.js"; + +const BROWSERBOX_SCRIPT_DATA_ATTRIBUTE = "data-browserbox-webview"; +const SESSION_REQUEST_TIMEOUT_MS = 120_000; + +type BrowserBoxSessionSource = { + expiresAt?: number; + expires_at?: number; + id?: string; + loginLink?: string; + loginUrl?: string; + login_url?: string; + region?: string; + remainingMs?: number; + remaining_ms?: number; + sessionId?: string; + session_id?: string; +}; + +export type BrowserBoxSession = BrowserBoxSessionSource & { + active?: boolean; + loginUrl: string; + region: string; + remainingMs: number; + sessionId: string; +}; + +export type BrowserBoxTab = { + active?: boolean; + canGoBack?: boolean; + canGoForward?: boolean; + faviconDataURI?: string; + id?: string; + loading?: boolean; + title?: string; + url?: string; +}; + +export type BrowserBoxWebviewElement = HTMLElement & { + getTabs: () => Promise; + goBack: () => Promise; + goForward: () => Promise; + navigateTo: (url: string) => Promise; + reload: () => Promise; + stop: () => Promise; + whenReady: () => Promise; +}; + +let browserBoxAssetPromise: Promise | undefined; + +const withTrailingSlashRemoved = (value: string): string => + value.endsWith("/") ? value.slice(0, -1) : value; + +export const normalizeBrowserBoxLoginLink = (rawLoginLink: string): string => { + if (typeof rawLoginLink !== "string" || rawLoginLink.trim().length === 0) { + return ""; + } + + try { + const parsed = new URL(rawLoginLink, window.location.href); + parsed.searchParams.set("ui", "false"); + return parsed.href; + } catch { + return rawLoginLink.trim(); + } +}; + +export const getBrowserBoxWebviewAssetUrl = (): string => { + if (typeof window === "undefined") { + return `/${BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH}`; + } + + return new URL( + BROWSERBOX_WEBVIEW_ASSET_RELATIVE_PATH, + document.baseURI + ).toString(); +}; + +export const loadBrowserBoxWebviewAsset = async ( + assetUrl = getBrowserBoxWebviewAssetUrl() +): Promise => { + if (typeof window === "undefined") return; + if (window.customElements?.get("browserbox-webview")) return; + if (!browserBoxAssetPromise) { + browserBoxAssetPromise = new Promise((resolve, reject) => { + const existingScript = document.querySelector( + `script[${BROWSERBOX_SCRIPT_DATA_ATTRIBUTE}="true"]` + ); + + if (existingScript) { + existingScript.addEventListener("load", () => resolve(), { + once: true, + }); + existingScript.addEventListener( + "error", + () => + reject( + new Error("Existing BrowserBox webview asset failed to load.") + ), + { once: true } + ); + return; + } + + const script = document.createElement("script"); + + script.async = true; + script.dataset.browserboxWebview = "true"; + script.src = assetUrl; + script.addEventListener("load", () => resolve(), { once: true }); + script.addEventListener( + "error", + () => + reject(new Error(`Failed to load BrowserBox asset at ${assetUrl}.`)), + { once: true } + ); + document.head.append(script); + }).finally(() => { + if (!window.customElements?.get("browserbox-webview")) { + browserBoxAssetPromise = undefined; + } + }); + } + + await browserBoxAssetPromise; +}; + +export class BrowserBoxSessionClient { + public readonly baseUrl: string; + + public constructor(serverBaseUrl = DEFAULT_BROWSERBOX_SESSION_API_BASE_URL) { + this.baseUrl = withTrailingSlashRemoved(serverBaseUrl.trim()); + } + + public normalizeSession(raw: BrowserBoxSessionSource): BrowserBoxSession { + const loginUrl = raw.loginUrl || raw.login_url || raw.loginLink || ""; + const sessionId = raw.sessionId || raw.session_id || raw.id || ""; + let remainingMs = Number(raw.remainingMs ?? raw.remaining_ms); + + if (!Number.isFinite(remainingMs)) { + const expiresAt = Number(raw.expiresAt ?? raw.expires_at); + + remainingMs = Number.isFinite(expiresAt) + ? Math.max(0, expiresAt - Date.now()) + : 0; + } + + return { + ...raw, + loginUrl, + region: raw.region || "iad", + remainingMs, + sessionId, + }; + } + + public async createSession(): Promise { + const controller = new AbortController(); + const timeoutId = window.setTimeout( + () => controller.abort(), + SESSION_REQUEST_TIMEOUT_MS + ); + + try { + const response = await fetch(`${this.baseUrl}/api/session`, { + body: JSON.stringify({}), + credentials: "include", + headers: { "Content-Type": "application/json" }, + method: "POST", + mode: "cors", + signal: controller.signal, + }); + const payload = (await response.json().catch(() => ({}))) as + | BrowserBoxSessionSource + | { error?: string }; + + if (!response.ok) { + const errorMessage = + payload && + typeof payload === "object" && + "error" in payload && + typeof payload.error === "string" && + payload.error.length > 0 + ? payload.error + : `Failed to create BrowserBox session (${response.status}).`; + + throw new Error(errorMessage); + } + + return this.normalizeSession(payload as BrowserBoxSessionSource); + } finally { + window.clearTimeout(timeoutId); + } + } + + public async checkSession(): Promise< + { active: false } | ({ active: true } & BrowserBoxSession) + > { + try { + const response = await fetch(`${this.baseUrl}/api/session/status`, { + credentials: "include", + method: "GET", + mode: "cors", + }); + const payload = (await response.json().catch(() => ({}))) as + | (BrowserBoxSessionSource & { active?: boolean }) + | { active?: boolean }; + + if (!response.ok || !payload?.active) { + return { active: false }; + } + + return { + active: true, + ...this.normalizeSession(payload as BrowserBoxSessionSource), + }; + } catch { + return { active: false }; + } + } + + public async notifyDisconnect( + sessionId: string, + options: { mode?: "defer" | "hard" } = {} + ): Promise { + if (!sessionId) return; + + const payload = JSON.stringify({ + mode: options.mode === "hard" ? "hard" : "defer", + sessionId, + }); + const url = `${this.baseUrl}/api/session/disconnect`; + + if (typeof navigator.sendBeacon === "function") { + try { + const blob = new Blob([payload], { type: "application/json" }); + + navigator.sendBeacon(url, blob); + return; + } catch { + // Fall through to fetch keepalive. + } + } + + try { + await fetch(url, { + body: payload, + credentials: "include", + headers: { "Content-Type": "application/json" }, + keepalive: true, + method: "POST", + mode: "cors", + }); + } catch (error) { + console.error("BrowserBox disconnect notification failed.", error); + } + } +} diff --git a/components/apps/Browser/index.tsx b/components/apps/Browser/index.tsx index 2b99534990..7e5bdec9b8 100644 --- a/components/apps/Browser/index.tsx +++ b/components/apps/Browser/index.tsx @@ -1,8 +1,5 @@ import { basename, join, resolve } from "path"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; -import useProxyMenu, { - type ProxyState, -} from "components/apps/Browser/useProxyMenu"; import { ADDRESS_INPUT_PROPS } from "components/apps/FileExplorer/AddressBar"; import useHistoryMenu from "components/apps/Browser/useHistoryMenu"; import useBookmarkMenu from "components/apps/Browser/useBookmarkMenu"; @@ -11,17 +8,18 @@ import { type DirectoryEntries, } from "components/apps/Browser/directoryIndex"; import { - Arrow, - Network, - Refresh, - Stop, -} from "components/apps/Browser/NavigationIcons"; + BrowserBoxSessionClient, + loadBrowserBoxWebviewAsset, + normalizeBrowserBoxLoginLink, + type BrowserBoxSession, + type BrowserBoxWebviewElement, +} from "components/apps/Browser/browserboxSession"; +import { Arrow, Refresh, Stop } from "components/apps/Browser/NavigationIcons"; import StyledBrowser from "components/apps/Browser/StyledBrowser"; import { DINO_GAME, HOME_PAGE, NOT_FOUND, - PROXIES, bookmarks, } from "components/apps/Browser/config"; import { type ComponentProcessProps } from "components/system/Apps/RenderComponent"; @@ -33,7 +31,6 @@ import useHistory from "hooks/useHistory"; import Button from "styles/common/Button"; import Icon from "styles/common/Icon"; import { - FAVICON_BASE_PATH, IFRAME_CONFIG, ONE_TIME_PASSIVE_EVENT, SHORTCUT_EXTENSION, @@ -59,6 +56,61 @@ declare module "react" { } } +type BrowserSurfaceMode = "local" | "remote"; + +type RemoteNavigationState = { + canGoBack: boolean; + canGoForward: boolean; +}; + +const BROWSERBOX_REQUEST_TIMEOUT_MS = "45000"; +const REMOTE_NAVIGATION_STATE: RemoteNavigationState = { + canGoBack: false, + canGoForward: false, +}; + +const createBrowserBoxStatusPage = ( + title: string, + message: string +): string => ` + + + + ${title} + + + +
+

${title}

+

${message}

+
+ +`; + const Browser: FC = ({ id }) => { const { icon: setIcon, @@ -69,21 +121,350 @@ const Browser: FC = ({ id }) => { } = useProcesses(); const { setForegroundId, updateRecentFiles } = useSession(); const { prependFileToTitle } = useTitle(id); - const { initialTitle = "", url = "" } = process || {}; + const { url = "" } = process || {}; const initialUrl = url || HOME_PAGE; const { canGoBack, canGoForward, history, moveHistory, position } = useHistory(initialUrl, id); const { exists, fs, stat, readFile, readdir } = useFileSystem(); const inputRef = useRef(null); const iframeRef = useRef(null); + const browserBoxHostRef = useRef(null); + const browserBoxRef = useRef(null); + const browserBoxListenersCleanupRef = useRef<(() => void) | undefined>( + undefined + ); + const browserBoxSessionRef = useRef(undefined); + const browserBoxSessionPromiseRef = useRef< + Promise | undefined + >(undefined); + const browserBoxDisconnectNotifiedRef = useRef(false); const [loading, setLoading] = useState(false); const [srcDoc, setSrcDoc] = useState(""); - const changeHistory = (step: number): void => { - moveHistory(step); - - if (inputRef.current) inputRef.current.value = history[position + step]; - }; + const [surfaceMode, setSurfaceMode] = useState("local"); + const surfaceModeRef = useRef("local"); + const [remoteNavigationState, setRemoteNavigationState] = + useState(REMOTE_NAVIGATION_STATE); const currentUrl = useRef(""); + const browserBoxClient = useMemo(() => new BrowserBoxSessionClient(), []); + + const changeHistory = useCallback( + (step: number): void => { + moveHistory(step); + + if (inputRef.current) inputRef.current.value = history[position + step]; + }, + [history, moveHistory, position] + ); + + const runAsync = useCallback( + (operation: () => Promise, errorMessage: string): void => { + operation().catch((error) => { + console.error(errorMessage, error); + }); + }, + [] + ); + + const goToLink = useCallback( + (newUrl: string): void => { + if (inputRef.current) { + inputRef.current.value = newUrl; + } + + changeUrl(id, newUrl); + }, + [changeUrl, id] + ); + + const { backMenu, forwardMenu } = useHistoryMenu( + history, + position, + moveHistory + ); + const bookmarkMenu = useBookmarkMenu(); + + const resetRemoteNavigationState = useCallback((): void => { + setRemoteNavigationState(REMOTE_NAVIGATION_STATE); + }, []); + + const showBrowserBoxStatus = useCallback( + (message: string): void => { + setSurfaceMode("local"); + setLoading(false); + setSrcDoc(createBrowserBoxStatusPage("BrowserBox unavailable", message)); + prependFileToTitle("BrowserBox unavailable"); + setIcon(id, processDirectory.Browser.icon); + resetRemoteNavigationState(); + }, + [id, prependFileToTitle, resetRemoteNavigationState, setIcon] + ); + + const notifyBrowserBoxDisconnect = useCallback(async (): Promise => { + if (browserBoxDisconnectNotifiedRef.current) return; + + const sessionId = browserBoxSessionRef.current?.sessionId; + + if (!sessionId) return; + browserBoxDisconnectNotifiedRef.current = true; + await browserBoxClient.notifyDisconnect(sessionId, { mode: "defer" }); + }, [browserBoxClient]); + + const isBrowserBoxUsable = useCallback( + async (webview: BrowserBoxWebviewElement | null): Promise => { + if (!webview || typeof webview.getTabs !== "function") { + return false; + } + + try { + await webview.getTabs(); + return true; + } catch { + return false; + } + }, + [] + ); + + const refreshBrowserBoxState = useCallback(async (): Promise => { + const webview = browserBoxRef.current; + + if (!webview) return; + + try { + const tabs = await webview.getTabs(); + const activeTab = tabs.find((tab) => tab.active) || tabs[0]; + + if (!activeTab) { + resetRemoteNavigationState(); + return; + } + + setRemoteNavigationState({ + canGoBack: Boolean(activeTab.canGoBack), + canGoForward: Boolean(activeTab.canGoForward), + }); + + if (activeTab.url && inputRef.current) { + inputRef.current.value = activeTab.url; + } + + if (activeTab.title) { + prependFileToTitle(activeTab.title); + } else if (activeTab.url) { + prependFileToTitle(activeTab.url); + } + + if (activeTab.faviconDataURI) { + setIcon(id, activeTab.faviconDataURI); + } + } catch (error) { + console.error("Failed to refresh BrowserBox tab state.", error); + } + }, [id, prependFileToTitle, resetRemoteNavigationState, setIcon]); + + const wireBrowserBoxEvents = useCallback( + (webview: BrowserBoxWebviewElement): (() => void) => { + const handleReady = (): void => { + setLoading(false); + }; + const handleRefreshState = (): void => { + runAsync( + refreshBrowserBoxState, + "Failed to refresh BrowserBox tab state." + ); + }; + const handleApiReady = (): void => { + handleRefreshState(); + }; + const handleDidStartLoading = (): void => { + setLoading(true); + }; + const handleDidStopLoading = (): void => { + setLoading(false); + runAsync( + refreshBrowserBoxState, + "Failed to refresh BrowserBox tab state." + ); + }; + const handleDidNavigate = (event: Event): void => { + const { url: navigatedUrl } = + (event as CustomEvent<{ url?: string }>).detail || {}; + + if (typeof navigatedUrl === "string" && navigatedUrl.length > 0) { + currentUrl.current = navigatedUrl; + if (inputRef.current) { + inputRef.current.value = navigatedUrl; + } + changeUrl(id, navigatedUrl); + } + + setSurfaceMode("remote"); + runAsync( + refreshBrowserBoxState, + "Failed to refresh BrowserBox tab state." + ); + }; + const handleTabMetadata = (): void => { + handleRefreshState(); + }; + const handleFocus = (): void => { + setForegroundId(id); + }; + const handleDisconnected = (event: Event): void => { + const { reason } = + (event as CustomEvent<{ reason?: string }>).detail || {}; + + if (reason === "login-link-changed") return; + + browserBoxSessionRef.current = undefined; + browserBoxDisconnectNotifiedRef.current = false; + resetRemoteNavigationState(); + + if (surfaceModeRef.current === "remote") { + showBrowserBoxStatus( + "BrowserBox disconnected while loading the remote browser session." + ); + } + }; + + webview.addEventListener("ready", handleReady); + webview.addEventListener("api-ready", handleApiReady); + webview.addEventListener("did-start-loading", handleDidStartLoading); + webview.addEventListener("did-stop-loading", handleDidStopLoading); + webview.addEventListener("did-navigate", handleDidNavigate); + webview.addEventListener("active-tab-changed", handleTabMetadata); + webview.addEventListener("tab-updated", handleTabMetadata); + webview.addEventListener("favicon-changed", handleTabMetadata); + webview.addEventListener("pointerdown", handleFocus); + webview.addEventListener("focusin", handleFocus); + webview.addEventListener("disconnected", handleDisconnected); + + return () => { + webview.removeEventListener("ready", handleReady); + webview.removeEventListener("api-ready", handleApiReady); + webview.removeEventListener("did-start-loading", handleDidStartLoading); + webview.removeEventListener("did-stop-loading", handleDidStopLoading); + webview.removeEventListener("did-navigate", handleDidNavigate); + webview.removeEventListener("active-tab-changed", handleTabMetadata); + webview.removeEventListener("tab-updated", handleTabMetadata); + webview.removeEventListener("favicon-changed", handleTabMetadata); + webview.removeEventListener("pointerdown", handleFocus); + webview.removeEventListener("focusin", handleFocus); + webview.removeEventListener("disconnected", handleDisconnected); + }; + }, + [ + changeUrl, + id, + refreshBrowserBoxState, + resetRemoteNavigationState, + runAsync, + setForegroundId, + showBrowserBoxStatus, + ] + ); + + const ensureBrowserBoxElement = + useCallback(async (): Promise => { + await loadBrowserBoxWebviewAsset(); + + const host = browserBoxHostRef.current; + + if (!host) { + throw new Error("BrowserBox host element is missing."); + } + + let webview = browserBoxRef.current; + + if (!webview || !host.contains(webview)) { + webview = document.createElement( + "browserbox-webview" + ) as BrowserBoxWebviewElement; + webview.style.display = "block"; + webview.style.height = "100%"; + webview.style.width = "100%"; + webview.setAttribute("allow-user-toggle-ui", "false"); + webview.setAttribute("height", "100%"); + webview.setAttribute( + "request-timeout-ms", + BROWSERBOX_REQUEST_TIMEOUT_MS + ); + webview.setAttribute("title", `${id} BrowserBox`); + webview.setAttribute("ui-visible", "false"); + webview.setAttribute("width", "100%"); + browserBoxListenersCleanupRef.current?.(); + browserBoxListenersCleanupRef.current = wireBrowserBoxEvents(webview); + host.replaceChildren(webview); + browserBoxRef.current = webview; + } + + webview.setAttribute("embedder-origin", window.location.origin); + linkElement(id, "peekElement", webview); + + return webview; + }, [id, linkElement, wireBrowserBoxEvents]); + + const ensureBrowserBoxSession = useCallback(async (): Promise< + BrowserBoxSession | undefined + > => { + if (browserBoxSessionRef.current?.loginUrl) { + return browserBoxSessionRef.current; + } + + if (browserBoxSessionPromiseRef.current) { + return browserBoxSessionPromiseRef.current; + } + + browserBoxSessionPromiseRef.current = (async () => { + const existingSession = await browserBoxClient.checkSession(); + const nextSession = + existingSession.active && existingSession.loginUrl + ? existingSession + : await browserBoxClient.createSession(); + + if (!nextSession?.loginUrl) { + throw new Error("BrowserBox session response is missing loginUrl."); + } + + const normalizedSession = { + ...nextSession, + loginUrl: normalizeBrowserBoxLoginLink(nextSession.loginUrl), + }; + + browserBoxDisconnectNotifiedRef.current = false; + browserBoxSessionRef.current = normalizedSession; + + return normalizedSession; + })().finally(() => { + browserBoxSessionPromiseRef.current = undefined; + }); + + return browserBoxSessionPromiseRef.current; + }, [browserBoxClient]); + + const ensureRemoteBrowserReady = + useCallback(async (): Promise => { + const session = await ensureBrowserBoxSession(); + + if (!session?.loginUrl) { + throw new Error("BrowserBox session did not return a loginUrl."); + } + + const webview = await ensureBrowserBoxElement(); + + if (webview.getAttribute("login-link") !== session.loginUrl) { + webview.setAttribute("login-link", session.loginUrl); + } + + setSurfaceMode("remote"); + + if (!(await isBrowserBoxUsable(webview))) { + await webview.whenReady(); + } + + return webview; + }, [ensureBrowserBoxElement, ensureBrowserBoxSession, isBrowserBoxUsable]); + const changeIframeWindowLocation = ( newUrl: string, contentWindow: Window @@ -103,331 +484,443 @@ const Browser: FC = ({ id }) => { contentWindow.location?.replace(newUrl); } }; - const goToLink = useCallback( - (newUrl: string): void => { - if (inputRef.current) { - inputRef.current.value = newUrl; - } - changeUrl(id, newUrl); + const navigateWithBrowserBox = useCallback( + async (addressInput: string, addressUrl: string): Promise => { + setLoading(true); + setSrcDoc(""); + setIcon(id, processDirectory.Browser.icon); + + try { + const webview = await ensureRemoteBrowserReady(); + currentUrl.current = addressUrl; + if (inputRef.current) { + inputRef.current.value = addressUrl; + } + await webview.navigateTo(addressUrl); + + if (addressUrl.startsWith(GOOGLE_SEARCH_QUERY)) { + prependFileToTitle(`${addressInput} - Google Search`); + } else { + const bookmark = bookmarks.find( + ({ url: bookmarkUrl }) => bookmarkUrl === addressInput + ); + + prependFileToTitle(bookmark?.name || addressUrl); + } + + await refreshBrowserBoxState(); + } catch (error) { + console.error("BrowserBox navigation failed.", error); + showBrowserBoxStatus( + `BrowserBox could not open ${addressInput}. Check the demo session service and try again.` + ); + } finally { + setLoading(false); + } }, - [changeUrl, id] - ); - const { backMenu, forwardMenu } = useHistoryMenu( - history, - position, - moveHistory + [ + ensureRemoteBrowserReady, + id, + prependFileToTitle, + refreshBrowserBoxState, + setIcon, + showBrowserBoxStatus, + ] ); - const [proxyState, setProxyState] = useState("CORS"); - const proxyMenu = useProxyMenu(proxyState, setProxyState); - const bookmarkMenu = useBookmarkMenu(); + const setUrl = useCallback( async (addressInput: string): Promise => { const { contentWindow } = iframeRef.current || {}; + const isHtml = + [".htm", ".html"].includes(getExtension(addressInput)) && + (await exists(addressInput)); - if (contentWindow?.location) { - const isHtml = - [".htm", ".html"].includes(getExtension(addressInput)) && - (await exists(addressInput)); - - setLoading(true); - if (isHtml) setSrcDoc((await readFile(addressInput)).toString()); - setIcon(id, processDirectory.Browser.icon); + setIcon(id, processDirectory.Browser.icon); - if (addressInput.toLowerCase().startsWith(DINO_GAME.url)) { + if (addressInput.toLowerCase().startsWith(DINO_GAME.url)) { + if (contentWindow?.location) { + setSurfaceMode("local"); + setLoading(true); changeIframeWindowLocation( `${window.location.origin}${DINO_GAME.path}`, contentWindow ); prependFileToTitle(`${DINO_GAME.url}/`); - } else if (!isHtml) { - const processedUrl = await getUrlOrSearch(addressInput); - - if ( - LOCAL_HOST.has(processedUrl.host) || - LOCAL_HOST.has(addressInput) - ) { - const directory = - decodeURI(processedUrl.pathname).replace(/\/$/, "") || "/"; - const searchParams = Object.fromEntries( - new URLSearchParams( - processedUrl.search.replace(";", "&") - ).entries() - ); - const { O: order, C: column } = searchParams; - const isAscending = !order || order === "A"; - - let newSrcDoc = NOT_FOUND; - let newTitle = "404 Not Found"; - - if ( - (await exists(directory)) && - (await stat(directory)).isDirectory() - ) { - const dirStats = ( - await Promise.all( - (await readdir(directory)).map(async (entry) => { - const href = join(directory, entry); - let description; - let shortcutUrl; - - if (getExtension(entry) === SHORTCUT_EXTENSION) { - try { - ({ comment: description, url: shortcutUrl } = - getShortcutInfo(await readFile(href))); - } catch { - // Ignore failure to read shortcut - } - } - - const filePath = - shortcutUrl && (await exists(shortcutUrl)) - ? shortcutUrl - : href; - const stats = await stat(filePath); - const isDir = stats.isDirectory(); - - return { - description, - href: isDir && shortcutUrl ? shortcutUrl : href, - icon: isDir ? "folder" : undefined, - modified: getModifiedTime(filePath, stats), - size: isDir || shortcutUrl ? undefined : stats.size, - }; - }) - ) - ) - .sort( - (a, b) => - Number(b.icon === "folder") - Number(a.icon === "folder") - ) - .sort((a, b) => { - const aIsFolder = a.icon === "folder"; - const bIsFolder = b.icon === "folder"; - - if (aIsFolder === bIsFolder) { - const aName = basename(a.href); - const bName = basename(b.href); - - if (isAscending) return aName < bName ? -1 : 1; - - return aName > bName ? -1 : 1; - } - - return 0; - }) - .sort((a, b) => { - if (!column || column === "N") return 0; + resetRemoteNavigationState(); + } + return; + } - const sortValue = ( - getValue: (entry: DirectoryEntries) => number | string - ): number => { - const aValue = getValue(a); - const bValue = getValue(b); + if (isHtml && contentWindow?.location) { + setSurfaceMode("local"); + setLoading(true); + setSrcDoc((await readFile(addressInput)).toString()); + prependFileToTitle(basename(addressInput) || addressInput); + resetRemoteNavigationState(); + return; + } - if (aValue === bValue) return 0; - if (isAscending) return aValue < bValue ? -1 : 1; + const processedUrl = await getUrlOrSearch(addressInput); - return aValue > bValue ? -1 : 1; - }; + if (LOCAL_HOST.has(processedUrl.host) || LOCAL_HOST.has(addressInput)) { + if (!contentWindow?.location) return; - if (column === "S") { - return sortValue(({ size }) => size ?? 0); + setSurfaceMode("local"); + setLoading(true); + resetRemoteNavigationState(); + + const directory = + decodeURI(processedUrl.pathname).replace(/\/$/, "") || "/"; + const searchParams = Object.fromEntries( + new URLSearchParams(processedUrl.search.replace(";", "&")).entries() + ); + const { O: order, C: column } = searchParams; + const isAscending = !order || order === "A"; + + let newSrcDoc = NOT_FOUND; + let newTitle = "404 Not Found"; + + if ( + (await exists(directory)) && + (await stat(directory)).isDirectory() + ) { + const dirStats = ( + await Promise.all( + (await readdir(directory)).map(async (entry) => { + const href = join(directory, entry); + let description; + let shortcutUrl; + + if (getExtension(entry) === SHORTCUT_EXTENSION) { + try { + ({ comment: description, url: shortcutUrl } = + getShortcutInfo(await readFile(href))); + } catch { + // Ignore failure to read shortcut } + } + + const filePath = + shortcutUrl && (await exists(shortcutUrl)) + ? shortcutUrl + : href; + const stats = await stat(filePath); + const isDir = stats.isDirectory(); + + return { + description, + href: isDir && shortcutUrl ? shortcutUrl : href, + icon: isDir ? "folder" : undefined, + modified: getModifiedTime(filePath, stats), + size: isDir || shortcutUrl ? undefined : stats.size, + }; + }) + ) + ) + .sort( + (a, b) => + Number(b.icon === "folder") - Number(a.icon === "folder") + ) + .sort((a, b) => { + const aIsFolder = a.icon === "folder"; + const bIsFolder = b.icon === "folder"; + + if (aIsFolder === bIsFolder) { + const aName = basename(a.href); + const bName = basename(b.href); + + if (isAscending) return aName < bName ? -1 : 1; + + return aName > bName ? -1 : 1; + } - if (column === "M") { - return sortValue(({ modified }) => modified ?? 0); - } + return 0; + }) + .sort((a, b) => { + if (!column || column === "N") return 0; - if (column === "D") { - return sortValue(({ description }) => description ?? ""); - } + const sortValue = ( + getValue: (entry: DirectoryEntries) => number | string + ): number => { + const aValue = getValue(a); + const bValue = getValue(b); - return 0; - }) - .sort( - (a, b) => - Number(b.icon === "folder") - Number(a.icon === "folder") - ); + if (aValue === bValue) return 0; + if (isAscending) return aValue < bValue ? -1 : 1; - iframeRef.current?.addEventListener( - "load", - () => { - try { - contentWindow.document.body - .querySelectorAll("a") - .forEach((a) => { - a.addEventListener("click", (event) => { - event.preventDefault(); - - const target = - event.currentTarget as HTMLAnchorElement; - const isDir = - target.getAttribute("type") === "folder"; - const { origin, pathname, search } = new URL( - target.href - ); - - if (search) { - goToLink( - `${origin}${encodeURI(directory)}${search}` - ); - } else if (isDir) { - goToLink(target.href); - } else if (fs && target.href) { - getInfoWithExtension( - fs, - decodeURI(pathname), - getExtension(pathname), - ({ pid, url: infoUrl }) => { - open(pid || "OpenWith", { url: infoUrl }); - - if (pid && infoUrl) { - updateRecentFiles(infoUrl, pid); - } - } - ); - } - }); - }); - } catch { - // Ignore failure to add click event listeners - } - }, - ONE_TIME_PASSIVE_EVENT - ); - - newSrcDoc = createDirectoryIndex( - directory, - processedUrl.origin, - searchParams, - directory === "/" - ? dirStats - : [ - { - href: resolve(directory, ".."), - icon: "back", - }, - ...dirStats, - ] - ); - - newTitle = `Index of ${directory}`; - } + return aValue > bValue ? -1 : 1; + }; + + if (column === "S") { + return sortValue(({ size }) => size ?? 0); + } - setSrcDoc(newSrcDoc); - prependFileToTitle(newTitle); - } else { - const addressUrl = PROXIES[proxyState] - ? await PROXIES[proxyState](processedUrl.href) - : processedUrl.href; + if (column === "M") { + return sortValue(({ modified }) => modified ?? 0); + } - changeIframeWindowLocation(addressUrl, contentWindow); + if (column === "D") { + return sortValue(({ description }) => description ?? ""); + } - if (addressUrl.startsWith(GOOGLE_SEARCH_QUERY)) { - prependFileToTitle(`${addressInput} - Google Search`); - } else { - const { name = initialTitle } = - bookmarks?.find( - ({ url: bookmarkUrl }) => bookmarkUrl === addressInput - ) || {}; + return 0; + }) + .sort( + (a, b) => + Number(b.icon === "folder") - Number(a.icon === "folder") + ); - prependFileToTitle(name); - } + iframeRef.current?.addEventListener( + "load", + () => { + try { + contentWindow.document.body + .querySelectorAll("a") + .forEach((a) => { + a.addEventListener("click", (event) => { + event.preventDefault(); + + const target = event.currentTarget as HTMLAnchorElement; + const isDir = target.getAttribute("type") === "folder"; + const { origin, pathname, search } = new URL(target.href); + + if (search) { + goToLink(`${origin}${encodeURI(directory)}${search}`); + } else if (isDir) { + goToLink(target.href); + } else if (fs && target.href) { + getInfoWithExtension( + fs, + decodeURI(pathname), + getExtension(pathname), + ({ pid, url: infoUrl }) => { + open(pid || "OpenWith", { url: infoUrl }); + + if (pid && infoUrl) { + updateRecentFiles(infoUrl, pid); + } + } + ); + } + }); + }); + } catch { + // Ignore failure to add click event listeners + } + }, + ONE_TIME_PASSIVE_EVENT + ); - if (addressInput.startsWith("ipfs://")) { - setIcon(id, "/System/Icons/Favicons/ipfs.webp"); - } else { - const favicon = new Image(); - const faviconUrl = `${ - new URL(addressUrl).origin - }${FAVICON_BASE_PATH}`; - - favicon.addEventListener( - "error", - () => { - const { icon } = - bookmarks?.find( - ({ url: bookmarkUrl }) => bookmarkUrl === addressUrl - ) || {}; - - if (icon) setIcon(id, icon); - }, - ONE_TIME_PASSIVE_EVENT - ); - favicon.addEventListener( - "load", - () => setIcon(id, faviconUrl), - ONE_TIME_PASSIVE_EVENT - ); - favicon.decoding = "async"; - favicon.src = faviconUrl; - } - } + newSrcDoc = createDirectoryIndex( + directory, + processedUrl.origin, + searchParams, + directory === "/" + ? dirStats + : [ + { + href: resolve(directory, ".."), + icon: "back", + }, + ...dirStats, + ] + ); + + newTitle = `Index of ${directory}`; } + + setSrcDoc(newSrcDoc); + prependFileToTitle(newTitle); + return; } + + await navigateWithBrowserBox(addressInput, processedUrl.href); }, [ exists, fs, goToLink, id, - initialTitle, + navigateWithBrowserBox, open, prependFileToTitle, - proxyState, readFile, readdir, + resetRemoteNavigationState, setIcon, stat, updateRecentFiles, ] ); + const supportsCredentialless = useMemo( () => "credentialless" in HTMLIFrameElement.prototype, [] ); + const displayedCanGoBack = + surfaceMode === "remote" ? remoteNavigationState.canGoBack : canGoBack; + const displayedCanGoForward = + surfaceMode === "remote" + ? remoteNavigationState.canGoForward + : canGoForward; + + const goBack = useCallback((): void => { + if (surfaceMode === "remote") { + runAsync(async () => { + const webview = await ensureRemoteBrowserReady(); + await webview.goBack(); + await refreshBrowserBoxState(); + }, "BrowserBox goBack failed."); + return; + } + + changeHistory(-1); + }, [ + changeHistory, + ensureRemoteBrowserReady, + refreshBrowserBoxState, + runAsync, + surfaceMode, + ]); + + const goForward = useCallback((): void => { + if (surfaceMode === "remote") { + runAsync(async () => { + const webview = await ensureRemoteBrowserReady(); + await webview.goForward(); + await refreshBrowserBoxState(); + }, "BrowserBox goForward failed."); + return; + } + + changeHistory(1); + }, [ + changeHistory, + ensureRemoteBrowserReady, + refreshBrowserBoxState, + runAsync, + surfaceMode, + ]); + + const reloadCurrent = useCallback((): void => { + if (surfaceMode === "remote") { + runAsync(async () => { + try { + setLoading(true); + const webview = await ensureRemoteBrowserReady(); + await webview.reload(); + } catch (error) { + setLoading(false); + throw error; + } + }, "BrowserBox reload failed."); + return; + } + + runAsync(() => setUrl(history[position]), "Failed to navigate browser."); + }, [ + ensureRemoteBrowserReady, + history, + position, + runAsync, + setUrl, + surfaceMode, + ]); + + const stopCurrent = useCallback((): void => { + if (surfaceMode === "remote") { + runAsync(async () => { + const webview = browserBoxRef.current; + + if (!webview) return; + + try { + await webview.stop(); + } finally { + setLoading(false); + } + }, "BrowserBox stop failed."); + return; + } + + setLoading(false); + }, [runAsync, surfaceMode]); + + useEffect(() => { + surfaceModeRef.current = surfaceMode; + }, [surfaceMode]); useEffect(() => { if (process && history[position] !== currentUrl.current) { currentUrl.current = history[position]; - setUrl(history[position]); + runAsync(() => setUrl(history[position]), "Failed to navigate browser."); } - }, [history, position, process, setUrl]); + }, [history, position, process, runAsync, setUrl]); + + useEffect(() => { + const handlePageHide = (): void => { + runAsync( + notifyBrowserBoxDisconnect, + "Failed to notify BrowserBox disconnect." + ); + }; + + window.addEventListener("pagehide", handlePageHide); + window.addEventListener("beforeunload", handlePageHide); + + return () => { + window.removeEventListener("pagehide", handlePageHide); + window.removeEventListener("beforeunload", handlePageHide); + browserBoxListenersCleanupRef.current?.(); + browserBoxListenersCleanupRef.current = undefined; + runAsync( + notifyBrowserBoxDisconnect, + "Failed to notify BrowserBox disconnect." + ); + }; + }, [notifyBrowserBoxDisconnect, runAsync]); useEffect(() => { - if (iframeRef.current) { + if (surfaceMode === "remote" && browserBoxRef.current) { + linkElement(id, "peekElement", browserBoxRef.current); + } else if (iframeRef.current) { linkElement(id, "peekElement", iframeRef.current); } - }, [id, linkElement]); + }, [id, linkElement, surfaceMode]); return ( +