diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index b71f61ea..9fd721c8 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -102,7 +102,7 @@ export interface RTCState { peerConnection: RTCPeerConnection | null; setPeerConnection: (pc: RTCState["peerConnection"]) => void; - setRpcDataChannel: (channel: RTCDataChannel) => void; + setRpcDataChannel: (channel: RTCDataChannel | null) => void; rpcDataChannel: RTCDataChannel | null; hidRpcDisabled: boolean; @@ -164,41 +164,42 @@ export const useRTCStore = create(set => ({ setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), rpcDataChannel: null, - setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), + setRpcDataChannel: channel => set({ rpcDataChannel: channel }), hidRpcDisabled: false, - setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), + setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }), rpcHidProtocolVersion: null, - setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), + setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }), rpcHidChannel: null, - setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), + setRpcHidChannel: channel => set({ rpcHidChannel: channel }), rpcHidUnreliableChannel: null, - setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), + setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }), rpcHidUnreliableNonOrderedChannel: null, - setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), + setRpcHidUnreliableNonOrderedChannel: channel => + set({ rpcHidUnreliableNonOrderedChannel: channel }), transceiver: null, - setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), + setTransceiver: transceiver => set({ transceiver }), peerConnectionState: null, - setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), + setPeerConnectionState: state => set({ peerConnectionState: state }), mediaStream: null, - setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), + setMediaStream: stream => set({ mediaStream: stream }), videoStreamStats: null, - appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), + appendVideoStreamStats: stats => set({ videoStreamStats: stats }), videoStreamStatsHistory: new Map(), isTurnServerInUse: false, - setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), + setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), inboundRtpStats: new Map(), - appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { + appendInboundRtpStats: stats => { set(prevState => ({ inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), })); @@ -206,7 +207,7 @@ export const useRTCStore = create(set => ({ clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), candidatePairStats: new Map(), - appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { + appendCandidatePairStats: stats => { set(prevState => ({ candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), })); @@ -214,21 +215,21 @@ export const useRTCStore = create(set => ({ clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), localCandidateStats: new Map(), - appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { + appendLocalCandidateStats: stats => { set(prevState => ({ localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), })); }, remoteCandidateStats: new Map(), - appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { + appendRemoteCandidateStats: stats => { set(prevState => ({ remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), })); }, diskDataChannelStats: new Map(), - appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { + appendDiskDataChannelStats: stats => { set(prevState => ({ diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), })); @@ -236,7 +237,7 @@ export const useRTCStore = create(set => ({ // Add these new properties to the store implementation terminalChannel: null, - setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), + setTerminalChannel: channel => set({ terminalChannel: channel }), })); export interface MouseMove { @@ -255,12 +256,20 @@ export interface MouseState { export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, - setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), - setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), + setMouseMove: move => set({ mouseMove: move }), + setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), })); -export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; -export type HdmiErrorStates = Extract +export type HdmiStates = + | "ready" + | "no_signal" + | "no_lock" + | "out_of_range" + | "connecting"; +export type HdmiErrorStates = Extract< + VideoState["hdmiState"], + "no_signal" | "no_lock" | "out_of_range" +>; export interface HdmiState { ready: boolean; @@ -275,10 +284,7 @@ export interface VideoState { setClientSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void; hdmiState: HdmiStates; - setHdmiState: (state: { - ready: boolean; - error?: HdmiErrorStates; - }) => void; + setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void; } export const useVideoStore = create(set => ({ @@ -289,7 +295,8 @@ export const useVideoStore = create(set => ({ clientHeight: 0, // The video element's client size - setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), + setClientSize: (clientWidth: number, clientHeight: number) => + set({ clientWidth, clientHeight }), // Resolution setSize: (width: number, height: number) => set({ width, height }), @@ -432,13 +439,15 @@ export interface MountMediaState { export const useMountMediaStore = create(set => ({ remoteVirtualMediaState: null, - setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), + setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => + set({ remoteVirtualMediaState: state }), modalView: "mode", setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), isMountMediaDialogOpen: false, - setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), + setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => + set({ isMountMediaDialogOpen: isOpen }), uploadedFiles: [], addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => @@ -455,7 +464,7 @@ export interface KeyboardLedState { compose: boolean; kana: boolean; shift: boolean; // Optional, as not all keyboards have a shift LED -}; +} export const hidKeyBufferSize = 6; export const hidErrorRollOver = 0x01; @@ -490,14 +499,23 @@ export interface HidState { } export const useHidStore = create(set => ({ - keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, - setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), + keyboardLedState: { + num_lock: false, + caps_lock: false, + scroll_lock: false, + compose: false, + kana: false, + shift: false, + } as KeyboardLedState, + setKeyboardLedState: (ledState: KeyboardLedState): void => + set({ keyboardLedState: ledState }), keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), isVirtualKeyboardEnabled: false, - setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), + setVirtualKeyboardEnabled: (enabled: boolean): void => + set({ isVirtualKeyboardEnabled: enabled }), isPasteInProgress: false, setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), @@ -547,9 +565,9 @@ export interface OtaState { systemVerificationProgress: number; systemVerifiedAt: string | null; - systemUpdateProgress: number; - systemUpdatedAt: string | null; -}; + systemUpdateProgress: number; + systemUpdatedAt: string | null; +} export interface UpdateState { isUpdatePending: boolean; @@ -558,7 +576,7 @@ export interface UpdateState { otaState: OtaState; setOtaState: (state: OtaState) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; - modalView: UpdateModalViews + modalView: UpdateModalViews; setModalView: (view: UpdateModalViews) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; @@ -595,12 +613,11 @@ export const useUpdateStore = create(set => ({ modalView: "loading", setModalView: (view: UpdateModalViews) => set({ modalView: view }), updateErrorMessage: null, - setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), + setUpdateErrorMessage: (errorMessage: string) => + set({ updateErrorMessage: errorMessage }), })); -export type UsbConfigModalViews = - | "updateUsbConfig" - | "updateUsbConfigSuccess"; +export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess"; export interface UsbConfigModalState { modalView: UsbConfigModalViews ; @@ -924,7 +941,7 @@ export const useMacrosStore = create((set, get) => ({ } finally { set({ loading: false }); } - } + }, })); export interface FailsafeModeState { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 4f55fae7..5f2718b7 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -548,8 +548,9 @@ export default function KvmIdRoute() { clearCandidatePairStats(); setSidebarView(null); setPeerConnection(null); + setRpcDataChannel(null); }; - }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); + }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]); // TURN server usage detection useEffect(() => { diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts new file mode 100644 index 00000000..ae97be13 --- /dev/null +++ b/ui/src/utils/jsonrpc.ts @@ -0,0 +1,244 @@ +import { useRTCStore } from "@/hooks/stores"; +import { sleep } from "@/utils"; + +// JSON-RPC utility for use outside of React components + +export interface JsonRpcCallOptions { + method: string; + params?: unknown; + attemptTimeoutMs?: number; + maxAttempts?: number; +} + +export interface JsonRpcCallResponse { + jsonrpc: string; + result?: T; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: number | string | null; +} + +let rpcCallCounter = 0; + +// Helper: wait for RTC data channel to be ready +// This waits indefinitely for the channel to be ready, only aborting via the signal +// Throws if the channel instance changed while waiting (stale connection detected) +async function waitForRtcReady(signal: AbortSignal): Promise { + const pollInterval = 100; + let lastSeenChannel: RTCDataChannel | null = null; + + while (!signal.aborted) { + const state = useRTCStore.getState(); + const currentChannel = state.rpcDataChannel; + + // Channel instance changed (new connection replaced old one) + if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) { + console.debug("[waitForRtcReady] Channel instance changed, aborting wait"); + throw new Error("RTC connection changed while waiting for readiness"); + } + + // Channel was removed from store (connection closed) + if (lastSeenChannel && !currentChannel) { + console.debug("[waitForRtcReady] Channel was removed from store, aborting wait"); + throw new Error("RTC connection was closed while waiting for readiness"); + } + + // No channel yet, keep waiting + if (!currentChannel) { + await sleep(pollInterval); + continue; + } + + // Track this channel instance + lastSeenChannel = currentChannel; + + // Channel is ready! + if (currentChannel.readyState === "open") { + return currentChannel; + } + + await sleep(pollInterval); + } + + // Signal was aborted for some reason + console.debug("[waitForRtcReady] Aborted via signal"); + throw new Error("RTC readiness check aborted"); +} + +// Helper: send RPC request and wait for response +async function sendRpcRequest( + rpcDataChannel: RTCDataChannel, + options: JsonRpcCallOptions, + signal: AbortSignal, +): Promise> { + return new Promise((resolve, reject) => { + rpcCallCounter++; + const requestId = `rpc_${Date.now()}_${rpcCallCounter}`; + + const request = { + jsonrpc: "2.0", + method: options.method, + params: options.params || {}, + id: requestId, + }; + + const messageHandler = (event: MessageEvent) => { + try { + const response = JSON.parse(event.data) as JsonRpcCallResponse; + if (response.id === requestId) { + cleanup(); + resolve(response); + } + } catch { + // Ignore parse errors from other messages + } + }; + + const abortHandler = () => { + cleanup(); + reject(new Error("Request aborted")); + }; + + const cleanup = () => { + rpcDataChannel.removeEventListener("message", messageHandler); + signal.removeEventListener("abort", abortHandler); + }; + + signal.addEventListener("abort", abortHandler); + rpcDataChannel.addEventListener("message", messageHandler); + rpcDataChannel.send(JSON.stringify(request)); + }); +} + +// Function overloads for better typing +export function callJsonRpc( + options: JsonRpcCallOptions, +): Promise & { result: T }>; +export function callJsonRpc( + options: JsonRpcCallOptions, +): Promise>; +export async function callJsonRpc( + options: JsonRpcCallOptions, +): Promise> { + const maxAttempts = options.maxAttempts ?? 1; + const timeout = options.attemptTimeoutMs || 5000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds + const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000); + let timeoutId: ReturnType | null = null; + + try { + // Wait for RTC readiness without timeout - this allows time for WebRTC to connect + const readyAbortController = new AbortController(); + const rpcDataChannel = await waitForRtcReady(readyAbortController.signal); + + // Now apply timeout only to the actual RPC request/response + const rpcAbortController = new AbortController(); + timeoutId = setTimeout(() => rpcAbortController.abort(), timeout); + + // Send RPC request and wait for response + const response = await sendRpcRequest( + rpcDataChannel, + options, + rpcAbortController.signal, + ); + + // Retry on error if attempts remain + if (response.error && attempt < maxAttempts - 1) { + await sleep(backoffMs); + continue; + } + + return response; + } catch (error) { + // Retry on timeout/error if attempts remain + if (attempt < maxAttempts - 1) { + await sleep(backoffMs); + continue; + } + + throw error instanceof Error + ? error + : new Error(`JSON-RPC call failed after ${timeout}ms`); + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + } + } + + // Should never reach here due to loop logic, but TypeScript needs this + throw new Error("Unexpected error in callJsonRpc"); +} + +// Specific network settings API calls +export async function getNetworkSettings() { + const response = await callJsonRpc({ method: "getNetworkSettings" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function setNetworkSettings(settings: unknown) { + const response = await callJsonRpc({ + method: "setNetworkSettings", + params: { settings }, + }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function getNetworkState() { + const response = await callJsonRpc({ method: "getNetworkState" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export async function renewDHCPLease() { + const response = await callJsonRpc({ method: "renewDHCPLease" }); + if (response.error) { + throw new Error(response.error.message); + } + return response.result; +} + +export interface VersionInfo { + appVersion: string; + systemVersion: string; +} + +export interface SystemVersionInfo { + local: VersionInfo; + remote?: VersionInfo; + systemUpdateAvailable: boolean; + appUpdateAvailable: boolean; + error?: string; +} + +export async function getUpdateStatus() { + const response = await callJsonRpc({ + method: "getUpdateStatus", + // This function calls our api server to see if there are any updates available. + // It can be called on page load right after a restart, so we need to give it time to + // establish a connection to the api server. + maxAttempts: 6, + }); + + if (response.error) throw response.error; + return response.result; +} + +export async function getLocalVersion() { + const response = await callJsonRpc({ method: "getLocalVersion" }); + if (response.error) throw response.error; + return response.result; +}