diff --git a/cmd/main.go b/cmd/main.go index 59033c47..9a1e1899 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -16,10 +16,10 @@ import ( ) const ( - envChildID = "JETKVM_CHILD_ID" - errorDumpDir = "/userdata/jetkvm/" - errorDumpStateFile = ".has_error_dump" - errorDumpTemplate = "jetkvm-%s.log" + envChildID = "JETKVM_CHILD_ID" + errorDumpDir = "/userdata/jetkvm/crashdump" + errorDumpLastFile = "last-crash.log" + errorDumpTemplate = "jetkvm-%s.log" ) func program() { @@ -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") @@ -117,30 +122,47 @@ func supervise() error { return nil } -func createErrorDump(logFile *os.File) { - logFile.Close() - - // touch the error dump state file - if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil { - return - } - - fileName := fmt.Sprintf(errorDumpTemplate, time.Now().Format("20060102150405")) - filePath := filepath.Join(errorDumpDir, fileName) - if err := os.Rename(logFile.Name(), filePath); err == nil { - fmt.Printf("error dump created: %s\n", filePath) - return - } - - fnSrc, err := os.Open(logFile.Name()) +func isSymlinkTo(oldName, newName string) bool { + file, err := os.Stat(newName) if err != nil { - return + return false + } + if file.Mode()&os.ModeSymlink != os.ModeSymlink { + return false + } + target, err := os.Readlink(newName) + if err != nil { + return false + } + return target == oldName +} + +func ensureSymlink(oldName, newName string) error { + if isSymlinkTo(oldName, newName) { + return nil + } + _ = os.Remove(newName) + return os.Symlink(oldName, newName) +} + +func renameFile(f *os.File, newName string) error { + _ = f.Close() + + // try to rename the file first + if err := os.Rename(f.Name(), newName); err == nil { + return nil + } + + // copy the log file to the error dump directory + fnSrc, err := os.Open(f.Name()) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) } defer fnSrc.Close() - fnDst, err := os.Create(filePath) + fnDst, err := os.Create(newName) if err != nil { - return + return fmt.Errorf("failed to create file: %w", err) } defer fnDst.Close() @@ -148,18 +170,60 @@ func createErrorDump(logFile *os.File) { for { n, err := fnSrc.Read(buf) if err != nil && err != io.EOF { - return + return fmt.Errorf("failed to read file: %w", err) } if n == 0 { break } if _, err := fnDst.Write(buf[:n]); err != nil { - return + return fmt.Errorf("failed to write file: %w", err) } } - fmt.Printf("error dump created: %s\n", filePath) + return nil +} + +func ensureErrorDumpDir() error { + // TODO: check if the directory is writable + f, err := os.Stat(errorDumpDir) + if err == nil && f.IsDir() { + return nil + } + if err := os.MkdirAll(errorDumpDir, 0755); err != nil { + return fmt.Errorf("failed to create error dump directory: %w", err) + } + return nil +} + +func createErrorDump(logFile *os.File) { + fmt.Println() + + fileName := fmt.Sprintf( + errorDumpTemplate, + time.Now().Format("20060102-150405"), + ) + + // check if the directory exists + if err := ensureErrorDumpDir(); err != nil { + fmt.Printf("failed to ensure error dump directory: %v\n", err) + return + } + + filePath := filepath.Join(errorDumpDir, fileName) + if err := renameFile(logFile, filePath); err != nil { + fmt.Printf("failed to rename file: %v\n", err) + return + } + + fmt.Printf("error dump copied: %s\n", filePath) + + lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile) + + if err := ensureSymlink(filePath, lastFilePath); err != nil { + fmt.Printf("failed to create symlink: %v\n", err) + return + } } func doSupervise() { 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 2a9055ce..0a773246 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 99e7bdcf..ea6c6fba 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1268,4 +1268,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 81c85431..669c6f44 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,11 @@ import ( var appCtx context.Context func Main() { + checkFailsafeReason() + if failsafeModeActive { + logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated") + } + LoadConfig() var cancel context.CancelFunc @@ -48,6 +53,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) } @@ -58,7 +64,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 d7c85b0b..c42cad3c 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 bfbbb26e..b71f61ea 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -465,7 +465,7 @@ export interface KeysDownState { keys: number[]; } -export type USBStates = +export type USBStates = | "configured" | "attached" | "not attached" @@ -926,3 +926,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 5c52d59c..05214e00 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 b13fc3a1..876019fc 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -354,3 +354,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 db0e0530..62c94b57 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -1,28 +1,45 @@ import { useNavigate } from "react-router"; -import { useCallback } from "react"; +import { useCallback, useState } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; +import { useFailsafeModeStore } from "@/hooks/stores"; + +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 onConfirmUpdate = useCallback(() => { + const onConfirmUpdate = useCallback(async () => { + setIsRebooting(true); // This is where we send the RPC to the golang binary - send("reboot", {force: true}); - }, [send]); + send("reboot", { force: true }); + + await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS)); + setFailsafeMode(false, ""); + navigateTo("/"); + }, [navigateTo, send, setFailsafeMode]); { /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */ } - return navigate("..")} onConfirmUpdate={onConfirmUpdate} />; + return navigate("..")} onConfirmUpdate={onConfirmUpdate} />; } export function Dialog({ + isRebooting, onClose, onConfirmUpdate, }: { + isRebooting: boolean; onClose: () => void; onConfirmUpdate: () => void; }) { @@ -30,19 +47,22 @@ export function Dialog({ return (
- +
); } function ConfirmationBox({ + isRebooting, onYes, onNo, }: { + isRebooting: boolean; onYes: () => void; onNo: () => void; }) { @@ -55,11 +75,16 @@ function ConfirmationBox({

Do you want to proceed with rebooting the system?

- -
-
+ {isRebooting ? ( +
+ +
+ ) : ( +
+
+ )} ); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 338beb97..1d15fa2e 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -19,7 +19,8 @@ import { cx } from "@/cva.config"; import Card from "@components/Card"; import { LinkButton } from "@components/Button"; import { FeatureFlag } from "@components/FeatureFlag"; -import { useUiStore } from "@/hooks/stores"; +import { useUiStore, useFailsafeModeStore } from "@/hooks/stores"; +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() { @@ -29,6 +30,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 = () => { @@ -157,21 +160,28 @@ export default function SettingsRoute() { -
+
(isActive ? "active" : "")} - > + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >

Video

-
+
(isActive ? "active" : "")} + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >
@@ -237,8 +247,8 @@ export default function SettingsRoute() {
-
- {/* */} +
+ {isFailsafeMode && failsafeReason && }
import('@/components/sidebar/connectio const Terminal = lazy(() => import('@components/Terminal')); const UpdateInProgressStatusCard = lazy(() => import("@/components/UpdateInProgressStatusCard")); import Modal from "@/components/Modal"; +import { FailSafeModeOverlay } from "@components/FailSafeModeOverlay"; import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { ConnectionFailedOverlay, @@ -113,6 +115,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); }; + export default function KvmIdRoute() { const loaderResp = useLoaderData() as LocalLoaderResp | CloudLoaderResp; // Depending on the mode, we set the appropriate variables @@ -123,9 +126,9 @@ export default function KvmIdRoute() { const params = useParams() as { id: string }; const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); - const [ queryParams, setQueryParams ] = useSearchParams(); + const [queryParams, setQueryParams] = useSearchParams(); - const { + const { peerConnection, setPeerConnection, peerConnectionState, setPeerConnectionState, setMediaStream, @@ -597,13 +600,14 @@ export default function KvmIdRoute() { }); }, 10000); - const { setNetworkState} = useNetworkStateStore(); + const { setNetworkState } = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); - const { - keyboardLedState, setKeyboardLedState, + const { + keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); + const { setFailsafeMode } = useFailsafeModeStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -666,6 +670,12 @@ export default function KvmIdRoute() { window.location.href = currentUrl.toString(); } } + + 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); @@ -756,7 +766,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const { appVersion, getLocalVersion} = useVersion(); + const { appVersion, getLocalVersion } = useVersion(); useEffect(() => { if (appVersion) return; @@ -764,6 +774,8 @@ export default function KvmIdRoute() { getLocalVersion(); }, [appVersion, getLocalVersion]); + const { isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore(); + const ConnectionStatusElement = useMemo(() => { const hasConnectionFailed = connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? ""); @@ -841,13 +853,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() }