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 <adamshiervani@fastmail.com>
This commit is contained in:
Adam Shiervani 2025-10-27 16:21:11 +01:00 committed by GitHub
parent 9a4d061034
commit ce9f95b8c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 354 additions and 208 deletions

View File

@ -29,7 +29,7 @@ func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig {
type PostRebootAction struct { type PostRebootAction struct {
HealthCheck string `json:"healthCheck"` HealthCheck string `json:"healthCheck"`
RedirectUrl string `json:"redirectUrl"` RedirectTo string `json:"redirectTo"`
} }
func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings {
@ -202,7 +202,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
if newIPv4Mode == "static" && oldIPv4Mode != "static" { if newIPv4Mode == "static" && oldIPv4Mode != "static" {
postRebootAction = &PostRebootAction{ postRebootAction = &PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), 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") 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 { newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
postRebootAction = &PostRebootAction{ postRebootAction = &PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), 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") l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required")

15
ota.go
View File

@ -489,9 +489,22 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if rebootNeeded { if rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update") 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{ postRebootAction := &PostRebootAction{
HealthCheck: "/device/status", HealthCheck: "/device/status",
RedirectUrl: "/settings/general/update?version=" + remote.SystemVersion, RedirectTo: redirectTo,
} }
if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil { if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil {

View File

@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { sleep } from "@/utils";
export interface USBConfig { export interface USBConfig {
vendor_id: string; vendor_id: string;
@ -108,7 +109,7 @@ export function UsbDeviceSetting() {
} }
// We need some time to ensure the USB devices are updated // We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000)); await sleep(2000);
setLoading(false); setLoading(false);
syncUsbDeviceConfig(); syncUsbDeviceConfig();
notifications.success(m.usb_device_updated()); notifications.success(m.usb_device_updated());

View File

@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); 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 // We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000)); await sleep(2000);
setLoading(false); setLoading(false);
notifications.success( notifications.success(
m.usb_config_set_success({ manufacturer: usbConfig.manufacturer, product: usbConfig.product }), m.usb_config_set_success({ manufacturer: usbConfig.manufacturer, product: usbConfig.product }),

View File

@ -474,8 +474,15 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro
if (response.ok) { if (response.ok) {
// Device is available, redirect to the specified URL // Device is available, redirect to the specified URL
console.log('Device is available, redirecting to:', postRebootAction.redirectUrl); console.log('Device is available, redirecting to:', postRebootAction.redirectTo);
window.location.href = postRebootAction.redirectUrl;
// 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(); window.location.reload();
} }
} catch (err) { } catch (err) {

View File

@ -21,7 +21,7 @@ interface JsonRpcResponse {
export type PostRebootAction = { export type PostRebootAction = {
healthCheck: string; healthCheck: string;
redirectUrl: string; redirectTo: string;
} | null; } | null;
// Utility function to append stats to a Map // Utility function to append stats to a Map

View File

@ -16,6 +16,7 @@ import {
import { useHidRpc } from "@/hooks/useHidRpc"; import { useHidRpc } from "@/hooks/useHidRpc";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings";
import { sleep } from "@/utils";
const MACRO_RESET_KEYBOARD_STATE = { const MACRO_RESET_KEYBOARD_STATE = {
keys: new Array(hidKeyBufferSize).fill(0), keys: new Array(hidKeyBufferSize).fill(0),
@ -31,8 +32,6 @@ export interface MacroStep {
export type MacroSteps = MacroStep[]; export type MacroSteps = MacroStep[];
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));
export default function useKeyboard() { export default function useKeyboard() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore(); const { rpcDataChannel } = useRTCStore();
@ -97,24 +96,23 @@ export default function useKeyboard() {
[send, setKeysDownState], [send, setKeysDownState],
); );
const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => { const sendKeystrokeLegacy = useCallback(
return await new Promise<void>((resolve, reject) => { async (keys: number[], modifier: number, ac?: AbortController) => {
const abortListener = () => { return await new Promise<void>((resolve, reject) => {
reject(new Error("Keyboard report aborted")); const abortListener = () => {
}; reject(new Error("Keyboard report aborted"));
};
ac?.signal?.addEventListener("abort", abortListener); ac?.signal?.addEventListener("abort", abortListener);
send( send("keyboardReport", { keys, modifier }, params => {
"keyboardReport",
{ keys, modifier },
params => {
if ("error" in params) return reject(params.error); if ("error" in params) return reject(params.error);
resolve(); resolve();
}, });
); });
}); },
}, [send]); [send],
);
const KEEPALIVE_INTERVAL = 50; const KEEPALIVE_INTERVAL = 50;
@ -149,7 +147,6 @@ export default function useKeyboard() {
} }
}, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]); }, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]);
// IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists
function simulateDeviceSideKeyHandlingForLegacyDevices( function simulateDeviceSideKeyHandlingForLegacyDevices(
state: KeysDownState, 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 we reach here it means we didn't find an empty slot or the key in the buffer
if (overrun) { if (overrun) {
if (press) { 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 // Fill all key slots with ErrorRollOver (0x01) to indicate overflow
keys.length = hidKeyBufferSize; keys.length = hidKeyBufferSize;
keys.fill(hidErrorRollOver); 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. // 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. // 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. // A small pause is added between steps to ensure that the device can process the events.
const executeMacroRemote = useCallback(async ( const executeMacroRemote = useCallback(
steps: MacroSteps, async (steps: MacroSteps) => {
) => { const macro: KeyboardMacroStep[] = [];
const macro: KeyboardMacroStep[] = [];
for (const [_, step] of steps.entries()) { for (const [_, step] of steps.entries()) {
const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean);
const modifierMask: number = (step.modifiers || []) 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 the step has keys and/or modifiers, press them and hold for the delay
if (keyValues.length > 0 || modifierMask > 0) { if (keyValues.length > 0 || modifierMask > 0) {
macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 });
macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 });
}
}
sendKeyboardMacroEventHidRpc(macro);
}, [sendKeyboardMacroEventHidRpc]);
const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];
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();
} }
}
return await new Promise<void>((resolve, reject) => { sendKeyboardMacroEventHidRpc(macro);
// Set up abort listener },
const abortListener = () => { [sendKeyboardMacroEventHidRpc],
reject(new Error("Macro execution aborted")); );
const executeMacroClientSide = useCallback(
async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];
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<void>((resolve, reject) => {
// Set up abort listener
const abortListener = () => {
reject(new Error("Macro execution aborted"));
};
runAll() ac.signal.addEventListener("abort", abortListener);
.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) => { runAll()
if (rpcHidReady) { .then(() => {
return executeMacroRemote(steps); ac.signal.removeEventListener("abort", abortListener);
} resolve();
return executeMacroClientSide(steps); })
}, [rpcHidReady, executeMacroRemote, executeMacroClientSide]); .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 () => { const cancelExecuteMacro = useCallback(async () => {
if (abortController.current) { if (abortController.current) {
@ -375,5 +381,11 @@ export default function useKeyboard() {
cancelOngoingKeyboardMacroHidRpc(); cancelOngoingKeyboardMacroHidRpc();
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]); }, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]);
return { handleKeyPress, resetKeyboardState, executeMacro, cleanup, cancelExecuteMacro }; return {
handleKeyPress,
resetKeyboardState,
executeMacro,
cleanup,
cancelExecuteMacro,
};
} }

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(() => {
return new Promise<SystemVersionInfo>((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) { const getVersionInfo = useCallback(async () => {
notifications.error(m.updates_failed_check({ error: String(result.error) })); try {
reject(new Error("Failed to check for updates")); const result = await getUpdateStatus();
} else { setAppVersion(result.local.appVersion);
resolve(result); setSystemVersion(result.local.systemVersion);
} return result;
} } catch (error) {
}); const jsonRpcError = error as JsonRpcError;
}); notifications.error(m.updates_failed_check({ error: jsonRpcError.message }));
}, [send, setAppVersion, setSystemVersion]); throw jsonRpcError;
}
}, [setAppVersion, setSystemVersion]);
const getLocalVersion = useCallback(() => { const getLocalVersion = useCallback(async () => {
return new Promise<VersionInfo>((resolve, reject) => { try {
send("getLocalVersion", {}, (resp: JsonRpcResponse) => { const result = await getLocalVersionRpc();
if ("error" in resp) { setAppVersion(result.appVersion);
console.log(resp.error) setSystemVersion(result.systemVersion);
if (resp.error.code === RpcMethodNotFound) { return result;
console.warn("Failed to get device version, using legacy version"); } catch (error: unknown) {
return getVersionInfo().then(result => resolve(result.local)).catch(reject); const jsonRpcError = error as JsonRpcError;
}
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;
setAppVersion(result.appVersion); if (jsonRpcError.code === RpcMethodNotFound) {
setSystemVersion(result.systemVersion); console.error("Failed to get local version, using legacy remote version");
resolve(result); const result = await getVersionInfo();
} return result.local;
}); }
});
}, [send, setAppVersion, setSystemVersion, getVersionInfo]); 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 { return {
getVersionInfo, getVersionInfo,
@ -77,4 +54,4 @@ export function useVersion() {
appVersion, appVersion,
systemVersion, systemVersion,
}; };
} }

View File

@ -4,12 +4,14 @@ 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 { sleep } from "@/utils";
import { SystemVersionInfo } from "@/utils/jsonrpc";
export default function SettingsGeneralUpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -134,13 +136,14 @@ function LoadingState({
}, 0); }, 0);
getVersionInfo() getVersionInfo()
.then(versionInfo => { .then(async versionInfo => {
// Add a small delay to ensure it's not just flickering // 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 => { .then(versionInfo => {
if (!signal.aborted) { if (!signal.aborted) {
onFinished(versionInfo as SystemVersionInfo); onFinished(versionInfo);
} }
}) })
.catch(error => { .catch(error => {

View File

@ -677,6 +677,13 @@ export default function KvmIdRoute() {
return; 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); const currentUrl = new URL(window.location.href);
currentUrl.search = ""; currentUrl.search = "";
currentUrl.searchParams.set("updateSuccess", "true"); currentUrl.searchParams.set("updateSuccess", "true");

View File

@ -1,7 +1,6 @@
import { KeySequence } from "@hooks/stores"; 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 { m } from "@localizations/messages.js";
import { locales } from '@localizations/runtime.js';
export const formatters = { export const formatters = {
date: (date: Date, options?: Intl.DateTimeFormatOptions) => date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
@ -47,14 +46,14 @@ export const formatters = {
amount: number; amount: number;
name: Intl.RelativeTimeFormatUnit; name: Intl.RelativeTimeFormatUnit;
}[] = [ }[] = [
{ amount: 60, name: "seconds" }, { amount: 60, name: "seconds" },
{ amount: 60, name: "minutes" }, { amount: 60, name: "minutes" },
{ amount: 24, name: "hours" }, { amount: 24, name: "hours" },
{ amount: 7, name: "days" }, { amount: 7, name: "days" },
{ amount: 4.34524, name: "weeks" }, { amount: 4.34524, name: "weeks" },
{ amount: 12, name: "months" }, { amount: 12, name: "months" },
{ amount: Number.POSITIVE_INFINITY, name: "years" }, { amount: Number.POSITIVE_INFINITY, name: "years" },
]; ];
let duration = (date.valueOf() - new Date().valueOf()) / 1000; let duration = (date.valueOf() - new Date().valueOf()) / 1000;
@ -255,27 +254,41 @@ export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] {
...macro, ...macro,
sortOrder: index + 1, 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] { export function map_locale_code_to_name(
// the first is the name in the current app locale (e.g. Inglese), currentLocale: LocaleCode,
// the second is the name in the language of the locale itself (e.g. English) locale: string,
switch (locale) { ): [string, string] {
case '': return [m.locale_auto(), ""]; // the first is the name in the current app locale (e.g. Inglese),
case 'en': return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })]; // the second is the name in the language of the locale itself (e.g. English)
case 'da': return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })]; switch (locale) {
case 'de': return [m.locale_de({}, { locale: currentLocale }), m.locale_de({}, { locale })]; case "":
case 'es': return [m.locale_es({}, { locale: currentLocale }), m.locale_es({}, { locale })]; return [m.locale_auto(), ""];
case 'fr': return [m.locale_fr({}, { locale: currentLocale }), m.locale_fr({}, { locale })]; case "en":
case 'it': return [m.locale_it({}, { locale: currentLocale }), m.locale_it({}, { locale })]; return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })];
case 'nb': return [m.locale_nb({}, { locale: currentLocale }), m.locale_nb({}, { locale })]; case "da":
case 'sv': return [m.locale_sv({}, { locale: currentLocale }), m.locale_sv({}, { locale })]; return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })];
case 'zh': return [m.locale_zh({}, { locale: currentLocale }), m.locale_zh({}, { locale })]; case "de":
default: return [locale, ""]; 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 = "/") { export function deleteCookie(name: string, domain?: string, path = "/") {
const domainPart = domain ? `; domain=${domain}` : ""; 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}`; document.cookie = `${name}=; path=${path}; max-age=0${domainPart}`;
// fallback: set an expires in the past for older agents // 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}`; document.cookie = `${name}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainPart}`;
} }
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -1,15 +1,18 @@
import { useRTCStore } from "@/hooks/stores"; import { useRTCStore } from "@/hooks/stores";
import { sleep } from "@/utils";
// 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; attemptTimeoutMs?: number;
maxAttempts?: 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 +23,28 @@ export interface JsonRpcCallResponse {
let rpcCallCounter = 0; let rpcCallCounter = 0;
export function callJsonRpc(options: JsonRpcCallOptions): Promise<JsonRpcCallResponse> { // Helper: wait for RTC data channel to be ready
return new Promise((resolve, reject) => { async function waitForRtcReady(signal: AbortSignal): Promise<RTCDataChannel> {
// Access the RTC store directly outside of React context const pollInterval = 100;
const rpcDataChannel = useRTCStore.getState().rpcDataChannel;
if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { while (!signal.aborted) {
reject(new Error("RPC data channel not available")); const state = useRTCStore.getState();
return; 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 +55,93 @@ 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 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<T>(
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 // 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 +177,35 @@ 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.
maxAttempts: 6,
});
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;
}