refactor: streamline version retrieval logic and enhance error handling in useVersion hook

This commit is contained in:
Adam Shiervani 2025-10-23 15:17:37 +00:00
parent 0bd2821924
commit 632125e38f
3 changed files with 166 additions and 79 deletions

View File

@ -1,23 +1,11 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useDeviceStore } from "@/hooks/stores"; 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 notifications from "@/notifications";
import { m } from "@localizations/messages.js"; 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() { export function useVersion() {
const { const {
appVersion, appVersion,
@ -25,51 +13,40 @@ export function useVersion() {
setAppVersion, setAppVersion,
setSystemVersion, setSystemVersion,
} = useDeviceStore(); } = useDeviceStore();
const { send } = useJsonRpc();
const getVersionInfo = useCallback(() => { const getVersionInfo = useCallback(async () => {
return new Promise<SystemVersionInfo>((resolve, reject) => { try {
send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { const result = await getUpdateStatus();
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); setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion); setSystemVersion(result.local.systemVersion);
return result;
if (result.error) { } catch (error) {
notifications.error(m.updates_failed_check({ error: String(result.error) })); const jsonRpcError = error as JsonRpcError;
reject(new Error("Failed to check for updates")); notifications.error(m.updates_failed_check({ error: jsonRpcError.message }));
} else { throw jsonRpcError;
resolve(result);
} }
} }, [setAppVersion, setSystemVersion]);
});
});
}, [send, setAppVersion, setSystemVersion]);
const getLocalVersion = useCallback(() => {
return new Promise<VersionInfo>((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); setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion); setSystemVersion(result.systemVersion);
resolve(result); return result;
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
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);
}, [send, setAppVersion, setSystemVersion, getVersionInfo]); notifications.error(m.updates_failed_get_device_version({ error: jsonRpcError.message }));
throw jsonRpcError;
}
}, [setAppVersion, setSystemVersion, getVersionInfo]);
return { return {
getVersionInfo, getVersionInfo,

View File

@ -4,13 +4,15 @@ import { useLocation, useNavigate } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores"; import { UpdateState, useUpdateStore } from "@hooks/stores";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { SystemVersionInfo, useVersion } from "@hooks/useVersion"; import { useVersion } from "@hooks/useVersion";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card"; import Card from "@components/Card";
import LoadingSpinner from "@components/LoadingSpinner"; 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 { m } from "@localizations/messages.js";
import { SystemVersionInfo } from "../utils/jsonrpc";
export default function SettingsGeneralUpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();

View File

@ -1,15 +1,17 @@
import { useRTCStore } from "@/hooks/stores"; import { useRTCStore } from "@/hooks/stores";
// JSON-RPC utility for use outside of React components // JSON-RPC utility for use outside of React components
export interface JsonRpcCallOptions { export interface JsonRpcCallOptions {
method: string; method: string;
params?: unknown; params?: unknown;
timeout?: number; timeout?: number;
retriesOnError?: number;
} }
export interface JsonRpcCallResponse { export interface JsonRpcCallResponse<T = unknown> {
jsonrpc: string; jsonrpc: string;
result?: unknown; result?: T;
error?: { error?: {
code: number; code: number;
message: string; message: string;
@ -20,16 +22,31 @@ export interface JsonRpcCallResponse {
let rpcCallCounter = 0; let rpcCallCounter = 0;
export function callJsonRpc(options: JsonRpcCallOptions): Promise<JsonRpcCallResponse> { // Helper: sleep utility for retry delays
return new Promise((resolve, reject) => { const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
// Access the RTC store directly outside of React context
const rpcDataChannel = useRTCStore.getState().rpcDataChannel;
if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { // Helper: wait for RTC data channel to be ready
reject(new Error("RPC data channel not available")); async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
return; 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<T>(
rpcDataChannel: RTCDataChannel,
options: JsonRpcCallOptions,
signal: AbortSignal,
): Promise<JsonRpcCallResponse<T>> {
return new Promise((resolve, reject) => {
rpcCallCounter++; rpcCallCounter++;
const requestId = `rpc_${Date.now()}_${rpcCallCounter}`; const requestId = `rpc_${Date.now()}_${rpcCallCounter}`;
@ -40,32 +57,90 @@ export function callJsonRpc(options: JsonRpcCallOptions): Promise<JsonRpcCallRes
id: requestId, id: requestId,
}; };
const timeout = options.timeout || 5000;
let timeoutId: number | undefined; // eslint-disable-line prefer-const
const messageHandler = (event: MessageEvent) => { const messageHandler = (event: MessageEvent) => {
try { try {
const response = JSON.parse(event.data) as JsonRpcCallResponse; const response = JSON.parse(event.data) as JsonRpcCallResponse<T>;
if (response.id === requestId) { if (response.id === requestId) {
clearTimeout(timeoutId); cleanup();
rpcDataChannel.removeEventListener("message", messageHandler);
resolve(response); resolve(response);
} }
} catch (error) { } catch {
// Ignore parse errors from other messages // Ignore parse errors from other messages
} }
}; };
timeoutId = setTimeout(() => { const abortHandler = () => {
rpcDataChannel.removeEventListener("message", messageHandler); cleanup();
reject(new Error(`JSON-RPC call timed out after ${timeout}ms`)); reject(new Error("Request aborted"));
}, timeout); };
const cleanup = () => {
rpcDataChannel.removeEventListener("message", messageHandler);
signal.removeEventListener("abort", abortHandler);
};
signal.addEventListener("abort", abortHandler);
rpcDataChannel.addEventListener("message", messageHandler); rpcDataChannel.addEventListener("message", messageHandler);
rpcDataChannel.send(JSON.stringify(request)); rpcDataChannel.send(JSON.stringify(request));
}); });
} }
// Function overloads for better typing
export function callJsonRpc<T>(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<T> & { result: T }>;
export function callJsonRpc(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<unknown>>;
export async function callJsonRpc<T = unknown>(
options: JsonRpcCallOptions,
): Promise<JsonRpcCallResponse<T>> {
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<T>(
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 // Specific network settings API calls
export async function getNetworkSettings() { export async function getNetworkSettings() {
const response = await callJsonRpc({ method: "getNetworkSettings" }); const response = await callJsonRpc({ method: "getNetworkSettings" });
@ -101,3 +176,36 @@ export async function renewDHCPLease() {
} }
return response.result; 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<SystemVersionInfo>({
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<VersionInfo>({ method: "getLocalVersion" });
if (response.error) throw response.error;
return response.result;
}