From 1d6b7ad83acde82a13c21fa1cf63d7e05b4ff35d Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 11 Feb 2025 15:57:21 +0100 Subject: [PATCH 01/14] chore: bump version to 0.3.5 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 04c7402..eea9730 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.3.4 +VERSION_DEV := 0.3.6-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.3.5 hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 From 7bca9cb8273d4fdeee37156dc40f948bbfd6dd7c Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 18 Feb 2025 13:42:58 +0100 Subject: [PATCH 02/14] chore: bump version to 0.3.6 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 04c7402..f9e4426 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.3.4 +VERSION_DEV := 0.3.7-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.3.6 hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 From 16efeee31d5623f4d5a1cf9d2b822bbcee5fa891 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Wed, 19 Feb 2025 10:24:27 +0100 Subject: [PATCH 03/14] fix(config): should return defaultConfig when config file doesnt exist --- config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config.go b/config.go index 3818b7b..87301a8 100644 --- a/config.go +++ b/config.go @@ -52,6 +52,9 @@ func LoadConfig() { return } + // load the default config + config = defaultConfig + file, err := os.Open(configPath) if err != nil { logger.Debug("default config file doesn't exist, using default") From 4351cc8dd718aeded8d2da34f909c333c64a6e2d Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Wed, 19 Feb 2025 10:38:09 +0100 Subject: [PATCH 04/14] chore: bump version to 0.3.7 --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f9e4426..8aaa8df 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ -VERSION_DEV := 0.3.7-dev$(shell date +%Y%m%d%H%M) -VERSION := 0.3.6 +VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M) +VERSION := 0.3.7 hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 From 8b59a3e3875c096a55d3d4f8e399e2c39e8bb694 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Fri, 7 Mar 2025 20:01:14 +0100 Subject: [PATCH 05/14] chore(prometheus): move prometheus to a new file --- main.go | 2 ++ prometheus.go | 17 +++++++++++++++++ web.go | 5 ----- 3 files changed, 19 insertions(+), 5 deletions(-) create mode 100644 prometheus.go diff --git a/main.go b/main.go index e23e9c8..a3caba7 100644 --- a/main.go +++ b/main.go @@ -36,6 +36,8 @@ func Main() { StartNativeCtrlSocketServer() StartNativeVideoSocketServer() + initPrometheus() + go func() { err = ExtractAndRunNativeBin() if err != nil { diff --git a/prometheus.go b/prometheus.go new file mode 100644 index 0000000..8ebf259 --- /dev/null +++ b/prometheus.go @@ -0,0 +1,17 @@ +package kvm + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" + "github.com/prometheus/common/version" +) + +var promHandler http.Handler + +func initPrometheus() { + // A Prometheus metrics endpoint. + version.Version = builtAppVersion + prometheus.MustRegister(versioncollector.NewCollector("jetkvm")) +} diff --git a/web.go b/web.go index dea3e17..b35a2db 100644 --- a/web.go +++ b/web.go @@ -12,10 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" - "github.com/prometheus/client_golang/prometheus" - versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version" "github.com/prometheus/client_golang/prometheus/promhttp" - "github.com/prometheus/common/version" "golang.org/x/crypto/bcrypt" ) @@ -90,8 +87,6 @@ func setupRouter() *gin.Engine { r.POST("/device/setup", handleSetup) // A Prometheus metrics endpoint. - version.Version = builtAppVersion - prometheus.MustRegister(versioncollector.NewCollector("jetkvm")) r.GET("/metrics", gin.WrapH(promhttp.Handler())) // Protected routes (allows both password and noPassword modes) From 285de31ade13d9eb8988842381c608092af3bc71 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Fri, 7 Mar 2025 20:14:22 +0100 Subject: [PATCH 06/14] feat(tls): add simple tls support --- config.go | 2 + main.go | 3 ++ web_tls.go | 132 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 web_tls.go diff --git a/config.go b/config.go index e4e27d7..304d8f6 100644 --- a/config.go +++ b/config.go @@ -37,6 +37,7 @@ type Config struct { DisplayMaxBrightness int `json:"display_max_brightness"` DisplayDimAfterSec int `json:"display_dim_after_sec"` DisplayOffAfterSec int `json:"display_off_after_sec"` + TLSMode string `json:"tls_mode"` UsbConfig *UsbConfig `json:"usb_config"` } @@ -50,6 +51,7 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes + TLSMode: "", UsbConfig: &UsbConfig{ VendorId: "0x1d6b", //The Linux Foundation ProductId: "0x0104", //Multifunction Composite Gadget diff --git a/main.go b/main.go index a3caba7..2c8c22a 100644 --- a/main.go +++ b/main.go @@ -68,6 +68,9 @@ func Main() { }() //go RunFuseServer() go RunWebServer() + if config.TLSMode != "" { + go RunWebSecureServer() + } // If the cloud token isn't set, the client won't be started by default. // However, if the user adopts the device via the web interface, handleCloudRegister will start the client. if config.CloudToken != "" { diff --git a/web_tls.go b/web_tls.go new file mode 100644 index 0000000..fff9253 --- /dev/null +++ b/web_tls.go @@ -0,0 +1,132 @@ +package kvm + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "log" + "math/big" + "net" + "net/http" + "strings" + "sync" + "time" +) + +const ( + WebSecureListen = ":443" + WebSecureSelfSignedDefaultDomain = "jetkvm.local" + WebSecureSelfSignedDuration = 365 * 24 * time.Hour +) + +var ( + tlsCerts = make(map[string]*tls.Certificate) + tlsCertLock = &sync.Mutex{} +) + +// RunWebSecureServer runs a web server with TLS. +func RunWebSecureServer() { + r := setupRouter() + + server := &http.Server{ + Addr: WebSecureListen, + Handler: r, + TLSConfig: &tls.Config{ + // TODO: cache certificate in persistent storage + GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { + hostname := WebSecureSelfSignedDefaultDomain + if info.ServerName != "" { + hostname = info.ServerName + } else { + hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0] + } + + logger.Infof("TLS handshake for %s, SupportedProtos: %v", hostname, info.SupportedProtos) + + cert := createSelfSignedCert(hostname) + + return cert, nil + }, + }, + } + logger.Infof("Starting websecure server on %s", RunWebSecureServer) + err := server.ListenAndServeTLS("", "") + if err != nil { + panic(err) + } + return +} + +func createSelfSignedCert(hostname string) *tls.Certificate { + if tlsCert := tlsCerts[hostname]; tlsCert != nil { + return tlsCert + } + tlsCertLock.Lock() + defer tlsCertLock.Unlock() + + logger.Infof("Creating self-signed certificate for %s", hostname) + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + log.Fatalf("Failed to generate private key: %v", err) + } + keyUsage := x509.KeyUsageDigitalSignature + + notBefore := time.Now() + notAfter := notBefore.AddDate(1, 0, 0) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + logger.Errorf("Failed to generate serial number: %v", err) + } + + dnsName := hostname + ip := net.ParseIP(hostname) + if ip != nil { + dnsName = WebSecureSelfSignedDefaultDomain + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: hostname, + Organization: []string{"JetKVM"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + + DNSNames: []string{dnsName}, + IPAddresses: []net.IP{}, + } + + if ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + logger.Errorf("Failed to create certificate: %v", err) + } + + cert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) + if cert == nil { + logger.Errorf("Failed to encode certificate") + } + + tlsCert := &tls.Certificate{ + Certificate: [][]byte{derBytes}, + PrivateKey: priv, + } + tlsCerts[hostname] = tlsCert + + return tlsCert +} From d291053e0624c755c24b88fe5ef19d46bedbe555 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Tue, 18 Mar 2025 18:00:21 +0100 Subject: [PATCH 07/14] fix: build info was missing --- Makefile | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 0453301..848682c 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,26 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date -u +%FT%T%z) +BUILDTS ?= $(shell date -u +%s) REVISION ?= $(shell git rev-parse HEAD) VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M) VERSION := 0.3.7 +PROMETHEUS_TAG := github.com/prometheus/common/version +KVM_PKG_NAME := github.com/jetkvm/kvm + GO_LDFLAGS := \ -s -w \ - -X github.com/prometheus/common/version.Branch=$(BRANCH) \ - -X github.com/prometheus/common/version.BuildDate=$(BUILDDATE) \ - -X github.com/prometheus/common/version.Revision=$(REVISION) + -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \ + -X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \ + -X $(PROMETHEUS_TAG).Revision=$(REVISION) \ + -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 build_dev: hash_resource @echo "Building..." - GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go frontend: cd ui && npm ci && npm run build:device @@ -28,7 +33,7 @@ dev_release: frontend build_dev build_release: frontend hash_resource @echo "Building release..." - GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go release: @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ From d52e7d04d12f14b718b22182c5ca765bf006631b Mon Sep 17 00:00:00 2001 From: Aveline <352441+ym@users.noreply.github.com> Date: Wed, 19 Mar 2025 11:47:15 +0100 Subject: [PATCH 08/14] feat: relative mouse (#246) --- jsonrpc.go | 1 + ui/src/components/InfoBar.tsx | 14 +++- ui/src/components/WebRTCVideo.tsx | 75 ++++++++++++++++---- ui/src/hooks/stores.ts | 32 +++++---- ui/src/routes/devices.$id.settings.mouse.tsx | 65 ++++++++--------- usb.go | 7 +- 6 files changed, 133 insertions(+), 61 deletions(-) diff --git a/jsonrpc.go b/jsonrpc.go index 298a810..64935e1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -799,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{ "getCloudState": {Func: rpcGetCloudState}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, + "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "getVideoState": {Func: rpcGetVideoState}, "getUSBState": {Func: rpcGetUSBState}, diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index be94043..7c002f1 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -14,6 +14,7 @@ export default function InfoBar() { const activeModifiers = useHidStore(state => state.activeModifiers); const mouseX = useMouseStore(state => state.mouseX); const mouseY = useMouseStore(state => state.mouseY); + const mouseMove = useMouseStore(state => state.mouseMove); const videoClientSize = useVideoStore( state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, @@ -62,7 +63,7 @@ export default function InfoBar() { ) : null} - {settings.debugMode ? ( + {(settings.debugMode && settings.mouseMode == "absolute") ? (
Pointer: @@ -71,6 +72,17 @@ export default function InfoBar() {
) : null} + {(settings.debugMode && settings.mouseMode == "relative") ? ( +
+ Last Move: + + {mouseMove ? + `${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` : + "N/A"} + +
+ ) : null} + {settings.debugMode && (
USB State: diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 1587d29..29c72d1 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -29,6 +29,7 @@ export default function WebRTCVideo() { const settings = useSettingsStore(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const setMousePosition = useMouseStore(state => state.setMousePosition); + const setMouseMove = useMouseStore(state => state.setMouseMove); const { setClientSize: setVideoClientSize, setSize: setVideoSize, @@ -93,19 +94,44 @@ export default function WebRTCVideo() { ); // Mouse-related - const sendMouseMovement = useCallback( + const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; + const sendRelMouseMovement = useCallback( (x: number, y: number, buttons: number) => { - send("absMouseReport", { x, y, buttons }); + if (settings.mouseMode !== "relative") return; + // if we ignore the event, double-click will not work + // if (x === 0 && y === 0 && buttons === 0) return; + send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons }); + setMouseMove({ x, y, buttons }); + }, + [send, setMouseMove, settings.mouseMode], + ); + const relMouseMoveHandler = useCallback( + (e: MouseEvent) => { + if (settings.mouseMode !== "relative") return; + + // Send mouse movement + const { buttons } = e; + sendRelMouseMovement(e.movementX, e.movementY, buttons); + }, + [sendRelMouseMovement, settings.mouseMode], + ); + + const sendAbsMouseMovement = useCallback( + (x: number, y: number, buttons: number) => { + if (settings.mouseMode !== "absolute") return; + send("absMouseReport", { x, y, buttons }); // We set that for the debug info bar setMousePosition(x, y); }, - [send, setMousePosition], + [send, setMousePosition, settings.mouseMode], ); - const mouseMoveHandler = useCallback( + const absMouseMoveHandler = useCallback( (e: MouseEvent) => { if (!videoClientWidth || !videoClientHeight) return; + if (settings.mouseMode !== "absolute") return; + // Get the aspect ratios of the video element and the video stream const videoElementAspectRatio = videoClientWidth / videoClientHeight; const videoStreamAspectRatio = videoWidth / videoHeight; @@ -140,9 +166,9 @@ export default function WebRTCVideo() { // Send mouse movement const { buttons } = e; - sendMouseMovement(x, y, buttons); + sendAbsMouseMovement(x, y, buttons); }, - [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], + [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], ); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); @@ -193,8 +219,8 @@ export default function WebRTCVideo() { ); const resetMousePosition = useCallback(() => { - sendMouseMovement(0, 0, 0); - }, [sendMouseMovement]); + sendAbsMouseMovement(0, 0, 0); + }, [sendAbsMouseMovement]); // Keyboard-related const handleModifierKeys = useCallback( @@ -371,9 +397,9 @@ export default function WebRTCVideo() { const abortController = new AbortController(); const signal = abortController.signal; - videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal, @@ -395,7 +421,7 @@ export default function WebRTCVideo() { }; }, [ - mouseMoveHandler, + absMouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler, @@ -403,6 +429,31 @@ export default function WebRTCVideo() { ], ); + useEffect( + function setupRelativeMouseEventListeners() { + if (settings.mouseMode !== "relative") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // bind to body to capture all mouse events + const body = document.querySelector("body"); + if (!body) return; + + body.addEventListener("mousemove", relMouseMoveHandler, { signal }); + body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); + body.addEventListener("pointerup", relMouseMoveHandler, { signal }); + + return () => { + abortController.abort(); + + body.removeEventListener("mousemove", relMouseMoveHandler); + body.removeEventListener("pointerdown", relMouseMoveHandler); + body.removeEventListener("pointerup", relMouseMoveHandler); + }; + }, [settings.mouseMode, relMouseMoveHandler], + ) + useEffect( function updateVideoStream() { if (!mediaStream) return; diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index ac8ad7d..f30c28c 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -197,15 +197,23 @@ export const useRTCStore = create(set => ({ setTerminalChannel: channel => set({ terminalChannel: channel }), })); +interface MouseMove { + x: number; + y: number; + buttons: number; +} interface MouseState { mouseX: number; mouseY: number; + mouseMove?: MouseMove; + setMouseMove: (move?: MouseMove) => void; setMousePosition: (x: number, y: number) => void; } export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, + setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), })); @@ -543,12 +551,12 @@ export interface UpdateState { setOtaState: (state: UpdateState["otaState"]) => void; setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void; modalView: - | "loading" - | "updating" - | "upToDate" - | "updateAvailable" - | "updateCompleted" - | "error"; + | "loading" + | "updating" + | "upToDate" + | "updateAvailable" + | "updateCompleted" + | "error"; setModalView: (view: UpdateState["modalView"]) => void; setUpdateErrorMessage: (errorMessage: string) => void; updateErrorMessage: string | null; @@ -612,12 +620,12 @@ export const useUsbConfigModalStore = create(set => ({ interface LocalAuthModalState { modalView: - | "createPassword" - | "deletePassword" - | "updatePassword" - | "creationSuccess" - | "deleteSuccess" - | "updateSuccess"; + | "createPassword" + | "deletePassword" + | "updatePassword" + | "creationSuccess" + | "deleteSuccess" + | "updateSuccess"; setModalView: (view: LocalAuthModalState["modalView"]) => void; } diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index c8c351a..1d3a6cd 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -1,23 +1,27 @@ -import { SettingsPageHeader } from "@components/SettingsPageheader"; -import { SettingsItem } from "./devices.$id.settings"; -import { Checkbox } from "@/components/Checkbox"; -import { GridCard } from "@/components/Card"; +import MouseIcon from "@/assets/mouse-icon.svg"; import PointingFinger from "@/assets/pointing-finger.svg"; -import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { GridCard } from "@/components/Card"; +import { Checkbox } from "@/components/Checkbox"; import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores"; -import notifications from "@/notifications"; -import { useCallback, useEffect, useState } from "react"; import { useJsonRpc } from "@/hooks/useJsonRpc"; -import { cx } from "../cva.config"; +import notifications from "@/notifications"; +import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { CheckCircleIcon } from "@heroicons/react/16/solid"; +import { useCallback, useEffect, useState } from "react"; +import { FeatureFlag } from "../components/FeatureFlag"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { useFeatureFlag } from "../hooks/useFeatureFlag"; -import { FeatureFlag } from "../components/FeatureFlag"; +import { SettingsItem } from "./devices.$id.settings"; type ScrollSensitivity = "low" | "default" | "high"; export default function SettingsKeyboardMouseRoute() { 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 scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity); const setScrollSensitivity = useDeviceSettingsStore( state => state.setScrollSensitivity, @@ -122,19 +126,19 @@ export default function SettingsKeyboardMouseRoute() {
-
+
-
- ); -} diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 1c8812c..605ae4d 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -5,6 +5,10 @@ import { useJsonRpc } from "../hooks/useJsonRpc"; import notifications from "../notifications"; import { SettingsItem } from "../routes/devices.$id.settings"; import Checkbox from "./Checkbox"; +import { Button } from "./Button"; +import { SelectMenuBasic } from "./SelectMenuBasic"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import Fieldset from "./Fieldset"; export interface USBConfig { vendor_id: string; @@ -26,12 +30,43 @@ const defaultUsbDeviceConfig: UsbDeviceConfig = { absolute_mouse: true, relative_mouse: true, mass_storage: true, -} +}; + +const usbPresets = [ + { + label: "Keyboard, Mouse and Mass Storage", + value: "default", + config: { + keyboard: true, + absolute_mouse: true, + relative_mouse: true, + mass_storage: true, + }, + }, + { + label: "Keyboard Only", + value: "keyboard_only", + config: { + keyboard: true, + absolute_mouse: false, + relative_mouse: false, + mass_storage: false, + }, + }, + { + label: "Custom", + value: "custom", + }, +]; export function UsbDeviceSetting() { const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); + + const [usbDeviceConfig, setUsbDeviceConfig] = + useState(defaultUsbDeviceConfig); + const [selectedPreset, setSelectedPreset] = useState("default"); - const [usbDeviceConfig, setUsbDeviceConfig] = useState(defaultUsbDeviceConfig); const syncUsbDeviceConfig = useCallback(() => { send("getUsbDevices", {}, resp => { if ("error" in resp) { @@ -40,90 +75,168 @@ export function UsbDeviceSetting() { `Failed to load USB devices: ${resp.error.data || "Unknown error"}`, ); } else { - console.log("syncUsbDeviceConfig#getUsbDevices result:", resp.result); const usbConfigState = resp.result as UsbDeviceConfig; setUsbDeviceConfig(usbConfigState); + + // Set the appropriate preset based on current config + const matchingPreset = usbPresets.find( + preset => + preset.value !== "custom" && + preset.config && + Object.keys(preset.config).length === Object.keys(usbConfigState).length && + Object.keys(preset.config).every(key => { + const configKey = key as keyof typeof preset.config; + return preset.config[configKey] === usbConfigState[configKey]; + }), + ); + + setSelectedPreset(matchingPreset ? matchingPreset.value : "custom"); } }); }, [send]); const handleUsbConfigChange = useCallback( (devices: UsbDeviceConfig) => { - send("setUsbDevices", { devices }, resp => { + setLoading(true); + send("setUsbDevices", { devices }, async resp => { if ("error" in resp) { notifications.error( `Failed to set usb devices: ${resp.error.data || "Unknown error"}`, ); + setLoading(false); return; } - notifications.success( - `USB Devices updated` - ); + + // We need some time to ensure the USB devices are updated + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); syncUsbDeviceConfig(); + notifications.success(`USB Devices updated`); }); }, [send, syncUsbDeviceConfig], ); - const onUsbConfigItemChange = useCallback((key: keyof UsbDeviceConfig) => (e: React.ChangeEvent) => { - setUsbDeviceConfig((val) => { - val[key] = e.target.checked; - handleUsbConfigChange(val); - return val; - }); - }, [handleUsbConfigChange]); + const onUsbConfigItemChange = useCallback( + (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent) => { + setUsbDeviceConfig(val => { + val[key] = e.target.checked; + handleUsbConfigChange(val); + return val; + }); + }, + [handleUsbConfigChange], + ); + + const handlePresetChange = useCallback( + async (e: React.ChangeEvent) => { + const newPreset = e.target.value; + setSelectedPreset(newPreset); + + if (newPreset !== "custom") { + const presetConfig = usbPresets.find( + preset => preset.value === newPreset, + )?.config; + + if (presetConfig) { + handleUsbConfigChange(presetConfig); + } + } + }, + [handleUsbConfigChange], + ); useEffect(() => { syncUsbDeviceConfig(); }, [syncUsbDeviceConfig]); return ( - <> +
-
- - - -
-
- - - -
-
- - - -
-
- - - -
- + + + + + + + + {selectedPreset === "custom" && ( +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+
+ )} +
); } diff --git a/ui/src/components/UsbConfigSetting.tsx b/ui/src/components/UsbInfoSetting.tsx similarity index 51% rename from ui/src/components/UsbConfigSetting.tsx rename to ui/src/components/UsbInfoSetting.tsx index 1e8ab03..4ac93ff 100644 --- a/ui/src/components/UsbConfigSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -1,6 +1,8 @@ import { useMemo } from "react"; import { useCallback } from "react"; +import { Button } from "@components/Button"; +import { InputFieldWithLabel } from "./InputField"; import { useEffect, useState } from "react"; import { UsbConfigState } from "../hooks/stores"; @@ -8,7 +10,7 @@ import { useJsonRpc } from "../hooks/useJsonRpc"; import notifications from "../notifications"; import { SettingsItem } from "../routes/devices.$id.settings"; import { SelectMenuBasic } from "./SelectMenuBasic"; -import USBConfigDialog from "./USBConfigDialog"; +import Fieldset from "./Fieldset"; const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); @@ -51,8 +53,9 @@ const usbConfigs = [ type UsbConfigMap = Record; -export function UsbConfigSetting() { +export function UsbInfoSetting() { const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); const [usbConfigProduct, setUsbConfigProduct] = useState(""); const [deviceId, setDeviceId] = useState(""); @@ -110,17 +113,23 @@ export function UsbConfigSetting() { const handleUsbConfigChange = useCallback( (usbConfig: USBConfig) => { - send("setUsbConfig", { usbConfig }, resp => { + setLoading(true); + send("setUsbConfig", { usbConfig }, async resp => { if ("error" in resp) { notifications.error( `Failed to set usb config: ${resp.error.data || "Unknown error"}`, ); + setLoading(false); return; } - // setUsbConfigProduct(usbConfig.product); + + // We need some time to ensure the USB devices are updated + await new Promise(resolve => setTimeout(resolve, 2000)); + setLoading(false); notifications.success( `USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`, ); + syncUsbConfigProduct(); }); }, @@ -141,18 +150,18 @@ export function UsbConfigSetting() { }, [send, syncUsbConfigProduct]); return ( - <> -
- +
{ if (e.target.value === "custom") { setUsbConfigProduct(e.target.value); @@ -165,13 +174,130 @@ export function UsbConfigSetting() { /> {usbConfigProduct === "custom" && ( - handleUsbConfigChange(usbConfig)} - onRestoreToDefault={() => - handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) - } - /> +
+ handleUsbConfigChange(usbConfig)} + onRestoreToDefault={() => + handleUsbConfigChange(usbConfigData[usbConfigs[0].value]) + } + /> +
)} - +
+ ); +} + +function USBConfigDialog({ + loading, + onSetUsbConfig, + onRestoreToDefault, +}: { + loading: boolean; + onSetUsbConfig: (usbConfig: USBConfig) => void; + onRestoreToDefault: () => void; +}) { + const [usbConfigState, setUsbConfigState] = useState({ + vendor_id: "", + product_id: "", + serial_number: "", + manufacturer: "", + product: "", + }); + + const [send] = useJsonRpc(); + + const syncUsbConfig = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + } else { + setUsbConfigState(resp.result as UsbConfigState); + } + }); + }, [send, setUsbConfigState]); + + // Load stored usb config from the backend + useEffect(() => { + syncUsbConfig(); + }, [syncUsbConfig]); + + const handleUsbVendorIdChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, vendor_id: value }); + }; + + const handleUsbProductIdChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, product_id: value }); + }; + + const handleUsbSerialChange = (value: string) => { + setUsbConfigState({ ...usbConfigState, serial_number: value }); + }; + + const handleUsbManufacturer = (value: string) => { + setUsbConfigState({ ...usbConfigState, manufacturer: value }); + }; + + const handleUsbProduct = (value: string) => { + setUsbConfigState({ ...usbConfigState, product: value }); + }; + + return ( +
+
+ handleUsbVendorIdChange(e.target.value)} + /> + handleUsbProductIdChange(e.target.value)} + /> + handleUsbSerialChange(e.target.value)} + /> + handleUsbManufacturer(e.target.value)} + /> + handleUsbProduct(e.target.value)} + /> +
+
+
+
); } diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 3a60466..d9d3919 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -6,7 +6,7 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "../notifications"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; -import { UsbConfigSetting } from "../components/UsbConfigSetting"; +import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { FeatureFlag } from "../components/FeatureFlag"; @@ -131,11 +131,11 @@ export default function SettingsHardwareRoute() {
- + - +
); diff --git a/ui/src/routes/devices.$id.settings.tsx b/ui/src/routes/devices.$id.settings.tsx index 6084afb..1a8de03 100644 --- a/ui/src/routes/devices.$id.settings.tsx +++ b/ui/src/routes/devices.$id.settings.tsx @@ -16,6 +16,7 @@ import { cx } from "../cva.config"; import { useUiStore } from "../hooks/stores"; import useKeyboard from "../hooks/useKeyboard"; import { useResizeObserver } from "../hooks/useResizeObserver"; +import LoadingSpinner from "../components/LoadingSpinner"; /* 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() { @@ -206,7 +207,7 @@ export default function SettingsRoute() {
-
+
{/* */}
-

{title}

+
+

{title}

+ {loading && } +

{description}

{children ?
{children}
: null} From f3c49b853d8f652b242868fc337098c281ae8307 Mon Sep 17 00:00:00 2001 From: Siyuan Miao Date: Wed, 19 Mar 2025 17:43:19 +0100 Subject: [PATCH 10/14] fix(usb_mass_storage): should use path instead of configPath --- internal/usbgadget/config.go | 9 +++++++++ usb_mass_storage.go | 8 ++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/internal/usbgadget/config.go b/internal/usbgadget/config.go index 5f08733..5cc3ed2 100644 --- a/internal/usbgadget/config.go +++ b/internal/usbgadget/config.go @@ -128,6 +128,15 @@ func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) { return joinPath(u.kvmGadgetPath, item.configPath), nil } +// GetPath returns the path to the item. +func (u *UsbGadget) GetPath(itemKey string) (string, error) { + item, ok := u.configMap[itemKey] + if !ok { + return "", fmt.Errorf("config item %s not found", itemKey) + } + return joinPath(u.kvmGadgetPath, item.path), nil +} + func mountConfigFS() error { _, err := os.Stat(gadgetPath) // TODO: check if it's mounted properly diff --git a/usb_mass_storage.go b/usb_mass_storage.go index 45f613f..6578069 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -15,9 +15,9 @@ import ( "time" "github.com/gin-gonic/gin" - "github.com/psanford/httpreadat" "github.com/google/uuid" "github.com/pion/webrtc/v4" + "github.com/psanford/httpreadat" "github.com/jetkvm/kvm/resource" ) @@ -27,7 +27,7 @@ func writeFile(path string, data string) error { } func setMassStorageImage(imagePath string) error { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return fmt.Errorf("failed to get mass storage path: %w", err) } @@ -39,7 +39,7 @@ func setMassStorageImage(imagePath string) error { } func setMassStorageMode(cdrom bool) error { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return fmt.Errorf("failed to get mass storage path: %w", err) } @@ -110,7 +110,7 @@ func rpcMountBuiltInImage(filename string) error { } func getMassStorageMode() (bool, error) { - massStorageFunctionPath, err := gadget.GetConfigPath("mass_storage_lun0") + massStorageFunctionPath, err := gadget.GetPath("mass_storage_lun0") if err != nil { return false, fmt.Errorf("failed to get mass storage path: %w", err) } From f30eb0355e1c89c3cd763a3d07b8fcc277014597 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 19 Mar 2025 18:12:49 +0100 Subject: [PATCH 11/14] fix(Dialog): ensure navigation occurs after mount process completion (#273) --- ui/src/routes/devices.$id.mount.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 1a0ace8..6215d89 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -99,9 +99,8 @@ export function Dialog({ onClose }: { onClose: () => void }) { }) .finally(() => { setMountInProgress(false); + navigate(".."); }); - - navigate(".."); }); } @@ -125,6 +124,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { // and the modal exit animation for like 500ms setTimeout(() => { setMountInProgress(false); + navigate(".."); }, 500); }); }); @@ -155,6 +155,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { }) .finally(() => { setMountInProgress(false); + navigate(".."); }); }, ); @@ -839,7 +840,11 @@ function DeviceFileView({ onDelete={() => { const selectedFile = onStorageFiles.find(f => f.name === file.name); if (!selectedFile) return; - if (window.confirm("Are you sure you want to delete " + selectedFile.name + "?")) { + if ( + window.confirm( + "Are you sure you want to delete " + selectedFile.name + "?", + ) + ) { handleDeleteFile(selectedFile); } }} From f198df816c47bd2038bdbefa44ef5a5bc55f1955 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Wed, 19 Mar 2025 18:18:51 +0100 Subject: [PATCH 12/14] fix(Dialog): restore navigation after mount process completion (#274) --- ui/src/routes/devices.$id.mount.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/src/routes/devices.$id.mount.tsx b/ui/src/routes/devices.$id.mount.tsx index 6215d89..42be090 100644 --- a/ui/src/routes/devices.$id.mount.tsx +++ b/ui/src/routes/devices.$id.mount.tsx @@ -99,7 +99,6 @@ export function Dialog({ onClose }: { onClose: () => void }) { }) .finally(() => { setMountInProgress(false); - navigate(".."); }); }); } @@ -114,7 +113,7 @@ export function Dialog({ onClose }: { onClose: () => void }) { clearMountMediaState(); syncRemoteVirtualMediaState() .then(() => { - false; + navigate(".."); }) .catch(err => { triggerError(err instanceof Error ? err.message : String(err)); @@ -124,7 +123,6 @@ export function Dialog({ onClose }: { onClose: () => void }) { // and the modal exit animation for like 500ms setTimeout(() => { setMountInProgress(false); - navigate(".."); }, 500); }); }); @@ -155,7 +153,6 @@ export function Dialog({ onClose }: { onClose: () => void }) { }) .finally(() => { setMountInProgress(false); - navigate(".."); }); }, ); From caf3922ecd0b0b03199eabb83fca5b587c670ad6 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 24 Mar 2025 12:07:31 +0100 Subject: [PATCH 13/14] refactor(WebRTCVideo): improve mouse event handling and video playback logic (#282) --- ui/src/components/WebRTCVideo.tsx | 223 ++++++++++++++++++------------ ui/src/routes/devices.$id.tsx | 4 + 2 files changed, 136 insertions(+), 91 deletions(-) diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 29c72d1..de36e37 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -94,7 +94,7 @@ export default function WebRTCVideo() { ); // Mouse-related - const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos; + const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); const sendRelMouseMovement = useCallback( (x: number, y: number, buttons: number) => { if (settings.mouseMode !== "relative") return; @@ -168,7 +168,14 @@ export default function WebRTCVideo() { const { buttons } = e; sendAbsMouseMovement(x, y, buttons); }, - [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode], + [ + sendAbsMouseMovement, + videoClientHeight, + videoClientWidth, + videoWidth, + videoHeight, + settings.mouseMode, + ], ); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); @@ -355,28 +362,6 @@ export default function WebRTCVideo() { ], ); - // Effect hooks - useEffect( - function setupKeyboardEvents() { - const abortController = new AbortController(); - const signal = abortController.signal; - - document.addEventListener("keydown", keyDownHandler, { signal }); - document.addEventListener("keyup", keyUpHandler, { signal }); - - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - window.clearKeys = () => sendKeyboardEvent([], []); - window.addEventListener("blur", resetKeyboardState, { signal }); - document.addEventListener("visibilitychange", resetKeyboardState, { signal }); - - return () => { - abortController.abort(); - }; - }, - [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], - ); - const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { // In fullscreen mode in chrome & safari, the space key is used to pause/play the video // there is no way to prevent this, so we need to simply force play the video when it's paused. @@ -389,71 +374,6 @@ export default function WebRTCVideo() { } }, []); - useEffect( - function setupVideoEventListeners() { - let videoElmRefValue = null; - if (!videoElm.current) return; - videoElmRefValue = videoElm.current; - const abortController = new AbortController(); - const signal = abortController.signal; - - videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); - videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); - videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { - signal, - passive: true, - }); - videoElmRefValue.addEventListener( - "contextmenu", - (e: MouseEvent) => e.preventDefault(), - { signal }, - ); - videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); - - const local = resetMousePosition; - window.addEventListener("blur", local, { signal }); - document.addEventListener("visibilitychange", local, { signal }); - - return () => { - if (videoElmRefValue) abortController.abort(); - }; - }, - [ - absMouseMoveHandler, - resetMousePosition, - onVideoPlaying, - mouseWheelHandler, - videoKeyUpHandler, - ], - ); - - useEffect( - function setupRelativeMouseEventListeners() { - if (settings.mouseMode !== "relative") return; - - const abortController = new AbortController(); - const signal = abortController.signal; - - // bind to body to capture all mouse events - const body = document.querySelector("body"); - if (!body) return; - - body.addEventListener("mousemove", relMouseMoveHandler, { signal }); - body.addEventListener("pointerdown", relMouseMoveHandler, { signal }); - body.addEventListener("pointerup", relMouseMoveHandler, { signal }); - - return () => { - abortController.abort(); - - body.removeEventListener("mousemove", relMouseMoveHandler); - body.removeEventListener("pointerdown", relMouseMoveHandler); - body.removeEventListener("pointerup", relMouseMoveHandler); - }; - }, [settings.mouseMode, relMouseMoveHandler], - ) - useEffect( function updateVideoStream() { if (!mediaStream) return; @@ -476,6 +396,120 @@ export default function WebRTCVideo() { ], ); + // Setup Keyboard Events + useEffect( + function setupKeyboardEvents() { + const abortController = new AbortController(); + const signal = abortController.signal; + + document.addEventListener("keydown", keyDownHandler, { signal }); + document.addEventListener("keyup", keyUpHandler, { signal }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + window.clearKeys = () => sendKeyboardEvent([], []); + window.addEventListener("blur", resetKeyboardState, { signal }); + document.addEventListener("visibilitychange", resetKeyboardState, { signal }); + + return () => { + abortController.abort(); + }; + }, + [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], + ); + + useEffect( + function setupVideoEventListeners() { + const videoElmRefValue = videoElm.current; + if (!videoElmRefValue) return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // To prevent the video from being paused when the user presses a space in fullscreen mode + videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); + + // We need to know when the video is playing to update state and video size + videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); + + return () => { + abortController.abort(); + }; + }, + [ + absMouseMoveHandler, + resetMousePosition, + onVideoPlaying, + mouseWheelHandler, + videoKeyUpHandler, + ], + ); + + // Setup Absolute Mouse Events + useEffect( + function setAbsoluteMouseModeEventListeners() { + const videoElmRefValue = videoElm.current; + if (!videoElmRefValue) return; + + if (settings.mouseMode !== "absolute") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); + videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { + signal, + passive: true, + }); + + // Reset the mouse position when the window is blurred or the document is hidden + const local = resetMousePosition; + window.addEventListener("blur", local, { signal }); + document.addEventListener("visibilitychange", local, { signal }); + + return () => { + abortController.abort(); + }; + }, + [absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode], + ); + + // Setup Relative Mouse Events + const containerRef = useRef(null); + useEffect( + function setupRelativeMouseEventListeners() { + if (settings.mouseMode !== "relative") return; + + const abortController = new AbortController(); + const signal = abortController.signal; + + // We bind to the larger container in relative mode because of delta between the acceleration of the local + // mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use. + // When we get Pointer Lock support, we can remove this. + const containerElm = containerRef.current; + if (!containerElm) return; + + containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal }); + containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal }); + containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal }); + + containerElm.addEventListener("wheel", mouseWheelHandler, { + signal, + passive: true, + }); + + const preventContextMenu = (e: MouseEvent) => e.preventDefault(); + containerElm.addEventListener("contextmenu", preventContextMenu, { signal }); + + return () => { + abortController.abort(); + }; + }, + [settings.mouseMode, relMouseMoveHandler, mouseWheelHandler], + ); + return (
@@ -490,7 +524,12 @@ export default function WebRTCVideo() {
-
+
e.stopPropagation()} + // onMouseDown={e => e.stopPropagation()} + // onMouseUp={e => e.stopPropagation()} + // onPointerMove={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()} onKeyDown={e => { e.stopPropagation(); From 204e6c7fafab1f5519be3f94943a04f2434bffd8 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Mon, 24 Mar 2025 12:32:12 +0100 Subject: [PATCH 14/14] feat(UsbDeviceSetting): integrate remote virtual media state management and improve USB config handlingt --- ui/src/components/UsbDeviceSetting.tsx | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 605ae4d..07125e6 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -9,7 +9,6 @@ import { Button } from "./Button"; import { SelectMenuBasic } from "./SelectMenuBasic"; import { SettingsSectionHeader } from "./SettingsSectionHeader"; import Fieldset from "./Fieldset"; - export interface USBConfig { vendor_id: string; product_id: string; @@ -119,13 +118,12 @@ export function UsbDeviceSetting() { const onUsbConfigItemChange = useCallback( (key: keyof UsbDeviceConfig) => (e: React.ChangeEvent) => { - setUsbDeviceConfig(val => { - val[key] = e.target.checked; - handleUsbConfigChange(val); - return val; - }); + setUsbDeviceConfig(prev => ({ + ...prev, + [key]: e.target.checked, + })); }, - [handleUsbConfigChange], + [], ); const handlePresetChange = useCallback(