Merge branch 'dev' of github.com:jackislanding/jack-kvm into feature/jiggler-scheduler

This commit is contained in:
JackTheRooster 2025-03-24 22:58:56 -05:00
commit 5515d33b09
5 changed files with 319 additions and 120 deletions

View File

@ -27,6 +27,9 @@ jobs:
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with: with:
go-version: 1.23.x go-version: 1.23.x
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint - name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1 uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with: with:

View File

@ -9,7 +9,6 @@ import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic"; import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader"; import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset"; import Fieldset from "./Fieldset";
export interface USBConfig { export interface USBConfig {
vendor_id: string; vendor_id: string;
product_id: string; product_id: string;
@ -119,13 +118,12 @@ export function UsbDeviceSetting() {
const onUsbConfigItemChange = useCallback( const onUsbConfigItemChange = useCallback(
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => { (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig(val => { setUsbDeviceConfig(prev => ({
val[key] = e.target.checked; ...prev,
handleUsbConfigChange(val); [key]: e.target.checked,
return val; }));
});
}, },
[handleUsbConfigChange], [],
); );
const handlePresetChange = useCallback( const handlePresetChange = useCallback(

View File

@ -1,10 +1,11 @@
import React from "react"; import React from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { LuPlay } from "react-icons/lu";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -34,7 +35,7 @@ export function LoadingOverlay({ show }: LoadingOverlayProps) {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: show ? 0.3 : 0.1, duration: show ? 0.3 : 0.1,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -68,7 +69,7 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -118,25 +119,27 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<AnimatePresence> <AnimatePresence>
{show && isNoSignal && ( {show && isNoSignal && (
<motion.div <motion.div
className="absolute inset-0 w-full h-full aspect-video" className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li> <li>
Ensure source device is powered on and outputting a signal
</li>
<li> <li>
If using an adapter, it&apos;s compatible and functioning If using an adapter, it&apos;s compatible and functioning
correctly correctly
@ -169,7 +172,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
transition={{ transition={{
duration: 0.3, duration: 0.3,
ease: "easeInOut" ease: "easeInOut",
}} }}
> >
<OverlayContent> <OverlayContent>
@ -187,7 +190,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
<div> <div>
<LinkButton <LinkButton
to={"/help/hdmi-error"} to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light" theme="light"
text="Learn more" text="Learn more"
TrailingIcon={ArrowRightIcon} TrailingIcon={ArrowRightIcon}
@ -204,3 +207,54 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</> </>
); );
} }
interface NoAutoplayPermissionsOverlayProps {
show: boolean;
onPlayClick: () => void;
}
export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="space-y-4">
<h2 className="text-2xl font-extrabold text-black dark:text-white">
Autoplay permissions required
</h2>
<div className="space-y-2 text-center">
<div>
<Button
size="MD"
theme="primary"
LeadingIcon={LuPlay}
text="Manually start stream"
onClick={onPlayClick}
/>
</div>
<div className="text-xs text-slate-600 dark:text-slate-400">
Please adjust browser settings to enable autoplay
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
);
}

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import {
useDeviceSettingsStore, useDeviceSettingsStore,
useHidStore, useHidStore,
@ -15,7 +15,7 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { HDMIErrorOverlay } from "./VideoOverlay"; import { HDMIErrorOverlay, NoAutoplayPermissionsOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay"; import { ConnectionErrorOverlay } from "./VideoOverlay";
import { LoadingOverlay } from "./VideoOverlay"; import { LoadingOverlay } from "./VideoOverlay";
@ -47,7 +47,7 @@ export default function WebRTCVideo() {
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isLoading = !hdmiError && !isPlaying; const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes( const isConnectionError = ["error", "failed", "disconnected", "closed"].includes(
peerConnectionState || "", peerConnectionState || "",
); );
@ -94,7 +94,7 @@ export default function WebRTCVideo() {
); );
// Mouse-related // Mouse-related
const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback( const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -168,7 +168,14 @@ export default function WebRTCVideo() {
const { buttons } = e; const { buttons } = e;
sendAbsMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], [
sendAbsMouseMovement,
videoClientHeight,
videoClientWidth,
videoWidth,
videoHeight,
settings.mouseMode,
],
); );
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@ -355,28 +362,6 @@ export default function WebRTCVideo() {
], ],
); );
// Effect hooks
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video // In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused. // there is no way to prevent this, so we need to simply force play the video when it's paused.
@ -389,71 +374,6 @@ export default function WebRTCVideo() {
} }
}, []); }, []);
useEffect(
function setupVideoEventListeners() {
let videoElmRefValue = null;
if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
if (videoElmRefValue) abortController.abort();
};
},
[
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
);
useEffect(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
const abortController = new AbortController();
const signal = abortController.signal;
// bind to body to capture all mouse events
const body = document.querySelector("body");
if (!body) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
return () => {
abortController.abort();
body.removeEventListener("mousemove", relMouseMoveHandler);
body.removeEventListener("pointerdown", relMouseMoveHandler);
body.removeEventListener("pointerup", relMouseMoveHandler);
};
}, [settings.mouseMode, relMouseMoveHandler],
)
useEffect( useEffect(
function updateVideoStream() { function updateVideoStream() {
if (!mediaStream) return; if (!mediaStream) return;
@ -476,6 +396,129 @@ export default function WebRTCVideo() {
], ],
); );
// Setup Keyboard Events
useEffect(
function setupKeyboardEvents() {
const abortController = new AbortController();
const signal = abortController.signal;
document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal });
return () => {
abortController.abort();
};
},
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
);
// Setup Video Event Listeners
useEffect(
function setupVideoEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
const abortController = new AbortController();
const signal = abortController.signal;
// To prevent the video from being paused when the user presses a space in fullscreen mode
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
// We need to know when the video is playing to update state and video size
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
return () => {
abortController.abort();
};
},
[
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
);
// Setup Absolute Mouse Events
useEffect(
function setAbsoluteMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
// Reset the mouse position when the window is blurred or the document is hidden
const local = resetMousePosition;
window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal });
return () => {
abortController.abort();
};
},
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
);
// Setup Relative Mouse Events
const containerRef = useRef<HTMLDivElement>(null);
useEffect(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
const abortController = new AbortController();
const signal = abortController.signal;
// We bind to the larger container in relative mode because of delta between the acceleration of the local
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
// When we get Pointer Lock support, we can remove this.
const containerElm = containerRef.current;
if (!containerElm) return;
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
},
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
);
const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnectionState !== "connected") return false;
if (isPlaying) return false;
if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false;
return true;
}, [peerConnectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
@ -490,7 +533,12 @@ export default function WebRTCVideo() {
</fieldset> </fieldset>
</div> </div>
<div className="h-full overflow-hidden"> <div
ref={containerRef}
className={cx("h-full overflow-hidden", {
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
})}
>
<div className="relative h-full"> <div className="relative h-full">
<div <div
className={cx( className={cx(
@ -519,7 +567,9 @@ export default function WebRTCVideo() {
className={cx( className={cx(
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000", "outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none":
settings.mouseMode === "absolute" &&
settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError, "opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
@ -534,6 +584,12 @@ export default function WebRTCVideo() {
<LoadingOverlay show={isLoading} /> <LoadingOverlay show={isLoading} />
<ConnectionErrorOverlay show={isConnectionError} /> <ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -130,10 +130,68 @@ export default function KvmIdRoute() {
const setDiskChannel = useRTCStore(state => state.setDiskChannel); const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel); const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const location = useLocation();
const [connectionAttempts, setConnectionAttempts] = useState(0);
const [startedConnectingAt, setStartedConnectingAt] = useState<Date | null>(null);
const [connectedAt, setConnectedAt] = useState<Date | null>(null);
const [connectionFailed, setConnectionFailed] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { otaState, setOtaState, setModalView } = useUpdateStore(); 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
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( const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
if (!pc) return; if (!pc) return;
@ -169,7 +227,7 @@ export default function KvmIdRoute() {
// - In device mode, the device api would timeout, the fetch would throw an error, therefore the catch block would be hit // - 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 // Regardless, we should close the peer connection and let the useInterval handle reconnecting
if (!res.ok) { if (!res.ok) {
pc?.close(); closePeerConnection();
console.error(`Error setting SDP - Status: ${res.status}}`, json); console.error(`Error setting SDP - Status: ${res.status}}`, json);
return; return;
} }
@ -180,14 +238,20 @@ export default function KvmIdRoute() {
).catch(e => console.log(`Error setting remote description: ${e}`)); ).catch(e => console.log(`Error setting remote description: ${e}`));
} catch (error) { } catch (error) {
console.error(`Error setting SDP: ${error}`); console.error(`Error setting SDP: ${error}`);
pc?.close(); closePeerConnection();
} }
}, },
[navigate, params.id], [closePeerConnection, navigate, params.id],
); );
const connectWebRTC = useCallback(async () => { const connectWebRTC = useCallback(async () => {
console.log("Attempting to connect WebRTC"); 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);
const pc = new RTCPeerConnection({ const pc = new RTCPeerConnection({
// We only use STUN or TURN servers if we're in the cloud // We only use STUN or TURN servers if we're in the cloud
...(isInCloud && iceConfig?.iceServers ...(isInCloud && iceConfig?.iceServers
@ -197,6 +261,11 @@ export default function KvmIdRoute() {
// Set up event listeners and data channels // Set up event listeners and data channels
pc.onconnectionstatechange = () => { 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); setPeerConnectionState(pc.connectionState);
}; };
@ -236,16 +305,35 @@ export default function KvmIdRoute() {
setTransceiver, setTransceiver,
]); ]);
// WebRTC connection management useEffect(() => {
useInterval(() => { 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
if ( if (
["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "") ["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "")
) { ) {
return; return;
} }
if (location.pathname.includes("other-session")) 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(); connectWebRTC();
}, 3000); }, 3000);
return () => clearInterval(interval);
}, [
connectWebRTC,
connectionFailed,
location.pathname,
peerConnection?.connectionState,
]);
// On boot, if the connection state is undefined, we connect to the WebRTC // On boot, if the connection state is undefined, we connect to the WebRTC
useEffect(() => { useEffect(() => {
@ -431,7 +519,6 @@ export default function KvmIdRoute() {
}, [kvmTerminal, peerConnection, serialConsole]); }, [kvmTerminal, peerConnection, serialConsole]);
const outlet = useOutlet(); const outlet = useOutlet();
const location = useLocation();
const onModalClose = useCallback(() => { const onModalClose = useCallback(() => {
if (location.pathname !== "/other-session") navigateTo("/"); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]); }, [navigateTo, location.pathname]);
@ -523,6 +610,7 @@ export default function KvmIdRoute() {
}} }}
> >
<Modal open={outlet !== null} onClose={onModalClose}> <Modal open={outlet !== null} onClose={onModalClose}>
{/* The 'used by other session' modal needs to have access to the connectWebRTC function */}
<Outlet context={{ connectWebRTC }} /> <Outlet context={{ connectWebRTC }} />
</Modal> </Modal>
</div> </div>