diff --git a/cmd/main.go b/cmd/main.go index d9636088..9a1e1899 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -74,7 +74,12 @@ func supervise() error { // run the child binary cmd := exec.Command(binPath) - cmd.Env = append(os.Environ(), []string{envChildID + "=" + kvm.GetBuiltAppVersion()}...) + lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile) + + cmd.Env = append(os.Environ(), []string{ + fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()), + fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath), + }...) cmd.Args = os.Args logFile, err := os.CreateTemp("", "jetkvm-stdout.log") diff --git a/failsafe.go b/failsafe.go new file mode 100644 index 00000000..3c6b3d3a --- /dev/null +++ b/failsafe.go @@ -0,0 +1,107 @@ +package kvm + +import ( + "fmt" + "os" + "strings" + "sync" +) + +const ( + failsafeDefaultLastCrashPath = "/userdata/jetkvm/crashdump/last-crash.log" + failsafeFile = "/userdata/jetkvm/.enablefailsafe" + failsafeLastCrashEnv = "JETKVM_LAST_ERROR_PATH" + failsafeEnv = "JETKVM_FORCE_FAILSAFE" +) + +var ( + failsafeOnce sync.Once + failsafeCrashLog = "" + failsafeModeActive = false + failsafeModeReason = "" +) + +type FailsafeModeNotification struct { + Active bool `json:"active"` + Reason string `json:"reason"` +} + +// this function has side effects and can be only executed once +func checkFailsafeReason() { + failsafeOnce.Do(func() { + // check if the failsafe environment variable is set + if os.Getenv(failsafeEnv) == "1" { + failsafeModeActive = true + failsafeModeReason = "failsafe_env_set" + return + } + + // check if the failsafe file exists + if _, err := os.Stat(failsafeFile); err == nil { + failsafeModeActive = true + failsafeModeReason = "failsafe_file_exists" + _ = os.Remove(failsafeFile) + return + } + + // get the last crash log path from the environment variable + lastCrashPath := os.Getenv(failsafeLastCrashEnv) + if lastCrashPath == "" { + lastCrashPath = failsafeDefaultLastCrashPath + } + + // check if the last crash log file exists + l := failsafeLogger.With().Str("path", lastCrashPath).Logger() + fi, err := os.Lstat(lastCrashPath) + if err != nil { + if !os.IsNotExist(err) { + l.Warn().Err(err).Msg("failed to stat last crash log") + } + return + } + + if fi.Mode()&os.ModeSymlink != os.ModeSymlink { + l.Warn().Msg("last crash log is not a symlink, ignoring") + return + } + + // open the last crash log file and find if it contains the string "panic" + content, err := os.ReadFile(lastCrashPath) + if err != nil { + l.Warn().Err(err).Msg("failed to read last crash log") + return + } + + // unlink the last crash log file + failsafeCrashLog = string(content) + _ = os.Remove(lastCrashPath) + + // TODO: read the goroutine stack trace and check which goroutine is panicking + if strings.Contains(failsafeCrashLog, "runtime.cgocall") { + failsafeModeActive = true + failsafeModeReason = "video" + return + } + }) +} + +func notifyFailsafeMode(session *Session) { + if !failsafeModeActive || session == nil { + return + } + + jsonRpcLogger.Info().Str("reason", failsafeModeReason).Msg("sending failsafe mode notification") + + writeJSONRPCEvent("failsafeMode", FailsafeModeNotification{ + Active: true, + Reason: failsafeModeReason, + }, session) +} + +func rpcGetFailsafeLogs() (string, error) { + if !failsafeModeActive { + return "", fmt.Errorf("failsafe mode is not active") + } + + return failsafeCrashLog, nil +} diff --git a/internal/native/cgo_linux.go b/internal/native/cgo_linux.go index 850da0e8..be1a5a36 100644 --- a/internal/native/cgo_linux.go +++ b/internal/native/cgo_linux.go @@ -1,5 +1,8 @@ //go:build linux +// TODO: use a generator to generate the cgo code for the native functions +// there's too much boilerplate code to write manually + package native import ( @@ -46,7 +49,17 @@ static inline void jetkvm_cgo_setup_rpc_handler() { */ import "C" -var cgoLock sync.Mutex +var ( + cgoLock sync.Mutex + cgoDisabled bool +) + +func setCgoDisabled(disabled bool) { + cgoLock.Lock() + defer cgoLock.Unlock() + + cgoDisabled = disabled +} //export jetkvm_go_video_state_handler func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) { @@ -91,6 +104,10 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) { var eventCodeToNameMap = map[int]string{} func uiEventCodeToName(code int) string { + if cgoDisabled { + return "" + } + name, ok := eventCodeToNameMap[code] if !ok { cCode := C.int(code) @@ -103,6 +120,10 @@ func uiEventCodeToName(code int) string { } func setUpNativeHandlers() { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -114,6 +135,10 @@ func setUpNativeHandlers() { } func uiInit(rotation uint16) { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -123,6 +148,10 @@ func uiInit(rotation uint16) { } func uiTick() { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -130,6 +159,10 @@ func uiTick() { } func videoInit(factor float64) error { + if cgoDisabled { + return nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -143,6 +176,10 @@ func videoInit(factor float64) error { } func videoShutdown() { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -150,6 +187,10 @@ func videoShutdown() { } func videoStart() { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -157,6 +198,10 @@ func videoStart() { } func videoStop() { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -164,6 +209,10 @@ func videoStop() { } func videoLogStatus() string { + if cgoDisabled { + return "" + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -174,6 +223,10 @@ func videoLogStatus() string { } func uiSetVar(name string, value string) { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -187,6 +240,10 @@ func uiSetVar(name string, value string) { } func uiGetVar(name string) string { + if cgoDisabled { + return "" + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -197,6 +254,10 @@ func uiGetVar(name string) string { } func uiSwitchToScreen(screen string) { + if cgoDisabled { + return + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -206,6 +267,10 @@ func uiSwitchToScreen(screen string) { } func uiGetCurrentScreen() string { + if cgoDisabled { + return "" + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -214,6 +279,10 @@ func uiGetCurrentScreen() string { } func uiObjAddState(objName string, state string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -226,6 +295,10 @@ func uiObjAddState(objName string, state string) (bool, error) { } func uiObjClearState(objName string, state string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -238,6 +311,10 @@ func uiObjClearState(objName string, state string) (bool, error) { } func uiGetLVGLVersion() string { + if cgoDisabled { + return "" + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -246,6 +323,10 @@ func uiGetLVGLVersion() string { // TODO: use Enum instead of string but it's not a hot path and performance is not a concern now func uiObjAddFlag(objName string, flag string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -258,6 +339,10 @@ func uiObjAddFlag(objName string, flag string) (bool, error) { } func uiObjClearFlag(objName string, flag string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -278,6 +363,10 @@ func uiObjShow(objName string) (bool, error) { } func uiObjSetOpacity(objName string, opacity int) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -289,6 +378,10 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) { } func uiObjFadeIn(objName string, duration uint32) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -301,6 +394,10 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) { } func uiObjFadeOut(objName string, duration uint32) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -313,6 +410,10 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) { } func uiLabelSetText(objName string, text string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -330,6 +431,10 @@ func uiLabelSetText(objName string, text string) (bool, error) { } func uiImgSetSrc(objName string, src string) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -345,6 +450,10 @@ func uiImgSetSrc(objName string, src string) (bool, error) { } func uiDispSetRotation(rotation uint16) (bool, error) { + if cgoDisabled { + return false, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -357,6 +466,10 @@ func uiDispSetRotation(rotation uint16) (bool, error) { } func videoGetStreamQualityFactor() (float64, error) { + if cgoDisabled { + return 0, nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -365,6 +478,10 @@ func videoGetStreamQualityFactor() (float64, error) { } func videoSetStreamQualityFactor(factor float64) error { + if cgoDisabled { + return nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -373,6 +490,10 @@ func videoSetStreamQualityFactor(factor float64) error { } func videoGetEDID() (string, error) { + if cgoDisabled { + return "", nil + } + cgoLock.Lock() defer cgoLock.Unlock() @@ -381,6 +502,10 @@ func videoGetEDID() (string, error) { } func videoSetEDID(edid string) error { + if cgoDisabled { + return nil + } + cgoLock.Lock() defer cgoLock.Unlock() diff --git a/internal/native/native.go b/internal/native/native.go index 3b1cc0b4..cb8761cf 100644 --- a/internal/native/native.go +++ b/internal/native/native.go @@ -9,6 +9,7 @@ import ( ) type Native struct { + disable bool ready chan struct{} l *zerolog.Logger lD *zerolog.Logger @@ -27,6 +28,7 @@ type Native struct { } type NativeOptions struct { + Disable bool SystemVersion *semver.Version AppVersion *semver.Version DisplayRotation uint16 @@ -74,6 +76,7 @@ func NewNative(opts NativeOptions) *Native { } return &Native{ + disable: opts.Disable, ready: make(chan struct{}), l: nativeLogger, lD: displayLogger, @@ -92,6 +95,12 @@ func NewNative(opts NativeOptions) *Native { } func (n *Native) Start() { + if n.disable { + nativeLogger.Warn().Msg("native is disabled, skipping initialization") + setCgoDisabled(true) + return + } + // set up singleton setInstance(n) setUpNativeHandlers() diff --git a/jsonrpc.go b/jsonrpc.go index 5ed90a7a..d5032615 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1248,4 +1248,5 @@ var rpcHandlers = map[string]RPCHandler{ "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly}, "setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}}, + "getFailSafeLogs": {Func: rpcGetFailsafeLogs}, } diff --git a/log.go b/log.go index 2047bbfa..9cd9188e 100644 --- a/log.go +++ b/log.go @@ -11,6 +11,7 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { var ( logger = logging.GetSubsystemLogger("jetkvm") + failsafeLogger = logging.GetSubsystemLogger("failsafe") networkLogger = logging.GetSubsystemLogger("network") cloudLogger = logging.GetSubsystemLogger("cloud") websocketLogger = logging.GetSubsystemLogger("websocket") diff --git a/main.go b/main.go index bcc2d73d..f5a25f27 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,12 @@ var appCtx context.Context func Main() { logger.Log().Msg("JetKVM Starting Up") + + checkFailsafeReason() + if failsafeModeActive { + logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated") + } + LoadConfig() var cancel context.CancelFunc @@ -50,6 +56,7 @@ func Main() { // Initialize network if err := initNetwork(); err != nil { logger.Error().Err(err).Msg("failed to initialize network") + // TODO: reset config to default os.Exit(1) } @@ -60,7 +67,6 @@ func Main() { // Initialize mDNS if err := initMdns(); err != nil { logger.Error().Err(err).Msg("failed to initialize mDNS") - os.Exit(1) } initPrometheus() diff --git a/native.go b/native.go index 4a523bce..81a0e50d 100644 --- a/native.go +++ b/native.go @@ -17,6 +17,7 @@ var ( func initNative(systemVersion *semver.Version, appVersion *semver.Version) { nativeInstance = native.NewNative(native.NativeOptions{ + Disable: failsafeModeActive, SystemVersion: systemVersion, AppVersion: appVersion, DisplayRotation: config.GetDisplayRotation(), diff --git a/ui/src/components/FailSafeModeBanner.tsx b/ui/src/components/FailSafeModeBanner.tsx new file mode 100644 index 00000000..04fddc67 --- /dev/null +++ b/ui/src/components/FailSafeModeBanner.tsx @@ -0,0 +1,30 @@ +import { LuTriangleAlert } from "react-icons/lu"; + +import Card from "@components/Card"; + +interface FailsafeModeBannerProps { + reason: string; +} + +export function FailsafeModeBanner({ reason }: FailsafeModeBannerProps) { + const getReasonMessage = () => { + switch (reason) { + case "video": + return "Failsafe Mode Active: Video-related settings are currently unavailable"; + default: + return "Failsafe Mode Active: Some settings may be unavailable"; + } + }; + + return ( + +
+ +

+ {getReasonMessage()} +

+
+
+ ); +} + diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx new file mode 100644 index 00000000..eadc5d9d --- /dev/null +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -0,0 +1,216 @@ +import { useState } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; +import { motion, AnimatePresence } from "framer-motion"; +import { LuInfo } from "react-icons/lu"; + +import { Button } from "@/components/Button"; +import Card, { GridCard } from "@components/Card"; +import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; +import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; +import { useVersion } from "@/hooks/useVersion"; +import { useDeviceStore } from "@/hooks/stores"; +import notifications from "@/notifications"; +import { DOWNGRADE_VERSION } from "@/ui.config"; + +import { GitHubIcon } from "./Icons"; + + + +interface FailSafeModeOverlayProps { + reason: string; +} + +interface OverlayContentProps { + readonly children: React.ReactNode; +} + +function OverlayContent({ children }: OverlayContentProps) { + return ( + +
+ {children} +
+
+ ); +} + +interface TooltipProps { + readonly children: React.ReactNode; + readonly text: string; + readonly show: boolean; +} + +function Tooltip({ children, text, show }: TooltipProps) { + if (!show) { + return <>{children}; + } + + + return ( +
+ {children} +
+ +
+ + {text} +
+
+
+
+ ); +} + +export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { + const { send } = useJsonRpc(); + const { navigateTo } = useDeviceUiNavigation(); + const { appVersion } = useVersion(); + const { systemVersion } = useDeviceStore(); + const [isDownloadingLogs, setIsDownloadingLogs] = useState(false); + const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false); + + const getReasonCopy = () => { + switch (reason) { + case "video": + return { + message: + "We've detected an issue with the video capture process. Your device is still running and accessible, but video streaming is temporarily unavailable.", + }; + default: + return { + message: + "A critical process has encountered an issue. Your device is still accessible, but some functionality may be temporarily unavailable.", + }; + } + }; + + const { message } = getReasonCopy(); + + const handleReportAndDownloadLogs = () => { + setIsDownloadingLogs(true); + + send("getFailSafeLogs", {}, async (resp: JsonRpcResponse) => { + setIsDownloadingLogs(false); + + if ("error" in resp) { + notifications.error(`Failed to get recovery logs: ${resp.error.message}`); + return; + } + + // Download logs + const logContent = resp.result as string; + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const filename = `jetkvm-recovery-${reason}-${timestamp}.txt`; + + const blob = new Blob([logContent], { type: "text/plain" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + await new Promise(resolve => setTimeout(resolve, 1000)); + a.click(); + await new Promise(resolve => setTimeout(resolve, 1000)); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + notifications.success("Crash logs downloaded successfully"); + setHasDownloadedLogs(true); + + // Open GitHub issue + const issueBody = `## Issue Description +The \`${reason}\` process encountered an error and failsafe mode was activated. + +**Reason:** \`${reason}\` +**Timestamp:** ${new Date().toISOString()} +**App Version:** ${appVersion || "Unknown"} +**System Version:** ${systemVersion || "Unknown"} + +## Logs +Please attach the recovery logs file that was downloaded to your computer: +\`${filename}\` + +> [!NOTE] +> Please remove any sensitive information from the logs. The reports are public and can be viewed by anyone. + +## Additional Context +[Please describe what you were doing when this occurred]`; + + const issueUrl = + `https://github.com/jetkvm/kvm/issues/new?` + + `title=${encodeURIComponent(`Recovery Mode: ${reason} process issue`)}&` + + `body=${encodeURIComponent(issueBody)}`; + + window.open(issueUrl, "_blank"); + }); + }; + + const handleDowngrade = () => { + navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`); + }; + + return ( + + + +
+ +
+
+
+

Fail safe mode activated

+

{message}

+
+
+
+
+ + +
+
+
+
+
+
+
+ ); +} + diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 0be28425..6240116b 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -1003,3 +1003,15 @@ export const useMacrosStore = create((set, get) => ({ } }, })); + +export interface FailsafeModeState { + isFailsafeMode: boolean; + reason: string; // "video", "network", etc. + setFailsafeMode: (active: boolean, reason: string) => void; +} + +export const useFailsafeModeStore = create(set => ({ + isFailsafeMode: false, + reason: "", + setFailsafeMode: (active, reason) => set({ isFailsafeMode: active, reason }), +})); diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 2ff862b9..e01dcbc7 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { useRTCStore } from "@hooks/stores"; +import { useRTCStore, useFailsafeModeStore } from "@hooks/stores"; export interface JsonRpcRequest { jsonrpc: string; @@ -34,12 +34,51 @@ export const RpcMethodNotFound = -32601; const callbackStore = new Map void>(); let requestCounter = 0; +// Map of blocked RPC methods by failsafe reason +const blockedMethodsByReason: Record = { + video: [ + 'setStreamQualityFactor', + 'getEDID', + 'setEDID', + 'getVideoLogStatus', + 'setDisplayRotation', + 'getVideoSleepMode', + 'setVideoSleepMode', + 'getVideoState', + ], +}; + export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { const { rpcDataChannel } = useRTCStore(); + const { isFailsafeMode, reason } = useFailsafeModeStore(); const send = useCallback( async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { if (rpcDataChannel?.readyState !== "open") return; + + // Check if method is blocked in failsafe mode + if (isFailsafeMode && reason) { + const blockedMethods = blockedMethodsByReason[reason] || []; + if (blockedMethods.includes(method)) { + console.warn(`RPC method "${method}" is blocked in failsafe mode (reason: ${reason})`); + + // Call callback with error if provided + if (callback) { + const errorResponse: JsonRpcErrorResponse = { + jsonrpc: "2.0", + error: { + code: -32000, + message: "Method unavailable in failsafe mode", + data: `This feature is unavailable while in failsafe mode (${reason})`, + }, + id: requestCounter + 1, + }; + callback(errorResponse); + } + return; + } + } + requestCounter++; const payload = { jsonrpc: "2.0", method, params, id: requestCounter }; // Store the callback if it exists @@ -47,7 +86,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.send(JSON.stringify(payload)); }, - [rpcDataChannel] + [rpcDataChannel, isFailsafeMode, reason] ); useEffect(() => { diff --git a/ui/src/index.css b/ui/src/index.css index 0e837875..bf1e4621 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -363,3 +363,11 @@ video::-webkit-media-controls { .hide-scrollbar::-webkit-scrollbar { display: none; } + +.diagonal-stripes { + background: repeating-linear-gradient( + 135deg, + rgba(255, 0, 0, 0.1) 0 12px, /* red-50 with 20% opacity */ + transparent 12px 24px + ); +} diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index fc0feeaa..b8c32b51 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -1,14 +1,24 @@ -import { useCallback } from "react"; +import { useCallback , useState } from "react"; import { useNavigate } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { Button } from "@components/Button"; -import { m } from "@localizations/messages.js"; +import { useFailsafeModeStore } from "@/hooks/stores"; import { sleep } from "@/utils"; +import { m } from "@localizations/messages.js"; + +import LoadingSpinner from "../components/LoadingSpinner"; +import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; + +// Time to wait after initiating reboot before redirecting to home +const REBOOT_REDIRECT_DELAY_MS = 5000; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); const { send } = useJsonRpc(); + const [isRebooting, setIsRebooting] = useState(false); + const { navigateTo } = useDeviceUiNavigation(); + const { setFailsafeMode } = useFailsafeModeStore(); const onClose = useCallback(async () => { navigate(".."); // back to the devices.$id.settings page @@ -18,17 +28,24 @@ export default function SettingsGeneralRebootRoute() { }, [navigate]); - const onConfirmUpdate = useCallback(() => { - send("reboot", { force: true}); - }, [send]); + const onConfirmUpdate = useCallback(async () => { + setIsRebooting(true); + send("reboot", { force: true }); - return ; + await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS)); + setFailsafeMode(false, ""); + navigateTo("/"); + }, [navigateTo, send, setFailsafeMode]); + + return ; } export function Dialog({ + isRebooting, onClose, onConfirmUpdate, }: Readonly<{ + isRebooting: boolean; onClose: () => void; onConfirmUpdate: () => void; }>) { @@ -37,6 +54,7 @@ export function Dialog({
@@ -46,9 +64,11 @@ export function Dialog({ } function ConfirmationBox({ + isRebooting, onYes, onNo, }: { + isRebooting: boolean; onYes: () => void; onNo: () => void; }) { @@ -61,11 +81,16 @@ function ConfirmationBox({

{m.general_reboot_description()}

- -
-
+ {isRebooting ? ( +
+ +
+ ) : ( +
+
+ )}
); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index a8fced9d..70827fff 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -20,7 +20,8 @@ import { useUiStore } from "@hooks/stores"; import Card from "@components/Card"; import { LinkButton } from "@components/Button"; import { FeatureFlag } from "@components/FeatureFlag"; -import { m } from "@localizations/messages.js"; +import { m, useFailsafeModeStore } from "@localizations/messages.js"; +import { FailsafeModeBanner } from "@components/FailSafeModeBanner"; /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ export default function SettingsRoute() { @@ -30,6 +31,8 @@ export default function SettingsRoute() { const [showLeftGradient, setShowLeftGradient] = useState(false); const [showRightGradient, setShowRightGradient] = useState(false); const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject }); + const { isFailsafeMode: isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore(); + const isVideoDisabled = isFailsafeMode && failsafeReason === "video"; // Handle scroll position to show/hide gradients const handleScroll = () => { @@ -158,21 +161,28 @@ export default function SettingsRoute() { -
+
(isActive ? "active" : "")} - > + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >

{m.settings_video()}

-
+
(isActive ? "active" : "")} + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >
@@ -238,8 +248,8 @@ export default function SettingsRoute() {
-
- {/* */} +
+ {isFailsafeMode && failsafeReason && }
import('@components/sidebar/connection const Terminal = lazy(() => import('@components/Terminal')); const UpdateInProgressStatusCard = lazy(() => import("@components/UpdateInProgressStatusCard")); import Modal from "@components/Modal"; +import { FailSafeModeOverlay } from "@components/FailSafeModeOverlay"; import { ConnectionFailedOverlay, LoadingConnectionOverlay, @@ -101,6 +103,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { return isOnDevice ? deviceLoader() : cloudLoader(params); }; + export default function KvmIdRoute() { const loaderResp = useLoaderData(); // Depending on the mode, we set the appropriate variables @@ -612,14 +615,15 @@ export default function KvmIdRoute() { }); }, 10000); - const { setNetworkState } = useNetworkStateStore(); + const { setNetworkState } = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); const { keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); - const { setHidRpcDisabled } = useRTCStore(); + const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); + const { setFailsafeMode } = useFailsafeModeStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -696,6 +700,12 @@ export default function KvmIdRoute() { setRebootState({ isRebooting: true, postRebootAction }); navigateTo("/"); } + + if (resp.method === "failsafeMode") { + const { active, reason } = resp.params as { active: boolean; reason: string }; + console.debug("Setting failsafe mode", { active, reason }); + setFailsafeMode(active, reason); + } } const { send } = useJsonRpc(onJsonRpcRequest); @@ -794,6 +804,8 @@ export default function KvmIdRoute() { getLocalVersion(); }, [appVersion, getLocalVersion]); + const { isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore(); + const ConnectionStatusElement = useMemo(() => { const isOtherSession = location.pathname.includes("other-session"); if (isOtherSession) return null; @@ -869,13 +881,15 @@ export default function KvmIdRoute() { />
- + {(isFailsafeMode && failsafeReason === "video") ? null : }
- {!!ConnectionStatusElement && ConnectionStatusElement} + {isFailsafeMode && failsafeReason ? ( + + ) : !!ConnectionStatusElement && ConnectionStatusElement}
diff --git a/ui/src/ui.config.ts b/ui/src/ui.config.ts index b76dd7c4..3cb3b58b 100644 --- a/ui/src/ui.config.ts +++ b/ui/src/ui.config.ts @@ -1,4 +1,6 @@ export const CLOUD_API = import.meta.env.VITE_CLOUD_API; +export const DOWNGRADE_VERSION = import.meta.env.VITE_DOWNGRADE_VERSION || "0.4.8"; + // In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint export const DEVICE_API = ""; diff --git a/video.go b/video.go index cd74e680..7ce342a7 100644 --- a/video.go +++ b/video.go @@ -27,6 +27,7 @@ func triggerVideoStateUpdate() { } func rpcGetVideoState() (native.VideoState, error) { + notifyFailsafeMode(currentSession) return lastVideoState, nil } diff --git a/webrtc.go b/webrtc.go index 37488f77..abe1aba7 100644 --- a/webrtc.go +++ b/webrtc.go @@ -289,6 +289,7 @@ func newSession(config SessionConfig) (*Session, error) { triggerOTAStateUpdate() triggerVideoStateUpdate() triggerUSBStateUpdate() + notifyFailsafeMode(session) case "terminal": handleTerminalChannel(d) case "serial": @@ -391,10 +392,12 @@ func newSession(config SessionConfig) (*Session, error) { } func onActiveSessionsChanged() { + notifyFailsafeMode(currentSession) requestDisplayUpdate(true, "active_sessions_changed") } func onFirstSessionConnected() { + notifyFailsafeMode(currentSession) _ = nativeInstance.VideoStart() stopVideoSleepModeTicker() }