diff --git a/config.go b/config.go index 3ed661e8..531c2676 100644 --- a/config.go +++ b/config.go @@ -179,8 +179,9 @@ func getDefaultConfig() Config { _ = confparser.SetDefaultsAndValidate(c) return c }(), - DefaultLogLevel: "INFO", - AudioOutputSource: "usb", + DefaultLogLevel: "INFO", + AudioOutputSource: "usb", + VideoQualityFactor: 1.0, } } diff --git a/display.go b/display.go index 042bf122..68723b59 100644 --- a/display.go +++ b/display.go @@ -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 diff --git a/internal/native/cgo/ctrl.c b/internal/native/cgo/ctrl.c index 0c10ee15..547d5694 100644 --- a/internal/native/cgo/ctrl.c +++ b/internal/native/cgo/ctrl.c @@ -306,7 +306,7 @@ int jetkvm_ui_add_flag(const char *obj_name, const char *flag_name) { if (obj == NULL) { return -1; } - + lv_obj_flag_t flag_val = str_to_lv_obj_flag(flag_name); if (flag_val == 0) { @@ -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); @@ -417,4 +417,4 @@ void jetkvm_crash() { // let's call a function that will crash the program int* p = 0; *p = 0; -} \ No newline at end of file +} diff --git a/internal/native/cgo/video.c b/internal/native/cgo/video.c index 57131ff7..5deb9f5e 100644 --- a/internal/native/cgo/video.c +++ b/internal/native/cgo/video.c @@ -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; diff --git a/internal/native/native.go b/internal/native/native.go index 2a9055ce..3b1cc0b4 100644 --- a/internal/native/native.go +++ b/internal/native/native.go @@ -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 } diff --git a/jsonrpc.go b/jsonrpc.go index 93b0654c..5a341b03 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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 } diff --git a/main.go b/main.go index 1b6599f5..4df1a746 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/ota.go b/ota.go index 65a67517..5371e428 100644 --- a/ota.go +++ b/ota.go @@ -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") } diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 155ea249..102d3bee 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -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< /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" @@ -131,7 +160,7 @@ if [[ "$SKIP_UI_BUILD" = true && ! -f "static/index.html" ]]; then SKIP_UI_BUILD=false fi -if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then +if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then msg_info "▶ Building frontend" make frontend SKIP_UI_BUILD=0 SKIP_UI_BUILD_RELEASE=1 @@ -144,13 +173,13 @@ fi if [ "$RUN_GO_TESTS" = true ]; then msg_info "▶ Building go tests" - make build_dev_test + 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} @@ -191,35 +220,35 @@ then SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ 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 \ SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ 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}" diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx index fcb0a614..6ae358f2 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -212,7 +212,7 @@ export const Button = React.forwardRef( Button.displayName = "Button"; type LinkPropsType = Pick & - React.ComponentProps & { disabled?: boolean }; + React.ComponentProps & { 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 ( - + ); diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 10d94108..e84cfc04 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -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) { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 671d601a..aa9ba033 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -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(set => ({ @@ -627,8 +631,10 @@ export const useUpdateStore = create(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 }), })); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index eb36f5dd..1c62818d 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -74,10 +74,10 @@ export async function checkDeviceAuth() { .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); - 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; diff --git a/ui/src/routes/devices.$id.deregister.tsx b/ui/src/routes/devices.$id.deregister.tsx index d0b821db..07db5d7d 100644 --- a/ui/src/routes/devices.$id.deregister.tsx +++ b/ui/src/routes/devices.$id.deregister.tsx @@ -58,7 +58,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { return { device, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { user }; } }; diff --git a/ui/src/routes/devices.$id.rename.tsx b/ui/src/routes/devices.$id.rename.tsx index b61dc45a..a7191764 100644 --- a/ui/src/routes/devices.$id.rename.tsx +++ b/ui/src/routes/devices.$id.rename.tsx @@ -54,7 +54,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { return { device, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { user }; } }; diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index ae2ae3c3..766b8c4a 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -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; }); diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index 84be89c7..fa692c7a 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -8,12 +8,18 @@ import { m } from "@localizations/messages.js"; 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 navigate("..")} onConfirmUpdate={onConfirmUpdate} />; + return ; } export function Dialog({ diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 45c3235b..48c2168b 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -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 navigate("..")} onConfirmUpdate={onConfirmUpdate} />; + return ; } export function Dialog({ diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 75b941d6..42776414 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -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( @@ -81,7 +81,7 @@ export default function SettingsHardwareRoute() { const duration = enabled ? 90 : -1; send("setVideoSleepMode", { duration }, (resp: JsonRpcResponse) => { if ("error" in resp) { - notifications.error(m.hardware_power_saving_failed_error({ error: resp.error.data ||m.unknown_error() })); + notifications.error(m.hardware_power_saving_failed_error({ error: resp.error.data || m.unknown_error() })); setPowerSavingEnabled(!enabled); // Attempt to revert on error return; } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 64806984..7cabbf29 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -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); - - 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): Promise => { 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): Promise => 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 = diff --git a/ui/src/routes/devices.tsx b/ui/src/routes/devices.tsx index f658c73e..c6972e0e 100644 --- a/ui/src/routes/devices.tsx +++ b/ui/src/routes/devices.tsx @@ -16,7 +16,7 @@ interface LoaderData { devices: { id: string; name: string; online: boolean; lastSeen: string }[]; user: User; } -const loader: LoaderFunction = async ()=> { +const loader: LoaderFunction = async () => { const user = await checkAuth(); try { @@ -30,7 +30,7 @@ const loader: LoaderFunction = async ()=> { return { devices, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { devices: [], user }; } };