Skip to content
6 changes: 3 additions & 3 deletions network.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
}
Expand All @@ -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")
Expand Down
15 changes: 14 additions & 1 deletion ota.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/UsbDeviceSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 2 additions & 1 deletion ui/src/components/UsbInfoSetting.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("&");

Expand Down Expand Up @@ -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 }),
Expand Down
11 changes: 9 additions & 2 deletions ui/src/components/VideoOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion ui/src/hooks/stores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ interface JsonRpcResponse {

export type PostRebootAction = {
healthCheck: string;
redirectUrl: string;
redirectTo: string;
} | null;

// Utility function to append stats to a Map
Expand Down
176 changes: 94 additions & 82 deletions ui/src/hooks/useKeyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -31,8 +32,6 @@ export interface MacroStep {

export type MacroSteps = MacroStep[];

const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms));

export default function useKeyboard() {
const { send } = useJsonRpc();
const { rpcDataChannel } = useRTCStore();
Expand Down Expand Up @@ -97,24 +96,23 @@ export default function useKeyboard() {
[send, setKeysDownState],
);

const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => {
return await new Promise<void>((resolve, reject) => {
const abortListener = () => {
reject(new Error("Keyboard report aborted"));
};
const sendKeystrokeLegacy = useCallback(
async (keys: number[], modifier: number, ac?: AbortController) => {
return await new Promise<void>((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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
// 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]);
sendKeyboardMacroEventHidRpc(macro);
},
[sendKeyboardMacroEventHidRpc],
);

const executeMacroClientSide = useCallback(async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];
const executeMacroClientSide = useCallback(
async (steps: MacroSteps) => {
const promises: (() => Promise<void>)[] = [];

const ac = new AbortController();
setAbortController(ac);
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);
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) {
promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac));
promises.push(() => resetKeyboardState());
promises.push(() => sleep(step.delay || 100));
}
await promise();
}
}

return await new Promise<void>((resolve, reject) => {
// Set up abort listener
const abortListener = () => {
reject(new Error("Macro execution aborted"));
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);

runAll()
.then(() => {
ac.signal.removeEventListener("abort", abortListener);
resolve();
})
.catch((error) => {
ac.signal.removeEventListener("abort", abortListener);
reject(error);
});
});
}, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]);
return await new Promise<void>((resolve, reject) => {
// Set up abort listener
const abortListener = () => {
reject(new Error("Macro execution aborted"));
};

ac.signal.addEventListener("abort", abortListener);

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 executeMacro = useCallback(
async (steps: MacroSteps) => {
if (rpcHidReady) {
return executeMacroRemote(steps);
}
return executeMacroClientSide(steps);
},
[rpcHidReady, executeMacroRemote, executeMacroClientSide],
);

const cancelExecuteMacro = useCallback(async () => {
if (abortController.current) {
Expand All @@ -375,5 +381,11 @@ export default function useKeyboard() {
cancelOngoingKeyboardMacroHidRpc();
}, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]);

return { handleKeyPress, resetKeyboardState, executeMacro, cleanup, cancelExecuteMacro };
return {
handleKeyPress,
resetKeyboardState,
executeMacro,
cleanup,
cancelExecuteMacro,
};
}
Loading