diff --git a/config.go b/config.go index 46f83e6..513b655 100644 --- a/config.go +++ b/config.go @@ -114,7 +114,7 @@ var defaultConfig = &Config{ ActiveExtension: "", KeyboardMacros: []KeyboardMacro{}, DisplayRotation: "270", - KeyboardLayout: "en_US", + KeyboardLayout: "en-US", DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes diff --git a/internal/usbgadget/hid_keyboard.go b/internal/usbgadget/hid_keyboard.go index 3b9f6c2..f4fbaa6 100644 --- a/internal/usbgadget/hid_keyboard.go +++ b/internal/usbgadget/hid_keyboard.go @@ -105,14 +105,14 @@ func (u *UsbGadget) updateKeyboardState(state byte) { defer u.keyboardStateLock.Unlock() if state&^ValidKeyboardLedMasks != 0 { - u.log.Error().Uint8("state", state).Msg("ignoring invalid bits") + u.log.Warn().Uint8("state", state).Msg("ignoring invalid bits") return } if u.keyboardState == state { return } - u.log.Trace().Interface("old", u.keyboardState).Interface("new", state).Msg("keyboardState updated") + u.log.Trace().Uint8("old", u.keyboardState).Uint8("new", state).Msg("keyboardState updated") u.keyboardState = state if u.onKeyboardStateChange != nil { @@ -131,23 +131,6 @@ func (u *UsbGadget) GetKeyboardState() KeyboardState { return getKeyboardState(u.keyboardState) } -const ( - // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C - ModifierMaskLeftControl = 0x01 - ModifierMaskRightControl = 0x10 - ModifierMaskLeftShift = 0x02 - ModifierMaskRightShift = 0x20 - ModifierMaskLeftAlt = 0x04 - ModifierMaskRightAlt = 0x40 - ModifierMaskLeftSuper = 0x08 - ModifierMaskRightSuper = 0x80 - - EitherShiftMask = ModifierMaskLeftShift | ModifierMaskRightShift - EitherControlMask = ModifierMaskLeftControl | ModifierMaskRightControl - EitherAltMask = ModifierMaskLeftAlt | ModifierMaskRightAlt - EitherSuperMask = ModifierMaskLeftSuper | ModifierMaskRightSuper -) - func (u *UsbGadget) GetKeysDownState() KeysDownState { u.keyboardStateLock.Lock() defer u.keyboardStateLock.Unlock() @@ -310,6 +293,18 @@ const ( RightSuper = 0xE7 // Right GUI (e.g. Windows key, Apple Command key) ) +const ( + // https://www.usb.org/sites/default/files/documents/hid1_11.pdf Appendix C + ModifierMaskLeftControl = 0x01 + ModifierMaskRightControl = 0x10 + ModifierMaskLeftShift = 0x02 + ModifierMaskRightShift = 0x20 + ModifierMaskLeftAlt = 0x04 + ModifierMaskRightAlt = 0x40 + ModifierMaskLeftSuper = 0x08 + ModifierMaskRightSuper = 0x80 +) + // KeyCodeToMaskMap is a slice of KeyCodeMask for quick lookup var KeyCodeToMaskMap = map[byte]byte{ LeftControl: ModifierMaskLeftControl, @@ -327,6 +322,11 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) defer u.keyboardLock.Unlock() defer u.resetUserInputTime() + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the client/browser-side code in useKeyboard.ts so make sure to keep + // them in sync. var state = u.keysDownState modifier := state.Modifier keys := append([]byte(nil), state.Keys...) @@ -334,7 +334,8 @@ func (u *UsbGadget) KeypressReport(key byte, press bool) (KeysDownState, error) if mask, exists := KeyCodeToMaskMap[key]; exists { // If the key is a modifier key, we update the keyboardModifier state // by setting or clearing the corresponding bit in the modifier byte. - // This allows us to track the state of modifier keys like Shift, Control, Alt, and Super. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. if press { modifier |= mask } else { diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index c8a214b..6a8e75e 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -26,17 +26,13 @@ export default function Actionbar({ requestFullscreen: () => Promise; }) { const { navigateTo } = useDeviceUiNavigation(); - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); + const { isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); + const { setDisableVideoFocusTrap, terminalType, setTerminalType, toggleSidebarView } = useUiStore(); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - const toggleSidebarView = useUiStore(state => state.toggleSidebarView); - const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - const terminalType = useUiStore(state => state.terminalType); - const setTerminalType = useUiStore(state => state.setTerminalType); const remoteVirtualMediaState = useMountMediaStore( state => state.remoteVirtualMediaState, ); - const developerMode = useSettingsStore(state => state.developerMode); + const { developerMode } = useSettingsStore(); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -47,13 +43,13 @@ export default function Actionbar({ isOpen.current = open; if (!open) { setTimeout(() => { - setDisableFocusTrap(false); + setDisableVideoFocusTrap(false); console.debug("Popover is closing. Returning focus trap to video"); }, 0); } } }, - [setDisableFocusTrap], + [setDisableVideoFocusTrap], ); return ( @@ -81,7 +77,7 @@ export default function Actionbar({ text="Paste text" LeadingIcon={MdOutlineContentPasteGo} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -123,7 +119,7 @@ export default function Actionbar({ ); }} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -154,7 +150,7 @@ export default function Actionbar({ theme="light" text="Wake on LAN" onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} LeadingIcon={({ className }) => ( setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} /> @@ -218,7 +214,7 @@ export default function Actionbar({ text="Extension" LeadingIcon={LuCable} onClick={() => { - setDisableFocusTrap(true); + setDisableVideoFocusTrap(true); }} /> @@ -243,7 +239,7 @@ export default function Actionbar({ theme="light" text="Virtual Keyboard" LeadingIcon={FaKeyboard} - onClick={() => setVirtualKeyboard(!virtualKeyboard)} + onClick={() => setVirtualKeyboardEnabled(!isVirtualKeyboardEnabled)} />
diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 543634a..4bb7a97 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -48,7 +48,7 @@ export default function DashboardNavbar({ navigate("/"); }, [navigate, setUser]); - const usbState = useHidStore(state => state.usbState); + const { usbState } = useHidStore(); // for testing //userEmail = "user@example.org"; diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index eafc380..29f159d 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -2,24 +2,18 @@ import { useEffect, useMemo } from "react"; import { cx } from "@/cva.config"; import { - HidState, - MouseState, - RTCState, - SettingsState, useHidStore, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, - VideoState, + VideoState } from "@/hooks/stores"; import { keys, modifiers } from "@/keyboardMappings"; export default function InfoBar() { - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const mouseX = useMouseStore((state: MouseState) => state.mouseX); - const mouseY = useMouseStore((state: MouseState) => state.mouseY); - const mouseMove = useMouseStore((state: MouseState) => state.mouseMove); + const { keysDownState } = useHidStore(); + const { mouseX, mouseY, mouseMove } = useMouseStore(); const videoClientSize = useVideoStore( (state: VideoState) => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, @@ -29,10 +23,8 @@ export default function InfoBar() { (state: VideoState) => `${Math.round(state.width)}x${Math.round(state.height)}`, ); - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); - - const settings = useSettingsStore(); - const showPressedKeys = useSettingsStore((state: SettingsState) => state.showPressedKeys); + const { rpcDataChannel } = useRTCStore(); + const { debugMode, mouseMode, showPressedKeys } = useSettingsStore(); useEffect(() => { if (!rpcDataChannel) return; @@ -41,11 +33,9 @@ export default function InfoBar() { console.error(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); }, [rpcDataChannel]); - const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); - const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); - - const usbState = useHidStore((state: HidState) => state.usbState); - const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); + const { keyboardLedState, usbState } = useHidStore(); + const { isTurnServerInUse } = useRTCStore(); + const { hdmiState } = useVideoStore(); const displayKeys = useMemo(() => { if (!showPressedKeys) @@ -64,21 +54,21 @@ export default function InfoBar() {
- {settings.debugMode ? ( + {debugMode ? (
Resolution:{" "} {videoSize}
) : null} - {settings.debugMode ? ( + {debugMode ? (
Video Size: {videoClientSize}
) : null} - {(settings.debugMode && settings.mouseMode == "absolute") ? ( + {(debugMode && mouseMode == "absolute") ? (
Pointer: @@ -87,7 +77,7 @@ export default function InfoBar() {
) : null} - {(settings.debugMode && settings.mouseMode == "relative") ? ( + {(debugMode && mouseMode == "relative") ? (
Last Move: @@ -98,13 +88,13 @@ export default function InfoBar() {
) : null} - {settings.debugMode && ( + {debugMode && (
USB State: {usbState}
)} - {settings.debugMode && ( + {debugMode && (
HDMI State: {hdmiState} diff --git a/ui/src/components/MacroBar.tsx b/ui/src/components/MacroBar.tsx index 066c21f..0ba8cf4 100644 --- a/ui/src/components/MacroBar.tsx +++ b/ui/src/components/MacroBar.tsx @@ -10,7 +10,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; export default function MacroBar() { const { macros, initialized, loadMacros, setSendFn } = useMacrosStore(); const { executeMacro } = useKeyboard(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); useEffect(() => { setSendFn(send); diff --git a/ui/src/components/Terminal.tsx b/ui/src/components/Terminal.tsx index f5d662d..ba3e667 100644 --- a/ui/src/components/Terminal.tsx +++ b/ui/src/components/Terminal.tsx @@ -1,6 +1,6 @@ import "react-simple-keyboard/build/css/index.css"; import { ChevronDownIcon } from "@heroicons/react/16/solid"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; @@ -65,21 +65,22 @@ function Terminal({ readonly dataChannel: RTCDataChannel; readonly type: AvailableTerminalTypes; }) { - const enableTerminal = useUiStore(state => state.terminalType == type); - const setTerminalType = useUiStore(state => state.setTerminalType); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - + const { terminalType, setTerminalType, setDisableVideoFocusTrap } = useUiStore(); const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG }); + const isTerminalTypeEnabled = useMemo(() => { + return terminalType == type; + }, [terminalType, type]); + useEffect(() => { setTimeout(() => { - setDisableVideoFocusTrap(enableTerminal); + setDisableVideoFocusTrap(isTerminalTypeEnabled); }, 500); return () => { setDisableVideoFocusTrap(false); }; - }, [enableTerminal, setDisableVideoFocusTrap]); + }, [setDisableVideoFocusTrap, isTerminalTypeEnabled]); const readyState = dataChannel.readyState; useEffect(() => { @@ -175,9 +176,9 @@ function Terminal({ ], { "pointer-events-none translate-y-[500px] opacity-100 transition duration-300": - !enableTerminal, + !isTerminalTypeEnabled, "pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300": - enableTerminal, + isTerminalTypeEnabled, }, )} > diff --git a/ui/src/components/USBStateStatus.tsx b/ui/src/components/USBStateStatus.tsx index 618a9c3..9321a19 100644 --- a/ui/src/components/USBStateStatus.tsx +++ b/ui/src/components/USBStateStatus.tsx @@ -4,9 +4,7 @@ import { cx } from "@/cva.config"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import LoadingSpinner from "@components/LoadingSpinner"; import StatusCard from "@components/StatusCards"; -import { HidState } from "@/hooks/stores"; - -type USBStates = HidState["usbState"]; +import { USBStates } from "@/hooks/stores"; type StatusProps = Record< USBStates, diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 432ec3d..2a5193c 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -59,7 +59,7 @@ const usbPresets = [ ]; export function UsbDeviceSetting() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [loading, setLoading] = useState(false); const [usbDeviceConfig, setUsbDeviceConfig] = diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index 4f63697..dc6b474 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -54,7 +54,7 @@ const usbConfigs = [ type UsbConfigMap = Record; export function UsbInfoSetting() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [loading, setLoading] = useState(false); const [usbConfigProduct, setUsbConfigProduct] = useState(""); @@ -205,7 +205,7 @@ function USBConfigDialog({ product: "", }); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const syncUsbConfig = useCallback(() => { send("getUsbConfig", {}, resp => { diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index fa7857d..16ccb9b 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -12,7 +12,7 @@ import "react-simple-keyboard/build/css/index.css"; import AttachIconRaw from "@/assets/attach-icon.svg"; import DetachIconRaw from "@/assets/detach-icon.svg"; import { cx } from "@/cva.config"; -import { HidState, useHidStore, useUiStore } from "@/hooks/stores"; +import { useHidStore, useUiStore } from "@/hooks/stores"; import useKeyboard from "@/hooks/useKeyboard"; import { keyDisplayMap, keys } from "@/keyboardMappings"; @@ -28,17 +28,8 @@ function KeyboardWrapper() { const [layoutName] = useState("default"); const keyboardRef = useRef(null); - const showAttachedVirtualKeyboard = useUiStore( - state => state.isAttachedVirtualKeyboardVisible, - ); - const setShowAttachedVirtualKeyboard = useUiStore( - state => state.setAttachedVirtualKeyboardVisibility, - ); - - const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); - const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); - - const keysDownState = useHidStore((state: HidState) => state.keysDownState); + const { isAttachedVirtualKeyboardVisible, setAttachedVirtualKeyboardVisibility } = useUiStore(); + const { keysDownState, isVirtualKeyboardEnabled, setVirtualKeyboardEnabled } = useHidStore(); const { handleKeyPress, executeMacro } = useKeyboard(); const [isDragging, setIsDragging] = useState(false); @@ -129,23 +120,23 @@ function KeyboardWrapper() { }, [endDrag, onDrag, startDrag]); const onKeyDown = useCallback( - (key: string) => { + async (key: string) => { const latchingKeys = ["CapsLock", "ScrollLock", "NumLock", "Meta", "Compose", "Kana"]; const dynamicKeys = ["ShiftLeft", "ShiftRight", "ControlLeft", "ControlRight", "AltLeft", "AltRight", "MetaLeft", "MetaRight"]; // handle the fake key-macros we have defined for common combinations if (key === "CtrlAltDelete") { - executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Delete"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } if (key === "AltMetaEscape") { - executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Escape"], modifiers: ["AltLeft", "MetaLeft"], delay: 100 } ]); return; } if (key === "CtrlAltBackspace") { - executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); + await executeMacro([ { keys: ["Backspace"], modifiers: ["ControlLeft", "AltLeft"], delay: 100 } ]); return; } @@ -175,7 +166,7 @@ function KeyboardWrapper() { ); // TODO handle the display of down keys and the layout change for shift/caps lock - // const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState.caps_lock)); + // const { isCapsLockActive } = useShallow(useHidStore()); // // Handle toggle of layout for shift or caps lock // const toggleLayout = () => { // setLayoutName(prevLayout => (prevLayout === "default" ? "shift" : "default")); @@ -185,11 +176,11 @@ function KeyboardWrapper() {
- {virtualKeyboard && ( + {isVirtualKeyboardEnabled && (
- {showAttachedVirtualKeyboard ? ( + {isAttachedVirtualKeyboardVisible ? (
@@ -245,7 +236,7 @@ function KeyboardWrapper() { theme="light" text="Hide" LeadingIcon={ChevronDownIcon} - onClick={() => setVirtualKeyboard(false)} + onClick={() => setVirtualKeyboardEnabled(false)} />
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 6e273f5..d4c7da4 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -11,14 +11,10 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { cx } from "@/cva.config"; import { keys } from "@/keyboardMappings"; import { - MouseState, - RTCState, - SettingsState, useMouseStore, useRTCStore, useSettingsStore, useVideoStore, - VideoState, } from "@/hooks/stores"; import { @@ -31,16 +27,14 @@ import { export default function WebRTCVideo() { // Video and stream related refs and states const videoElm = useRef(null); - const mediaStream = useRTCStore((state: RTCState) => state.mediaStream); + const { mediaStream, peerConnectionState } = useRTCStore(); const [isPlaying, setIsPlaying] = useState(false); - const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); const [isPointerLockActive, setIsPointerLockActive] = useState(false); const [isKeyboardLockActive, setIsKeyboardLockActive] = useState(false); // Store hooks const settings = useSettingsStore(); const { handleKeyPress, resetKeyboardState } = useKeyboard(); - const setMousePosition = useMouseStore((state: MouseState) => state.setMousePosition); - const setMouseMove = useMouseStore((state: MouseState) => state.setMouseMove); + const { setMousePosition, setMouseMove } = useMouseStore(); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -48,18 +42,16 @@ export default function WebRTCVideo() { height: videoHeight, clientWidth: videoClientWidth, clientHeight: videoClientHeight, + hdmiState, } = useVideoStore(); // Video enhancement settings - const videoSaturation = useSettingsStore((state: SettingsState) => state.videoSaturation); - const videoBrightness = useSettingsStore((state: SettingsState) => state.videoBrightness); - const videoContrast = useSettingsStore((state: SettingsState) => state.videoContrast); + const { videoSaturation, videoBrightness, videoContrast } = useSettingsStore(); // RTC related states - const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); + const { peerConnection } = useRTCStore(); // HDMI and UI states - const hdmiState = useVideoStore((state: VideoState) => state.hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const isVideoLoading = !isPlaying; @@ -67,14 +59,14 @@ export default function WebRTCVideo() { const [blockWheelEvent, setBlockWheelEvent] = useState(false); // Misc states and hooks - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); // Video-related const handleResize = useCallback( - ({ width, height }: { width: number; height: number }) => { + ( { width, height }: { width: number | undefined; height: number | undefined }) => { if (!videoElm.current) return; // Do something with width and height, e.g.: - setVideoClientSize(width, height); + setVideoClientSize(width || 0, height || 0); setVideoSize(videoElm.current.videoWidth, videoElm.current.videoHeight); }, [setVideoClientSize, setVideoSize] @@ -103,7 +95,7 @@ export default function WebRTCVideo() { function updateVideoSizeOnMount() { if (videoElm.current) updateVideoSizeStore(videoElm.current); }, - [setVideoClientSize, updateVideoSizeStore, setVideoSize], + [updateVideoSizeStore], ); // Pointer lock and keyboard lock related @@ -447,13 +439,7 @@ export default function WebRTCVideo() { // We set the as early as possible addStreamToVideoElm(mediaStream); }, - [ - setVideoClientSize, - mediaStream, - updateVideoSizeStore, - peerConnection, - addStreamToVideoElm, - ], + [addStreamToVideoElm, mediaStream], ); // Setup Keyboard Events diff --git a/ui/src/components/extensions/ATXPowerControl.tsx b/ui/src/components/extensions/ATXPowerControl.tsx index 0334a18..e276da1 100644 --- a/ui/src/components/extensions/ATXPowerControl.tsx +++ b/ui/src/components/extensions/ATXPowerControl.tsx @@ -23,7 +23,7 @@ export function ATXPowerControl() { > | null>(null); const [atxState, setAtxState] = useState(null); - const [send] = useJsonRpc(function onRequest(resp) { + const { send } = useJsonRpc(function onRequest(resp) { if (resp.method === "atxState") { setAtxState(resp.params as ATXState); } diff --git a/ui/src/components/extensions/DCPowerControl.tsx b/ui/src/components/extensions/DCPowerControl.tsx index a13e4ea..13bf128 100644 --- a/ui/src/components/extensions/DCPowerControl.tsx +++ b/ui/src/components/extensions/DCPowerControl.tsx @@ -19,7 +19,7 @@ interface DCPowerState { } export function DCPowerControl() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [powerState, setPowerState] = useState(null); const getDCPowerState = useCallback(() => { diff --git a/ui/src/components/extensions/SerialConsole.tsx b/ui/src/components/extensions/SerialConsole.tsx index 544d3fd..d19f1f0 100644 --- a/ui/src/components/extensions/SerialConsole.tsx +++ b/ui/src/components/extensions/SerialConsole.tsx @@ -17,7 +17,7 @@ interface SerialSettings { } export function SerialConsole() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [settings, setSettings] = useState({ baudRate: "9600", dataBits: "8", @@ -49,7 +49,7 @@ export function SerialConsole() { setSettings(newSettings); }); }; - const setTerminalType = useUiStore(state => state.setTerminalType); + const { setTerminalType } = useUiStore(); return (
diff --git a/ui/src/components/popovers/ExtensionPopover.tsx b/ui/src/components/popovers/ExtensionPopover.tsx index 10ee2ea..f6ec1f1 100644 --- a/ui/src/components/popovers/ExtensionPopover.tsx +++ b/ui/src/components/popovers/ExtensionPopover.tsx @@ -39,7 +39,7 @@ const AVAILABLE_EXTENSIONS: Extension[] = [ ]; export default function ExtensionPopover() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [activeExtension, setActiveExtension] = useState(null); // Load active extension on component mount diff --git a/ui/src/components/popovers/MountPopover.tsx b/ui/src/components/popovers/MountPopover.tsx index 752398b..86ba623 100644 --- a/ui/src/components/popovers/MountPopover.tsx +++ b/ui/src/components/popovers/MountPopover.tsx @@ -21,8 +21,8 @@ import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import notifications from "@/notifications"; const MountPopopover = forwardRef((_props, ref) => { - const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); - const [send] = useJsonRpc(); + const { diskDataChannelStats } = useRTCStore(); + const { send } = useJsonRpc(); const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } = useMountMediaStore(); diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 23a504a..0b69718 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -25,25 +25,23 @@ const noModifier = 0 export default function PasteModal() { const TextAreaRef = useRef(null); - const setPasteMode = useHidStore(state => state.setPasteModeEnabled); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + const { setPasteModeEnabled } = useHidStore(); + const { setDisableVideoFocusTrap } = useUiStore(); - const [send] = useJsonRpc(); - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); + const { send } = useJsonRpc(); + const { rpcDataChannel } = useRTCStore(); const [invalidChars, setInvalidChars] = useState([]); const close = useClose(); - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - - // this ensures we always get the original en_US if it hasn't been set yet + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); + + // this ensures we always get the en-US if it hasn't been set yet + // and if we get en_US from the backend, we convert it to en-US const safeKeyboardLayout = useMemo(() => { if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; + return keyboardLayout.replace("en_US", "en-US"); + return "en-US"; }, [keyboardLayout]); useEffect(() => { @@ -54,13 +52,13 @@ export default function PasteModal() { }, [send, setKeyboardLayout]); const onCancelPasteMode = useCallback(() => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); setInvalidChars([]); - }, [setDisableVideoFocusTrap, setPasteMode]); + }, [setDisableVideoFocusTrap, setPasteModeEnabled]); const onConfirmPaste = useCallback(async () => { - setPasteMode(false); + setPasteModeEnabled(false); setDisableVideoFocusTrap(false); if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; @@ -111,7 +109,7 @@ export default function PasteModal() { ); }); } - }, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteMode]); + }, [rpcDataChannel?.readyState, safeKeyboardLayout, send, setDisableVideoFocusTrap, setPasteModeEnabled]); useEffect(() => { if (TextAreaRef.current) { diff --git a/ui/src/components/popovers/WakeOnLan/Index.tsx b/ui/src/components/popovers/WakeOnLan/Index.tsx index 1cf7f18..e801052 100644 --- a/ui/src/components/popovers/WakeOnLan/Index.tsx +++ b/ui/src/components/popovers/WakeOnLan/Index.tsx @@ -14,11 +14,9 @@ import AddDeviceForm from "./AddDeviceForm"; export default function WakeOnLanModal() { const [storedDevices, setStoredDevices] = useState([]); const [showAddForm, setShowAddForm] = useState(false); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); - - const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); - - const [send] = useJsonRpc(); + const { setDisableVideoFocusTrap } = useUiStore(); + const { rpcDataChannel } = useRTCStore(); + const { send } = useJsonRpc(); const close = useClose(); const [errorMessage, setErrorMessage] = useState(null); const [addDeviceErrorMessage, setAddDeviceErrorMessage] = useState(null); diff --git a/ui/src/components/sidebar/connectionStats.tsx b/ui/src/components/sidebar/connectionStats.tsx index 404deb1..3faf81b 100644 --- a/ui/src/components/sidebar/connectionStats.tsx +++ b/ui/src/components/sidebar/connectionStats.tsx @@ -37,10 +37,18 @@ function createChartArray( } export default function ConnectionStatsSidebar() { - const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); - - const candidatePairStats = useRTCStore(state => state.candidatePairStats); - const setSidebarView = useUiStore(state => state.setSidebarView); + const { sidebarView, setSidebarView } = useUiStore(); + const { + mediaStream, + peerConnection, + inboundRtpStats, + appendInboundRtpStats, + candidatePairStats, + appendCandidatePairStats, + appendLocalCandidateStats, + appendRemoteCandidateStats, + appendDiskDataChannelStats, + } = useRTCStore(); function isMetricSupported( stream: Map, @@ -49,20 +57,6 @@ export default function ConnectionStatsSidebar() { return Array.from(stream).some(([, stat]) => stat[metric] !== undefined); } - const appendInboundRtpStats = useRTCStore(state => state.appendInboundRtpStats); - const appendIceCandidatePair = useRTCStore(state => state.appendCandidatePairStats); - const appendDiskDataChannelStats = useRTCStore( - state => state.appendDiskDataChannelStats, - ); - const appendLocalCandidateStats = useRTCStore(state => state.appendLocalCandidateStats); - const appendRemoteCandidateStats = useRTCStore( - state => state.appendRemoteCandidateStats, - ); - - const peerConnection = useRTCStore(state => state.peerConnection); - const mediaStream = useRTCStore(state => state.mediaStream); - const sidebarView = useUiStore(state => state.sidebarView); - useInterval(function collectWebRTCStats() { (async () => { if (!mediaStream) return; @@ -80,8 +74,7 @@ export default function ConnectionStatsSidebar() { successfulLocalCandidateId = report.localCandidateId; successfulRemoteCandidateId = report.remoteCandidateId; } - - appendIceCandidatePair(report); + appendCandidatePairStats(report); } else if (report.type === "local-candidate") { // We only want to append the local candidate stats that were used in nominated candidate pair if (successfulLocalCandidateId === report.id) { diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index c5d714c..f071825 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -235,9 +235,12 @@ export const useMouseStore = create(set => ({ setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), })); +export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; +export type HdmiErrorStates = Extract + export interface HdmiState { ready: boolean; - error?: Extract; + error?: HdmiErrorStates; } export interface VideoState { @@ -247,19 +250,13 @@ export interface VideoState { clientHeight: number; setClientSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void; - hdmiState: "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; + hdmiState: HdmiStates; setHdmiState: (state: { ready: boolean; - error?: Extract; + error?: HdmiErrorStates; }) => void; } -export interface BacklightSettings { - max_brightness: number; - dim_after: number; - off_after: number; -} - export const useVideoStore = create(set => ({ width: 0, height: 0, @@ -288,6 +285,12 @@ export const useVideoStore = create(set => ({ }, })); +export interface BacklightSettings { + max_brightness: number; + dim_after: number; + off_after: number; +} + export interface SettingsState { isCursorHidden: boolean; setCursorVisibility: (enabled: boolean) => void; @@ -444,6 +447,13 @@ export interface KeysDownState { keys: number[]; } +export type USBStates = + | "configured" + | "attached" + | "not attached" + | "suspended" + | "addressed"; + export interface HidState { keyboardLedState: KeyboardLedState; setKeyboardLedState: (state: KeyboardLedState) => void; @@ -451,8 +461,8 @@ export interface HidState { keysDownState: KeysDownState; setKeysDownState: (state: KeysDownState) => void; - keyPressAvailable: boolean; - setKeyPressAvailable: (available: boolean) => void; + keyPressReportApiAvailable: boolean; + setkeyPressReportApiAvailable: (available: boolean) => void; isVirtualKeyboardEnabled: boolean; setVirtualKeyboardEnabled: (enabled: boolean) => void; @@ -460,8 +470,8 @@ export interface HidState { isPasteModeEnabled: boolean; setPasteModeEnabled: (enabled: boolean) => void; - usbState: "configured" | "attached" | "not attached" | "suspended" | "addressed"; - setUsbState: (state: HidState["usbState"]) => void; + usbState: USBStates; + setUsbState: (state: USBStates) => void; } export const useHidStore = create(set => ({ @@ -471,8 +481,8 @@ export const useHidStore = create(set => ({ keysDownState: { modifier: 0, keys: [0,0,0,0,0,0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), - keyPressAvailable: true, - setKeyPressAvailable: (available: boolean) => set({ keyPressAvailable: available }), + keyPressReportApiAvailable: true, + setkeyPressReportApiAvailable: (available: boolean) => set({ keyPressReportApiAvailable: available }), isVirtualKeyboardEnabled: false, setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), @@ -482,7 +492,7 @@ export const useHidStore = create(set => ({ // Add these new properties for USB state usbState: "not attached", - setUsbState: (state: HidState["usbState"]) => set({ usbState: state }), + setUsbState: (state: USBStates) => set({ usbState: state }), })); export const useUserStore = create(set => ({ @@ -490,11 +500,15 @@ export const useUserStore = create(set => ({ setUser: user => set({ user }), })); -export interface UpdateState { - isUpdatePending: boolean; - setIsUpdatePending: (isPending: boolean) => void; - updateDialogHasBeenMinimized: boolean; - otaState: { +export type UpdateModalViews = + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; + +export interface OtaState { updating: boolean; error: string | null; @@ -523,17 +537,17 @@ export interface UpdateState { systemUpdateProgress: number; systemUpdatedAt: string | null; - }; - setOtaState: (state: UpdateState["otaState"]) => void; +}; + +export interface UpdateState { + isUpdatePending: boolean; + setIsUpdatePending: (isPending: boolean) => void; + updateDialogHasBeenMinimized: boolean; + otaState: OtaState; + setOtaState: (state: OtaState) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; - modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; - setModalView: (view: UpdateState["modalView"]) => void; + modalView: UpdateModalViews + setModalView: (view: UpdateModalViews) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; } @@ -567,15 +581,19 @@ export const useUpdateStore = create(set => ({ setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => set({ updateDialogHasBeenMinimized: hasBeenMinimized }), modalView: "loading", - setModalView: (view: UpdateState["modalView"]) => set({ modalView: view }), + setModalView: (view: UpdateModalViews) => set({ modalView: view }), updateErrorMessage: null, setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }), })); -interface UsbConfigModalState { - modalView: "updateUsbConfig" | "updateUsbConfigSuccess"; +export type UsbConfigModalViews = + | "updateUsbConfig" + | "updateUsbConfigSuccess"; + +export interface UsbConfigModalState { + modalView: UsbConfigModalViews ; errorMessage: string | null; - setModalView: (view: UsbConfigModalState["modalView"]) => void; + setModalView: (view: UsbConfigModalViews) => void; setErrorMessage: (message: string | null) => void; } @@ -590,24 +608,26 @@ export interface UsbConfigState { export const useUsbConfigModalStore = create(set => ({ modalView: "updateUsbConfig", errorMessage: null, - setModalView: (view: UsbConfigModalState["modalView"]) => set({ modalView: view }), + setModalView: (view: UsbConfigModalViews) => set({ modalView: view }), setErrorMessage: (message: string | null) => set({ errorMessage: message }), })); -interface LocalAuthModalState { - modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; - setModalView: (view: LocalAuthModalState["modalView"]) => void; +export type LocalAuthModalViews = + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; + +export interface LocalAuthModalState { + modalView:LocalAuthModalViews; + setModalView: (view:LocalAuthModalViews) => void; } export const useLocalAuthModalStore = create(set => ({ modalView: "createPassword", - setModalView: (view: LocalAuthModalState["modalView"]) => set({ modalView: view }), + setModalView: (view: LocalAuthModalViews) => set({ modalView: view }), })); export interface DeviceState { diff --git a/ui/src/hooks/useJsonRpc.ts b/ui/src/hooks/useJsonRpc.ts index 5f088dc..a50c15b 100644 --- a/ui/src/hooks/useJsonRpc.ts +++ b/ui/src/hooks/useJsonRpc.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect } from "react"; -import { RTCState, useRTCStore } from "@/hooks/stores"; +import { useRTCStore } from "@/hooks/stores"; export interface JsonRpcRequest { jsonrpc: string; @@ -33,7 +33,7 @@ const callbackStore = new Map void>( let requestCounter = 0; export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); const send = useCallback( (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => { @@ -45,7 +45,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { rpcDataChannel.send(JSON.stringify(payload)); }, - [rpcDataChannel], + [rpcDataChannel] ); useEffect(() => { @@ -76,7 +76,8 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) { return () => { rpcDataChannel.removeEventListener("message", messageHandler); }; - }, [rpcDataChannel, onRequest]); + }, + [rpcDataChannel, onRequest]); - return [send]; + return { send }; } diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index f9d9b04..a6a4582 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -1,20 +1,32 @@ import { useCallback } from "react"; -import { KeysDownState, HidState, useHidStore, RTCState, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; +import { KeysDownState, useHidStore, useRTCStore, hidKeyBufferSize, hidErrorRollOver } from "@/hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; export default function useKeyboard() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); + const { rpcDataChannel } = useRTCStore(); - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); + const { keysDownState, setKeysDownState } = useHidStore(); - const keyPressAvailable = useHidStore((state: HidState) => state.keyPressAvailable); - const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); + // INTRODUCTION: The earlier version of the JetKVM device shipped with all keyboard state + // being tracked on the browser/client-side. When adding the keyPressReport API to the + // device-side code, we have to still support the situation where the browser/client-side code + // is running on the cloud against a device that has not been updated yet and thus does not + // support the keyPressReport API. In that case, we need to handle the key presses locally + // and send the full state to the device, so it can behave like a real USB HID keyboard. + // This flag indicates whether the keyPressReport API is available on the device which is + // dynamically set when the device responds to the first key press event or reports its + // keysDownState when queried since the keyPressReport was introduced together with the + // getKeysDownState API. + const { keyPressReportApiAvailable, setkeyPressReportApiAvailable} = useHidStore(); + // sendKeyboardEvent is used to send the full keyboard state to the device for macro handling and resetting keyboard state. + // It sends the keys currently pressed and the modifier state. + // The device will respond with the keysDownState if it supports the keyPressReport API + // or just accept the state if it does not support (returning no result) const sendKeyboardEvent = useCallback( (state: KeysDownState) => { if (rpcDataChannel?.readyState !== "open") return; @@ -24,23 +36,30 @@ export default function useKeyboard() { if ("error" in resp) { console.error(`Failed to send keyboard report ${state}`, resp.error); } else { + // If the device supports keyPressReport API, it will (also) return the keysDownState when we send + // the keyboardReport const keysDownState = resp.result as KeysDownState; if (keysDownState) { - // new devices return the keyDownState, so we can use it to update the state - setKeysDownState(keysDownState); - setKeyPressAvailable(true); // if they returned a keysDownState, we know they also support keyPressReport + setKeysDownState(keysDownState); // treat the response as the canonical state + setkeyPressReportApiAvailable(true); // if they returned a keysDownState, we ALSO know they also support keyPressReport } else { - // old devices do not return the keyDownState, so we just pretend they accepted what we sent - setKeysDownState(state); - // and we shouldn't set keyPressAvailable here because we don't know if they support it + // older devices versions do not return the keyDownState + setKeysDownState(state); // we just pretend they accepted what we sent + setkeyPressReportApiAvailable(false); // we ALSO know they do not support keyPressReport } } }); }, - [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], + [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], ); + // sendKeypressEvent is used to send a single key press/release event to the device. + // It sends the key and whether it is pressed or released. + // Older device version will not understand this request and will respond with + // an error with code -32601, which means that the RPC method name was not recognized. + // In that case we will switch to local key handling and update the keysDownState + // in client/browser-side code using simulateDeviceSideKeyHandlingForLegacyDevices. const sendKeypressEvent = useCallback( (key: number, press: boolean) => { if (rpcDataChannel?.readyState !== "open") return; @@ -48,11 +67,10 @@ export default function useKeyboard() { console.debug(`Send keypressEvent key: ${key}, press: ${press}`); send("keypressReport", { key, press }, (resp: JsonRpcResponse) => { if ("error" in resp) { - // -32601 means the method is not supported + // -32601 means the method is not supported because the device is running an older version if (resp.error.code === -32601) { - // if we don't support key press report, we need to disable all that handling - console.error("Failed calling keypressReport, switching to local handling", resp.error); - setKeyPressAvailable(false); + console.error("Legacy device does not support keypressReport API, switching to local key down state handling", resp.error); + setkeyPressReportApiAvailable(false); } else { console.error(`Failed to send key ${key} press: ${press}`, resp.error); } @@ -61,14 +79,17 @@ export default function useKeyboard() { if (keysDownState) { setKeysDownState(keysDownState); - // we don't need to set keyPressAvailable here, because it's already true or we never landed here + // we don't need to set keyPressReportApiAvailable here, because it's already true or we never landed here } } }); }, - [rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState], + [rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState], ); + // resetKeyboardState is used to reset the keyboard state to no keys pressed and no modifiers. + // This is useful for macros and when the browser loses focus to ensure that the keyboard state + // is clean. const resetKeyboardState = useCallback(() => { console.debug("Resetting keyboard state"); keysDownState.keys.fill(0); // Reset the keys buffer to zeros @@ -76,6 +97,12 @@ export default function useKeyboard() { sendKeyboardEvent(keysDownState); }, [keysDownState, sendKeyboardEvent]); + // executeMacro is used to execute a macro consisting of multiple steps. + // Each step can have multiple keys, multiple modifiers and a delay. + // The keys and modifiers are pressed together and held for the delay duration. + // After the delay, the keys and modifiers are released and the next step is executed. + // If a step has no keys or modifiers, it is treated as a delay-only step. + // A small pause is added between steps to ensure that the device can process the events. const executeMacro = async (steps: { keys: string[] | null; modifiers: string[] | null; delay: number }[]) => { for (const [index, step] of steps.entries()) { const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); @@ -99,14 +126,43 @@ export default function useKeyboard() { } }; - // this code exists because we have devices that don't support the keysPress api yet (not current) - // so we mirror the device-side code here to keep track of the keyboard state - function handleKeyLocally(state: KeysDownState, key: number, press: boolean): KeysDownState { + // handleKeyPress is used to handle a key press or release event. + // This function handle both key press and key release events. + // It checks if the keyPressReport API is available and sends the key press event. + // If the keyPressReport API is not available, it simulates the device-side key + // handling for legacy devices and updates the keysDownState accordingly. + // It then sends the full keyboard state to the device. + const handleKeyPress = useCallback( + (key: number, press: boolean) => { + if (rpcDataChannel?.readyState !== "open") return; + + if (keyPressReportApiAvailable) { + // if the keyPress api is available, we can just send the key press event + sendKeypressEvent(key, press); + } else { + // if the keyPress api is not available, we need to handle the key locally + const downState = simulateDeviceSideKeyHandlingForLegacyDevices(keysDownState, key, press); + sendKeyboardEvent(downState); // then we send the full state + } + }, + [keyPressReportApiAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent], + ); + + // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists + function simulateDeviceSideKeyHandlingForLegacyDevices(state: KeysDownState, key: number, press: boolean): KeysDownState { + // IMPORTANT: This code parallels the logic in the kernel's hid-gadget driver + // for handling key presses and releases. It ensures that the USB gadget + // behaves similarly to a real USB HID keyboard. This logic is paralleled + // in the device-side code in hid_keyboard.go so make sure to keep them in sync. const keys = state.keys; let modifiers = state.modifier; const modifierMask = hidKeyToModifierMask[key] || 0; if (modifierMask !== 0) { + // If the key is a modifier key, we update the keyboardModifier state + // by setting or clearing the corresponding bit in the modifier byte. + // This allows us to track the state of dynamic modifier keys like + // Shift, Control, Alt, and Super. console.debug(`Handling modifier key: ${key}, press: ${press}, current modifiers: ${modifiers}, modifier mask: ${modifierMask}`); if (press) { modifiers |= modifierMask; @@ -151,26 +207,5 @@ export default function useKeyboard() { return { modifier: modifiers, keys }; } - const handleKeyPress = useCallback( - (key: number, press: boolean) => { - if (rpcDataChannel?.readyState !== "open") return; - - if (keyPressAvailable) { - // if the keyPress api is available, we can just send the key press event - sendKeypressEvent(key, press); - // if keyPress api is STILL available, we don't need to handle the key locally - if (keyPressAvailable) return; - } - - // if the keyPress api is not available, we need to handle the key locally - const downState = handleKeyLocally(keysDownState, key, press); - setKeysDownState(downState); - - // then we send the full state - sendKeyboardEvent(downState); - }, - [keyPressAvailable, keysDownState, rpcDataChannel?.readyState, sendKeyboardEvent, sendKeypressEvent, setKeysDownState], - ); - return { handleKeyPress, resetKeyboardState, executeMacro }; } diff --git a/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts index c91729b..7dddd88 100644 --- a/ui/src/keyboardMappings.ts +++ b/ui/src/keyboardMappings.ts @@ -86,7 +86,6 @@ export const keys = { KeyZ: 0x1d, KeypadExclamation: 0xcf, Minus: 0x2d, - None: 0x00, NumLock: 0x53, // and Clear Numpad0: 0x62, // and Insert Numpad1: 0x59, // and End diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 7ac519c..ae5d985 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -64,7 +64,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { setRemoteVirtualMediaState(null); } - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); async function syncRemoteVirtualMediaState() { return new Promise((resolve, reject) => { send("getVirtualMediaState", {}, resp => { @@ -684,7 +684,7 @@ function DeviceFileView({ const [currentPage, setCurrentPage] = useState(1); const filesPerPage = 5; - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); interface StorageSpace { bytesUsed: number; @@ -996,7 +996,7 @@ function UploadFileView({ const [fileError, setFileError] = useState(null); const [uploadError, setUploadError] = useState(null); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const rtcDataChannelRef = useRef(null); useEffect(() => { diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index e0543b8..951c07b 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -42,7 +42,7 @@ export default function SettingsAccessIndexRoute() { const { navigateTo } = useDeviceUiNavigation(); const navigate = useNavigate(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [isAdopted, setAdopted] = useState(false); const [deviceId, setDeviceId] = useState(null); @@ -166,9 +166,7 @@ export default function SettingsAccessIndexRoute() { notifications.success("TLS settings updated successfully"); }); - }, - [send], - ); + }, [send]); // Handle TLS mode change const handleTlsModeChange = (value: string) => { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index d1dab68..c453e79 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -15,10 +15,10 @@ import notifications from "../notifications"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsAdvancedRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [sshKey, setSSHKey] = useState(""); - const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); + const { setDeveloperMode } = useSettingsStore(); const [devChannel, setDevChannel] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index ecefdfa..8916af4 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -13,7 +13,7 @@ import { useDeviceStore } from "../hooks/stores"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsGeneralRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const [autoUpdate, setAutoUpdate] = useState(true); diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index c6889f6..0bf114c 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -6,7 +6,7 @@ import { Button } from "@components/Button"; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const onConfirmUpdate = useCallback(() => { // This is where we send the RPC to the golang binary diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 7c41449..f456d89 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -16,7 +16,7 @@ export default function SettingsGeneralUpdateRoute() { const { updateSuccess } = location.state || {}; const { setModalView, otaState } = useUpdateStore(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const onConfirmUpdate = useCallback(() => { send("tryUpdate", {}); @@ -134,10 +134,8 @@ function LoadingState({ }) { const [progressWidth, setProgressWidth] = useState("0%"); const abortControllerRef = useRef(null); - const [send] = useJsonRpc(); - - const setAppVersion = useDeviceStore(state => state.setAppVersion); - const setSystemVersion = useDeviceStore(state => state.setSystemVersion); + const { send } = useJsonRpc(); + const { setAppVersion, setSystemVersion } = useDeviceStore(); const getVersionInfo = useCallback(() => { return new Promise((resolve, reject) => { diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 82cc6a1..850126c 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -12,10 +12,9 @@ import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { FeatureFlag } from "../components/FeatureFlag"; export default function SettingsHardwareRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const settings = useSettingsStore(); - - const setDisplayRotation = useSettingsStore(state => state.setDisplayRotation); + const { setDisplayRotation } = useSettingsStore(); const handleDisplayRotationChange = (rotation: string) => { setDisplayRotation(rotation); @@ -34,7 +33,7 @@ export default function SettingsHardwareRoute() { }); }; - const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); + const { setBacklightSettings } = useSettingsStore(); const handleBacklightSettingsChange = (settings: BacklightSettings) => { // If the user has set the display to dim after it turns off, set the dim_after diff --git a/ui/src/routes/devices.$id.settings.keyboard.tsx b/ui/src/routes/devices.$id.settings.keyboard.tsx index 40c7c6f..d740ffb 100644 --- a/ui/src/routes/devices.$id.settings.keyboard.tsx +++ b/ui/src/routes/devices.$id.settings.keyboard.tsx @@ -12,25 +12,20 @@ import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SettingsItem } from "./devices.$id.settings"; export default function SettingsKeyboardRoute() { - const keyboardLayout = useSettingsStore(state => state.keyboardLayout); - const showPressedKeys = useSettingsStore(state => state.showPressedKeys); - const setKeyboardLayout = useSettingsStore( - state => state.setKeyboardLayout, - ); - const setShowPressedKeys = useSettingsStore( - state => state.setShowPressedKeys, - ); + const { keyboardLayout, setKeyboardLayout } = useSettingsStore(); + const { showPressedKeys, setShowPressedKeys } = useSettingsStore(); - // this ensures we always get the original en_US if it hasn't been set yet + // this ensures we always get the en-US if it hasn't been set yet + // and if we get en_US from the backend, we convert it to en-US const safeKeyboardLayout = useMemo(() => { if (keyboardLayout && keyboardLayout.length > 0) - return keyboardLayout; - return "en_US"; + return keyboardLayout.replace("en_US", "en-US"); + return "en-US"; }, [keyboardLayout]); const layoutOptions = keyboardOptions(); - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); useEffect(() => { send("getKeyboardLayout", {}, resp => { diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index 26c4b5b..18016d3 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -66,14 +66,11 @@ const jigglerOptions = [ type JigglerValues = (typeof jigglerOptions)[number]["value"] | "custom"; export default function SettingsMouseRoute() { - const hideCursor = useSettingsStore(state => state.isCursorHidden); - const setHideCursor = useSettingsStore(state => state.setCursorVisibility); - - const mouseMode = useSettingsStore(state => state.mouseMode); - const setMouseMode = useSettingsStore(state => state.setMouseMode); - - const scrollThrottling = useSettingsStore(state => state.scrollThrottling); - const setScrollThrottling = useSettingsStore(state => state.setScrollThrottling); + const { + isCursorHidden, setCursorVisibility, + mouseMode, setMouseMode, + scrollThrottling, setScrollThrottling + } = useSettingsStore(); const [selectedJigglerOption, setSelectedJigglerOption] = useState(null); @@ -86,7 +83,7 @@ export default function SettingsMouseRoute() { { value: "100", label: "Very High" }, ]; - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const syncJigglerSettings = useCallback(() => { send("getJigglerState", {}, resp => { @@ -182,8 +179,8 @@ export default function SettingsMouseRoute() { description="Hide the cursor when sending mouse movements" > setHideCursor(e.target.checked)} + checked={isCursorHidden} + onChange={e => setCursorVisibility(e.target.checked)} /> diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index 1df380f..ebcbc85 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -72,7 +72,7 @@ export function LifeTimeLabel({ lifetime }: { lifetime: string }) { } export default function SettingsNetworkRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [networkState, setNetworkState] = useNetworkStateStore(state => [ state, state.setNetworkState, diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 2a7e190..0309ce9 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -28,7 +28,7 @@ import { cx } from "../cva.config"; /* 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() { const location = useLocation(); - const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); + const { setDisableVideoFocusTrap } = useUiStore(); const { resetKeyboardState } = useKeyboard(); const scrollContainerRef = useRef(null); const [showLeftGradient, setShowLeftGradient] = useState(false); diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index 9e888ab..e6a39ea 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -41,18 +41,17 @@ const streamQualityOptions = [ ]; export default function SettingsVideoRoute() { - const [send] = useJsonRpc(); + const { send } = useJsonRpc(); const [streamQuality, setStreamQuality] = useState("1"); const [customEdidValue, setCustomEdidValue] = useState(null); const [edid, setEdid] = useState(null); // Video enhancement settings from store - const videoSaturation = useSettingsStore(state => state.videoSaturation); - const setVideoSaturation = useSettingsStore(state => state.setVideoSaturation); - const videoBrightness = useSettingsStore(state => state.videoBrightness); - const setVideoBrightness = useSettingsStore(state => state.setVideoBrightness); - const videoContrast = useSettingsStore(state => state.videoContrast); - const setVideoContrast = useSettingsStore(state => state.setVideoContrast); + const { + videoSaturation, setVideoSaturation, + videoBrightness, setVideoBrightness, + videoContrast, setVideoContrast + } = useSettingsStore(); useEffect(() => { send("getStreamQualityFactor", {}, resp => { diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index 81ad5fd..08a1413 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -18,15 +18,11 @@ import useWebSocket from "react-use-websocket"; import { cx } from "@/cva.config"; import { - DeviceState, - HidState, KeyboardLedState, KeysDownState, - MountMediaState, NetworkState, - RTCState, - UIState, - UpdateState, + OtaState, + USBStates, useDeviceStore, useHidStore, useMountMediaStore, @@ -132,22 +128,22 @@ export default function KvmIdRoute() { const authMode = "authMode" in loaderResp ? loaderResp.authMode : null; const params = useParams() as { id: string }; - const sidebarView = useUiStore((state: UIState) => state.sidebarView); - const [queryParams, setQueryParams] = useSearchParams(); + const { sidebarView, setSidebarView, disableVideoFocusTrap } = useUiStore(); + const [ queryParams, setQueryParams ] = useSearchParams(); + + const { + peerConnection, setPeerConnection, + peerConnectionState, setPeerConnectionState, + diskChannel, setDiskChannel, + setMediaStream, + setRpcDataChannel, + isTurnServerInUse, setTurnServerInUse, + rpcDataChannel, + setTransceiver + } = useRTCStore(); - const setIsTurnServerInUse = useRTCStore((state: RTCState) => state.setTurnServerInUse); - const peerConnection = useRTCStore((state: RTCState) => state.peerConnection); - const setPeerConnectionState = useRTCStore((state: RTCState) => state.setPeerConnectionState); - const peerConnectionState = useRTCStore((state: RTCState) => state.peerConnectionState); - const setMediaMediaStream = useRTCStore((state: RTCState) => state.setMediaStream); - const setPeerConnection = useRTCStore((state: RTCState) => state.setPeerConnection); - const setDiskChannel = useRTCStore((state: RTCState) => state.setDiskChannel); - const setRpcDataChannel = useRTCStore((state: RTCState) => state.setRpcDataChannel); - const setTransceiver = useRTCStore((state: RTCState) => state.setTransceiver); const location = useLocation(); - const isLegacySignalingEnabled = useRef(false); - const [connectionFailed, setConnectionFailed] = useState(false); const navigate = useNavigate(); @@ -480,7 +476,7 @@ export default function KvmIdRoute() { }; pc.ontrack = function (event) { - setMediaMediaStream(event.streams[0]); + setMediaStream(event.streams[0]); }; setTransceiver(pc.addTransceiver("video", { direction: "recvonly" })); @@ -502,7 +498,7 @@ export default function KvmIdRoute() { legacyHTTPSignaling, sendWebRTCSignal, setDiskChannel, - setMediaMediaStream, + setMediaStream, setPeerConnection, setPeerConnectionState, setRpcDataChannel, @@ -517,9 +513,7 @@ export default function KvmIdRoute() { }, [peerConnectionState, cleanupAndStopReconnecting]); // Cleanup effect - const clearInboundRtpStats = useRTCStore((state: RTCState) => state.clearInboundRtpStats); - const clearCandidatePairStats = useRTCStore((state: RTCState) => state.clearCandidatePairStats); - const setSidebarView = useUiStore((state: UIState) => state.setSidebarView); + const { clearInboundRtpStats, clearCandidatePairStats } = useRTCStore(); useEffect(() => { return () => { @@ -550,11 +544,10 @@ export default function KvmIdRoute() { if (!lastRemoteStat?.length) return; const remoteCandidateIsUsingTurn = lastRemoteStat[1].candidateType === "relay"; // [0] is the timestamp, which we don't care about here - setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); - }, [peerConnectionState, setIsTurnServerInUse]); + setTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn); + }, [peerConnectionState, setTurnServerInUse]); // TURN server usage reporting - const isTurnServerInUse = useRTCStore((state: RTCState) => state.isTurnServerInUse); const lastBytesReceived = useRef(0); const lastBytesSent = useRef(0); @@ -587,17 +580,13 @@ export default function KvmIdRoute() { }); }, 10000); - const setNetworkState = useNetworkStateStore((state: NetworkState) => state.setNetworkState); - - const setUsbState = useHidStore((state: HidState) => state.setUsbState); - const setHdmiState = useVideoStore((state: VideoState) => state.setHdmiState); - - const keyboardLedState = useHidStore((state: HidState) => state.keyboardLedState); - const setKeyboardLedState = useHidStore((state: HidState) => state.setKeyboardLedState); - - const keysDownState = useHidStore((state: HidState) => state.keysDownState); - const setKeysDownState = useHidStore((state: HidState) => state.setKeysDownState); - const setKeyPressAvailable = useHidStore((state: HidState) => state.setKeyPressAvailable); + const { setNetworkState} = useNetworkStateStore(); + const { setHdmiState } = useVideoStore(); + const { + keyboardLedState, setKeyboardLedState, + keysDownState, setKeysDownState, setUsbState, + setkeyPressReportApiAvailable + } = useHidStore(); const [hasUpdated, setHasUpdated] = useState(false); const { navigateTo } = useDeviceUiNavigation(); @@ -608,7 +597,7 @@ export default function KvmIdRoute() { } if (resp.method === "usbState") { - setUsbState(resp.params as unknown as HidState["usbState"]); + setUsbState(resp.params as unknown as USBStates); } if (resp.method === "videoInputState") { @@ -632,12 +621,12 @@ export default function KvmIdRoute() { if (downState) { console.debug("Setting key down state:", downState); setKeysDownState(downState); - setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } } if (resp.method === "otaState") { - const otaState = resp.params as UpdateState["otaState"]; + const otaState = resp.params as OtaState; setOtaState(otaState); if (otaState.updating === true) { @@ -661,8 +650,7 @@ export default function KvmIdRoute() { } } - const rpcDataChannel = useRTCStore((state: RTCState) => state.rpcDataChannel); - const [send] = useJsonRpc(onJsonRpcRequest); + const { send } = useJsonRpc(onJsonRpcRequest); useEffect(() => { if (rpcDataChannel?.readyState !== "open") return; @@ -709,7 +697,7 @@ export default function KvmIdRoute() { if (resp.error.code === -32601) { // if we don't support key down state, we know key press is also not available console.error("Failed to get key down state, switching to old-school", resp.error); - setKeyPressAvailable(false); + setkeyPressReportApiAvailable(false); } else { console.error("Failed to get key down state", resp.error); } @@ -719,12 +707,12 @@ export default function KvmIdRoute() { if (downState) { console.debug("Keyboard key down state", downState); setKeysDownState(downState); - setKeyPressAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport + setkeyPressReportApiAvailable(true); // if they returned a keyDownState, we know they also support keyPressReport } } setNeedKeyDownState(false); }); - }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setKeyPressAvailable, setKeysDownState]); + }, [keysDownState, needKeyDownState, rpcDataChannel?.readyState, send, setkeyPressReportApiAvailable, setKeysDownState]); // When the update is successful, we need to refresh the client javascript and show a success modal useEffect(() => { @@ -733,14 +721,13 @@ export default function KvmIdRoute() { } }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]); - const diskChannel = useRTCStore((state: RTCState) => state.diskChannel)!; - const file = useMountMediaStore((state: MountMediaState) => state.localFile)!; + const { localFile } = useMountMediaStore(); useEffect(() => { - if (!diskChannel || !file) return; + if (!diskChannel || !localFile) return; diskChannel.onmessage = async e => { console.debug("Received", e.data); const data = JSON.parse(e.data); - const blob = file.slice(data.start, data.end); + const blob = localFile.slice(data.start, data.end); const buf = await blob.arrayBuffer(); const header = new ArrayBuffer(16); const headerView = new DataView(header); @@ -751,11 +738,9 @@ export default function KvmIdRoute() { fullData.set(new Uint8Array(buf), header.byteLength); diskChannel.send(fullData); }; - }, [diskChannel, file]); + }, [diskChannel, localFile]); // System update - const disableVideoFocusTrap = useUiStore((state: UIState) => state.disableVideoFocusTrap); - const [kvmTerminal, setKvmTerminal] = useState(null); const [serialConsole, setSerialConsole] = useState(null); @@ -775,9 +760,7 @@ export default function KvmIdRoute() { if (location.pathname !== "/other-session") navigateTo("/"); }, [navigateTo, location.pathname]); - const appVersion = useDeviceStore((state: DeviceState) => state.appVersion); - const setAppVersion = useDeviceStore((state: DeviceState) => state.setAppVersion); - const setSystemVersion = useDeviceStore((state: DeviceState) => state.setSystemVersion); + const { appVersion, setAppVersion, setSystemVersion} = useDeviceStore(); useEffect(() => { if (appVersion) return;