From fb3e57aa865675556c17ef8121cccd6b8e4b00c1 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:38:50 +0100 Subject: [PATCH 01/21] feat: implement fail-safe mode UI --- ui/src/components/FailSafeModeBanner.tsx | 30 +++ ui/src/components/FaileSafeModeOverlay.tsx | 203 +++++++++++++++++++++ ui/src/hooks/stores.ts | 15 +- ui/src/hooks/useJsonRpc.ts | 43 ++++- ui/src/index.css | 8 + ui/src/routes/devices.$id.settings.tsx | 17 +- ui/src/routes/devices.$id.tsx | 26 ++- 7 files changed, 331 insertions(+), 11 deletions(-) create mode 100644 ui/src/components/FailSafeModeBanner.tsx create mode 100644 ui/src/components/FaileSafeModeOverlay.tsx diff --git a/ui/src/components/FailSafeModeBanner.tsx b/ui/src/components/FailSafeModeBanner.tsx new file mode 100644 index 000000000..04fddc677 --- /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/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx new file mode 100644 index 000000000..80c56b0f3 --- /dev/null +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -0,0 +1,203 @@ +import { useState } from "react"; +import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; +import { motion, AnimatePresence } from "framer-motion"; + +import { Button } from "@/components/Button"; +import { 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 { GitHubIcon } from "./Icons"; + +interface FailSafeModeOverlayProps { + reason: string; +} + +interface OverlayContentProps { + readonly children: React.ReactNode; +} + +function OverlayContent({ children }: OverlayContentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { + const { send } = useJsonRpc(); + const { navigateTo } = useDeviceUiNavigation(); + const { appVersion } = useVersion(); + const { systemVersion } = useDeviceStore(); + const [showRebootConfirm, setShowRebootConfirm] = useState(false); + const [isDownloadingLogs, setIsDownloadingLogs] = 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. You can reboot to attempt recovery, report the issue, or downgrade to the last stable version.", + }; + default: + return { + message: + "A critical process has encountered an issue. Your device is still accessible, but some functionality may be temporarily unavailable.", + }; + } + }; + + const { title, message } = getReasonCopy(); + + const handleReboot = () => { + if (!showRebootConfirm) { + setShowRebootConfirm(true); + return; + } + + send("reboot", { force: true }, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error(`Failed to reboot: ${resp.error.message}`); + } + }); + }; + + const handleReportAndDownloadLogs = () => { + setIsDownloadingLogs(true); + + send("getFailSafeLogs", {}, (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); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + notifications.success("Recovery logs downloaded successfully"); + + // Open GitHub issue + const issueBody = `## Issue Description +The ${reason} process encountered an error and recovery 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}\` + +## 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?appVersion=0.4.8"); + }; + + return ( + + + +
+ +
+
+
+

Fail safe mode activated

+

{message}

+
+ {showRebootConfirm ? ( +
+

+ Rebooting will restart your device. This may resolve the issue. Continue? +

+
+
+
+ ) : ( +
+
+ )} +
+
+
+
+
+
+ ); +} + diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index bfbbb26e5..b1270b1c8 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,16 @@ export const useMacrosStore = create((set, get) => ({ } } })); + +export interface FailsafeModeState { + isFailsafeMode: boolean; + reason: string | null; // "video", "network", etc. + setFailsafeMode: (enabled: boolean, reason: string | null) => void; +} + +export const useFailsafeModeStore = create(set => ({ + isFailsafeMode: false, + reason: null, + setFailsafeMode: (enabled: boolean, reason: string | null) => + set({ isFailsafeMode: enabled, reason }), +})); diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5c52d59cd..5ad4d366a 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: 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 b13fc3a12..12f9141e0 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.2) 0 12px, /* red-50 with 20% opacity */ + transparent 12px 24px + ); +} diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 338beb976..bb97003b1 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,7 +160,9 @@ export default function SettingsRoute() { -
+
(isActive ? "active" : "")} @@ -168,7 +173,9 @@ export default function SettingsRoute() {
-
+
(isActive ? "active" : "")} @@ -237,8 +244,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/FaileSafeModeOverlay"; 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 @@ -125,7 +128,7 @@ export default function KvmIdRoute() { const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); const [ queryParams, setQueryParams ] = useSearchParams(); - const { + const { peerConnection, setPeerConnection, peerConnectionState, setPeerConnectionState, setMediaStream, @@ -480,6 +483,11 @@ export default function KvmIdRoute() { const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); + + + setTimeout(() => { + useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); + }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); @@ -599,11 +607,12 @@ export default function KvmIdRoute() { const { setNetworkState} = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); - const { + 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 +675,12 @@ export default function KvmIdRoute() { window.location.href = currentUrl.toString(); } } + + if (resp.method === "failsafeMode") { + const { enabled, reason } = resp.params as { enabled: boolean; reason: string }; + console.debug("Setting failsafe mode", { enabled, reason }); + setFailsafeMode(enabled, reason); + } } const { send } = useJsonRpc(onJsonRpcRequest); @@ -764,6 +779,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 +858,16 @@ export default function KvmIdRoute() { />
- + {!isFailsafeMode && }
{!!ConnectionStatusElement && ConnectionStatusElement} + {isFailsafeMode && failsafeReason && ( + + )}
From fc7156f5314ed7fea19a780fd52258e9f78dc94f Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:39:24 +0100 Subject: [PATCH 02/21] fix: remove unused variable from FailSafeModeOverlay component --- ui/src/components/FaileSafeModeOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index 80c56b0f3..35c4af1a7 100644 --- a/ui/src/components/FaileSafeModeOverlay.tsx +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -53,7 +53,7 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { } }; - const { title, message } = getReasonCopy(); + const { message } = getReasonCopy(); const handleReboot = () => { if (!showRebootConfirm) { From 2c512f72bc755aa9798d692a0a8fde48ff96d4ee Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:40:59 +0100 Subject: [PATCH 03/21] chore: comment out failsafe mode timeout in KvmIdRoute component --- ui/src/routes/devices.$id.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 86cf083df..b85a3f445 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -484,10 +484,9 @@ export default function KvmIdRoute() { rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); - - setTimeout(() => { - useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); - }, 1000); + // setTimeout(() => { + // useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); + // }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); From 42a0e1fd9b291036cad5788d299524a6f153a85e Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:50:19 +0100 Subject: [PATCH 04/21] refactor: update FailSafeModeOverlay to simplify reboot handling and adjust video settings UI --- ui/src/components/FaileSafeModeOverlay.tsx | 84 +++++++--------------- ui/src/index.css | 2 +- ui/src/routes/devices.$id.settings.tsx | 13 ++-- ui/src/routes/devices.$id.tsx | 6 +- 4 files changed, 36 insertions(+), 69 deletions(-) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index 35c4af1a7..e7d37c52a 100644 --- a/ui/src/components/FaileSafeModeOverlay.tsx +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -35,7 +35,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { const { navigateTo } = useDeviceUiNavigation(); const { appVersion } = useVersion(); const { systemVersion } = useDeviceStore(); - const [showRebootConfirm, setShowRebootConfirm] = useState(false); const [isDownloadingLogs, setIsDownloadingLogs] = useState(false); const getReasonCopy = () => { @@ -55,19 +54,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { const { message } = getReasonCopy(); - const handleReboot = () => { - if (!showRebootConfirm) { - setShowRebootConfirm(true); - return; - } - - send("reboot", { force: true }, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(`Failed to reboot: ${resp.error.message}`); - } - }); - }; - const handleReportAndDownloadLogs = () => { setIsDownloadingLogs(true); @@ -146,52 +132,30 @@ Please attach the recovery logs file that was downloaded to your computer:

Fail safe mode activated

{message}

- {showRebootConfirm ? ( -
-

- Rebooting will restart your device. This may resolve the issue. Continue? -

-
-
-
- ) : ( -
-
- )} +
+
diff --git a/ui/src/index.css b/ui/src/index.css index 12f9141e0..876019fcd 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -358,7 +358,7 @@ video::-webkit-media-controls { .diagonal-stripes { background: repeating-linear-gradient( 135deg, - rgba(255, 0, 0, 0.2) 0 12px, /* red-50 with 20% opacity */ + 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.tsx b/ui/src/routes/devices.$id.settings.tsx index bb97003b1..3a69549e0 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -161,12 +161,13 @@ export default function SettingsRoute() {
(isActive ? "active" : "")} - > + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >

Video

@@ -174,11 +175,13 @@ export default function SettingsRoute() {
(isActive ? "active" : "")} + className={({ isActive }) => cx(isActive ? "active" : "", { + "pointer-events-none": isVideoDisabled + })} >
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index b85a3f445..f63c72dbd 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -484,9 +484,9 @@ export default function KvmIdRoute() { rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); - // setTimeout(() => { - // useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); - // }, 1000); + setTimeout(() => { + useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); + }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); From 6afb15b29a8db256f5c2954d57ad842eb79271e3 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:50:49 +0100 Subject: [PATCH 05/21] chore: comment out failsafe mode timeout in KvmIdRoute component --- ui/src/routes/devices.$id.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index f63c72dbd..b85a3f445 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -484,9 +484,9 @@ export default function KvmIdRoute() { rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); - setTimeout(() => { - useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); - }, 1000); + // setTimeout(() => { + // useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); + // }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); From 239c2dc9327a92190cab9e099ec671f7df66843f Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 12:22:26 +0000 Subject: [PATCH 06/21] chore: backport supervisor changes --- cmd/main.go | 101 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 80 insertions(+), 21 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 59033c476..d9636088e 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() { @@ -117,30 +117,47 @@ func supervise() error { return nil } -func createErrorDump(logFile *os.File) { - logFile.Close() +func isSymlinkTo(oldName, newName string) bool { + file, err := os.Stat(newName) + if err != nil { + return false + } + if file.Mode()&os.ModeSymlink != os.ModeSymlink { + return false + } + target, err := os.Readlink(newName) + if err != nil { + return false + } + return target == oldName +} - // touch the error dump state file - if err := os.WriteFile(filepath.Join(errorDumpDir, errorDumpStateFile), []byte{}, 0644); err != nil { - return +func ensureSymlink(oldName, newName string) error { + if isSymlinkTo(oldName, newName) { + return nil } + _ = os.Remove(newName) + return os.Symlink(oldName, newName) +} - 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 +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 } - fnSrc, err := os.Open(logFile.Name()) + // copy the log file to the error dump directory + fnSrc, err := os.Open(f.Name()) if err != nil { - return + 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 +165,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() { From 03ab8d8285453897d1538c0273c1378b508cc349 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 12:51:17 +0000 Subject: [PATCH 07/21] feat: add failsafe mode to recover from infinite restarts caused by cgo panics --- cmd/main.go | 7 +- internal/native/cgo_linux.go | 127 ++++++++++++++++++++++++++++++++++- internal/native/native.go | 9 +++ log.go | 1 + main.go | 5 ++ native.go | 1 + 6 files changed, 148 insertions(+), 2 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index d9636088e..9a1e18997 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/internal/native/cgo_linux.go b/internal/native/cgo_linux.go index 850da0e8e..be1a5a361 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 2a9055cea..0a7732466 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/log.go b/log.go index 2047bbfa2..9cd9188e6 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 81c854315..d09717134 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,11 @@ import ( var appCtx context.Context func Main() { + checkFailsafeReason() + if shouldActivateFailsafe { + logger.Warn().Str("reason", shouldActivateFailsafeReason).Msg("failsafe mode activated") + } + LoadConfig() var cancel context.CancelFunc diff --git a/native.go b/native.go index d7c85b0b2..8cbd181e8 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: shouldActivateFailsafe, SystemVersion: systemVersion, AppVersion: appVersion, DisplayRotation: config.GetDisplayRotation(), From 99a60120e550c63a685567fe523719d7f6a58a8e Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 15:02:18 +0000 Subject: [PATCH 08/21] feat: add failsafe mode to recover from infinite restarts caused by cgo panics --- failsafe.go | 105 +++++++++++++++++++++ jsonrpc.go | 1 + main.go | 4 +- native.go | 2 +- ui/src/components/FaileSafeModeOverlay.tsx | 2 +- ui/src/hooks/stores.ts | 6 +- ui/src/routes/devices.$id.tsx | 6 +- video.go | 1 + webrtc.go | 3 + 9 files changed, 120 insertions(+), 10 deletions(-) create mode 100644 failsafe.go diff --git a/failsafe.go b/failsafe.go new file mode 100644 index 000000000..1168b9e0d --- /dev/null +++ b/failsafe.go @@ -0,0 +1,105 @@ +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 { + 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/jsonrpc.go b/jsonrpc.go index 99e7bdcf2..ea6c6fba5 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/main.go b/main.go index d09717134..29a742ed3 100644 --- a/main.go +++ b/main.go @@ -15,8 +15,8 @@ var appCtx context.Context func Main() { checkFailsafeReason() - if shouldActivateFailsafe { - logger.Warn().Str("reason", shouldActivateFailsafeReason).Msg("failsafe mode activated") + if failsafeModeActive { + logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated") } LoadConfig() diff --git a/native.go b/native.go index 8cbd181e8..c42cad3ca 100644 --- a/native.go +++ b/native.go @@ -17,7 +17,7 @@ var ( func initNative(systemVersion *semver.Version, appVersion *semver.Version) { nativeInstance = native.NewNative(native.NativeOptions{ - Disable: shouldActivateFailsafe, + Disable: failsafeModeActive, SystemVersion: systemVersion, AppVersion: appVersion, DisplayRotation: config.GetDisplayRotation(), diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index e7d37c52a..c01637471 100644 --- a/ui/src/components/FaileSafeModeOverlay.tsx +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -108,7 +108,7 @@ Please attach the recovery logs file that was downloaded to your computer: }; const handleDowngrade = () => { - navigateTo("/settings/general/update?appVersion=0.4.8"); + navigateTo("/settings/general/update?app=0.4.8"); }; return ( diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index b1270b1c8..047bdabaf 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -930,12 +930,12 @@ export const useMacrosStore = create((set, get) => ({ export interface FailsafeModeState { isFailsafeMode: boolean; reason: string | null; // "video", "network", etc. - setFailsafeMode: (enabled: boolean, reason: string | null) => void; + setFailsafeMode: (active: boolean, reason: string | null) => void; } export const useFailsafeModeStore = create(set => ({ isFailsafeMode: false, reason: null, - setFailsafeMode: (enabled: boolean, reason: string | null) => - set({ isFailsafeMode: enabled, reason }), + setFailsafeMode: (active: boolean, reason: string | null) => + set({ isFailsafeMode: active, reason }), })); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index b85a3f445..e0c6ec2be 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -676,9 +676,9 @@ export default function KvmIdRoute() { } if (resp.method === "failsafeMode") { - const { enabled, reason } = resp.params as { enabled: boolean; reason: string }; - console.debug("Setting failsafe mode", { enabled, reason }); - setFailsafeMode(enabled, reason); + const { active, reason } = resp.params as { active: boolean; reason: string }; + console.debug("Setting failsafe mode", { active, reason }); + setFailsafeMode(active, reason); } } diff --git a/video.go b/video.go index cd74e6804..7ce342a7d 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 37488f778..abe1aba7e 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() } From 72f29df8355890aca451f1de9d601415b00db60d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 15:18:50 +0000 Subject: [PATCH 09/21] fix: ignore errors when crash log doesn't exist --- failsafe.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/failsafe.go b/failsafe.go index 1168b9e0d..3c6b3d3aa 100644 --- a/failsafe.go +++ b/failsafe.go @@ -54,7 +54,9 @@ func checkFailsafeReason() { l := failsafeLogger.With().Str("path", lastCrashPath).Logger() fi, err := os.Lstat(lastCrashPath) if err != nil { - l.Warn().Err(err).Msg("failed to stat last crash log") + if !os.IsNotExist(err) { + l.Warn().Err(err).Msg("failed to stat last crash log") + } return } From 82ad2a467f361cba1b41820eff0ee04d455ace5e Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 17:22:02 +0100 Subject: [PATCH 10/21] feat: enhance FailSafeModeOverlay with tooltip and log download improvements --- ui/src/components/FaileSafeModeOverlay.tsx | 102 +++++++++++++----- .../devices.$id.settings.general.reboot.tsx | 47 +++++--- ui/src/routes/devices.$id.tsx | 17 ++- 3 files changed, 112 insertions(+), 54 deletions(-) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index c01637471..774be1691 100644 --- a/ui/src/components/FaileSafeModeOverlay.tsx +++ b/ui/src/components/FaileSafeModeOverlay.tsx @@ -1,9 +1,10 @@ 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 { GridCard } from "@components/Card"; +import Card, { GridCard } from "@components/Card"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { useVersion } from "@/hooks/useVersion"; @@ -30,19 +31,47 @@ function OverlayContent({ children }: OverlayContentProps) { ); } +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. You can reboot to attempt recovery, report the issue, or downgrade to the last stable version.", + "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 { @@ -80,13 +109,14 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { document.body.removeChild(a); URL.revokeObjectURL(url); - notifications.success("Recovery logs downloaded successfully"); + notifications.success("Crash logs downloaded successfully"); + setHasDownloadedLogs(true); // Open GitHub issue const issueBody = `## Issue Description -The ${reason} process encountered an error and recovery mode was activated. +The \`${reason}\` process encountered an error and fail safe mode was activated. -**Reason:** ${reason} +**Reason:** \`${reason}\` **Timestamp:** ${new Date().toISOString()} **App Version:** ${appVersion || "Unknown"} **System Version:** ${systemVersion || "Unknown"} @@ -95,6 +125,9 @@ The ${reason} process encountered an error and recovery mode was activated. Please attach the recovery logs file that was downloaded to your computer: \`${filename}\` +> [!NOTE] +> Please omit any sensitive information from the logs. + ## Additional Context [Please describe what you were doing when this occurred]`; @@ -114,7 +147,7 @@ Please attach the recovery logs file that was downloaded to your computer: return ( Fail safe mode activated

{message}

-
-
+ +
diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index db0e05309..bb7daddd9 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -1,28 +1,39 @@ 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 LoadingSpinner from "../components/LoadingSpinner"; +import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; + export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); const { send } = useJsonRpc(); + const [isRebooting, setIsRebooting] = useState(false); + const { navigateTo } = useDeviceUiNavigation(); - 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, 5000)); + navigateTo("/"); + }, [navigateTo, send]); { /* 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 +41,22 @@ export function Dialog({ return (
- +
); } function ConfirmationBox({ + isRebooting, onYes, onNo, }: { + isRebooting: boolean; onYes: () => void; onNo: () => void; }) { @@ -55,11 +69,16 @@ function ConfirmationBox({

Do you want to proceed with rebooting the system?

- -
-
+ {isRebooting ? ( +
+ +
+ ) : ( +
+
+ )} ); diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index e0c6ec2be..ff0836f3c 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -126,7 +126,7 @@ export default function KvmIdRoute() { const params = useParams() as { id: string }; const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); - const [ queryParams, setQueryParams ] = useSearchParams(); + const [queryParams, setQueryParams] = useSearchParams(); const { peerConnection, setPeerConnection, @@ -483,10 +483,6 @@ export default function KvmIdRoute() { const rpcDataChannel = pc.createDataChannel("rpc"); rpcDataChannel.onopen = () => { setRpcDataChannel(rpcDataChannel); - - // setTimeout(() => { - // useFailsafeModeStore.setState({ isFailsafeMode: true, reason: "video" }); - // }, 1000); }; const rpcHidChannel = pc.createDataChannel("hidrpc"); @@ -604,10 +600,10 @@ export default function KvmIdRoute() { }); }, 10000); - const { setNetworkState} = useNetworkStateStore(); + const { setNetworkState } = useNetworkStateStore(); const { setHdmiState } = useVideoStore(); const { - keyboardLedState, setKeyboardLedState, + keyboardLedState, setKeyboardLedState, keysDownState, setKeysDownState, setUsbState, } = useHidStore(); const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled); @@ -770,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; @@ -863,10 +859,9 @@ export default function KvmIdRoute() { className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4" >
- {!!ConnectionStatusElement && ConnectionStatusElement} - {isFailsafeMode && failsafeReason && ( + {isFailsafeMode && failsafeReason ? ( - )} + ) : !!ConnectionStatusElement && ConnectionStatusElement}
From 5933adb23bce6b2bfb2f39618e0577fee1d57b1f Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 17:56:00 +0100 Subject: [PATCH 11/21] feat: implement FailSafeModeOverlay component with log download and issue reporting functionality --- .../{FaileSafeModeOverlay.tsx => FailSafeModeOverlay.tsx} | 0 ui/src/hooks/useJsonRpc.ts | 2 +- ui/src/routes/devices.$id.settings.general.reboot.tsx | 5 ++++- ui/src/routes/devices.$id.settings.tsx | 2 +- ui/src/routes/devices.$id.tsx | 2 +- 5 files changed, 7 insertions(+), 4 deletions(-) rename ui/src/components/{FaileSafeModeOverlay.tsx => FailSafeModeOverlay.tsx} (100%) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx similarity index 100% rename from ui/src/components/FaileSafeModeOverlay.tsx rename to ui/src/components/FailSafeModeOverlay.tsx diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5ad4d366a..05214e00d 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -50,7 +50,7 @@ const blockedMethodsByReason: Record = { export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { const { rpcDataChannel } = useRTCStore(); - const { isFailsafeMode: isFailsafeMode, reason } = useFailsafeModeStore(); + const { isFailsafeMode, reason } = useFailsafeModeStore(); const send = useCallback( async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index bb7daddd9..5404e5b88 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -7,6 +7,9 @@ import { Button } from "@components/Button"; 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(); @@ -18,7 +21,7 @@ export default function SettingsGeneralRebootRoute() { // This is where we send the RPC to the golang binary send("reboot", { force: true }); - await new Promise(resolve => setTimeout(resolve, 5000)); + await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS)); navigateTo("/"); }, [navigateTo, send]); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 3a69549e0..1d15fa2e3 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -161,7 +161,7 @@ export default function SettingsRoute() {
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/FaileSafeModeOverlay"; +import { FailSafeModeOverlay } from "@components/FailSafeModeOverlay"; import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { ConnectionFailedOverlay, From 502cd4ecc279a1c720816e27946e8968f67aa075 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 18:20:17 +0100 Subject: [PATCH 12/21] fix: handle mDNS initialization error without exiting the application --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 29a742ed3..669c6f443 100644 --- a/main.go +++ b/main.go @@ -53,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) } @@ -63,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() From 6b052e7777b52b0d90393253eec372bfc9e17293 Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:29:26 +0100 Subject: [PATCH 13/21] Update ui/src/components/FailSafeModeOverlay.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- ui/src/components/FailSafeModeOverlay.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx index 774be1691..2a424c0cc 100644 --- a/ui/src/components/FailSafeModeOverlay.tsx +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -114,7 +114,7 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { // Open GitHub issue const issueBody = `## Issue Description -The \`${reason}\` process encountered an error and fail safe mode was activated. +The \`${reason}\` process encountered an error and failsafe mode was activated. **Reason:** \`${reason}\` **Timestamp:** ${new Date().toISOString()} From 9115362956fa2771f718d828191fe4d65bc9014d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 11:57:07 +0000 Subject: [PATCH 14/21] chore: make downgrade version configurable --- ui/src/components/FailSafeModeOverlay.tsx | 13 +++++++++---- ui/src/ui.config.ts | 2 ++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx index 2a424c0cc..2aff7412f 100644 --- a/ui/src/components/FailSafeModeOverlay.tsx +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -10,9 +10,12 @@ 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; } @@ -86,7 +89,7 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { const handleReportAndDownloadLogs = () => { setIsDownloadingLogs(true); - send("getFailSafeLogs", {}, (resp: JsonRpcResponse) => { + send("getFailSafeLogs", {}, async (resp: JsonRpcResponse) => { setIsDownloadingLogs(false); if ("error" in resp) { @@ -105,7 +108,9 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) { 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); @@ -126,7 +131,7 @@ Please attach the recovery logs file that was downloaded to your computer: \`${filename}\` > [!NOTE] -> Please omit any sensitive information from the logs. +> 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]`; @@ -141,7 +146,7 @@ Please attach the recovery logs file that was downloaded to your computer: }; const handleDowngrade = () => { - navigateTo("/settings/general/update?app=0.4.8"); + navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`); }; return ( @@ -192,7 +197,7 @@ Please attach the recovery logs file that was downloaded to your computer: size="SM" onClick={handleDowngrade} theme="light" - text="Downgrade to v0.4.8" + text={`Downgrade to v${DOWNGRADE_VERSION}`} disabled={!hasDownloadedLogs} /> diff --git a/ui/src/ui.config.ts b/ui/src/ui.config.ts index b76dd7c46..3cb3b58be 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 = ""; From 426cd6fe56957fd8d9c0fbee4281f5bfdad6296e Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 10 Nov 2025 16:17:37 +0100 Subject: [PATCH 15/21] fix: update KvmIdRoute to conditionally render WebRTCVideo based on failsafeReason --- ui/src/routes/devices.$id.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index c88af2a2f..7455c4123 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -853,7 +853,7 @@ export default function KvmIdRoute() { />
- {!isFailsafeMode && } + {!isFailsafeMode && failsafeReason === "video" && }
Date: Mon, 10 Nov 2025 16:19:02 +0100 Subject: [PATCH 16/21] fix: simplify tooltip text in FailSafeModeOverlay for clarity --- ui/src/components/FailSafeModeOverlay.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx index 2aff7412f..eadc5d9db 100644 --- a/ui/src/components/FailSafeModeOverlay.tsx +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -182,7 +182,7 @@ Please attach the recovery logs file that was downloaded to your computer: />
- +