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; }