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 (
+
+ );
+}
+
+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()
}