import { useCallback, useEffect, useRef, useState } from "react"; import { LoaderFunctionArgs, Outlet, Params, redirect, useLoaderData, useLocation, useNavigate, useOutlet, useParams, useSearchParams, } from "react-router-dom"; import { useInterval } from "usehooks-ts"; import FocusTrap from "focus-trap-react"; import { motion, AnimatePresence } from "framer-motion"; import { cx } from "@/cva.config"; import { DeviceSettingsState, HidState, UpdateState, useDeviceSettingsStore, useDeviceStore, useHidStore, useMountMediaStore, User, useRTCStore, useUiStore, useUpdateStore, useVideoStore, VideoState, } from "@/hooks/stores"; import WebRTCVideo from "@components/WebRTCVideo"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; import DashboardNavbar from "@components/Header"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import Terminal from "@components/Terminal"; import { CLOUD_API, DEVICE_API } from "@/ui.config"; import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard"; import api from "../api"; import Modal from "../components/Modal"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { FeatureFlagProvider } from "../providers/FeatureFlagProvider"; import notifications from "../notifications"; import { SystemVersionInfo } from "./devices.$id.settings.general.update"; import { DeviceStatus } from "./welcome-local"; interface LocalLoaderResp { authMode: "password" | "noPassword" | null; } interface CloudLoaderResp { deviceName: string; user: User | null; iceConfig: { iceServers: { credential?: string; urls: string | string[]; username?: string }; } | null; } export type AuthMode = "password" | "noPassword" | null; export interface LocalDevice { authMode: AuthMode; deviceId: string; } const deviceLoader = async () => { const res = await api .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); if (!res.isSetup) return redirect("/welcome"); const deviceRes = await api.GET(`${DEVICE_API}/device`); if (deviceRes.status === 401) return redirect("/login-local"); if (deviceRes.ok) { const device = (await deviceRes.json()) as LocalDevice; return { authMode: device.authMode }; } throw new Error("Error fetching device"); }; const cloudLoader = async (params: Params): Promise => { const user = await checkAuth(); const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`); const iceConfig = await iceResp.json(); const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`); if (!deviceResp.ok) { if (deviceResp.status === 404) { throw new Response("Device not found", { status: 404 }); } throw new Error("Error fetching device"); } const { device } = (await deviceResp.json()) as { device: { id: string; name: string; user: { googleId: string } }; }; return { user, iceConfig, deviceName: device.name || device.id }; }; const loader = async ({ params }: LoaderFunctionArgs) => { return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); }; export default function KvmIdRoute() { const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; // Depending on the mode, we set the appropriate variables const user = "user" in loaderResp ? loaderResp.user : null; const deviceName = "deviceName" in loaderResp ? loaderResp.deviceName : null; const iceConfig = "iceConfig" in loaderResp ? loaderResp.iceConfig : null; const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; const sidebarView = useUiStore(state => state.sidebarView); const [queryParams, setQueryParams] = useSearchParams(); const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const peerConnection = useRTCStore(state => state.peerConnection); const peerConnectionState = useRTCStore(state => state.peerConnectionState); const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); const setTransceiver = useRTCStore(state => state.setTransceiver); const location = useLocation(); const [connectionAttempts, setConnectionAttempts] = useState(0); const [startedConnectingAt, setStartedConnectingAt] = useState(null); const [connectedAt, setConnectedAt] = useState(null); const [connectionFailed, setConnectionFailed] = useState(false); const navigate = useNavigate(); const { otaState, setOtaState, setModalView } = useUpdateStore(); const closePeerConnection = useCallback( function closePeerConnection() { peerConnection?.close(); // "closed" is a valid RTCPeerConnection state according to the WebRTC spec // https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/connectionState#closed // However, the onconnectionstatechange event doesn't fire when close() is called manually // So we need to explicitly update our state to maintain consistency // I don't know why this is happening, but this is the best way I can think of to handle it // ALSO, this will render the connection error overlay linking to docs setPeerConnectionState("closed"); }, [peerConnection, setPeerConnectionState], ); useEffect(() => { const connectionAttemptsThreshold = 30; if (connectionAttempts > connectionAttemptsThreshold) { console.log(`Connection failed after ${connectionAttempts} attempts.`); setConnectionFailed(true); closePeerConnection(); } }, [connectionAttempts, closePeerConnection]); useEffect(() => { // Skip if already connected if (connectedAt) return; // Skip if connection is declared as failed if (connectionFailed) return; const interval = setInterval(() => { console.log("Checking connection status"); // Skip if connection hasn't started if (!startedConnectingAt) return; const elapsedTime = Math.floor( new Date().getTime() - startedConnectingAt.getTime(), ); // Fail connection if it's been over X seconds since we started connecting if (elapsedTime > 60 * 1000) { console.error(`Connection failed after ${elapsedTime} ms.`); setConnectionFailed(true); closePeerConnection(); } }, 1000); return () => clearInterval(interval); }, [closePeerConnection, connectedAt, connectionFailed, startedConnectingAt]); const sdp = useCallback( async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { if (!pc) return; if (event.candidate !== null) return; try { const sd = btoa(JSON.stringify(pc.localDescription)); const sessionUrl = isOnDevice ? `${DEVICE_API}/webrtc/session` : `${CLOUD_API}/webrtc/session`; const res = await api.POST(sessionUrl, { sd, // When on device, we don't need to specify the device id, as it's already known ...(isOnDevice ? {} : { id: params.id }), }); const json = await res.json(); if (isOnDevice) { if (res.status === 401) { return navigate("/login-local"); } } if (isInCloud) { // The cloud API returns a 401 if the user is not logged in // Most likely the session has expired if (res.status === 401) return navigate("/login"); // If can be a few things // - In cloud mode, the cloud api would return a 404, if the device hasn't contacted the cloud yet // - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit // Regardless, we should close the peer connection and let the useInterval handle reconnecting if (!res.ok) { closePeerConnection(); console.error(`Error setting SDP - Status: ${res.status}}`, json); return; } } pc.setRemoteDescription( new RTCSessionDescription(JSON.parse(atob(json.sd))), ).catch(e => console.log(`Error setting remote description: ${e}`)); } catch (error) { console.error(`Error setting SDP: ${error}`); closePeerConnection(); } }, [closePeerConnection, navigate, params.id], ); const connectWebRTC = useCallback(async () => { console.log("Attempting to connect WebRTC"); // Track connection status to detect failures and show error overlay setConnectionAttempts(x => x + 1); setStartedConnectingAt(new Date()); setConnectedAt(null); let pc: RTCPeerConnection; try { pc = new RTCPeerConnection({ // We only use STUN or TURN servers if we're in the cloud ...(isInCloud && iceConfig?.iceServers ? { iceServers: [iceConfig?.iceServers] } : {}), }); } catch (e) { console.error(`Error creating peer connection: ${e}`); closePeerConnection(); return; } // Set up event listeners and data channels pc.onconnectionstatechange = () => { // If the connection state is connected, we reset the connection attempts. if (pc.connectionState === "connected") { setConnectionAttempts(0); setConnectedAt(new Date()); } setPeerConnectionState(pc.connectionState); }; pc.onicecandidate = event => sdp(event, pc); pc.ontrack = function (event) { setMediaMediaStream(event.streams[0]); }; setTransceiver(pc.addTransceiver("video", { direction: "recvonly" })); const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); }; const diskDataChannel = pc.createDataChannel("disk"); diskDataChannel.onopen = () => { setDiskChannel(diskDataChannel); }; try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); setPeerConnection(pc); } catch (e) { console.error(`Error creating offer: ${e}`); closePeerConnection(); } }, [ closePeerConnection, iceConfig?.iceServers, sdp, setDiskChannel, setMediaMediaStream, setPeerConnection, setPeerConnectionState, setRpcDataChannel, setTransceiver, ]); useEffect(() => { console.log("Attempting to connect WebRTC"); // If we're in an other session, we don't need to connect if (location.pathname.includes("other-session")) return; // If we're already connected or connecting, we don't need to connect // We have to use the state from the store, because the peerConnection.connectionState doesnt trigger a value change, if called manually from .close() if (["connected", "connecting", "new"].includes(peerConnectionState ?? "")) { return; } // In certain cases, we want to never connect again. This happens when we've tried for a long time and failed if (connectionFailed) { console.log("Connection failed. We won't attempt to connect again."); return; } const interval = setInterval(() => { connectWebRTC(); }, 3000); return () => clearInterval(interval); }, [connectWebRTC, connectionFailed, location.pathname, peerConnectionState]); // On boot, if the connection state is undefined, we connect to the WebRTC useEffect(() => { if (peerConnection?.connectionState === undefined) { connectWebRTC(); } }, [connectWebRTC, peerConnection?.connectionState]); // Cleanup effect const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); const clearCandidatePairStats = useRTCStore(state => state.clearCandidatePairStats); const setSidebarView = useUiStore(state => state.setSidebarView); useEffect(() => { return () => { peerConnection?.close(); }; }, [peerConnection]); // For some reason, we have to have this unmount separate from the cleanup effect above useEffect(() => { return () => { clearInboundRtpStats(); clearCandidatePairStats(); setSidebarView(null); setPeerConnection(null); }; }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); // TURN server usage detection useEffect(() => { if (peerConnection?.connectionState !== "connected") return; const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState(); const lastLocalStat = Array.from(localCandidateStats).pop(); if (!lastLocalStat?.length) return; const localCandidateIsUsingTurn = lastLocalStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here const lastRemoteStat = Array.from(remoteCandidateStats).pop(); if (!lastRemoteStat?.length) return; const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); }, [peerConnection?.connectionState, setIsTurnServerInUse]); // TURN server usage reporting const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const lastBytesReceived = useRef(0); const lastBytesSent = useRef(0); useInterval(() => { // Don't report usage if we're not using the turn server if (!isTurnServerInUse) return; const { candidatePairStats } = useRTCStore.getState(); const lastCandidatePair = Array.from(candidatePairStats).pop(); const report = lastCandidatePair?.[1]; if (!report) return; let bytesReceivedDelta = 0; let bytesSentDelta = 0; if (report.bytesReceived) { bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current; lastBytesReceived.current = report.bytesReceived; } if (report.bytesSent) { bytesSentDelta = report.bytesSent - lastBytesSent.current; lastBytesSent.current = report.bytesSent; } // Fire and forget api.POST(`${CLOUD_API}/webrtc/turn_activity`, { bytesReceived: bytesReceivedDelta, bytesSent: bytesSentDelta, }); }, 10000); const setUsbState = useHidStore(state => state.setUsbState); const setHdmiState = useVideoStore(state => state.setHdmiState); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); function onJsonRpcRequest(resp: JsonRpcRequest) { if (resp.method === "otherSessionConnected") { navigateTo("/other-session"); } if (resp.method === "usbState") { setUsbState(resp.params as unknown as HidState["usbState"]); } if (resp.method === "videoInputState") { setHdmiState(resp.params as Parameters[0]); } if (resp.method === "otaState") { const otaState = resp.params as UpdateState["otaState"]; setOtaState(otaState); if (otaState.updating === true) { setHasUpdated(true); } if (hasUpdated && otaState.updating === false) { setHasUpdated(false); if (otaState.error) { setModalView("error"); navigateTo("/settings/general/update"); return; } const currentUrl = new URL(window.location.href); currentUrl.search = ""; currentUrl.searchParams.set("updateSuccess", "true"); window.location.href = currentUrl.toString(); } } } const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const [send] = useJsonRpc(onJsonRpcRequest); useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; send("getVideoState", {}, resp => { if ("error" in resp) return; setHdmiState(resp.result as Parameters[0]); }); }, [rpcDataChannel?.readyState, send, setHdmiState]); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error window.send = send; // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { if (queryParams.get("updateSuccess")) { navigateTo("/settings/general/update", { state: { updateSuccess: true } }); } }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); const diskChannel = useRTCStore(state => state.diskChannel)!; const file = useMountMediaStore(state => state.localFile)!; useEffect(() => { if (!diskChannel || !file) return; diskChannel.onmessage = async e => { console.log("Received", e.data); const data = JSON.parse(e.data); const blob = file.slice(data.start, data.end); const buf = await blob.arrayBuffer(); const header = new ArrayBuffer(16); const headerView = new DataView(header); headerView.setBigUint64(0, BigInt(data.start), false); // start offset, big-endian headerView.setBigUint64(8, BigInt(buf.byteLength), false); // length, big-endian const fullData = new Uint8Array(header.byteLength + buf.byteLength); fullData.set(new Uint8Array(header), 0); fullData.set(new Uint8Array(buf), header.byteLength); diskChannel.send(fullData); }; }, [diskChannel, file]); // System update const disableKeyboardFocusTrap = useUiStore(state => state.disableVideoFocusTrap); const [kvmTerminal, setKvmTerminal] = useState(null); const [serialConsole, setSerialConsole] = useState(null); useEffect(() => { if (!peerConnection) return; if (!kvmTerminal) { console.log('Creating data channel "terminal"'); setKvmTerminal(peerConnection.createDataChannel("terminal")); } if (!serialConsole) { console.log('Creating data channel "serial"'); setSerialConsole(peerConnection.createDataChannel("serial")); } }, [kvmTerminal, peerConnection, serialConsole]); const outlet = useOutlet(); const onModalClose = useCallback(() => { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); const appVersion = useDeviceStore(state => state.appVersion); const setAppVersion = useDeviceStore(state => state.setAppVersion); const setSystemVersion = useDeviceStore(state => state.setSystemVersion); useEffect(() => { if (appVersion) return; send("getUpdateStatus", {}, async resp => { if ("error" in resp) { notifications.error("Failed to get device version"); } else { const result = resp.result as SystemVersionInfo; setAppVersion(result.local.appVersion); setSystemVersion(result.local.systemVersion); } }); }, [appVersion, send, setAppVersion, setSystemVersion]); const setScrollSensitivity = useDeviceSettingsStore( state => state.setScrollSensitivity, ); // Initialize device settings useEffect( function initializeDeviceSettings() { send("getScrollSensitivity", {}, resp => { if ("error" in resp) return; setScrollSensitivity(resp.result as DeviceSettingsState["scrollSensitivity"]); }); }, [send, setScrollSensitivity], ); return ( {!outlet && otaState.updating && ( )}
e.stopPropagation()} onKeyDown={e => { e.stopPropagation(); if (e.key === "Escape") navigateTo("/"); }} > {/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
{kvmTerminal && ( )} {serialConsole && ( )}
); } function SidebarContainer({ sidebarView }: { sidebarView: string | null }) { return (
{sidebarView === "connection-stats" && ( )}
); } KvmIdRoute.loader = loader;