From 632125e38ff605ae607f2b1fa67eff43550d9754 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Thu, 23 Oct 2025 15:17:37 +0000 Subject: [PATCH] refactor: streamline version retrieval logic and enhance error handling in useVersion hook --- ui/src/hooks/useVersion.tsx | 91 ++++------- .../devices.$id.settings.general.update.tsx | 6 +- ui/src/utils/jsonrpc.ts | 148 +++++++++++++++--- 3 files changed, 166 insertions(+), 79 deletions(-) diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index 487068f6..94c2f99d 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -1,23 +1,11 @@ import { useCallback } from "react"; import { useDeviceStore } from "@/hooks/stores"; -import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; +import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc"; +import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; -export interface VersionInfo { - appVersion: string; - systemVersion: string; -} - -export interface SystemVersionInfo { - local: VersionInfo; - remote?: VersionInfo; - systemUpdateAvailable: boolean; - appUpdateAvailable: boolean; - error?: string; -} - export function useVersion() { const { appVersion, @@ -25,51 +13,40 @@ export function useVersion() { setAppVersion, setSystemVersion, } = useDeviceStore(); - const { send } = useJsonRpc(); - const getVersionInfo = useCallback(() => { - return new Promise((resolve, reject) => { - send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(m.updates_failed_check({ error: String(resp.error) })); - reject(new Error("Failed to check for updates")); - } else { - const result = resp.result as SystemVersionInfo; - setAppVersion(result.local.appVersion); - setSystemVersion(result.local.systemVersion); - if (result.error) { - notifications.error(m.updates_failed_check({ error: String(result.error) })); - reject(new Error("Failed to check for updates")); - } else { - resolve(result); - } - } - }); - }); - }, [send, setAppVersion, setSystemVersion]); + const getVersionInfo = useCallback(async () => { + try { + const result = await getUpdateStatus(); + setAppVersion(result.local.appVersion); + setSystemVersion(result.local.systemVersion); + return result; + } catch (error) { + const jsonRpcError = error as JsonRpcError; + notifications.error(m.updates_failed_check({ error: jsonRpcError.message })); + throw jsonRpcError; + } + }, [setAppVersion, setSystemVersion]); - const getLocalVersion = useCallback(() => { - return new Promise((resolve, reject) => { - send("getLocalVersion", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - console.log(resp.error) - if (resp.error.code === RpcMethodNotFound) { - console.warn("Failed to get device version, using legacy version"); - return getVersionInfo().then(result => resolve(result.local)).catch(reject); - } - console.error("Failed to get device version N", resp.error); - notifications.error(m.updates_failed_get_device_version({ error: String(resp.error) })); - reject(new Error("Failed to get device version")); - } else { - const result = resp.result as VersionInfo; + const getLocalVersion = useCallback(async () => { + try { + const result = await getLocalVersionRpc(); + setAppVersion(result.appVersion); + setSystemVersion(result.systemVersion); + return result; + } catch (error: unknown) { + const jsonRpcError = error as JsonRpcError; - setAppVersion(result.appVersion); - setSystemVersion(result.systemVersion); - resolve(result); - } - }); - }); - }, [send, setAppVersion, setSystemVersion, getVersionInfo]); + if (jsonRpcError.code === RpcMethodNotFound) { + console.error("Failed to get local version, using legacy remote version"); + const result = await getVersionInfo(); + return result.local; + } + + console.error("Failed to get device version", jsonRpcError); + notifications.error(m.updates_failed_get_device_version({ error: jsonRpcError.message })); + throw jsonRpcError; + } + }, [setAppVersion, setSystemVersion, getVersionInfo]); return { getVersionInfo, @@ -77,4 +54,4 @@ export function useVersion() { appVersion, systemVersion, }; -} \ No newline at end of file +} diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index c98744fa..b67db2e4 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -4,13 +4,15 @@ import { useLocation, useNavigate } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { UpdateState, useUpdateStore } from "@hooks/stores"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; -import { SystemVersionInfo, useVersion } from "@hooks/useVersion"; +import { useVersion } from "@hooks/useVersion"; import { Button } from "@components/Button"; import Card from "@components/Card"; import LoadingSpinner from "@components/LoadingSpinner"; -import UpdatingStatusCard, { type UpdatePart} from "@components/UpdatingStatusCard"; +import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard"; import { m } from "@localizations/messages.js"; +import { SystemVersionInfo } from "../utils/jsonrpc"; + export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); const location = useLocation(); diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index ecfa1c4b..b4b5d039 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -1,15 +1,17 @@ import { useRTCStore } from "@/hooks/stores"; // JSON-RPC utility for use outside of React components + export interface JsonRpcCallOptions { method: string; params?: unknown; timeout?: number; + retriesOnError?: number; } -export interface JsonRpcCallResponse { +export interface JsonRpcCallResponse { jsonrpc: string; - result?: unknown; + result?: T; error?: { code: number; message: string; @@ -20,16 +22,31 @@ export interface JsonRpcCallResponse { let rpcCallCounter = 0; -export function callJsonRpc(options: JsonRpcCallOptions): Promise { - return new Promise((resolve, reject) => { - // Access the RTC store directly outside of React context - const rpcDataChannel = useRTCStore.getState().rpcDataChannel; +// Helper: sleep utility for retry delays +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); - if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { - reject(new Error("RPC data channel not available")); - return; +// Helper: wait for RTC data channel to be ready +async function waitForRtcReady(signal: AbortSignal): Promise { + const pollInterval = 100; + + while (!signal.aborted) { + const state = useRTCStore.getState(); + if (state.rpcDataChannel?.readyState === "open") { + return state.rpcDataChannel; } + await sleep(pollInterval); + } + 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}`; @@ -40,32 +57,90 @@ export function callJsonRpc(options: JsonRpcCallOptions): Promise { try { - const response = JSON.parse(event.data) as JsonRpcCallResponse; + const response = JSON.parse(event.data) as JsonRpcCallResponse; if (response.id === requestId) { - clearTimeout(timeoutId); - rpcDataChannel.removeEventListener("message", messageHandler); + cleanup(); resolve(response); } - } catch (error) { + } catch { // Ignore parse errors from other messages } }; - timeoutId = setTimeout(() => { - rpcDataChannel.removeEventListener("message", messageHandler); - reject(new Error(`JSON-RPC call timed out after ${timeout}ms`)); - }, timeout); + 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 maxRetries = options.retriesOnError ?? 0; + const timeout = options.timeout || 5000; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), timeout); + + try { + // Wait for RTC readiness + const rpcDataChannel = await waitForRtcReady(abortController.signal); + + // Send RPC request and wait for response + const response = await sendRpcRequest( + rpcDataChannel, + options, + abortController.signal, + ); + + clearTimeout(timeoutId); + + // Retry on error if attempts remain + if (response.error && attempt < maxRetries) { + await sleep(1000); + continue; + } + + return response; + } catch (error) { + clearTimeout(timeoutId); + + // Retry on timeout/error if attempts remain + if (attempt < maxRetries) { + await sleep(1000); + continue; + } + + throw error instanceof Error + ? error + : new Error(`JSON-RPC call failed after ${timeout}ms`); + } + } + + // 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" }); @@ -101,3 +176,36 @@ export async function renewDHCPLease() { } 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. + timeout: 10000, + retriesOnError: 5, + }); + + 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; +}