mirror of https://github.com/jetkvm/kvm.git
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:
parent
9a4d061034
commit
ce9f95b8c8
|
|
@ -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
15
ota.go
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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 }),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 => {
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue