diff --git a/Makefile b/Makefile index 04c7402a..7c29699a 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,22 @@ +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-$(shell git rev-parse --short HEAD)-$(shell git describe --tags --abbrev=0) 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 +29,11 @@ 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 + +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 release: @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ diff --git a/README.md b/README.md index 1b516d79..04d9749e 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,173 @@ -
- JetKVM logo +# jetkvm-next -### KVM +> jetkvm-next is not affiliated with, nor supported by, JetKVM or BuildJet. -[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs) +jetkvm-next is a fork of the JetKVM application with various in-progress features merged in from commnunity +pull requests. -[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm) +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. -
+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) +- 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) +- 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/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) -- **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. +If you're looking to try these features out in jetkvm-next, you should go to the PR and read the authors documentation. -## Contributing +## 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. -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. +It's a bit of a bodge implementation, but shows the multiple sessions can be handled by the JetKVM. -## I need help +Every release of jetkvm-next includes jetkvm-next-multisession in a pre-release, the jetkvm-next-multisession branch is based +off the main jetkvm-next branch, and applies changes to the session handling code. -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). +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. -## I want to report an issue +## 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. -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. +**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. -# Development +**On Windows?:** Install WSL now, it makes life much easier. -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. +### Prepare the KVM +Boot up your KVM, login, and enable SSH access with your SSH public key. -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. +Test you can login to the KVM with your SSH key, remember the username is `root`. -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. +### 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. -## Backend +#### 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 -The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web. +#### Get the code +Make yourself a directory to keep jetkvm-next in, and clone the repo: -## Frontend +`git clone https://github.com/nevexo/jetkvm-kvm.git` -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. +(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 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/config.go b/config.go index 1636434a..82442ae3 100644 --- a/config.go +++ b/config.go @@ -11,17 +11,32 @@ type WakeOnLanDevice struct { MacAddress string `json:"macAddress"` } +type UsbConfig struct { + VendorId string `json:"vendor_id"` + ProductId string `json:"product_id"` + SerialNumber string `json:"serial_number"` + Manufacturer string `json:"manufacturer"` + Product string `json:"product"` +} + 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"` + KeyboardLayout string `json:"keyboard_layout"` + 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"` + DisplayDimAfterSec int `json:"display_dim_after_sec"` + DisplayOffAfterSec int `json:"display_off_after_sec"` + EdidString string `json:"hdmi_edid_string"` + UsbConfig UsbConfig `json:"usb_config"` + VirtualMediaEnabled bool `json:"virtual_media_enabled"` } const configPath = "/userdata/kvm_config.json" diff --git a/display.go b/display.go index f312eb66..416401b0 100644 --- a/display.go +++ b/display.go @@ -1,12 +1,26 @@ package kvm import ( + "errors" "fmt" "log" + "os" + "strconv" "time" ) var currentScreen = "ui_Boot_Screen" +var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF + +var ( + dimTicker *time.Ticker + offTicker *time.Ticker +) + +const ( + touchscreenDevice string = "/dev/input/event1" + backlightControlClass string = "/sys/class/backlight/backlight/brightness" +) func switchToScreen(screen string) { _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) @@ -65,6 +79,7 @@ func requestDisplayUpdate() { return } go func() { + wakeDisplay(false) fmt.Println("display updating........................") //TODO: only run once regardless how many pending updates updateDisplay() @@ -83,6 +98,156 @@ 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 { + // 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(backlightControlClass); 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(backlightControlClass, bs, 0644) + if err != nil { + return err + } + + fmt.Printf("display: set brightness to %v\n", brightness) + return nil +} + +// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness +// of the display by half of the max brightness. +func tick_displayDim() { + err := setDisplayBrightness(config.DisplayMaxBrightness / 2) + if err != nil { + fmt.Printf("display: failed to dim display: %s\n", err) + } + + dimTicker.Stop() + + backlightState = 1 +} + +// tick_displayOff() is called when the off ticker expires, it turns off the display +// by setting the brightness to zero. +func tick_displayOff() { + err := setDisplayBrightness(0) + if err != nil { + fmt.Printf("display: failed to turn off display: %s\n", err) + } + + offTicker.Stop() + + backlightState = 2 +} + +// 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. +// Set force to true to skip the backlight state check, this should be done if altering the tickers. +func wakeDisplay(force bool) { + if backlightState == 0 && !force { + return + } + + // Don't try to wake up if the display is turned off. + if config.DisplayMaxBrightness == 0 { + return + } + + err := setDisplayBrightness(config.DisplayMaxBrightness) + if err != nil { + fmt.Printf("display wake failed, %s\n", err) + } + + if config.DisplayDimAfterSec != 0 { + dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second) + } + + if config.DisplayOffAfterSec != 0 { + offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second) + } + 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() { + ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666) + if err != nil { + fmt.Printf("display: failed to open touchscreen device: %s\n", err) + return + } + + defer ts.Close() + + // 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) + if err != nil { + fmt.Printf("display: failed to read from touchscreen device: %s\n", err) + return + } + + wakeDisplay(false) + } +} + +// startBacklightTickers starts the two tickers for dimming and switching off the display +// if they're not already set. This is done separately to the init routine as the "never dim" +// option has the value set to zero, but time.NewTicker only accept positive values. +func startBacklightTickers() { + LoadConfig() + // Don't start the tickers if the display is switched off. + // Set the display to off if that's the case. + if config.DisplayMaxBrightness == 0 { + setDisplayBrightness(0) + return + } + + if dimTicker == nil && config.DisplayDimAfterSec != 0 { + fmt.Printf("display: dim_ticker has started\n") + dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) + defer dimTicker.Stop() + + go func() { + for { + select { + case <-dimTicker.C: + tick_displayDim() + } + } + }() + } + + if offTicker == nil && config.DisplayOffAfterSec != 0 { + fmt.Printf("display: off_ticker has started\n") + offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) + defer offTicker.Stop() + + go func() { + for { + select { + case <-offTicker.C: + tick_displayOff() + } + } + }() + } +} + func init() { go func() { waitCtrlClientConnected() @@ -91,6 +256,10 @@ func init() { updateStaticContents() displayInited = true fmt.Println("display inited") + startBacklightTickers() + wakeDisplay(true) requestDisplayUpdate() }() + + go watchTsEvents() } 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/internal/jsonrpc/router.go b/internal/jsonrpc/router.go new file mode 100644 index 00000000..0534432c --- /dev/null +++ b/internal/jsonrpc/router.go @@ -0,0 +1,300 @@ +package jsonrpc + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "reflect" + "sync" + "sync/atomic" + "time" +) + +type JSONRPCRouter struct { + writer io.Writer + + handlers map[string]*RPCHandler + nextId atomic.Int64 + + responseChannelsMutex sync.Mutex + responseChannels map[int64]chan JSONRPCResponse +} + +func NewJSONRPCRouter(writer io.Writer, handlers map[string]*RPCHandler) *JSONRPCRouter { + return &JSONRPCRouter{ + writer: writer, + handlers: handlers, + + responseChannels: make(map[int64]chan JSONRPCResponse), + } +} + +func (s *JSONRPCRouter) 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 *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 + 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) + if err != nil { + errorResponse := JSONRPCResponse{ + JSONRPC: "2.0", + Error: &JSONRPCResponseError{ + 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: &JSONRPCResponseError{ + 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: &JSONRPCResponseError{ + 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 *JSONRPCRouter) 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..ac4f956c --- /dev/null +++ b/internal/jsonrpc/types.go @@ -0,0 +1,32 @@ +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 *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 { + JSONRPC string `json:"jsonrpc"` + Method string `json:"method"` + Params interface{} `json:"params,omitempty"` +} + +type RPCHandler struct { + Func interface{} + Params []string +} diff --git a/internal/plugin/database.go b/internal/plugin/database.go new file mode 100644 index 00000000..6e669dce --- /dev/null +++ b/internal/plugin/database.go @@ -0,0 +1,92 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" + "path" + "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 (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 + ".tmp") + if err != nil { + return fmt.Errorf("failed to create plugin database tmp: %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) + } + + if err := os.Rename(databaseFile+".tmp", databaseFile); err != nil { + return fmt.Errorf("failed to move plugin database to active file: %v", err) + } + + return nil +} + +// Find all extract directories that are not referenced in the Plugins map and remove them +func (d *PluginDatabase) CleanupExtractDirectories() error { + extractDirectories, err := os.ReadDir(pluginsExtractsFolder) + if err != nil { + return fmt.Errorf("failed to read extract directories: %v", err) + } + + for _, extractDir := range extractDirectories { + found := false + for _, pluginInstall := range d.Plugins { + for _, extractedFolder := range pluginInstall.ExtractedVersions { + if extractDir.Name() == extractedFolder { + found = true + break + } + } + if found { + break + } + } + + if !found { + if err := os.RemoveAll(path.Join(pluginsExtractsFolder, extractDir.Name())); err != nil { + return fmt.Errorf("failed to remove extract directory: %v", err) + } + } + } + + return nil +} diff --git a/internal/plugin/extract.go b/internal/plugin/extract.go new file mode 100644 index 00000000..9fd8bb80 --- /dev/null +++ b/internal/plugin/extract.go @@ -0,0 +1,95 @@ +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 "", 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 "", 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 "", fmt.Errorf("failed to create extracts folder: %v", err) + } + + if err := extractTarball(reader, destinationFolder); err != nil { + if err := os.RemoveAll(destinationFolder); err != nil { + return "", fmt.Errorf("failed to remove failed extraction folder: %v", err) + } + + return "", fmt.Errorf("failed to extract tarball: %v", err) + } + + return destinationFolder, nil +} + +func extractTarball(reader io.Reader, destinationFolder string) error { + tarReader := tar.NewReader(reader) + + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return 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 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 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 fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + if _, err := io.Copy(file, tarReader); err != nil { + return fmt.Errorf("failed to extract file: %v", err) + } + default: + return fmt.Errorf("unsupported tar entry type: %v", header.Typeflag) + } + } + + return nil +} diff --git a/internal/plugin/install.go b/internal/plugin/install.go new file mode 100644 index 00000000..dedf3291 --- /dev/null +++ b/internal/plugin/install.go @@ -0,0 +1,165 @@ +package plugin + +import ( + "fmt" + "log" + "os" + "os/exec" + "path" + "syscall" +) + +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 + runningVersion string + processManager *ProcessManager + rpcServer *PluginRpcServer +} + +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] +} + +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 := PluginStatus{ + PluginManifest: *manifest, + Enabled: p.Enabled, + } + + // 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 + + 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 = "error" + status.Message = p.processManager.LastError.Error() + } + } + log.Printf("Status from process manager: %v", status.Status) + } + + return &status, nil +} + +func (p *PluginInstall) ReconcileSubprocess() error { + manifest, err := p.GetManifest() + if err != nil { + return fmt.Errorf("failed to get plugin manifest: %v", err) + } + + versionRunning := p.runningVersion + + versionShouldBeRunning := p.Version + if !p.Enabled { + versionShouldBeRunning = "" + } + + log.Printf("Reconciling plugin %s running %v, should be running %v", manifest.Name, versionRunning, versionShouldBeRunning) + + if versionRunning == versionShouldBeRunning { + log.Printf("Plugin %s is already running version %s", manifest.Name, versionRunning) + return nil + } + + if p.processManager != nil { + log.Printf("Stopping plugin %s running version %s", manifest.Name, versionRunning) + p.processManager.Disable() + p.processManager = nil + p.runningVersion = "" + err = p.rpcServer.Stop() + if err != nil { + return fmt.Errorf("failed to stop rpc server: %v", err) + } + } + + if versionShouldBeRunning == "" { + return nil + } + + workingDir := path.Join(pluginsFolder, "working_dirs", p.manifest.Name) + err = os.MkdirAll(workingDir, 0755) + if err != nil { + return fmt.Errorf("failed to create working directory: %v", err) + } + + p.rpcServer = NewPluginRpcServer(p, workingDir) + err = p.rpcServer.Start() + if err != nil { + return fmt.Errorf("failed to start rpc server: %v", err) + } + + p.processManager = NewProcessManager(func() *exec.Cmd { + cmd := exec.Command(manifest.BinaryPath) + cmd.Dir = p.GetExtractedFolder() + cmd.Env = append(cmd.Env, + "JETKVM_PLUGIN_SOCK="+p.rpcServer.SocketPath(), + "JETKVM_PLUGIN_WORKING_DIR="+workingDir, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + // Ensure that the process is killed when the parent dies + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, + Pdeathsig: syscall.SIGKILL, + } + return cmd + }) + p.processManager.StartMonitor() + p.processManager.Enable() + 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 +} + +func (p *PluginInstall) Shutdown() { + if p.processManager != nil { + p.processManager.Disable() + p.processManager = nil + p.runningVersion = "" + } + + if p.rpcServer != nil { + p.rpcServer.Stop() + } +} diff --git a/internal/plugin/plugin.go b/internal/plugin/plugin.go new file mode 100644 index 00000000..e72acdfb --- /dev/null +++ b/internal/plugin/plugin.go @@ -0,0 +1,257 @@ +package plugin + +import ( + "encoding/json" + "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) + + if err := pluginDatabase.Load(); err != nil { + fmt.Printf("failed to load plugin database: %v\n", err) + } +} + +// Starts all plugins that need to be started +func ReconcilePlugins() { + for _, install := range pluginDatabase.Plugins { + err := install.ReconcileSubprocess() + if err != nil { + fmt.Printf("failed to reconcile subprocess for plugin: %v\n", err) + } + } +} + +func GracefullyShutdownPlugins() { + for _, install := range pluginDatabase.Plugins { + install.Shutdown() + } +} + +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 +} + +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 { + 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) + } + + 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) + } + + err := pluginInstall.ReconcileSubprocess() + if err != nil { + return fmt.Errorf("failed to start plugin %s: %v", name, err) + } + + // TODO: Determine if the old extract should be removed + + return nil +} + +func RpcPluginList() ([]PluginStatus, error) { + plugins := make([]PluginStatus, 0, len(pluginDatabase.Plugins)) + for pluginName, plugin := range pluginDatabase.Plugins { + status, err := plugin.GetStatus() + if err != nil { + return nil, fmt.Errorf("failed to get plugin status for %s: %v", pluginName, err) + } + plugins = append(plugins, *status) + } + return plugins, nil +} + +func RpcPluginUpdateConfig(name string, enabled bool) (*PluginStatus, error) { + pluginInstall, ok := pluginDatabase.Plugins[name] + if !ok { + return nil, fmt.Errorf("plugin not found: %s", name) + } + + pluginInstall.Enabled = enabled + pluginDatabase.Plugins[name] = pluginInstall + + if err := pluginDatabase.Save(); err != nil { + return nil, fmt.Errorf("failed to save plugin database: %v", err) + } + + err := pluginInstall.ReconcileSubprocess() + if err != nil { + return nil, fmt.Errorf("failed to stop plugin %s: %v", name, err) + } + + 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 RpcPluginUninstall(name string) error { + pluginInstall, ok := pluginDatabase.Plugins[name] + if !ok { + return fmt.Errorf("plugin not found: %s", name) + } + + pluginInstall.Enabled = false + + err := pluginInstall.ReconcileSubprocess() + if err != nil { + return fmt.Errorf("failed to stop plugin %s: %v", name, err) + } + + delete(pluginDatabase.Plugins, name) + if err := pluginDatabase.Save(); err != nil { + return fmt.Errorf("failed to save plugin database: %v", err) + } + + err = pluginDatabase.CleanupExtractDirectories() + if err != nil { + return fmt.Errorf("failed to cleanup extract directories: %v", err) + } + + 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/process_manager.go b/internal/plugin/process_manager.go new file mode 100644 index 00000000..9d647d88 --- /dev/null +++ b/internal/plugin/process_manager.go @@ -0,0 +1,119 @@ +package plugin + +import ( + "fmt" + "log" + "os/exec" + "syscall" + "time" +) + +// TODO: this can probably be defaulted to this, but overwritten on a per-plugin basis +const ( + gracefulShutdownDelay = 30 * time.Second + maxRestartBackoff = 30 * time.Second +) + +type ProcessManager struct { + cmdGen func() *exec.Cmd + cmd *exec.Cmd + enabled bool + backoff time.Duration + shutdown chan struct{} + restartCh chan struct{} + LastError error +} + +func NewProcessManager(commandGenerator func() *exec.Cmd) *ProcessManager { + return &ProcessManager{ + cmdGen: commandGenerator, + enabled: true, + backoff: 250 * time.Millisecond, + shutdown: make(chan struct{}), + restartCh: make(chan struct{}, 1), + } +} + +func (pm *ProcessManager) StartMonitor() { + go pm.monitor() +} + +func (pm *ProcessManager) monitor() { + for { + select { + case <-pm.shutdown: + pm.terminate() + return + case <-pm.restartCh: + if pm.enabled { + go pm.runProcess() + } + } + } +} + +func (pm *ProcessManager) runProcess() { + pm.LastError = nil + pm.cmd = pm.cmdGen() + log.Printf("Starting process: %v", pm.cmd) + err := pm.cmd.Start() + if err != nil { + log.Printf("Failed to start process: %v", err) + pm.LastError = fmt.Errorf("failed to start process: %w", err) + pm.scheduleRestart() + return + } + + err = pm.cmd.Wait() + if err != nil { + log.Printf("Process exited: %v", err) + pm.LastError = fmt.Errorf("process exited with error: %w", err) + pm.scheduleRestart() + } +} + +func (pm *ProcessManager) scheduleRestart() { + if pm.enabled { + log.Printf("Restarting process in %v...", pm.backoff) + time.Sleep(pm.backoff) + pm.backoff *= 2 // Exponential backoff + if pm.backoff > maxRestartBackoff { + pm.backoff = maxRestartBackoff + } + pm.restartCh <- struct{}{} + } +} + +func (pm *ProcessManager) terminate() { + if pm.cmd.Process != nil { + log.Printf("Sending SIGTERM...") + pm.cmd.Process.Signal(syscall.SIGTERM) + select { + case <-time.After(gracefulShutdownDelay): + log.Printf("Forcing process termination...") + pm.cmd.Process.Kill() + case <-pm.waitForExit(): + log.Printf("Process exited gracefully.") + } + } +} + +func (pm *ProcessManager) waitForExit() <-chan struct{} { + done := make(chan struct{}) + go func() { + pm.cmd.Wait() + close(done) + }() + return done +} + +func (pm *ProcessManager) Enable() { + pm.enabled = true + pm.restartCh <- struct{}{} +} + +func (pm *ProcessManager) Disable() { + pm.enabled = false + close(pm.shutdown) + pm.cmd.Wait() +} diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go new file mode 100644 index 00000000..dacb1d89 --- /dev/null +++ b/internal/plugin/rpc.go @@ -0,0 +1,174 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "kvm/internal/jsonrpc" + "log" + "net" + "os" + "path" + "slices" + "time" +) + +type PluginRpcStatus struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +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 + + 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.NewJSONRPCRouter(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 { + 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() + } + 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.JSONRPCRouter) { + 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 { + select { + case <-ctx.Done(): + return + case <-ticker.C: + var statusResponse PluginRpcStatus + 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) { + 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 new file mode 100644 index 00000000..de1001a0 --- /dev/null +++ b/internal/plugin/type.go @@ -0,0 +1,18 @@ +package plugin + +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 PluginStatus struct { + PluginManifest + Enabled bool `json:"enabled"` + Status string `json:"status"` + Message string `json:"message,omitempty"` +} 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 2ce5f189..3e4d1291 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -5,50 +5,52 @@ import ( "encoding/json" "errors" "fmt" + "kvm/internal/jsonrpc" + "kvm/internal/plugin" "log" "os" "os/exec" "path/filepath" - "reflect" + "syscall" "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"` +func NewDataChannelWriter(dataChannel *webrtc.DataChannel) *DataChannelWriter { + return &DataChannelWriter{ + dataChannel: dataChannel, + } } -type JSONRPCEvent struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - 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 { - 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 NewDataChannelJsonRpcRouter(dataChannel *webrtc.DataChannel) *jsonrpc.JSONRPCRouter { + return jsonrpc.NewJSONRPCRouter( + 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, @@ -69,60 +71,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 } @@ -131,6 +79,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) { @@ -183,6 +143,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 } @@ -219,6 +185,56 @@ func rpcTryUpdate() error { return nil } +func rpcSetBacklightSettings(params BacklightSettings) error { + LoadConfig() + + blConfig := params + + // NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with. + if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 { + return fmt.Errorf("maxBrightness must be between 0 and 255") + } + + 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.DisplayDimAfterSec = blConfig.DimAfter + config.DisplayOffAfterSec = blConfig.OffAfter + + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + + log.Printf("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec) + + // If the device started up with auto-dim and/or auto-off set to zero, the display init + // method will not have started the tickers. So in case that has changed, attempt to start the tickers now. + startBacklightTickers() + + // Wake the display after the settings are altered, this ensures the tickers + // are reset to the new settings, and will bring the display up to maxBrightness. + // Calling with force set to true, to ignore the current state of the display, and force + // it to reset the tickers. + wakeDisplay(true) + return nil +} + +func rpcGetBacklightSettings() (*BacklightSettings, error) { + LoadConfig() + + return &BacklightSettings{ + MaxBrightness: config.DisplayMaxBrightness, + DimAfter: int(config.DisplayDimAfterSec), + OffAfter: int(config.DisplayOffAfterSec), + }, nil +} + const ( devModeFile = "/userdata/jetkvm/devmode.enable" sshKeyDir = "/userdata/dropbear/.ssh" @@ -315,108 +331,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 @@ -478,6 +392,29 @@ func rpcSetUsbEmulationState(enabled bool) error { } } +func rpcGetUsbConfig() (UsbConfig, error) { + LoadConfig() + return config.UsbConfig, nil +} + +func rpcSetUsbConfig(usbConfig UsbConfig) error { + LoadConfig() + config.UsbConfig = usbConfig + + err := UpdateGadgetConfig() + if err != nil { + return fmt.Errorf("failed to write gadget config: %w", err) + } + + err = SaveConfig() + if err != nil { + return fmt.Errorf("failed to save usb config: %w", err) + } + + log.Printf("[jsonrpc.go:rpcSetUsbConfig] usb config set to %s", usbConfig) + return nil +} + func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { LoadConfig() if config.WakeOnLanDevices == nil { @@ -507,8 +444,13 @@ 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]RPCHandler{ +var rpcHandlers = map[string]*jsonrpc.RPCHandler{ "ping": {Func: rpcPing}, "getDeviceID": {Func: rpcGetDeviceID}, "deregisterDevice": {Func: rpcDeregisterDevice}, @@ -523,6 +465,8 @@ var rpcHandlers = map[string]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}, @@ -542,6 +486,8 @@ var rpcHandlers = map[string]RPCHandler{ "isUpdatePending": {Func: rpcIsUpdatePending}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, + "getUsbConfig": {Func: rpcGetUsbConfig}, + "setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getStorageSpace": {Func: rpcGetStorageSpace}, @@ -554,4 +500,13 @@ 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"}}, + "pluginExtract": {Func: plugin.RpcPluginExtract, Params: []string{"filename"}}, + "pluginInstall": {Func: plugin.RpcPluginInstall, Params: []string{"name", "version"}}, + "pluginList": {Func: plugin.RpcPluginList}, + "pluginUpdateConfig": {Func: plugin.RpcPluginUpdateConfig, Params: []string{"name", "enabled"}}, + "pluginUninstall": {Func: plugin.RpcPluginUninstall, Params: []string{"name"}}, + "setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}}, + "getBacklightSettings": {Func: rpcGetBacklightSettings}, + "rebootDevice": {Func: rpcRebootDevice}, } diff --git a/main.go b/main.go index 7ff771f5..1b02a985 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package kvm import ( "context" + "kvm/internal/plugin" "log" "net/http" "os" @@ -71,10 +72,13 @@ func Main() { if config.CloudToken != "" { go RunWebsocketClient() } + go plugin.ReconcilePlugins() sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs log.Println("JetKVM Shutting Down") + + plugin.GracefullyShutdownPlugins() //if fuseServer != nil { // err := setMassStorageImage(" ") // if err != 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) + } + } +} 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() } diff --git a/next_deploy.sh b/next_deploy.sh new file mode 100755 index 00000000..2b573986 --- /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/next/jetkvm_app ]; then + echo "Error: Binary not found at ./bin/next/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 < state.remoteVirtualMediaState, ); const developerMode = useSettingsStore(state => state.developerMode); + const hdmiState = useVideoStore(state => state.hdmiState); // This is the only way to get a reliable state change for the popover // at time of writing this there is no mount, or unmount event for the popover @@ -52,6 +56,8 @@ export default function Actionbar({ [setDisableFocusTrap], ); + const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); + return (
setVirtualKeyboard(!virtualKeyboard)} />
+ {useSettingsStore().actionBarCtrlAltDel && ( +
+
+ )}
@@ -247,6 +270,7 @@ export default function Actionbar({ size="XS" theme="light" text="Fullscreen" + disabled={hdmiState !== 'ready'} LeadingIcon={LuMaximize} onClick={() => requestFullscreen()} /> 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/MountMediaDialog.tsx b/ui/src/components/MountMediaDialog.tsx index 505d233a..6f7c96b6 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, }, { @@ -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/PluginConfigureDialog.tsx b/ui/src/components/PluginConfigureDialog.tsx new file mode 100644 index 00000000..cc1762e2 --- /dev/null +++ b/ui/src/components/PluginConfigureDialog.tsx @@ -0,0 +1,223 @@ +import { PluginStatus, usePluginStore } from "@/hooks/stores"; +import Modal from "@components/Modal"; +import AutoHeight from "@components/AutoHeight"; +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, + 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); + + const {setIsPluginUploadModalOpen} = usePluginStore(); + + 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) { + setLoading(false); + setError(resp.error.message); + return + } + setOpen(false); + }); + }, [send, plugin, setOpen]) + + const uninstallPlugin = useCallback(() => { + if (!plugin) return; + if (!window.confirm("Are you sure you want to uninstall this plugin? This will not delete any data.")) { + return; + } + + setLoading(true); + send("pluginUninstall", { name: plugin.name }, resp => { + if ("error" in resp) { + setLoading(false); + setError(resp.error.message); + return + } + setOpen(false); + }); + }, [send, plugin, setOpen]) + + const uploadPlugin = useCallback(() => { + setOpen(false); + setIsPluginUploadModalOpen(true); + }, [setIsPluginUploadModalOpen, setOpen]) + + return ( + +
+ +
+
+
+
+ JetKVM Logo + JetKVM Logo +
+
+ {plugin && <> +

+ {plugin.status} +

+ + } +
+
+
+
+ +
+ {/* Enable/Disable toggle */} +
+
+ +
+ + Name + + {plugin?.name} + + + Active Version + + {plugin?.version} + + + Description + + {plugin?.description} + + + Homepage + + + {plugin?.homepage} + +
+ +
+ +
+ {error &&

{error}

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

+ Plugin message: +

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

+ Plugin configuration coming soon +

+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
+ + ) +} diff --git a/ui/src/components/PluginList.tsx b/ui/src/components/PluginList.tsx new file mode 100644 index 00000000..74380df3 --- /dev/null +++ b/ui/src/components/PluginList.tsx @@ -0,0 +1,113 @@ +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { Button } from "@components/Button"; +import { PluginStatus, usePluginStore, useUiStore } from "@/hooks/stores"; +import { useCallback, useEffect, useState } from "react"; +import UploadPluginModal from "@components/UploadPluginDialog"; +import PluginConfigureModal from "@components/PluginConfigureDialog"; +import { PluginStatusIcon } from "./PluginStatusIcon"; + +export default function PluginList() { + const [send] = useJsonRpc(); + const [error, setError] = useState(null); + + const { + isPluginUploadModalOpen, + setIsPluginUploadModalOpen, + setPluginUploadModalView, + plugins, + setPlugins, + pluginConfigureModalOpen, + setPluginConfigureModalOpen, + configuringPlugin, + setConfiguringPlugin, + } = usePluginStore(); + const sidebarView = useUiStore(state => state.sidebarView); + + const updatePlugins = useCallback(() => { + setError(null); + send("pluginList", {}, resp => { + if ("error" in resp) { + 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" && !pluginConfigureModalOpen) return; + updatePlugins(); + + const updateInterval = setInterval(() => { + updatePlugins(); + }, 10_000); + return () => clearInterval(updateInterval); + }, [updatePlugins, sidebarView, pluginConfigureModalOpen]) + + return ( + <> +
+
    + {error &&
  • {error}
  • } + {plugins.length === 0 &&
  • No plugins installed
  • } + {plugins.map(plugin => ( +
  • + +
    +

    {plugin.name}

    +

    + {plugin.homepage} +

    +
    +
    +
    +
  • + ))} +
+
+ + { + setPluginConfigureModalOpen(open); + if (!open) { + updatePlugins(); + } + }} + plugin={plugins.find(p => p.name == configuringPlugin) ?? null} + /> + +
+
+ + ); +} diff --git a/ui/src/components/PluginStatusIcon.tsx b/ui/src/components/PluginStatusIcon.tsx new file mode 100644 index 00000000..074751ff --- /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 === "error") { + classNames = "bg-red-500 border-red-600"; + } + + return ( +
+
+
+ ); +} diff --git a/ui/src/components/USBConfigDialog.tsx b/ui/src/components/USBConfigDialog.tsx new file mode 100644 index 00000000..b21a75e6 --- /dev/null +++ b/ui/src/components/USBConfigDialog.tsx @@ -0,0 +1,215 @@ +import { GridCard } from "@/components/Card"; +import {useCallback, useEffect, 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 { InputFieldWithLabel } from "./InputField"; +import { useJsonRpc } from "@/hooks/useJsonRpc"; +import { useUsbConfigModalStore } from "@/hooks/stores"; + +export interface UsbConfigState { + vendor_id: string; + product_id: string; + serial_number: string; + manufacturer: string; + product: string; +} + +export default function USBConfigDialog({ + open, + setOpen, +}: { + open: boolean; + setOpen: (open: boolean) => void; +}) { + return ( + setOpen(false)}> + + + ); +} + +export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { + const { modalView, setModalView } = useUsbConfigModalStore(); + const [error, setError] = useState(null); + + const [send] = useJsonRpc(); + + const handleUsbConfigChange = useCallback((usbConfig: object) => { + send("setUsbConfig", { usbConfig }, resp => { + if ("error" in resp) { + setError(`Failed to update USB Config: ${resp.error.data || "Unknown error"}`); + return; + } + setModalView("updateUsbConfigSuccess"); + }); + }, [send, setModalView]); + + return ( + +
+ {modalView === "updateUsbConfig" && ( + setOpen(false)} + error={error} + /> + )} + + {modalView === "updateUsbConfigSuccess" && ( + setOpen(false)} + /> + )} +
+
+ ); +} + +function UpdateUsbConfigModal({ + onSetUsbConfig, + onCancel, + error, +}: { + onSetUsbConfig: (usb_config: object) => void; + onCancel: () => void; + error: string | null; +}) { + const [usbConfigState, setUsbConfigState] = useState({ + vendor_id: '', + product_id: '', + serial_number: '', + manufacturer: '', + product: '' + }); + const [send] = useJsonRpc(); + + const syncUsbConfig = useCallback(() => { + send("getUsbConfig", {}, resp => { + if ("error" in resp) { + console.error("Failed to load USB Config:", resp.error); + } else { + setUsbConfigState(resp.result as UsbConfigState); + } + }); + }, [send, setUsbConfigState]); + + // Load stored usb config from the backend + useEffect(() => { + syncUsbConfig(); + }, [syncUsbConfig]); + + const handleUsbVendorIdChange = (value: string) => { + setUsbConfigState({... usbConfigState, vendor_id: value}) + }; + + const handleUsbProductIdChange = (value: string) => { + setUsbConfigState({... usbConfigState, product_id: value}) + }; + + const handleUsbSerialChange = (value: string) => { + setUsbConfigState({... usbConfigState, serial_number: value}) + }; + + const handleUsbManufacturer = (value: string) => { + setUsbConfigState({... usbConfigState, manufacturer: value}) + }; + + const handleUsbProduct = (value: string) => { + setUsbConfigState({... usbConfigState, product: value}) + }; + + return ( +
+
+ + +
+
+
+

USB Emulation Configuration

+

+ Set custom USB parameters to control how the USB device is emulated. + The device will rebind once the parameters are updated. +

+
+ handleUsbVendorIdChange(e.target.value)} + /> + handleUsbProductIdChange(e.target.value)} + /> + handleUsbSerialChange(e.target.value)} + /> + handleUsbManufacturer(e.target.value)} + /> + handleUsbProduct(e.target.value)} + /> +
+
+ {error &&

{error}

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

{headline}

+

{description}

+
+
+
+ ); +} diff --git a/ui/src/components/UploadPluginDialog.tsx b/ui/src/components/UploadPluginDialog.tsx new file mode 100644 index 00000000..def9bbcd --- /dev/null +++ b/ui/src/components/UploadPluginDialog.tsx @@ -0,0 +1,679 @@ +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 { + PluginManifest, + usePluginStore, + 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 { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; +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 }) { + const { + pluginUploadModalView, + setPluginUploadModalView, + pluginUploadFilename, + setPluginUploadFilename, + pluginUploadManifest, + setPluginUploadManifest, + setConfiguringPlugin, + setPluginConfigureModalOpen, + } = 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 ( + +
+ +
+
+ JetKVM Logo + JetKVM Logo + + {!extractError && pluginUploadModalView === "upload" && { + setOpen(false) + }} + 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) + if (pluginUploadManifest) { + setConfiguringPlugin(pluginUploadManifest.name) + setPluginConfigureModalOpen(true) + } + setPluginUploadManifest(null) + setPluginUploadModalView("upload") + }} + onBack={() => { + setPluginUploadModalView("upload") + setPluginUploadFilename(null) + }} + />} +
+
+
+
+
+ ); +} + +// 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, + onUploadCompleted, +}: { + onBack: () => void; + onUploadCompleted: (filename: string) => 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"); + onUploadCompleted(file.name); + 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"); + onUploadCompleted(file.name); + } 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" ? ( +
+
+
+ ); +} + +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}

+
+ )} +
+
+
+ ); +} 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 f5f083bb..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); @@ -30,6 +41,8 @@ export default function WebRTCVideo() { const { setClientSize: setVideoClientSize, setSize: setVideoSize, + width: videoWidth, + height: videoHeight, clientWidth: videoClientWidth, clientHeight: videoClientHeight, } = useVideoStore(); @@ -102,20 +115,43 @@ export default function WebRTCVideo() { const mouseMoveHandler = useCallback( (e: MouseEvent) => { if (!videoClientWidth || !videoClientHeight) return; - const { buttons } = e; + // Get the aspect ratios of the video element and the video stream + const videoElementAspectRatio = videoClientWidth / videoClientHeight; + const videoStreamAspectRatio = videoWidth / videoHeight; + + // Calculate the effective video display area + let effectiveWidth = videoClientWidth; + let effectiveHeight = videoClientHeight; + let offsetX = 0; + let offsetY = 0; + + if (videoElementAspectRatio > videoStreamAspectRatio) { + // Pillarboxing: black bars on the left and right + effectiveWidth = videoClientHeight * videoStreamAspectRatio; + offsetX = (videoClientWidth - effectiveWidth) / 2; + } else if (videoElementAspectRatio < videoStreamAspectRatio) { + // Letterboxing: black bars on the top and bottom + effectiveHeight = videoClientWidth / videoStreamAspectRatio; + offsetY = (videoClientHeight - effectiveHeight) / 2; + } + + // Clamp mouse position within the effective video boundaries + const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth); + const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight); - // Clamp mouse position within the video boundaries - const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth); - const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight); + // Map clamped mouse position to the video stream's coordinate system + const relativeX = (clampedX - offsetX) / effectiveWidth; + const relativeY = (clampedY - offsetY) / effectiveHeight; - // Normalize mouse position to 0-32767 range (HID absolute coordinate system) - const x = Math.round((currMouseX / videoClientWidth) * 32767); - const y = Math.round((currMouseY / videoClientHeight) * 32767); + // Convert to HID absolute coordinate system (0-32767 range) + const x = Math.round(relativeX * 32767); + const y = Math.round(relativeY * 32767); // Send mouse movement + const { buttons } = e; sendMouseMovement(x, y, buttons); }, - [sendMouseMovement, videoClientHeight, videoClientWidth], + [sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], ); const mouseWheelHandler = useCallback( @@ -425,7 +461,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, 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"); 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 ec606a67..b3f17adb 100644 --- a/ui/src/components/sidebar/settings.tsx +++ b/ui/src/components/sidebar/settings.tsx @@ -1,9 +1,10 @@ import SidebarHeader from "@components/SidebarHeader"; import { + BacklightSettings, useLocalAuthModalStore, useSettingsStore, useUiStore, - useUpdateStore, + useUpdateStore, useUsbConfigModalStore, } from "@/hooks/stores"; import { Checkbox } from "@components/Checkbox"; import { Button, LinkButton } from "@components/Button"; @@ -25,6 +26,10 @@ 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 PluginList from "@components/PluginList"; +import USBConfigDialog from "@components/USBConfigDialog"; +import { keyboardMappingsStore } from "@/keyboardMappings/KeyboardMappingStore"; +import { KeyboardLayout } from "@/keyboardMappings/KeyboardLayouts"; export function SettingsItem({ title, @@ -39,18 +44,18 @@ export function SettingsItem({ name?: string; }) { return ( - + ); } const defaultEdid = - "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; + "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"; const edids = [ { value: defaultEdid, @@ -58,17 +63,17 @@ const edids = [ }, { value: - "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096", + "00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096", label: "Acer B246WL, 1920x1200", }, { value: - "00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC", + "00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC", label: "ASUS PA248QV, 1920x1200", }, { value: - "00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F", + "00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F", label: "DELL D2721H, 1920x1080", }, ]; @@ -77,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); @@ -95,6 +101,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; @@ -110,19 +117,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(() => { @@ -137,7 +144,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; } @@ -146,11 +153,25 @@ 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) { notifications.error( - `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, + `Failed to set stream quality: ${resp.error.data || "Unknown error"}`, ); return; } @@ -162,7 +183,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; } @@ -174,7 +195,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; } @@ -186,7 +207,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; } @@ -211,28 +232,50 @@ 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) => { + // If the user has set the display to dim after it turns off, set the dim_after + // value to never. + if (settings.dim_after > settings.off_after && settings.off_after != 0) { + settings.dim_after = 0; + } + + setBacklightSettings(settings); + } + + const handleBacklightSettingsSave = () => { + send("setBacklightSettings", { params: settings.backlightSettings }, resp => { + if ("error" in resp) { + notifications.error( + `Failed to set backlight settings: ${resp.error.data || "Unknown error"}`, + ); + return; + } + notifications.success("Backlight settings updated successfully"); + }); + }; + const handleUpdateSSHKey = useCallback(() => { 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; } @@ -274,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)); @@ -288,7 +336,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) { @@ -302,6 +350,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 }; @@ -331,8 +390,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"); @@ -340,7 +399,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(); @@ -354,6 +415,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(() => { @@ -367,8 +436,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 { @@ -382,7 +451,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; } @@ -390,337 +459,377 @@ 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 ( -
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)} + onChange={e => handleKeyboardLayoutChange(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)} - /> -
-
- - )}
- {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 - - . -
-
-
- -
- +
+ +
+ + 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)} + /> +
+
-
-
- - - {!isAdopted ? ( -
- -
- ) : ( -
-
-

- Your device is adopted to JetKVM Cloud -

-
-
-
+ )}
- - )} -
- {isOnDevice ? ( - <> -
- +
+ {isOnDevice && ( + <> +
+
+ -
- - {localDevice?.authMode === "password" ? ( -
+
+
)} - +
+ + )} +
+ {isOnDevice ? ( + <> +
+ + +
+ + {localDevice?.authMode === "password" ? ( +
-
- )} - - { - settings.setDebugMode(e.target.checked); - }} - /> - + - {settings.debugMode && ( - <> - -
+
+ )} + { + settings.setDebugMode(e.target.checked); + }} + /> + + {settings.developerMode && ( + +
+
+ { + // 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 b4cfbec8..5b340ef4 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -229,6 +229,12 @@ export interface VideoState { }) => void; } +export interface BacklightSettings { + max_brightness: number; + dim_after: number; + off_after: number; +} + export const useVideoStore = create(set => ({ width: 0, height: 0, @@ -270,6 +276,12 @@ interface SettingsState { // Add new developer mode state developerMode: boolean; setDeveloperMode: (enabled: boolean) => void; + + actionBarCtrlAltDel: boolean; + setActionBarCtrlAltDel: (enabled: boolean) => void; + + backlightSettings: BacklightSettings; + setBacklightSettings: (settings: BacklightSettings) => void; } export const useSettingsStore = create( @@ -287,6 +299,16 @@ export const useSettingsStore = create( // Add developer mode with default value developerMode: false, setDeveloperMode: enabled => set({ developerMode: enabled }), + + 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", @@ -528,3 +550,79 @@ export const useLocalAuthModalStore = create(set => ({ setModalView: view => set({ modalView: view }), setErrorMessage: message => set({ errorMessage: message }), })); + + +export interface PluginManifest { + name: string; + version: string; + description?: string; + homepage: string; +} + +export interface PluginStatus extends PluginManifest { + enabled: boolean; + status: "stopped" | "running" | "loading" | "pending-configuration" | "error"; + message?: 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; + + plugins: PluginStatus[]; + setPlugins: (plugins: PluginStatus[]) => void; + + pluginConfigureModalOpen: boolean; + setPluginConfigureModalOpen: (isOpen: boolean) => void; + + configuringPlugin: string | null; + setConfiguringPlugin: (pluginName: string | null) => void; +} + +export const usePluginStore = create(set => ({ + isPluginUploadModalOpen: false, + setIsPluginUploadModalOpen: isOpen => set({ isPluginUploadModalOpen: isOpen }), + + pluginUploadFilename: null, + setPluginUploadFilename: filename => set({ pluginUploadFilename: filename }), + + pluginUploadManifest: null, + setPluginUploadManifest: manifest => set({ pluginUploadManifest: manifest }), + + pluginUploadModalView: "upload", + setPluginUploadModalView: view => set({ pluginUploadModalView: view }), + + plugins: [], + setPlugins: plugins => set({ plugins }), + + pluginConfigureModalOpen: false, + setPluginConfigureModalOpen: isOpen => set({ pluginConfigureModalOpen: isOpen }), + + 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/ui/src/keyboardMappings.ts b/ui/src/keyboardMappings.ts deleted file mode 100644 index ffc781c4..00000000 --- a/ui/src/keyboardMappings.ts +++ /dev/null @@ -1,214 +0,0 @@ -export const keys = { - 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 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 diff --git a/ui/vite.config.ts b/ui/vite.config.ts index e9c7fe50..b6d26f64 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -2,13 +2,31 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react-swc"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig(({ mode }) => { +declare const process: { + env: { + JETKVM_PROXY_URL: string; + }; +}; + +export default defineConfig(({ mode, command }) => { const isCloud = mode === "production"; const onDevice = mode === "device"; + const { JETKVM_PROXY_URL } = process.env; + return { plugins: [tsconfigPaths(), react()], build: { outDir: isCloud ? "dist" : "../static" }, - server: { host: "0.0.0.0" }, - base: onDevice ? "/static" : "/", + server: { + host: "0.0.0.0", + proxy: JETKVM_PROXY_URL ? { + '/me': JETKVM_PROXY_URL, + '/device': JETKVM_PROXY_URL, + '/webrtc': JETKVM_PROXY_URL, + '/auth': JETKVM_PROXY_URL, + '/storage': JETKVM_PROXY_URL, + '/cloud': JETKVM_PROXY_URL, + } : undefined + }, + base: onDevice && command === 'build' ? "/static" : "/", }; }); diff --git a/usb.go b/usb.go index 075409ad..3ba0ae74 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 @@ -132,7 +171,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid0Path, [][]string{ {"protocol", "1"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "8"}, }) if err != nil { @@ -152,7 +191,7 @@ func writeGadgetConfig() error { } err = writeGadgetAttrs(hid1Path, [][]string{ {"protocol", "2"}, - {"subclass", "0"}, + {"subclass", "1"}, {"report_length", "6"}, }) if err != nil { 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 diff --git a/web.go b/web.go index 64f8de71..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" ) @@ -17,8 +21,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 { @@ -78,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()) @@ -116,7 +127,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..2cbed4ca 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 @@ -75,8 +102,9 @@ func newSession() (*Session, error) { switch d.Label() { case "rpc": session.RPCChannel = d + rpcServer := NewDataChannelJsonRpcRouter(d) d.OnMessage(func(msg webrtc.DataChannelMessage) { - go onRPCMessage(msg, session) + go rpcServer.HandleMessage(msg.Data) }) triggerOTAStateUpdate() triggerVideoStateUpdate()