diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index e8eee9f2c..8266771d7 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -9,8 +9,9 @@ type gadgetConfigItem struct { order uint device string path []string - attrs gadgetAttributes - configAttrs gadgetAttributes + attrs gadgetAttributes + optionalAttrs gadgetAttributes // written with IgnoreErrors; for kernel features that may not exist + configAttrs gadgetAttributes configPath []string reportDesc []byte } @@ -34,7 +35,7 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{ "bcdDevice": "0x0100", // USB2 }, configAttrs: gadgetAttributes{ - "MaxPower": "250", // in unit of 2mA + "MaxPower": "250", // in unit of 2mA "bmAttributes": "0xa0", // bus-powered + remote wakeup }, }, diff --git a/internal/usbgadget/config_tx.go b/internal/usbgadget/config_tx.go index f1e87a07f..750feb2ee 100644 --- a/internal/usbgadget/config_tx.go +++ b/internal/usbgadget/config_tx.go @@ -221,6 +221,16 @@ func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, dep )...) } + if len(item.optionalAttrs) > 0 { + // write optional attributes (IgnoreErrors=true for backward compat with older kernels) + files = append(files, tx.writeGadgetAttrsOptional( + gadgetItemPath, + item.optionalAttrs, + component, + beforeChange, + )...) + } + // write report descriptor if available reportDescPath := path.Join(gadgetItemPath, "report_desc") if item.reportDesc != nil { @@ -295,6 +305,24 @@ func (tx *UsbGadgetTransaction) writeGadgetAttrs(basePath string, attrs gadgetAt return files } +func (tx *UsbGadgetTransaction) writeGadgetAttrsOptional(basePath string, attrs gadgetAttributes, component string, beforeChange []string) (files []string) { + files = make([]string, 0) + for key, val := range attrs { + filePath := filepath.Join(basePath, key) + tx.addFileChange(component, RequestedFileChange{ + Path: filePath, + ExpectedState: FileStateFileContentMatch, + ExpectedContent: []byte(val), + Description: "write optional gadget attribute", + DependsOn: []string{basePath}, + BeforeChange: beforeChange, + IgnoreErrors: true, + }) + files = append(files, filePath) + } + return files +} + func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target string, deps []string) { tx.log.Trace().Str("path", path).Str("target", target).Msg("add reorder symlink change") diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 5b114be81..c511ce14f 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -23,6 +23,8 @@ var keyboardConfig = gadgetConfigItem{ "subclass": "1", "report_length": "8", "no_out_endpoint": "0", + }, + optionalAttrs: gadgetAttributes{ "wakeup_on_write": "1", }, reportDesc: keyboardReportDesc, diff --git a/internal/usbgadget/hid_mouse_absolute.go b/internal/usbgadget/hid_mouse_absolute.go index 93bde8f2f..09ea6675d 100644 --- a/internal/usbgadget/hid_mouse_absolute.go +++ b/internal/usbgadget/hid_mouse_absolute.go @@ -15,6 +15,8 @@ var absoluteMouseConfig = gadgetConfigItem{ "subclass": "0", "report_length": "6", "no_out_endpoint": "1", + }, + optionalAttrs: gadgetAttributes{ "wakeup_on_write": "1", }, reportDesc: absoluteMouseCombinedReportDesc, diff --git a/internal/usbgadget/hid_mouse_relative.go b/internal/usbgadget/hid_mouse_relative.go index 0cbe239a6..615b27fe8 100644 --- a/internal/usbgadget/hid_mouse_relative.go +++ b/internal/usbgadget/hid_mouse_relative.go @@ -15,6 +15,8 @@ var relativeMouseConfig = gadgetConfigItem{ "subclass": "1", "report_length": "5", "no_out_endpoint": "1", + }, + optionalAttrs: gadgetAttributes{ "wakeup_on_write": "1", }, reportDesc: relativeMouseCombinedReportDesc, diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 93004e077..a23e95bf0 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -1093,5 +1093,8 @@ "wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "welcome_to_jetkvm": "Welcome to JetKVM", - "welcome_to_jetkvm_description": "Control any computer remotely" + "welcome_to_jetkvm_description": "Control any computer remotely", + "video_overlay_no_hdmi_try_wake": "If the target computer is sleeping, you can try waking it with a simulated keyboard press", + "video_overlay_no_hdmi_wake_host": "Try Wake Host", + "video_overlay_no_hdmi_wake_host_sending": "Sending wake signal..." } diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index 1690aaebe..9ec2cf290 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -216,9 +216,11 @@ export function PeerConnectionDisconnectedOverlay({ show }: PeerConnectionDiscon interface HDMIErrorOverlayProps { readonly show: boolean; readonly hdmiState: string; + readonly onWakeHost?: () => void; + readonly isWaking?: boolean; } -export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { +export function HDMIErrorOverlay({ show, hdmiState, onWakeHost, isWaking }: HDMIErrorOverlayProps) { const isNoSignal = hdmiState === "no_signal"; const isOtherError = hdmiState === "no_lock" || hdmiState === "out_of_range"; @@ -247,9 +249,10 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
  • {m.video_overlay_no_hdmi_ensure_cable()}
  • {m.video_overlay_no_hdmi_ensure_power()}
  • {m.video_overlay_no_hdmi_adapter_compat()}
  • +
  • {m.video_overlay_no_hdmi_try_wake()}
  • -
    +
    + {onWakeHost && ( +
    diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index e475dfa67..a61a6b4cf 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -6,6 +6,7 @@ import { isWindows } from "@/utils"; import useKeyboard from "@hooks/useKeyboard"; import useMouse from "@hooks/useMouse"; import { useRTCStore, useSettingsStore, useUiStore, useVideoStore } from "@hooks/stores"; +import { useJsonRpc } from "@hooks/useJsonRpc"; import VirtualKeyboard from "@components/VirtualKeyboard"; import Actionbar from "@components/ActionBar"; import MacroBar from "@components/MacroBar"; @@ -30,6 +31,8 @@ export default function WebRTCVideo({ }) { // Video and stream related refs and states const videoElm = useRef(null); + const [isWaking, setIsWaking] = useState(false); + const { send } = useJsonRpc(); const fullscreenContainerRef = useRef(null); const { mediaStream, peerConnectionState } = useRTCStore(); const [isPlaying, setIsPlaying] = useState(false); @@ -39,6 +42,18 @@ export default function WebRTCVideo({ const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost"; + // Wake host handler - sends a spacebar press+release to wake sleeping host + const handleWakeHost = useCallback(() => { + setIsWaking(true); + // Send spacebar (HID usage 0x2C) press + send("keyboardReport", { keys: [0x2c, 0, 0, 0, 0, 0], modifier: 0 }, () => { + // Send key release + send("keyboardReport", { keys: [0, 0, 0, 0, 0, 0], modifier: 0 }, () => { + setTimeout(() => setIsWaking(false), 3000); + }); + }); + }, [send]); + // Store hooks const settings = useSettingsStore(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); @@ -675,7 +690,12 @@ export default function WebRTCVideo({ >
    - + {