diff --git a/main.go b/main.go index 2648b68d..bcc2d73d 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 @@ -79,16 +80,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 } @@ -98,6 +99,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 { @@ -107,6 +114,7 @@ func Main() { time.Sleep(1 * time.Hour) } }() + //go RunFuseServer() go RunWebServer() @@ -123,7 +131,8 @@ func Main() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs - logger.Info().Msg("JetKVM Shutting Down") + + logger.Log().Msg("JetKVM Shutting Down") //if fuseServer != nil { // err := setMassStorageImage(" ") // if err != nil { 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/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/hooks/stores.ts b/ui/src/hooks/stores.ts index 7157354f..06f29582 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -573,14 +573,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 => ({ @@ -611,8 +615,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 6482c85a..b3001a69 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -73,10 +73,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 }; @@ -86,7 +86,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.tsx b/ui/src/routes/devices.$id.tsx index 2a0de491..bae8faa6 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 } from "@/ui.config"; +import { CLOUD_API } 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() { @@ -185,7 +174,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( @@ -230,9 +219,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` @@ -240,10 +234,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(); }, @@ -261,6 +255,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 @@ -293,6 +288,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"); @@ -309,10 +305,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 }; } };