From e2e4644437a28e772349c4eb50eb7a019ffb6fab Mon Sep 17 00:00:00 2001 From: Antony Messerli Date: Tue, 11 Feb 2025 08:13:29 -0600 Subject: [PATCH 01/83] Update to latest ISO versions (#78) * Fedora 38 is EOL, bump to 41 and use main Fedora mirror * Bumps Arch Linux and Debian to latest builds --- ui/src/components/MountMediaDialog.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 505d233a..4aca608f 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -534,17 +534,17 @@ function UrlView({ }, { name: "Debian 12", - url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.7.0-amd64-netinst.iso", + url: "https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-12.9.0-amd64-netinst.iso", icon: DebianIcon, }, { - name: "Fedora 38", - url: "https://mirror.ihost.md/fedora/releases/38/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-38-1.6.iso", + name: "Fedora 41", + url: "https://download.fedoraproject.org/pub/fedora/linux/releases/41/Workstation/x86_64/iso/Fedora-Workstation-Live-x86_64-41-1.4.iso", icon: FedoraIcon, }, { name: "Arch Linux", - url: "https://archlinux.doridian.net/iso/2024.10.01/archlinux-2024.10.01-x86_64.iso", + url: "https://archlinux.doridian.net/iso/2025.02.01/archlinux-2025.02.01-x86_64.iso", icon: ArchIcon, }, { From 49915674325d8f6557849e3a031037fbdf1f96d5 Mon Sep 17 00:00:00 2001 From: Brandon Tuttle <11356668+tutman96@users.noreply.github.com> Date: Tue, 11 Feb 2025 09:13:41 -0500 Subject: [PATCH 02/83] Remove rounded corners from video stream (#86) --- ui/src/components/WebRTCVideo.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index f5f083bb..7603369c 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -425,7 +425,7 @@ export default function WebRTCVideo() { disablePictureInPicture controlsList="nofullscreen" className={cx( - "outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000", + "outline-50 max-h-full max-w-full object-contain transition-all duration-1000", { "cursor-none": settings.isCursorHidden, "opacity-0": isLoading || isConnectionError || hdmiError, From d76e9dcd35134e14c9c2d6b86c8a0afedf9220cf Mon Sep 17 00:00:00 2001 From: Aveline Date: Tue, 11 Feb 2025 15:45:14 +0100 Subject: [PATCH 03/83] feat: add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT (#146) Add ICE servers and local IP address returned by the API to fix connectivity issues behind NAT --- cloud.go | 11 ++++++++--- web.go | 8 +++++--- webrtc.go | 33 ++++++++++++++++++++++++++++++--- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/cloud.go b/cloud.go index 5088ec73..3520e2f8 100644 --- a/cloud.go +++ b/cloud.go @@ -7,13 +7,14 @@ import ( "fmt" "net/http" "net/url" - "github.com/coder/websocket/wsjson" "time" + "github.com/coder/websocket/wsjson" + "github.com/coreos/go-oidc/v3/oidc" - "github.com/gin-gonic/gin" "github.com/coder/websocket" + "github.com/gin-gonic/gin" ) type CloudRegisterRequest struct { @@ -192,7 +193,11 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess return fmt.Errorf("google identity mismatch") } - session, err := newSession() + session, err := newSession(SessionConfig{ + ICEServers: req.ICEServers, + LocalIP: req.IP, + IsCloud: true, + }) if err != nil { _ = wsjson.Write(context.Background(), c, gin.H{"error": err}) return err diff --git a/web.go b/web.go index 64f8de71..02c7eeae 100644 --- a/web.go +++ b/web.go @@ -17,8 +17,10 @@ import ( var staticFiles embed.FS type WebRTCSessionRequest struct { - Sd string `json:"sd"` - OidcGoogle string `json:"OidcGoogle,omitempty"` + Sd string `json:"sd"` + OidcGoogle string `json:"OidcGoogle,omitempty"` + IP string `json:"ip,omitempty"` + ICEServers []string `json:"iceServers,omitempty"` } type SetPasswordRequest struct { @@ -116,7 +118,7 @@ func handleWebRTCSession(c *gin.Context) { return } - session, err := newSession() + session, err := newSession(SessionConfig{}) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err}) return diff --git a/webrtc.go b/webrtc.go index 20ffb99c..27084fca 100644 --- a/webrtc.go +++ b/webrtc.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net" "strings" "github.com/pion/webrtc/v4" @@ -19,6 +20,12 @@ type Session struct { shouldUmountVirtualMedia bool } +type SessionConfig struct { + ICEServers []string + LocalIP string + IsCloud bool +} + func (s *Session) ExchangeOffer(offerStr string) (string, error) { b, err := base64.StdEncoding.DecodeString(offerStr) if err != nil { @@ -61,9 +68,29 @@ func (s *Session) ExchangeOffer(offerStr string) (string, error) { return base64.StdEncoding.EncodeToString(localDescription), nil } -func newSession() (*Session, error) { - peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{ - ICEServers: []webrtc.ICEServer{{}}, +func newSession(config SessionConfig) (*Session, error) { + webrtcSettingEngine := webrtc.SettingEngine{} + iceServer := webrtc.ICEServer{} + + if config.IsCloud { + if config.ICEServers == nil { + fmt.Printf("ICE Servers not provided by cloud") + } else { + iceServer.URLs = config.ICEServers + fmt.Printf("Using ICE Servers provided by cloud: %v\n", iceServer.URLs) + } + + if config.LocalIP == "" || net.ParseIP(config.LocalIP) == nil { + fmt.Printf("Local IP address %v not provided or invalid, won't set NAT1To1IPs\n", config.LocalIP) + } else { + webrtcSettingEngine.SetNAT1To1IPs([]string{config.LocalIP}, webrtc.ICECandidateTypeSrflx) + fmt.Printf("Setting NAT1To1IPs to %s\n", config.LocalIP) + } + } + + api := webrtc.NewAPI(webrtc.WithSettingEngine(webrtcSettingEngine)) + peerConnection, err := api.NewPeerConnection(webrtc.Configuration{ + ICEServers: []webrtc.ICEServer{iceServer}, }) if err != nil { return nil, err From fd5b9eb8530c3b9776579212cd08ebe1ed2c6336 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 19:12:19 +0000 Subject: [PATCH 04/83] NEXT-ONLY: chore: update README --- README.md | 61 ++++++++++++++++++++----------------------------------- 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 1b516d79..def66532 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,29 @@ -
- JetKVM logo +# jetkvm-next -### KVM +jetkvm-next is a fork of the JetKVM application with various in-progress features merged in from commnunity +pull requests. -[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs) +This branch isn't meant to be pulled into the upstream, and will almost certainly contain some bugs, it's a +bleeding-edge build of the software that community members can use to try out new features, or for developers to check +their upcoming features don't clash with other in-progress PRs. -[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm) +> Generally, you shouldn't run jetkvm-next on your device, you should keep it on the main update stream, or optionally +> switch to the beta branch in the device's settings. -
+Main repo: https://github.com/jetkvm/kvm -JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively. +## Current Additional Features +The below [in-development features](https://github.com/jetkvm/kvm) are currently included in `jetkvm-next`. +The commits from the developer's working tree and cherry picked into this branch, to check the "version" of the feature, +compare the commit hash on this branch, to the current hash of the commit(s) in the pull request. -## Features +- tutman - [Plugin System](https://github.com/jetkvm/kvm/pull/10) +- SuperQ - [Prometheus Metrics](https://github.com/jetkvm/kvm/pull/6) +- Nevexo - [Force-release IPv4 addresses on Link Down](https://github.com/jetkvm/kvm/pull/16) +- Nevexo - [Display backlight brightness control](https://github.com/jetkvm/kvm/pull/17) +- Nevexo - [CTRL+ALT+DEL Button on Action Bar](https://github.com/jetkvm/kvm/pull/18) +- tutman - [Clean-up jetkvm_native when app exits](https://github.com/jetkvm/kvm/pull/19) -- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control. -- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC. -- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device. - -## Contributing - -We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully. - -## I need help - -The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW). - -## I want to report an issue - -If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/kvm/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue. - -# Development - -JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An intermediate level of Go & TypeScript knowledge is recommended for comfortable programming. - -The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud. - -For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information. - -## Backend - -The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web. - -## Frontend - -The frontend is written in React and TypeScript and is served by the KVM device. It has three build targets: `device`, `development` and `production`. Development is used for development of the cloud version on your local machine, device is used for building the frontend for the KVM device and production is used for building the frontend for the cloud. +## Additional Info +There's a GitHub Action setup to build the JetKVM software whenever this repo has a new release added, it'll take +a few minutes for the binary to appear on the release once this repo is tagged. From 979a1c62f5fae77934dc87d7602743c6c9b747b2 Mon Sep 17 00:00:00 2001 From: SuperQ Date: Tue, 31 Dec 2024 14:12:19 +0100 Subject: [PATCH 05/83] Feature: Add a metrics endpoint Add a basic Prometheus Monitoring metrics endpoint. * Include a `jetkvm_build_info` metric. * `go mod tidy` Signed-off-by: SuperQ --- Makefile | 13 ++++++++++-- go.mod | 23 ++++++++++++-------- go.sum | 64 +++++++++++++++++++++++++++++++++----------------------- web.go | 9 ++++++++ 4 files changed, 72 insertions(+), 37 deletions(-) diff --git a/Makefile b/Makefile index 04c7402a..0f295340 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,21 @@ +BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) +BUILDDATE ?= $(shell date --iso-8601=seconds) +REVISION ?= $(shell git rev-parse HEAD) VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M) VERSION := 0.3.4 +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) + 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="-s -w -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.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go frontend: cd ui && npm ci && npm run build:device @@ -19,7 +28,7 @@ dev_release: build_dev build_release: frontend hash_resource @echo "Building release..." - GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go + GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go release: @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ diff --git a/go.mod b/go.mod index 5ddcfb68..15a6c7d0 100644 --- a/go.mod +++ b/go.mod @@ -19,17 +19,21 @@ require ( github.com/pion/mdns/v2 v2.0.7 github.com/pion/webrtc/v4 v4.0.0 github.com/pojntfx/go-nbd v0.3.2 + github.com/prometheus/client_golang v1.20.5 + github.com/prometheus/common v0.61.0 github.com/psanford/httpreadat v0.1.0 github.com/vishvananda/netlink v1.3.0 - golang.org/x/crypto v0.28.0 - golang.org/x/net v0.30.0 + golang.org/x/crypto v0.30.0 + golang.org/x/net v0.32.0 ) replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b require ( + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect @@ -40,12 +44,13 @@ require ( github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect - github.com/kr/pretty v0.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pilebones/go-udev v0.9.0 // indirect github.com/pion/datachannel v1.5.9 // indirect @@ -61,16 +66,16 @@ require ( github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/turn/v4 v4.0.0 // indirect - github.com/rogpeppe/go-internal v1.8.0 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect github.com/vishvananda/netns v0.0.4 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/oauth2 v0.21.0 // indirect - golang.org/x/sys v0.26.0 // indirect - golang.org/x/text v0.19.0 // indirect - google.golang.org/protobuf v1.34.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + golang.org/x/oauth2 v0.24.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect + google.golang.org/protobuf v1.35.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index be219176..51a7336b 100644 --- a/go.sum +++ b/go.sum @@ -2,10 +2,14 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+ github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM= github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs= github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -16,7 +20,6 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -40,8 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -51,20 +54,22 @@ github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uo github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4= github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -76,6 +81,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4= github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= @@ -114,14 +121,20 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8= github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= +github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE= github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= -github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -132,8 +145,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= @@ -147,29 +161,27 @@ github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguH golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw= -golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= -golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= -golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= -golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= +golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY= +golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI= +golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs= +golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= +golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= -golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= -golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= -google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/web.go b/web.go index 02c7eeae..3c91c16d 100644 --- a/web.go +++ b/web.go @@ -10,6 +10,10 @@ 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" ) @@ -80,6 +84,11 @@ func setupRouter() *gin.Engine { // We use this to setup the device in the welcome page 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) protected := r.Group("/") protected.Use(protectedMiddleware()) From c3be8fdcb5728ac7630e8502b6a117ab50393cb1 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 00:10:56 +0000 Subject: [PATCH 06/83] feat(ui/ActionBar): add Ctrl + Alt + Del button to Action Bar This commit adds a CTRL + ALT + DEL button to the Action Bar allowing you to send the combination to the target machine without launching the Virtual Keyboard, or sending the signal to the computer you're accessing the KVM from. This is useful for people installing OS', or potentially debugging kernel issues. --- ui/src/components/ActionBar.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index cd5432c9..85077371 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -10,12 +10,14 @@ import Container from "@components/Container"; import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu"; import { cx } from "@/cva.config"; import PasteModal from "@/components/popovers/PasteModal"; -import { FaKeyboard } from "react-icons/fa6"; +import { FaKeyboard, FaLock } from "react-icons/fa6"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react"; import MountPopopover from "./popovers/MountPopover"; import { Fragment, useCallback, useRef } from "react"; import { CommandLineIcon } from "@heroicons/react/20/solid"; +import useKeyboard from "@/hooks/useKeyboard"; +import { keys, modifiers } from "@/keyboardMappings"; export default function Actionbar({ requestFullscreen, @@ -52,6 +54,8 @@ export default function Actionbar({ [setDisableFocusTrap], ); + const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + return (
setVirtualKeyboard(!virtualKeyboard)} />
+
+
From da9649a440e43d5bad25dc1e10b2eb305128c257 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 00:22:08 +0000 Subject: [PATCH 07/83] feat(ui): make Ctrl + Alt + Del button a setting This commit makes the Action Bar Ctrl + Alt + Del button a setting, which is off by default. --- ui/src/components/ActionBar.tsx | 19 ++++++++++--------- ui/src/components/sidebar/settings.tsx | 9 +++++++++ ui/src/hooks/stores.ts | 6 ++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 85077371..1a39e781 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -207,22 +207,23 @@ export default function Actionbar({ onClick={() => setVirtualKeyboard(!virtualKeyboard)} />
-
-
+ }} + /> + + )}
diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index ec606a67..bbde0999 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -796,6 +796,15 @@ export default function SettingsSidebar() { }} /> + + settings.setActionBarCtrlAltDel(e.target.checked)} + /> +
void; + + actionBarCtrlAltDel: boolean; + setActionBarCtrlAltDel: (enabled: boolean) => void; } export const useSettingsStore = create( @@ -287,6 +290,9 @@ export const useSettingsStore = create( // Add developer mode with default value developerMode: false, setDeveloperMode: enabled => set({ developerMode: enabled }), + + actionBarCtrlAltDel: false, + setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }), }), { name: "settings", From 04a0cb83cd7b7b80c56061508baa848e0348910c Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 11:23:32 +0000 Subject: [PATCH 08/83] feat(display.go): impl setDisplayBrightness() Implements setDisplayBrightness(brightness int) which allows setting the backlight brightness on JetKVM's hardware. Needs to be implemented into the RPC, config and frontend. --- display.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/display.go b/display.go index f312eb66..607e7643 100644 --- a/display.go +++ b/display.go @@ -4,6 +4,9 @@ import ( "fmt" "log" "time" + "os" + "errors" + "strconv" ) var currentScreen = "ui_Boot_Screen" @@ -83,6 +86,29 @@ func updateStaticContents() { updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID()) } +// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter +// the backlight brightness of the JetKVM hardware's display. +func setDisplayBrightness(brightness int) (error) { + if brightness > 100 || brightness < 0 { + return errors.New("brightness value out of bounds, must be between 0 and 100") + } + + // Check the display backlight class is available + if _, err := os.Stat("/sys/class/backlight/backlight/brightness"); errors.Is(err, os.ErrNotExist) { + return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware.") + } + + // Set the value + bs := []byte(strconv.Itoa(brightness)) + err := os.WriteFile("/sys/class/backlight/backlight/brightness", bs, 0644) + if err != nil { + return err + } + + fmt.Print("display: set brightness to %v", brightness) + return nil +} + func init() { go func() { waitCtrlClientConnected() From d7586ef92b4efee0d7c7208af94e96733aba69f7 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 22:05:46 +0000 Subject: [PATCH 09/83] feat(config): add backlight control settings --- config.go | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/config.go b/config.go index 1636434a..1972a701 100644 --- a/config.go +++ b/config.go @@ -12,23 +12,29 @@ type WakeOnLanDevice struct { } type Config struct { - CloudURL string `json:"cloud_url"` - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + CloudURL string `json:"cloud_url"` + CloudToken string `json:"cloud_token"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + DisplayMaxBrightness int `json:"display_max_brightness"` + DisplayDimAfterMs int64 `json:"display_dim_after_ms"` + DisplayOffAfterMs int64 `json:"display_off_after_ms"` } const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value + CloudURL: "https://api.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value + DisplayMaxBrightness: 100, + DisplayDimAfterMs: 0, + DisplayOffAfterMs: 0, } var config *Config From 8d3b597cde30a51f5d5b292ed20472aaf1f47431 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 22:07:05 +0000 Subject: [PATCH 10/83] feat(display): add automatic dimming & switch off to display WIP, dims the display to 50% of the BacklightMaxBrightness after BacklightDimAfterMS expires. Turns the display off after BacklightOffAfterMS --- display.go | 72 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 4 deletions(-) diff --git a/display.go b/display.go index 607e7643..a6dd5f1b 100644 --- a/display.go +++ b/display.go @@ -1,15 +1,17 @@ package kvm import ( + "errors" "fmt" "log" - "time" "os" - "errors" "strconv" + "time" ) var currentScreen = "ui_Boot_Screen" +var lastWakeTime = time.Now() +var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF func switchToScreen(screen string) { _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) @@ -68,6 +70,7 @@ func requestDisplayUpdate() { return } go func() { + wakeDisplay() fmt.Println("display updating........................") //TODO: only run once regardless how many pending updates updateDisplay() @@ -88,7 +91,7 @@ func updateStaticContents() { // setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter // the backlight brightness of the JetKVM hardware's display. -func setDisplayBrightness(brightness int) (error) { +func setDisplayBrightness(brightness int) error { if brightness > 100 || brightness < 0 { return errors.New("brightness value out of bounds, must be between 0 and 100") } @@ -105,10 +108,58 @@ func setDisplayBrightness(brightness int) (error) { return err } - fmt.Print("display: set brightness to %v", brightness) + fmt.Printf("display: set brightness to %v", brightness) return nil } +// displayTimeoutTick checks the time the display was last woken, and compares that to the +// config's displayTimeout values to decide whether or not to dim/switch off the display. +func displayTimeoutTick() { + tn := time.Now() + td := tn.Sub(lastWakeTime).Milliseconds() + + // fmt.Printf("display: tick: time since wake: %vms, dim after: %v, off after: %v\n", td, config.DisplayDimAfterMs, config.DisplayOffAfterMs) + + if td > config.DisplayOffAfterMs && config.DisplayOffAfterMs != 0 && (backlightState == 1 || backlightState == 0) { + // Display fully off + + backlightState = 2 + err := setDisplayBrightness(0) + if err != nil { + fmt.Printf("display: timeout: Failed to switch off backlight: %s\n", err) + } + + } else if td > config.DisplayDimAfterMs && config.DisplayDimAfterMs != 0 && backlightState == 0 { + // Display dimming + + // Get 50% of max brightness, rounded up. + dimBright := config.DisplayMaxBrightness / 2 + fmt.Printf("display: timeout: target dim brightness: %v\n", dimBright) + + backlightState = 1 + err := setDisplayBrightness(dimBright) + if err != nil { + fmt.Printf("display: timeout: Failed to dim backlight: %s\n", err) + } + } +} + +// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display +// last woke, ready for displayTimeoutTick to put the display back in the dim/off states. +func wakeDisplay() { + if config.DisplayMaxBrightness == 0 { + config.DisplayMaxBrightness = 100 + } + + err := setDisplayBrightness(config.DisplayMaxBrightness) + if err != nil { + fmt.Printf("display wake failed, %s\n", err) + } + + lastWakeTime = time.Now() + backlightState = 0 +} + func init() { go func() { waitCtrlClientConnected() @@ -119,4 +170,17 @@ func init() { fmt.Println("display inited") requestDisplayUpdate() }() + + go func() { + // Start display auto-sleeping ticker + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + displayTimeoutTick() + } + } + }() } From b15728a880e54bd4a6dffa320c99f146c503af78 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 22:07:21 +0000 Subject: [PATCH 11/83] feat(rpc): add methods to get and set BacklightSettings --- jsonrpc.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/jsonrpc.go b/jsonrpc.go index 2ce5f189..738ee470 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -34,6 +34,12 @@ type JSONRPCEvent struct { Params interface{} `json:"params,omitempty"` } +type BacklightSettings struct { + MaxBrightness int `json:"max_brightness"` + DimAfter int `json:"dim_after"` + OffAfter int `json:"off_after"` +} + func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { responseBytes, err := json.Marshal(response) if err != nil { @@ -219,6 +225,43 @@ func rpcTryUpdate() error { return nil } +func rpcSetBacklightSettings(data *BacklightSettings) error { + LoadConfig() + + blConfig := *data + + if blConfig.MaxBrightness > 100 || blConfig.MaxBrightness < 0 { + return fmt.Errorf("maxBrightness must be between 0 and 100") + } + + if blConfig.DimAfter < 0 { + return fmt.Errorf("dimAfter must be a positive integer") + } + + if blConfig.OffAfter < 0 { + return fmt.Errorf("offAfter must be a positive integer") + } + + config.DisplayMaxBrightness = blConfig.MaxBrightness + config.DisplayDimAfterMs = int64(blConfig.DimAfter) + config.DisplayOffAfterMs = int64(blConfig.OffAfter) + + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func rpcGetBacklightSettings() (*BacklightSettings, error) { + LoadConfig() + + return &BacklightSettings{ + MaxBrightness: config.DisplayMaxBrightness, + DimAfter: int(config.DisplayDimAfterMs), + OffAfter: int(config.DisplayOffAfterMs), + }, nil +} + const ( devModeFile = "/userdata/jetkvm/devmode.enable" sshKeyDir = "/userdata/dropbear/.ssh" @@ -554,4 +597,6 @@ var rpcHandlers = map[string]RPCHandler{ "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, + "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"settings"}}, + "getBacklightSettings": {Func: rpcGetBacklightSettings}, } From c907e7452f1b5ffcff148736ee98c2848df73e5d Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 22:07:34 +0000 Subject: [PATCH 12/83] WIP: feat(settings): add Max backlight setting --- ui/src/components/sidebar/settings.tsx | 53 ++++++++++++++++++++++++++ ui/src/hooks/stores.ts | 16 ++++++++ 2 files changed, 69 insertions(+) diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index bbde0999..d8adba38 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -1,5 +1,6 @@ import SidebarHeader from "@components/SidebarHeader"; import { + BacklightSettings, useLocalAuthModalStore, useSettingsStore, useUiStore, @@ -95,6 +96,7 @@ export default function SettingsSidebar() { const hideCursor = useSettingsStore(state => state.isCursorHidden); const setHideCursor = useSettingsStore(state => state.setCursorVisibility); const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode); + const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings); const [currentVersions, setCurrentVersions] = useState<{ appVersion: string; @@ -228,6 +230,18 @@ export default function SettingsSidebar() { [send, setDeveloperMode], ); + const handleBacklightSettingChange = useCallback((settings: BacklightSettings) => { + send("setBacklightSettings", { settings }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set backlight settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + notifications.success("Backlight settings updated successfully"); + }); + }, [send]); + const handleUpdateSSHKey = useCallback(() => { send("setSSHKeyState", { sshKey }, resp => { if ("error" in resp) { @@ -302,6 +316,17 @@ export default function SettingsSidebar() { } }); + send("getBacklightSettings", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to get backlight settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + const result = resp.result as BacklightSettings; + setBacklightSettings(result); + }) + send("getDevModeState", {}, resp => { if ("error" in resp) return; const result = resp.result as { enabled: boolean }; @@ -806,6 +831,34 @@ export default function SettingsSidebar() { />
+
+ +
+ + {/* TODO: Allow the user to pick any value between 0 and 100 */} + { + handleBacklightSettingChange({ + max_brightness: parseInt(e.target.value), + dim_after: settings.backlightSettings.dim_after, + off_after: settings.backlightSettings.off_after, + }); + }} + /> + +
void; } +export interface BacklightSettings { + max_brightness: number; + dim_after: number; + off_after: number; +} + export const useVideoStore = create(set => ({ width: 0, height: 0, @@ -273,6 +279,9 @@ interface SettingsState { actionBarCtrlAltDel: boolean; setActionBarCtrlAltDel: (enabled: boolean) => void; + + backlightSettings: BacklightSettings; + setBacklightSettings: (settings: BacklightSettings) => void; } export const useSettingsStore = create( @@ -293,6 +302,13 @@ export const useSettingsStore = create( actionBarCtrlAltDel: false, setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }), + + backlightSettings: { + max_brightness: 100, + dim_after: 10000, + off_after: 50000, + }, + setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }), }), { name: "settings", From 69b08753f8dc7f49d434ccad5979435bcea318ba Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 22:22:06 +0000 Subject: [PATCH 13/83] chore: use constant for backlight control file --- display.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/display.go b/display.go index a6dd5f1b..ebf65334 100644 --- a/display.go +++ b/display.go @@ -13,6 +13,10 @@ var currentScreen = "ui_Boot_Screen" var lastWakeTime = time.Now() var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF +const ( + BACKLIGHT_CONTROL_CLASS string = "/sys/class/backlight/backlight/brightness" +) + func switchToScreen(screen string) { _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) if err != nil { @@ -97,13 +101,13 @@ func setDisplayBrightness(brightness int) error { } // Check the display backlight class is available - if _, err := os.Stat("/sys/class/backlight/backlight/brightness"); errors.Is(err, os.ErrNotExist) { + if _, err := os.Stat(BACKLIGHT_CONTROL_CLASS); errors.Is(err, os.ErrNotExist) { return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware.") } // Set the value bs := []byte(strconv.Itoa(brightness)) - err := os.WriteFile("/sys/class/backlight/backlight/brightness", bs, 0644) + err := os.WriteFile(BACKLIGHT_CONTROL_CLASS, bs, 0644) if err != nil { return err } From 3bbc80e056617fddaaf3485c3ceccd196616afb0 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 22:22:29 +0000 Subject: [PATCH 14/83] fix: only attempt to wake the display if it's off --- display.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/display.go b/display.go index ebf65334..578f3b08 100644 --- a/display.go +++ b/display.go @@ -151,6 +151,10 @@ func displayTimeoutTick() { // wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display // last woke, ready for displayTimeoutTick to put the display back in the dim/off states. func wakeDisplay() { + if backlightState == 0 { + return + } + if config.DisplayMaxBrightness == 0 { config.DisplayMaxBrightness = 100 } From 3d93e722057e3794a32532802e78da12c7bc8adc Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 22:22:50 +0000 Subject: [PATCH 15/83] feat(display): wake on touch --- display.go | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/display.go b/display.go index 578f3b08..7aaea7c8 100644 --- a/display.go +++ b/display.go @@ -14,6 +14,7 @@ var lastWakeTime = time.Now() var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF const ( + TOUCHSCREEN_DEVICE string = "/dev/input/event1" BACKLIGHT_CONTROL_CLASS string = "/sys/class/backlight/backlight/brightness" ) @@ -168,6 +169,34 @@ func wakeDisplay() { backlightState = 0 } +// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the +// touchscreen interface still works even with LCD dimming/off. +// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight +// control should be hoisted up to jetkvm_native. +func watchTsEvents() { + // Open touchscreen device + ts, err := os.OpenFile(TOUCHSCREEN_DEVICE, os.O_RDONLY, 0666) + if err != nil { + fmt.Printf("display: failed to open touchscreen device: %s\n", err) + return + } + + defer ts.Close() + + // Watch for events + for { + buf := make([]byte, 24) + _, err := ts.Read(buf) + if err != nil { + fmt.Printf("display: failed to read from touchscreen device: %s\n", err) + return + } + + // Touchscreen event, wake the display + wakeDisplay() + } +} + func init() { go func() { waitCtrlClientConnected() @@ -191,4 +220,6 @@ func init() { } } }() + + go watchTsEvents() } From 93ce4c261b10445d2b70c392b8a14b74da1a6c35 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 22:26:39 +0000 Subject: [PATCH 16/83] fix: re-use buffer between reads --- display.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/display.go b/display.go index 7aaea7c8..86245f5e 100644 --- a/display.go +++ b/display.go @@ -184,8 +184,8 @@ func watchTsEvents() { defer ts.Close() // Watch for events + buf := make([]byte, 24) for { - buf := make([]byte, 24) _, err := ts.Read(buf) if err != nil { fmt.Printf("display: failed to read from touchscreen device: %s\n", err) From bda26d3a04488db9e1ed41b8f169c645a77d6f91 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sat, 4 Jan 2025 22:27:10 +0000 Subject: [PATCH 17/83] fix: wakeDisplay() on start to fix warm start issue If the application had turned off the display before exiting, it wouldn't be brought on when the application restarted without a device reboot. --- display.go | 1 + 1 file changed, 1 insertion(+) diff --git a/display.go b/display.go index 86245f5e..fc758492 100644 --- a/display.go +++ b/display.go @@ -205,6 +205,7 @@ func init() { updateStaticContents() displayInited = true fmt.Println("display inited") + wakeDisplay() requestDisplayUpdate() }() From 8b6d9242f492da4cdda2c6877aed3a938d549575 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 02:24:53 +0000 Subject: [PATCH 18/83] chore: various comment & string updates --- display.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/display.go b/display.go index fc758492..ab395167 100644 --- a/display.go +++ b/display.go @@ -97,13 +97,15 @@ func updateStaticContents() { // setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter // the backlight brightness of the JetKVM hardware's display. func setDisplayBrightness(brightness int) error { + // NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64. + // The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!). if brightness > 100 || brightness < 0 { return errors.New("brightness value out of bounds, must be between 0 and 100") } // Check the display backlight class is available if _, err := os.Stat(BACKLIGHT_CONTROL_CLASS); errors.Is(err, os.ErrNotExist) { - return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware.") + return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware") } // Set the value @@ -123,8 +125,6 @@ func displayTimeoutTick() { tn := time.Now() td := tn.Sub(lastWakeTime).Milliseconds() - // fmt.Printf("display: tick: time since wake: %vms, dim after: %v, off after: %v\n", td, config.DisplayDimAfterMs, config.DisplayOffAfterMs) - if td > config.DisplayOffAfterMs && config.DisplayOffAfterMs != 0 && (backlightState == 1 || backlightState == 0) { // Display fully off @@ -174,7 +174,6 @@ func wakeDisplay() { // TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight // control should be hoisted up to jetkvm_native. func watchTsEvents() { - // Open touchscreen device ts, err := os.OpenFile(TOUCHSCREEN_DEVICE, os.O_RDONLY, 0666) if err != nil { fmt.Printf("display: failed to open touchscreen device: %s\n", err) @@ -183,7 +182,9 @@ func watchTsEvents() { defer ts.Close() - // Watch for events + // This buffer is set to 24 bytes as that's the normal size of events on /dev/input + // Reference: https://www.kernel.org/doc/Documentation/input/input.txt + // This could potentially be set higher, to require multiple events to wake the display. buf := make([]byte, 24) for { _, err := ts.Read(buf) @@ -192,7 +193,6 @@ func watchTsEvents() { return } - // Touchscreen event, wake the display wakeDisplay() } } From f2588158c1f12b8fae556dabff02bb06b2501afe Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Fri, 3 Jan 2025 09:29:12 +0000 Subject: [PATCH 19/83] fix(net): stop dhcp client and release all v4 addr on linkdown This commit fixes jetkvm/kvm#12 by disabling the udhcpc client when the link goes down, it then removes all the active IPv4 addresses from the deivce. Once the link comes back up, it re-activates the udhcpc client so it can fetch a new IPv4 address for the device. This doesn't make any changes to the IPv6 side of things yet. --- network.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/network.go b/network.go index f461e453..ee88d051 100644 --- a/network.go +++ b/network.go @@ -6,6 +6,7 @@ import ( "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" "net" + "os/exec" "time" "github.com/vishvananda/netlink" @@ -25,6 +26,23 @@ type LocalIpInfo struct { MAC string } +// setDhcpClientState sends signals to udhcpc to change it's current mode +// of operation. Setting active to true will force udhcpc to renew the DHCP lease. +// Setting active to false will put udhcpc into idle mode. +func setDhcpClientState(active bool) { + var signal string; + if active { + signal = "-SIGUSR1" + } else { + signal = "-SIGUSR2" + } + + cmd := exec.Command("/usr/bin/killall", signal, "udhcpc"); + if err := cmd.Run(); err != nil { + fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err) + } +} + func checkNetworkState() { iface, err := netlink.LinkByName("eth0") if err != nil { @@ -47,9 +65,26 @@ func checkNetworkState() { fmt.Printf("failed to get addresses for eth0: %v\n", err) } + // If the link is going down, put udhcpc into idle mode. + // If the link is coming back up, activate udhcpc and force it to renew the lease. + if newState.Up != networkState.Up { + setDhcpClientState(newState.Up) + } + for _, addr := range addrs { if addr.IP.To4() != nil { - newState.IPv4 = addr.IP.String() + if !newState.Up && networkState.Up { + // If the network is going down, remove all IPv4 addresses from the interface. + fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String()) + err := netlink.AddrDel(iface, &addr) + if err != nil { + fmt.Printf("network: failed to delete %s", addr.IP.String()) + } + + newState.IPv4 = "..." + } else { + newState.IPv4 = addr.IP.String() + } } else if addr.IP.To16() != nil && newState.IPv6 == "" { newState.IPv6 = addr.IP.String() } From 510f7565bdc2063182e4343b83eb15cdba5afe94 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Wed, 1 Jan 2025 19:35:39 +0000 Subject: [PATCH 20/83] Implement plugin upload support and placeholder settings item --- internal/plugin/plugin.go | 53 +++ internal/storage/type.go | 6 + internal/storage/uploads.go | 34 ++ internal/storage/utils.go | 19 + jsonrpc.go | 2 + ui/src/components/MountMediaDialog.tsx | 2 +- ui/src/components/UploadPluginDialog.tsx | 482 +++++++++++++++++++++++ ui/src/components/sidebar/settings.tsx | 42 ++ ui/src/hooks/stores.ts | 11 + usb_mass_storage.go | 61 +-- 10 files changed, 664 insertions(+), 48 deletions(-) create mode 100644 internal/plugin/plugin.go create mode 100644 internal/storage/type.go create mode 100644 internal/storage/uploads.go create mode 100644 internal/storage/utils.go create mode 100644 ui/src/components/UploadPluginDialog.tsx diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 00000000..a794bba6 --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,53 @@ +package plugin + +import ( + "fmt" + "kvm/internal/storage" + "os" + "path" + + "github.com/google/uuid" +) + +const pluginsFolder = "/userdata/jetkvm/plugins" +const pluginsUploadFolder = pluginsFolder + "/_uploads" + +func init() { + _ = os.MkdirAll(pluginsUploadFolder, 0755) +} + +func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUpload, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) + if err != nil { + return nil, err + } + + filePath := path.Join(pluginsUploadFolder, sanitizedFilename) + uploadPath := filePath + ".incomplete" + + if _, err := os.Stat(filePath); err == nil { + return nil, fmt.Errorf("file already exists: %s", sanitizedFilename) + } + + var alreadyUploadedBytes int64 = 0 + if stat, err := os.Stat(uploadPath); err == nil { + alreadyUploadedBytes = stat.Size() + } + + uploadId := "plugin_" + uuid.New().String() + file, err := os.OpenFile(uploadPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, fmt.Errorf("failed to open file for upload: %v", err) + } + + storage.AddPendingUpload(uploadId, storage.PendingUpload{ + File: file, + Size: size, + AlreadyUploadedBytes: alreadyUploadedBytes, + }) + + return &storage.StorageFileUpload{ + AlreadyUploadedBytes: alreadyUploadedBytes, + DataChannel: uploadId, + }, nil +} diff --git a/internal/storage/type.go b/internal/storage/type.go new file mode 100644 index 00000000..ba7a1232 --- /dev/null +++ b/internal/storage/type.go @@ -0,0 +1,6 @@ +package storage + +type StorageFileUpload struct { + AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"` + DataChannel string `json:"dataChannel"` +} diff --git a/internal/storage/uploads.go b/internal/storage/uploads.go new file mode 100644 index 00000000..48fdaf7a --- /dev/null +++ b/internal/storage/uploads.go @@ -0,0 +1,34 @@ +package storage + +import ( + "os" + "sync" +) + +type PendingUpload struct { + File *os.File + Size int64 + AlreadyUploadedBytes int64 +} + +var pendingUploads = make(map[string]PendingUpload) +var pendingUploadsMutex sync.Mutex + +func GetPendingUpload(uploadId string) (PendingUpload, bool) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + upload, ok := pendingUploads[uploadId] + return upload, ok +} + +func AddPendingUpload(uploadId string, upload PendingUpload) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + pendingUploads[uploadId] = upload +} + +func DeletePendingUpload(uploadId string) { + pendingUploadsMutex.Lock() + defer pendingUploadsMutex.Unlock() + delete(pendingUploads, uploadId) +} diff --git a/internal/storage/utils.go b/internal/storage/utils.go new file mode 100644 index 00000000..e622fc23 --- /dev/null +++ b/internal/storage/utils.go @@ -0,0 +1,19 @@ +package storage + +import ( + "errors" + "path/filepath" + "strings" +) + +func SanitizeFilename(filename string) (string, error) { + cleanPath := filepath.Clean(filename) + if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") { + return "", errors.New("invalid filename") + } + sanitized := filepath.Base(cleanPath) + if sanitized == "." || sanitized == string(filepath.Separator) { + return "", errors.New("invalid filename") + } + return sanitized, nil +} diff --git a/jsonrpc.go b/jsonrpc.go index 738ee470..18a3ee61 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "kvm/internal/plugin" "log" "os" "os/exec" @@ -597,6 +598,7 @@ var rpcHandlers = map[string]RPCHandler{ "getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices}, "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, + "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"settings"}}, "getBacklightSettings": {Func: rpcGetBacklightSettings}, } diff --git a/ui/src/components/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 4aca608f..6f7c96b6 100644 --- a/ui/src/components/MountMediaDialog.tsx +++ b/ui/src/components/MountMediaDialog.tsx @@ -1516,7 +1516,7 @@ function PreUploadedImageItem({ ); } -function ViewHeader({ title, description }: { title: string; description: string }) { +export function ViewHeader({ title, description }: { title: string; description: string }) { return (

diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx new file mode 100644 index 00000000..5fb950fc --- /dev/null +++ b/ui/src/components/UploadPluginDialog.tsx @@ -0,0 +1,482 @@ +import Card, { GridCard } from "@/components/Card"; +import { useEffect, useRef, useState } from "react"; +import { Button } from "@components/Button"; +import LogoBlueIcon from "@/assets/logo-blue.svg"; +import LogoWhiteIcon from "@/assets/logo-white.svg"; +import Modal from "@components/Modal"; +import { + useRTCStore, +} from "../hooks/stores"; +import { cx } from "../cva.config"; +import { + LuCheck, + LuUpload, +} from "react-icons/lu"; +import { formatters } from "@/utils"; +import { PlusCircleIcon } from "@heroicons/react/20/solid"; +import AutoHeight from "./AutoHeight"; +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; +import { isOnDevice } from "../main"; +import { ViewHeader } from "./MountMediaDialog"; + +export default function UploadPluginModal({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ); +} + +function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + return ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo + + { + setOpen(false) + }} + onCancelUpload={() => { + setOpen(false) + }} + /> +
+
+
+
+
+ ); +} + +// This is pretty much a copy-paste from the UploadFileView component in the MountMediaDialog just with the media terminology changed and the rpc method changed. +// TODO: refactor to a shared component +function UploadFileView({ + onBack, + onCancelUpload, +}: { + onBack: () => void; + onCancelUpload: () => void; +}) { + const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( + "idle", + ); + const [uploadProgress, setUploadProgress] = useState(0); + const [uploadedFileName, setUploadedFileName] = useState(null); + const [uploadedFileSize, setUploadedFileSize] = useState(null); + const [uploadSpeed, setUploadSpeed] = useState(null); + const [fileError, setFileError] = useState(null); + const [uploadError, setUploadError] = useState(null); + + const [send] = useJsonRpc(); + const rtcDataChannelRef = useRef(null); + + useEffect(() => { + const ref = rtcDataChannelRef.current; + return () => { + if (ref) { + ref.onopen = null; + ref.onerror = null; + ref.onmessage = null; + ref.onclose = null; + ref.close(); + } + }; + }, []); + + function handleWebRTCUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const rtcDataChannel = useRTCStore + .getState() + .peerConnection?.createDataChannel(dataChannel); + + if (!rtcDataChannel) { + console.error("Failed to create data channel for file upload"); + notifications.error("Failed to create data channel for file upload"); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + + return; + } + + rtcDataChannelRef.current = rtcDataChannel; + + const lowWaterMark = 256 * 1024; + const highWaterMark = 1 * 1024 * 1024; + rtcDataChannel.bufferedAmountLowThreshold = lowWaterMark; + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + rtcDataChannel.onmessage = e => { + try { + const { AlreadyUploadedBytes, Size } = JSON.parse(e.data) as { + AlreadyUploadedBytes: number; + Size: number; + }; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = AlreadyUploadedBytes - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((AlreadyUploadedBytes / Size) * 100); + } + + lastUploadedBytes = AlreadyUploadedBytes; + lastUpdateTime = now; + } catch (e) { + console.error("Error processing RTC Data channel message:", e); + } + }; + + rtcDataChannel.onopen = () => { + let pauseSending = false; // Pause sending when the buffered amount is high + const chunkSize = 4 * 1024; // 4KB chunks + + let offset = alreadyUploadedBytes; + const sendNextChunk = () => { + if (offset >= file.size) { + rtcDataChannel.close(); + setUploadState("success"); + return; + } + + if (pauseSending) return; + + const chunk = file.slice(offset, offset + chunkSize); + chunk.arrayBuffer().then(buffer => { + rtcDataChannel.send(buffer); + + if (rtcDataChannel.bufferedAmount >= highWaterMark) { + pauseSending = true; + } + + offset += buffer.byteLength; + console.log(`Chunk sent: ${offset} / ${file.size} bytes`); + sendNextChunk(); + }); + }; + + sendNextChunk(); + rtcDataChannel.onbufferedamountlow = () => { + console.log("RTC Data channel buffered amount low"); + pauseSending = false; // Now the data channel is ready to send more data + sendNextChunk(); + }; + }; + + rtcDataChannel.onerror = error => { + console.error("RTC Data channel error:", error); + notifications.error(`Upload failed: ${error}`); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + }; + } + + async function handleHttpUpload( + file: File, + alreadyUploadedBytes: number, + dataChannel: string, + ) { + const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`; + + const xhr = new XMLHttpRequest(); + xhr.open("POST", uploadUrl, true); + + let lastUploadedBytes = alreadyUploadedBytes; + let lastUpdateTime = Date.now(); + const speedHistory: number[] = []; + + xhr.upload.onprogress = event => { + if (event.lengthComputable) { + const totalUploaded = alreadyUploadedBytes + event.loaded; + const totalSize = file.size; + + const now = Date.now(); + const timeDiff = (now - lastUpdateTime) / 1000; // in seconds + const bytesDiff = totalUploaded - lastUploadedBytes; + + if (timeDiff > 0) { + const instantSpeed = bytesDiff / timeDiff; // bytes per second + + // Add to speed history, keeping last 5 readings + speedHistory.push(instantSpeed); + if (speedHistory.length > 5) { + speedHistory.shift(); + } + + // Calculate average speed + const averageSpeed = + speedHistory.reduce((a, b) => a + b, 0) / speedHistory.length; + + setUploadSpeed(averageSpeed); + setUploadProgress((totalUploaded / totalSize) * 100); + } + + lastUploadedBytes = totalUploaded; + lastUpdateTime = now; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + setUploadState("success"); + } else { + console.error("Upload error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + } + }; + + xhr.onerror = () => { + console.error("XHR error:", xhr.statusText); + setUploadError(xhr.statusText); + setUploadState("idle"); + }; + + // Prepare the data to send + const blob = file.slice(alreadyUploadedBytes); + + // Send the file data + xhr.send(blob); + } + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // Reset the upload error when a new file is selected + setUploadError(null); + + setFileError(null); + console.log(`File selected: ${file.name}, size: ${file.size} bytes`); + setUploadedFileName(file.name); + setUploadedFileSize(file.size); + setUploadState("uploading"); + console.log("Upload state set to 'uploading'"); + + send("pluginStartUpload", { filename: file.name, size: file.size }, resp => { + console.log("pluginStartUpload response:", resp); + if ("error" in resp) { + console.error("Upload error:", resp.error.message); + setUploadError(resp.error.data || resp.error.message); + setUploadState("idle"); + console.log("Upload state set to 'idle'"); + return; + } + + const { alreadyUploadedBytes, dataChannel } = resp.result as { + alreadyUploadedBytes: number; + dataChannel: string; + }; + + console.log( + `Already uploaded bytes: ${alreadyUploadedBytes}, Data channel: ${dataChannel}`, + ); + + if (isOnDevice) { + handleHttpUpload(file, alreadyUploadedBytes, dataChannel); + } else { + handleWebRTCUpload(file, alreadyUploadedBytes, dataChannel); + } + }); + } + }; + + return ( +
+ +
+
{ + if (uploadState === "idle") { + document.getElementById("file-upload")?.click(); + } + }} + className="block select-none" + > +
+ +
+
+ {uploadState === "idle" && ( +
+
+ +
+ +
+
+
+

+ Click to select a file +

+

+ Supported formats: TAR, TAR.GZ +

+
+ )} + + {uploadState === "uploading" && ( +
+
+ +
+ +
+
+
+

+ Uploading {formatters.truncateMiddle(uploadedFileName, 30)} +

+

+ {formatters.bytes(uploadedFileSize || 0)} +

+
+
+
+
+
+ Uploading... + + {uploadSpeed !== null + ? `${formatters.bytes(uploadSpeed)}/s` + : "Calculating..."} + +
+
+
+ )} + + {uploadState === "success" && ( +
+
+ +
+ +
+
+
+

+ Upload successful +

+

+ {formatters.truncateMiddle(uploadedFileName, 40)} has been + uploaded +

+
+ )} +
+
+
+
+
+ + {fileError &&

{fileError}

} +
+ + {/* Display upload error if present */} + {uploadError && ( +
+ Error: {uploadError} +
+ )} + +
+
+ {uploadState === "uploading" ? ( +
+
+
+ ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index d8adba38..a18aa3c2 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -5,6 +5,7 @@ import { useSettingsStore, useUiStore, useUpdateStore, + usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -26,6 +27,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog"; import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; +import UploadPluginModal from "@components/UploadPluginDialog"; export function SettingsItem({ title, @@ -265,6 +267,8 @@ export default function SettingsSidebar() { } }; + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + useEffect(() => { getCloudState(); @@ -768,6 +772,44 @@ export default function SettingsSidebar() {
) : null} +
+ +
    +
  • +
    +
    +
    +
    +

    Tailscale

    +

    https://github.com/tutman96/jetkvm-plugin-tailscale

    +
    +
    +
    +
  • +
+
+
+
+
(set => ({ setModalView: view => set({ modalView: view }), setErrorMessage: message => set({ errorMessage: message }), })); + + +interface PluginState { + isPluginUploadModalOpen: boolean; + setIsPluginUploadModalOpen: (isOpen: boolean) => void; +} + +export const usePluginStore = create(set => ({ + isPluginUploadModalOpen: false, + setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), +})); \ No newline at end of file diff --git a/usb_mass_storage.go b/usb_mass_storage.go index b897c205..b72ab97c 100644 --- a/usb_mass_storage.go +++ b/usb_mass_storage.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "kvm/internal/storage" "kvm/resource" "log" "net/http" @@ -252,7 +253,7 @@ func rpcMountWithWebRTC(filename string, size int64, mode VirtualMediaMode) erro } func rpcMountWithStorage(filename string, mode VirtualMediaMode) error { - filename, err := sanitizeFilename(filename) + filename, err := storage.SanitizeFilename(filename) if err != nil { return err } @@ -341,20 +342,8 @@ func rpcListStorageFiles() (*StorageFiles, error) { return &StorageFiles{Files: storageFiles}, nil } -func sanitizeFilename(filename string) (string, error) { - cleanPath := filepath.Clean(filename) - if filepath.IsAbs(cleanPath) || strings.Contains(cleanPath, "..") { - return "", errors.New("invalid filename") - } - sanitized := filepath.Base(cleanPath) - if sanitized == "." || sanitized == string(filepath.Separator) { - return "", errors.New("invalid filename") - } - return sanitized, nil -} - func rpcDeleteStorageFile(filename string) error { - sanitizedFilename, err := sanitizeFilename(filename) + sanitizedFilename, err := storage.SanitizeFilename(filename) if err != nil { return err } @@ -373,15 +362,10 @@ func rpcDeleteStorageFile(filename string) error { return nil } -type StorageFileUpload struct { - AlreadyUploadedBytes int64 `json:"alreadyUploadedBytes"` - DataChannel string `json:"dataChannel"` -} - const uploadIdPrefix = "upload_" -func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, error) { - sanitizedFilename, err := sanitizeFilename(filename) +func rpcStartStorageFileUpload(filename string, size int64) (*storage.StorageFileUpload, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) if err != nil { return nil, err } @@ -403,28 +387,19 @@ func rpcStartStorageFileUpload(filename string, size int64) (*StorageFileUpload, if err != nil { return nil, fmt.Errorf("failed to open file for upload: %v", err) } - pendingUploadsMutex.Lock() - pendingUploads[uploadId] = pendingUpload{ + + storage.AddPendingUpload(uploadId, storage.PendingUpload{ File: file, Size: size, AlreadyUploadedBytes: alreadyUploadedBytes, - } - pendingUploadsMutex.Unlock() - return &StorageFileUpload{ + }) + + return &storage.StorageFileUpload{ AlreadyUploadedBytes: alreadyUploadedBytes, DataChannel: uploadId, }, nil } -type pendingUpload struct { - File *os.File - Size int64 - AlreadyUploadedBytes int64 -} - -var pendingUploads = make(map[string]pendingUpload) -var pendingUploadsMutex sync.Mutex - type UploadProgress struct { Size int64 AlreadyUploadedBytes int64 @@ -433,9 +408,7 @@ type UploadProgress struct { func handleUploadChannel(d *webrtc.DataChannel) { defer d.Close() uploadId := d.Label() - pendingUploadsMutex.Lock() - pendingUpload, ok := pendingUploads[uploadId] - pendingUploadsMutex.Unlock() + pendingUpload, ok := storage.GetPendingUpload(uploadId) if !ok { logger.Warnf("upload channel opened for unknown upload: %s", uploadId) return @@ -454,9 +427,7 @@ func handleUploadChannel(d *webrtc.DataChannel) { } else { logger.Warnf("uploaded ended before the complete file received") } - pendingUploadsMutex.Lock() - delete(pendingUploads, uploadId) - pendingUploadsMutex.Unlock() + storage.DeletePendingUpload(uploadId) }() uploadComplete := make(chan struct{}) lastProgressTime := time.Now() @@ -502,9 +473,7 @@ func handleUploadChannel(d *webrtc.DataChannel) { func handleUploadHttp(c *gin.Context) { uploadId := c.Query("uploadId") - pendingUploadsMutex.Lock() - pendingUpload, ok := pendingUploads[uploadId] - pendingUploadsMutex.Unlock() + pendingUpload, ok := storage.GetPendingUpload(uploadId) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "Upload not found"}) return @@ -524,9 +493,7 @@ func handleUploadHttp(c *gin.Context) { } else { logger.Warnf("uploaded ended before the complete file received") } - pendingUploadsMutex.Lock() - delete(pendingUploads, uploadId) - pendingUploadsMutex.Unlock() + storage.DeletePendingUpload(uploadId) }() reader := c.Request.Body From 82a614fa877f3b2561ee3fe2b2916fc7e525ca6f Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Wed, 1 Jan 2025 22:34:59 +0000 Subject: [PATCH 21/83] Add extracting and validating the plugin --- internal/plugin/database.go | 54 ++++++ internal/plugin/extract.go | 83 +++++++++ internal/plugin/plugin.go | 122 +++++++++++++- internal/plugin/type.go | 30 ++++ jsonrpc.go | 2 + ui/src/components/UploadPluginDialog.tsx | 203 ++++++++++++++++++++++- ui/src/components/sidebar/settings.tsx | 7 +- ui/src/hooks/stores.ts | 27 ++- 8 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 internal/plugin/database.go create mode 100644 internal/plugin/extract.go create mode 100644 internal/plugin/type.go diff --git a/internal/plugin/database.go b/internal/plugin/database.go new file mode 100644 index 00000000..d5d6f609 --- /dev/null +++ b/internal/plugin/database.go @@ -0,0 +1,54 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" +) + +const databaseFile = pluginsFolder + "/plugins.json" + +var pluginDatabase = PluginDatabase{} + +func init() { + if err := pluginDatabase.Load(); err != nil { + fmt.Printf("failed to load plugin database: %v\n", err) + } +} + +func (d *PluginDatabase) Load() error { + file, err := os.Open(databaseFile) + if os.IsNotExist(err) { + d.Plugins = make(map[string]PluginInstall) + return nil + } + if err != nil { + return fmt.Errorf("failed to open plugin database: %v", err) + } + defer file.Close() + + if err := json.NewDecoder(file).Decode(d); err != nil { + return fmt.Errorf("failed to decode plugin database: %v", err) + } + + return nil +} + +func (d *PluginDatabase) Save() error { + d.saveMutex.Lock() + defer d.saveMutex.Unlock() + + file, err := os.Create(databaseFile) + if err != nil { + return fmt.Errorf("failed to create plugin database: %v", err) + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(d); err != nil { + return fmt.Errorf("failed to encode plugin database: %v", err) + } + + return nil +} diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go new file mode 100644 index 00000000..45508e9b --- /dev/null +++ b/internal/plugin/extract.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "path" + "path/filepath" + "strings" + + "github.com/google/uuid" +) + +const pluginsExtractsFolder = pluginsFolder + "/extracts" + +func init() { + _ = os.MkdirAll(pluginsExtractsFolder, 0755) +} + +func extractPlugin(filePath string) (*string, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open file for extraction: %v", err) + } + defer file.Close() + + var reader io.Reader = file + // TODO: there's probably a better way of doing this without relying on the file extension + if strings.HasSuffix(filePath, ".gz") { + gzipReader, err := gzip.NewReader(file) + if err != nil { + return nil, fmt.Errorf("failed to create gzip reader: %v", err) + } + defer gzipReader.Close() + reader = gzipReader + } + + destinationFolder := path.Join(pluginsExtractsFolder, uuid.New().String()) + if err := os.MkdirAll(destinationFolder, 0755); err != nil { + return nil, fmt.Errorf("failed to create extracts folder: %v", err) + } + + tarReader := tar.NewReader(reader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read tar header: %v", err) + } + + // Prevent path traversal attacks + targetPath := filepath.Join(destinationFolder, header.Name) + if !strings.HasPrefix(targetPath, filepath.Clean(destinationFolder)+string(os.PathSeparator)) { + return nil, fmt.Errorf("tar file contains illegal path: %s", header.Name) + } + + switch header.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, os.FileMode(header.Mode)); err != nil { + return nil, fmt.Errorf("failed to create directory: %v", err) + } + case tar.TypeReg: + file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_WRONLY, os.FileMode(header.Mode)) + if err != nil { + return nil, fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + if _, err := io.Copy(file, tarReader); err != nil { + return nil, fmt.Errorf("failed to extract file: %v", err) + } + default: + return nil, fmt.Errorf("unsupported tar entry type: %v", header.Typeflag) + } + } + + return &destinationFolder, nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index a794bba6..3a3318d5 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -1,6 +1,7 @@ package plugin import ( + "encoding/json" "fmt" "kvm/internal/storage" "os" @@ -10,7 +11,7 @@ import ( ) const pluginsFolder = "/userdata/jetkvm/plugins" -const pluginsUploadFolder = pluginsFolder + "/_uploads" +const pluginsUploadFolder = pluginsFolder + "/uploads" func init() { _ = os.MkdirAll(pluginsUploadFolder, 0755) @@ -51,3 +52,122 @@ func RpcPluginStartUpload(filename string, size int64) (*storage.StorageFileUplo DataChannel: uploadId, }, nil } + +func RpcPluginExtract(filename string) (*PluginManifest, error) { + sanitizedFilename, err := storage.SanitizeFilename(filename) + if err != nil { + return nil, err + } + + filePath := path.Join(pluginsUploadFolder, sanitizedFilename) + extractFolder, err := extractPlugin(filePath) + if err != nil { + return nil, err + } + + if err := os.Remove(filePath); err != nil { + return nil, fmt.Errorf("failed to delete uploaded file: %v", err) + } + + manifest, err := readManifest(*extractFolder) + if err != nil { + return nil, err + } + + // Get existing PluginInstall + install, ok := pluginDatabase.Plugins[manifest.Name] + if !ok { + install = PluginInstall{ + Enabled: false, + Version: manifest.Version, + ExtractedVersions: make(map[string]string), + } + } + + _, ok = install.ExtractedVersions[manifest.Version] + if ok { + return nil, fmt.Errorf("this version has already been uploaded: %s", manifest.Version) + } + + install.ExtractedVersions[manifest.Version] = *extractFolder + pluginDatabase.Plugins[manifest.Name] = install + + if err := pluginDatabase.Save(); err != nil { + return nil, fmt.Errorf("failed to save plugin database: %v", err) + } + + return manifest, nil +} + +func RpcPluginInstall(name string, version string) error { + // TODO: find the plugin version in the plugins.json file + pluginInstall, ok := pluginDatabase.Plugins[name] + if !ok { + return fmt.Errorf("plugin not found: %s", name) + } + + if pluginInstall.Version == version && pluginInstall.Enabled { + fmt.Printf("Plugin %s is already installed with version %s\n", name, version) + return nil + } + + _, ok = pluginInstall.ExtractedVersions[version] + if !ok { + return fmt.Errorf("plugin version not found: %s", version) + } + + // TODO: If there is a running plugin with the same name, stop it and start the new version + + pluginInstall.Version = version + pluginInstall.Enabled = true + pluginDatabase.Plugins[name] = pluginInstall + + if err := pluginDatabase.Save(); err != nil { + return fmt.Errorf("failed to save plugin database: %v", err) + } + // TODO: start the plugin + + // TODO: Determine if the old version should be removed + + return nil +} + +func readManifest(extractFolder string) (*PluginManifest, error) { + manifestPath := path.Join(extractFolder, "manifest.json") + manifestFile, err := os.Open(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to open manifest file: %v", err) + } + defer manifestFile.Close() + + manifest := PluginManifest{} + if err := json.NewDecoder(manifestFile).Decode(&manifest); err != nil { + return nil, fmt.Errorf("failed to read manifest file: %v", err) + } + + if err := validateManifest(&manifest); err != nil { + return nil, fmt.Errorf("invalid manifest file: %v", err) + } + + return &manifest, nil +} + +func validateManifest(manifest *PluginManifest) error { + if manifest.ManifestVersion != "1" { + return fmt.Errorf("unsupported manifest version: %s", manifest.ManifestVersion) + } + + if manifest.Name == "" { + return fmt.Errorf("missing plugin name") + } + + if manifest.Version == "" { + return fmt.Errorf("missing plugin version") + } + + if manifest.Homepage == "" { + return fmt.Errorf("missing plugin homepage") + } + + return nil +} diff --git a/internal/plugin/type.go b/internal/plugin/type.go new file mode 100644 index 00000000..6f07c598 --- /dev/null +++ b/internal/plugin/type.go @@ -0,0 +1,30 @@ +package plugin + +import "sync" + +type PluginManifest struct { + ManifestVersion string `json:"manifest_version"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage"` + BinaryPath string `json:"bin"` + SystemMinVersion string `json:"system_min_version,omitempty"` +} + +type PluginInstall struct { + Enabled bool `json:"enabled"` + + // Current active version of the plugin + Version string `json:"version"` + + // Map of a plugin version to the extracted directory + ExtractedVersions map[string]string `json:"extracted_versions"` +} + +type PluginDatabase struct { + // Map with the plugin name as the key + Plugins map[string]PluginInstall `json:"plugins"` + + saveMutex sync.Mutex +} diff --git a/jsonrpc.go b/jsonrpc.go index 18a3ee61..034d32b9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -599,6 +599,8 @@ var rpcHandlers = map[string]RPCHandler{ "setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}}, "resetConfig": {Func: rpcResetConfig}, "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, + "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, + "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"settings"}}, "getBacklightSettings": {Func: rpcGetBacklightSettings}, } diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 5fb950fc..5cbd4ad0 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -5,6 +5,8 @@ import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import Modal from "@components/Modal"; import { + PluginManifest, + usePluginStore, useRTCStore, } from "../hooks/stores"; import { cx } from "../cva.config"; @@ -16,6 +18,7 @@ import { formatters } from "@/utils"; import { PlusCircleIcon } from "@heroicons/react/20/solid"; import AutoHeight from "./AutoHeight"; import { useJsonRpc } from "../hooks/useJsonRpc"; +import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import notifications from "../notifications"; import { isOnDevice } from "../main"; import { ViewHeader } from "./MountMediaDialog"; @@ -35,6 +38,28 @@ export default function UploadPluginModal({ } function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const { + pluginUploadModalView, + setPluginUploadModalView, + pluginUploadFilename, + setPluginUploadFilename, + pluginUploadManifest, + setPluginUploadManifest, + } = usePluginStore(); + const [send] = useJsonRpc(); + const [extractError, setExtractError] = useState(null); + + function extractPlugin(filename: string) { + send("pluginExtract", { filename }, resp => { + if ("error" in resp) { + setExtractError(resp.error.data || resp.error.message); + return + } + + setPluginUploadManifest(resp.result as PluginManifest); + }); + } + return (
void }) { className="h-[24px] dark:block hidden dark:!mt-0" /> - { setOpen(false) }} - onCancelUpload={() => { + onUploadCompleted={(filename) => { + setPluginUploadFilename(filename) + setPluginUploadModalView("install") + extractPlugin(filename) + }} + />} + + {extractError && ( + { + setOpen(false) + setPluginUploadFilename(null) + setExtractError(null) + }} + onRetry={() => { + setExtractError(null) + setPluginUploadFilename(null) + setPluginUploadModalView("upload") + }} + /> + )} + + {!extractError && pluginUploadModalView === "install" && { setOpen(false) + setPluginUploadFilename(null) + // TODO: Open plugin settings dialog }} - /> + onBack={() => { + setPluginUploadModalView("upload") + setPluginUploadFilename(null) + }} + />}
@@ -74,10 +131,10 @@ function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { // TODO: refactor to a shared component function UploadFileView({ onBack, - onCancelUpload, + onUploadCompleted, }: { onBack: () => void; - onCancelUpload: () => void; + onUploadCompleted: (filename: string) => void; }) { const [uploadState, setUploadState] = useState<"idle" | "uploading" | "success">( "idle", @@ -177,6 +234,7 @@ function UploadFileView({ if (offset >= file.size) { rtcDataChannel.close(); setUploadState("success"); + onUploadCompleted(file.name); return; } @@ -260,6 +318,7 @@ function UploadFileView({ xhr.onload = () => { if (xhr.status === 200) { setUploadState("success"); + onUploadCompleted(file.name); } else { console.error("Upload error:", xhr.statusText); setUploadError(xhr.statusText); @@ -459,7 +518,7 @@ function UploadFileView({ theme="light" text="Cancel Upload" onClick={() => { - onCancelUpload(); + onBack(); setUploadState("idle"); setUploadProgress(0); setUploadedFileName(null); @@ -470,7 +529,7 @@ function UploadFileView({ ) : (
); +} + +function InstallPluginView({ + filename, + manifest, + onInstall, + onBack, +}: { + filename: string; + manifest: PluginManifest | null; + onInstall: () => void; + onBack: () => void; +}) { + const [send] = useJsonRpc(); + const [error, setError] = useState(null); + const [installing, setInstalling] = useState(false); + + function handleInstall() { + if (installing) return; + setInstalling(true); + send("pluginInstall", { name: manifest!.name, version: manifest!.version }, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + + setInstalling(false); + onInstall(); + }); + } + + return ( +
+ + {manifest && ( +
+
+

{manifest.name}

+

{manifest.description}

+

+ Version: {manifest.version} +

+

+ + {manifest.homepage} + +

+
+
+ )} + {error && ( +
+ Error: {error} +
+ )} +
+
+
+
+
+ ); +} + +function ErrorView({ + errorMessage, + onClose, + onRetry, +}: { + errorMessage: string | null; + onClose: () => void; + onRetry: () => void; +}) { + return ( +
+
+
+ +

Plugin Extract Error

+
+

+ An error occurred while attempting to extract the plugin. Please ensure the plugin is valid and try again. +

+
+ {errorMessage && ( + +

{errorMessage}

+
+ )} +
+
+
+ ); } \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index a18aa3c2..84e5875b 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -267,7 +267,7 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen} = usePluginStore(); + const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); useEffect(() => { getCloudState(); @@ -801,7 +801,10 @@ export default function SettingsSidebar() { size="SM" theme="primary" text="Upload Plugin" - onClick={() => setIsPluginUploadModalOpen(true)} + onClick={() => { + setPluginUploadModalView("upload"); + setIsPluginUploadModalOpen(true) + }} /> (set => ({ })); +export interface PluginManifest { + name: string; + version: string; + description?: string; + homepage: string; +} + interface PluginState { isPluginUploadModalOpen: boolean; setIsPluginUploadModalOpen: (isOpen: boolean) => void; + + pluginUploadFilename: string | null; + setPluginUploadFilename: (filename: string | null) => void; + + pluginUploadManifest: PluginManifest | null; + setPluginUploadManifest: (manifest: PluginManifest | null) => void; + + pluginUploadModalView: "upload" | "install"; + setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void; } export const usePluginStore = create(set => ({ isPluginUploadModalOpen: false, setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), -})); \ No newline at end of file + + pluginUploadFilename: null, + setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }), + + pluginUploadManifest: null, + setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }), + + pluginUploadModalView: "upload", + setPluginUploadModalView: view => set({ pluginUploadModalView: view }), +})); From 91c1287472eaf3ce12035170eab8f5b1f4293d1c Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 15:53:07 +0000 Subject: [PATCH 22/83] Write plugin database to tmp file first --- internal/plugin/database.go | 8 ++++++-- ui/src/components/UploadPluginDialog.tsx | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/internal/plugin/database.go b/internal/plugin/database.go index d5d6f609..2f9a89fe 100644 --- a/internal/plugin/database.go +++ b/internal/plugin/database.go @@ -38,9 +38,9 @@ func (d *PluginDatabase) Save() error { d.saveMutex.Lock() defer d.saveMutex.Unlock() - file, err := os.Create(databaseFile) + file, err := os.Create(databaseFile + ".tmp") if err != nil { - return fmt.Errorf("failed to create plugin database: %v", err) + return fmt.Errorf("failed to create plugin database tmp: %v", err) } defer file.Close() @@ -50,5 +50,9 @@ func (d *PluginDatabase) Save() error { return fmt.Errorf("failed to encode plugin database: %v", err) } + if err := os.Rename(databaseFile+".tmp", databaseFile); err != nil { + return fmt.Errorf("failed to move plugin database to active file: %v", err) + } + return nil } diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 5cbd4ad0..b0c4f02f 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -576,7 +576,7 @@ function InstallPluginView({ description={ !manifest ? `Extracting plugin from ${filename}...` : - `Do you want to install the plugin?` + `Do you want to install this plugin?` } /> {manifest && ( From f6c78d7464bf0e69044a625a91bdfadb0792668f Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 16:43:09 +0000 Subject: [PATCH 23/83] Implement pluginList RPC and associated UI --- internal/plugin/database.go | 8 ++ internal/plugin/install.go | 31 +++++++ internal/plugin/plugin.go | 22 +++++ internal/plugin/type.go | 21 +---- jsonrpc.go | 1 + ui/src/components/PluginList.tsx | 107 +++++++++++++++++++++++++ ui/src/components/sidebar/settings.tsx | 40 +-------- ui/src/hooks/stores.ts | 11 +++ 8 files changed, 186 insertions(+), 55 deletions(-) create mode 100644 internal/plugin/install.go create mode 100644 ui/src/components/PluginList.tsx diff --git a/internal/plugin/database.go b/internal/plugin/database.go index 2f9a89fe..f97e7481 100644 --- a/internal/plugin/database.go +++ b/internal/plugin/database.go @@ -4,10 +4,18 @@ import ( "encoding/json" "fmt" "os" + "sync" ) const databaseFile = pluginsFolder + "/plugins.json" +type PluginDatabase struct { + // Map with the plugin name as the key + Plugins map[string]PluginInstall `json:"plugins"` + + saveMutex sync.Mutex +} + var pluginDatabase = PluginDatabase{} func init() { diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 00000000..c860afff --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,31 @@ +package plugin + +type PluginInstall struct { + Enabled bool `json:"enabled"` + + // Current active version of the plugin + Version string `json:"version"` + + // Map of a plugin version to the extracted directory + ExtractedVersions map[string]string `json:"extracted_versions"` + + manifest *PluginManifest +} + +func (p *PluginInstall) GetManifest() (*PluginManifest, error) { + if p.manifest != nil { + return p.manifest, nil + } + + manifest, err := readManifest(p.GetExtractedFolder()) + if err != nil { + return nil, err + } + + p.manifest = manifest + return manifest, nil +} + +func (p *PluginInstall) GetExtractedFolder() string { + return p.ExtractedVersions[p.Version] +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 3a3318d5..b841f01f 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -132,6 +132,28 @@ func RpcPluginInstall(name string, version string) error { return nil } +func RpcPluginList() ([]PluginStatus, error) { + plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) + for pluginName, plugin := range pluginDatabase.Plugins { + manifest, err := plugin.GetManifest() + if err != nil { + return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err) + } + + status := "stopped" + if plugin.Enabled { + status = "running" + } + + plugins = append(plugins, PluginStatus{ + PluginManifest: *manifest, + Enabled: plugin.Enabled, + Status: status, + }) + } + return plugins, nil +} + func readManifest(extractFolder string) (*PluginManifest, error) { manifestPath := path.Join(extractFolder, "manifest.json") manifestFile, err := os.Open(manifestPath) diff --git a/internal/plugin/type.go b/internal/plugin/type.go index 6f07c598..01d85a50 100644 --- a/internal/plugin/type.go +++ b/internal/plugin/type.go @@ -1,7 +1,5 @@ package plugin -import "sync" - type PluginManifest struct { ManifestVersion string `json:"manifest_version"` Name string `json:"name"` @@ -12,19 +10,8 @@ type PluginManifest struct { SystemMinVersion string `json:"system_min_version,omitempty"` } -type PluginInstall struct { - Enabled bool `json:"enabled"` - - // Current active version of the plugin - Version string `json:"version"` - - // Map of a plugin version to the extracted directory - ExtractedVersions map[string]string `json:"extracted_versions"` -} - -type PluginDatabase struct { - // Map with the plugin name as the key - Plugins map[string]PluginInstall `json:"plugins"` - - saveMutex sync.Mutex +type PluginStatus struct { + PluginManifest + Enabled bool `json:"enabled"` + Status string `json:"status"` } diff --git a/jsonrpc.go b/jsonrpc.go index 034d32b9..0152298f 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -601,6 +601,7 @@ var rpcHandlers = map[string]RPCHandler{ "pluginStartUpload": {Func: plugin.RpcPluginStartUpload, Params: []string{"filename", "size"}}, "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, + "pluginList": {Func: plugin.RpcPluginList}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"settings"}}, "getBacklightSettings": {Func: rpcGetBacklightSettings}, } diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx new file mode 100644 index 00000000..947e6187 --- /dev/null +++ b/ui/src/components/PluginList.tsx @@ -0,0 +1,107 @@ +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { Button } from "@components/Button"; +import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; +import { useCallback, useEffect, useState } from "react"; +import { cx } from "@/cva.config"; +import UploadPluginModal from "@components/UploadPluginDialog"; + +function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { + let classNames = "bg-slate-500 border-slate-600"; + if (plugin.enabled && plugin.status === "running") { + classNames = "bg-green-500 border-green-600"; + } else if (plugin.enabled && plugin.status === "stopped") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
+
+
+ ) +} + +export default function PluginList() { + const [send] = useJsonRpc(); + const [error, setError] = useState(null); + + const { + isPluginUploadModalOpen, + setIsPluginUploadModalOpen, + setPluginUploadModalView, + plugins, + setPlugins + } = usePluginStore(); + const sidebarView = useUiStore(state => state.sidebarView); + + const updatePlugins = useCallback(() => { + send("pluginList", {}, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + setPlugins(resp.result as PluginStatus[]); + }); + }, [send, setPlugins]) + + useEffect(() => { + // Only update plugins when the sidebar view is the settings view + if (sidebarView !== "system") return; + updatePlugins(); + + const updateInterval = setInterval(() => { + updatePlugins(); + }, 10_000); + return () => clearInterval(updateInterval); + }, [updatePlugins, sidebarView]) + + return ( + <> +
+
    + {error &&
  • {error}
  • } + {plugins.length === 0 &&
  • No plugins installed
  • } + {plugins.map(plugin => ( +
  • + +
    +

    {plugin.name}

    +

    + {plugin.homepage} +

    +
    +
    +
    +
  • + ))} +
+
+ +
+
+ + ); +} \ No newline at end of file diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 84e5875b..90de4e25 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -5,7 +5,6 @@ import { useSettingsStore, useUiStore, useUpdateStore, - usePluginStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -27,7 +26,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog"; import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; -import UploadPluginModal from "@components/UploadPluginDialog"; +import PluginList from "@components/PluginList"; export function SettingsItem({ title, @@ -267,8 +266,6 @@ export default function SettingsSidebar() { } }; - const {isPluginUploadModalOpen, setIsPluginUploadModalOpen, setPluginUploadModalView} = usePluginStore(); - useEffect(() => { getCloudState(); @@ -777,40 +774,7 @@ export default function SettingsSidebar() { title="Plugins" description="Manage installed plugins and their settings" /> -
    -
  • -
    -
    -
    -
    -

    Tailscale

    -

    https://github.com/tutman96/jetkvm-plugin-tailscale

    -
    -
    -
    -
  • -
-
-
+
diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 5c496c28..9b0a9691 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -559,6 +559,11 @@ export interface PluginManifest { homepage: string; } +export interface PluginStatus extends PluginManifest { + enabled: boolean; + status: "stopped" | "running"; +} + interface PluginState { isPluginUploadModalOpen: boolean; setIsPluginUploadModalOpen: (isOpen: boolean) => void; @@ -571,6 +576,9 @@ interface PluginState { pluginUploadModalView: "upload" | "install"; setPluginUploadModalView: (view: PluginState["pluginUploadModalView"]) => void; + + plugins: PluginStatus[]; + setPlugins: (plugins: PluginStatus[]) => void; } export const usePluginStore = create(set => ({ @@ -585,4 +593,7 @@ export const usePluginStore = create(set => ({ pluginUploadModalView: "upload", setPluginUploadModalView: view => set({ pluginUploadModalView: view }), + + plugins: [], + setPlugins: plugins => set({ plugins }), })); From cdb9bb7cc4cb4832ccf4de01f3047bfca0523ddd Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sat, 4 Jan 2025 17:18:25 +0000 Subject: [PATCH 24/83] Add enable/disable button --- internal/plugin/install.go | 20 +++ internal/plugin/plugin.go | 34 ++++-- jsonrpc.go | 1 + ui/src/components/PluginConfigureDialog.tsx | 127 ++++++++++++++++++++ ui/src/components/PluginList.tsx | 23 +++- ui/src/hooks/stores.ts | 12 ++ 6 files changed, 203 insertions(+), 14 deletions(-) create mode 100644 ui/src/components/PluginConfigureDialog.tsx diff --git a/internal/plugin/install.go b/internal/plugin/install.go index c860afff..01d8f25d 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -1,5 +1,7 @@ package plugin +import "fmt" + type PluginInstall struct { Enabled bool `json:"enabled"` @@ -29,3 +31,21 @@ func (p *PluginInstall) GetManifest() (*PluginManifest, error) { func (p *PluginInstall) GetExtractedFolder() string { return p.ExtractedVersions[p.Version] } + +func (p *PluginInstall) GetStatus() (*PluginStatus, error) { + manifest, err := p.GetManifest() + if err != nil { + return nil, fmt.Errorf("failed to get plugin manifest: %v", err) + } + + status := "stopped" + if p.Enabled { + status = "running" + } + + return &PluginStatus{ + PluginManifest: *manifest, + Enabled: p.Enabled, + Status: status, + }, nil +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index b841f01f..a161e682 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -135,23 +135,33 @@ func RpcPluginInstall(name string, version string) error { func RpcPluginList() ([]PluginStatus, error) { plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) for pluginName, plugin := range pluginDatabase.Plugins { - manifest, err := plugin.GetManifest() + status, err := plugin.GetStatus() if err != nil { - return nil, fmt.Errorf("failed to get plugin manifest for %s: %v", pluginName, err) + return nil, fmt.Errorf("failed to get plugin status for %s: %v", pluginName, err) } + plugins = append(plugins, *status) + } + return plugins, nil +} - status := "stopped" - if plugin.Enabled { - status = "running" - } +func RpcUpdateConfig(name string, enabled bool) (*PluginStatus, error) { + pluginInstall, ok := pluginDatabase.Plugins[name] + if !ok { + return nil, fmt.Errorf("plugin not found: %s", name) + } - plugins = append(plugins, PluginStatus{ - PluginManifest: *manifest, - Enabled: plugin.Enabled, - Status: status, - }) + pluginInstall.Enabled = enabled + pluginDatabase.Plugins[name] = pluginInstall + + if err := pluginDatabase.Save(); err != nil { + return nil, fmt.Errorf("failed to save plugin database: %v", err) } - return plugins, nil + + status, err := pluginInstall.GetStatus() + if err != nil { + return nil, fmt.Errorf("failed to get plugin status for %s: %v", name, err) + } + return status, nil } func readManifest(extractFolder string) (*PluginManifest, error) { diff --git a/jsonrpc.go b/jsonrpc.go index 0152298f..1841bc5a 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -602,6 +602,7 @@ var rpcHandlers = map[string]RPCHandler{ "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, "pluginList": {Func: plugin.RpcPluginList}, + "pluginUpdateConfig": {Func: plugin.RpcUpdateConfig, Params: []string{"name", "enabled"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"settings"}}, "getBacklightSettings": {Func: rpcGetBacklightSettings}, } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx new file mode 100644 index 00000000..c53eb893 --- /dev/null +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -0,0 +1,127 @@ +import { PluginStatus } from "@/hooks/stores"; +import Modal from "@components/Modal"; +import AutoHeight from "@components/AutoHeight"; +import { GridCard } from "@components/Card"; +import LogoBlueIcon from "@/assets/logo-blue.svg"; +import LogoWhiteIcon from "@/assets/logo-white.svg"; +import { ViewHeader } from "./MountMediaDialog"; +import { Button } from "./Button"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { useCallback, useEffect, useState } from "react"; + +export default function PluginConfigureModal({ + plugin, + open, + setOpen, +}: { + plugin: PluginStatus | null; + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ) +} + +function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (open: boolean) => void }) { + const [send] = useJsonRpc(); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(false); + }, [plugin]) + + const updatePlugin = useCallback((enabled: boolean) => { + if (!plugin) return; + if (!enabled) { + if (!window.confirm("Are you sure you want to disable this plugin?")) { + return; + } + } + + setLoading(true); + send("pluginUpdateConfig", { name: plugin.name, enabled }, resp => { + if ("error" in resp) { + setError(resp.error.message); + return + } + setOpen(false); + }); + }, [send, plugin, setOpen]) + + return ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo +
+
+ +
+ {/* Enable/Disable toggle */} +
+
+ +
+ {error &&

{error}

} +

+ TODO: Plugin configuration goes here +

+ +
+
+
+
+
+
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index 947e6187..d2397f17 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -4,6 +4,7 @@ import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; +import PluginConfigureModal from "./PluginConfigureDialog"; function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { let classNames = "bg-slate-500 border-slate-600"; @@ -29,7 +30,11 @@ export default function PluginList() { setIsPluginUploadModalOpen, setPluginUploadModalView, plugins, - setPlugins + setPlugins, + pluginConfigureModalOpen, + setPluginConfigureModalOpen, + configuringPlugin, + setConfiguringPlugin, } = usePluginStore(); const sidebarView = useUiStore(state => state.sidebarView); @@ -74,7 +79,10 @@ export default function PluginList() { size="SM" theme="light" text="Settings" - onClick={() => console.log("Settings clicked")} + onClick={() => { + setConfiguringPlugin(plugin); + setPluginConfigureModalOpen(true); + }} />
@@ -82,6 +90,17 @@ export default function PluginList() {
+ { + setPluginConfigureModalOpen(open); + if (!open) { + updatePlugins(); + } + }} + plugin={configuringPlugin} + /> +
From 6144390d49612bc3d51a8f8a3b976a85eadeb9e4 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 19:46:33 +0000 Subject: [PATCH 32/83] NEXT-ONLY: chore: update Makefile --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 4bac6047..2fca030c 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date --iso-8601=seconds) REVISION ?= $(shell git rev-parse HEAD) VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M) -VERSION_NEXT := $(git describe --tags --abbrev=0)-$(git rev-parse --short HEAD) +VERSION_NEXT := 0.3.5-$(git rev-parse --short HEAD)-$(git describe --tags --abbrev=0) VERSION := 0.3.4 GO_LDFLAGS := \ @@ -31,7 +31,7 @@ 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 -build_next: hash_resource +build_next: frontend hash_resource @echo "Building jetkvm-next..." GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION_NEXT)" -o bin/next/jetkvm_app cmd/main.go From a841b70edff858da830d0f7a04b3c2a6cb75faee Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 19:46:49 +0000 Subject: [PATCH 33/83] NEXT-ONLY: chore: add hacky script to deploy next binary --- next_deploy.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100755 next_deploy.sh diff --git a/next_deploy.sh b/next_deploy.sh new file mode 100755 index 00000000..136fbcb0 --- /dev/null +++ b/next_deploy.sh @@ -0,0 +1,81 @@ +# Exit immediately if a command exits with a non-zero status +set -e + +# Function to display help message +show_help() { + echo "Usage: $0 [options] -r " + echo + echo "Required:" + echo " -r, --remote Remote host IP address" + echo + echo "Optional:" + echo " -u, --user Remote username (default: root)" + echo " --help Display this help message" + echo + echo "Example:" + echo " $0 -r 192.168.0.17" + echo " $0 -r 192.168.0.17 -u admin" + exit 0 +} + +# Default values +REMOTE_USER="root" +REMOTE_PATH="/userdata/jetkvm/bin" + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -r|--remote) + REMOTE_HOST="$2" + shift 2 + ;; + -u|--user) + REMOTE_USER="$2" + shift 2 + ;; + --help) + show_help + exit 0 + ;; + *) + echo "Unknown option: $1" + show_help + exit 1 + ;; + esac +done + +# Verify required parameters +if [ -z "$REMOTE_HOST" ]; then + echo "Error: Remote IP is a required parameter" + show_help +fi + +# Check if the binary has been built at ./bin/next/jetkvm_app +if [ ! -f bin/jetkvm_app ]; then + echo "Error: Binary not found at ./bin/jetkvm_app, run make build_next." + exit 1 +fi + +# Change directory to the binary output directory +cd bin/next + +# Copy the binary to the remote host +cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_next" + +# Deploy and run the application on the remote host +ssh "${REMOTE_USER}@${REMOTE_HOST}" ash < Date: Sun, 5 Jan 2025 19:44:34 +0000 Subject: [PATCH 34/83] Golang standards :) --- internal/plugin/process_manager.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/internal/plugin/process_manager.go b/internal/plugin/process_manager.go index 31bd53ae..9d647d88 100644 --- a/internal/plugin/process_manager.go +++ b/internal/plugin/process_manager.go @@ -9,8 +9,10 @@ import ( ) // TODO: this can probably be defaulted to this, but overwritten on a per-plugin basis -const GRACEFUL_SHUTDOWN_DELAY = 30 * time.Second -const MAX_RESTART_BACKOFF = 30 * time.Second +const ( + gracefulShutdownDelay = 30 * time.Second + maxRestartBackoff = 30 * time.Second +) type ProcessManager struct { cmdGen func() *exec.Cmd @@ -75,8 +77,8 @@ func (pm *ProcessManager) scheduleRestart() { log.Printf("Restarting process in %v...", pm.backoff) time.Sleep(pm.backoff) pm.backoff *= 2 // Exponential backoff - if pm.backoff > MAX_RESTART_BACKOFF { - pm.backoff = MAX_RESTART_BACKOFF + if pm.backoff > maxRestartBackoff { + pm.backoff = maxRestartBackoff } pm.restartCh <- struct{}{} } @@ -87,7 +89,7 @@ func (pm *ProcessManager) terminate() { log.Printf("Sending SIGTERM...") pm.cmd.Process.Signal(syscall.SIGTERM) select { - case <-time.After(GRACEFUL_SHUTDOWN_DELAY): + case <-time.After(gracefulShutdownDelay): log.Printf("Forcing process termination...") pm.cmd.Process.Kill() case <-pm.waitForExit(): From 8d2cade2ff9f251cc8e8248c2a19e600f14e6f6f Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 19:46:21 +0000 Subject: [PATCH 35/83] Newlines for all things --- ui/src/components/PluginConfigureDialog.tsx | 2 +- ui/src/components/PluginList.tsx | 2 +- ui/src/components/UploadPluginDialog.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 04d139f9..c5808f1d 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -151,4 +151,4 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op

) -} \ No newline at end of file +} diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index d5d54832..f2f0bdd9 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -124,4 +124,4 @@ export default function PluginList() {
); -} \ No newline at end of file +} diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx index 6ba8d995..b1c967ef 100644 --- a/ui/src/components/UploadPluginDialog.tsx +++ b/ui/src/components/UploadPluginDialog.tsx @@ -669,4 +669,4 @@ function ErrorView({
); -} \ No newline at end of file +} From 6c2f926d7001a4631065a9506784f5602d790674 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 19:52:24 +0000 Subject: [PATCH 36/83] NEXT-ONLY: chore: update makefile (stop being an idiot) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 2fca030c..7c29699a 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) BUILDDATE ?= $(shell date --iso-8601=seconds) REVISION ?= $(shell git rev-parse HEAD) VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M) -VERSION_NEXT := 0.3.5-$(git rev-parse --short HEAD)-$(git describe --tags --abbrev=0) +VERSION_NEXT := 0.3.5-$(shell git rev-parse --short HEAD)-$(shell git describe --tags --abbrev=0) VERSION := 0.3.4 GO_LDFLAGS := \ From a57c0d0a9168f505449c8b3e9edb6397f3c04c62 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Sun, 5 Jan 2025 20:33:31 +0000 Subject: [PATCH 37/83] NEXT-ONLY: add jetkvm/kvm#27 to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index def66532..8924f146 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - Nevexo - [Display backlight brightness control](https://github.com/jetkvm/kvm/pull/17) - Nevexo - [CTRL+ALT+DEL Button on Action Bar](https://github.com/jetkvm/kvm/pull/18) - tutman - [Clean-up jetkvm_native when app exits](https://github.com/jetkvm/kvm/pull/19) +- Nevexo - [Only start WebSocket client when necessary](https://github.com/jetkvm/kvm/pull/27) ## Additional Info There's a GitHub Action setup to build the JetKVM software whenever this repo has a new release added, it'll take From 8705b33dbf0ca393f674b589b164ded6499c0abf Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Mon, 6 Jan 2025 16:11:59 +0000 Subject: [PATCH 38/83] feat: restore EDID on reboot This commit adds the config entry "EdidString" and saves the EDID string when it's modified via the RPC. The EDID is restored when the jetkvm_native control socket connects (usually at boot) Signed-off-by: Cameron Fleming --- config.go | 11 +++++++++++ jsonrpc.go | 6 ++++++ native.go | 16 ++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/config.go b/config.go index 1972a701..8a4f7439 100644 --- a/config.go +++ b/config.go @@ -25,6 +25,17 @@ type Config struct { DisplayMaxBrightness int `json:"display_max_brightness"` DisplayDimAfterMs int64 `json:"display_dim_after_ms"` DisplayOffAfterMs int64 `json:"display_off_after_ms"` + CloudURL string `json:"cloud_url"` + CloudToken string `json:"cloud_token"` + GoogleIdentity string `json:"google_identity"` + JigglerEnabled bool `json:"jiggler_enabled"` + AutoUpdateEnabled bool `json:"auto_update_enabled"` + IncludePreRelease bool `json:"include_pre_release"` + HashedPassword string `json:"hashed_password"` + LocalAuthToken string `json:"local_auth_token"` + LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration + WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` + EdidString string `json:"hdmi_edid_string"` } const configPath = "/userdata/kvm_config.json" diff --git a/jsonrpc.go b/jsonrpc.go index 2f4bdfb9..f6a9e394 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -190,6 +190,12 @@ func rpcSetEDID(edid string) error { if err != nil { return err } + + // Save EDID to config, allowing it to be restored on reboot. + LoadConfig() + config.EdidString = edid + SaveConfig() + return nil } diff --git a/native.go b/native.go index d34ab07b..1bd8429d 100644 --- a/native.go +++ b/native.go @@ -152,6 +152,9 @@ func handleCtrlClient(conn net.Conn) { ctrlSocketConn = conn + // Restore HDMI EDID if applicable + go restoreHdmiEdid() + readBuf := make([]byte, 4096) for { n, err := conn.Read(readBuf) @@ -304,3 +307,16 @@ func ensureBinaryUpdated(destPath string) error { return nil } + +// Restore the HDMI EDID value from the config. +// Called after successful connection to jetkvm_native. +func restoreHdmiEdid() { + LoadConfig() + if config.EdidString != "" { + logger.Infof("Restoring HDMI EDID to %v", config.EdidString) + _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString}) + if err != nil { + logger.Errorf("Failed to restore HDMI EDID: %v", err) + } + } +} From 87c5a6422bba273915aca6ae548e0f08845bda0a Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Mon, 6 Jan 2025 16:16:23 +0000 Subject: [PATCH 39/83] NEXT-ONLY: introduce https://github.com/jetkvm/kvm/pull/34 Signed-off-by: Cameron Fleming --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8924f146..f6368252 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - Nevexo - [CTRL+ALT+DEL Button on Action Bar](https://github.com/jetkvm/kvm/pull/18) - tutman - [Clean-up jetkvm_native when app exits](https://github.com/jetkvm/kvm/pull/19) - Nevexo - [Only start WebSocket client when necessary](https://github.com/jetkvm/kvm/pull/27) +- Nevexo - [Restore EDID on Reboot](https://github.com/jetkvm/kvm/pull/34) ## Additional Info There's a GitHub Action setup to build the JetKVM software whenever this repo has a new release added, it'll take From 5f69a6da3098be4906479271e148f6ce17c30a41 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Mon, 6 Jan 2025 16:20:06 +0000 Subject: [PATCH 40/83] NEXT-ONLY: Fix complete failure at merging config.go Signed-off-by: Cameron Fleming --- config.go | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/config.go b/config.go index 8a4f7439..c036dd10 100644 --- a/config.go +++ b/config.go @@ -25,16 +25,6 @@ type Config struct { DisplayMaxBrightness int `json:"display_max_brightness"` DisplayDimAfterMs int64 `json:"display_dim_after_ms"` DisplayOffAfterMs int64 `json:"display_off_after_ms"` - CloudURL string `json:"cloud_url"` - CloudToken string `json:"cloud_token"` - GoogleIdentity string `json:"google_identity"` - JigglerEnabled bool `json:"jiggler_enabled"` - AutoUpdateEnabled bool `json:"auto_update_enabled"` - IncludePreRelease bool `json:"include_pre_release"` - HashedPassword string `json:"hashed_password"` - LocalAuthToken string `json:"local_auth_token"` - LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration - WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` EdidString string `json:"hdmi_edid_string"` } From 5a69b074d168f73ffed3d3a24375ce1df992a210 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 20:32:20 +0000 Subject: [PATCH 41/83] Refactor jsonrpc server into its own package --- internal/jsonrpc/rpc_server.go | 179 +++++++++++++++++++++++++++ internal/jsonrpc/types.go | 26 ++++ jsonrpc.go | 204 ++++--------------------------- ui/src/components/PluginList.tsx | 2 +- webrtc.go | 3 +- 5 files changed, 229 insertions(+), 185 deletions(-) create mode 100644 internal/jsonrpc/rpc_server.go create mode 100644 internal/jsonrpc/types.go diff --git a/internal/jsonrpc/rpc_server.go b/internal/jsonrpc/rpc_server.go new file mode 100644 index 00000000..bdaef1b7 --- /dev/null +++ b/internal/jsonrpc/rpc_server.go @@ -0,0 +1,179 @@ +package jsonrpc + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "reflect" +) + +type JSONRPCServer struct { + writer io.Writer + + handlers map[string]*RPCHandler +} + +func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { + return &JSONRPCServer{ + writer: writer, + handlers: handlers, + } +} + +func (s *JSONRPCServer) HandleMessage(data []byte) error { + var request JSONRPCRequest + err := json.Unmarshal(data, &request) + if err != nil { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]interface{}{ + "code": -32700, + "message": "Parse error", + }, + ID: 0, + } + return s.writeResponse(errorResponse) + } + + //log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID) + handler, ok := s.handlers[request.Method] + if !ok { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]interface{}{ + "code": -32601, + "message": "Method not found", + }, + ID: request.ID, + } + return s.writeResponse(errorResponse) + } + + result, err := callRPCHandler(handler, request.Params) + if err != nil { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: map[string]interface{}{ + "code": -32603, + "message": "Internal error", + "data": err.Error(), + }, + ID: request.ID, + } + return s.writeResponse(errorResponse) + } + + response := JSONRPCResponse{ + JSONRPC: "2.0", + Result: result, + ID: request.ID, + } + return s.writeResponse(response) +} + +func (s *JSONRPCServer) writeResponse(response JSONRPCResponse) error { + responseBytes, err := json.Marshal(response) + if err != nil { + return err + } + _, err = s.writer.Write(responseBytes) + return err +} + +func callRPCHandler(handler *RPCHandler, params map[string]interface{}) (interface{}, error) { + handlerValue := reflect.ValueOf(handler.Func) + handlerType := handlerValue.Type() + + if handlerType.Kind() != reflect.Func { + return nil, errors.New("handler is not a function") + } + + numParams := handlerType.NumIn() + args := make([]reflect.Value, numParams) + // Get the parameter names from the RPCHandler + paramNames := handler.Params + + if len(paramNames) != numParams { + return nil, errors.New("mismatch between handler parameters and defined parameter names") + } + + for i := 0; i < numParams; i++ { + paramType := handlerType.In(i) + paramName := paramNames[i] + paramValue, ok := params[paramName] + if !ok { + return nil, errors.New("missing parameter: " + paramName) + } + + convertedValue := reflect.ValueOf(paramValue) + if !convertedValue.Type().ConvertibleTo(paramType) { + if paramType.Kind() == reflect.Slice && (convertedValue.Kind() == reflect.Slice || convertedValue.Kind() == reflect.Array) { + newSlice := reflect.MakeSlice(paramType, convertedValue.Len(), convertedValue.Len()) + for j := 0; j < convertedValue.Len(); j++ { + elemValue := convertedValue.Index(j) + if elemValue.Kind() == reflect.Interface { + elemValue = elemValue.Elem() + } + if !elemValue.Type().ConvertibleTo(paramType.Elem()) { + // Handle float64 to uint8 conversion + if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { + intValue := int(elemValue.Float()) + if intValue < 0 || intValue > 255 { + return nil, fmt.Errorf("value out of range for uint8: %v", intValue) + } + newSlice.Index(j).SetUint(uint64(intValue)) + } else { + fromType := elemValue.Type() + toType := paramType.Elem() + return nil, fmt.Errorf("invalid element type in slice for parameter %s: from %v to %v", paramName, fromType, toType) + } + } else { + newSlice.Index(j).Set(elemValue.Convert(paramType.Elem())) + } + } + args[i] = newSlice + } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { + jsonData, err := json.Marshal(convertedValue.Interface()) + if err != nil { + return nil, fmt.Errorf("failed to marshal map to JSON: %v", err) + } + + newStruct := reflect.New(paramType).Interface() + if err := json.Unmarshal(jsonData, newStruct); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err) + } + args[i] = reflect.ValueOf(newStruct).Elem() + } else { + return nil, fmt.Errorf("invalid parameter type for: %s", paramName) + } + } else { + args[i] = convertedValue.Convert(paramType) + } + } + + results := handlerValue.Call(args) + + if len(results) == 0 { + return nil, nil + } + + if len(results) == 1 { + if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if !results[0].IsNil() { + return nil, results[0].Interface().(error) + } + return nil, nil + } + return results[0].Interface(), nil + } + + if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { + if !results[1].IsNil() { + return nil, results[1].Interface().(error) + } + return results[0].Interface(), nil + } + + return nil, errors.New("unexpected return values from handler") +} diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go new file mode 100644 index 00000000..30f8a2ca --- /dev/null +++ b/internal/jsonrpc/types.go @@ -0,0 +1,26 @@ +package jsonrpc + +type JSONRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params map[string]interface{} `json:"params,omitempty"` + ID interface{} `json:"id,omitempty"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result interface{} `json:"result,omitempty"` + Error interface{} `json:"error,omitempty"` + ID interface{} `json:"id"` +} + +type JSONRPCEvent struct { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type RPCHandler struct { + Func interface{} + Params []string +} diff --git a/jsonrpc.go b/jsonrpc.go index f6a9e394..d08cc602 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,34 +5,24 @@ import ( "encoding/json" "errors" "fmt" + "kvm/internal/jsonrpc" "kvm/internal/plugin" "log" "os" "os/exec" "path/filepath" - "reflect" "github.com/pion/webrtc/v4" ) -type JSONRPCRequest struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params map[string]interface{} `json:"params,omitempty"` - ID interface{} `json:"id,omitempty"` +type DataChannelWriter struct { + dataChannel *webrtc.DataChannel } -type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result interface{} `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` - ID interface{} `json:"id"` -} - -type JSONRPCEvent struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params interface{} `json:"params,omitempty"` +func NewDataChannelWriter(dataChannel *webrtc.DataChannel) *DataChannelWriter { + return &DataChannelWriter{ + dataChannel: dataChannel, + } } type BacklightSettings struct { @@ -41,21 +31,25 @@ type BacklightSettings struct { OffAfter int `json:"off_after"` } -func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { - responseBytes, err := json.Marshal(response) - if err != nil { - log.Println("Error marshalling JSONRPC response:", err) - return - } - err = session.RPCChannel.SendText(string(responseBytes)) +func (w *DataChannelWriter) Write(data []byte) (int, error) { + err := w.dataChannel.SendText(string(data)) if err != nil { log.Println("Error sending JSONRPC response:", err) - return + return 0, err } + return len(data), nil } +func NewDataChannelJsonRpcServer(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCServer { + return jsonrpc.NewJSONRPCServer( + NewDataChannelWriter(dataChannel), + rpcHandlers, + ) +} + +// TODO: embed this into the session's rpc server func writeJSONRPCEvent(event string, params interface{}, session *Session) { - request := JSONRPCEvent{ + request := jsonrpc.JSONRPCEvent{ JSONRPC: "2.0", Method: event, Params: params, @@ -76,60 +70,6 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) { } } -func onRPCMessage(message webrtc.DataChannelMessage, session *Session) { - var request JSONRPCRequest - err := json.Unmarshal(message.Data, &request) - if err != nil { - errorResponse := JSONRPCResponse{ - JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32700, - "message": "Parse error", - }, - ID: 0, - } - writeJSONRPCResponse(errorResponse, session) - return - } - - //log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID) - handler, ok := rpcHandlers[request.Method] - if !ok { - errorResponse := JSONRPCResponse{ - JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32601, - "message": "Method not found", - }, - ID: request.ID, - } - writeJSONRPCResponse(errorResponse, session) - return - } - - result, err := callRPCHandler(handler, request.Params) - if err != nil { - errorResponse := JSONRPCResponse{ - JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32603, - "message": "Internal error", - "data": err.Error(), - }, - ID: request.ID, - } - writeJSONRPCResponse(errorResponse, session) - return - } - - response := JSONRPCResponse{ - JSONRPC: "2.0", - Result: result, - ID: request.ID, - } - writeJSONRPCResponse(response, session) -} - func rpcPing() (string, error) { return "pong", nil } @@ -365,108 +305,6 @@ func rpcSetSSHKeyState(sshKey string) error { return nil } -func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { - handlerValue := reflect.ValueOf(handler.Func) - handlerType := handlerValue.Type() - - if handlerType.Kind() != reflect.Func { - return nil, errors.New("handler is not a function") - } - - numParams := handlerType.NumIn() - args := make([]reflect.Value, numParams) - // Get the parameter names from the RPCHandler - paramNames := handler.Params - - if len(paramNames) != numParams { - return nil, errors.New("mismatch between handler parameters and defined parameter names") - } - - for i := 0; i < numParams; i++ { - paramType := handlerType.In(i) - paramName := paramNames[i] - paramValue, ok := params[paramName] - if !ok { - return nil, errors.New("missing parameter: " + paramName) - } - - convertedValue := reflect.ValueOf(paramValue) - if !convertedValue.Type().ConvertibleTo(paramType) { - if paramType.Kind() == reflect.Slice && (convertedValue.Kind() == reflect.Slice || convertedValue.Kind() == reflect.Array) { - newSlice := reflect.MakeSlice(paramType, convertedValue.Len(), convertedValue.Len()) - for j := 0; j < convertedValue.Len(); j++ { - elemValue := convertedValue.Index(j) - if elemValue.Kind() == reflect.Interface { - elemValue = elemValue.Elem() - } - if !elemValue.Type().ConvertibleTo(paramType.Elem()) { - // Handle float64 to uint8 conversion - if elemValue.Kind() == reflect.Float64 && paramType.Elem().Kind() == reflect.Uint8 { - intValue := int(elemValue.Float()) - if intValue < 0 || intValue > 255 { - return nil, fmt.Errorf("value out of range for uint8: %v", intValue) - } - newSlice.Index(j).SetUint(uint64(intValue)) - } else { - fromType := elemValue.Type() - toType := paramType.Elem() - return nil, fmt.Errorf("invalid element type in slice for parameter %s: from %v to %v", paramName, fromType, toType) - } - } else { - newSlice.Index(j).Set(elemValue.Convert(paramType.Elem())) - } - } - args[i] = newSlice - } else if paramType.Kind() == reflect.Struct && convertedValue.Kind() == reflect.Map { - jsonData, err := json.Marshal(convertedValue.Interface()) - if err != nil { - return nil, fmt.Errorf("failed to marshal map to JSON: %v", err) - } - - newStruct := reflect.New(paramType).Interface() - if err := json.Unmarshal(jsonData, newStruct); err != nil { - return nil, fmt.Errorf("failed to unmarshal JSON into struct: %v", err) - } - args[i] = reflect.ValueOf(newStruct).Elem() - } else { - return nil, fmt.Errorf("invalid parameter type for: %s", paramName) - } - } else { - args[i] = convertedValue.Convert(paramType) - } - } - - results := handlerValue.Call(args) - - if len(results) == 0 { - return nil, nil - } - - if len(results) == 1 { - if results[0].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[0].IsNil() { - return nil, results[0].Interface().(error) - } - return nil, nil - } - return results[0].Interface(), nil - } - - if len(results) == 2 && results[1].Type().Implements(reflect.TypeOf((*error)(nil)).Elem()) { - if !results[1].IsNil() { - return nil, results[1].Interface().(error) - } - return results[0].Interface(), nil - } - - return nil, errors.New("unexpected return values from handler") -} - -type RPCHandler struct { - Func interface{} - Params []string -} - func rpcSetMassStorageMode(mode string) (string, error) { log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) var cdrom bool @@ -558,7 +396,7 @@ func rpcResetConfig() error { } // TODO: replace this crap with code generator -var rpcHandlers = map[string]RPCHandler{ +var rpcHandlers = map[string]*jsonrpc.RPCHandler{ "ping": {Func: rpcPing}, "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index f2f0bdd9..96af34a8 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -4,7 +4,7 @@ import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; -import PluginConfigureModal from "./PluginConfigureDialog"; +import PluginConfigureModal from "@components/PluginConfigureDialog"; function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { let classNames = "bg-slate-500 border-slate-600"; diff --git a/webrtc.go b/webrtc.go index 27084fca..f7d226b5 100644 --- a/webrtc.go +++ b/webrtc.go @@ -102,8 +102,9 @@ func newSession(config SessionConfig) (*Session, error) { switch d.Label() { case "rpc": session.RPCChannel = d + rpcServer := NewDataChannelJsonRpcServer(d) d.OnMessage(func(msg webrtc.DataChannelMessage) { - go onRPCMessage(msg, session) + rpcServer.HandleMessage(msg.Data) }) triggerOTAStateUpdate() triggerVideoStateUpdate() From b8c92503f4b28368c8567b7d8a4541de400ac0b5 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 5 Jan 2025 23:38:54 +0000 Subject: [PATCH 42/83] wip: Plugin RPC with status reporting to the UI --- internal/jsonrpc/rpc_server.go | 147 +++++++++++++++-- internal/jsonrpc/types.go | 14 +- internal/plugin/install.go | 48 +++--- internal/plugin/rpc.go | 166 ++++++++++++++++++++ internal/plugin/type.go | 2 +- ui/src/components/PluginConfigureDialog.tsx | 59 +++++-- ui/src/components/PluginList.tsx | 26 +-- ui/src/components/PluginStatusIcon.tsx | 19 +++ ui/src/hooks/stores.ts | 4 +- 9 files changed, 411 insertions(+), 74 deletions(-) create mode 100644 internal/plugin/rpc.go create mode 100644 ui/src/components/PluginStatusIcon.tsx diff --git a/internal/jsonrpc/rpc_server.go b/internal/jsonrpc/rpc_server.go index bdaef1b7..5f8e870a 100644 --- a/internal/jsonrpc/rpc_server.go +++ b/internal/jsonrpc/rpc_server.go @@ -5,31 +5,152 @@ import ( "errors" "fmt" "io" + "log" "reflect" + "sync" + "sync/atomic" + "time" ) type JSONRPCServer struct { writer io.Writer handlers map[string]*RPCHandler + nextId atomic.Int64 + + responseChannelsMutex sync.Mutex + responseChannels map[int64]chan JSONRPCResponse } func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { return &JSONRPCServer{ - writer: writer, - handlers: handlers, + writer: writer, + handlers: handlers, + responseChannels: make(map[int64]chan JSONRPCResponse), + nextId: atomic.Int64{}, + } +} + +func (s *JSONRPCServer) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError { + id := s.nextId.Add(1) + request := JSONRPCRequest{ + JSONRPC: "2.0", + Method: method, + Params: params, + ID: id, + } + requestBytes, err := json.Marshal(request) + if err != nil { + return &JSONRPCResponseError{ + Code: -32700, + Message: "Parse error", + Data: err, + } + } + + // log.Printf("Sending RPC request: Method=%s, Params=%v, ID=%d", method, params, id) + + responseChan := make(chan JSONRPCResponse, 1) + s.responseChannelsMutex.Lock() + s.responseChannels[id] = responseChan + s.responseChannelsMutex.Unlock() + defer func() { + s.responseChannelsMutex.Lock() + delete(s.responseChannels, id) + s.responseChannelsMutex.Unlock() + }() + + _, err = s.writer.Write(requestBytes) + if err != nil { + return &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: err, + } + } + + timeout := time.After(5 * time.Second) + select { + case response := <-responseChan: + if response.Error != nil { + return response.Error + } + + rawResult, err := json.Marshal(response.Result) + if err != nil { + return &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: err, + } + } + + if err := json.Unmarshal(rawResult, result); err != nil { + return &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: err, + } + } + + return nil + case <-timeout: + return &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: "timeout waiting for response", + } } } +type JSONRPCMessage struct { + Method *string `json:"method,omitempty"` + ID *int64 `json:"id,omitempty"` +} + func (s *JSONRPCServer) HandleMessage(data []byte) error { + // Data will either be a JSONRPCRequest or JSONRPCResponse object + // We need to determine which one it is + var raw JSONRPCMessage + err := json.Unmarshal(data, &raw) + if err != nil { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: &JSONRPCResponseError{ + Code: -32700, + Message: "Parse error", + }, + ID: 0, + } + return s.writeResponse(errorResponse) + } + + if raw.Method == nil && raw.ID != nil { + var resp JSONRPCResponse + if err := json.Unmarshal(data, &resp); err != nil { + fmt.Println("error unmarshalling response", err) + return err + } + + s.responseChannelsMutex.Lock() + responseChan, ok := s.responseChannels[*raw.ID] + s.responseChannelsMutex.Unlock() + if ok { + responseChan <- resp + } else { + log.Println("No response channel found for ID", resp.ID) + } + return nil + } + var request JSONRPCRequest - err := json.Unmarshal(data, &request) + err = json.Unmarshal(data, &request) if err != nil { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32700, - "message": "Parse error", + Error: &JSONRPCResponseError{ + Code: -32700, + Message: "Parse error", }, ID: 0, } @@ -41,9 +162,9 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error { if !ok { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32601, - "message": "Method not found", + Error: &JSONRPCResponseError{ + Code: -32601, + Message: "Method not found", }, ID: request.ID, } @@ -54,10 +175,10 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error { if err != nil { errorResponse := JSONRPCResponse{ JSONRPC: "2.0", - Error: map[string]interface{}{ - "code": -32603, - "message": "Internal error", - "data": err.Error(), + Error: &JSONRPCResponseError{ + Code: -32603, + Message: "Internal error", + Data: err.Error(), }, ID: request.ID, } diff --git a/internal/jsonrpc/types.go b/internal/jsonrpc/types.go index 30f8a2ca..ac4f956c 100644 --- a/internal/jsonrpc/types.go +++ b/internal/jsonrpc/types.go @@ -8,10 +8,16 @@ type JSONRPCRequest struct { } type JSONRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - Result interface{} `json:"result,omitempty"` - Error interface{} `json:"error,omitempty"` - ID interface{} `json:"id"` + JSONRPC string `json:"jsonrpc"` + Result interface{} `json:"result,omitempty"` + Error *JSONRPCResponseError `json:"error,omitempty"` + ID interface{} `json:"id"` +} + +type JSONRPCResponseError struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` } type JSONRPCEvent struct { diff --git a/internal/plugin/install.go b/internal/plugin/install.go index e5bf3956..89319a89 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -3,7 +3,6 @@ package plugin import ( "fmt" "log" - "net" "os" "os/exec" "path" @@ -22,7 +21,7 @@ type PluginInstall struct { manifest *PluginManifest runningVersion *string processManager *ProcessManager - rpcListener net.Listener + rpcServer *PluginRpcServer } func (p *PluginInstall) GetManifest() (*PluginManifest, error) { @@ -54,13 +53,24 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { Enabled: p.Enabled, } - status.Status = "stopped" - if p.processManager != nil { - status.Status = "running" - if p.processManager.LastError != nil { - status.Status = "errored" - status.Error = p.processManager.LastError.Error() + if p.rpcServer != nil && p.rpcServer.status.Status != "disconnected" { + log.Printf("Status from RPC: %v", p.rpcServer.status) + status.Status = p.rpcServer.status.Status + status.Message = p.rpcServer.status.Message + + if status.Status == "error" { + status.Message = p.rpcServer.status.Message + } + } else { + status.Status = "stopped" + if p.processManager != nil { + status.Status = "running" + if p.processManager.LastError != nil { + status.Status = "errored" + status.Message = p.processManager.LastError.Error() + } } + log.Printf("Status from process manager: %v", status.Status) } return &status, nil @@ -94,8 +104,10 @@ func (p *PluginInstall) ReconcileSubprocess() error { p.processManager.Disable() p.processManager = nil p.runningVersion = nil - p.rpcListener.Close() - p.rpcListener = nil + err = p.rpcServer.Stop() + if err != nil { + return fmt.Errorf("failed to stop rpc server: %v", err) + } } if versionShouldBeRunning == "" { @@ -103,25 +115,22 @@ func (p *PluginInstall) ReconcileSubprocess() error { } workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name) - socketPath := path.Join(workingDir, "plugin.sock") - - os.Remove(socketPath) err = os.MkdirAll(workingDir, 0755) if err != nil { return fmt.Errorf("failed to create working directory: %v", err) } - listener, err := net.Listen("unix", socketPath) + p.rpcServer = NewPluginRpcServer(p, workingDir) + err = p.rpcServer.Start() if err != nil { - return fmt.Errorf("failed to listen on socket: %v", err) + return fmt.Errorf("failed to start rpc server: %v", err) } - p.rpcListener = listener p.processManager = NewProcessManager(func() *exec.Cmd { cmd := exec.Command(manifest.BinaryPath) cmd.Dir = p.GetExtractedFolder() cmd.Env = append(cmd.Env, - "JETKVM_PLUGIN_SOCK="+socketPath, + "JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(), "JETKVM_PLUGIN_WORKING_DIR="+workingDir, ) cmd.Stdout = os.Stdout @@ -147,8 +156,7 @@ func (p *PluginInstall) Shutdown() { p.runningVersion = nil } - if p.rpcListener != nil { - p.rpcListener.Close() - p.rpcListener = nil + if p.rpcServer != nil { + p.rpcServer.Stop() } } diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go new file mode 100644 index 00000000..9606dd62 --- /dev/null +++ b/internal/plugin/rpc.go @@ -0,0 +1,166 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "kvm/internal/jsonrpc" + "log" + "net" + "os" + "path" + "time" +) + +type PluginRpcStatus struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +var ( + PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""} + PluginRpcStatusLoading = PluginRpcStatus{"loading", ""} + PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""} + PluginRpcStatusRunning = PluginRpcStatus{"running", ""} + PluginRpcStatusError = PluginRpcStatus{"error", ""} +) + +type PluginRpcServer struct { + install *PluginInstall + workingDir string + + listener net.Listener + status PluginRpcStatus +} + +func NewPluginRpcServer(install *PluginInstall, workingDir string) *PluginRpcServer { + return &PluginRpcServer{ + install: install, + workingDir: workingDir, + status: PluginRpcStatusDisconnected, + } +} + +func (s *PluginRpcServer) Start() error { + socketPath := s.SocketPath() + _ = os.Remove(socketPath) + listener, err := net.Listen("unix", socketPath) + if err != nil { + return fmt.Errorf("failed to listen on socket: %v", err) + } + s.listener = listener + + s.status = PluginRpcStatusDisconnected + go func() { + for { + conn, err := listener.Accept() + if err != nil { + // If the error indicates the listener is closed, break out + if opErr, ok := err.(*net.OpError); ok && opErr.Err.Error() == "use of closed network connection" { + log.Println("Listener closed, exiting accept loop.") + return + } + + log.Printf("Failed to accept connection: %v", err) + continue + } + log.Printf("Accepted plugin rpc connection from %v", conn.RemoteAddr()) + + go s.handleConnection(conn) + } + }() + + return nil +} + +func (s *PluginRpcServer) Stop() error { + if s.listener != nil { + s.status = PluginRpcStatusDisconnected + return s.listener.Close() + } + return nil +} + +func (s *PluginRpcServer) Status() PluginRpcStatus { + return s.status +} + +func (s *PluginRpcServer) SocketPath() string { + return path.Join(s.workingDir, "plugin.sock") +} + +func (s *PluginRpcServer) handleConnection(conn net.Conn) { + rpcserver := jsonrpc.NewJSONRPCServer(conn, map[string]*jsonrpc.RPCHandler{}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go s.handleRpcStatus(ctx, rpcserver) + + // Read from the conn and write into rpcserver.HandleMessage + buf := make([]byte, 65*1024) + for { + // TODO: if read 65k bytes, then likey there is more data to read... figure out how to handle this + n, err := conn.Read(buf) + if err != nil { + log.Printf("Failed to read message: %v", err) + if errors.Is(err, net.ErrClosed) { + s.status = PluginRpcStatusDisconnected + } else { + s.status = PluginRpcStatusError + s.status.Message = fmt.Errorf("failed to read message: %v", err).Error() + } + break + } + + err = rpcserver.HandleMessage(buf[:n]) + if err != nil { + log.Printf("Failed to handle message: %v", err) + s.status = PluginRpcStatusError + s.status.Message = fmt.Errorf("failed to handle message: %v", err).Error() + continue + } + } +} + +func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) { + // log.Printf("Plugin rpc server started. Getting supported methods...") + // supportedMethodsResponse, err := rpcserver.Request("getPluginSupportedMethods", map[string]interface{}{}) + // if err != nil { + // log.Printf("Failed to get supported methods: %v", err) + // s.status = PluginRpcStatusError + // s.status.Message = fmt.Errorf("error getting supported methods: %v", err).Error() + // } + + // if supportedMethodsResponse.Error != nil { + // log.Printf("Failed to get supported methods: %v", supportedMethodsResponse.Error) + // s.status = PluginRpcStatusError + // s.status.Message = fmt.Errorf("error getting supported methods: %v", supportedMethodsResponse.Error).Error() + // } + + // log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.Result) + + ticker := time.NewTicker(1 * time.Second) + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + var statusResponse PluginRpcStatus + err := rpcserver.Request("getPluginStatus", map[string]interface{}{}, &statusResponse) + if err != nil { + log.Printf("Failed to get status: %v", err) + if err, ok := err.Data.(error); ok && errors.Is(err, net.ErrClosed) { + s.status = PluginRpcStatusDisconnected + break + } + + s.status = PluginRpcStatusError + s.status.Message = fmt.Errorf("error getting status: %v", err).Error() + continue + } + + s.status = statusResponse + } + } +} diff --git a/internal/plugin/type.go b/internal/plugin/type.go index 0e6988c4..de1001a0 100644 --- a/internal/plugin/type.go +++ b/internal/plugin/type.go @@ -14,5 +14,5 @@ type PluginStatus struct { PluginManifest Enabled bool `json:"enabled"` Status string `json:"status"` - Error string `json:"error,omitempty"` + Message string `json:"message,omitempty"` } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index c5808f1d..c5ac794b 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -1,13 +1,15 @@ import { PluginStatus } from "@/hooks/stores"; import Modal from "@components/Modal"; import AutoHeight from "@components/AutoHeight"; -import { GridCard } from "@components/Card"; +import Card, { GridCard } from "@components/Card"; import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg"; import { ViewHeader } from "./MountMediaDialog"; import { Button } from "./Button"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useCallback, useEffect, useState } from "react"; +import { PluginStatusIcon } from "./PluginStatusIcon"; +import { cx } from "@/cva.config"; export default function PluginConfigureModal({ plugin, @@ -42,7 +44,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op return; } } - + setLoading(true); send("pluginUpdateConfig", { name: plugin.name, enabled }, resp => { if ("error" in resp) { @@ -77,16 +79,28 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
- JetKVM Logo - JetKVM Logo +
+
+ JetKVM Logo + JetKVM Logo +
+
+ {plugin && <> +

+ {plugin.status} +

+ + } +
+
@@ -104,6 +118,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
+
+
{error &&

{error}

} + {plugin?.message && ( + <> +

+ Plugin message: +

+ + {plugin.message} + + + )}

TODO: Plugin configuration goes here

+
+
-
- +
+ ) } diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx index 96af34a8..9be368c3 100644 --- a/ui/src/components/PluginList.tsx +++ b/ui/src/components/PluginList.tsx @@ -2,24 +2,9 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { Button } from "@components/Button"; import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; import { useCallback, useEffect, useState } from "react"; -import { cx } from "@/cva.config"; import UploadPluginModal from "@components/UploadPluginDialog"; import PluginConfigureModal from "@components/PluginConfigureDialog"; - -function PluginListStatusIcon({ plugin }: { plugin: PluginStatus }) { - let classNames = "bg-slate-500 border-slate-600"; - if (plugin.enabled && plugin.status === "running") { - classNames = "bg-green-500 border-green-600"; - } else if (plugin.enabled && plugin.status === "errored") { - classNames = "bg-red-500 border-red-600"; - } - - return ( -
-
-
- ) -} +import { PluginStatusIcon } from "./PluginStatusIcon"; export default function PluginList() { const [send] = useJsonRpc(); @@ -45,20 +30,21 @@ export default function PluginList() { setError(resp.error.message); return } + console.log('pluginList', resp.result); setPlugins(resp.result as PluginStatus[]); }); }, [send, setPlugins]) useEffect(() => { // Only update plugins when the sidebar view is the settings view - if (sidebarView !== "system") return; + if (sidebarView !== "system" && !pluginConfigureModalOpen) return; updatePlugins(); const updateInterval = setInterval(() => { updatePlugins(); }, 10_000); return () => clearInterval(updateInterval); - }, [updatePlugins, sidebarView]) + }, [updatePlugins, sidebarView, pluginConfigureModalOpen]) return ( <> @@ -68,7 +54,7 @@ export default function PluginList() { {plugins.length === 0 &&
  • No plugins installed
  • } {plugins.map(plugin => (
  • - +

    {plugin.name}

    @@ -99,7 +85,7 @@ export default function PluginList() { updatePlugins(); } }} - plugin={configuringPlugin} + plugin={plugins.find(p => p.name == configuringPlugin?.name) ?? null} />

    diff --git a/ui/src/components/PluginStatusIcon.tsx b/ui/src/components/PluginStatusIcon.tsx new file mode 100644 index 00000000..721ec435 --- /dev/null +++ b/ui/src/components/PluginStatusIcon.tsx @@ -0,0 +1,19 @@ +import { cx } from "@/cva.config"; +import { PluginStatus } from "@/hooks/stores"; + +export function PluginStatusIcon({ plugin }: { plugin: PluginStatus; }) { + let classNames = "bg-slate-500 border-slate-600"; + if (plugin.enabled && plugin.status === "running") { + classNames = "bg-green-500 border-green-600"; + } else if (plugin.enabled && plugin.status === "pending-configuration") { + classNames = "bg-yellow-500 border-yellow-600"; + } else if (plugin.enabled && plugin.status === "errored") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
    +
    +
    + ); +} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index a7eab94e..44d9232d 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -561,8 +561,8 @@ export interface PluginManifest { export interface PluginStatus extends PluginManifest { enabled: boolean; - status: "stopped" | "running" | "errored"; - error?: string; + status: "stopped" | "running" | "loading" | "pending-configuration" | "errored"; + message?: string; } interface PluginState { From 3cd47c79be3cb926b9dae033ec055438fe0cd082 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:11:51 +0000 Subject: [PATCH 43/83] Handle error conditions better and detect support methods automatically --- internal/plugin/install.go | 8 ++-- internal/plugin/rpc.go | 42 ++++++++++++--------- ui/src/components/PluginConfigureDialog.tsx | 2 +- ui/src/components/PluginStatusIcon.tsx | 2 +- ui/src/hooks/stores.ts | 2 +- 5 files changed, 33 insertions(+), 23 deletions(-) diff --git a/internal/plugin/install.go b/internal/plugin/install.go index 89319a89..0f0bf01a 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -53,8 +53,10 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { Enabled: p.Enabled, } - if p.rpcServer != nil && p.rpcServer.status.Status != "disconnected" { - log.Printf("Status from RPC: %v", p.rpcServer.status) + // If the rpc server is connected and the plugin is reporting status, use that + if p.rpcServer != nil && + p.rpcServer.status.Status != "disconnected" && + p.rpcServer.status.Status != "unknown" { status.Status = p.rpcServer.status.Status status.Message = p.rpcServer.status.Message @@ -66,7 +68,7 @@ func (p *PluginInstall) GetStatus() (*PluginStatus, error) { if p.processManager != nil { status.Status = "running" if p.processManager.LastError != nil { - status.Status = "errored" + status.Status = "error" status.Message = p.processManager.LastError.Error() } } diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go index 9606dd62..ff31472a 100644 --- a/internal/plugin/rpc.go +++ b/internal/plugin/rpc.go @@ -9,6 +9,7 @@ import ( "net" "os" "path" + "slices" "time" ) @@ -19,12 +20,17 @@ type PluginRpcStatus struct { var ( PluginRpcStatusDisconnected = PluginRpcStatus{"disconnected", ""} + PluginRpcStatusUnknown = PluginRpcStatus{"unknown", ""} PluginRpcStatusLoading = PluginRpcStatus{"loading", ""} PluginRpcStatusPendingConfiguration = PluginRpcStatus{"pending-configuration", ""} PluginRpcStatusRunning = PluginRpcStatus{"running", ""} PluginRpcStatusError = PluginRpcStatus{"error", ""} ) +type PluginRpcSupportedMethods struct { + SupportedRpcMethods []string `json:"supported_rpc_methods"` +} + type PluginRpcServer struct { install *PluginInstall workingDir string @@ -103,10 +109,10 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { // TODO: if read 65k bytes, then likey there is more data to read... figure out how to handle this n, err := conn.Read(buf) if err != nil { - log.Printf("Failed to read message: %v", err) if errors.Is(err, net.ErrClosed) { s.status = PluginRpcStatusDisconnected } else { + log.Printf("Failed to read message: %v", err) s.status = PluginRpcStatusError s.status.Message = fmt.Errorf("failed to read message: %v", err).Error() } @@ -124,21 +130,23 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { } func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) { - // log.Printf("Plugin rpc server started. Getting supported methods...") - // supportedMethodsResponse, err := rpcserver.Request("getPluginSupportedMethods", map[string]interface{}{}) - // if err != nil { - // log.Printf("Failed to get supported methods: %v", err) - // s.status = PluginRpcStatusError - // s.status.Message = fmt.Errorf("error getting supported methods: %v", err).Error() - // } - - // if supportedMethodsResponse.Error != nil { - // log.Printf("Failed to get supported methods: %v", supportedMethodsResponse.Error) - // s.status = PluginRpcStatusError - // s.status.Message = fmt.Errorf("error getting supported methods: %v", supportedMethodsResponse.Error).Error() - // } - - // log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.Result) + s.status = PluginRpcStatusUnknown + + log.Printf("Plugin rpc server started. Getting supported methods...") + var supportedMethodsResponse PluginRpcSupportedMethods + err := rpcserver.Request("getPluginSupportedMethods", nil, &supportedMethodsResponse) + if err != nil { + log.Printf("Failed to get supported methods: %v", err) + s.status = PluginRpcStatusError + s.status.Message = fmt.Errorf("error getting supported methods: %v", err.Message).Error() + } + + log.Printf("Plugin has supported methods: %v", supportedMethodsResponse.SupportedRpcMethods) + + if !slices.Contains(supportedMethodsResponse.SupportedRpcMethods, "getPluginStatus") { + log.Printf("Plugin does not support getPluginStatus method") + return + } ticker := time.NewTicker(1 * time.Second) for { @@ -147,7 +155,7 @@ func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrp return case <-ticker.C: var statusResponse PluginRpcStatus - err := rpcserver.Request("getPluginStatus", map[string]interface{}{}, &statusResponse) + err := rpcserver.Request("getPluginStatus", nil, &statusResponse) if err != nil { log.Printf("Failed to get status: %v", err) if err, ok := err.Data.(error); ok && errors.Is(err, net.ErrClosed) { diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index c5ac794b..68a17101 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -134,7 +134,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op

    {plugin.message} diff --git a/ui/src/components/PluginStatusIcon.tsx b/ui/src/components/PluginStatusIcon.tsx index 721ec435..074751ff 100644 --- a/ui/src/components/PluginStatusIcon.tsx +++ b/ui/src/components/PluginStatusIcon.tsx @@ -7,7 +7,7 @@ export function PluginStatusIcon({ plugin }: { plugin: PluginStatus; }) { classNames = "bg-green-500 border-green-600"; } else if (plugin.enabled && plugin.status === "pending-configuration") { classNames = "bg-yellow-500 border-yellow-600"; - } else if (plugin.enabled && plugin.status === "errored") { + } else if (plugin.enabled && plugin.status === "error") { classNames = "bg-red-500 border-red-600"; } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 44d9232d..fd661611 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -561,7 +561,7 @@ export interface PluginManifest { export interface PluginStatus extends PluginManifest { enabled: boolean; - status: "stopped" | "running" | "loading" | "pending-configuration" | "errored"; + status: "stopped" | "running" | "loading" | "pending-configuration" | "error"; message?: string; } From cb6a2d9c6322b5db8346a3232e55667a52b53696 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:22:05 +0000 Subject: [PATCH 44/83] Change wording from TODO to coming soon --- ui/src/components/PluginConfigureDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 68a17101..7eab9511 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -141,7 +141,7 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op )}

    - TODO: Plugin configuration goes here + Plugin configuration coming soon

    From af7062e275f382cd4ce4d56d1eb10092b09ca022 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Mon, 6 Jan 2025 18:56:29 +0000 Subject: [PATCH 45/83] Better handle install and re-install lifecycle. Also display all the juicy bits about the plugin --- internal/plugin/install.go | 17 +++--- internal/plugin/plugin.go | 5 +- ui/src/components/PluginConfigureDialog.tsx | 58 +++++++++++++++++---- ui/src/components/PluginList.tsx | 4 +- ui/src/components/UploadPluginDialog.tsx | 9 +++- ui/src/hooks/stores.ts | 4 +- 6 files changed, 70 insertions(+), 27 deletions(-) diff --git a/internal/plugin/install.go b/internal/plugin/install.go index 0f0bf01a..dedf3291 100644 --- a/internal/plugin/install.go +++ b/internal/plugin/install.go @@ -19,7 +19,7 @@ type PluginInstall struct { ExtractedVersions map[string]string `json:"extracted_versions"` manifest *PluginManifest - runningVersion *string + runningVersion string processManager *ProcessManager rpcServer *PluginRpcServer } @@ -84,10 +84,7 @@ func (p *PluginInstall) ReconcileSubprocess() error { return fmt.Errorf("failed to get plugin manifest: %v", err) } - versionRunning := "" - if p.runningVersion != nil { - versionRunning = *p.runningVersion - } + versionRunning := p.runningVersion versionShouldBeRunning := p.Version if !p.Enabled { @@ -105,7 +102,7 @@ func (p *PluginInstall) ReconcileSubprocess() error { log.Printf("Stopping plugin %s running version %s", manifest.Name, versionRunning) p.processManager.Disable() p.processManager = nil - p.runningVersion = nil + p.runningVersion = "" err = p.rpcServer.Stop() if err != nil { return fmt.Errorf("failed to stop rpc server: %v", err) @@ -146,7 +143,11 @@ func (p *PluginInstall) ReconcileSubprocess() error { }) p.processManager.StartMonitor() p.processManager.Enable() - p.runningVersion = &p.Version + p.runningVersion = p.Version + + // Clear out manifest so the new version gets pulled next time + p.manifest = nil + log.Printf("Started plugin %s version %s", manifest.Name, p.Version) return nil } @@ -155,7 +156,7 @@ func (p *PluginInstall) Shutdown() { if p.processManager != nil { p.processManager.Disable() p.processManager = nil - p.runningVersion = nil + p.runningVersion = "" } if p.rpcServer != nil { diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go index 5aa0ccbc..e72acdfb 100644 --- a/internal/plugin/plugin.go +++ b/internal/plugin/plugin.go @@ -120,7 +120,6 @@ func RpcPluginExtract(filename string) (*PluginManifest, error) { } func RpcPluginInstall(name string, version string) error { - // TODO: find the plugin version in the plugins.json file pluginInstall, ok := pluginDatabase.Plugins[name] if !ok { return fmt.Errorf("plugin not found: %s", name) @@ -136,8 +135,6 @@ func RpcPluginInstall(name string, version string) error { return fmt.Errorf("plugin version not found: %s", version) } - // TODO: If there is a running plugin with the same name, stop it and start the new version - pluginInstall.Version = version pluginInstall.Enabled = true pluginDatabase.Plugins[name] = pluginInstall @@ -151,7 +148,7 @@ func RpcPluginInstall(name string, version string) error { return fmt.Errorf("failed to start plugin %s: %v", name, err) } - // TODO: Determine if the old version should be removed + // TODO: Determine if the old extract should be removed return nil } diff --git a/ui/src/components/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx index 7eab9511..cc1762e2 100644 --- a/ui/src/components/PluginConfigureDialog.tsx +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -1,4 +1,4 @@ -import { PluginStatus } from "@/hooks/stores"; +import { PluginStatus, usePluginStore } from "@/hooks/stores"; import Modal from "@components/Modal"; import AutoHeight from "@components/AutoHeight"; import Card, { GridCard } from "@components/Card"; @@ -33,6 +33,8 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const {setIsPluginUploadModalOpen} = usePluginStore(); + useEffect(() => { setLoading(false); }, [plugin]) @@ -73,6 +75,11 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op }); }, [send, plugin, setOpen]) + const uploadPlugin = useCallback(() => { + setOpen(false); + setIsPluginUploadModalOpen(true); + }, [setIsPluginUploadModalOpen, setOpen]) + return (
    @@ -118,6 +125,30 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op
    +
    + + Name + + {plugin?.name} + + + Active Version + + {plugin?.version} + + + Description + + {plugin?.description} + + + Homepage + + + {plugin?.homepage} + +
    +
    {error}

    } {plugin?.message && ( <> -

    - Plugin message: -

    - - {plugin.message} - +

    + Plugin message: +

    + + {plugin.message} + )}

    @@ -154,6 +185,13 @@ function Dialog({ plugin, setOpen }: { plugin: PluginStatus | null, setOpen: (op }} >

    +
    + {error &&

    {error}

    } +
    +
    + ); +} + +function SuccessModal({ + headline, + description, + onClose, +}: { + headline: string; + description: string; + onClose: () => void; +}) { + return ( +
    +
    + + +
    +
    +
    +

    {headline}

    +

    {description}

    +
    +
    +
    + ); +} diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index 992298bc..f0ada91b 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -4,7 +4,7 @@ import { useLocalAuthModalStore, useSettingsStore, useUiStore, - useUpdateStore, + useUpdateStore, useUsbConfigModalStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -27,6 +27,7 @@ import { LocalDevice } from "@routes/devices.$id"; import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; import PluginList from "@components/PluginList"; +import USBConfigDialog from "@components/USBConfigDialog"; export function SettingsItem({ title, @@ -41,18 +42,18 @@ export function SettingsItem({ name?: string; }) { return ( - + ); } const defaultEdid = - "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; + "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; const edids = [ { value: defaultEdid, @@ -60,17 +61,17 @@ const edids = [ }, { value: - "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096", + "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096", label: "Acer B246WL, 1920x1200", }, { value: - "00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC", + "00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC", label: "ASUS PA248QV, 1920x1200", }, { value: - "00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F", + "00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F", label: "DELL D2721H, 1920x1080", }, ]; @@ -113,19 +114,19 @@ export default function SettingsSidebar() { }, [send]); const handleUsbEmulationToggle = useCallback( - (enabled: boolean) => { - send("setUsbEmulationState", { enabled: enabled }, resp => { - if ("error" in resp) { - notifications.error( - `Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`, - ); - return; - } - setUsbEmulationEnabled(enabled); - getUsbEmulationState(); - }); - }, - [getUsbEmulationState, send], + (enabled: boolean) => { + send("setUsbEmulationState", { enabled: enabled }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setUsbEmulationEnabled(enabled); + getUsbEmulationState(); + }); + }, + [getUsbEmulationState, send], ); const getCloudState = useCallback(() => { @@ -140,7 +141,7 @@ export default function SettingsSidebar() { send("deregisterDevice", {}, resp => { if ("error" in resp) { notifications.error( - `Failed to de-register device: ${resp.error.data || "Unknown error"}`, + `Failed to de-register device: ${resp.error.data || "Unknown error"}`, ); return; } @@ -153,7 +154,7 @@ export default function SettingsSidebar() { send("setStreamQualityFactor", { factor: Number(factor) }, resp => { if ("error" in resp) { notifications.error( - `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, + `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, ); return; } @@ -165,7 +166,7 @@ export default function SettingsSidebar() { send("setAutoUpdateState", { enabled }, resp => { if ("error" in resp) { notifications.error( - `Failed to set auto-update: ${resp.error.data || "Unknown error"}`, + `Failed to set auto-update: ${resp.error.data || "Unknown error"}`, ); return; } @@ -177,7 +178,7 @@ export default function SettingsSidebar() { send("setDevChannelState", { enabled }, resp => { if ("error" in resp) { notifications.error( - `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, + `Failed to set dev channel state: ${resp.error.data || "Unknown error"}`, ); return; } @@ -189,7 +190,7 @@ export default function SettingsSidebar() { send("setJigglerState", { enabled }, resp => { if ("error" in resp) { notifications.error( - `Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, + `Failed to set jiggler state: ${resp.error.data || "Unknown error"}`, ); return; } @@ -214,21 +215,21 @@ export default function SettingsSidebar() { }; const handleDevModeChange = useCallback( - (developerMode: boolean) => { - send("setDevModeState", { enabled: developerMode }, resp => { - if ("error" in resp) { - notifications.error( - `Failed to set dev mode: ${resp.error.data || "Unknown error"}`, - ); - return; - } - setDeveloperMode(developerMode); - setTimeout(() => { - sidebarRef.current?.scrollTo({ top: 5000, behavior: "smooth" }); - }, 0); - }); - }, - [send, setDeveloperMode], + (developerMode: boolean) => { + send("setDevModeState", { enabled: developerMode }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set dev mode: ${resp.error.data || "Unknown error"}`, + ); + return; + } + setDeveloperMode(developerMode); + setTimeout(() => { + sidebarRef.current?.scrollTo({ top: 5000, behavior: "smooth" }); + }, 0); + }); + }, + [send, setDeveloperMode], ); const handleBacklightSettingsChange = (settings: BacklightSettings) => { @@ -257,7 +258,7 @@ export default function SettingsSidebar() { send("setSSHKeyState", { sshKey }, resp => { if ("error" in resp) { notifications.error( - `Failed to update SSH key: ${resp.error.data || "Unknown error"}`, + `Failed to update SSH key: ${resp.error.data || "Unknown error"}`, ); return; } @@ -313,7 +314,7 @@ export default function SettingsSidebar() { const receivedEdid = resp.result as string; const matchingEdid = edids.find( - x => x.value.toLowerCase() === receivedEdid.toLowerCase(), + x => x.value.toLowerCase() === receivedEdid.toLowerCase(), ); if (matchingEdid) { @@ -367,8 +368,8 @@ export default function SettingsSidebar() { const getDevice = useCallback(async () => { try { const status = await api - .GET(`${import.meta.env.VITE_SIGNAL_API}/device`) - .then(res => res.json() as Promise); + .GET(`${import.meta.env.VITE_SIGNAL_API}/device`) + .then(res => res.json() as Promise); setLocalDevice(status); } catch (error) { notifications.error("Failed to get authentication status"); @@ -376,7 +377,9 @@ export default function SettingsSidebar() { }, []); const { setModalView: setLocalAuthModalView } = useLocalAuthModalStore(); + const { setModalView: setUsbConfigModalView } = useUsbConfigModalStore(); const [isLocalAuthDialogOpen, setIsLocalAuthDialogOpen] = useState(false); + const [isUsbConfigDialogOpen, setIsUsbConfigDialogOpen] = useState(false); useEffect(() => { if (isOnDevice) getDevice(); @@ -390,6 +393,14 @@ export default function SettingsSidebar() { } }, [getDevice, isLocalAuthDialogOpen]); + useEffect(() => { + if (!isOnDevice) return; + // Refresh device status when the local usb config dialog is closed + if (!isUsbConfigDialogOpen) { + getDevice(); + } + }, [getDevice, isUsbConfigDialogOpen]); + const revalidator = useRevalidator(); const [currentTheme, setCurrentTheme] = useState(() => { @@ -403,8 +414,8 @@ export default function SettingsSidebar() { localStorage.removeItem("theme"); // Check system preference const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; + ? "dark" + : "light"; root.classList.remove("light", "dark"); root.classList.add(systemTheme); } else { @@ -418,7 +429,7 @@ export default function SettingsSidebar() { send("resetConfig", {}, resp => { if ("error" in resp) { notifications.error( - `Failed to reset configuration: ${resp.error.data || "Unknown error"}`, + `Failed to reset configuration: ${resp.error.data || "Unknown error"}`, ); return; } @@ -427,336 +438,336 @@ export default function SettingsSidebar() { }, [send]); return ( -
    e.stopPropagation()} - onKeyUp={e => e.stopPropagation()} - > -
    e.stopPropagation()} + onKeyUp={e => e.stopPropagation()} > -
    -
    - - App: {currentVersions.appVersion} -
    - System: {currentVersions.systemVersion} - - ) : ( - "Loading current versions..." - ) - } - /> -
    -
    -
    -
    - +
    + -
    - - { - setHideCursor(e.target.checked); - }} - /> - - - { - handleJigglerChange(e.target.checked); - }} - /> -
    - -
    -
    - - - +
    - - + + +
    -
    -
    -
    - -
    - - handleStreamQualityChange(e.target.value)} - /> - - - { - if (e.target.value === "custom") { - setEdid("custom"); - setCustomEdidValue(""); - } else { - handleEDIDChange(e.target.value as string); - } - }} - options={[...edids, { value: "custom", label: "Custom" }]} - /> - - {customEdidValue !== null && ( - <> - - setCustomEdidValue(e.target.value)} +
    +
    + +
    + + handleStreamQualityChange(e.target.value)} /> -
    -
    - - )} -
    -
    - {isOnDevice && ( - <> -
    -
    - - - -
    - -
    -
    -

    - Cloud Security -

    -
    -
      -
    • • End-to-end encryption using WebRTC (DTLS and SRTP)
    • -
    • • Zero Trust security model
    • -
    • • OIDC (OpenID Connect) authentication
    • -
    • • All streams encrypted in transit
    • -
    -
    - -
    - All cloud components are open-source and available on{" "} - - GitHub - - . -
    -
    -
    - -
    - + + {customEdidValue !== null && ( + <> + + setCustomEdidValue(e.target.value)} + /> +
    +
    -
    -
    - - - {!isAdopted ? ( -
    - -
    - ) : ( -
    -
    -

    - Your device is adopted to JetKVM Cloud -

    -
    -
    -
    + )}
    - - )} -
    - {isOnDevice ? ( - <> -
    - +
    + {isOnDevice && ( + <> +
    +
    + -
    - - {localDevice?.authMode === "password" ? ( -
    +
    +
    )} - +
    + + )} +
    + {isOnDevice ? ( + <> +
    + + +
    + + {localDevice?.authMode === "password" ? ( +
    -
    +
    -
    - - handleDevModeChange(e.target.checked)} - /> - - - {settings.developerMode && ( -
    - handleSSHKeyChange(e.target.value)} - placeholder="Enter your SSH public key" +
    + + handleDevModeChange(e.target.checked)} /> -

    - The default SSH user is root. -

    -
    -
    -
    - )} - - { - settings.setDebugMode(e.target.checked); - }} - /> - + - {settings.debugMode && ( - <> - -
    +
    + )} -
    + { + // Revalidate the current route to refresh the local device status and dependent UI components + revalidator.revalidate(); + setIsLocalAuthDialogOpen(x); + }} + /> + { + // Revalidate the current route to refresh the local device status and dependent UI components + revalidator.revalidate(); + setIsUsbConfigDialogOpen(x); + }} + />
    - { - // Revalidate the current route to refresh the local device status and dependent UI components - revalidator.revalidate(); - setIsLocalAuthDialogOpen(x); - }} - /> -
    ); -} +} \ No newline at end of file diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 5130102c..5b340ef4 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -610,3 +610,19 @@ export const usePluginStore = create(set => ({ configuringPlugin: null, setConfiguringPlugin: plugin => set({ configuringPlugin: plugin }), })); + +interface UsbConfigModalState { + modalView: + | "updateUsbConfig" + | "updateUsbConfigSuccess"; + errorMessage: string | null; + setModalView: (view: UsbConfigModalState["modalView"]) => void; + setErrorMessage: (message: string | null) => void; +} + +export const useUsbConfigModalStore = create(set => ({ + modalView: "updateUsbConfig", + errorMessage: null, + setModalView: view => set({ modalView: view }), + setErrorMessage: message => set({ errorMessage: message }), +})); \ No newline at end of file diff --git a/usb.go b/usb.go index 075409ad..9125ce21 100644 --- a/usb.go +++ b/usb.go @@ -58,6 +58,44 @@ func init() { //TODO: read hid reports(capslock, numlock, etc) from keyboardHidFile } +func UpdateGadgetConfig() error { + LoadConfig() + gadgetAttrs := [][]string{ + {"idVendor", config.UsbConfig.VendorId}, + {"idProduct", config.UsbConfig.ProductId}, + } + err := writeGadgetAttrs(kvmGadgetPath, gadgetAttrs) + if err != nil { + return err + } + + log.Printf("Successfully updated usb gadget attributes: %v", gadgetAttrs) + + strAttrs := [][]string{ + {"serialnumber", config.UsbConfig.SerialNumber}, + {"manufacturer", config.UsbConfig.Manufacturer}, + {"product", config.UsbConfig.Product}, + } + gadgetStringsPath := filepath.Join(kvmGadgetPath, "strings", "0x409") + err = os.MkdirAll(gadgetStringsPath, 0755) + if err != nil { + return err + } + err = writeGadgetAttrs(gadgetStringsPath, strAttrs) + if err != nil { + return err + } + + log.Printf("Successfully updated usb string attributes: %s", strAttrs) + + err = rebindUsb() + if err != nil { + return err + } + + return nil +} + func writeGadgetAttrs(basePath string, attrs [][]string) error { for _, item := range attrs { filePath := filepath.Join(basePath, item[0]) @@ -79,10 +117,11 @@ func writeGadgetConfig() error { return err } + LoadConfig() err = writeGadgetAttrs(kvmGadgetPath, [][]string{ - {"bcdUSB", "0x0200"}, //USB 2.0 - {"idVendor", "0x1d6b"}, //The Linux Foundation - {"idProduct", "0104"}, //Multifunction Composite Gadget¬ + {"bcdUSB", "0x0200"}, //USB 2.0 + {"idVendor", config.UsbConfig.VendorId}, + {"idProduct", config.UsbConfig.ProductId}, {"bcdDevice", "0100"}, }) if err != nil { @@ -97,8 +136,8 @@ func writeGadgetConfig() error { err = writeGadgetAttrs(gadgetStringsPath, [][]string{ {"serialnumber", GetDeviceID()}, - {"manufacturer", "JetKVM"}, - {"product", "JetKVM USB Emulation Device"}, + {"manufacturer", config.UsbConfig.Manufacturer}, + {"product", config.UsbConfig.Product}, }) if err != nil { return err From 973b3d7d6e1f61513735d3a654d58e68fb3d8fa4 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Wed, 29 Jan 2025 14:47:09 +0000 Subject: [PATCH 68/83] NEXT-ONLY: add jackislanding to README Signed-off-by: Cameron Fleming --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 55869c4b..54c3f1e8 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - tutman - [Remove Rounded Corners](https://github.com/jetkvm/kvm/pull/86) - antonym - [Update ISO Versions](https://github.com/jetkvm/kvm/pull/78) - tutman - [Fix fullscreen video absolute position](https://github.com/jetkvm/kvm/pull/85) +- jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls) ## next-multisession As requested by a few in the [JetKVM Discord](https://jetkvm.com/discord), this tree also includes a branch that enables From efef38d8461a98a48fcbbb8c2eeb39b1450651c3 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 20:36:52 +0000 Subject: [PATCH 69/83] Revert "Rename JSONRPCServer to JSONRPCRouter" This reverts commit 10508712855e9631ed1644584dce6946240844fc. --- internal/jsonrpc/{router.go => rpc_server.go} | 18 ++++++++++-------- jsonrpc.go | 4 ++-- webrtc.go | 9 ++++++++- 3 files changed, 20 insertions(+), 11 deletions(-) rename internal/jsonrpc/{router.go => rpc_server.go} (93%) diff --git a/internal/jsonrpc/router.go b/internal/jsonrpc/rpc_server.go similarity index 93% rename from internal/jsonrpc/router.go rename to internal/jsonrpc/rpc_server.go index fda698bf..5f8e870a 100644 --- a/internal/jsonrpc/router.go +++ b/internal/jsonrpc/rpc_server.go @@ -12,7 +12,7 @@ import ( "time" ) -type JSONRPCRouter struct { +type JSONRPCServer struct { writer io.Writer handlers map[string]*RPCHandler @@ -22,14 +22,16 @@ type JSONRPCRouter struct { responseChannels map[int64]chan JSONRPCResponse } -func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCRouter { - return &JSONRPCRouter{ - writer: writer, - handlers: handlers, +func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { + return &JSONRPCServer{ + writer: writer, + handlers: handlers, + responseChannels: make(map[int64]chan JSONRPCResponse), + nextId: atomic.Int64{}, } } -func (s *JSONRPCRouter) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError { +func (s *JSONRPCServer) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError { id := s.nextId.Add(1) request := JSONRPCRequest{ JSONRPC: "2.0", @@ -106,7 +108,7 @@ type JSONRPCMessage struct { ID *int64 `json:"id,omitempty"` } -func (s *JSONRPCRouter) HandleMessage(data []byte) error { +func (s *JSONRPCServer) HandleMessage(data []byte) error { // Data will either be a JSONRPCRequest or JSONRPCResponse object // We need to determine which one it is var raw JSONRPCMessage @@ -191,7 +193,7 @@ func (s *JSONRPCRouter) HandleMessage(data []byte) error { return s.writeResponse(response) } -func (s *JSONRPCRouter) writeResponse(response JSONRPCResponse) error { +func (s *JSONRPCServer) writeResponse(response JSONRPCResponse) error { responseBytes, err := json.Marshal(response) if err != nil { return err diff --git a/jsonrpc.go b/jsonrpc.go index 917e6fe4..2c2ddea1 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -40,8 +40,8 @@ func (w *DataChannelWriter) Write(data []byte) (int, error) { return len(data), nil } -func NewDataChannelJsonRpcRouter(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCRouter { - return jsonrpc.NewJSONRPCRouter( +func NewDataChannelJsonRpcServer(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCServer { + return jsonrpc.NewJSONRPCServer( NewDataChannelWriter(dataChannel), rpcHandlers, ) diff --git a/webrtc.go b/webrtc.go index 2cbed4ca..f8f8a5cb 100644 --- a/webrtc.go +++ b/webrtc.go @@ -102,7 +102,14 @@ func newSession(config SessionConfig) (*Session, error) { switch d.Label() { case "rpc": session.RPCChannel = d - rpcServer := NewDataChannelJsonRpcRouter(d) + fmt.Println("starting rpc server") + rpcServer := NewDataChannelJsonRpcServer(d) + d.OnError(func(err error) { + fmt.Println("rpc error", err) + }) + d.OnClose(func() { + fmt.Println("rpc closed") + }) d.OnMessage(func(msg webrtc.DataChannelMessage) { go rpcServer.HandleMessage(msg.Data) }) From 26b84074ae38605dd9823d69b50f52525b3c237b Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 20:36:59 +0000 Subject: [PATCH 70/83] Revert "fix(rpc.go): use JSONRPCRouter instead of JSONRPCServer after merging with refactory-jsonrpc" This reverts commit 25c8160e0ea5b560cc0512dd20b153e57b0b1d6b. --- internal/plugin/rpc.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go index dacb1d89..ff31472a 100644 --- a/internal/plugin/rpc.go +++ b/internal/plugin/rpc.go @@ -96,7 +96,7 @@ func (s *PluginRpcServer) SocketPath() string { } func (s *PluginRpcServer) handleConnection(conn net.Conn) { - rpcserver := jsonrpc.NewJSONRPCRouter(conn, map[string]*jsonrpc.RPCHandler{}) + rpcserver := jsonrpc.NewJSONRPCServer(conn, map[string]*jsonrpc.RPCHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -129,7 +129,7 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { } } -func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCRouter) { +func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) { s.status = PluginRpcStatusUnknown log.Printf("Plugin rpc server started. Getting supported methods...") From 9e82e8f7741e484b8d483834717d5da1e43c7aa6 Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 29 Jan 2025 22:50:25 -0600 Subject: [PATCH 71/83] custom-vidpid: fix for initial values being empty --- ui/src/components/USBConfigDialog.tsx | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/ui/src/components/USBConfigDialog.tsx b/ui/src/components/USBConfigDialog.tsx index e9ba5f2f..b21a75e6 100644 --- a/ui/src/components/USBConfigDialog.tsx +++ b/ui/src/components/USBConfigDialog.tsx @@ -78,15 +78,13 @@ function UpdateUsbConfigModal({ onCancel: () => void; error: string | null; }) { - const [usbConfig, setUsbConfig] = useState({ + const [usbConfigState, setUsbConfigState] = useState({ vendor_id: '', product_id: '', serial_number: '', manufacturer: '', - product: '', - }) - - const [usbConfigState, setUsbConfigState] = useState(); + product: '' + }); const [send] = useJsonRpc(); const syncUsbConfig = useCallback(() => { @@ -105,23 +103,23 @@ function UpdateUsbConfigModal({ }, [syncUsbConfig]); const handleUsbVendorIdChange = (value: string) => { - setUsbConfig({... usbConfig, vendor_id: value}) + setUsbConfigState({... usbConfigState, vendor_id: value}) }; const handleUsbProductIdChange = (value: string) => { - setUsbConfig({... usbConfig, product_id: value}) + setUsbConfigState({... usbConfigState, product_id: value}) }; const handleUsbSerialChange = (value: string) => { - setUsbConfig({... usbConfig, serial_number: value}) + setUsbConfigState({... usbConfigState, serial_number: value}) }; const handleUsbManufacturer = (value: string) => { - setUsbConfig({... usbConfig, manufacturer: value}) + setUsbConfigState({... usbConfigState, manufacturer: value}) }; const handleUsbProduct = (value: string) => { - setUsbConfig({... usbConfig, product: value}) + setUsbConfigState({... usbConfigState, product: value}) }; return ( @@ -180,7 +178,7 @@ function UpdateUsbConfigModal({ size="SM" theme="primary" text="Update USB Config" - onClick={() => onSetUsbConfig(usbConfig)} + onClick={() => onSetUsbConfig(usbConfigState)} />
    From 48bda2cec1bae90317d3819521663430dcc811c5 Mon Sep 17 00:00:00 2001 From: William Johnstone Date: Wed, 29 Jan 2025 01:52:13 +0000 Subject: [PATCH 72/83] Early implementation of different keyboard layouts. Store is functioning as expected, adding new layouts should be trivial and easily scalable. Implementation is different for each function that uses the keyboard (PasteModal vs Typing in the WebRTC window) these will all require their own testing. --- config.go | 16 +- jsonrpc.go | 14 ++ ui/src/components/InfoBar.tsx | 15 +- ui/src/components/VirtualKeyboard.tsx | 14 +- ui/src/components/WebRTCVideo.tsx | 13 +- ui/src/components/popovers/PasteModal.tsx | 24 +- ui/src/components/sidebar/settings.tsx | 49 ++++ ui/src/keyboardMappings.ts | 214 ----------------- ui/src/keyboardMappings/KeyboardLayouts.ts | 25 ++ .../keyboardMappings/KeyboardMappingStore.ts | 39 ++++ ui/src/keyboardMappings/layouts/uk_apple.ts | 24 ++ ui/src/keyboardMappings/layouts/us.ts | 215 ++++++++++++++++++ 12 files changed, 428 insertions(+), 234 deletions(-) delete mode 100644 ui/src/keyboardMappings.ts create mode 100644 ui/src/keyboardMappings/KeyboardLayouts.ts create mode 100644 ui/src/keyboardMappings/KeyboardMappingStore.ts create mode 100644 ui/src/keyboardMappings/layouts/uk_apple.ts create mode 100644 ui/src/keyboardMappings/layouts/us.ts diff --git a/config.go b/config.go index 8d2621ee..82442ae3 100644 --- a/config.go +++ b/config.go @@ -25,6 +25,7 @@ type Config struct { GoogleIdentity string `json:"google_identity"` JigglerEnabled bool `json:"jiggler_enabled"` AutoUpdateEnabled bool `json:"auto_update_enabled"` + KeyboardLayout string `json:"keyboard_layout"` IncludePreRelease bool `json:"include_pre_release"` HashedPassword string `json:"hashed_password"` LocalAuthToken string `json:"local_auth_token"` @@ -41,19 +42,8 @@ type Config struct { const configPath = "/userdata/kvm_config.json" var defaultConfig = &Config{ - CloudURL: "https://api.jetkvm.com", - AutoUpdateEnabled: true, // Set a default value - DisplayMaxBrightness: 64, - DisplayDimAfterSec: 120, // 2 minutes - DisplayOffAfterSec: 1800, // 30 minutes - VirtualMediaEnabled: true, - UsbConfig: UsbConfig{ - VendorId: "0x1d6b", //The Linux Foundation - ProductId: "0x0104", //Multifunction Composite Gadget - SerialNumber: "", - Manufacturer: "JetKVM", - Product: "JetKVM USB Emulation Device", - }, + CloudURL: "https://api.jetkvm.com", + AutoUpdateEnabled: true, // Set a default value } var config *Config diff --git a/jsonrpc.go b/jsonrpc.go index 2c2ddea1..8456b550 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -78,6 +78,18 @@ func rpcGetDeviceID() (string, error) { return GetDeviceID(), nil } +func rpcGetKeyboardLayout() (string, error) { + return config.KeyboardLayout, nil +} + +func rpcSetKeyboardLayout(KeyboardLayout string) (string, error) { + config.KeyboardLayout = KeyboardLayout + if err := SaveConfig(); err != nil { + return config.KeyboardLayout, fmt.Errorf("failed to save config: %w", err) + } + return KeyboardLayout, nil +} + var streamFactor = 1.0 func rpcGetStreamQualityFactor() (float64, error) { @@ -447,6 +459,8 @@ var rpcHandlers = map[string]*jsonrpc.RPCHandler{ "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, "getJigglerState": {Func: rpcGetJigglerState}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, + "getKeyboardLayout": {Func: rpcGetKeyboardLayout}, + "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, "getAutoUpdateState": {Func: rpcGetAutoUpdateState}, diff --git a/ui/src/components/InfoBar.tsx b/ui/src/components/InfoBar.tsx index be940434..4490afe9 100644 --- a/ui/src/components/InfoBar.tsx +++ b/ui/src/components/InfoBar.tsx @@ -6,10 +6,21 @@ import { useSettingsStore, useVideoStore, } from "@/hooks/stores"; -import { useEffect } from "react"; -import { keys, modifiers } from "@/keyboardMappings"; +import { useEffect, useState } from "react"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; export default function InfoBar() { + const [keys, setKeys] = useState(keyboardMappingsStore.keys); + const [modifiers, setModifiers] = useState(keyboardMappingsStore.modifiers); + + useEffect(() => { + const unsubscribeKeyboardStore = keyboardMappingsStore.subscribe(() => { + setKeys(keyboardMappingsStore.keys); + setModifiers(keyboardMappingsStore.modifiers); + }); + return unsubscribeKeyboardStore; // Cleanup on unmount + }, []); + const activeKeys = useHidStore(state => state.activeKeys); const activeModifiers = useHidStore(state => state.activeModifiers); const mouseX = useMouseStore(state => state.mouseX); diff --git a/ui/src/components/VirtualKeyboard.tsx b/ui/src/components/VirtualKeyboard.tsx index e3858c00..f056c11c 100644 --- a/ui/src/components/VirtualKeyboard.tsx +++ b/ui/src/components/VirtualKeyboard.tsx @@ -7,7 +7,8 @@ import "react-simple-keyboard/build/css/index.css"; import { useHidStore, useUiStore } from "@/hooks/stores"; import { Transition } from "@headlessui/react"; import { cx } from "@/cva.config"; -import { keys, modifiers } from "@/keyboardMappings"; +//import { keys, modifiers } from "@/keyboardMappings/KeyboardMappingStore"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; import useKeyboard from "@/hooks/useKeyboard"; import DetachIconRaw from "@/assets/detach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg"; @@ -21,6 +22,17 @@ const AttachIcon = ({ className }: { className?: string }) => { }; function KeyboardWrapper() { + const [keys, setKeys] = useState(keyboardMappingsStore.keys); + const [modifiers, setModifiers] = useState(keyboardMappingsStore.modifiers); + + useEffect(() => { + const unsubscribeKeyboardStore = keyboardMappingsStore.subscribe(() => { + setKeys(keyboardMappingsStore.keys); + setModifiers(keyboardMappingsStore.modifiers); + }); + return unsubscribeKeyboardStore; // Cleanup on unmount + }, []); + const [layoutName, setLayoutName] = useState("default"); const keyboardRef = useRef(null); diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index 1e5699cd..5d7222c9 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -7,7 +7,7 @@ import { useUiStore, useVideoStore, } from "@/hooks/stores"; -import { keys, modifiers } from "@/keyboardMappings"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; import { useResizeObserver } from "@/hooks/useResizeObserver"; import { cx } from "@/cva.config"; import VirtualKeyboard from "@components/VirtualKeyboard"; @@ -18,6 +18,17 @@ import { useJsonRpc } from "@/hooks/useJsonRpc"; import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay"; export default function WebRTCVideo() { + const [keys, setKeys] = useState(keyboardMappingsStore.keys); + const [modifiers, setModifiers] = useState(keyboardMappingsStore.modifiers); + + useEffect(() => { + const unsubscribeKeyboardStore = keyboardMappingsStore.subscribe(() => { + setKeys(keyboardMappingsStore.keys); + setModifiers(keyboardMappingsStore.modifiers); + }); + return unsubscribeKeyboardStore; // Cleanup on unmount + }, []); + // Video and stream related refs and states const videoElm = useRef(null); const mediaStream = useRTCStore(state => state.mediaStream); diff --git a/ui/src/components/popovers/PasteModal.tsx b/ui/src/components/popovers/PasteModal.tsx index 661c48dd..6b3878f5 100644 --- a/ui/src/components/popovers/PasteModal.tsx +++ b/ui/src/components/popovers/PasteModal.tsx @@ -9,13 +9,26 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { LuCornerDownLeft } from "react-icons/lu"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { useClose } from "@headlessui/react"; -import { chars, keys, modifiers } from "@/keyboardMappings"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; const hidKeyboardPayload = (keys: number[], modifier: number) => { return { keys, modifier }; }; export default function PasteModal() { + const [keys, setKeys] = useState(keyboardMappingsStore.keys); + const [chars, setChars] = useState(keyboardMappingsStore.chars); + const [modifiers, setModifiers] = useState(keyboardMappingsStore.modifiers); + + useEffect(() => { + const unsubscribeKeyboardStore = keyboardMappingsStore.subscribe(() => { + setKeys(keyboardMappingsStore.keys); + setChars(keyboardMappingsStore.chars); + setModifiers(keyboardMappingsStore.modifiers); + }); + return unsubscribeKeyboardStore; // Cleanup on unmount + }, []); + const TextAreaRef = useRef(null); const setPasteMode = useHidStore(state => state.setPasteModeEnabled); const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap); @@ -41,13 +54,18 @@ export default function PasteModal() { try { for (const char of text) { - const { key, shift } = chars[char] ?? {}; + const { key, shift, alt } = chars[char] ?? {}; if (!key) continue; + // Build the modifier bitmask + const modifier = + (shift ? modifiers["ShiftLeft"] : 0) | + (alt ? modifiers["AltLeft"] : 0); + await new Promise((resolve, reject) => { send( "keyboardReport", - hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0), + hidKeyboardPayload([keys[key]], modifier), params => { if ("error" in params) return reject(params.error); send("keyboardReport", hidKeyboardPayload([], 0), params => { diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index f0ada91b..fd0a0f5b 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -28,6 +28,8 @@ import { useRevalidator } from "react-router-dom"; import { ShieldCheckIcon } from "@heroicons/react/20/solid"; import PluginList from "@components/PluginList"; import USBConfigDialog from "@components/USBConfigDialog"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; +import { KeyboardLayout } from "@/keyboardMappings/KeyboardLayouts"; export function SettingsItem({ title, @@ -80,6 +82,7 @@ export default function SettingsSidebar() { const setSidebarView = useUiStore(state => state.setSidebarView); const settings = useSettingsStore(); const [send] = useJsonRpc(); + const [keyboardLayout, setKeyboardLayout] = useState("us"); const [streamQuality, setStreamQuality] = useState("1"); const [autoUpdate, setAutoUpdate] = useState(true); const [devChannel, setDevChannel] = useState(false); @@ -150,6 +153,20 @@ export default function SettingsSidebar() { }); }; + const handleKeyboardLayoutChange = (keyboardLayout: string) => { + send("setKeyboardLayout", { kbLayout: keyboardLayout }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set keyboard layout: ${resp.error.data || "Unknown error"}`, + ); + return; + } + // TODO set this to update to the actual layout chosen + keyboardMappingsStore.setLayout(KeyboardLayout.UKApple) + setKeyboardLayout(keyboardLayout); + }); + }; + const handleStreamQualityChange = (factor: string) => { send("setStreamQualityFactor", { factor: Number(factor) }, resp => { if ("error" in resp) { @@ -300,6 +317,11 @@ export default function SettingsSidebar() { setDevChannel(resp.result as boolean); }); + send("getKeyboardLayout", {}, resp => { + if ("error" in resp) return; + setKeyboardLayout(String(resp.result)); + }); + send("getStreamQualityFactor", {}, resp => { if ("error" in resp) return; setStreamQuality(String(resp.result)); @@ -556,6 +578,33 @@ export default function SettingsSidebar() {
    +
    + +
    + + handleKeyboardLayoutChange(e.target.value)} + /> + +
    +
    +
    ; - -export const chars = { - A: { key: "KeyA", shift: true }, - B: { key: "KeyB", shift: true }, - C: { key: "KeyC", shift: true }, - D: { key: "KeyD", shift: true }, - E: { key: "KeyE", shift: true }, - F: { key: "KeyF", shift: true }, - G: { key: "KeyG", shift: true }, - H: { key: "KeyH", shift: true }, - I: { key: "KeyI", shift: true }, - J: { key: "KeyJ", shift: true }, - K: { key: "KeyK", shift: true }, - L: { key: "KeyL", shift: true }, - M: { key: "KeyM", shift: true }, - N: { key: "KeyN", shift: true }, - O: { key: "KeyO", shift: true }, - P: { key: "KeyP", shift: true }, - Q: { key: "KeyQ", shift: true }, - R: { key: "KeyR", shift: true }, - S: { key: "KeyS", shift: true }, - T: { key: "KeyT", shift: true }, - U: { key: "KeyU", shift: true }, - V: { key: "KeyV", shift: true }, - W: { key: "KeyW", shift: true }, - X: { key: "KeyX", shift: true }, - Y: { key: "KeyY", shift: true }, - Z: { key: "KeyZ", shift: true }, - a: { key: "KeyA", shift: false }, - b: { key: "KeyB", shift: false }, - c: { key: "KeyC", shift: false }, - d: { key: "KeyD", shift: false }, - e: { key: "KeyE", shift: false }, - f: { key: "KeyF", shift: false }, - g: { key: "KeyG", shift: false }, - h: { key: "KeyH", shift: false }, - i: { key: "KeyI", shift: false }, - j: { key: "KeyJ", shift: false }, - k: { key: "KeyK", shift: false }, - l: { key: "KeyL", shift: false }, - m: { key: "KeyM", shift: false }, - n: { key: "KeyN", shift: false }, - o: { key: "KeyO", shift: false }, - p: { key: "KeyP", shift: false }, - q: { key: "KeyQ", shift: false }, - r: { key: "KeyR", shift: false }, - s: { key: "KeyS", shift: false }, - t: { key: "KeyT", shift: false }, - u: { key: "KeyU", shift: false }, - v: { key: "KeyV", shift: false }, - w: { key: "KeyW", shift: false }, - x: { key: "KeyX", shift: false }, - y: { key: "KeyY", shift: false }, - z: { key: "KeyZ", shift: false }, - 1: { key: "Digit1", shift: false }, - "!": { key: "Digit1", shift: true }, - 2: { key: "Digit2", shift: false }, - "@": { key: "Digit2", shift: true }, - 3: { key: "Digit3", shift: false }, - "#": { key: "Digit3", shift: true }, - 4: { key: "Digit4", shift: false }, - $: { key: "Digit4", shift: true }, - "%": { key: "Digit5", shift: true }, - 5: { key: "Digit5", shift: false }, - "^": { key: "Digit6", shift: true }, - 6: { key: "Digit6", shift: false }, - "&": { key: "Digit7", shift: true }, - 7: { key: "Digit7", shift: false }, - "*": { key: "Digit8", shift: true }, - 8: { key: "Digit8", shift: false }, - "(": { key: "Digit9", shift: true }, - 9: { key: "Digit9", shift: false }, - ")": { key: "Digit0", shift: true }, - 0: { key: "Digit0", shift: false }, - "-": { key: "Minus", shift: false }, - _: { key: "Minus", shift: true }, - "=": { key: "Equal", shift: false }, - "+": { key: "Equal", shift: true }, - "'": { key: "Quote", shift: false }, - '"': { key: "Quote", shift: true }, - ",": { key: "Comma", shift: false }, - "<": { key: "Comma", shift: true }, - "/": { key: "Slash", shift: false }, - "?": { key: "Slash", shift: true }, - ".": { key: "Period", shift: false }, - ">": { key: "Period", shift: true }, - ";": { key: "Semicolon", shift: false }, - ":": { key: "Semicolon", shift: true }, - "[": { key: "BracketLeft", shift: false }, - "{": { key: "BracketLeft", shift: true }, - "]": { key: "BracketRight", shift: false }, - "}": { key: "BracketRight", shift: true }, - "\\": { key: "Backslash", shift: false }, - "|": { key: "Backslash", shift: true }, - "`": { key: "Backquote", shift: false }, - "~": { key: "Backquote", shift: true }, - "§": { key: "IntlBackslash", shift: false }, - "±": { key: "IntlBackslash", shift: true }, - " ": { key: "Space", shift: false }, - "\n": { key: "Enter", shift: false }, - Enter: { key: "Enter", shift: false }, - Tab: { key: "Tab", shift: false }, -} as Record; - -export const modifiers = { - ControlLeft: 0x01, - ControlRight: 0x10, - ShiftLeft: 0x02, - ShiftRight: 0x20, - AltLeft: 0x04, - AltRight: 0x40, - MetaLeft: 0x08, - MetaRight: 0x80, -} as Record; diff --git a/ui/src/keyboardMappings/KeyboardLayouts.ts b/ui/src/keyboardMappings/KeyboardLayouts.ts new file mode 100644 index 00000000..baadeab4 --- /dev/null +++ b/ui/src/keyboardMappings/KeyboardLayouts.ts @@ -0,0 +1,25 @@ +import {keysUKApple, charsUKApple, modifiersUKApple } from './layouts/uk_apple'; +import {keysUS, charsUS, modifiersUS } from './layouts/us'; + +export enum KeyboardLayout { + US = "us", + UKApple = "uk_apple", + } + +export function getKeyboardMappings(layout: KeyboardLayout) { + switch (layout) { + case KeyboardLayout.UKApple: + return { + keys: keysUKApple, + chars: charsUKApple, + modifiers: modifiersUKApple, + }; + case KeyboardLayout.US: + default: + return { + keys: keysUS, + chars: charsUS, + modifiers: modifiersUS, + }; + } + } \ No newline at end of file diff --git a/ui/src/keyboardMappings/KeyboardMappingStore.ts b/ui/src/keyboardMappings/KeyboardMappingStore.ts new file mode 100644 index 00000000..2d41bc1f --- /dev/null +++ b/ui/src/keyboardMappings/KeyboardMappingStore.ts @@ -0,0 +1,39 @@ +import { getKeyboardMappings, KeyboardLayout } from "@/keyboardMappings/KeyboardLayouts"; + +// TODO Move this in with all the other stores? + +class KeyboardMappingsStore { + private _layout: KeyboardLayout = KeyboardLayout.US; + private _subscribers: (() => void)[] = []; + + public keys = getKeyboardMappings(this._layout).keys; + public chars = getKeyboardMappings(this._layout).chars; + public modifiers = getKeyboardMappings(this._layout).modifiers; + + setLayout(newLayout: KeyboardLayout) { + if (this._layout === newLayout) return; + this._layout = newLayout; + const updatedMappings = getKeyboardMappings(newLayout); + this.keys = updatedMappings.keys; + this.chars = updatedMappings.chars; + this.modifiers = updatedMappings.modifiers; + this._notifySubscribers(); + } + + getLayout() { + return this._layout; + } + + subscribe(callback: () => void) { + this._subscribers.push(callback); + return () => { + this._subscribers = this._subscribers.filter(sub => sub !== callback); // Cleanup + }; + } + + private _notifySubscribers() { + this._subscribers.forEach(callback => callback()); + } +} + +export const keyboardMappingsStore = new KeyboardMappingsStore(); \ No newline at end of file diff --git a/ui/src/keyboardMappings/layouts/uk_apple.ts b/ui/src/keyboardMappings/layouts/uk_apple.ts new file mode 100644 index 00000000..b9107ea6 --- /dev/null +++ b/ui/src/keyboardMappings/layouts/uk_apple.ts @@ -0,0 +1,24 @@ +import { charsUS, keysUS, modifiersUS } from "./us"; + +// Extend US Keys with UK Apple-specific changes +export const keysUKApple = { + ...keysUS, +} as Record; + +// Extend US Chars with UK Apple-specific changes +export const charsUKApple = { + ...charsUS, + "`": { key: "Backquote", shift: false }, + "~": { key: "Backquote", shift: true }, + "\\" : { key: "Backslash", shift: false }, + "|": { key: "Backslash", shift: true }, + "#": { key: "Digit3", shift: false, alt: true }, + "£": { key: "Digit3", shift: true }, + "@": { key: "Digit2", shift: true }, + "\"": { key: "Quote", shift: true }, +} as Record; + +// Modifiers are typically the same between UK and US layouts +export const modifiersUKApple = { + ...modifiersUS, +}; \ No newline at end of file diff --git a/ui/src/keyboardMappings/layouts/us.ts b/ui/src/keyboardMappings/layouts/us.ts new file mode 100644 index 00000000..4b75b778 --- /dev/null +++ b/ui/src/keyboardMappings/layouts/us.ts @@ -0,0 +1,215 @@ +export const keysUS = { + AltLeft: 0xe2, + AltRight: 0xe6, + ArrowDown: 0x51, + ArrowLeft: 0x50, + ArrowRight: 0x4f, + ArrowUp: 0x52, + Backquote: 0x35, + Backslash: 0x31, + Backspace: 0x2a, + BracketLeft: 0x2f, + BracketRight: 0x30, + CapsLock: 0x39, + Comma: 0x36, + ContextMenu: 0, + Delete: 0x4c, + Digit0: 0x27, + Digit1: 0x1e, + Digit2: 0x1f, + Digit3: 0x20, + Digit4: 0x21, + Digit5: 0x22, + Digit6: 0x23, + Digit7: 0x24, + Digit8: 0x25, + Digit9: 0x26, + End: 0x4d, + Enter: 0x28, + Equal: 0x2e, + Escape: 0x29, + F1: 0x3a, + F2: 0x3b, + F3: 0x3c, + F4: 0x3d, + F5: 0x3e, + F6: 0x3f, + F7: 0x40, + F8: 0x41, + F9: 0x42, + F10: 0x43, + F11: 0x44, + F12: 0x45, + F13: 0x68, + Home: 0x4a, + Insert: 0x49, + IntlBackslash: 0x31, + KeyA: 0x04, + KeyB: 0x05, + KeyC: 0x06, + KeyD: 0x07, + KeyE: 0x08, + KeyF: 0x09, + KeyG: 0x0a, + KeyH: 0x0b, + KeyI: 0x0c, + KeyJ: 0x0d, + KeyK: 0x0e, + KeyL: 0x0f, + KeyM: 0x10, + KeyN: 0x11, + KeyO: 0x12, + KeyP: 0x13, + KeyQ: 0x14, + KeyR: 0x15, + KeyS: 0x16, + KeyT: 0x17, + KeyU: 0x18, + KeyV: 0x19, + KeyW: 0x1a, + KeyX: 0x1b, + KeyY: 0x1c, + KeyZ: 0x1d, + KeypadExclamation: 0xcf, + Minus: 0x2d, + NumLock: 0x53, + Numpad0: 0x62, + Numpad1: 0x59, + Numpad2: 0x5a, + Numpad3: 0x5b, + Numpad4: 0x5c, + Numpad5: 0x5d, + Numpad6: 0x5e, + Numpad7: 0x5f, + Numpad8: 0x60, + Numpad9: 0x61, + NumpadAdd: 0x57, + NumpadDivide: 0x54, + NumpadEnter: 0x58, + NumpadMultiply: 0x55, + NumpadSubtract: 0x56, + NumpadDecimal: 0x63, + PageDown: 0x4e, + PageUp: 0x4b, + Period: 0x37, + Quote: 0x34, + Semicolon: 0x33, + Slash: 0x38, + Space: 0x2c, + Tab: 0x2b, +} as Record; + +export const charsUS = { + A: { key: "KeyA", shift: true }, + B: { key: "KeyB", shift: true }, + C: { key: "KeyC", shift: true }, + D: { key: "KeyD", shift: true }, + E: { key: "KeyE", shift: true }, + F: { key: "KeyF", shift: true }, + G: { key: "KeyG", shift: true }, + H: { key: "KeyH", shift: true }, + I: { key: "KeyI", shift: true }, + J: { key: "KeyJ", shift: true }, + K: { key: "KeyK", shift: true }, + L: { key: "KeyL", shift: true }, + M: { key: "KeyM", shift: true }, + N: { key: "KeyN", shift: true }, + O: { key: "KeyO", shift: true }, + P: { key: "KeyP", shift: true }, + Q: { key: "KeyQ", shift: true }, + R: { key: "KeyR", shift: true }, + S: { key: "KeyS", shift: true }, + T: { key: "KeyT", shift: true }, + U: { key: "KeyU", shift: true }, + V: { key: "KeyV", shift: true }, + W: { key: "KeyW", shift: true }, + X: { key: "KeyX", shift: true }, + Y: { key: "KeyY", shift: true }, + Z: { key: "KeyZ", shift: true }, + a: { key: "KeyA", shift: false }, + b: { key: "KeyB", shift: false }, + c: { key: "KeyC", shift: false }, + d: { key: "KeyD", shift: false }, + e: { key: "KeyE", shift: false }, + f: { key: "KeyF", shift: false }, + g: { key: "KeyG", shift: false }, + h: { key: "KeyH", shift: false }, + i: { key: "KeyI", shift: false }, + j: { key: "KeyJ", shift: false }, + k: { key: "KeyK", shift: false }, + l: { key: "KeyL", shift: false }, + m: { key: "KeyM", shift: false }, + n: { key: "KeyN", shift: false }, + o: { key: "KeyO", shift: false }, + p: { key: "KeyP", shift: false }, + q: { key: "KeyQ", shift: false }, + r: { key: "KeyR", shift: false }, + s: { key: "KeyS", shift: false }, + t: { key: "KeyT", shift: false }, + u: { key: "KeyU", shift: false }, + v: { key: "KeyV", shift: false }, + w: { key: "KeyW", shift: false }, + x: { key: "KeyX", shift: false }, + y: { key: "KeyY", shift: false }, + z: { key: "KeyZ", shift: false }, + 1: { key: "Digit1", shift: false }, + "!": { key: "Digit1", shift: true }, + 2: { key: "Digit2", shift: false }, + "@": { key: "Digit2", shift: true }, + 3: { key: "Digit3", shift: false }, + "#": { key: "Digit3", shift: true }, + 4: { key: "Digit4", shift: false }, + $: { key: "Digit4", shift: true }, + "%": { key: "Digit5", shift: true }, + 5: { key: "Digit5", shift: false }, + "^": { key: "Digit6", shift: true }, + 6: { key: "Digit6", shift: false }, + "&": { key: "Digit7", shift: true }, + 7: { key: "Digit7", shift: false }, + "*": { key: "Digit8", shift: true }, + 8: { key: "Digit8", shift: false }, + "(": { key: "Digit9", shift: true }, + 9: { key: "Digit9", shift: false }, + ")": { key: "Digit0", shift: true }, + 0: { key: "Digit0", shift: false }, + "-": { key: "Minus", shift: false }, + _: { key: "Minus", shift: true }, + "=": { key: "Equal", shift: false }, + "+": { key: "Equal", shift: true }, + "'": { key: "Quote", shift: false }, + '"': { key: "Quote", shift: true }, + ",": { key: "Comma", shift: false }, + "<": { key: "Comma", shift: true }, + "/": { key: "Slash", shift: false }, + "?": { key: "Slash", shift: true }, + ".": { key: "Period", shift: false }, + ">": { key: "Period", shift: true }, + ";": { key: "Semicolon", shift: false }, + ":": { key: "Semicolon", shift: true }, + "[": { key: "BracketLeft", shift: false }, + "{": { key: "BracketLeft", shift: true }, + "]": { key: "BracketRight", shift: false }, + "}": { key: "BracketRight", shift: true }, + "\\": { key: "Backslash", shift: false }, + "|": { key: "Backslash", shift: true }, + "`": { key: "Backquote", shift: false }, + "~": { key: "Backquote", shift: true }, + "§": { key: "IntlBackslash", shift: false }, + "±": { key: "IntlBackslash", shift: true }, + " ": { key: "Space", shift: false }, + "\n": { key: "Enter", shift: false }, + Enter: { key: "Enter", shift: false }, + Tab: { key: "Tab", shift: false }, +} as Record; + +export const modifiersUS = { + ControlLeft: 0x01, + ControlRight: 0x10, + ShiftLeft: 0x02, + ShiftRight: 0x20, + AltLeft: 0x04, + AltRight: 0x40, + MetaLeft: 0x08, + MetaRight: 0x80, +} as Record; + \ No newline at end of file From ff338933802f2e3473fd2f546fa175edc6af68fc Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 21:05:04 +0000 Subject: [PATCH 73/83] NEXT-ONLY: fix ctrlaltdel PR to work with custom keymaps Alters ActionBar.tsx to make the CTRL+ALT+DEL button in the Action Bar PR work again with layered with jetkvm/kvm#116 - not pulling into the PR until #116 has been integrated, but the my PR will probably just be dropped anyway. --- ui/src/components/ActionBar.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/src/components/ActionBar.tsx b/ui/src/components/ActionBar.tsx index 32845865..ad05487d 100644 --- a/ui/src/components/ActionBar.tsx +++ b/ui/src/components/ActionBar.tsx @@ -18,7 +18,7 @@ import MountPopopover from "./popovers/MountPopover"; import { Fragment, useCallback, useRef } from "react"; import { CommandLineIcon } from "@heroicons/react/20/solid"; import useKeyboard from "@/hooks/useKeyboard"; -import { keys, modifiers } from "@/keyboardMappings"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; export default function Actionbar({ requestFullscreen, @@ -218,8 +218,8 @@ export default function Actionbar({ LeadingIcon={FaLock} onClick={() => { sendKeyboardEvent( - [keys["Delete"]], - [modifiers["ControlLeft"], modifiers["AltLeft"]], + [keyboardMappingsStore.keys["Delete"]], + [keyboardMappingsStore.modifiers["ControlLeft"], keyboardMappingsStore.modifiers["AltLeft"]], ); setTimeout(resetKeyboardState, 100); }} From 8c84441533a6500c44bf9d907fb0d8c657e67b6a Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 21:10:14 +0000 Subject: [PATCH 74/83] NEXT-ONLY: Update README to introduce #116 --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 54c3f1e8..8d374a2a 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - antonym - [Update ISO Versions](https://github.com/jetkvm/kvm/pull/78) - tutman - [Fix fullscreen video absolute position](https://github.com/jetkvm/kvm/pull/85) - jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls) +- williamjohnstone - [Multiple Keyboard Layouts](https://github.com/jetkvm/kvm/pull/116) ## next-multisession As requested by a few in the [JetKVM Discord](https://jetkvm.com/discord), this tree also includes a branch that enables From b2dac6e087bcc1b4ae265051061ce5108509f542 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 21:20:48 +0000 Subject: [PATCH 75/83] feat: add reboot button --- jsonrpc.go | 7 ++++++ ui/src/components/sidebar/settings.tsx | 30 +++++++++++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/jsonrpc.go b/jsonrpc.go index 8456b550..caf8b8af 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -11,6 +11,7 @@ import ( "os" "os/exec" "path/filepath" + "syscall" "github.com/pion/webrtc/v4" ) @@ -443,6 +444,11 @@ func rpcResetConfig() error { return nil } +func rpcRebootDevice() { + syscall.Sync() + syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART) +} + // TODO: replace this crap with code generator var rpcHandlers = map[string]*jsonrpc.RPCHandler{ "ping": {Func: rpcPing}, @@ -502,4 +508,5 @@ var rpcHandlers = map[string]*jsonrpc.RPCHandler{ "pluginUninstall": {Func: plugin.RpcPluginUninstall, Params: []string{"name"}}, "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, "getBacklightSettings": {Func: rpcGetBacklightSettings}, + "rebootDevice": {Func: rpcRebootDevice}, } diff --git a/ui/src/components/sidebar/settings.tsx b/ui/src/components/sidebar/settings.tsx index fd0a0f5b..b3f17adb 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -459,6 +459,19 @@ export default function SettingsSidebar() { }); }, [send]); + + const handleRebootDevice = useCallback(() => { + send("rebootDevice", {}, resp => { + if ("error" in resp) { + notifications.error( + `Failed to reboot device: ${resp.error.data || "Unknown error"}`, + ); + return; + } + notifications.success("Device is rebooting..."); + }); + }, [send]); + return (
    )} -
    + {settings.debugMode && ( + +
    Date: Thu, 30 Jan 2025 21:30:32 +0000 Subject: [PATCH 76/83] NEXT-ONLY: Introduce reboot button to README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8d374a2a..b29e2812 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - tutman - [Fix fullscreen video absolute position](https://github.com/jetkvm/kvm/pull/85) - jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls) - williamjohnstone - [Multiple Keyboard Layouts](https://github.com/jetkvm/kvm/pull/116) +- Nevexo - Add Reboot Button (No PR for this as it's not final) ## next-multisession As requested by a few in the [JetKVM Discord](https://jetkvm.com/discord), this tree also includes a branch that enables From c5d8c265e5deb8e46ab46e28f97a8f10717f1d75 Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Sun, 19 Jan 2025 23:12:24 +0000 Subject: [PATCH 77/83] Rename JSONRPCServer to JSONRPCRouter --- internal/jsonrpc/{rpc_server.go => router.go} | 18 ++++++++---------- jsonrpc.go | 4 ++-- webrtc.go | 9 +-------- 3 files changed, 11 insertions(+), 20 deletions(-) rename internal/jsonrpc/{rpc_server.go => router.go} (93%) diff --git a/internal/jsonrpc/rpc_server.go b/internal/jsonrpc/router.go similarity index 93% rename from internal/jsonrpc/rpc_server.go rename to internal/jsonrpc/router.go index 5f8e870a..fda698bf 100644 --- a/internal/jsonrpc/rpc_server.go +++ b/internal/jsonrpc/router.go @@ -12,7 +12,7 @@ import ( "time" ) -type JSONRPCServer struct { +type JSONRPCRouter struct { writer io.Writer handlers map[string]*RPCHandler @@ -22,16 +22,14 @@ type JSONRPCServer struct { responseChannels map[int64]chan JSONRPCResponse } -func NewJSONRPCServer(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCServer { - return &JSONRPCServer{ - writer: writer, - handlers: handlers, - responseChannels: make(map[int64]chan JSONRPCResponse), - nextId: atomic.Int64{}, +func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCRouter { + return &JSONRPCRouter{ + writer: writer, + handlers: handlers, } } -func (s *JSONRPCServer) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError { +func (s *JSONRPCRouter) Request(method string, params map[string]interface{}, result interface{}) *JSONRPCResponseError { id := s.nextId.Add(1) request := JSONRPCRequest{ JSONRPC: "2.0", @@ -108,7 +106,7 @@ type JSONRPCMessage struct { ID *int64 `json:"id,omitempty"` } -func (s *JSONRPCServer) HandleMessage(data []byte) error { +func (s *JSONRPCRouter) HandleMessage(data []byte) error { // Data will either be a JSONRPCRequest or JSONRPCResponse object // We need to determine which one it is var raw JSONRPCMessage @@ -193,7 +191,7 @@ func (s *JSONRPCServer) HandleMessage(data []byte) error { return s.writeResponse(response) } -func (s *JSONRPCServer) writeResponse(response JSONRPCResponse) error { +func (s *JSONRPCRouter) writeResponse(response JSONRPCResponse) error { responseBytes, err := json.Marshal(response) if err != nil { return err diff --git a/jsonrpc.go b/jsonrpc.go index caf8b8af..3e4d1291 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -41,8 +41,8 @@ func (w *DataChannelWriter) Write(data []byte) (int, error) { return len(data), nil } -func NewDataChannelJsonRpcServer(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCServer { - return jsonrpc.NewJSONRPCServer( +func NewDataChannelJsonRpcRouter(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCRouter { + return jsonrpc.NewJSONRPCRouter( NewDataChannelWriter(dataChannel), rpcHandlers, ) diff --git a/webrtc.go b/webrtc.go index f8f8a5cb..2cbed4ca 100644 --- a/webrtc.go +++ b/webrtc.go @@ -102,14 +102,7 @@ func newSession(config SessionConfig) (*Session, error) { switch d.Label() { case "rpc": session.RPCChannel = d - fmt.Println("starting rpc server") - rpcServer := NewDataChannelJsonRpcServer(d) - d.OnError(func(err error) { - fmt.Println("rpc error", err) - }) - d.OnClose(func() { - fmt.Println("rpc closed") - }) + rpcServer := NewDataChannelJsonRpcRouter(d) d.OnMessage(func(msg webrtc.DataChannelMessage) { go rpcServer.HandleMessage(msg.Data) }) From c43a3648dcfc01fbbfa5b922ba3eea3ebfaa98aa Mon Sep 17 00:00:00 2001 From: tutman96 <11356668+tutman96@users.noreply.github.com> Date: Thu, 30 Jan 2025 21:23:39 +0000 Subject: [PATCH 78/83] Fix jsonrpc references --- internal/jsonrpc/router.go | 2 ++ internal/plugin/rpc.go | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/jsonrpc/router.go b/internal/jsonrpc/router.go index fda698bf..0534432c 100644 --- a/internal/jsonrpc/router.go +++ b/internal/jsonrpc/router.go @@ -26,6 +26,8 @@ func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRP return &JSONRPCRouter{ writer: writer, handlers: handlers, + + responseChannels: make(map[int64]chan JSONRPCResponse), } } diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go index ff31472a..dacb1d89 100644 --- a/internal/plugin/rpc.go +++ b/internal/plugin/rpc.go @@ -96,7 +96,7 @@ func (s *PluginRpcServer) SocketPath() string { } func (s *PluginRpcServer) handleConnection(conn net.Conn) { - rpcserver := jsonrpc.NewJSONRPCServer(conn, map[string]*jsonrpc.RPCHandler{}) + rpcserver := jsonrpc.NewJSONRPCRouter(conn, map[string]*jsonrpc.RPCHandler{}) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -129,7 +129,7 @@ func (s *PluginRpcServer) handleConnection(conn net.Conn) { } } -func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCServer) { +func (s *PluginRpcServer) handleRpcStatus(ctx context.Context, rpcserver *jsonrpc.JSONRPCRouter) { s.status = PluginRpcStatusUnknown log.Printf("Plugin rpc server started. Getting supported methods...") From 595b27be302441070c15bb3ada62e78bf98ad8b8 Mon Sep 17 00:00:00 2001 From: Andrew Nicholson Date: Wed, 29 Jan 2025 13:28:44 +0000 Subject: [PATCH 79/83] Enable "Boot Interface Subclass" for keyboard and mouse. This is often required for the keyboard/mouse to be recognized in BIOS/UEFI firmware. --- usb.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/usb.go b/usb.go index 9125ce21..3ba0ae74 100644 --- a/usb.go +++ b/usb.go @@ -171,7 +171,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid0Path, [][]string{ {"protocol", "1"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "8"}, }) if err != nil { @@ -191,7 +191,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid1Path, [][]string{ {"protocol", "2"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "6"}, }) if err != nil { From 4b0e7eec5738a91b3ba0ae14ce13d519270c3cfa Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Thu, 30 Jan 2025 21:40:38 +0000 Subject: [PATCH 80/83] NEXT-ONLY: Add andnic USB HID fix to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b29e2812..fe52db5a 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,9 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - tutman - [Remove Rounded Corners](https://github.com/jetkvm/kvm/pull/86) - antonym - [Update ISO Versions](https://github.com/jetkvm/kvm/pull/78) - tutman - [Fix fullscreen video absolute position](https://github.com/jetkvm/kvm/pull/85) -- jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls) +- jackislanding - [Allow configuring USB IDs](https://github.com/jetkvm/kvm/pulls/90) - williamjohnstone - [Multiple Keyboard Layouts](https://github.com/jetkvm/kvm/pull/116) +- andnic - [USB HID Fix](https://github.com/jetkvm/kvm/pull/113) - Nevexo - Add Reboot Button (No PR for this as it's not final) ## next-multisession From c3e99a07e2ebac8319af2b09c73593e88e7a8472 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Wed, 5 Feb 2025 10:41:16 +0000 Subject: [PATCH 81/83] NEXT-ONLY: fix next_deploy Signed-off-by: Cameron Fleming --- next_deploy.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/next_deploy.sh b/next_deploy.sh index 136fbcb0..2b573986 100755 --- a/next_deploy.sh +++ b/next_deploy.sh @@ -52,8 +52,8 @@ if [ -z "$REMOTE_HOST" ]; then fi # Check if the binary has been built at ./bin/next/jetkvm_app -if [ ! -f bin/jetkvm_app ]; then - echo "Error: Binary not found at ./bin/jetkvm_app, run make build_next." +if [ ! -f bin/next/jetkvm_app ]; then + echo "Error: Binary not found at ./bin/next/jetkvm_app, run make build_next." exit 1 fi From 0f10abad6a42f880209a5b5a8274165f528f3f32 Mon Sep 17 00:00:00 2001 From: Cameron Fleming Date: Wed, 5 Feb 2025 10:57:19 +0000 Subject: [PATCH 82/83] NEXT-ONLY: instructions Signed-off-by: Cameron Fleming --- README.md | 135 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fe52db5a..04d9749e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # jetkvm-next +> jetkvm-next is not affiliated with, nor supported by, JetKVM or BuildJet. + jetkvm-next is a fork of the JetKVM application with various in-progress features merged in from commnunity pull requests. @@ -7,9 +9,6 @@ This branch isn't meant to be pulled into the upstream, and will almost certainl bleeding-edge build of the software that community members can use to try out new features, or for developers to check their upcoming features don't clash with other in-progress PRs. -> Generally, you shouldn't run jetkvm-next on your device, you should keep it on the main update stream, or optionally -> switch to the beta branch in the device's settings. - Main repo: https://github.com/jetkvm/kvm ## Current Additional Features @@ -33,6 +32,8 @@ compare the commit hash on this branch, to the current hash of the commit(s) in - andnic - [USB HID Fix](https://github.com/jetkvm/kvm/pull/113) - Nevexo - Add Reboot Button (No PR for this as it's not final) +If you're looking to try these features out in jetkvm-next, you should go to the PR and read the authors documentation. + ## next-multisession As requested by a few in the [JetKVM Discord](https://jetkvm.com/discord), this tree also includes a branch that enables support for multiple sessions connecting to the JetKVM. @@ -45,6 +46,128 @@ off the main jetkvm-next branch, and applies changes to the session handling cod next-muiltisession does not include any concept of control authority/mutex, so all users connected will have full control over the target machine, and you'll be fighting for the cursor with the other user. -## Additional Info -There's a GitHub Action setup to build the JetKVM software whenever this repo has a new release added, it'll take -a few minutes for the binary to appear on the release once this repo is tagged. +## Installation +> You should read this section multiple times before even plugging in the JetKVM device. +> If these instructions don't immediately make sense, then it's probably best to avoid installing +> jetkvm-next, it's incredibly bleeding-edge, and could explode in a million different ways. + +**DISCLAIMER:** This is very much beta, canary, unstable, software there could be bugs that cause +damage to your JetKVM hardware, such as wearing out the eMMC, breaking the LCD or overheating. + +**On Windows?:** Install WSL now, it makes life much easier. + +### Prepare the KVM +Boot up your KVM, login, and enable SSH access with your SSH public key. + +Test you can login to the KVM with your SSH key, remember the username is `root`. + +### Build your own binary +While I provide pre-compiled binaries with every [release](https://github.com/nevexo/jetkvm-kvm/releases), you can (and should) build +the binary yourself, this allows you to analyse the code running on your device, and be familiar with the innerworkings of the JetKVM +software stack, before you have to start debugging it. + +#### You will need: +- A JavaScript runtime, such as Node.JS ([Bun](https://bun.sh) works fine, but you'll need to adjust the Makefile) +- Git +- Go (and the various compilers for ARMv7) +- make + +#### Get the code +Make yourself a directory to keep jetkvm-next in, and clone the repo: + +`git clone https://github.com/nevexo/jetkvm-kvm.git` + +(If you're updating, just do `git pull` in the jetkvm-kvm directory.) + +As the next branch is the main development branch of jetkvm-next, it may not build as-is, so you should check-out +one of the tags for the version you want to use. At the time of writing, that's next-7, but if I forget to update +the README (I will) you should check [the releases page](https://github.com/nevexo/jetkvm-kvm/releases) for the latest tags. + +`git checkout next-7` + +If you want multisession, then stick -multisession on the end of the checkout command, but note I usually release multisession +a little bit later than the default. + +Your code will now be in line with the code in the binary released. + +#### Build the code +This will automatically build both the frontend and the jetkvm-next binary. If you don't have the proper ARM compilers +installed for Go, you'll see some errors, simply Google the package that Go says is missing, and the name of your OS, and you'll +be able to find it. (If you're on WSL, search for the distro you're using, not Windows) + +`make build_next` + +#### Deploy the binary +**NOTE:** There's a bug in next_deploy.sh for all versions next-7 and older, so if you're building one of those, you'll need to +run these commands first: + +``` +mkdir -p bin/next +touch bin/jetkvm_app +``` +(the script checks if the normal jetkvm_app binary exists, even though it only needs the one in bin/next, oops!) + +Run the deployment script: +``` +./next_deploy.sh -r [address of kvm] +``` + +After a moment, you should see `Deployment complete!` - skip to the bottom to see how to launch it. + +### Use the provided binary +Again, I highly recommend you get familiar with the innerworkings of the JetKVM stack and build your own binaries. +But, if you can't be bothered with the above: + +#### Get the binary +Simply go to the releases page, and download the latest available image, you can choose the multisession version at this stage, if you wish. + +Pop the binary somewhere that you can get to with your terminal (on WSL, that's probably /mnt/c/Users/[yourname]/Downloads) + +#### Deploy the binary +**NOTE:** The buildroot image on the JetKVM doesn't have support for scp, so this is where it gets interesting. + +Use `cat` to send the contents of the jetkvm_app_next binary over to your KVM. + +`cat jetkvm_app_next | ssh "root@[IP of JetKVM]" "cat > /userdata/jetkvm/bin/jetkvm_app_next"` + +That's it :) + +## Run jetkvm-next +**NOTE:** You need to be somewhat quick at doing this as the kernel watchdog timer will reboot the jetkvm +if the jetkvm_app binary hasn't been running for a while. You can turn that off by running `echo 'V' > /dev/watchdog` + +To run jetkvm-next now, run: +``` +cd /userdata/jetkvm/bin +killall jetkvm_app +killall jetkvm_native +./jetkvm_app_next +``` + +The app will launch, and you can try out the new features! When you reboot the device, it'll return to jetkvm_app. + +### Use jetkvm-next by default +You can rename the jetkvm_app binaries to make the KVM start next by default. + +``` +cd /userdata/jetkvm/bin +killall jetkvm_app +killall jetkvm_native +mv jetkvm_app jetkvm_app_old +mv jetkvm_app_next jetkvm_app +reboot +``` + +Your JetKVM is now running jetkvm-next! + +### Going back to stable +If you followed the above instructions properly, switching back to stable is easy. + +``` +cd /userdata/jetkvm/bin +killall jetkvm_app +mv jetkvm_app jetkvm_app_next +mv jetkvm_app_old jetkvm_app +``` + +If you lost jetkvm_app_old, then [factory reset](https://jetkvm.com/docs/advanced-usage/factory-reset). \ No newline at end of file From 18fa6c696226dc396b9ee44d750268697c93fa60 Mon Sep 17 00:00:00 2001 From: "00o.sh" Date: Thu, 13 Feb 2025 09:37:46 -0600 Subject: [PATCH 83/83] Update xterm dependency to @xterm/xterm and adjust imports accordingly --- ui/package.json | 2 +- ui/src/components/Xterm.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package.json b/ui/package.json index 9a7fae57..7eab1235 100644 --- a/ui/package.json +++ b/ui/package.json @@ -39,7 +39,7 @@ "tailwind-merge": "^2.2.2", "usehooks-ts": "^3.1.0", "validator": "^13.12.0", - "xterm": "^5.3.0", + "@xterm/xterm": "^5.0.0", "zustand": "^4.5.2" }, "devDependencies": { diff --git a/ui/src/components/Xterm.tsx b/ui/src/components/Xterm.tsx index 1a0a008b..7dfbc9fa 100644 --- a/ui/src/components/Xterm.tsx +++ b/ui/src/components/Xterm.tsx @@ -1,12 +1,12 @@ import { useEffect, useLayoutEffect, useRef } from "react"; -import { Terminal } from "xterm"; +import { Terminal } from "@xterm/xterm"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { FitAddon } from "@xterm/addon-fit"; import { ClipboardAddon } from "@xterm/addon-clipboard"; -import "xterm/css/xterm.css"; +import "@xterm/xterm/css/xterm.css"; import { useRTCStore, useUiStore } from "../hooks/stores"; const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");