From fb3e57aa865675556c17ef8121cccd6b8e4b00c1 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 7 Nov 2025 13:38:50 +0100 Subject: [PATCH 01/16] 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 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/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx new file mode 100644 index 00000000..80c56b0f --- /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 bfbbb26e..b1270b1c 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 5c52d59c..5ad4d366 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 b13fc3a1..12f9141e 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 338beb97..bb97003b 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/16] 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 80c56b0f..35c4af1a 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/16] 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 86cf083d..b85a3f44 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/16] refactor: update FailSafeModeOverlay to simplify reboot handling and adjust video settings UI --- ui/src/components/FaileSafeModeOverlay.tsx | 80 ++++++---------------- ui/src/index.css | 2 +- ui/src/routes/devices.$id.settings.tsx | 13 ++-- ui/src/routes/devices.$id.tsx | 6 +- 4 files changed, 34 insertions(+), 67 deletions(-) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index 35c4af1a..e7d37c52 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 12f9141e..876019fc 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 bb97003b..3a69549e 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 b85a3f44..f63c72db 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/16] 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 f63c72db..b85a3f44 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/16] chore: backport supervisor changes --- cmd/main.go | 111 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 85 insertions(+), 26 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 59033c47..d9636088 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() - - // 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 +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/16] 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 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/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/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..d0971713 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 d7c85b0b..8cbd181e 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/16] 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 00000000..1168b9e0 --- /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 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/main.go b/main.go index d0971713..29a742ed 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 8cbd181e..c42cad3c 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 e7d37c52..c0163747 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 b1270b1c..047bdaba 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 b85a3f44..e0c6ec2b 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 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() } From 72f29df8355890aca451f1de9d601415b00db60d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 15:18:50 +0000 Subject: [PATCH 09/16] 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 1168b9e0..3c6b3d3a 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/16] feat: enhance FailSafeModeOverlay with tooltip and log download improvements --- ui/src/components/FaileSafeModeOverlay.tsx | 98 ++++++++++++++----- .../devices.$id.settings.general.reboot.tsx | 47 ++++++--- ui/src/routes/devices.$id.tsx | 17 ++-- 3 files changed, 110 insertions(+), 52 deletions(-) diff --git a/ui/src/components/FaileSafeModeOverlay.tsx b/ui/src/components/FaileSafeModeOverlay.tsx index c0163747..774be169 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 db0e0530..bb7daddd 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 e0c6ec2b..ff0836f3 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/16] 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 5ad4d366..05214e00 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 bb7daddd..5404e5b8 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 3a69549e..1d15fa2e 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/16] 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 29a742ed..669c6f44 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/16] 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 774be169..2a424c0c 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/16] 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 2a424c0c..2aff7412 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 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 = ""; From 426cd6fe56957fd8d9c0fbee4281f5bfdad6296e Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 10 Nov 2025 16:17:37 +0100 Subject: [PATCH 15/16] 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 c88af2a2..7455c412 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/16] 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 2aff7412..eadc5d9d 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: />
- +