diff --git a/.golangci.yml b/.golangci.yml index ddf4443..95a1cb8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,12 +1,22 @@ --- linters: enable: - # - goimports - # - misspell + - forbidigo + - goimports + - misspell # - revive + - whitespace issues: exclude-rules: - path: _test.go linters: - errcheck + +linters-settings: + forbidigo: + forbid: + - p: ^fmt\.Print.*$ + msg: Do not commit print statements. Use logger package. + - p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$ + msg: Do not commit log statements. Use logger package. diff --git a/README.md b/README.md index 1b516d7..5d0e9d7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware ## I need help -The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). +The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord). ## I want to report an issue diff --git a/cloud.go b/cloud.go index a30a14c..be53b08 100644 --- a/cloud.go +++ b/cloud.go @@ -10,6 +10,8 @@ import ( "time" "github.com/coder/websocket/wsjson" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "github.com/coreos/go-oidc/v3/oidc" @@ -36,6 +38,97 @@ const ( CloudWebSocketPingInterval = 15 * time.Second ) +var ( + metricCloudConnectionStatus = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_status", + Help: "The status of the cloud connection", + }, + ) + metricCloudConnectionEstablishedTimestamp = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_established_timestamp", + Help: "The timestamp when the cloud connection was established", + }, + ) + metricCloudConnectionLastPingTimestamp = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_last_ping_timestamp", + Help: "The timestamp when the last ping response was received", + }, + ) + metricCloudConnectionLastPingDuration = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_last_ping_duration", + Help: "The duration of the last ping response", + }, + ) + metricCloudConnectionPingDuration = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "jetkvm_cloud_connection_ping_duration", + Help: "The duration of the ping response", + Buckets: []float64{ + 0.1, 0.5, 1, 10, + }, + }, + ) + metricCloudConnectionTotalPingCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_cloud_connection_total_ping_count", + Help: "The total number of pings sent to the cloud", + }, + ) + metricCloudConnectionSessionRequestCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_cloud_connection_session_total_request_count", + Help: "The total number of session requests received from the cloud", + }, + ) + metricCloudConnectionSessionRequestDuration = promauto.NewHistogram( + prometheus.HistogramOpts{ + Name: "jetkvm_cloud_connection_session_request_duration", + Help: "The duration of session requests", + Buckets: []float64{ + 0.1, 0.5, 1, 10, + }, + }, + ) + metricCloudConnectionLastSessionRequestTimestamp = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_last_session_request_timestamp", + Help: "The timestamp of the last session request", + }, + ) + metricCloudConnectionLastSessionRequestDuration = promauto.NewGauge( + prometheus.GaugeOpts{ + Name: "jetkvm_cloud_connection_last_session_request_duration", + Help: "The duration of the last session request", + }, + ) + metricCloudConnectionFailureCount = promauto.NewCounter( + prometheus.CounterOpts{ + Name: "jetkvm_cloud_connection_failure_count", + Help: "The number of times the cloud connection has failed", + }, + ) +) + +func cloudResetMetrics(established bool) { + metricCloudConnectionLastPingTimestamp.Set(-1) + metricCloudConnectionLastPingDuration.Set(-1) + + metricCloudConnectionLastSessionRequestTimestamp.Set(-1) + metricCloudConnectionLastSessionRequestDuration.Set(-1) + + if established { + metricCloudConnectionEstablishedTimestamp.SetToCurrentTime() + metricCloudConnectionStatus.Set(1) + } else { + metricCloudConnectionEstablishedTimestamp.Set(-1) + metricCloudConnectionStatus.Set(-1) + } +} + func handleCloudRegister(c *gin.Context) { var req CloudRegisterRequest @@ -90,11 +183,6 @@ func handleCloudRegister(c *gin.Context) { return } - if config.CloudToken == "" { - cloudLogger.Info("Starting websocket client due to adoption") - go RunWebsocketClient() - } - config.CloudToken = tokenResp.SecretToken provider, err := oidc.NewProvider(c, "https://accounts.google.com") @@ -130,19 +218,23 @@ func runWebsocketClient() error { time.Sleep(5 * time.Second) return fmt.Errorf("cloud token is not set") } + wsURL, err := url.Parse(config.CloudURL) if err != nil { return fmt.Errorf("failed to parse config.CloudURL: %w", err) } + if wsURL.Scheme == "http" { wsURL.Scheme = "ws" } else { wsURL.Scheme = "wss" } + header := http.Header{} header.Set("X-Device-ID", GetDeviceID()) header.Set("Authorization", "Bearer "+config.CloudToken) dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) + defer cancelDial() c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ HTTPHeader: header, @@ -152,17 +244,35 @@ func runWebsocketClient() error { } defer c.CloseNow() //nolint:errcheck cloudLogger.Infof("websocket connected to %s", wsURL) + + // set the metrics when we successfully connect to the cloud. + cloudResetMetrics(true) + runCtx, cancelRun := context.WithCancel(context.Background()) defer cancelRun() go func() { for { time.Sleep(CloudWebSocketPingInterval) + + // set the timer for the ping duration + timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { + metricCloudConnectionLastPingDuration.Set(v) + metricCloudConnectionPingDuration.Observe(v) + })) + err := c.Ping(runCtx) + if err != nil { cloudLogger.Warnf("websocket ping error: %v", err) cancelRun() return } + + // dont use `defer` here because we want to observe the duration of the ping + timer.ObserveDuration() + + metricCloudConnectionTotalPingCount.Inc() + metricCloudConnectionLastPingTimestamp.SetToCurrentTime() } }() for { @@ -184,6 +294,8 @@ func runWebsocketClient() error { cloudLogger.Infof("new session request: %v", req.OidcGoogle) cloudLogger.Tracef("session request info: %v", req) + metricCloudConnectionSessionRequestCount.Inc() + metricCloudConnectionLastSessionRequestTimestamp.SetToCurrentTime() err = handleSessionRequest(runCtx, c, req) if err != nil { cloudLogger.Infof("error starting new session: %v", err) @@ -193,6 +305,12 @@ func runWebsocketClient() error { } func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { + timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { + metricCloudConnectionLastSessionRequestDuration.Set(v) + metricCloudConnectionSessionRequestDuration.Observe(v) + })) + defer timer.ObserveDuration() + oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout) defer cancelOIDC() provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") @@ -253,9 +371,34 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess func RunWebsocketClient() { for { + // reset the metrics when we start the websocket client. + cloudResetMetrics(false) + + // If the cloud token is not set, we don't need to run the websocket client. + if config.CloudToken == "" { + time.Sleep(5 * time.Second) + continue + } + + // If the network is not up, well, we can't connect to the cloud. + if !networkState.Up { + cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds") + time.Sleep(3 * time.Second) + continue + } + + // If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail. + if isTimeSyncNeeded() && !timeSyncSuccess { + cloudLogger.Warn("system time is not synced, will retry in 3 seconds") + time.Sleep(3 * time.Second) + continue + } + err := runWebsocketClient() if err != nil { cloudLogger.Errorf("websocket client error: %v", err) + metricCloudConnectionStatus.Set(0) + metricCloudConnectionFailureCount.Inc() time.Sleep(5 * time.Second) } } diff --git a/main.go b/main.go index 1debb0c..d8934a8 100644 --- a/main.go +++ b/main.go @@ -74,11 +74,9 @@ func Main() { initCertStore() go RunWebSecureServer() } - // If the cloud token isn't set, the client won't be started by default. - // However, if the user adopts the device via the web interface, handleCloudRegister will start the client. - if config.CloudToken != "" { - go RunWebsocketClient() - } + // As websocket client already checks if the cloud token is set, we can start it here. + go RunWebsocketClient() + initSerialPort() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) diff --git a/prometheus.go b/prometheus.go index 8ebf259..5d4c5e7 100644 --- a/prometheus.go +++ b/prometheus.go @@ -1,15 +1,11 @@ package kvm import ( - "net/http" - "github.com/prometheus/client_golang/prometheus" versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/common/version" ) -var promHandler http.Handler - func initPrometheus() { // A Prometheus metrics endpoint. version.Version = builtAppVersion diff --git a/serial.go b/serial.go index a4ab7d5..31fd553 100644 --- a/serial.go +++ b/serial.go @@ -66,7 +66,6 @@ func runATXControl() { newLedPWRState != ledPWRState || newBtnRSTState != btnRSTState || newBtnPWRState != btnPWRState { - logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v", newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState) diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 452a19c..03a907e 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -36,7 +36,7 @@ export default function DashboardNavbar({ picture, kvmName, }: NavbarProps) { - const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState); + const peerConnection = useRTCStore(state => state.peerConnection); const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); const onLogout = useCallback(async () => { @@ -82,14 +82,14 @@ export default function DashboardNavbar({
diff --git a/ui/src/components/PeerConnectionStatusCard.tsx b/ui/src/components/PeerConnectionStatusCard.tsx index 07e91cd..98025cd 100644 --- a/ui/src/components/PeerConnectionStatusCard.tsx +++ b/ui/src/components/PeerConnectionStatusCard.tsx @@ -9,19 +9,22 @@ const PeerConnectionStatusMap = { failed: "Connection failed", closed: "Closed", new: "Connecting", -}; +} as Record; export type PeerConnections = keyof typeof PeerConnectionStatusMap; -type StatusProps = Record; + } +>; export default function PeerConnectionStatusCard({ state, title, }: { - state?: PeerConnections; + state?: RTCPeerConnectionState | null; title?: string; }) { if (!state) return null; diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index d8e86c6..f0b2cb2 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -8,11 +8,14 @@ import { HidState } from "@/hooks/stores"; type USBStates = HidState["usbState"]; -type StatusProps = Record; iconClassName: string; statusIndicatorClassName: string; - }>; + } +>; const USBStateMap: Record = { configured: "Connected", @@ -27,9 +30,8 @@ export default function USBStateStatus({ peerConnectionState, }: { state: USBStates; - peerConnectionState?: RTCPeerConnectionState; + peerConnectionState?: RTCPeerConnectionState | null; }) { - const StatusCardProps: StatusProps = { configured: { icon: ({ className }) => ( diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index f13b6ce..0620af4 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -1,6 +1,6 @@ import React from "react"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; -import { ArrowRightIcon } from "@heroicons/react/16/solid"; +import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { motion, AnimatePresence } from "framer-motion"; import { LuPlay } from "react-icons/lu"; @@ -25,12 +25,12 @@ interface LoadingOverlayProps { show: boolean; } -export function LoadingOverlay({ show }: LoadingOverlayProps) { +export function LoadingVideoOverlay({ show }: LoadingOverlayProps) { return ( {show && ( {show && ( + +
+
+ +
+

+ {text} +

+
+
+
+ )} +
+ ); +} + +interface ConnectionErrorOverlayProps { + show: boolean; + setupPeerConnection: () => Promise; +} + +export function ConnectionErrorOverlay({ + show, + setupPeerConnection, +}: ConnectionErrorOverlayProps) { + return ( + + {show && ( + @@ -87,14 +125,21 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
  • Try restarting both the device and your computer
  • -
    +
    +
    diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 911c5ea..5d8fb55 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -19,9 +19,8 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { HDMIErrorOverlay, + LoadingVideoOverlay, NoAutoplayPermissionsOverlay, - ConnectionErrorOverlay, - LoadingOverlay, } from "./VideoOverlay"; export default function WebRTCVideo() { @@ -46,15 +45,13 @@ export default function WebRTCVideo() { // RTC related states const peerConnection = useRTCStore(state => state.peerConnection); - const peerConnectionState = useRTCStore(state => state.peerConnectionState); // HDMI and UI states const hdmiState = useVideoStore(state => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); - const isLoading = !hdmiError && !isPlaying; - const isConnectionError = ["error", "failed", "disconnected", "closed"].includes( - peerConnectionState || "", - ); + const isVideoLoading = !isPlaying; + + // console.log("peerConnection?.connectionState", peerConnection?.connectionState); // Keyboard related states const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } = @@ -379,25 +376,52 @@ export default function WebRTCVideo() { } }, []); + const addStreamToVideoElm = useCallback( + (mediaStream: MediaStream) => { + if (!videoElm.current) return; + const videoElmRefValue = videoElm.current; + console.log("Adding stream to video element", videoElmRefValue); + videoElmRefValue.srcObject = mediaStream; + updateVideoSizeStore(videoElmRefValue); + }, + [updateVideoSizeStore], + ); + + useEffect( + function updateVideoStreamOnNewTrack() { + if (!peerConnection) return; + const abortController = new AbortController(); + const signal = abortController.signal; + + peerConnection.addEventListener( + "track", + (e: RTCTrackEvent) => { + console.log("Adding stream to video element"); + addStreamToVideoElm(e.streams[0]); + }, + { signal }, + ); + + return () => { + abortController.abort(); + }; + }, + [addStreamToVideoElm, peerConnection], + ); + useEffect( function updateVideoStream() { if (!mediaStream) return; - if (!videoElm.current) return; - if (peerConnection?.iceConnectionState !== "connected") return; - - setTimeout(() => { - if (videoElm?.current) { - videoElm.current.srcObject = mediaStream; - } - }, 0); - updateVideoSizeStore(videoElm.current); + console.log("Updating video stream from mediaStream"); + // We set the as early as possible + addStreamToVideoElm(mediaStream); }, [ setVideoClientSize, - setVideoSize, mediaStream, updateVideoSizeStore, - peerConnection?.iceConnectionState, + peerConnection, + addStreamToVideoElm, ], ); @@ -474,6 +498,8 @@ export default function WebRTCVideo() { const local = resetMousePosition; window.addEventListener("blur", local, { signal }); document.addEventListener("visibilitychange", local, { signal }); + const preventContextMenu = (e: MouseEvent) => e.preventDefault(); + videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal }); return () => { abortController.abort(); @@ -517,17 +543,17 @@ export default function WebRTCVideo() { ); const hasNoAutoPlayPermissions = useMemo(() => { - if (peerConnectionState !== "connected") return false; + if (peerConnection?.connectionState !== "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]); + }, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]); return (
    -
    +
    videoElm.current?.requestFullscreen({ @@ -575,28 +601,29 @@ export default function WebRTCVideo() { "cursor-none": settings.mouseMode === "absolute" && settings.isCursorHidden, - "opacity-0": isLoading || isConnectionError || hdmiError, + "opacity-0": isVideoLoading || hdmiError, "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": isPlaying, }, )} /> -
    -
    - - - - { - videoElm.current?.play(); - }} - /> + {peerConnection?.connectionState == "connected" && ( +
    +
    + + + { + videoElm.current?.play(); + }} + /> +
    -
    + )}
    diff --git a/ui/src/routes/devices.$id.other-session.tsx b/ui/src/routes/devices.$id.other-session.tsx index 2805666..16cb479 100644 --- a/ui/src/routes/devices.$id.other-session.tsx +++ b/ui/src/routes/devices.$id.other-session.tsx @@ -6,7 +6,7 @@ import LogoBlue from "@/assets/logo-blue.svg"; import LogoWhite from "@/assets/logo-white.svg"; interface ContextType { - connectWebRTC: () => Promise; + setupPeerConnection: () => Promise; } /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ @@ -16,7 +16,7 @@ export default function OtherSessionRoute() { // Function to handle closing the modal const handleClose = () => { - outletContext?.connectWebRTC().then(() => navigate("..")); + outletContext?.setupPeerConnection().then(() => navigate("..")); }; return ( diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 05b0322..d2662fc 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -45,6 +45,10 @@ import Modal from "../components/Modal"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; 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"; @@ -126,8 +130,6 @@ export default function KvmIdRoute() { const setIsTurnServerInUse = useRTCStore(state => state.setTurnServerInUse); const peerConnection = useRTCStore(state => state.peerConnection); - - const setPeerConnectionState = useRTCStore(state => state.setPeerConnectionState); const setMediaMediaStream = useRTCStore(state => state.setMediaStream); const setPeerConnection = useRTCStore(state => state.setPeerConnection); const setDiskChannel = useRTCStore(state => state.setDiskChannel); @@ -135,77 +137,55 @@ export default function KvmIdRoute() { 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 [loadingMessage, setLoadingMessage] = useState("Connecting to device..."); const closePeerConnection = useCallback( function closePeerConnection() { + console.log("Closing peer connection"); + + setConnectionFailed(true); + connectionFailedRef.current = true; + 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"); + signalingAttempts.current = 0; }, - [peerConnection, setPeerConnectionState], + [peerConnection], ); + // We need to track connectionFailed in a ref to avoid stale closure issues + // This is necessary because syncRemoteSessionDescription is a callback that captures + // the connectionFailed value at creation time, but we need the latest value + // when the function is actually called. Without this ref, the function would use + // a stale value of connectionFailed in some conditions. + // + // We still need the state variable for UI rendering, so we sync the ref with the state. + // This pattern is a workaround for what useEvent hook would solve more elegantly + // (which would give us a callback that always has access to latest state without re-creation). + const connectionFailedRef = useRef(false); 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; + connectionFailedRef.current = connectionFailed; + }, [connectionFailed]); + const signalingAttempts = useRef(0); + const syncRemoteSessionDescription = useCallback( + async function syncRemoteSessionDescription(pc: RTCPeerConnection) { 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 @@ -213,66 +193,109 @@ export default function KvmIdRoute() { }); const json = await res.json(); - - if (isOnDevice) { - if (res.status === 401) { - return navigate("/login-local"); - } + 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"); } - 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"); + console.log("Successfully got Remote Session Description. Setting."); + setLoadingMessage("Setting remote session description..."); - // 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; - } - } + const decodedSd = atob(json.sd); + const parsedSd = JSON.parse(decodedSd); + pc.setRemoteDescription(new RTCSessionDescription(parsedSd)); - pc.setRemoteDescription( - new RTCSessionDescription(JSON.parse(atob(json.sd))), - ).catch(e => console.log(`Error setting remote description: ${e}`)); + 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); + }); } catch (error) { - console.error(`Error setting SDP: ${error}`); - closePeerConnection(); + 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(); + } } }, [closePeerConnection, navigate, params.id], ); - const connectWebRTC = useCallback(async () => { - console.log("Attempting to connect WebRTC"); + const setupPeerConnection = useCallback(async () => { + console.log("Setting up peer connection"); + setConnectionFailed(false); + setLoadingMessage("Connecting to device..."); - // Track connection status to detect failures and show error overlay - setConnectionAttempts(x => x + 1); - setStartedConnectingAt(new Date()); - setConnectedAt(null); - - const pc = new RTCPeerConnection({ - // We only use STUN or TURN servers if we're in the cloud - ...(isInCloud && iceConfig?.iceServers - ? { iceServers: [iceConfig?.iceServers] } - : {}), - }); + let pc: RTCPeerConnection; + try { + console.log("Creating peer connection"); + setLoadingMessage("Creating peer connection..."); + pc = new RTCPeerConnection({ + // We only use STUN or TURN servers if we're in the cloud + ...(isInCloud && iceConfig?.iceServers + ? { iceServers: [iceConfig?.iceServers] } + : {}), + }); + console.log("Peer connection created", pc); + setLoadingMessage("Peer connection created"); + } catch (e) { + console.error(`Error creating peer connection: ${e}`); + setTimeout(() => { + closePeerConnection(); + }, 1000); + 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); + console.log("Connection state changed", pc.connectionState); }; - pc.onicecandidate = event => sdp(event, pc); + 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); + } else if (pc.iceGatheringState === "gathering") { + console.log("ICE Gathering Started"); + setLoadingMessage("Gathering ICE candidates..."); + } + }; pc.ontrack = function (event) { setMediaMediaStream(event.streams[0]); @@ -290,60 +313,32 @@ export default function KvmIdRoute() { setDiskChannel(diskDataChannel); }; + setPeerConnection(pc); + try { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); - setPeerConnection(pc); } catch (e) { - console.error(`Error creating offer: ${e}`); + console.error(`Error creating offer: ${e}`, new Date().toISOString()); + 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 - if ( - ["connected", "connecting", "new"].includes(peerConnection?.connectionState ?? "") - ) { - 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, - peerConnection?.connectionState, + syncRemoteSessionDescription, ]); // On boot, if the connection state is undefined, we connect to the WebRTC useEffect(() => { if (peerConnection?.connectionState === undefined) { - connectWebRTC(); + setupPeerConnection(); } - }, [connectWebRTC, peerConnection?.connectionState]); + }, [setupPeerConnection, peerConnection?.connectionState]); // Cleanup effect const clearInboundRtpStats = useRTCStore(state => state.clearInboundRtpStats); @@ -597,7 +592,27 @@ export default function KvmIdRoute() { kvmName={deviceName || "JetKVM Device"} /> -
    +
    +
    +
    + + +
    +
    +
    @@ -614,7 +629,7 @@ export default function KvmIdRoute() { > {/* The 'used by other session' modal needs to have access to the connectWebRTC function */} - +
    diff --git a/web.go b/web.go index b35a2db..9201e7b 100644 --- a/web.go +++ b/web.go @@ -16,6 +16,7 @@ import ( "golang.org/x/crypto/bcrypt" ) +//nolint:typecheck //go:embed all:static var staticFiles embed.FS @@ -419,7 +420,6 @@ func handleSetup(c *gin.Context) { // Set the cookie c.SetCookie("authToken", config.LocalAuthToken, 7*24*60*60, "/", "", false, true) - } else { // For noPassword mode, ensure the password field is empty config.HashedPassword = ""