Merge branch 'dev' into feat/audio-support

This commit is contained in:
Alex P 2025-10-30 01:56:48 +02:00
commit 516a953f41
22 changed files with 203 additions and 105 deletions

View File

@ -181,6 +181,7 @@ func getDefaultConfig() Config {
}(),
DefaultLogLevel: "INFO",
AudioOutputSource: "usb",
VideoQualityFactor: 1.0,
}
}

View File

@ -305,11 +305,11 @@ func wakeDisplay(force bool, reason string) {
displayLogger.Warn().Err(err).Msg("failed to wake display")
}
if config.DisplayDimAfterSec != 0 {
if config.DisplayDimAfterSec != 0 && dimTicker != nil {
dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
}
if config.DisplayOffAfterSec != 0 {
if config.DisplayOffAfterSec != 0 && offTicker != nil {
offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
}
backlightState = 0

View File

@ -368,7 +368,7 @@ void jetkvm_video_stop() {
}
int jetkvm_video_set_quality_factor(float quality_factor) {
if (quality_factor < 0 || quality_factor > 1) {
if (quality_factor <= 0 || quality_factor > 1) {
return -1;
}
video_set_quality_factor(quality_factor);

View File

@ -235,7 +235,7 @@ int video_init(float factor)
{
detect_sleep_mode();
if (factor < 0 || factor > 1) {
if (factor <= 0 || factor > 1) {
factor = 1.0f;
}
quality_factor = factor;

View File

@ -69,7 +69,7 @@ func NewNative(opts NativeOptions) *Native {
sleepModeSupported := isSleepModeSupported()
defaultQualityFactor := opts.DefaultQualityFactor
if defaultQualityFactor < 0 || defaultQualityFactor > 1 {
if defaultQualityFactor <= 0 || defaultQualityFactor > 1 {
defaultQualityFactor = 1.0
}

View File

@ -177,10 +177,8 @@ func rpcReboot(force bool) error {
return hwReboot(force, nil, 0)
}
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
return streamFactor, nil
return config.VideoQualityFactor, nil
}
func rpcSetStreamQualityFactor(factor float64) error {
@ -190,7 +188,10 @@ func rpcSetStreamQualityFactor(factor float64) error {
return err
}
streamFactor = factor
config.VideoQualityFactor = factor
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}

25
main.go
View File

@ -14,6 +14,7 @@ import (
var appCtx context.Context
func Main() {
logger.Log().Msg("JetKVM Starting Up")
LoadConfig()
var cancel context.CancelFunc
@ -80,16 +81,16 @@ func Main() {
startVideoSleepModeTicker()
go func() {
// wait for 15 minutes before starting auto-update checks
// this is to avoid interfering with initial setup processes
// and to ensure the system is stable before checking for updates
time.Sleep(15 * time.Minute)
for {
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
return
}
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
time.Sleep(30 * time.Second)
for {
logger.Info().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("auto-update check")
if !config.AutoUpdateEnabled {
logger.Debug().Msg("auto-update disabled")
time.Sleep(5 * time.Minute) // we'll check if auto-updates are enabled in five minutes
continue
}
@ -99,6 +100,12 @@ func Main() {
continue
}
if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() {
logger.Debug().Msg("system time is not synced, will retry in 30 seconds")
time.Sleep(30 * time.Second)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
@ -108,6 +115,7 @@ func Main() {
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
@ -124,6 +132,7 @@ func Main() {
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info().Msg("JetKVM Shutting Down")
stopAudio()

40
ota.go
View File

@ -176,7 +176,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
if nr > 0 {
nw, ew := file.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
return fmt.Errorf("short file write: %d < %d", nw, nr)
}
written += int64(nw)
if ew != nil {
@ -240,7 +240,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
if nr > 0 {
nw, ew := hash.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
return fmt.Errorf("short hash write: %d < %d", nw, nr)
}
verified += int64(nw)
if ew != nil {
@ -260,11 +260,16 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
}
}
hashSum := hash.Sum(nil)
scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
// close the file so we can rename below
if err := fileToHash.Close(); err != nil {
return fmt.Errorf("error closing file: %w", err)
}
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
hashSum := hex.EncodeToString(hash.Sum(nil))
scopedLogger.Info().Str("path", path).Str("hash", hashSum).Msg("SHA256 hash of")
if hashSum != expectedHash {
return fmt.Errorf("hash mismatch: %s != %s", hashSum, expectedHash)
}
if err := os.Rename(unverifiedPath, path); err != nil {
@ -313,7 +318,7 @@ func triggerOTAStateUpdate() {
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Bool("includePreRelease", includePreRelease).
Logger()
scopedLogger.Info().Msg("Trying to update...")
@ -362,8 +367,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error downloading app update: %w", err)
}
downloadFinished := time.Now()
otaState.AppDownloadFinishedAt = &downloadFinished
otaState.AppDownloadProgress = 1
@ -379,17 +385,21 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error verifying app update: %w", err)
}
verifyFinished := time.Now()
otaState.AppVerifiedAt = &verifyFinished
otaState.AppVerificationProgress = 1
triggerOTAStateUpdate()
otaState.AppUpdatedAt = &verifyFinished
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("App update downloaded")
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("App is up to date")
}
@ -405,8 +415,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error downloading system update: %w", err)
}
downloadFinished := time.Now()
otaState.SystemDownloadFinishedAt = &downloadFinished
otaState.SystemDownloadProgress = 1
@ -422,8 +433,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
return fmt.Errorf("error verifying system update: %w", err)
}
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
@ -439,8 +451,10 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -475,13 +489,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("System is up to date")
}

View File

@ -111,6 +111,7 @@ type Client struct {
var (
defaultTimerDuration = 1 * time.Second
defaultLinkUpTimeout = 30 * time.Second
defaultDHCPTimeout = 5 * time.Second // DHCP request timeout (not link up timeout)
maxRenewalAttemptDuration = 2 * time.Hour
)
@ -125,11 +126,11 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge
}
if cfg.Timeout == 0 {
cfg.Timeout = defaultLinkUpTimeout
cfg.Timeout = defaultDHCPTimeout
}
if cfg.Retries == 0 {
cfg.Retries = 3
cfg.Retries = 4
}
return &Client{
@ -153,9 +154,15 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge
}, nil
}
func resetTimer(t *time.Timer, l *zerolog.Logger) {
l.Debug().Dur("delay", defaultTimerDuration).Msg("will retry later")
t.Reset(defaultTimerDuration)
func resetTimer(t *time.Timer, attempt int, l *zerolog.Logger) {
// Exponential backoff: 1s, 2s, 4s, 8s, max 8s
backoffAttempt := attempt
if backoffAttempt > 3 {
backoffAttempt = 3
}
delay := time.Duration(1<<backoffAttempt) * time.Second
l.Debug().Dur("delay", delay).Int("attempt", attempt).Msg("will retry later")
t.Reset(delay)
}
func getRenewalTime(lease *Lease) time.Duration {
@ -168,12 +175,14 @@ func getRenewalTime(lease *Lease) time.Duration {
func (c *Client) requestLoop(t *time.Timer, family int, ifname string) {
l := c.l.With().Str("interface", ifname).Int("family", family).Logger()
attempt := 0
for range t.C {
l.Info().Msg("requesting lease")
l.Info().Int("attempt", attempt).Msg("requesting lease")
if _, err := c.ensureInterfaceUp(ifname); err != nil {
l.Error().Err(err).Msg("failed to ensure interface up")
resetTimer(t, c.l)
l.Error().Err(err).Int("attempt", attempt).Msg("failed to ensure interface up")
resetTimer(t, attempt, c.l)
attempt++
continue
}
@ -188,11 +197,14 @@ func (c *Client) requestLoop(t *time.Timer, family int, ifname string) {
lease, err = c.requestLease6(ifname)
}
if err != nil {
l.Error().Err(err).Msg("failed to request lease")
resetTimer(t, c.l)
l.Error().Err(err).Int("attempt", attempt).Msg("failed to request lease")
resetTimer(t, attempt, c.l)
attempt++
continue
}
// Successfully obtained lease, reset attempt counter
attempt = 0
c.handleLeaseChange(lease)
nextRenewal := getRenewalTime(lease)

View File

@ -26,6 +26,31 @@ show_help() {
echo " $0 -r 192.168.0.17 -u admin"
}
# Function to check if device is pingable
check_ping() {
local host=$1
msg_info "▶ Checking if device is reachable at ${host}..."
if ! ping -c 3 -W 5 "${host}" > /dev/null 2>&1; then
msg_err "Error: Cannot reach device at ${host}"
msg_err "Please verify the IP address and network connectivity"
exit 1
fi
msg_info "✓ Device is reachable"
}
# Function to check if SSH is accessible
check_ssh() {
local user=$1
local host=$2
msg_info "▶ Checking SSH connectivity to ${user}@${host}..."
if ! ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${user}@${host}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
msg_err "Error: Cannot establish SSH connection to ${user}@${host}"
msg_err "Please verify SSH access and credentials"
exit 1
fi
msg_info "✓ SSH connection successful"
}
# Default values
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
REMOTE_USER="root"
@ -113,6 +138,10 @@ if [ -z "$REMOTE_HOST" ]; then
exit 1
fi
# Check device connectivity before proceeding
check_ping "${REMOTE_HOST}"
check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
# check if the current CPU architecture is x86_64
if [ "$(uname -m)" != "x86_64" ]; then
msg_warn "Warning: This script is only supported on x86_64 architecture"
@ -147,10 +176,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
@ -193,10 +222,10 @@ then
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev \
@ -205,21 +234,21 @@ else
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -229,6 +258,17 @@ export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Wait until both binaries are killed, max 10 seconds
i=1
while [ \$i -le 10 ]; do
echo "Waiting for jetkvm_app and jetkvm_app_debug to be killed, \$i/10 ..."
if ! pgrep -f "jetkvm_app" > /dev/null && ! pgrep -f "jetkvm_app_debug" > /dev/null; then
break
fi
sleep 1
i=\$((i + 1))
done
# Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}"

View File

@ -212,7 +212,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
Button.displayName = "Button";
type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean, reloadDocument?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx(
"group outline-hidden",
@ -230,7 +230,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => {
);
} else {
return (
<Link to={to} className={classes}>
<Link to={to} reloadDocument={props.reloadDocument} className={classes}>
<ButtonContent {...props} />
</Link>
);

View File

@ -13,6 +13,7 @@ import { useRTCStore, PostRebootAction } from "@/hooks/stores";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import { isOnDevice } from "@/main";
import { sleep } from "@/utils";
interface OverlayContentProps {
@ -481,8 +482,11 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro
// - Protocol-relative URLs: resolved with current protocol
// - Fully qualified URLs: used as-is
const targetUrl = new URL(postRebootAction.redirectTo, window.location.origin);
clearInterval(intervalId); // Stop polling before redirect
window.location.href = targetUrl.href;
// Add 1s delay between setting location.href and calling reload() to prevent reload from interrupting the navigation.
await sleep(1000);
window.location.reload();
}
} catch (err) {

View File

@ -589,14 +589,18 @@ export interface OtaState {
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
otaState: OtaState;
setOtaState: (state: OtaState) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: UpdateModalViews
setModalView: (view: UpdateModalViews) => void;
setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null;
setUpdateErrorMessage: (errorMessage: string) => void;
}
export const useUpdateStore = create<UpdateState>(set => ({
@ -627,8 +631,10 @@ export const useUpdateStore = create<UpdateState>(set => ({
updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
}));

View File

@ -74,10 +74,10 @@ export async function checkDeviceAuth() {
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
if (!res.isSetup) throw redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.status === 401) throw redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
@ -87,7 +87,7 @@ export async function checkDeviceAuth() {
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
return isOnDevice ? checkDeviceAuth() : checkCloudAuth();
}
let router;

View File

@ -58,7 +58,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
return { user };
}
};

View File

@ -54,7 +54,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => {
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
return { user };
}
};

View File

@ -98,7 +98,7 @@ export default function SettingsAccessIndexRoute() {
}
getCloudState();
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
// In cloud mode, we need to navigate to the device overview page, as we don't have a connection anymore
if (!isOnDevice) navigate("/");
return;
});

View File

@ -9,11 +9,17 @@ export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate();
const { send } = useJsonRpc();
const onClose = useCallback(() => {
navigate(".."); // back to the devices.$id.settings page
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
}, [navigate]);
const onConfirmUpdate = useCallback(() => {
send("reboot", { force: true});
}, [send]);
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({

View File

@ -21,6 +21,11 @@ export default function SettingsGeneralUpdateRoute() {
const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const onClose = useCallback(() => {
navigate(".."); // back to the devices.$id.settings page
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
}, [navigate]);
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
@ -36,9 +41,9 @@ export default function SettingsGeneralUpdateRoute() {
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({

View File

@ -46,10 +46,10 @@ export default function SettingsHardwareRoute() {
}
setBacklightSettings(settings);
handleBacklightSettingsSave();
handleBacklightSettingsSave(settings);
};
const handleBacklightSettingsSave = () => {
const handleBacklightSettingsSave = (backlightSettings: BacklightSettings) => {
send("setBacklightSettings", { params: backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(

View File

@ -1,7 +1,6 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Outlet,
redirect,
useLoaderData,
useLocation,
useNavigate,
@ -16,7 +15,7 @@ import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
import { cx } from "@/cva.config";
import { CLOUD_API, DEVICE_API, OPUS_STEREO_PARAMS } from "@/ui.config";
import { CLOUD_API, OPUS_STEREO_PARAMS } from "@/ui.config";
import api from "@/api";
import { checkAuth, isInCloud, isOnDevice } from "@/main";
import {
@ -51,9 +50,14 @@ import {
RebootingOverlay,
} from "@components/VideoOverlay";
import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
import { DeviceStatus } from "@routes/welcome-local";
import { m } from "@localizations/messages.js";
export type AuthMode = "password" | "noPassword" | null;
interface LocalLoaderResp {
authMode: AuthMode;
}
interface CloudLoaderResp {
deviceName: string;
user: User | null;
@ -62,35 +66,20 @@ interface CloudLoaderResp {
} | null;
}
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice {
authMode: AuthMode;
deviceId: string;
}
const deviceLoader = async () => {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
const device = await checkAuth();
return { authMode: device.authMode } as LocalLoaderResp;
};
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
const user = await checkAuth();
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json();
const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`);
if (!deviceResp.ok) {
@ -105,11 +94,11 @@ const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> =>
device: { id: string; name: string; user: { googleId: string } };
};
return { user, iceConfig, deviceName: device.name || device.id };
return { user, iceConfig, deviceName: device.name || device.id } as CloudLoaderResp;
};
const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
return isOnDevice ? deviceLoader() : cloudLoader(params);
};
export default function KvmIdRoute() {
@ -209,7 +198,7 @@ export default function KvmIdRoute() {
try {
await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription));
console.log("[setRemoteSessionDescription] Remote description set successfully");
console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp);
setLoadingMessage(m.establishing_secure_connection());
} catch (error) {
console.error(
@ -254,9 +243,14 @@ export default function KvmIdRoute() {
const ignoreOffer = useRef(false);
const isSettingRemoteAnswerPending = useRef(false);
const makingOffer = useRef(false);
const reconnectAttemptsRef = useRef(2000);
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const reconnectInterval = (attempt: number) => {
// Exponential backoff with a max of 10 seconds between attempts
return Math.min(500 * 2 ** attempt, 10000);
}
const { sendMessage, getWebSocket } = useWebSocket(
isOnDevice
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
@ -264,10 +258,10 @@ export default function KvmIdRoute() {
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 2000,
reconnectInterval: 1000,
reconnectAttempts: reconnectAttemptsRef.current,
reconnectInterval: reconnectInterval,
onReconnectStop: (numAttempts: number) => {
console.debug("Reconnect stopped", numAttempts);
console.debug("Reconnect stopped after ", numAttempts, "attempts");
cleanupAndStopReconnecting();
},
@ -285,6 +279,7 @@ export default function KvmIdRoute() {
console.error("[Websocket] onError", event);
// We don't want to close everything down, we wait for the reconnect to stop instead
},
onOpen() {
console.debug("[Websocket] onOpen");
// We want to clear the reboot state when the websocket connection is opened
@ -317,6 +312,7 @@ export default function KvmIdRoute() {
*/
const parsedMessage = JSON.parse(message.data);
if (parsedMessage.type === "device-metadata") {
const { deviceVersion } = parsedMessage.data;
console.debug("[Websocket] Received device-metadata message");
@ -333,10 +329,12 @@ export default function KvmIdRoute() {
console.log("[Websocket] Device is using new signaling");
isLegacySignalingEnabled.current = false;
}
setupPeerConnection();
}
if (!peerConnection) return;
if (parsedMessage.type === "answer") {
console.debug("[Websocket] Received answer");
const readyForOffer =

View File

@ -30,7 +30,7 @@ const loader: LoaderFunction = async ()=> {
return { devices, user };
} catch (e) {
console.error(e);
return { devices: [] };
return { devices: [], user };
}
};