mirror of
https://github.com/luckfox-eng29/kvm.git
synced 2026-01-19 09:52:32 +01:00
Feat/Trickle ice (#336)
* feat(cloud): Use Websocket signaling in cloud mode * refactor: Enhance WebRTC signaling and connection handling * refactor: Improve WebRTC connection management and logging in KvmIdRoute * refactor: Update PeerConnectionDisconnectedOverlay to use Card component for better UI structure * refactor: Standardize metric naming and improve websocket logging * refactor: Rename WebRTC signaling functions and update deployment script for debug version * fix: Handle error when writing new ICE candidate to WebRTC signaling channel * refactor: Rename signaling handler function for clarity * refactor: Remove old http local http endpoint * refactor: Improve metric help text and standardize comparison operator in KvmIdRoute * chore(websocket): use MetricVec instead of Metric to store metrics * fix conflicts * fix: use wss when the page is served over https * feat: Add app version header and update WebRTC signaling endpoint * fix: Handle error when writing device metadata to WebRTC signaling channel --------- Co-authored-by: Siyuan Miao <i@xswan.net>
This commit is contained in:
@@ -36,7 +36,7 @@ export default function DashboardNavbar({
|
||||
picture,
|
||||
kvmName,
|
||||
}: NavbarProps) {
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const setUser = useUserStore(state => state.setUser);
|
||||
const navigate = useNavigate();
|
||||
const onLogout = useCallback(async () => {
|
||||
@@ -82,14 +82,14 @@ export default function DashboardNavbar({
|
||||
<div className="hidden items-center gap-x-2 md:flex">
|
||||
<div className="w-[159px]">
|
||||
<PeerConnectionStatusCard
|
||||
state={peerConnection?.connectionState}
|
||||
state={peerConnectionState}
|
||||
title={kvmName}
|
||||
/>
|
||||
</div>
|
||||
<div className="hidden w-[159px] md:block">
|
||||
<USBStateStatus
|
||||
state={usbState}
|
||||
peerConnectionState={peerConnection?.connectionState}
|
||||
peerConnectionState={peerConnectionState}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { LuPlay } from "react-icons/lu";
|
||||
|
||||
import { Button, LinkButton } from "@components/Button";
|
||||
import LoadingSpinner from "@components/LoadingSpinner";
|
||||
import { GridCard } from "@components/Card";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
|
||||
interface OverlayContentProps {
|
||||
children: React.ReactNode;
|
||||
@@ -94,7 +94,7 @@ interface ConnectionErrorOverlayProps {
|
||||
setupPeerConnection: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConnectionErrorOverlay({
|
||||
export function ConnectionFailedOverlay({
|
||||
show,
|
||||
setupPeerConnection,
|
||||
}: ConnectionErrorOverlayProps) {
|
||||
@@ -151,6 +151,60 @@ export function ConnectionErrorOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
interface PeerConnectionDisconnectedOverlay {
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
export function PeerConnectionDisconnectedOverlay({
|
||||
show,
|
||||
}: PeerConnectionDisconnectedOverlay) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show && (
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<OverlayContent>
|
||||
<div className="flex flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||
<li>Verify that the device is powered on and properly connected</li>
|
||||
<li>Check all cable connections for any loose or damaged wires</li>
|
||||
<li>Ensure your network connection is stable and active</li>
|
||||
<li>Try restarting both the device and your computer</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Card>
|
||||
<div className="flex items-center gap-x-2 p-4">
|
||||
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
Retrying connection...
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
interface HDMIErrorOverlayProps {
|
||||
show: boolean;
|
||||
hdmiState: string;
|
||||
|
||||
@@ -380,7 +380,7 @@ export default function WebRTCVideo() {
|
||||
(mediaStream: MediaStream) => {
|
||||
if (!videoElm.current) return;
|
||||
const videoElmRefValue = videoElm.current;
|
||||
console.log("Adding stream to video element", videoElmRefValue);
|
||||
// console.log("Adding stream to video element", videoElmRefValue);
|
||||
videoElmRefValue.srcObject = mediaStream;
|
||||
updateVideoSizeStore(videoElmRefValue);
|
||||
},
|
||||
@@ -396,7 +396,7 @@ export default function WebRTCVideo() {
|
||||
peerConnection.addEventListener(
|
||||
"track",
|
||||
(e: RTCTrackEvent) => {
|
||||
console.log("Adding stream to video element");
|
||||
// console.log("Adding stream to video element");
|
||||
addStreamToVideoElm(e.streams[0]);
|
||||
},
|
||||
{ signal },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { useInterval } from "usehooks-ts";
|
||||
import FocusTrap from "focus-trap-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import useWebSocket from "react-use-websocket";
|
||||
|
||||
import { cx } from "@/cva.config";
|
||||
import {
|
||||
@@ -43,15 +44,16 @@ import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard
|
||||
import api from "../api";
|
||||
import Modal from "../components/Modal";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
import {
|
||||
ConnectionFailedOverlay,
|
||||
LoadingConnectionOverlay,
|
||||
PeerConnectionDisconnectedOverlay,
|
||||
} from "../components/VideoOverlay";
|
||||
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
|
||||
import notifications from "../notifications";
|
||||
import {
|
||||
ConnectionErrorOverlay,
|
||||
LoadingConnectionOverlay,
|
||||
} from "../components/VideoOverlay";
|
||||
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
import { DeviceStatus } from "./welcome-local";
|
||||
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
|
||||
|
||||
interface LocalLoaderResp {
|
||||
authMode: "password" | "noPassword" | null;
|
||||
@@ -117,7 +119,6 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
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;
|
||||
@@ -130,6 +131,8 @@ export default function KvmIdRoute() {
|
||||
|
||||
const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse);
|
||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||
const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState);
|
||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||
const setMediaMediaStream = useRTCStore(state => state.setMediaStream);
|
||||
const setPeerConnection = useRTCStore(state => state.setPeerConnection);
|
||||
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
|
||||
@@ -137,23 +140,28 @@ export default function KvmIdRoute() {
|
||||
const setTransceiver = useRTCStore(state => state.setTransceiver);
|
||||
const location = useLocation();
|
||||
|
||||
const isLegacySignalingEnabled = useRef(false);
|
||||
|
||||
const [connectionFailed, setConnectionFailed] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { otaState, setOtaState, setModalView } = useUpdateStore();
|
||||
|
||||
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
|
||||
const closePeerConnection = useCallback(
|
||||
function closePeerConnection() {
|
||||
const cleanupAndStopReconnecting = useCallback(
|
||||
function cleanupAndStopReconnecting() {
|
||||
console.log("Closing peer connection");
|
||||
|
||||
setConnectionFailed(true);
|
||||
if (peerConnection) {
|
||||
setPeerConnectionState(peerConnection.connectionState);
|
||||
}
|
||||
connectionFailedRef.current = true;
|
||||
|
||||
peerConnection?.close();
|
||||
signalingAttempts.current = 0;
|
||||
},
|
||||
[peerConnection],
|
||||
[peerConnection, setPeerConnectionState],
|
||||
);
|
||||
|
||||
// We need to track connectionFailed in a ref to avoid stale closure issues
|
||||
@@ -171,95 +179,233 @@ export default function KvmIdRoute() {
|
||||
}, [connectionFailed]);
|
||||
|
||||
const signalingAttempts = useRef(0);
|
||||
const syncRemoteSessionDescription = useCallback(
|
||||
async function syncRemoteSessionDescription(pc: RTCPeerConnection) {
|
||||
const setRemoteSessionDescription = useCallback(
|
||||
async function setRemoteSessionDescription(
|
||||
pc: RTCPeerConnection,
|
||||
remoteDescription: RTCSessionDescriptionInit,
|
||||
) {
|
||||
setLoadingMessage("Setting remote description");
|
||||
|
||||
try {
|
||||
if (!pc) return;
|
||||
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
|
||||
const sessionUrl = isOnDevice
|
||||
? `${DEVICE_API}/webrtc/session`
|
||||
: `${CLOUD_API}/webrtc/session`;
|
||||
|
||||
console.log("Trying to get remote session description");
|
||||
setLoadingMessage(
|
||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||
);
|
||||
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 (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
|
||||
if (!res.ok) {
|
||||
console.error("Error getting SDP", { status: res.status, json });
|
||||
throw new Error("Error getting SDP");
|
||||
}
|
||||
|
||||
console.log("Successfully got Remote Session Description. Setting.");
|
||||
setLoadingMessage("Setting remote session description...");
|
||||
|
||||
const decodedSd = atob(json.sd);
|
||||
const parsedSd = JSON.parse(decodedSd);
|
||||
pc.setRemoteDescription(new RTCSessionDescription(parsedSd));
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
console.log("Waiting for remote description to be set");
|
||||
const maxAttempts = 10;
|
||||
const interval = 1000;
|
||||
let attempts = 0;
|
||||
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||
if (pc.sctp?.state === "connected") {
|
||||
console.log("Remote description set");
|
||||
clearInterval(checkInterval);
|
||||
resolve(true);
|
||||
} else if (attempts >= maxAttempts) {
|
||||
console.log(
|
||||
`Failed to get remote description after ${maxAttempts} attempts`,
|
||||
);
|
||||
closePeerConnection();
|
||||
clearInterval(checkInterval);
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to get remote description after ${maxAttempts} attempts`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
console.log("Waiting for remote description to be set");
|
||||
}
|
||||
}, interval);
|
||||
});
|
||||
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
|
||||
console.log("[setRemoteSessionDescription] Remote description set successfully");
|
||||
setLoadingMessage("Establishing secure connection...");
|
||||
} catch (error) {
|
||||
console.error("Error getting SDP", { error });
|
||||
console.log("Connection failed", connectionFailedRef.current);
|
||||
if (connectionFailedRef.current) return;
|
||||
if (signalingAttempts.current < 5) {
|
||||
signalingAttempts.current++;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
console.log("Attempting to get SDP again", signalingAttempts.current);
|
||||
syncRemoteSessionDescription(pc);
|
||||
} else {
|
||||
closePeerConnection();
|
||||
}
|
||||
console.error(
|
||||
"[setRemoteSessionDescription] Failed to set remote description:",
|
||||
error,
|
||||
);
|
||||
cleanupAndStopReconnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace the interval-based check with a more reliable approach
|
||||
let attempts = 0;
|
||||
const checkInterval = setInterval(() => {
|
||||
attempts++;
|
||||
|
||||
// When vivaldi has disabled "Broadcast IP for Best WebRTC Performance", this never connects
|
||||
if (pc.sctp?.state === "connected") {
|
||||
console.log("[setRemoteSessionDescription] Remote description set");
|
||||
clearInterval(checkInterval);
|
||||
setLoadingMessage("Connection established");
|
||||
} else if (attempts >= 10) {
|
||||
console.log(
|
||||
"[setRemoteSessionDescription] Failed to establish connection after 10 attempts",
|
||||
{
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
},
|
||||
);
|
||||
cleanupAndStopReconnecting();
|
||||
clearInterval(checkInterval);
|
||||
} else {
|
||||
console.log("[setRemoteSessionDescription] Waiting for connection, state:", {
|
||||
connectionState: pc.connectionState,
|
||||
iceConnectionState: pc.iceConnectionState,
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
[closePeerConnection, navigate, params.id],
|
||||
[cleanupAndStopReconnecting],
|
||||
);
|
||||
|
||||
const ignoreOffer = useRef(false);
|
||||
const isSettingRemoteAnswerPending = useRef(false);
|
||||
const makingOffer = useRef(false);
|
||||
|
||||
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
|
||||
const { sendMessage, getWebSocket } = useWebSocket(
|
||||
isOnDevice
|
||||
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
|
||||
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
|
||||
{
|
||||
heartbeat: true,
|
||||
retryOnError: true,
|
||||
reconnectAttempts: 5,
|
||||
reconnectInterval: 1000,
|
||||
onReconnectStop: () => {
|
||||
console.log("Reconnect stopped");
|
||||
cleanupAndStopReconnecting();
|
||||
},
|
||||
|
||||
shouldReconnect(event) {
|
||||
console.log("[Websocket] shouldReconnect", event);
|
||||
// TODO: Why true?
|
||||
return true;
|
||||
},
|
||||
|
||||
onClose(event) {
|
||||
console.log("[Websocket] onClose", event);
|
||||
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||
},
|
||||
|
||||
onError(event) {
|
||||
console.log("[Websocket] onError", event);
|
||||
// We don't want to close everything down, we wait for the reconnect to stop instead
|
||||
},
|
||||
onOpen() {
|
||||
console.log("[Websocket] onOpen");
|
||||
},
|
||||
|
||||
onMessage: message => {
|
||||
if (message.data === "pong") return;
|
||||
|
||||
/*
|
||||
Currently the signaling process is as follows:
|
||||
After open, the other side will send a `device-metadata` message with the device version
|
||||
If the device version is not set, we can assume the device is using the legacy signaling
|
||||
Otherwise, we can assume the device is using the new signaling
|
||||
|
||||
If the device is using the legacy signaling, we close the websocket connection
|
||||
and use the legacy HTTPSignaling function to get the remote session description
|
||||
|
||||
If the device is using the new signaling, we don't need to do anything special, but continue to use the websocket connection
|
||||
to chat with the other peer about the connection
|
||||
*/
|
||||
|
||||
const parsedMessage = JSON.parse(message.data);
|
||||
if (parsedMessage.type === "device-metadata") {
|
||||
const { deviceVersion } = parsedMessage.data;
|
||||
console.log("[Websocket] Received device-metadata message");
|
||||
console.log("[Websocket] Device version", deviceVersion);
|
||||
// If the device version is not set, we can assume the device is using the legacy signaling
|
||||
if (!deviceVersion) {
|
||||
console.log("[Websocket] Device is using legacy signaling");
|
||||
|
||||
// Now we don't need the websocket connection anymore, as we've established that we need to use the legacy signaling
|
||||
// which does everything over HTTP(at least from the perspective of the client)
|
||||
isLegacySignalingEnabled.current = true;
|
||||
getWebSocket()?.close();
|
||||
} else {
|
||||
console.log("[Websocket] Device is using new signaling");
|
||||
isLegacySignalingEnabled.current = false;
|
||||
}
|
||||
setupPeerConnection();
|
||||
}
|
||||
|
||||
if (!peerConnection) return;
|
||||
if (parsedMessage.type === "answer") {
|
||||
console.log("[Websocket] Received answer");
|
||||
const readyForOffer =
|
||||
// If we're making an offer, we don't want to accept an answer
|
||||
!makingOffer &&
|
||||
// If the peer connection is stable or we're setting the remote answer pending, we're ready for an offer
|
||||
(peerConnection?.signalingState === "stable" ||
|
||||
isSettingRemoteAnswerPending.current);
|
||||
|
||||
// If we're not ready for an offer, we don't want to accept an offer
|
||||
ignoreOffer.current = parsedMessage.type === "offer" && !readyForOffer;
|
||||
if (ignoreOffer.current) return;
|
||||
|
||||
// Set so we don't accept an answer while we're setting the remote description
|
||||
isSettingRemoteAnswerPending.current = parsedMessage.type === "answer";
|
||||
console.log(
|
||||
"[Websocket] Setting remote answer pending",
|
||||
isSettingRemoteAnswerPending.current,
|
||||
);
|
||||
|
||||
const sd = atob(parsedMessage.data);
|
||||
const remoteSessionDescription = JSON.parse(sd);
|
||||
|
||||
setRemoteSessionDescription(
|
||||
peerConnection,
|
||||
new RTCSessionDescription(remoteSessionDescription),
|
||||
);
|
||||
|
||||
// Reset the remote answer pending flag
|
||||
isSettingRemoteAnswerPending.current = false;
|
||||
} else if (parsedMessage.type === "new-ice-candidate") {
|
||||
console.log("[Websocket] Received new-ice-candidate");
|
||||
const candidate = parsedMessage.data;
|
||||
peerConnection.addIceCandidate(candidate);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Don't even retry once we declare failure
|
||||
!connectionFailed && isLegacySignalingEnabled.current === false,
|
||||
);
|
||||
|
||||
const sendWebRTCSignal = useCallback(
|
||||
(type: string, data: unknown) => {
|
||||
// Second argument tells the library not to queue the message, and send it once the connection is established again.
|
||||
// We have event handlers that handle the connection set up, so we don't need to queue the message.
|
||||
sendMessage(JSON.stringify({ type, data }), false);
|
||||
},
|
||||
[sendMessage],
|
||||
);
|
||||
|
||||
const legacyHTTPSignaling = useCallback(
|
||||
async (pc: RTCPeerConnection) => {
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
|
||||
// Legacy mode == UI in cloud with updated code connecting to older device version.
|
||||
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
|
||||
const sessionUrl = `${CLOUD_API}/webrtc/session`;
|
||||
|
||||
console.log("Trying to get remote session description");
|
||||
setLoadingMessage(
|
||||
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
|
||||
);
|
||||
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 (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
|
||||
if (!res.ok) {
|
||||
console.error("Error getting SDP", { status: res.status, json });
|
||||
cleanupAndStopReconnecting();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Successfully got Remote Session Description. Setting.");
|
||||
setLoadingMessage("Setting remote session description...");
|
||||
|
||||
const decodedSd = atob(json.sd);
|
||||
const parsedSd = JSON.parse(decodedSd);
|
||||
setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
|
||||
},
|
||||
[cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
|
||||
);
|
||||
|
||||
const setupPeerConnection = useCallback(async () => {
|
||||
console.log("Setting up peer connection");
|
||||
console.log("[setupPeerConnection] Setting up peer connection");
|
||||
setConnectionFailed(false);
|
||||
setLoadingMessage("Connecting to device...");
|
||||
|
||||
if (peerConnection?.signalingState === "stable") {
|
||||
console.log("[setupPeerConnection] Peer connection already established");
|
||||
return;
|
||||
}
|
||||
|
||||
let pc: RTCPeerConnection;
|
||||
try {
|
||||
console.log("Creating peer connection");
|
||||
console.log("[setupPeerConnection] Creating peer connection");
|
||||
setLoadingMessage("Creating peer connection...");
|
||||
pc = new RTCPeerConnection({
|
||||
// We only use STUN or TURN servers if we're in the cloud
|
||||
@@ -267,30 +413,65 @@ export default function KvmIdRoute() {
|
||||
? { iceServers: [iceConfig?.iceServers] }
|
||||
: {}),
|
||||
});
|
||||
console.log("Peer connection created", pc);
|
||||
setLoadingMessage("Peer connection created");
|
||||
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
console.log("[setupPeerConnection] Peer connection created", pc);
|
||||
setLoadingMessage("Setting up connection to device...");
|
||||
} catch (e) {
|
||||
console.error(`Error creating peer connection: ${e}`);
|
||||
console.error(`[setupPeerConnection] Error creating peer connection: ${e}`);
|
||||
setTimeout(() => {
|
||||
closePeerConnection();
|
||||
cleanupAndStopReconnecting();
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up event listeners and data channels
|
||||
pc.onconnectionstatechange = () => {
|
||||
console.log("Connection state changed", pc.connectionState);
|
||||
console.log("[setupPeerConnection] Connection state changed", pc.connectionState);
|
||||
setPeerConnectionState(pc.connectionState);
|
||||
};
|
||||
|
||||
pc.onnegotiationneeded = async () => {
|
||||
try {
|
||||
console.log("[setupPeerConnection] Creating offer");
|
||||
makingOffer.current = true;
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const sd = btoa(JSON.stringify(pc.localDescription));
|
||||
const isNewSignalingEnabled = isLegacySignalingEnabled.current === false;
|
||||
if (isNewSignalingEnabled) {
|
||||
sendWebRTCSignal("offer", { sd: sd });
|
||||
} else {
|
||||
console.log("Legacy signanling. Waiting for ICE Gathering to complete...");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`[setupPeerConnection] Error creating offer: ${e}`,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
cleanupAndStopReconnecting();
|
||||
} finally {
|
||||
makingOffer.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
pc.onicecandidate = async ({ candidate }) => {
|
||||
if (!candidate) return;
|
||||
if (candidate.candidate === "") return;
|
||||
sendWebRTCSignal("new-ice-candidate", candidate);
|
||||
};
|
||||
|
||||
pc.onicegatheringstatechange = event => {
|
||||
const pc = event.currentTarget as RTCPeerConnection;
|
||||
console.log("ICE Gathering State Changed", pc.iceGatheringState);
|
||||
if (pc.iceGatheringState === "complete") {
|
||||
console.log("ICE Gathering completed");
|
||||
setLoadingMessage("ICE Gathering completed");
|
||||
|
||||
// We can now start the https/ws connection to get the remote session description from the KVM device
|
||||
syncRemoteSessionDescription(pc);
|
||||
if (isLegacySignalingEnabled.current) {
|
||||
// We can now start the https/ws connection to get the remote session description from the KVM device
|
||||
legacyHTTPSignaling(pc);
|
||||
}
|
||||
} else if (pc.iceGatheringState === "gathering") {
|
||||
console.log("ICE Gathering Started");
|
||||
setLoadingMessage("Gathering ICE candidates...");
|
||||
@@ -314,31 +495,26 @@ export default function KvmIdRoute() {
|
||||
};
|
||||
|
||||
setPeerConnection(pc);
|
||||
|
||||
try {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
} catch (e) {
|
||||
console.error(`Error creating offer: ${e}`, new Date().toISOString());
|
||||
closePeerConnection();
|
||||
}
|
||||
}, [
|
||||
closePeerConnection,
|
||||
cleanupAndStopReconnecting,
|
||||
iceConfig?.iceServers,
|
||||
legacyHTTPSignaling,
|
||||
peerConnection?.signalingState,
|
||||
sendWebRTCSignal,
|
||||
setDiskChannel,
|
||||
setMediaMediaStream,
|
||||
setPeerConnection,
|
||||
setPeerConnectionState,
|
||||
setRpcDataChannel,
|
||||
setTransceiver,
|
||||
syncRemoteSessionDescription,
|
||||
]);
|
||||
|
||||
// On boot, if the connection state is undefined, we connect to the WebRTC
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState === undefined) {
|
||||
setupPeerConnection();
|
||||
if (peerConnectionState === "failed") {
|
||||
console.log("Connection failed, closing peer connection");
|
||||
cleanupAndStopReconnecting();
|
||||
}
|
||||
}, [setupPeerConnection, peerConnection?.connectionState]);
|
||||
}, [peerConnectionState, cleanupAndStopReconnecting]);
|
||||
|
||||
// Cleanup effect
|
||||
const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats);
|
||||
@@ -363,7 +539,7 @@ export default function KvmIdRoute() {
|
||||
|
||||
// TURN server usage detection
|
||||
useEffect(() => {
|
||||
if (peerConnection?.connectionState !== "connected") return;
|
||||
if (peerConnectionState !== "connected") return;
|
||||
const { localCandidateStats, remoteCandidateStats } = useRTCStore.getState();
|
||||
|
||||
const lastLocalStat = Array.from(localCandidateStats).pop();
|
||||
@@ -375,7 +551,7 @@ export default function KvmIdRoute() {
|
||||
const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here
|
||||
|
||||
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
|
||||
}, [peerConnection?.connectionState, setIsTurnServerInUse]);
|
||||
}, [peerConnectionState, setIsTurnServerInUse]);
|
||||
|
||||
// TURN server usage reporting
|
||||
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
|
||||
@@ -466,10 +642,6 @@ export default function KvmIdRoute() {
|
||||
});
|
||||
}, [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")) {
|
||||
@@ -506,12 +678,12 @@ export default function KvmIdRoute() {
|
||||
useEffect(() => {
|
||||
if (!peerConnection) return;
|
||||
if (!kvmTerminal) {
|
||||
console.log('Creating data channel "terminal"');
|
||||
// console.log('Creating data channel "terminal"');
|
||||
setKvmTerminal(peerConnection.createDataChannel("terminal"));
|
||||
}
|
||||
|
||||
if (!serialConsole) {
|
||||
console.log('Creating data channel "serial"');
|
||||
// console.log('Creating data channel "serial"');
|
||||
setSerialConsole(peerConnection.createDataChannel("serial"));
|
||||
}
|
||||
}, [kvmTerminal, peerConnection, serialConsole]);
|
||||
@@ -554,6 +726,43 @@ export default function KvmIdRoute() {
|
||||
[send, setScrollSensitivity],
|
||||
);
|
||||
|
||||
const ConnectionStatusElement = useMemo(() => {
|
||||
const hasConnectionFailed =
|
||||
connectionFailed || ["failed", "closed"].includes(peerConnectionState || "");
|
||||
|
||||
const isPeerConnectionLoading =
|
||||
["connecting", "new"].includes(peerConnectionState || "") ||
|
||||
peerConnection === null;
|
||||
|
||||
const isDisconnected = peerConnectionState === "disconnected";
|
||||
|
||||
const isOtherSession = location.pathname.includes("other-session");
|
||||
|
||||
if (isOtherSession) return null;
|
||||
if (peerConnectionState === "connected") return null;
|
||||
if (isDisconnected) {
|
||||
return <PeerConnectionDisconnectedOverlay show={true} />;
|
||||
}
|
||||
|
||||
if (hasConnectionFailed)
|
||||
return (
|
||||
<ConnectionFailedOverlay show={true} setupPeerConnection={setupPeerConnection} />
|
||||
);
|
||||
|
||||
if (isPeerConnectionLoading) {
|
||||
return <LoadingConnectionOverlay show={true} text={loadingMessage} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [
|
||||
connectionFailed,
|
||||
loadingMessage,
|
||||
location.pathname,
|
||||
peerConnection,
|
||||
peerConnectionState,
|
||||
setupPeerConnection,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FeatureFlagProvider appVersion={appVersion}>
|
||||
{!outlet && otaState.updating && (
|
||||
@@ -593,27 +802,13 @@ export default function KvmIdRoute() {
|
||||
/>
|
||||
|
||||
<div className="flex h-full w-full overflow-hidden">
|
||||
<div className="pointer-events-none fixed inset-0 isolate z-50 flex h-full w-full items-center justify-center">
|
||||
<div className="pointer-events-none fixed inset-0 isolate z-20 flex h-full w-full items-center justify-center">
|
||||
<div className="my-2 h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
<LoadingConnectionOverlay
|
||||
show={
|
||||
!connectionFailed &&
|
||||
(["connecting", "new"].includes(
|
||||
peerConnection?.connectionState || "",
|
||||
) ||
|
||||
peerConnection === null) &&
|
||||
!location.pathname.includes("other-session")
|
||||
}
|
||||
text={loadingMessage}
|
||||
/>
|
||||
<ConnectionErrorOverlay
|
||||
show={connectionFailed && !location.pathname.includes("other-session")}
|
||||
setupPeerConnection={setupPeerConnection}
|
||||
/>
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebRTCVideo />
|
||||
{peerConnectionState === "connected" && <WebRTCVideo />}
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user