This commit is contained in:
Marc Brooks 2025-10-01 21:44:32 +02:00 committed by GitHub
commit 5bb4ead9c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 156 additions and 76 deletions

27
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
@ -78,16 +79,16 @@ func Main() {
initDisplay()
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
}
@ -97,6 +98,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 {
@ -106,6 +113,7 @@ func Main() {
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
@ -122,7 +130,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 {

43
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,14 @@ 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
fileToHash.Close()
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 {
@ -296,6 +299,8 @@ type OTAState struct {
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
RebootNeeded bool `json:"rebootNeeded,omitempty"`
Rebooting bool `json:"rebooting,omitempty"`
}
var otaState = OTAState{}
@ -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...")
@ -323,6 +328,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState = OTAState{
Updating: true,
RebootNeeded: false,
}
triggerOTAStateUpdate()
@ -335,7 +341,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
scopedLogger.Error().Err(err).Msg("Error checking for updates")
return fmt.Errorf("error checking for updates: %w", err)
return err
}
now := time.Now()
@ -349,8 +355,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
appUpdateAvailable := updateStatus.AppUpdateAvailable
systemUpdateAvailable := updateStatus.SystemUpdateAvailable
rebootNeeded := false
if appUpdateAvailable {
scopedLogger.Info().
Str("local", local.AppVersion).
@ -361,7 +365,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
}
downloadFinished := time.Now()
@ -378,18 +381,20 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return 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
otaState.RebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("App is up to date")
}
@ -404,7 +409,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
}
downloadFinished := time.Now()
@ -421,7 +425,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
}
scopedLogger.Info().Msg("System update downloaded")
@ -441,6 +444,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@ -481,14 +485,19 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
otaState.RebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("System is up to date")
}
if rebootNeeded {
if otaState.RebootNeeded {
scopedLogger.Info().Msg("System Rebooting in 10s")
time.Sleep(10 * time.Second)
otaState.Rebooting = true
triggerOTAStateUpdate()
cmd := exec.Command("reboot")
err := cmd.Start()
if err != nil {

View File

@ -213,7 +213,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",
@ -231,7 +231,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

@ -518,6 +518,7 @@ export type UpdateModalViews =
| "upToDate"
| "updateAvailable"
| "updateCompleted"
| "rebooting"
| "error";
export interface OtaState {
@ -549,19 +550,26 @@ export interface OtaState {
systemUpdateProgress: number;
systemUpdatedAt: string | null;
rebootNeeded: boolean;
rebooting: boolean;
};
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 => ({
@ -587,13 +595,17 @@ export const useUpdateStore = create<UpdateState>(set => ({
appUpdatedAt: null,
systemUpdateProgress: 0,
systemUpdatedAt: null,
rebootNeeded: false,
rebooting: false,
},
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

@ -85,7 +85,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

@ -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

@ -8,6 +8,12 @@ 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(() => {
// This is where we send the RPC to the golang binary
send("reboot", {force: true});
@ -16,7 +22,7 @@ export default function SettingsGeneralRebootRoute() {
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({

View File

@ -1,10 +1,11 @@
import { useLocation, useNavigate } from "react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { MdConnectWithoutContact, MdRestartAlt } from "react-icons/md";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button";
import { Button, LinkButton } from "@components/Button";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
@ -18,6 +19,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");
@ -33,16 +39,14 @@ export default function SettingsGeneralUpdateRoute() {
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
}
export function Dialog({
onClose,
onConfirmUpdate,
@ -224,7 +228,7 @@ function UpdatingDeviceState({
100,
);
} else {
// System: 10% download, 90% update
// System: 10% download, 10% verification, 80% update
return Math.min(
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
100,
@ -239,14 +243,18 @@ function UpdatingDeviceState({
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (otaState.rebooting) {
return "Rebooting...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
} else if (otaState.rebootNeeded) {
return "Reboot needed";
} else {
return `Awaiting reboot`;
return "Awaiting reboot";
}
};
@ -278,12 +286,51 @@ function UpdatingDeviceState({
<Card className="space-y-4 p-4">
{areAllUpdatesComplete() ? (
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<CheckCircleIcon className="h-6 w-6 text-blue-700 dark:text-blue-500" />
{otaState.rebooting ? (
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
Rebooting the device to complete the update...
</span>
<p className="flex-col text-black dark:text-white">
This may take a few minutes. The device will automatically
reconnect once it is back online.<br/>
If it doesn{"'"}t reconnect automatically, you can manually
reconnect by clicking here:
<LinkButton
size="XS"
theme="light"
text="Reconnect to KVM"
LeadingIcon={MdConnectWithoutContact}
textAlign="center"
reloadDocument={true}
to={".."}
/>
</p>
</div>
) : (
otaState.rebootNeeded && (
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white">
Device reboot is pending...
</span>
<p className="flex-col text-black dark:text-white">
The JetKVM is preparing to reboot. This may take a while.<br/>
If it doesn{"'"}t automatically reboot after a few minutes, you
can manually request a reboot by clicking here:
<LinkButton
size="XS"
theme="light"
text="Reboot the KVM"
LeadingIcon={MdRestartAlt}
textAlign="center"
reloadDocument={true}
to={"../reboot"}
/>
</p>
</div>
)
)}
</div>
) : (
<>

View File

@ -1,7 +1,6 @@
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Outlet,
redirect,
useLoaderData,
useLocation,
useNavigate,
@ -15,7 +14,7 @@ import { FocusTrap } from "focus-trap-react";
import { motion, AnimatePresence } from "framer-motion";
import useWebSocket from "react-use-websocket";
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 { cx } from "@/cva.config";
@ -48,7 +47,6 @@ import {
} from "@/components/VideoOverlay";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { FeatureFlagProvider } from "@/providers/FeatureFlagProvider";
import { DeviceStatus } from "@routes/welcome-local";
import { useVersion } from "@/hooks/useVersion";
interface LocalLoaderResp {
@ -70,20 +68,8 @@ export interface LocalDevice {
}
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> => {
@ -106,11 +92,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() {
@ -146,6 +132,7 @@ export default function KvmIdRoute() {
const { otaState, setOtaState, setModalView } = useUpdateStore();
const [loadingMessage, setLoadingMessage] = useState("Connecting to device...");
const cleanupAndStopReconnecting = useCallback(
function cleanupAndStopReconnecting() {
console.log("Closing peer connection");
@ -182,11 +169,11 @@ export default function KvmIdRoute() {
pc: RTCPeerConnection,
remoteDescription: RTCSessionDescriptionInit,
) {
setLoadingMessage("Setting remote description");
setLoadingMessage("Setting remote description type:" + remoteDescription.type);
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("Establishing secure connection...");
} catch (error) {
console.error(
@ -231,9 +218,15 @@ export default function KvmIdRoute() {
const ignoreOffer = useRef(false);
const isSettingRemoteAnswerPending = useRef(false);
const makingOffer = useRef(false);
const reconnectAttemptsRef = useRef(20);
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`
@ -241,17 +234,16 @@ export default function KvmIdRoute() {
{
heartbeat: true,
retryOnError: true,
reconnectAttempts: 15,
reconnectInterval: 1000,
onReconnectStop: () => {
console.debug("Reconnect stopped");
reconnectAttempts: reconnectAttemptsRef.current,
reconnectInterval: reconnectInterval,
onReconnectStop: (attempt: number) => {
console.debug("Reconnect stopped after ", attempt, "attempts");
cleanupAndStopReconnecting();
},
shouldReconnect(event) {
console.debug("[Websocket] shouldReconnect", event);
// TODO: Why true?
return true;
return !connectionFailed; // we always want to try to reconnect unless we're explicitly stopped
},
onClose(event) {
@ -284,6 +276,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");
@ -300,10 +293,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 =
@ -594,6 +589,8 @@ export default function KvmIdRoute() {
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
}).catch(()=>{
// we don't care about errors here, but we don't want unhandled promise rejections
});
}, 10000);