From ce9f95b8c8cde04163bebf3ee94120636e0a69e7 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 27 Oct 2025 16:21:11 +0100 Subject: [PATCH] refactor: ota redirecting (#898) * refactor: improve URL handling in RebootingOverlay component * refactor: enhance redirect URL handling in TryUpdate function * refactor: disable old ota rebooting method in new version * refactor: streamline version retrieval logic and enhance error handling in useVersion hook * refactor: rename to RedirectTo * fix: force page reload when redirecting from reboot actions * refactor: consolidate sleep utility and update usages across components * refactor: update JsonRpcCallOptions to use maxAttempts and attemptTimeoutMs, implement exponential backoff for retries --------- Co-authored-by: Adam Shiervani --- network.go | 6 +- ota.go | 15 +- ui/src/components/UsbDeviceSetting.tsx | 3 +- ui/src/components/UsbInfoSetting.tsx | 3 +- ui/src/components/VideoOverlay.tsx | 11 +- ui/src/hooks/stores.ts | 2 +- ui/src/hooks/useKeyboard.ts | 186 ++++++++++-------- ui/src/hooks/useVersion.tsx | 91 ++++----- .../devices.$id.settings.general.update.tsx | 13 +- ui/src/routes/devices.$id.tsx | 7 + ui/src/utils.ts | 75 ++++--- ui/src/utils/jsonrpc.ts | 150 ++++++++++++-- 12 files changed, 354 insertions(+), 208 deletions(-) diff --git a/network.go b/network.go index 83eae429..846f41f1 100644 --- a/network.go +++ b/network.go @@ -29,7 +29,7 @@ func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig { type PostRebootAction struct { HealthCheck string `json:"healthCheck"` - RedirectUrl string `json:"redirectUrl"` + RedirectTo string `json:"redirectTo"` } func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { @@ -202,7 +202,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re if newIPv4Mode == "static" && oldIPv4Mode != "static" { postRebootAction = &PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), - RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 mode changed to static, reboot required") } @@ -219,7 +219,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { postRebootAction = &PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), - RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required") diff --git a/ota.go b/ota.go index 41bfea96..65a67517 100644 --- a/ota.go +++ b/ota.go @@ -489,9 +489,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err if rebootNeeded { scopedLogger.Info().Msg("System Rebooting due to OTA update") + // Build redirect URL with conditional query parameters + redirectTo := "/settings/general/update" + queryParams := url.Values{} + if systemUpdateAvailable { + queryParams.Set("systemVersion", remote.SystemVersion) + } + if appUpdateAvailable { + queryParams.Set("appVersion", remote.AppVersion) + } + if len(queryParams) > 0 { + redirectTo += "?" + queryParams.Encode() + } + postRebootAction := &PostRebootAction{ HealthCheck: "/device/status", - RedirectUrl: "/settings/general/update?version=" + remote.SystemVersion, + RedirectTo: redirectTo, } if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil { diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 006159cf..2fd6eaeb 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; import Fieldset from "@components/Fieldset"; import notifications from "@/notifications"; +import { sleep } from "@/utils"; export interface USBConfig { vendor_id: string; @@ -108,7 +109,7 @@ export function UsbDeviceSetting() { } // We need some time to ensure the USB devices are updated - await new Promise(resolve => setTimeout(resolve, 2000)); + await sleep(2000); setLoading(false); syncUsbDeviceConfig(); notifications.success(m.usb_device_updated()); diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index dc3aa277..13b185cd 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); @@ -123,7 +124,7 @@ export function UsbInfoSetting() { } // We need some time to ensure the USB devices are updated - await new Promise(resolve => setTimeout(resolve, 2000)); + await sleep(2000); setLoading(false); notifications.success( m.usb_config_set_success({ manufacturer: usbConfig.manufacturer, product: usbConfig.product }), diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index e59c0987..2f8c26e5 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -474,8 +474,15 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro if (response.ok) { // Device is available, redirect to the specified URL - console.log('Device is available, redirecting to:', postRebootAction.redirectUrl); - window.location.href = postRebootAction.redirectUrl; + console.log('Device is available, redirecting to:', postRebootAction.redirectTo); + + // URL constructor handles all cases elegantly: + // - Absolute paths: resolved against current origin + // - Protocol-relative URLs: resolved with current protocol + // - Fully qualified URLs: used as-is + const targetUrl = new URL(postRebootAction.redirectTo, window.location.origin); + + window.location.href = targetUrl.href; window.location.reload(); } } catch (err) { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 052c8d9a..7157354f 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -21,7 +21,7 @@ interface JsonRpcResponse { export type PostRebootAction = { healthCheck: string; - redirectUrl: string; + redirectTo: string; } | null; // Utility function to append stats to a Map diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index ee9573b5..4c4d2d43 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -16,6 +16,7 @@ import { import { useHidRpc } from "@/hooks/useHidRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; +import { sleep } from "@/utils"; const MACRO_RESET_KEYBOARD_STATE = { keys: new Array(hidKeyBufferSize).fill(0), @@ -31,8 +32,6 @@ export interface MacroStep { export type MacroSteps = MacroStep[]; -const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); - export default function useKeyboard() { const { send } = useJsonRpc(); const { rpcDataChannel } = useRTCStore(); @@ -97,24 +96,23 @@ export default function useKeyboard() { [send, setKeysDownState], ); - const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => { - return await new Promise((resolve, reject) => { - const abortListener = () => { - reject(new Error("Keyboard report aborted")); - }; + const sendKeystrokeLegacy = useCallback( + async (keys: number[], modifier: number, ac?: AbortController) => { + return await new Promise((resolve, reject) => { + const abortListener = () => { + reject(new Error("Keyboard report aborted")); + }; - ac?.signal?.addEventListener("abort", abortListener); + ac?.signal?.addEventListener("abort", abortListener); - send( - "keyboardReport", - { keys, modifier }, - params => { + send("keyboardReport", { keys, modifier }, params => { if ("error" in params) return reject(params.error); resolve(); - }, - ); - }); - }, [send]); + }); + }); + }, + [send], + ); const KEEPALIVE_INTERVAL = 50; @@ -149,7 +147,6 @@ export default function useKeyboard() { } }, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]); - // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists function simulateDeviceSideKeyHandlingForLegacyDevices( state: KeysDownState, @@ -200,7 +197,9 @@ export default function useKeyboard() { // If we reach here it means we didn't find an empty slot or the key in the buffer if (overrun) { if (press) { - console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`); + console.warn( + `keyboard buffer overflow current keys ${keys}, key: ${key} not added`, + ); // Fill all key slots with ErrorRollOver (0x01) to indicate overflow keys.length = hidKeyBufferSize; keys.fill(hidErrorRollOver); @@ -284,85 +283,92 @@ export default function useKeyboard() { // After the delay, the keys and modifiers are released and the next step is executed. // If a step has no keys or modifiers, it is treated as a delay-only step. // A small pause is added between steps to ensure that the device can process the events. - const executeMacroRemote = useCallback(async ( - steps: MacroSteps, - ) => { - const macro: KeyboardMacroStep[] = []; + const executeMacroRemote = useCallback( + async (steps: MacroSteps) => { + const macro: KeyboardMacroStep[] = []; - for (const [_, step] of steps.entries()) { - const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); - const modifierMask: number = (step.modifiers || []) + for (const [_, step] of steps.entries()) { + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []) - .map(mod => modifiers[mod]) + .map(mod => modifiers[mod]) - .reduce((acc, val) => acc + val, 0); + .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { - macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); - macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); - } - } - - sendKeyboardMacroEventHidRpc(macro); - }, [sendKeyboardMacroEventHidRpc]); - - const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { - const promises: (() => Promise)[] = []; - - const ac = new AbortController(); - setAbortController(ac); - - for (const [_, step] of steps.entries()) { - const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); - const modifierMask: number = (step.modifiers || []) - .map(mod => modifiers[mod]) - .reduce((acc, val) => acc + val, 0); - - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { - promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); - promises.push(() => resetKeyboardState()); - promises.push(() => sleep(step.delay || 100)); - } - } - - const runAll = async () => { - for (const promise of promises) { - // Check if we've been aborted before executing each promise - if (ac.signal.aborted) { - throw new Error("Macro execution aborted"); + // If the step has keys and/or modifiers, press them and hold for the delay + if (keyValues.length > 0 || modifierMask > 0) { + macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); + macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); } - await promise(); } - } - return await new Promise((resolve, reject) => { - // Set up abort listener - const abortListener = () => { - reject(new Error("Macro execution aborted")); + sendKeyboardMacroEventHidRpc(macro); + }, + [sendKeyboardMacroEventHidRpc], + ); + + const executeMacroClientSide = useCallback( + async (steps: MacroSteps) => { + const promises: (() => Promise)[] = []; + + const ac = new AbortController(); + setAbortController(ac); + + for (const [_, step] of steps.entries()) { + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []) + .map(mod => modifiers[mod]) + .reduce((acc, val) => acc + val, 0); + + // If the step has keys and/or modifiers, press them and hold for the delay + if (keyValues.length > 0 || modifierMask > 0) { + promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); + promises.push(() => resetKeyboardState()); + promises.push(() => sleep(step.delay || 100)); + } + } + + const runAll = async () => { + for (const promise of promises) { + // Check if we've been aborted before executing each promise + if (ac.signal.aborted) { + throw new Error("Macro execution aborted"); + } + await promise(); + } }; - ac.signal.addEventListener("abort", abortListener); + return await new Promise((resolve, reject) => { + // Set up abort listener + const abortListener = () => { + reject(new Error("Macro execution aborted")); + }; - runAll() - .then(() => { - ac.signal.removeEventListener("abort", abortListener); - resolve(); - }) - .catch((error) => { - ac.signal.removeEventListener("abort", abortListener); - reject(error); - }); - }); - }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); + ac.signal.addEventListener("abort", abortListener); - const executeMacro = useCallback(async (steps: MacroSteps) => { - if (rpcHidReady) { - return executeMacroRemote(steps); - } - return executeMacroClientSide(steps); - }, [rpcHidReady, executeMacroRemote, executeMacroClientSide]); + runAll() + .then(() => { + ac.signal.removeEventListener("abort", abortListener); + resolve(); + }) + .catch(error => { + ac.signal.removeEventListener("abort", abortListener); + reject(error); + }); + }); + }, + [sendKeystrokeLegacy, resetKeyboardState, setAbortController], + ); + + const executeMacro = useCallback( + async (steps: MacroSteps) => { + if (rpcHidReady) { + return executeMacroRemote(steps); + } + return executeMacroClientSide(steps); + }, + [rpcHidReady, executeMacroRemote, executeMacroClientSide], + ); const cancelExecuteMacro = useCallback(async () => { if (abortController.current) { @@ -375,5 +381,11 @@ export default function useKeyboard() { cancelOngoingKeyboardMacroHidRpc(); }, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]); - return { handleKeyPress, resetKeyboardState, executeMacro, cleanup, cancelExecuteMacro }; + return { + handleKeyPress, + resetKeyboardState, + executeMacro, + cleanup, + cancelExecuteMacro, + }; } 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..45c3235b 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -4,12 +4,14 @@ 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 { sleep } from "@/utils"; +import { SystemVersionInfo } from "@/utils/jsonrpc"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); @@ -134,13 +136,14 @@ function LoadingState({ }, 0); getVersionInfo() - .then(versionInfo => { + .then(async versionInfo => { // Add a small delay to ensure it's not just flickering - return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600)); + await sleep(600); + return versionInfo }) .then(versionInfo => { if (!signal.aborted) { - onFinished(versionInfo as SystemVersionInfo); + onFinished(versionInfo); } }) .catch(error => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index bf73902e..2a0de491 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -677,6 +677,13 @@ export default function KvmIdRoute() { return; } + + // This is to prevent the otaState from handling page refreshes after an update + // We've recently implemented a new general rebooting flow, so we don't need to handle this specific ota-rebooting case + // However, with old devices, we wont get the `willReboot` message, so we need to keep this for backwards compatibility + // only for the cloud version with an old device + if (rebootState?.isRebooting) return; + const currentUrl = new URL(window.location.href); currentUrl.search = ""; currentUrl.searchParams.set("updateSuccess", "true"); diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 1e73ada0..29d31ac1 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -1,7 +1,6 @@ import { KeySequence } from "@hooks/stores"; -import { getLocale } from '@localizations/runtime.js'; +import { getLocale , locales } from "@localizations/runtime.js"; import { m } from "@localizations/messages.js"; -import { locales } from '@localizations/runtime.js'; export const formatters = { date: (date: Date, options?: Intl.DateTimeFormatOptions) => @@ -47,14 +46,14 @@ export const formatters = { amount: number; name: Intl.RelativeTimeFormatUnit; }[] = [ - { amount: 60, name: "seconds" }, - { amount: 60, name: "minutes" }, - { amount: 24, name: "hours" }, - { amount: 7, name: "days" }, - { amount: 4.34524, name: "weeks" }, - { amount: 12, name: "months" }, - { amount: Number.POSITIVE_INFINITY, name: "years" }, - ]; + { amount: 60, name: "seconds" }, + { amount: 60, name: "minutes" }, + { amount: 24, name: "hours" }, + { amount: 7, name: "days" }, + { amount: 4.34524, name: "weeks" }, + { amount: 12, name: "months" }, + { amount: Number.POSITIVE_INFINITY, name: "years" }, + ]; let duration = (date.valueOf() - new Date().valueOf()) / 1000; @@ -255,27 +254,41 @@ export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] { ...macro, sortOrder: index + 1, })); -}; +} -type LocaleCode = typeof locales[number]; +type LocaleCode = (typeof locales)[number]; -export function map_locale_code_to_name(currentLocale: LocaleCode, locale: string): [string, string] { - // the first is the name in the current app locale (e.g. Inglese), - // the second is the name in the language of the locale itself (e.g. English) - switch (locale) { - case '': return [m.locale_auto(), ""]; - case 'en': return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })]; - case 'da': return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })]; - case 'de': return [m.locale_de({}, { locale: currentLocale }), m.locale_de({}, { locale })]; - case 'es': return [m.locale_es({}, { locale: currentLocale }), m.locale_es({}, { locale })]; - case 'fr': return [m.locale_fr({}, { locale: currentLocale }), m.locale_fr({}, { locale })]; - case 'it': return [m.locale_it({}, { locale: currentLocale }), m.locale_it({}, { locale })]; - case 'nb': return [m.locale_nb({}, { locale: currentLocale }), m.locale_nb({}, { locale })]; - case 'sv': return [m.locale_sv({}, { locale: currentLocale }), m.locale_sv({}, { locale })]; - case 'zh': return [m.locale_zh({}, { locale: currentLocale }), m.locale_zh({}, { locale })]; - default: return [locale, ""]; - } +export function map_locale_code_to_name( + currentLocale: LocaleCode, + locale: string, +): [string, string] { + // the first is the name in the current app locale (e.g. Inglese), + // the second is the name in the language of the locale itself (e.g. English) + switch (locale) { + case "": + return [m.locale_auto(), ""]; + case "en": + return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })]; + case "da": + return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })]; + case "de": + return [m.locale_de({}, { locale: currentLocale }), m.locale_de({}, { locale })]; + case "es": + return [m.locale_es({}, { locale: currentLocale }), m.locale_es({}, { locale })]; + case "fr": + return [m.locale_fr({}, { locale: currentLocale }), m.locale_fr({}, { locale })]; + case "it": + return [m.locale_it({}, { locale: currentLocale }), m.locale_it({}, { locale })]; + case "nb": + return [m.locale_nb({}, { locale: currentLocale }), m.locale_nb({}, { locale })]; + case "sv": + return [m.locale_sv({}, { locale: currentLocale }), m.locale_sv({}, { locale })]; + case "zh": + return [m.locale_zh({}, { locale: currentLocale }), m.locale_zh({}, { locale })]; + default: + return [locale, ""]; } +} export function deleteCookie(name: string, domain?: string, path = "/") { const domainPart = domain ? `; domain=${domain}` : ""; @@ -283,4 +296,8 @@ export function deleteCookie(name: string, domain?: string, path = "/") { document.cookie = `${name}=; path=${path}; max-age=0${domainPart}`; // fallback: set an expires in the past for older agents document.cookie = `${name}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainPart}`; -} \ No newline at end of file +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index ecfa1c4b..18659f00 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -1,15 +1,18 @@ 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; - timeout?: number; + attemptTimeoutMs?: number; + maxAttempts?: number; } -export interface JsonRpcCallResponse { +export interface JsonRpcCallResponse { jsonrpc: string; - result?: unknown; + result?: T; error?: { code: number; message: string; @@ -20,16 +23,28 @@ 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: wait for RTC data channel to be ready +async function waitForRtcReady(signal: AbortSignal): Promise { + const pollInterval = 100; - if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { - reject(new Error("RPC data channel not available")); - return; + 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 +55,93 @@ 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 maxAttempts = options.maxAttempts ?? 1; + const timeout = options.attemptTimeoutMs || 5000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), timeout); + + // 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); + + 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 < maxAttempts - 1) { + await sleep(backoffMs); + continue; + } + + return response; + } catch (error) { + clearTimeout(timeoutId); + + // 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`); + } + } + + // 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 +177,35 @@ 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. + 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; +}