"use dom"; import "@expo/hub-components/theme.css"; import "../global.css"; import { DeviceScreen, displayScreen, useActiveDeviceClient, type DeviceHttpCodec, type DeviceStreamMode, } from "@expo/hub-client"; import { EmptyState, LogSidebar, ResizeHandle, ServerConnectionOverlay, Sidebar, StreamPanel, type StreamModeAvailability, bg, text, type AddDeviceOutcome, type AddDeviceTarget, type ClipboardRequest, type Device, type DeviceFrameAssets, } from "@expo/hub-components"; import { useEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import pixelDeviceFrame from "../assets/device-frames/google-pixel-10-pro.png"; import pixel9DeviceFrame from "../assets/device-frames/pixel_9_obsidian.png"; import iphoneDeviceFrame from "../assets/device-frames/iphone-17-pro-silver.png"; import { dashboardSingleDevice } from "./single-device"; import { AnimatedDockedSidebar } from "./dashboard/AnimatedDockedSidebar"; import { dashboardHideBootDevice } from "./boot-device"; import { basePath } from "./dashboard/basePath"; import { bootDevice, createDevice, removeDevice, shutdownDevice } from "./dashboard/deviceActions"; import { DEFAULT_SIDEBAR_WIDTH, useDashboardStore } from "./dashboard/dashboardStore"; import { useHideUnsupportedDevices, visibleDevices, visibleNewDeviceOptions, } from "./dashboard/deviceVisibility"; import { useColorScheme } from "./dashboard/useColorScheme"; import { useDeviceLists } from "./dashboard/useDevices"; import { FloatingSidebarToggle, floatingSidebarToggleInset, } from "./dashboard/FloatingSidebarToggle"; import { useArgentInteractions } from "./dashboard/useArgentInteraction"; import { useNewDeviceOptions } from "./dashboard/useNewDeviceOptions"; import { SidebarOverlay } from "./dashboard/SidebarOverlay"; import { useSidebarLayout } from "./dashboard/useSidebarLayout"; import { androidStreamModeAvailability, browserStreamModeAvailability, } from "./dashboard/streamMode"; import { dashboardPlatformFilter } from "./platform-filter"; /** Append `extra` devices not already present in `base` (deduped by id). */ function mergeById(base: Device[], extra: Device[]): Device[] { const ids = new Set(base.map((device) => device.id)); return [...base, ...extra.filter((device) => !ids.has(device.id))]; } // Resizable-sidebar bounds. Each column starts at the original fixed width and // can be dragged between MIN and MAX — never so wide that the stream, alongside // the other sidebar, is squeezed below MIN_STREAM. const MIN_SIDEBAR_WIDTH = 280; const MAX_SIDEBAR_WIDTH = 560; const MIN_STREAM_WIDTH = 320; const DEVICE_FRAME_ASSETS: DeviceFrameAssets = { "android:pixel-9": { src: String(pixel9DeviceFrame), width: 1935, height: 4096, screen: { x: 95, y: 94, width: 1737, height: 3907 }, screenRadius: 236, }, "android:pixel-10-pro": { src: String(pixelDeviceFrame), width: 1250, height: 2631, screen: { x: 50, y: 48, width: 1138, height: 2532 }, screenRadius: 150, }, "ios:iphone-17-pro": { src: String(iphoneDeviceFrame), width: 2620, height: 5420, screen: { x: 104, y: 88, width: 2412, height: 5244 }, screenRadius: 340, }, }; /** * Clamp a dragged sidebar width to `[MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH]`, and * also cap it so the stream keeps at least `MIN_STREAM_WIDTH` next to the other * sidebar (`otherWidth` is 0 when that sidebar is collapsed/overlaid). */ function clampSidebarWidth(width: number, otherWidth: number): number { const viewport = typeof window === "undefined" ? Infinity : window.innerWidth; const roomCap = viewport - otherWidth - MIN_STREAM_WIDTH; const upper = Math.max(MIN_SIDEBAR_WIDTH, Math.min(MAX_SIDEBAR_WIDTH, roomCap)); return Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), upper); } /** * The single Expo Hub screen, authored as an Expo DOM component (`'use dom'`) so * it renders with web primitives and real CSS. Left: simulators + emulators. * Center: the stream of the selected device. Right: the output (logs) for * that device. Hub's own dark mode follows the system setting via `dark-theme`; * the stream's Theme control flips the *device's* appearance, not Hub's. * * Sidebars dock whenever their measured widths leave enough room for the stream * and become toggleable overlays otherwise. */ export default function Dashboard(_props: { dom?: import("expo/dom").DOMProps }) { const scheme = useColorScheme(); const singleDevice = dashboardSingleDevice(); const hideBootDevice = !!singleDevice || dashboardHideBootDevice(); const platform = dashboardPlatformFilter(); const { booted, recent, connectionStatus } = useDeviceLists(); // Installed runtimes/system images and models for the new-device forms. const newDeviceOptions = useNewDeviceOptions(); const hideUnsupportedDevices = useHideUnsupportedDevices(); const selectedId = useDashboardStore((state) => state.selectedDeviceId); const selectDevice = useDashboardStore((state) => state.selectDevice); const reconcileSelectedDevice = useDashboardStore((state) => state.reconcileSelectedDevice); // Devices started through the picker are retained until host discovery catches up. const added = useDashboardStore((state) => state.addedDevices); const trackAddedDevice = useDashboardStore((state) => state.trackAddedDevice); const dismissDevice = useDashboardStore((state) => state.dismissDevice); const streamModeAvailability = useMemo(browserStreamModeAvailability, []); const streamMode = useDashboardStore((state) => state.streamMode); const chooseStreamMode = useDashboardStore((state) => state.chooseStreamMode); const showDeviceFrame = useDashboardStore((state) => state.showDeviceFrame); const setShowDeviceFrame = useDashboardStore((state) => state.setShowDeviceFrame); const [httpCodec, setHttpCodec] = useState(() => streamMode === "mjpeg" ? "mjpeg" : streamMode === "h264" ? "h264" : "auto", ); // Draggable widths for each inline sidebar. The `*Start` refs snapshot the // width when a drag begins so each move re-derives width from the start point // (delta-from-start), which clamps cleanly without drifting. const sidebarWidth = useDashboardStore((state) => state.sidebarWidths.left); const logsWidth = useDashboardStore((state) => state.sidebarWidths.right); const resizeSidebar = useDashboardStore((state) => state.resizeSidebar); const sidebarWidthStart = useRef(DEFAULT_SIDEBAR_WIDTH); const logsWidthStart = useRef(DEFAULT_SIDEBAR_WIDTH); // While a resize handle is dragged the docked sidebars must not ease toward // each new width, or they wobble behind the pointer. const [resizing, setResizing] = useState(false); const sidebars = useSidebarLayout({ leftWidth: sidebarWidth, rightWidth: logsWidth, minStreamWidth: MIN_STREAM_WIDTH, }); // A toolbar Paste or Copy that the browser clipboard stopped, until the inspector takes it over. const [clipboardFallback, setClipboardFallback] = useState<{ deviceId: string | null; request: ClipboardRequest; } | null>(null); const clipboardRequest = clipboardFallback && clipboardFallback.deviceId === selectedId ? clipboardFallback.request : null; useEffect(() => setClipboardFallback(null), [selectedId]); const handleClipboardFallback = (request: ClipboardRequest) => { if (selectedId !== useDashboardStore.getState().selectedDeviceId) return; setClipboardFallback({ deviceId: selectedId, request }); sidebars.openRight(); }; // Merge booted devices (from the server) with any the user added, deduped by // id and split back into the two sections by platform. const simulators = useMemo( () => platform === "android" ? [] : mergeById( booted.simulators, added.filter((device) => device.platform === "ios"), ), [booted.simulators, added, platform], ); const emulators = useMemo( () => platform === "ios" ? [] : mergeById( booted.emulators, added.filter((device) => device.platform === "android"), ), [booted.emulators, added, platform], ); // The browser flag affects only shut-down recents and creation choices. Every // running device remains visible in the sidebar, including untested models. const recentSimulators = useMemo( () => visibleDevices(recent.simulators, hideUnsupportedDevices), [recent.simulators, hideUnsupportedDevices], ); const recentEmulators = useMemo( () => visibleDevices(recent.emulators, hideUnsupportedDevices), [recent.emulators, hideUnsupportedDevices], ); const simulatorOptions = useMemo( () => visibleNewDeviceOptions(newDeviceOptions.ios, hideUnsupportedDevices), [newDeviceOptions.ios, hideUnsupportedDevices], ); const emulatorOptions = useMemo( () => visibleNewDeviceOptions(newDeviceOptions.android, hideUnsupportedDevices), [newDeviceOptions.android, hideUnsupportedDevices], ); // Create/boot the chosen target on the host. The modal awaits this result, so // it stays open during slow Android boots and can show failures in context. async function handleAddDevice(target: AddDeviceTarget): Promise { const result = target.kind === "new" ? await createDevice(target.device) : target.device.booted ? { id: target.device.id, error: null } : await bootDevice(target.device); if (!result.id) { return { ok: false, error: result.error ?? "The device did not come online." }; } const device: Device = target.kind === "new" ? { id: result.id, name: target.device.name, version: target.device.version, platform: target.device.platform, physical: false, booted: true, supported: target.device.supported, deviceFrame: target.device.deviceFrame, lastUsedAt: Date.now(), } : { ...target.device, id: result.id, booted: true, lastUsedAt: Date.now() }; const replacedIds = target.kind === "new" ? [target.device.name, result.id] : [target.device.name, target.device.id, result.id]; // Pinned sessions follow discovery, including shutdowns outside this UI. // An optimistic entry would otherwise keep the stopped device selected. if (!singleDevice) trackAddedDevice(device, replacedIds); return { ok: true }; } // Shut down / remove the selected device on the host, then drop it from the // UI. The device leaves the polled booted list within a tick, and the // selection effect re-selects the next device (or falls back to EmptyState). async function handleShutdown(device: Device) { await shutdownDevice(device); dismissDevice(device.id); } async function handleRemove(device: Device) { await removeDevice(device); dismissDevice(device.id); } // Mirror the theme onto the document root so Radix portals (e.g. the dropdown // menu), which mount on document.body outside the wrapper below, still pick up // the dark `--expo-theme-*` variables. useEffect(() => { const root = document.documentElement; root.classList.toggle("dark-theme", scheme === "dark"); return () => root.classList.remove("dark-theme"); }, [scheme]); // Keep a valid selection — default to the first device once the list loads. // Selecting a device streams it (its helper is attached on demand); the // sidebar lists only booted devices, so the default selection streams an // already-running sim and never boots anything. useEffect(() => { const devices = [...simulators, ...emulators]; reconcileSelectedDevice(devices.map((device) => device.id)); }, [simulators, emulators, reconcileSelectedDevice]); const devices = [...simulators, ...emulators]; const selected = devices.find((device) => device.id === selectedId) ?? (singleDevice ? undefined : devices[0]); const selectedStreamModeAvailability = selected?.platform === "android" ? androidStreamModeAvailability( streamModeAvailability, typeof window.MediaSource !== "undefined", ) : streamModeAvailability; const handleStreamModeChange = (mode: DeviceStreamMode) => { chooseStreamMode(mode, selectedStreamModeAvailability); }; // One shared connection to the serve-sim/serve-emu server, wired to the // selected device. Null until the user picks one, so nothing connects (or // boots) on load. const client = useActiveDeviceClient( connectionStatus === "connected" && selected ? { platform: selected.platform, device: selected.id, streamMode } : null, basePath(), ); const agentInteractions = useArgentInteractions(); const agentInteraction = selected ? (agentInteractions[selected.id] ?? null) : null; const agentDeviceIds = Object.keys(agentInteractions); const connectionBlocked = connectionStatus !== "connected"; return (
{!singleDevice && ( )} {sidebars.leftDocked && ( { sidebarWidthStart.current = sidebarWidth; setResizing(true); }} onResizeEnd={() => setResizing(false)} onResize={(delta) => resizeSidebar( "left", clampSidebarWidth( sidebarWidthStart.current + delta, sidebars.rightDocked ? logsWidth : 0, ), ) } /> )} {selected ? ( = MIN_SIDEBAR_WIDTH + MIN_STREAM_WIDTH} showDeviceFrame={showDeviceFrame} deviceFrameAssets={DEVICE_FRAME_ASSETS} onClipboardFallback={handleClipboardFallback} /> ) : singleDevice ? ( = MIN_SIDEBAR_WIDTH + MIN_STREAM_WIDTH} singleDevice onStart={ platform === "ios" ? () => handleAddDevice({ kind: "recent", device: recent.simulators.find((device) => device.id === singleDevice) ?? { id: singleDevice, platform: "ios", name: "Session Simulator", version: "iOS", booted: false, physical: false, supported: true, deviceFrame: null, }, }) : undefined } /> ) : ( = MIN_SIDEBAR_WIDTH + MIN_STREAM_WIDTH} /> )} {sidebars.rightDocked && ( { logsWidthStart.current = logsWidth; setResizing(true); }} onResizeEnd={() => setResizing(false)} onResize={(delta) => resizeSidebar( "right", clampSidebarWidth( logsWidthStart.current + delta, sidebars.leftDocked ? sidebarWidth : 0, ), ) } /> )} handleShutdown(selected) : undefined} onRemove={!singleDevice && selected ? () => handleRemove(selected) : undefined} clipboardRequest={clipboardRequest} onClipboardRequestHandled={() => setClipboardFallback(null)} onToggle={sidebars.closeRight} width={logsWidth} /> {!singleDevice && ( )} handleShutdown(selected) : undefined} onRemove={!singleDevice && selected ? () => handleRemove(selected) : undefined} clipboardRequest={clipboardRequest} onClipboardRequestHandled={() => setClipboardFallback(null)} onToggle={sidebars.closeRight} width={logsWidth} /> {!singleDevice && !sidebars.leftOpen && ( )} {!sidebars.rightOpen && ( )} {connectionBlocked && createPortal( window.location.reload()} />, document.body, )}
); }