This commit is contained in:
Aveline 2025-03-19 18:33:35 +01:00 committed by GitHub
commit 43bf322c75
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
113 changed files with 5741 additions and 2959 deletions

View File

@ -6,5 +6,9 @@
// Should match what is defined in ui/package.json // Should match what is defined in ui/package.json
"version": "21.1.0" "version": "21.1.0"
} }
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
]
} }
}

142
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,142 @@
name: build image
on:
push:
branches:
- dev
- main
workflow_dispatch:
pull_request_review:
types: [submitted]
jobs:
build:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Build
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: v21.1.0
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Set up Golang
uses: actions/setup-go@v4
with:
go-version: '1.24.0'
- name: Build frontend
run: |
make frontend
- name: Build application
run: |
make build_dev
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: jetkvm-app
path: bin/jetkvm_app
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
needs: build
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: jetkvm-app
- name: Configure WireGuard and check connectivity
run: |
WG_KEY_FILE=$(mktemp)
echo -n "$CI_WG_PRIVATE" > $WG_KEY_FILE && \
sudo apt-get update && sudo apt-get install -y wireguard-tools && \
sudo ip link add dev wg-ci type wireguard && \
sudo ip addr add $CI_WG_IPS dev wg-ci && \
sudo wg set wg-ci listen-port 51820 \
private-key $WG_KEY_FILE \
peer $CI_WG_PUBLIC \
allowed-ips $CI_WG_ALLOWED_IPS \
endpoint $CI_WG_ENDPOINT \
persistent-keepalive 15 && \
sudo ip link set up dev wg-ci && \
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
SSH_PRIVATE_KEY=$(mktemp)
echo "$CI_SSH_PRIVATE" > $SSH_PRIVATE_KEY
chmod 0600 $SSH_PRIVATE_KEY
# Configure SSH
mkdir -p ~/.ssh
cat <<EOF >> ~/.ssh/config
Host jkci
HostName $CI_HOST
User $CI_USER
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Deploy application
run: |
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
echo 1 > /proc/sys/vm/drop_caches
# Reboot the application
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo
echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log
cat last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:
name: device-logs
path: last.log

34
.github/workflows/golangci-lint.yml vendored Normal file
View File

@ -0,0 +1,34 @@
---
name: golangci-lint
on:
push:
paths:
- "go.sum"
- "go.mod"
- "**.go"
- ".github/workflows/golangci-lint.yml"
- ".golangci.yml"
pull_request:
permissions: # added using https://github.com/step-security/secure-repo
contents: read
jobs:
golangci:
permissions:
contents: read # for actions/checkout to fetch code
pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
name: lint
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
with:
go-version: 1.23.x
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with:
args: --verbose
version: v1.62.0

12
.golangci.yml Normal file
View File

@ -0,0 +1,12 @@
---
linters:
enable:
# - goimports
# - misspell
# - revive
issues:
exclude-rules:
- path: _test.go
linters:
- errcheck

View File

@ -1,17 +1,31 @@
VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M) BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
VERSION := 0.3.7 BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
GO_LDFLAGS := \
-s -w \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
hash_resource: hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource build_dev: hash_resource
@echo "Building..." @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_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
frontend: frontend:
cd ui && npm ci && npm run build:device cd ui && npm ci && npm run build:device
dev_release: build_dev dev_release: frontend build_dev
@echo "Uploading release..." @echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256 @shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
@ -19,7 +33,7 @@ dev_release: build_dev
build_release: frontend hash_resource build_release: frontend hash_resource
@echo "Building release..." @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_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release: release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -3,7 +3,6 @@ package kvm
import ( import (
"context" "context"
"errors" "errors"
"log"
"net" "net"
"os" "os"
"time" "time"
@ -94,7 +93,8 @@ func (d *NBDDevice) Start() error {
// Remove the socket file if it already exists // Remove the socket file if it already exists
if _, err := os.Stat(nbdSocketPath); err == nil { if _, err := os.Stat(nbdSocketPath); err == nil {
if err := os.Remove(nbdSocketPath); err != nil { if err := os.Remove(nbdSocketPath); err != nil {
log.Fatalf("Failed to remove existing socket file %s: %v", nbdSocketPath, err) logger.Errorf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
os.Exit(1)
} }
} }
@ -134,7 +134,7 @@ func (d *NBDDevice) runServerConn() {
MaximumBlockSize: uint32(16 * 1024), MaximumBlockSize: uint32(16 * 1024),
SupportsMultiConn: false, SupportsMultiConn: false,
}) })
log.Println("nbd server exited:", err) logger.Infof("nbd server exited: %v", err)
} }
func (d *NBDDevice) runClientConn() { func (d *NBDDevice) runClientConn() {
@ -142,14 +142,14 @@ func (d *NBDDevice) runClientConn() {
ExportName: "jetkvm", ExportName: "jetkvm",
BlockSize: uint32(4 * 1024), BlockSize: uint32(4 * 1024),
}) })
log.Println("nbd client exited:", err) logger.Infof("nbd client exited: %v", err)
} }
func (d *NBDDevice) Close() { func (d *NBDDevice) Close() {
if d.dev != nil { if d.dev != nil {
err := client.Disconnect(d.dev) err := client.Disconnect(d.dev)
if err != nil { if err != nil {
log.Println("error disconnecting nbd client:", err) logger.Warnf("error disconnecting nbd client: %v", err)
} }
_ = d.dev.Close() _ = d.dev.Close()
} }

View File

@ -24,6 +24,18 @@ type CloudRegisterRequest struct {
ClientId string `json:"clientId"` ClientId string `json:"clientId"`
} }
const (
// CloudWebSocketConnectTimeout is the timeout for the websocket connection to the cloud
CloudWebSocketConnectTimeout = 1 * time.Minute
// CloudAPIRequestTimeout is the timeout for cloud API requests
CloudAPIRequestTimeout = 10 * time.Second
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
// should be lower than the websocket response timeout set in cloud-api
CloudOidcRequestTimeout = 10 * time.Second
// CloudWebSocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
CloudWebSocketPingInterval = 15 * time.Second
)
func handleCloudRegister(c *gin.Context) { func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest var req CloudRegisterRequest
@ -44,22 +56,31 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload)) client := &http.Client{Timeout: CloudAPIRequestTimeout}
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiResp, err := client.Do(apiReq)
if err != nil { if err != nil {
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()}) c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
return return
} }
defer resp.Body.Close() defer apiResp.Body.Close()
if resp.StatusCode != http.StatusOK { if apiResp.StatusCode != http.StatusOK {
c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status}) c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
return return
} }
var tokenResp struct { var tokenResp struct {
SecretToken string `json:"secretToken"` SecretToken string `json:"secretToken"`
} }
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil {
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()}) c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
return return
} }
@ -70,12 +91,11 @@ func handleCloudRegister(c *gin.Context) {
} }
if config.CloudToken == "" { if config.CloudToken == "" {
logger.Info("Starting websocket client due to adoption") cloudLogger.Info("Starting websocket client due to adoption")
go RunWebsocketClient() go RunWebsocketClient()
} }
config.CloudToken = tokenResp.SecretToken config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI
provider, err := oidc.NewProvider(c, "https://accounts.google.com") provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil { if err != nil {
@ -122,7 +142,7 @@ func runWebsocketClient() error {
header := http.Header{} header := http.Header{}
header.Set("X-Device-ID", GetDeviceID()) header.Set("X-Device-ID", GetDeviceID())
header.Set("Authorization", "Bearer "+config.CloudToken) header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute) dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
defer cancelDial() defer cancelDial()
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{ c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header, HTTPHeader: header,
@ -130,16 +150,16 @@ func runWebsocketClient() error {
if err != nil { if err != nil {
return err return err
} }
defer c.CloseNow() defer c.CloseNow() //nolint:errcheck
logger.Infof("WS connected to %v", wsURL.String()) cloudLogger.Infof("websocket connected to %s", wsURL)
runCtx, cancelRun := context.WithCancel(context.Background()) runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun() defer cancelRun()
go func() { go func() {
for { for {
time.Sleep(15 * time.Second) time.Sleep(CloudWebSocketPingInterval)
err := c.Ping(runCtx) err := c.Ping(runCtx)
if err != nil { if err != nil {
logger.Warnf("websocket ping error: %v", err) cloudLogger.Warnf("websocket ping error: %v", err)
cancelRun() cancelRun()
return return
} }
@ -157,24 +177,30 @@ func runWebsocketClient() error {
var req WebRTCSessionRequest var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req) err = json.Unmarshal(msg, &req)
if err != nil { if err != nil {
logger.Warnf("unable to parse ws message: %v", string(msg)) cloudLogger.Warnf("unable to parse ws message: %v", string(msg))
continue continue
} }
cloudLogger.Infof("new session request: %v", req.OidcGoogle)
cloudLogger.Tracef("session request info: %v", req)
err = handleSessionRequest(runCtx, c, req) err = handleSessionRequest(runCtx, c, req)
if err != nil { if err != nil {
logger.Infof("error starting new session: %v", err) cloudLogger.Infof("error starting new session: %v", err)
continue continue
} }
} }
} }
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error { func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute) oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC() defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com") provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
if err != nil { if err != nil {
fmt.Println("Failed to initialize OIDC provider:", err) _ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Errorf("failed to initialize OIDC provider: %v", err)
return err return err
} }
@ -190,6 +216,7 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
if config.GoogleIdentity != googleIdentity { if config.GoogleIdentity != googleIdentity {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"})
return fmt.Errorf("google identity mismatch") return fmt.Errorf("google identity mismatch")
} }
@ -216,6 +243,9 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
_ = peerConn.Close() _ = peerConn.Close()
}() }()
} }
cloudLogger.Info("new session accepted")
cloudLogger.Tracef("new session accepted: %v", session)
currentSession = session currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
return nil return nil
@ -225,7 +255,7 @@ func RunWebsocketClient() {
for { for {
err := runWebsocketClient() err := runWebsocketClient()
if err != nil { if err != nil {
fmt.Println("Websocket client error:", err) cloudLogger.Errorf("websocket client error: %v", err)
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
@ -234,12 +264,14 @@ func RunWebsocketClient() {
type CloudState struct { type CloudState struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
AppURL string `json:"appUrl,omitempty"`
} }
func rpcGetCloudState() CloudState { func rpcGetCloudState() CloudState {
return CloudState{ return CloudState{
Connected: config.CloudToken != "" && config.CloudURL != "", Connected: config.CloudToken != "" && config.CloudURL != "",
URL: config.CloudURL, URL: config.CloudURL,
AppURL: config.CloudAppURL,
} }
} }
@ -254,7 +286,7 @@ func rpcDeregisterDevice() error {
} }
req.Header.Set("Authorization", "Bearer "+config.CloudToken) req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: CloudAPIRequestTimeout}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("failed to send deregister request: %w", err) return fmt.Errorf("failed to send deregister request: %w", err)
@ -267,8 +299,8 @@ func rpcDeregisterDevice() error {
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it. // (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) { if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
config.CloudToken = "" config.CloudToken = ""
config.CloudURL = ""
config.GoogleIdentity = "" config.GoogleIdentity = ""
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save configuration after deregistering: %w", err) return fmt.Errorf("failed to save configuration after deregistering: %w", err)
} }

View File

@ -1,7 +1,7 @@
package main package main
import ( import (
"kvm" "github.com/jetkvm/kvm"
) )
func main() { func main() {

View File

@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"os" "os"
"sync" "sync"
"github.com/jetkvm/kvm/internal/usbgadget"
) )
type WakeOnLanDevice struct { type WakeOnLanDevice struct {
@ -14,6 +16,7 @@ type WakeOnLanDevice struct {
type Config struct { type Config struct {
CloudURL string `json:"cloud_url"` CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"` CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"` GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"` JigglerEnabled bool `json:"jiggler_enabled"`
@ -28,17 +31,35 @@ type Config struct {
DisplayMaxBrightness int `json:"display_max_brightness"` DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"` DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"` DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"`
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
} }
const configPath = "/userdata/kvm_config.json" const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{ var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com", CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "", ActiveExtension: "",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes
TLSMode: "",
UsbConfig: &usbgadget.Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
},
UsbDevices: &usbgadget.Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
},
} }
var ( var (
@ -47,6 +68,9 @@ var (
) )
func LoadConfig() { func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil { if config != nil {
logger.Info("config already loaded, skipping") logger.Info("config already loaded, skipping")
return return
@ -69,6 +93,15 @@ func LoadConfig() {
return return
} }
// merge the user config with the default config
if loadedConfig.UsbConfig == nil {
loadedConfig.UsbConfig = defaultConfig.UsbConfig
}
if loadedConfig.UsbDevices == nil {
loadedConfig.UsbDevices = defaultConfig.UsbDevices
}
config = &loadedConfig config = &loadedConfig
} }
@ -90,3 +123,9 @@ func SaveConfig() error {
return nil return nil
} }
func ensureConfigLoaded() {
if config == nil {
LoadConfig()
}
}

View File

@ -10,6 +10,7 @@ show_help() {
echo echo
echo "Optional:" echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)" echo " -u, --user <remote_user> Remote username (default: root)"
echo " --skip-ui-build Skip frontend/UI build"
echo " --help Display this help message" echo " --help Display this help message"
echo echo
echo "Example:" echo "Example:"
@ -21,6 +22,7 @@ show_help() {
# Default values # Default values
REMOTE_USER="root" REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin" REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -33,6 +35,10 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2" REMOTE_USER="$2"
shift 2 shift 2
;; ;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
;;
--help) --help)
show_help show_help
exit 0 exit 0
@ -52,12 +58,17 @@ if [ -z "$REMOTE_HOST" ]; then
fi fi
# Build the development version on the host # Build the development version on the host
if [ "$SKIP_UI_BUILD" = false ]; then
make frontend make frontend
fi
make build_dev make build_dev
# Change directory to the binary output directory # Change directory to the binary output directory
cd bin cd bin
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug" cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
@ -79,8 +90,7 @@ cd "$REMOTE_PATH"
chmod +x jetkvm_app_debug chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
./jetkvm_app_debug PION_LOG_TRACE=jetkvm,cloud ./jetkvm_app_debug
EOF EOF
echo "Deployment complete." echo "Deployment complete."

View File

@ -3,7 +3,6 @@ package kvm
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"strconv" "strconv"
"time" "time"
@ -25,7 +24,7 @@ const (
func switchToScreen(screen string) { func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen}) _, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
if err != nil { if err != nil {
log.Printf("failed to switch to screen %s: %v", screen, err) logger.Warnf("failed to switch to screen %s: %v", screen, err)
return return
} }
currentScreen = screen currentScreen = screen
@ -41,7 +40,7 @@ func updateLabelIfChanged(objName string, newText string) {
} }
func switchToScreenIfDifferent(screenName string) { func switchToScreenIfDifferent(screenName string) {
fmt.Println("switching screen from", currentScreen, screenName) logger.Infof("switching screen from %s to %s", currentScreen, screenName)
if currentScreen != screenName { if currentScreen != screenName {
switchToScreen(screenName) switchToScreen(screenName)
} }
@ -75,12 +74,12 @@ var displayInited = false
func requestDisplayUpdate() { func requestDisplayUpdate() {
if !displayInited { if !displayInited {
fmt.Println("display not inited, skipping updates") logger.Info("display not inited, skipping updates")
return return
} }
go func() { go func() {
wakeDisplay(false) wakeDisplay(false)
fmt.Println("display updating........................") logger.Info("display updating")
//TODO: only run once regardless how many pending updates //TODO: only run once regardless how many pending updates
updateDisplay() updateDisplay()
}() }()
@ -119,7 +118,7 @@ func setDisplayBrightness(brightness int) error {
return err return err
} }
fmt.Printf("display: set brightness to %v\n", brightness) logger.Infof("display: set brightness to %v", brightness)
return nil return nil
} }
@ -128,7 +127,7 @@ func setDisplayBrightness(brightness int) error {
func tick_displayDim() { func tick_displayDim() {
err := setDisplayBrightness(config.DisplayMaxBrightness / 2) err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
if err != nil { if err != nil {
fmt.Printf("display: failed to dim display: %s\n", err) logger.Warnf("display: failed to dim display: %s", err)
} }
dimTicker.Stop() dimTicker.Stop()
@ -141,7 +140,7 @@ func tick_displayDim() {
func tick_displayOff() { func tick_displayOff() {
err := setDisplayBrightness(0) err := setDisplayBrightness(0)
if err != nil { if err != nil {
fmt.Printf("display: failed to turn off display: %s\n", err) logger.Warnf("display: failed to turn off display: %s", err)
} }
offTicker.Stop() offTicker.Stop()
@ -164,7 +163,7 @@ func wakeDisplay(force bool) {
err := setDisplayBrightness(config.DisplayMaxBrightness) err := setDisplayBrightness(config.DisplayMaxBrightness)
if err != nil { if err != nil {
fmt.Printf("display wake failed, %s\n", err) logger.Warnf("display wake failed, %s", err)
} }
if config.DisplayDimAfterSec != 0 { if config.DisplayDimAfterSec != 0 {
@ -184,7 +183,7 @@ func wakeDisplay(force bool) {
func watchTsEvents() { func watchTsEvents() {
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666) ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
if err != nil { if err != nil {
fmt.Printf("display: failed to open touchscreen device: %s\n", err) logger.Warnf("display: failed to open touchscreen device: %s", err)
return return
} }
@ -197,7 +196,7 @@ func watchTsEvents() {
for { for {
_, err := ts.Read(buf) _, err := ts.Read(buf)
if err != nil { if err != nil {
fmt.Printf("display: failed to read from touchscreen device: %s\n", err) logger.Warnf("display: failed to read from touchscreen device: %s", err)
return return
} }
@ -212,17 +211,17 @@ func startBacklightTickers() {
// Don't start the tickers if the display is switched off. // Don't start the tickers if the display is switched off.
// Set the display to off if that's the case. // Set the display to off if that's the case.
if config.DisplayMaxBrightness == 0 { if config.DisplayMaxBrightness == 0 {
setDisplayBrightness(0) _ = setDisplayBrightness(0)
return return
} }
if dimTicker == nil && config.DisplayDimAfterSec != 0 { if dimTicker == nil && config.DisplayDimAfterSec != 0 {
fmt.Printf("display: dim_ticker has started\n") logger.Info("display: dim_ticker has started")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop() defer dimTicker.Stop()
go func() { go func() {
for { for { //nolint:gosimple
select { select {
case <-dimTicker.C: case <-dimTicker.C:
tick_displayDim() tick_displayDim()
@ -232,12 +231,12 @@ func startBacklightTickers() {
} }
if offTicker == nil && config.DisplayOffAfterSec != 0 { if offTicker == nil && config.DisplayOffAfterSec != 0 {
fmt.Printf("display: off_ticker has started\n") logger.Info("display: off_ticker has started")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop() defer offTicker.Stop()
go func() { go func() {
for { for { //nolint:gosimple
select { select {
case <-offTicker.C: case <-offTicker.C:
tick_displayOff() tick_displayOff()
@ -248,13 +247,15 @@ func startBacklightTickers() {
} }
func init() { func init() {
ensureConfigLoaded()
go func() { go func() {
waitCtrlClientConnected() waitCtrlClientConnected()
fmt.Println("setting initial display contents") logger.Info("setting initial display contents")
time.Sleep(500 * time.Millisecond) time.Sleep(500 * time.Millisecond)
updateStaticContents() updateStaticContents()
displayInited = true displayInited = true
fmt.Println("display inited") logger.Info("display inited")
startBacklightTickers() startBacklightTickers()
wakeDisplay(true) wakeDisplay(true)
requestDisplayUpdate() requestDisplayUpdate()

View File

@ -2,7 +2,6 @@ package kvm
import ( import (
"context" "context"
"fmt"
"os" "os"
"sync" "sync"
"syscall" "syscall"
@ -104,7 +103,7 @@ func RunFuseServer() {
var err error var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts) fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil { if err != nil {
fmt.Println("failed to mount fuse: %w", err) logger.Warnf("failed to mount fuse: %v", err)
} }
fuseServer.Wait() fuseServer.Wait()
} }

26
go.mod
View File

@ -1,4 +1,4 @@
module kvm module github.com/jetkvm/kvm
go 1.21.0 go 1.21.0
@ -15,23 +15,26 @@ require (
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1 github.com/hanwen/go-fuse/v2 v2.5.1
github.com/hashicorp/go-envparse v0.1.0 github.com/hashicorp/go-envparse v0.1.0
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965
github.com/pion/logging v0.2.2 github.com/pion/logging v0.2.2
github.com/pion/mdns/v2 v2.0.7 github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0 github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2 github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
github.com/psanford/httpreadat v0.1.0 github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0 github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2 go.bug.st/serial v1.6.2
golang.org/x/crypto v0.28.0 golang.org/x/crypto v0.31.0
golang.org/x/net v0.30.0 golang.org/x/net v0.33.0
) )
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require ( require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // 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/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/creack/goselect v0.1.2 // indirect github.com/creack/goselect v0.1.2 // indirect
@ -43,12 +46,13 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // 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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect github.com/pion/datachannel v1.5.9 // indirect
@ -64,16 +68,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.19.0 // indirect golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.0 // indirect google.golang.org/protobuf v1.36.1 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

66
go.sum
View File

@ -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/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 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw= 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 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= 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 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 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 h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk= 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= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@ -18,7 +22,6 @@ github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0= github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
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 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 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= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -42,8 +45,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/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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -55,20 +58,22 @@ github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdm
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc= github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 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 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 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/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.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 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/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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 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= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -80,8 +85,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/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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q= github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
@ -118,14 +123,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/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 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
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.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
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 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= 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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -136,8 +147,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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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.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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 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= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@ -153,29 +165,27 @@ go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 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 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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/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.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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.36.1/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 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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

4
hw.go
View File

@ -14,7 +14,7 @@ func extractSerialNumber() (string, error) {
return "", err return "", err
} }
r, err := regexp.Compile("Serial\\s*:\\s*(\\S+)") r, err := regexp.Compile(`Serial\s*:\s*(\S+)`)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to compile regex: %w", err) return "", fmt.Errorf("failed to compile regex: %w", err)
} }
@ -27,7 +27,7 @@ func extractSerialNumber() (string, error) {
return matches[1], nil return matches[1], nil
} }
func readOtpEntropy() ([]byte, error) { func readOtpEntropy() ([]byte, error) { //nolint:unused
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem") content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -0,0 +1,336 @@
package usbgadget
import (
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"sort"
)
type gadgetConfigItem struct {
order uint
device string
path []string
attrs gadgetAttributes
configAttrs gadgetAttributes
configPath []string
reportDesc []byte
}
type gadgetAttributes map[string]string
type gadgetConfigItemWithKey struct {
key string
item gadgetConfigItem
}
type orderedGadgetConfigItems []gadgetConfigItemWithKey
var defaultGadgetConfig = map[string]gadgetConfigItem{
"base": {
order: 0,
attrs: gadgetAttributes{
"bcdUSB": "0x0200", // USB 2.0
"idVendor": "0x1d6b", // The Linux Foundation
"idProduct": "0104", // Multifunction Composite Gadget
"bcdDevice": "0100",
},
configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA
},
},
"base_info": {
order: 1,
path: []string{"strings", "0x409"},
configPath: []string{"strings", "0x409"},
attrs: gadgetAttributes{
"serialnumber": "",
"manufacturer": "JetKVM",
"product": "JetKVM USB Emulation Device",
},
configAttrs: gadgetAttributes{
"configuration": "Config 1: HID",
},
},
// keyboard HID
"keyboard": keyboardConfig,
// mouse HID
"absolute_mouse": absoluteMouseConfig,
// relative mouse HID
"relative_mouse": relativeMouseConfig,
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
switch itemKey {
case "absolute_mouse":
return u.enabledDevices.AbsoluteMouse
case "relative_mouse":
return u.enabledDevices.RelativeMouse
case "keyboard":
return u.enabledDevices.Keyboard
case "mass_storage_base":
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
default:
return true
}
}
func (u *UsbGadget) loadGadgetConfig() {
if u.customConfig.isEmpty {
u.log.Trace("using default gadget config")
return
}
u.configMap["base"].attrs["idVendor"] = u.customConfig.VendorId
u.configMap["base"].attrs["idProduct"] = u.customConfig.ProductId
u.configMap["base_info"].attrs["serialnumber"] = u.customConfig.SerialNumber
u.configMap["base_info"].attrs["manufacturer"] = u.customConfig.Manufacturer
u.configMap["base_info"].attrs["product"] = u.customConfig.Product
}
func (u *UsbGadget) SetGadgetConfig(config *Config) {
u.configLock.Lock()
defer u.configLock.Unlock()
if config == nil {
return // nothing to do
}
u.customConfig = *config
u.loadGadgetConfig()
}
func (u *UsbGadget) SetGadgetDevices(devices *Devices) {
u.configLock.Lock()
defer u.configLock.Unlock()
if devices == nil {
return // nothing to do
}
u.enabledDevices = *devices
}
// GetConfigPath returns the path to the config item.
func (u *UsbGadget) GetConfigPath(itemKey string) (string, error) {
item, ok := u.configMap[itemKey]
if !ok {
return "", fmt.Errorf("config item %s not found", itemKey)
}
return joinPath(u.kvmGadgetPath, item.configPath), nil
}
// GetPath returns the path to the item.
func (u *UsbGadget) GetPath(itemKey string) (string, error) {
item, ok := u.configMap[itemKey]
if !ok {
return "", fmt.Errorf("config item %s not found", itemKey)
}
return joinPath(u.kvmGadgetPath, item.path), nil
}
func mountConfigFS() error {
_, err := os.Stat(gadgetPath)
// TODO: check if it's mounted properly
if err == nil {
return nil
}
if os.IsNotExist(err) {
err = exec.Command("mount", "-t", "configfs", "none", configFSPath).Run()
if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err)
}
} else {
return fmt.Errorf("unable to access usb gadget path: %w", err)
}
return nil
}
func (u *UsbGadget) Init() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
udcs := getUdcs()
if len(udcs) < 1 {
u.log.Error("no udc found, skipping USB stack init")
return nil
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Errorf("failed to mount configfs: %v, usb stack might not function properly", err)
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Errorf("failed to create config path: %v", err)
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to start gadget: %v", err)
}
return nil
}
func (u *UsbGadget) UpdateGadgetConfig() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Errorf("failed to update gadget: %v", err)
}
return nil
}
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems {
items := make([]gadgetConfigItemWithKey, 0)
for key, item := range u.configMap {
items = append(items, gadgetConfigItemWithKey{key, item})
}
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
})
return items
}
func (u *UsbGadget) writeGadgetConfig() error {
// create kvm gadget path
err := os.MkdirAll(u.kvmGadgetPath, 0755)
if err != nil {
return err
}
u.log.Tracef("writing gadget config")
for _, val := range u.getOrderedConfigItems() {
key := val.key
item := val.item
// check if the item is enabled in the config
if !u.isGadgetConfigItemEnabled(key) {
u.log.Tracef("disabling gadget config: %s", key)
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Tracef("writing gadget config: %s", key)
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Errorf("failed to write UDC: %v", err)
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Infof("failed to rebind usb: %v", err)
}
return nil
}
func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error {
// remove symlink if exists
if item.configPath == nil {
return nil
}
configPath := joinPath(u.configC1Path, item.configPath)
if _, err := os.Lstat(configPath); os.IsNotExist(err) {
u.log.Tracef("symlink %s does not exist", item.configPath)
return nil
}
if err := os.Remove(configPath); err != nil {
return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err)
}
return nil
}
func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error {
// create directory for the item
gadgetItemPath := joinPath(u.kvmGadgetPath, item.path)
err := os.MkdirAll(gadgetItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err)
}
if len(item.attrs) > 0 {
// write attributes for the item
err = u.writeGadgetAttrs(gadgetItemPath, item.attrs)
if err != nil {
return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err)
}
}
// write report descriptor if available
if item.reportDesc != nil {
err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644)
if err != nil {
return err
}
}
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(u.configC1Path, item.configPath)
err = os.MkdirAll(configItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", configItemPath, err)
}
err = u.writeGadgetAttrs(configItemPath, item.configAttrs)
if err != nil {
return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err)
}
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(u.configC1Path, item.configPath)
u.log.Tracef("Creating symlink from %s to %s", configPath, gadgetItemPath)
if err := ensureSymlink(configPath, gadgetItemPath); err != nil {
return err
}
}
return nil
}
func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error {
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
err := u.writeIfDifferent(filePath, []byte(val), 0644)
if err != nil {
return fmt.Errorf("failed to write to %s: %w", filePath, err)
}
}
return nil
}

View File

@ -0,0 +1,3 @@
package usbgadget
const dwc3Path = "/sys/bus/platform/drivers/dwc3"

11
internal/usbgadget/hid.go Normal file
View File

@ -0,0 +1,11 @@
package usbgadget
import "time"
func (u *UsbGadget) resetUserInputTime() {
u.lastUserInput = time.Now()
}
func (u *UsbGadget) GetLastUserInputTime() time.Time {
return u.lastUserInput
}

View File

@ -0,0 +1,95 @@
package usbgadget
import (
"fmt"
"os"
)
var keyboardConfig = gadgetConfigItem{
order: 1000,
device: "hid.usb0",
path: []string{"functions", "hid.usb0"},
configPath: []string{"hid.usb0"},
attrs: gadgetAttributes{
"protocol": "1",
"subclass": "1",
"report_length": "8",
},
reportDesc: keyboardReportDesc,
}
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
var keyboardReportDesc = []byte{
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
0x09, 0x06, /* USAGE (Keyboard) */
0xa1, 0x01, /* COLLECTION (Application) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x95, 0x08, /* REPORT_COUNT (8) */
0x81, 0x02, /* INPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
0x95, 0x01, /* REPORT_COUNT (1) */
0x75, 0x03, /* REPORT_SIZE (3) */
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
0x95, 0x06, /* REPORT_COUNT (6) */
0x75, 0x08, /* REPORT_SIZE (8) */
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
0xc0, /* END_COLLECTION */
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if u.keyboardHidFile == nil {
var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg0: %w", err)
}
}
_, err := u.keyboardHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg0: %w", err)
u.keyboardHidFile.Close()
u.keyboardHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) KeyboardReport(modifier uint8, keys []uint8) error {
u.keyboardLock.Lock()
defer u.keyboardLock.Unlock()
if len(keys) > 6 {
keys = keys[:6]
}
if len(keys) < 6 {
keys = append(keys, make([]uint8, 6-len(keys))...)
}
err := u.keyboardWriteHidFile([]byte{modifier, 0, keys[0], keys[1], keys[2], keys[3], keys[4], keys[5]})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}

View File

@ -0,0 +1,128 @@
package usbgadget
import (
"fmt"
"os"
)
var absoluteMouseConfig = gadgetConfigItem{
order: 1001,
device: "hid.usb1",
path: []string{"functions", "hid.usb1"},
configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "6",
},
reportDesc: absoluteMouseCombinedReportDesc,
}
var absoluteMouseCombinedReportDesc = []byte{
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
// Report ID 1: Absolute Mouse Movement
0x85, 0x01, // Report ID (1)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Button)
0x19, 0x01, // Usage Minimum (0x01)
0x29, 0x03, // Usage Maximum (0x03)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x03, // Report Count (3)
0x81, 0x02, // Input (Data, Var, Abs)
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x03, // Input (Cnst, Var, Abs)
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x16, 0x00, 0x00, // Logical Minimum (0)
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
0x36, 0x00, 0x00, // Physical Minimum (0)
0x46, 0xFF, 0x7F, // Physical Maximum (32767)
0x75, 0x10, // Report Size (16)
0x95, 0x02, // Report Count (2)
0x81, 0x02, // Input (Data, Var, Abs)
0xC0, // End Collection
// Report ID 2: Relative Wheel Movement
0x85, 0x02, // Report ID (2)
0x09, 0x38, // Usage (Wheel)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x01, // Report Count (1)
0x81, 0x06, // Input (Data, Var, Rel)
0xC0, // End Collection
}
func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
if u.absMouseHidFile == nil {
var err error
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg1: %w", err)
}
}
_, err := u.absMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg1: %w", err)
u.absMouseHidFile.Close()
u.absMouseHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) AbsMouseReport(x, y int, buttons uint8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
err := u.absMouseWriteHidFile([]byte{
1, // Report ID 1
buttons, // Buttons
uint8(x), // X Low Byte
uint8(x >> 8), // X High Byte
uint8(y), // Y Low Byte
uint8(y >> 8), // Y High Byte
})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}
func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error {
u.absMouseLock.Lock()
defer u.absMouseLock.Unlock()
// Accumulate the wheelY value
u.absMouseAccumulatedWheelY += float64(wheelY) / 8.0
// Only send a report if the accumulated value is significant
if abs(u.absMouseAccumulatedWheelY) < 1.0 {
return nil
}
scaledWheelY := int8(u.absMouseAccumulatedWheelY)
err := u.absMouseWriteHidFile([]byte{
2, // Report ID 2
byte(scaledWheelY), // Scaled Wheel Y (signed)
})
// Reset the accumulator, keeping any remainder
u.absMouseAccumulatedWheelY -= float64(scaledWheelY)
u.resetUserInputTime()
return err
}

View File

@ -0,0 +1,92 @@
package usbgadget
import (
"fmt"
"os"
)
var relativeMouseConfig = gadgetConfigItem{
order: 1002,
device: "hid.usb2",
path: []string{"functions", "hid.usb2"},
configPath: []string{"hid.usb2"},
attrs: gadgetAttributes{
"protocol": "2",
"subclass": "1",
"report_length": "4",
},
reportDesc: relativeMouseCombinedReportDesc,
}
// from: https://github.com/NicoHood/HID/blob/b16be57caef4295c6cd382a7e4c64db5073647f7/src/SingleReport/BootMouse.cpp#L26
var relativeMouseCombinedReportDesc = []byte{
0x05, 0x01, // USAGE_PAGE (Generic Desktop) 54
0x09, 0x02, // USAGE (Mouse)
0xa1, 0x01, // COLLECTION (Application)
// Pointer and Physical are required by Apple Recovery
0x09, 0x01, // USAGE (Pointer)
0xa1, 0x00, // COLLECTION (Physical)
// 8 Buttons
0x05, 0x09, // USAGE_PAGE (Button)
0x19, 0x01, // USAGE_MINIMUM (Button 1)
0x29, 0x08, // USAGE_MAXIMUM (Button 8)
0x15, 0x00, // LOGICAL_MINIMUM (0)
0x25, 0x01, // LOGICAL_MAXIMUM (1)
0x95, 0x08, // REPORT_COUNT (8)
0x75, 0x01, // REPORT_SIZE (1)
0x81, 0x02, // INPUT (Data,Var,Abs)
// X, Y, Wheel
0x05, 0x01, // USAGE_PAGE (Generic Desktop)
0x09, 0x30, // USAGE (X)
0x09, 0x31, // USAGE (Y)
0x09, 0x38, // USAGE (Wheel)
0x15, 0x81, // LOGICAL_MINIMUM (-127)
0x25, 0x7f, // LOGICAL_MAXIMUM (127)
0x75, 0x08, // REPORT_SIZE (8)
0x95, 0x03, // REPORT_COUNT (3)
0x81, 0x06, // INPUT (Data,Var,Rel)
// End
0xc0, // End Collection (Physical)
0xc0, // End Collection
}
func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
if u.relMouseHidFile == nil {
var err error
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
if err != nil {
return fmt.Errorf("failed to open hidg1: %w", err)
}
}
_, err := u.relMouseHidFile.Write(data)
if err != nil {
u.log.Errorf("failed to write to hidg2: %w", err)
u.relMouseHidFile.Close()
u.relMouseHidFile = nil
return err
}
return nil
}
func (u *UsbGadget) RelMouseReport(mx, my int8, buttons uint8) error {
u.relMouseLock.Lock()
defer u.relMouseLock.Unlock()
err := u.relMouseWriteHidFile([]byte{
buttons, // Buttons
uint8(mx), // X
uint8(my), // Y
0, // Wheel
})
if err != nil {
return err
}
u.resetUserInputTime()
return nil
}

View File

@ -0,0 +1,23 @@
package usbgadget
var massStorageBaseConfig = gadgetConfigItem{
order: 3000,
device: "mass_storage.usb0",
path: []string{"functions", "mass_storage.usb0"},
configPath: []string{"mass_storage.usb0"},
attrs: gadgetAttributes{
"stall": "1",
},
}
var massStorageLun0Config = gadgetConfigItem{
order: 3001,
path: []string{"functions", "mass_storage.usb0", "lun.0"},
attrs: gadgetAttributes{
"cdrom": "1",
"ro": "1",
"removable": "1",
"file": "\n",
"inquiry_string": "JetKVM Virtual Media",
},
}

109
internal/usbgadget/udc.go Normal file
View File

@ -0,0 +1,109 @@
package usbgadget
import (
"fmt"
"os"
"path"
"strings"
)
func getUdcs() []string {
var udcs []string
files, err := os.ReadDir("/sys/devices/platform/usbdrd")
if err != nil {
return nil
}
for _, file := range files {
if !file.IsDir() || !strings.HasSuffix(file.Name(), ".usb") {
continue
}
udcs = append(udcs, file.Name())
}
return udcs
}
func rebindUsb(udc string, ignoreUnbindError bool) error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(udc), 0644)
if err != nil && !ignoreUnbindError {
return err
}
err = os.WriteFile(path.Join(dwc3Path, "bind"), []byte(udc), 0644)
if err != nil {
return err
}
return nil
}
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
u.log.Infof("rebinding USB gadget to UDC %s", u.udc)
return rebindUsb(u.udc, ignoreUnbindError)
}
// RebindUsb rebinds the USB gadget to the UDC.
func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
u.configLock.Lock()
defer u.configLock.Unlock()
return u.rebindUsb(ignoreUnbindError)
}
func (u *UsbGadget) writeUDC() error {
path := path.Join(u.kvmGadgetPath, "UDC")
u.log.Tracef("writing UDC %s to %s", u.udc, path)
err := u.writeIfDifferent(path, []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("failed to write UDC: %w", err)
}
return nil
}
// GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) {
stateFile := path.Join("/sys/class/udc", u.udc, "state")
stateBytes, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
return "not attached"
} else {
u.log.Tracef("failed to read usb state: %v", err)
}
return "unknown"
}
return strings.TrimSpace(string(stateBytes))
}
// IsUDCBound checks if the UDC state is bound.
func (u *UsbGadget) IsUDCBound() (bool, error) {
udcFilePath := path.Join(dwc3Path, u.udc)
_, err := os.Stat(udcFilePath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("error checking USB emulation state: %w", err)
}
return true, nil
}
// BindUDC binds the gadget to the UDC.
func (u *UsbGadget) BindUDC() error {
err := os.WriteFile(path.Join(dwc3Path, "bind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error binding UDC: %w", err)
}
return nil
}
// UnbindUDC unbinds the gadget from the UDC.
func (u *UsbGadget) UnbindUDC() error {
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(u.udc), 0644)
if err != nil {
return fmt.Errorf("error unbinding UDC: %w", err)
}
return nil
}

View File

@ -0,0 +1,110 @@
// Package usbgadget provides a high-level interface to manage USB gadgets
// THIS PACKAGE IS FOR INTERNAL USE ONLY AND ITS API MAY CHANGE WITHOUT NOTICE
package usbgadget
import (
"os"
"path"
"sync"
"time"
"github.com/pion/logging"
)
// Devices is a struct that represents the USB devices that can be enabled on a USB gadget.
type Devices struct {
AbsoluteMouse bool `json:"absolute_mouse"`
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
}
// Config is a struct that represents the customizations for a USB gadget.
// TODO: rename to something else that won't confuse with the USB gadget configuration
type Config struct {
VendorId string `json:"vendor_id"`
ProductId string `json:"product_id"`
SerialNumber string `json:"serial_number"`
Manufacturer string `json:"manufacturer"`
Product string `json:"product"`
isEmpty bool
}
var defaultUsbGadgetDevices = Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
}
// UsbGadget is a struct that represents a USB gadget.
type UsbGadget struct {
name string
udc string
kvmGadgetPath string
configC1Path string
configMap map[string]gadgetConfigItem
customConfig Config
configLock sync.Mutex
keyboardHidFile *os.File
keyboardLock sync.Mutex
absMouseHidFile *os.File
absMouseLock sync.Mutex
relMouseHidFile *os.File
relMouseLock sync.Mutex
enabledDevices Devices
absMouseAccumulatedWheelY float64
lastUserInput time.Time
log logging.LeveledLogger
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = logging.NewDefaultLoggerFactory().NewLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *logging.LeveledLogger) *UsbGadget {
if logger == nil {
logger = &defaultLogger
}
if enabledDevices == nil {
enabledDevices = &defaultUsbGadgetDevices
}
if config == nil {
config = &Config{isEmpty: true}
}
g := &UsbGadget{
name: name,
kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: defaultGadgetConfig,
customConfig: *config,
configLock: sync.Mutex{},
keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{},
enabledDevices: *enabledDevices,
lastUserInput: time.Now(),
log: *logger,
absMouseAccumulatedWheelY: 0,
}
if err := g.Init(); err != nil {
g.log.Errorf("failed to init USB gadget: %v", err)
return nil
}
return g
}

View File

@ -0,0 +1,63 @@
package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
// Helper function to get absolute value of float64
func abs(x float64) float64 {
if x < 0 {
return -x
}
return x
}
func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...)
}
func ensureSymlink(linkPath string, target string) error {
if _, err := os.Lstat(linkPath); err == nil {
currentTarget, err := os.Readlink(linkPath)
if err != nil || currentTarget != target {
err = os.Remove(linkPath)
if err != nil {
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err)
}
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("failed to check if symlink exists: %w", err)
}
if err := os.Symlink(target, linkPath); err != nil {
return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err)
}
return nil
}
func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error {
if _, err := os.Stat(filePath); err == nil {
oldContent, err := os.ReadFile(filePath)
if err == nil {
if bytes.Equal(oldContent, content) {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Tracef("skipping writing to %s as it already has the correct content", filePath)
return nil
}
u.log.Tracef("writing to %s as it has different content old%v new%v", filePath, oldContent, content)
}
}
return os.WriteFile(filePath, content, permMode)
}

View File

@ -6,10 +6,6 @@ import (
var lastUserInput = time.Now() var lastUserInput = time.Now()
func resetUserInputTime() {
lastUserInput = time.Now()
}
var jigglerEnabled = false var jigglerEnabled = false
func rpcSetJigglerState(enabled bool) { func rpcSetJigglerState(enabled bool) {
@ -20,6 +16,8 @@ func rpcGetJigglerState() bool {
} }
func init() { func init() {
ensureConfigLoaded()
go runJiggler() go runJiggler()
} }

View File

@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -15,6 +14,8 @@ import (
"github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4"
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget"
) )
type JSONRPCRequest struct { type JSONRPCRequest struct {
@ -46,12 +47,12 @@ type BacklightSettings struct {
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) { func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response) responseBytes, err := json.Marshal(response)
if err != nil { if err != nil {
log.Println("Error marshalling JSONRPC response:", err) logger.Warnf("Error marshalling JSONRPC response: %v", err)
return return
} }
err = session.RPCChannel.SendText(string(responseBytes)) err = session.RPCChannel.SendText(string(responseBytes))
if err != nil { if err != nil {
log.Println("Error sending JSONRPC response:", err) logger.Warnf("Error sending JSONRPC response: %v", err)
return return
} }
} }
@ -64,16 +65,16 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
} }
requestBytes, err := json.Marshal(request) requestBytes, err := json.Marshal(request)
if err != nil { if err != nil {
log.Println("Error marshalling JSONRPC event:", err) logger.Warnf("Error marshalling JSONRPC event: %v", err)
return return
} }
if session == nil || session.RPCChannel == nil { if session == nil || session.RPCChannel == nil {
log.Println("RPC channel not available") logger.Info("RPC channel not available")
return return
} }
err = session.RPCChannel.SendText(string(requestBytes)) err = session.RPCChannel.SendText(string(requestBytes))
if err != nil { if err != nil {
log.Println("Error sending JSONRPC event:", err) logger.Warnf("Error sending JSONRPC event: %v", err)
return return
} }
} }
@ -94,7 +95,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return return
} }
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID) //logger.Infof("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
handler, ok := rpcHandlers[request.Method] handler, ok := rpcHandlers[request.Method]
if !ok { if !ok {
errorResponse := JSONRPCResponse{ errorResponse := JSONRPCResponse{
@ -147,7 +148,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
} }
func rpcSetStreamQualityFactor(factor float64) error { func rpcSetStreamQualityFactor(factor float64) error {
log.Printf("Setting stream quality factor to: %f", factor) logger.Infof("Setting stream quality factor to: %f", factor)
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor}) var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil { if err != nil {
return err return err
@ -183,10 +184,10 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error { func rpcSetEDID(edid string) error {
if edid == "" { if edid == "" {
log.Println("Restoring EDID to default") logger.Info("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b" edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else { } else {
log.Printf("Setting EDID to: %s", edid) logger.Infof("Setting EDID to: %s", edid)
} }
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid}) _, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
if err != nil { if err != nil {
@ -195,8 +196,7 @@ func rpcSetEDID(edid string) error {
// Save EDID to config, allowing it to be restored on reboot. // Save EDID to config, allowing it to be restored on reboot.
config.EdidString = edid config.EdidString = edid
SaveConfig() _ = SaveConfig()
return nil return nil
} }
@ -257,7 +257,7 @@ func rpcSetBacklightSettings(params BacklightSettings) error {
return fmt.Errorf("failed to save config: %w", err) 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) logger.Infof("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 // 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. // method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
@ -478,23 +478,23 @@ type RPCHandler struct {
} }
func rpcSetMassStorageMode(mode string) (string, error) { func rpcSetMassStorageMode(mode string) (string, error) {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
var cdrom bool var cdrom bool
if mode == "cdrom" { if mode == "cdrom" {
cdrom = true cdrom = true
} else if mode != "file" { } else if mode != "file" {
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode) logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
return "", fmt.Errorf("invalid mode: %s", mode) return "", fmt.Errorf("invalid mode: %s", mode)
} }
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
err := setMassStorageMode(cdrom) err := setMassStorageMode(cdrom)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err) return "", fmt.Errorf("failed to set mass storage mode: %w", err)
} }
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode) logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
// Get the updated mode after setting // Get the updated mode after setting
return rpcGetMassStorageMode() return rpcGetMassStorageMode()
@ -517,27 +517,30 @@ func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil return IsUpdatePending(), nil
} }
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
func rpcGetUsbEmulationState() (bool, error) { func rpcGetUsbEmulationState() (bool, error) {
_, err := os.Stat(udcFilePath) return gadget.IsUDCBound()
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("error checking USB emulation state: %w", err)
}
return true, nil
} }
func rpcSetUsbEmulationState(enabled bool) error { func rpcSetUsbEmulationState(enabled bool) error {
if enabled { if enabled {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644) return gadget.BindUDC()
} else { } else {
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644) return gadget.UnbindUDC()
} }
} }
func rpcGetUsbConfig() (usbgadget.Config, error) {
LoadConfig()
return *config.UsbConfig, nil
}
func rpcSetUsbConfig(usbConfig usbgadget.Config) error {
LoadConfig()
config.UsbConfig = &usbConfig
gadget.SetGadgetConfig(config.UsbConfig)
return updateUsbRelatedConfig()
}
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) { func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
if config.WakeOnLanDevices == nil { if config.WakeOnLanDevices == nil {
return []WakeOnLanDevice{}, nil return []WakeOnLanDevice{}, nil
@ -560,7 +563,7 @@ func rpcResetConfig() error {
return fmt.Errorf("failed to reset config: %w", err) return fmt.Errorf("failed to reset config: %w", err)
} }
log.Println("Configuration reset to default") logger.Info("Configuration reset to default")
return nil return nil
} }
@ -576,7 +579,7 @@ func rpcGetDCPowerState() (DCPowerState, error) {
} }
func rpcSetDCPowerState(enabled bool) error { func rpcSetDCPowerState(enabled bool) error {
log.Printf("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled) logger.Infof("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled)
err := setDCPowerState(enabled) err := setDCPowerState(enabled)
if err != nil { if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err) return fmt.Errorf("failed to set DC power state: %w", err)
@ -593,18 +596,18 @@ func rpcSetActiveExtension(extensionId string) error {
return nil return nil
} }
if config.ActiveExtension == "atx-power" { if config.ActiveExtension == "atx-power" {
unmountATXControl() _ = unmountATXControl()
} else if config.ActiveExtension == "dc-power" { } else if config.ActiveExtension == "dc-power" {
unmountDCControl() _ = unmountDCControl()
} }
config.ActiveExtension = extensionId config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil { if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err) return fmt.Errorf("failed to save config: %w", err)
} }
if extensionId == "atx-power" { if extensionId == "atx-power" {
mountATXControl() _ = mountATXControl()
} else if extensionId == "dc-power" { } else if extensionId == "dc-power" {
mountDCControl() _ = mountDCControl()
} }
return nil return nil
} }
@ -725,11 +728,70 @@ func rpcSetSerialSettings(settings SerialSettings) error {
Parity: parity, Parity: parity,
} }
port.SetMode(serialPortMode) _ = port.SetMode(serialPortMode)
return nil return nil
} }
func rpcGetUsbDevices() (usbgadget.Devices, error) {
return *config.UsbDevices, nil
}
func updateUsbRelatedConfig() error {
if err := gadget.UpdateGadgetConfig(); err != nil {
return fmt.Errorf("failed to write gadget config: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetUsbDevices(usbDevices usbgadget.Devices) error {
config.UsbDevices = &usbDevices
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetUsbDeviceState(device string, enabled bool) error {
switch device {
case "absoluteMouse":
config.UsbDevices.AbsoluteMouse = enabled
case "relativeMouse":
config.UsbDevices.RelativeMouse = enabled
case "keyboard":
config.UsbDevices.Keyboard = enabled
case "massStorage":
config.UsbDevices.MassStorage = enabled
default:
return fmt.Errorf("invalid device: %s", device)
}
gadget.SetGadgetDevices(config.UsbDevices)
return updateUsbRelatedConfig()
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var currentScrollSensitivity string = "default"
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
}
func rpcSetScrollSensitivity(sensitivity string) error {
currentScrollSensitivity = sensitivity
return nil
}
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID}, "getDeviceID": {Func: rpcGetDeviceID},
@ -737,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{
"getCloudState": {Func: rpcGetCloudState}, "getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState}, "getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState}, "getUSBState": {Func: rpcGetUSBState},
@ -764,6 +827,8 @@ var rpcHandlers = map[string]RPCHandler{
"isUpdatePending": {Func: rpcIsUpdatePending}, "isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState}, "getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}}, "setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}}, "checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState}, "getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace}, "getStorageSpace": {Func: rpcGetStorageSpace},
@ -786,4 +851,10 @@ var rpcHandlers = map[string]RPCHandler{
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}}, "setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings}, "getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}}, "setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}},
} }

2
log.go
View File

@ -5,4 +5,4 @@ import "github.com/pion/logging"
// we use logging framework from pion // we use logging framework from pion
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC // ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm") var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb") var cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud")

16
main.go
View File

@ -2,7 +2,6 @@ package kvm
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -36,6 +35,8 @@ func Main() {
StartNativeCtrlSocketServer() StartNativeCtrlSocketServer()
StartNativeVideoSocketServer() StartNativeVideoSocketServer()
initPrometheus()
go func() { go func() {
err = ExtractAndRunNativeBin() err = ExtractAndRunNativeBin()
if err != nil { if err != nil {
@ -44,11 +45,13 @@ func Main() {
} }
}() }()
initUsbGadget()
go func() { go func() {
time.Sleep(15 * time.Minute) time.Sleep(15 * time.Minute)
for { for {
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled) logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
if config.AutoUpdateEnabled == false { if !config.AutoUpdateEnabled {
return return
} }
if currentSession != nil { if currentSession != nil {
@ -66,6 +69,9 @@ func Main() {
}() }()
//go RunFuseServer() //go RunFuseServer()
go RunWebServer() go RunWebServer()
if config.TLSMode != "" {
go RunWebSecureServer()
}
// If the cloud token isn't set, the client won't be started by default. // If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client. // However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" { if config.CloudToken != "" {
@ -75,15 +81,15 @@ func Main() {
sigs := make(chan os.Signal, 1) sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs <-sigs
log.Println("JetKVM Shutting Down") logger.Info("JetKVM Shutting Down")
//if fuseServer != nil { //if fuseServer != nil {
// err := setMassStorageImage(" ") // err := setMassStorageImage(" ")
// if err != nil { // if err != nil {
// log.Printf("Failed to unmount mass storage image: %v", err) // logger.Infof("Failed to unmount mass storage image: %v", err)
// } // }
// err = fuseServer.Unmount() // err = fuseServer.Unmount()
// if err != nil { // if err != nil {
// log.Printf("Failed to unmount fuse: %v", err) // logger.Infof("Failed to unmount fuse: %v", err)
// } // }
// os.Exit(0) // os.Exit(0)

View File

@ -5,8 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"kvm/resource"
"log"
"net" "net"
"os" "os"
"os/exec" "os/exec"
@ -14,6 +12,8 @@ import (
"syscall" "syscall"
"time" "time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/webrtc/v4/pkg/media" "github.com/pion/webrtc/v4/pkg/media"
) )
@ -61,7 +61,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return nil, fmt.Errorf("error marshaling ctrl action: %w", err) return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
} }
fmt.Println("sending ctrl action", string(jsonData)) logger.Infof("sending ctrl action: %s", string(jsonData))
err = WriteCtrlMessage(jsonData) err = WriteCtrlMessage(jsonData)
if err != nil { if err != nil {
@ -91,8 +91,8 @@ func WriteCtrlMessage(message []byte) error {
return err return err
} }
var nativeCtrlSocketListener net.Listener var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener var nativeVideoSocketListener net.Listener //nolint:unused
var ctrlClientConnected = make(chan struct{}) var ctrlClientConnected = make(chan struct{})
@ -104,16 +104,18 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
// Remove the socket file if it already exists // Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil { if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil { if err := os.Remove(socketPath); err != nil {
log.Fatalf("Failed to remove existing socket file %s: %v", socketPath, err) logger.Errorf("Failed to remove existing socket file %s: %v", socketPath, err)
os.Exit(1)
} }
} }
listener, err := net.Listen("unixpacket", socketPath) listener, err := net.Listen("unixpacket", socketPath)
if err != nil { if err != nil {
log.Fatalf("Failed to start server on %s: %v", socketPath, err) logger.Errorf("Failed to start server on %s: %v", socketPath, err)
os.Exit(1)
} }
log.Printf("Server listening on %s", socketPath) logger.Infof("Server listening on %s", socketPath)
go func() { go func() {
conn, err := listener.Accept() conn, err := listener.Accept()
@ -188,24 +190,23 @@ func handleCtrlClient(conn net.Conn) {
func handleVideoClient(conn net.Conn) { func handleVideoClient(conn net.Conn) {
defer conn.Close() defer conn.Close()
log.Printf("Native video socket client connected: %v", conn.RemoteAddr()) logger.Infof("Native video socket client connected: %v", conn.RemoteAddr())
inboundPacket := make([]byte, maxFrameSize) inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now() lastFrame := time.Now()
for { for {
n, err := conn.Read(inboundPacket) n, err := conn.Read(inboundPacket)
if err != nil { if err != nil {
log.Println("error during read: %s", err) logger.Warnf("error during read: %v", err)
return return
} }
now := time.Now() now := time.Now()
sinceLastFrame := now.Sub(lastFrame) sinceLastFrame := now.Sub(lastFrame)
lastFrame = now lastFrame = now
//fmt.Println("Video packet received", n, sinceLastFrame)
if currentSession != nil { if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame}) err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil { if err != nil {
log.Println("Error writing sample", err) logger.Warnf("error writing sample: %v", err)
} }
} }
} }
@ -250,7 +251,7 @@ func ExtractAndRunNativeBin() error {
} }
}() }()
fmt.Printf("Binary started with PID: %d\n", cmd.Process.Pid) logger.Infof("Binary started with PID: %d", cmd.Process.Pid)
return nil return nil
} }

View File

@ -56,14 +56,14 @@ func setDhcpClientState(active bool) {
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc") cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err) logger.Warnf("network: setDhcpClientState: failed to change udhcpc state: %s", err)
} }
} }
func checkNetworkState() { func checkNetworkState() {
iface, err := netlink.LinkByName(NetIfName) iface, err := netlink.LinkByName(NetIfName)
if err != nil { if err != nil {
fmt.Printf("failed to get [%s] interface: %v\n", NetIfName, err) logger.Warnf("failed to get [%s] interface: %v", NetIfName, err)
return return
} }
@ -76,7 +76,7 @@ func checkNetworkState() {
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL) addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
if err != nil { if err != nil {
fmt.Printf("failed to get addresses for [%s]: %v\n", NetIfName, err) logger.Warnf("failed to get addresses for [%s]: %v", NetIfName, err)
} }
// If the link is going down, put udhcpc into idle mode. // If the link is going down, put udhcpc into idle mode.
@ -89,10 +89,10 @@ func checkNetworkState() {
if addr.IP.To4() != nil { if addr.IP.To4() != nil {
if !newState.Up && networkState.Up { if !newState.Up && networkState.Up {
// If the network is going down, remove all IPv4 addresses from the interface. // 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()) logger.Infof("network: state transitioned to down, removing IPv4 address %s", addr.IP.String())
err := netlink.AddrDel(iface, &addr) err := netlink.AddrDel(iface, &addr)
if err != nil { if err != nil {
fmt.Printf("network: failed to delete %s", addr.IP.String()) logger.Warnf("network: failed to delete %s", addr.IP.String())
} }
newState.IPv4 = "..." newState.IPv4 = "..."
@ -105,9 +105,9 @@ func checkNetworkState() {
} }
if newState != networkState { if newState != networkState {
fmt.Println("network state changed") logger.Info("network state changed")
// restart MDNS // restart MDNS
startMDNS() _ = startMDNS()
networkState = newState networkState = newState
requestDisplayUpdate() requestDisplayUpdate()
} }
@ -116,15 +116,15 @@ func checkNetworkState() {
func startMDNS() error { func startMDNS() error {
// If server was previously running, stop it // If server was previously running, stop it
if mDNSConn != nil { if mDNSConn != nil {
fmt.Printf("Stopping mDNS server\n") logger.Info("Stopping mDNS server")
err := mDNSConn.Close() err := mDNSConn.Close()
if err != nil { if err != nil {
fmt.Printf("failed to stop mDNS server: %v\n", err) logger.Warnf("failed to stop mDNS server: %v", err)
} }
} }
// Start a new server // Start a new server
fmt.Printf("Starting mDNS server on jetkvm.local\n") logger.Info("Starting mDNS server on jetkvm.local")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4) addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil { if err != nil {
return err return err
@ -181,7 +181,7 @@ func getNTPServersFromDHCPInfo() ([]string, error) {
for _, server := range strings.Fields(val) { for _, server := range strings.Fields(val) {
if net.ParseIP(server) == nil { if net.ParseIP(server) == nil {
fmt.Printf("invalid NTP server IP: %s, ignoring ... \n", server) logger.Infof("invalid NTP server IP: %s, ignoring", server)
} }
servers = append(servers, server) servers = append(servers, server)
} }
@ -190,11 +190,13 @@ func getNTPServersFromDHCPInfo() ([]string, error) {
} }
func init() { func init() {
ensureConfigLoaded()
updates := make(chan netlink.LinkUpdate) updates := make(chan netlink.LinkUpdate)
done := make(chan struct{}) done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil { if err := netlink.LinkSubscribe(updates, done); err != nil {
fmt.Println("failed to subscribe to link updates: %v", err) logger.Warnf("failed to subscribe to link updates: %v", err)
return return
} }
@ -208,7 +210,7 @@ func init() {
select { select {
case update := <-updates: case update := <-updates:
if update.Link.Attrs().Name == NetIfName { if update.Link.Attrs().Name == NetIfName {
fmt.Printf("link update: %+v\n", update) logger.Infof("link update: %+v", update)
checkNetworkState() checkNetworkState()
} }
case <-ticker.C: case <-ticker.C:
@ -220,6 +222,6 @@ func init() {
}() }()
err := startMDNS() err := startMDNS()
if err != nil { if err != nil {
fmt.Println("failed to run mDNS: %v", err) logger.Warnf("failed to run mDNS: %v", err)
} }
} }

19
ntp.go
View File

@ -3,7 +3,6 @@ package kvm
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os/exec" "os/exec"
"time" "time"
@ -21,7 +20,6 @@ const (
) )
var ( var (
timeSynced = false
timeSyncRetryInterval = 0 * time.Second timeSyncRetryInterval = 0 * time.Second
defaultNTPServers = []string{ defaultNTPServers = []string{
"time.cloudflare.com", "time.cloudflare.com",
@ -37,16 +35,16 @@ func TimeSyncLoop() {
} }
if !networkState.Up { if !networkState.Up {
log.Printf("Waiting for network to come up") logger.Infof("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt) time.Sleep(timeSyncWaitNetUpInt)
continue continue
} }
log.Printf("Syncing system time") logger.Infof("Syncing system time")
start := time.Now() start := time.Now()
err := SyncSystemTime() err := SyncSystemTime()
if err != nil { if err != nil {
log.Printf("Failed to sync system time: %v", err) logger.Warnf("Failed to sync system time: %v", err)
// retry after a delay // retry after a delay
timeSyncRetryInterval += timeSyncRetryStep timeSyncRetryInterval += timeSyncRetryStep
@ -58,8 +56,7 @@ func TimeSyncLoop() {
continue continue
} }
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start)) logger.Infof("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
timeSynced = true
time.Sleep(timeSyncInterval) // after the first sync is done time.Sleep(timeSyncInterval) // after the first sync is done
} }
} }
@ -79,20 +76,20 @@ func SyncSystemTime() (err error) {
func queryNetworkTime() (*time.Time, error) { func queryNetworkTime() (*time.Time, error) {
ntpServers, err := getNTPServersFromDHCPInfo() ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil { if err != nil {
log.Printf("failed to get NTP servers from DHCP info: %v\n", err) logger.Warnf("failed to get NTP servers from DHCP info: %v\n", err)
} }
if ntpServers == nil { if ntpServers == nil {
ntpServers = defaultNTPServers ntpServers = defaultNTPServers
log.Printf("Using default NTP servers: %v\n", ntpServers) logger.Infof("Using default NTP servers: %v\n", ntpServers)
} else { } else {
log.Printf("Using NTP servers from DHCP: %v\n", ntpServers) logger.Infof("Using NTP servers from DHCP: %v\n", ntpServers)
} }
for _, server := range ntpServers { for _, server := range ntpServers {
now, err := queryNtpServer(server, timeSyncTimeout) now, err := queryNtpServer(server, timeSyncTimeout)
if err == nil { if err == nil {
log.Printf("NTP server [%s] returned time: %v\n", server, now) logger.Infof("NTP server [%s] returned time: %v\n", server, now)
return now, nil return now, nil
} }
} }

25
ota.go
View File

@ -8,7 +8,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -77,7 +76,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateUrl.RawQuery = query.Encode() updateUrl.RawQuery = query.Encode()
fmt.Println("Checking for updates at:", updateUrl.String()) logger.Infof("Checking for updates at: %s", updateUrl)
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil) req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil { if err != nil {
@ -230,7 +229,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
} }
hashSum := hash.Sum(nil) hashSum := hash.Sum(nil)
fmt.Printf("SHA256 hash of %s: %x\n", path, hashSum) logger.Infof("SHA256 hash of %s: %x", path, hashSum)
if hex.EncodeToString(hashSum) != expectedHash { if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash) return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
@ -272,7 +271,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() { func triggerOTAStateUpdate() {
go func() { go func() {
if currentSession == nil { if currentSession == nil {
log.Println("No active RPC session, skipping update state update") logger.Info("No active RPC session, skipping update state update")
return return
} }
writeJSONRPCEvent("otaState", otaState, currentSession) writeJSONRPCEvent("otaState", otaState, currentSession)
@ -280,7 +279,7 @@ func triggerOTAStateUpdate() {
} }
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error { func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
log.Println("Trying to update...") logger.Info("Trying to update...")
if otaState.Updating { if otaState.Updating {
return fmt.Errorf("update already in progress") return fmt.Errorf("update already in progress")
} }
@ -315,7 +314,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
rebootNeeded := false rebootNeeded := false
if appUpdateAvailable { if appUpdateAvailable {
fmt.Printf("App update available: %s -> %s\n", local.AppVersion, remote.AppVersion) logger.Infof("App update available: %s -> %s", local.AppVersion, remote.AppVersion)
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress) err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil { if err != nil {
@ -341,14 +340,14 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1 otaState.AppUpdateProgress = 1
triggerOTAStateUpdate() triggerOTAStateUpdate()
fmt.Println("App update downloaded") logger.Info("App update downloaded")
rebootNeeded = true rebootNeeded = true
} else { } else {
fmt.Println("App is up to date") logger.Info("App is up to date")
} }
if systemUpdateAvailable { if systemUpdateAvailable {
fmt.Printf("System update available: %s -> %s\n", local.SystemVersion, remote.SystemVersion) logger.Infof("System update available: %s -> %s", local.SystemVersion, remote.SystemVersion)
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress) err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
if err != nil { if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err) otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
@ -366,7 +365,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate() triggerOTAStateUpdate()
return err return err
} }
fmt.Println("System update downloaded") logger.Info("System update downloaded")
verifyFinished := time.Now() verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1 otaState.SystemVerificationProgress = 1
@ -413,17 +412,17 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output) return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
} }
fmt.Printf("rk_ota success, output: %s\n", output) logger.Infof("rk_ota success, output: %s", output)
otaState.SystemUpdateProgress = 1 otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate() triggerOTAStateUpdate()
rebootNeeded = true rebootNeeded = true
} else { } else {
fmt.Println("System is up to date") logger.Info("System is up to date")
} }
if rebootNeeded { if rebootNeeded {
fmt.Println("System Rebooting in 10s...") logger.Info("System Rebooting in 10s")
time.Sleep(10 * time.Second) time.Sleep(10 * time.Second)
cmd := exec.Command("reboot") cmd := exec.Command("reboot")
err := cmd.Start() err := cmd.Start()

17
prometheus.go Normal file
View File

@ -0,0 +1,17 @@
package kvm
import (
"net/http"
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/common/version"
)
var promHandler http.Handler
func initPrometheus() {
// A Prometheus metrics endpoint.
version.Version = builtAppVersion
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
}

View File

@ -49,7 +49,7 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
if err != nil { if err != nil {
return nil, err return nil, err
} }
buf := make([]byte, 0) var buf []byte
for { for {
select { select {
case data := <-diskReadChan: case data := <-diskReadChan:

View File

@ -16,14 +16,14 @@ const serialPortPath = "/dev/ttyS3"
var port serial.Port var port serial.Port
func mountATXControl() error { func mountATXControl() error {
port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
go runATXControl() go runATXControl()
return nil return nil
} }
func unmountATXControl() error { func unmountATXControl() error {
reopenSerialPort() _ = reopenSerialPort()
return nil return nil
} }
@ -122,13 +122,13 @@ func pressATXResetButton(duration time.Duration) error {
} }
func mountDCControl() error { func mountDCControl() error {
port.SetMode(defaultMode) _ = port.SetMode(defaultMode)
go runDCControl() go runDCControl()
return nil return nil
} }
func unmountDCControl() error { func unmountDCControl() error {
reopenSerialPort() _ = reopenSerialPort()
return nil return nil
} }
@ -212,11 +212,11 @@ var defaultMode = &serial.Mode{
} }
func initSerialPort() { func initSerialPort() {
reopenSerialPort() _ = reopenSerialPort()
if config.ActiveExtension == "atx-power" { if config.ActiveExtension == "atx-power" {
mountATXControl() _ = mountATXControl()
} else if config.ActiveExtension == "dc-power" { } else if config.ActiveExtension == "dc-power" {
mountDCControl() _ = mountDCControl()
} }
} }

View File

@ -55,12 +55,14 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var size TerminalSize var size TerminalSize
err := json.Unmarshal([]byte(msg.Data), &size) err := json.Unmarshal([]byte(msg.Data), &size)
if err == nil { if err == nil {
pty.Setsize(ptmx, &pty.Winsize{ err = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows), Rows: uint16(size.Rows),
Cols: uint16(size.Cols), Cols: uint16(size.Cols),
}) })
if err == nil {
return return
} }
}
logger.Errorf("Failed to parse terminal size: %v", err) logger.Errorf("Failed to parse terminal size: %v", err)
} }
_, err := ptmx.Write(msg.Data) _, err := ptmx.Write(msg.Data)
@ -74,7 +76,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
ptmx.Close() ptmx.Close()
} }
if cmd != nil && cmd.Process != nil { if cmd != nil && cmd.Process != nil {
cmd.Process.Kill() _ = cmd.Process.Kill()
} }
}) })
} }

View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=http://localhost:3000

4
ui/.env.cloud-production Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://api.jetkvm.com

4
ui/.env.cloud-staging Normal file
View File

@ -0,0 +1,4 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@ -1,6 +0,0 @@
VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000
VITE_JETKVM_HEAD=

View File

@ -1,6 +0,0 @@
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=<script src="/device/ui-config.js"></script>

View File

@ -1,6 +0,0 @@
VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=

View File

@ -1,4 +0,0 @@
VITE_SIGNAL_API=https://staging-api.jetkvm.com
VITE_CLOUD_APP=https://staging-app.jetkvm.com
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@ -1,21 +1,19 @@
#!/bin/bash #!/bin/bash
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
echo "Usage: $0 <JetKVM IP Address>"
exit 1
fi
ip_address="$1"
# Print header # Print header
echo "┌──────────────────────────────────────┐" echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │" echo "│ JetKVM Development Setup │"
echo "└──────────────────────────────────────┘" echo "└──────────────────────────────────────┘"
# Prompt for IP address
printf "Please enter the IP address of your JetKVM device: "
read ip_address
# Validate input is not empty
if [ -z "$ip_address" ]; then
echo "Error: IP address cannot be empty"
exit 1
fi
# Set the environment variable and run Vite # Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address" echo "Starting development server with JetKVM device at: $ip_address"
sleep 1 sleep 1
JETKVM_PROXY_URL="http://$ip_address" vite dev --mode=device JETKVM_PROXY_URL="http://$ip_address" npx vite dev --mode=device

View File

@ -28,7 +28,6 @@
<title>JetKVM</title> <title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" /> <link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" /> <link rel="icon" href="/favicon.png" />
%VITE_JETKVM_HEAD%
<script> <script>
// Initial theme setup // Initial theme setup
document.documentElement.classList.toggle( document.documentElement.classList.toggle(

334
ui/package-lock.json generated
View File

@ -9,7 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@ -22,40 +22,41 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112", "react-simple-keyboard": "^3.7.112",
"react-xtermjs": "^1.0.9", "react-xtermjs": "^1.0.9",
"recharts": "^2.15.0", "recharts": "^2.15.1",
"semver": "^7.7.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.1",
"validator": "^13.12.0", "validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2", "@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.7.2", "@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.49", "postcss": "^8.5.3",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.5.13", "prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
}, },
@ -84,9 +85,9 @@
} }
}, },
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.24.4", "version": "7.26.9",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.4.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.9.tgz",
"integrity": "sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==", "integrity": "sha512-aA63XwOkcl4xxQa3HjPMqOP6LiK0ZDv3mUPYEFXkpHbaFjtGggE1A61FjFzJnB+p7/oy2gA8E+rcBNl/zC1tMg==",
"dependencies": { "dependencies": {
"regenerator-runtime": "^0.14.0" "regenerator-runtime": "^0.14.0"
}, },
@ -607,14 +608,14 @@
} }
}, },
"node_modules/@headlessui/tailwindcss": { "node_modules/@headlessui/tailwindcss": {
"version": "0.2.1", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",
"integrity": "sha512-2+5+NZ+RzMyrVeCZOxdbvkUSssSxGvcUxphkIfSVLpRiKsj+/63T2TOL9dBYMXVfj/CGr6hMxSRInzXv6YY7sA==", "integrity": "sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==",
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
"peerDependencies": { "peerDependencies": {
"tailwindcss": "^3.0" "tailwindcss": "^3.0 || ^4.0"
} }
}, },
"node_modules/@heroicons/react": { "node_modules/@heroicons/react": {
@ -1086,14 +1087,14 @@
] ]
}, },
"node_modules/@swc/core": { "node_modules/@swc/core": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.4.tgz",
"integrity": "sha512-rQ4dS6GAdmtzKiCRt3LFVxl37FaY1cgL9kSUTnhQ2xc3fmHOd7jdJK/V4pSZMG1ruGTd0bsi34O2R0Olg9Zo/w==", "integrity": "sha512-EHl6eNod/914xDRK4nu7gr78riK2cfi4DkAMvJt6COdaNGOnbR5eKrLe3SnRizyzzrPcxUMhflDL5hrcXS8rAQ==",
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@swc/counter": "^0.1.3", "@swc/counter": "^0.1.3",
"@swc/types": "^0.1.17" "@swc/types": "^0.1.19"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -1103,16 +1104,16 @@
"url": "https://opencollective.com/swc" "url": "https://opencollective.com/swc"
}, },
"optionalDependencies": { "optionalDependencies": {
"@swc/core-darwin-arm64": "1.10.1", "@swc/core-darwin-arm64": "1.11.4",
"@swc/core-darwin-x64": "1.10.1", "@swc/core-darwin-x64": "1.11.4",
"@swc/core-linux-arm-gnueabihf": "1.10.1", "@swc/core-linux-arm-gnueabihf": "1.11.4",
"@swc/core-linux-arm64-gnu": "1.10.1", "@swc/core-linux-arm64-gnu": "1.11.4",
"@swc/core-linux-arm64-musl": "1.10.1", "@swc/core-linux-arm64-musl": "1.11.4",
"@swc/core-linux-x64-gnu": "1.10.1", "@swc/core-linux-x64-gnu": "1.11.4",
"@swc/core-linux-x64-musl": "1.10.1", "@swc/core-linux-x64-musl": "1.11.4",
"@swc/core-win32-arm64-msvc": "1.10.1", "@swc/core-win32-arm64-msvc": "1.11.4",
"@swc/core-win32-ia32-msvc": "1.10.1", "@swc/core-win32-ia32-msvc": "1.11.4",
"@swc/core-win32-x64-msvc": "1.10.1" "@swc/core-win32-x64-msvc": "1.11.4"
}, },
"peerDependencies": { "peerDependencies": {
"@swc/helpers": "*" "@swc/helpers": "*"
@ -1124,9 +1125,9 @@
} }
}, },
"node_modules/@swc/core-darwin-arm64": { "node_modules/@swc/core-darwin-arm64": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.4.tgz",
"integrity": "sha512-NyELPp8EsVZtxH/mEqvzSyWpfPJ1lugpTQcSlMduZLj1EASLO4sC8wt8hmL1aizRlsbjCX+r0PyL+l0xQ64/6Q==", "integrity": "sha512-Oi4lt4wqjpp80pcCh+vzvpsESJ8XXozYCE5EM/dDpr+9m2oRpkseds7Gq4ulzgdbUDPo1jJ1PonjjrKpfKY+sQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1140,9 +1141,9 @@
} }
}, },
"node_modules/@swc/core-darwin-x64": { "node_modules/@swc/core-darwin-x64": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.4.tgz",
"integrity": "sha512-L4BNt1fdQ5ZZhAk5qoDfUnXRabDOXKnXBxMDJ+PWLSxOGBbWE6aJTnu4zbGjJvtot0KM46m2LPAPY8ttknqaZA==", "integrity": "sha512-Tb7ez94DXxhX5iJ5slnAlT2gwJinQk3pMnQ46Npi6adKr3ZXM5Bdk0jpRUp8XjEcgNXkQRV1DtrySgCz6YlEnQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1156,9 +1157,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm-gnueabihf": { "node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.4.tgz",
"integrity": "sha512-Y1u9OqCHgvVp2tYQAJ7hcU9qO5brDMIrA5R31rwWQIAKDkJKtv3IlTHF0hrbWk1wPR0ZdngkQSJZple7G+Grvw==", "integrity": "sha512-p1uV+6Mi+0M+1kL7qL206ZaohomYMW7yroXSLDTJXbIylx7wG2xrUQL6AFtz2DwqDoX/E8jMNBjp+GcEy8r8Ig==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1172,9 +1173,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm64-gnu": { "node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.4.tgz",
"integrity": "sha512-tNQHO/UKdtnqjc7o04iRXng1wTUXPgVd8Y6LI4qIbHVoVPwksZydISjMcilKNLKIwOoUQAkxyJ16SlOAeADzhQ==", "integrity": "sha512-4ijX4bWf9oc7kWkT6xUhugVGzEJ7U9c7CHNmt/xhI/yWsQdfM11+HECqWh7ay3m+aaEoVdvTeU5gykeF5jSxDA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1188,9 +1189,9 @@
} }
}, },
"node_modules/@swc/core-linux-arm64-musl": { "node_modules/@swc/core-linux-arm64-musl": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.4.tgz",
"integrity": "sha512-x0L2Pd9weQ6n8dI1z1Isq00VHFvpBClwQJvrt3NHzmR+1wCT/gcYl1tp9P5xHh3ldM8Cn4UjWCw+7PaUgg8FcQ==", "integrity": "sha512-XI+gOgcuSanejbAC5QXKTjNA3GUJi7bzHmeJbNhKpX9d349RdVwan0k9okHmhMBY7BywAg3LK0ovF9PmOLgMHg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1204,9 +1205,9 @@
} }
}, },
"node_modules/@swc/core-linux-x64-gnu": { "node_modules/@swc/core-linux-x64-gnu": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.4.tgz",
"integrity": "sha512-yyYEwQcObV3AUsC79rSzN9z6kiWxKAVJ6Ntwq2N9YoZqSPYph+4/Am5fM1xEQYf/kb99csj0FgOelomJSobxQA==", "integrity": "sha512-wyD6noaCPFayKOvl9mTxuiQoEULAagGuO0od2VkW7h4HvlgpOAZNekZYX73WEP/b+WuePNHurZ9KGpom43IzmA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1220,9 +1221,9 @@
} }
}, },
"node_modules/@swc/core-linux-x64-musl": { "node_modules/@swc/core-linux-x64-musl": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.4.tgz",
"integrity": "sha512-tcaS43Ydd7Fk7sW5ROpaf2Kq1zR+sI5K0RM+0qYLYYurvsJruj3GhBCaiN3gkzd8m/8wkqNqtVklWaQYSDsyqA==", "integrity": "sha512-e2vG9gUF1BRX0BWqSEHop6u14l5BtV3VS2Pmr+oquc0Ycs/zj81xhYc3ML4ByK5OxDkAaKBWryAOKTLaJA/DVg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1236,9 +1237,9 @@
} }
}, },
"node_modules/@swc/core-win32-arm64-msvc": { "node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.4.tgz",
"integrity": "sha512-D3Qo1voA7AkbOzQ2UGuKNHfYGKL6eejN8VWOoQYtGHHQi1p5KK/Q7V1ku55oxXBsj79Ny5FRMqiRJpVGad7bjQ==", "integrity": "sha512-rm51iljNqjCA/41gxYameuyjX1ENaTlvdxmaoPPYeUDt6hfypG93IxMJJCewaeHN9XfNxqZU7d4cupNqk+8nng==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1252,9 +1253,9 @@
} }
}, },
"node_modules/@swc/core-win32-ia32-msvc": { "node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.4.tgz",
"integrity": "sha512-WalYdFoU3454Og+sDKHM1MrjvxUGwA2oralknXkXL8S0I/8RkWZOB++p3pLaGbTvOO++T+6znFbQdR8KRaa7DA==", "integrity": "sha512-PHy3N6zlyU8te7Umi0ggXNbcx2VUkwpE59PW9FQQy9MBZM1Qn+OEGnO/4KLWjGFABw+9CwIeaRYgq6uCi1ry6A==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1268,9 +1269,9 @@
} }
}, },
"node_modules/@swc/core-win32-x64-msvc": { "node_modules/@swc/core-win32-x64-msvc": {
"version": "1.10.1", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.1.tgz", "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.4.tgz",
"integrity": "sha512-JWobfQDbTnoqaIwPKQ3DVSywihVXlQMbDuwik/dDWlj33A8oEHcjPOGs4OqcA3RHv24i+lfCQpM3Mn4FAMfacA==", "integrity": "sha512-0TiriDGl7Dr4ObfMBk07PS4Ql5hgQH0QnU3E8I+fbs45hqfwC5OrN47HOsXx4ZbEw8XYxp2NM8SGnVoTIm4J8w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1298,30 +1299,30 @@
} }
}, },
"node_modules/@swc/types": { "node_modules/@swc/types": {
"version": "0.1.17", "version": "0.1.19",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz",
"integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==", "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@swc/counter": "^0.1.3" "@swc/counter": "^0.1.3"
} }
}, },
"node_modules/@tailwindcss/forms": { "node_modules/@tailwindcss/forms": {
"version": "0.5.9", "version": "0.5.10",
"resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.9.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.10.tgz",
"integrity": "sha512-tM4XVr2+UVTxXJzey9Twx48c1gcxFStqn1pQz0tRsX8o3DvxhN5oY5pvyAbUx7VTaZxpej4Zzvc6h+1RJBzpIg==", "integrity": "sha512-utI1ONF6uf/pPNO68kmN1b8rEwNXv3czukalo8VtJH8ksIkZXr3Q3VYudZLkCsDd4Wku120uF02hYK25XGPorw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"mini-svg-data-uri": "^1.2.3" "mini-svg-data-uri": "^1.2.3"
}, },
"peerDependencies": { "peerDependencies": {
"tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20" "tailwindcss": ">=3.0.0 || >= 3.0.0-alpha.1 || >= 4.0.0-alpha.20 || >= 4.0.0-beta.1"
} }
}, },
"node_modules/@tailwindcss/typography": { "node_modules/@tailwindcss/typography": {
"version": "0.5.15", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
"integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"lodash.castarray": "^4.4.0", "lodash.castarray": "^4.4.0",
@ -1330,7 +1331,7 @@
"postcss-selector-parser": "6.0.10" "postcss-selector-parser": "6.0.10"
}, },
"peerDependencies": { "peerDependencies": {
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
} }
}, },
"node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": {
@ -1671,12 +1672,12 @@
"dev": true "dev": true
}, },
"node_modules/@vitejs/plugin-react-swc": { "node_modules/@vitejs/plugin-react-swc": {
"version": "3.7.2", "version": "3.8.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.7.2.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.8.0.tgz",
"integrity": "sha512-y0byko2b2tSVVf5Gpng1eEhX1OvPC7x8yns1Fx8jDzlJp4LS6CMkCPfLw47cjyoMrshQDoQw4qcgjsU9VvlCew==", "integrity": "sha512-T4sHPvS+DIqDP51ifPqa9XIRAz/kIvIi8oXcnOZZgHmMotgmmdxe/DD5tMFlt5nuIRzT0/QuiwmKlH0503Aapw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@swc/core": "^1.7.26" "@swc/core": "^1.10.15"
}, },
"peerDependencies": { "peerDependencies": {
"vite": "^4 || ^5 || ^6" "vite": "^4 || ^5 || ^6"
@ -3047,9 +3048,9 @@
"dev": true "dev": true
}, },
"node_modules/fast-equals": { "node_modules/fast-equals": {
"version": "5.0.1", "version": "5.2.2",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.0.1.tgz", "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz",
"integrity": "sha512-WF1Wi8PwwSY7/6Kx0vKXtw8RwuSGoM1bvDaJbu7MxDlR1vovZjIAKrnzyrThgAjm6JDTu0fVgWXDlMGspodfoQ==", "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@ -3444,9 +3445,9 @@
"dev": true "dev": true
}, },
"node_modules/goober": { "node_modules/goober": {
"version": "2.1.14", "version": "2.1.16",
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.14.tgz", "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
"integrity": "sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==", "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
"peerDependencies": { "peerDependencies": {
"csstype": "^3.0.10" "csstype": "^3.0.10"
} }
@ -4183,18 +4184,6 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"dev": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -4245,6 +4234,31 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.4.7.tgz",
"integrity": "sha512-mhegHAbf1r80fr+ytC6OkjKvIUegRNXKLWNPrCN2+GnixlNSPwT03FtKqp9oDny1kNcLWZvwbmEr+JqVryFrcg==",
"dependencies": {
"framer-motion": "^12.4.7",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion-dom": { "node_modules/motion-dom": {
"version": "11.14.3", "version": "11.14.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.14.3.tgz",
@ -4255,6 +4269,45 @@
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz", "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.14.3.tgz",
"integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ==" "integrity": "sha512-Xg+8xnqIJTpr0L/cidfTTBFkvRw26ZtGGuIhA94J9PQ2p4mEa06Xx7QVYZH0BP+EpMSaDlu+q0I0mmvwADPsaQ=="
}, },
"node_modules/motion/node_modules/framer-motion": {
"version": "12.4.7",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.4.7.tgz",
"integrity": "sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==",
"dependencies": {
"motion-dom": "^12.4.5",
"motion-utils": "^12.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/motion/node_modules/motion-dom": {
"version": "12.4.5",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.4.5.tgz",
"integrity": "sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==",
"dependencies": {
"motion-utils": "^12.0.0"
}
},
"node_modules/motion/node_modules/motion-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA=="
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@ -4272,9 +4325,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.7", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -4608,9 +4661,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.4.49", "version": "8.5.3",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
"integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -4626,7 +4679,7 @@
} }
], ],
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.8",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@ -4769,9 +4822,9 @@
} }
}, },
"node_modules/prettier": { "node_modules/prettier": {
"version": "3.4.2", "version": "3.5.2",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.2.tgz", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.2.tgz",
"integrity": "sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==", "integrity": "sha512-lc6npv5PH7hVqozBR7lkBNOGXV9vMwROAPlumdBkX0wTbbzPu/U1hk5yL8p2pt4Xoc+2mkT8t/sow2YrV/M5qg==",
"dev": true, "dev": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
@ -4931,11 +4984,12 @@
} }
}, },
"node_modules/react-hot-toast": { "node_modules/react-hot-toast": {
"version": "2.4.1", "version": "2.5.2",
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
"integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", "integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
"dependencies": { "dependencies": {
"goober": "^2.1.10" "csstype": "^3.1.3",
"goober": "^2.1.16"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=10"
@ -4946,9 +5000,9 @@
} }
}, },
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.4.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.4.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-7eltJxgVt7X64oHh6wSWNwwbKTCtMfK35hcjvJS0yxEAhPM8oUKdS3+kqaW1vicIltw+kR2unHaa12S9pPALoQ==", "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"peerDependencies": { "peerDependencies": {
"react": "*" "react": "*"
} }
@ -4998,17 +5052,17 @@
} }
}, },
"node_modules/react-smooth": { "node_modules/react-smooth": {
"version": "4.0.1", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.1.tgz", "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz",
"integrity": "sha512-OE4hm7XqR0jNOq3Qmk9mFLyd6p2+j6bvbPJ7qlB7+oo0eNcL2l7WQzG6MBnT3EXY6xzkLMUBec3AfewJdA0J8w==", "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==",
"dependencies": { "dependencies": {
"fast-equals": "^5.0.1", "fast-equals": "^5.0.1",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react-transition-group": "^4.4.5" "react-transition-group": "^4.4.5"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/react-transition-group": { "node_modules/react-transition-group": {
@ -5054,15 +5108,15 @@
} }
}, },
"node_modules/recharts": { "node_modules/recharts": {
"version": "2.15.0", "version": "2.15.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.0.tgz", "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.1.tgz",
"integrity": "sha512-cIvMxDfpAmqAmVgc4yb7pgm/O1tmmkl/CjrvXuW+62/+7jj/iF9Ykm+hb/UJt42TREHMyd3gb+pkgoa2MxgDIw==", "integrity": "sha512-v8PUTUlyiDe56qUj82w/EDVuzEFXwEHp9/xOowGAZwfLjB9uAy3GllQVIYMWF6nU+qibx85WF75zD7AjqoT54Q==",
"dependencies": { "dependencies": {
"clsx": "^2.0.0", "clsx": "^2.0.0",
"eventemitter3": "^4.0.1", "eventemitter3": "^4.0.1",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"react-is": "^18.3.1", "react-is": "^18.3.1",
"react-smooth": "^4.0.0", "react-smooth": "^4.0.4",
"recharts-scale": "^0.4.4", "recharts-scale": "^0.4.4",
"tiny-invariant": "^1.3.1", "tiny-invariant": "^1.3.1",
"victory-vendor": "^36.6.8" "victory-vendor": "^36.6.8"
@ -5282,13 +5336,9 @@
} }
}, },
"node_modules/semver": { "node_modules/semver": {
"version": "7.6.0", "version": "7.7.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz",
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==",
"dev": true,
"dependencies": {
"lru-cache": "^6.0.0"
},
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@ -5875,9 +5925,9 @@
} }
}, },
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.7.2", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true, "devOptional": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@ -5950,9 +6000,9 @@
} }
}, },
"node_modules/usehooks-ts": { "node_modules/usehooks-ts": {
"version": "3.1.0", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.0.tgz", "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==", "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"dependencies": { "dependencies": {
"lodash.debounce": "^4.0.8" "lodash.debounce": "^4.0.8"
}, },
@ -5960,7 +6010,7 @@
"node": ">=16.15.0" "node": ">=16.15.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^16.8.0 || ^17 || ^18" "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
} }
}, },
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
@ -6257,18 +6307,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"node_modules/xterm": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
"deprecated": "This package is now deprecated. Move to @xterm/xterm instead."
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"dev": true
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.1.tgz",

View File

@ -8,17 +8,17 @@
}, },
"scripts": { "scripts": {
"dev": "./dev_device.sh", "dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=development", "dev:cloud": "vite dev --mode=cloud-development",
"build": "npm run build:prod", "build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir", "build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=staging", "build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=production", "build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.0", "@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@xterm/addon-clipboard": "^0.1.0", "@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0", "@xterm/addon-fit": "^0.10.0",
@ -31,40 +31,41 @@
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4", "mini-svg-data-uri": "^1.4.4",
"motion": "^12.4.7",
"react": "^18.2.0", "react": "^18.2.0",
"react-animate-height": "^3.2.3", "react-animate-height": "^3.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1", "react-hot-toast": "^2.5.2",
"react-icons": "^5.4.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112", "react-simple-keyboard": "^3.7.112",
"react-xtermjs": "^1.0.9", "react-xtermjs": "^1.0.9",
"recharts": "^2.15.0", "recharts": "^2.15.1",
"semver": "^7.7.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"usehooks-ts": "^3.1.0", "usehooks-ts": "^3.1.1",
"validator": "^13.12.0", "validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.9", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.16",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/validator": "^13.12.2", "@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0", "@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.7.2", "@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1", "eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6", "eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.49", "postcss": "^8.5.3",
"prettier": "^3.4.2", "prettier": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.5.13", "prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.3",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^4.3.2" "vite-tsconfig-paths": "^4.3.2"
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -17,12 +17,14 @@ import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid"; import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover"; import ExtensionPopover from "./popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
export default function Actionbar({ export default function Actionbar({
requestFullscreen, requestFullscreen,
}: { }: {
requestFullscreen: () => Promise<void>; requestFullscreen: () => Promise<void>;
}) { }) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled); const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
@ -149,7 +151,7 @@ export default function Actionbar({
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Wake on Lan" text="Wake on LAN"
onClick={() => { onClick={() => {
setDisableFocusTrap(true); setDisableFocusTrap(true);
}} }}
@ -260,15 +262,16 @@ export default function Actionbar({
/> />
</div> </div>
<div className="hidden xs:block "> <div>
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => toggleSidebarView("system")} onClick={() => navigateTo("/settings")}
/> />
</div> </div>
<div className="hidden items-center gap-x-2 lg:flex"> <div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" /> <div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button <Button

View File

@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
{...props} {...props}
height={height} height={height}
duration={300} duration={300}
contentClassName="auto-content pointer-events-none" contentClassName="h-fit"
contentRef={contentDiv} contentRef={contentDiv}
disableDisplayNone disableDisplayNone
> >

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { forwardRef } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
type CardPropsType = { type CardPropsType = {
@ -16,23 +16,28 @@ export const GridCard = ({
return ( return (
<Card className={cx("overflow-hidden", cardClassName)}> <Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full"> <div className="relative h-full">
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" /> <div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" />
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" /> <div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
<div className="h-full isolate">{children}</div> <div className="isolate h-full">{children}</div>
</div> </div>
</Card> </Card>
); );
}; };
export default function Card({ children, className }: CardPropsType) { const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
return ( return (
<div <div
ref={ref}
className={cx( className={cx(
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30", "w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
className, className,
)} )}
> >
{children} {children}
</div> </div>
); );
} });
Card.displayName = "Card";
export default Card;

View File

@ -0,0 +1,27 @@
import { useEffect } from "react";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
export function FeatureFlag({
minAppVersion,
name = "unnamed",
fallback = null,
children,
}: {
minAppVersion: string;
name?: string;
fallback?: React.ReactNode;
children: React.ReactNode;
}) {
const { isEnabled, appVersion } = useFeatureFlag(minAppVersion);
useEffect(() => {
if (!appVersion) return;
console.log(
`Feature '${name}' ${isEnabled ? "ENABLED" : "DISABLED"}: ` +
`Current version: ${appVersion}, ` +
`Required min version: ${minAppVersion || "N/A"}`,
);
}, [isEnabled, name, minAppVersion, appVersion]);
return isEnabled ? children : fallback;
}

View File

@ -26,7 +26,7 @@ export default function FieldLabel({
> >
{label} {label}
{description && ( {description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600"> <span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description} {description}
</span> </span>
)} )}
@ -34,12 +34,12 @@ export default function FieldLabel({
); );
} else if (as === "span") { } else if (as === "span") {
return ( return (
<div className="flex flex-col select-none"> <div className="flex select-none flex-col">
<span className="font-display text-[13px] font-medium leading-snug text-black"> <span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
{label} {label}
</span> </span>
{description && ( {description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600"> <span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
{description} {description}
</span> </span>
)} )}

View File

@ -1,7 +1,7 @@
import { Fragment, useCallback } from "react"; import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton, Transition } from "@headlessui/react"; import { Menu, MenuButton } from "@headlessui/react";
import Container from "@/components/Container"; import Container from "@/components/Container";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu"; import { LuMonitorSmartphone } from "react-icons/lu";
@ -14,7 +14,7 @@ import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api"; import api from "../api";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button"; import { Button, LinkButton } from "./Button";
import { CLOUD_API, SIGNAL_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -37,24 +37,22 @@ export default function DashboardNavbar({
const setUser = useUserStore(state => state.setUser); const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate(); const navigate = useNavigate();
const onLogout = useCallback(async () => { const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
? `${SIGNAL_API}/auth/logout`
: `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl); const res = await api.POST(logoutUrl);
if (!res.ok) return; if (!res.ok) return;
setUser(null); setUser(null);
// The root route will redirect to appropiate login page, be it the local one or the cloud one // The root route will redirect to appropriate login page, be it the local one or the cloud one
navigate("/"); navigate("/");
}, [navigate, setUser]); }, [navigate, setUser]);
const usbState = useHidStore(state => state.usbState); const usbState = useHidStore(state => state.usbState);
return ( return (
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900"> <div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<Container> <Container>
<div className="flex items-center justify-between h-14"> <div className="flex h-14 items-center justify-between">
<div className="flex items-center shrink-0 gap-x-8"> <div className="flex shrink-0 items-center gap-x-8">
<div className="inline-block shrink-0"> <div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> <img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" /> <img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
@ -75,10 +73,10 @@ export default function DashboardNavbar({
})} })}
</div> </div>
</div> </div>
<div className="flex items-center justify-end w-full gap-x-2"> <div className="flex w-full items-center justify-end gap-x-2">
<div className="flex items-center space-x-4 shrink-0"> <div className="flex shrink-0 items-center space-x-4">
{showConnectionStatus && ( {showConnectionStatus && (
<div className="items-center hidden gap-x-2 md:flex"> <div className="hidden items-center gap-x-2 md:flex">
<div className="w-[159px]"> <div className="w-[159px]">
<PeerConnectionStatusCard <PeerConnectionStatusCard
state={peerConnectionState} state={peerConnectionState}
@ -105,10 +103,10 @@ export default function DashboardNavbar({
text={ text={
<> <>
{picture ? <></> : userEmail} {picture ? <></> : userEmail}
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" /> <ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
</> </>
} }
LeadingIcon={({ className }) => ( LeadingIcon={({ className }) =>
picture && ( picture && (
<img <img
src={picture} src={picture}
@ -119,29 +117,19 @@ export default function DashboardNavbar({
)} )}
/> />
) )
)} }
/> />
</MenuButton> </MenuButton>
</div> </div>
<Transition
as={Fragment} <Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white"> <div className="space-y-1 p-1 dark:text-white">
{userEmail && ( {userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20"> <div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item> <Menu.Item>
<div className="p-2"> <div className="p-2">
<div className="text-xs font-display"> <div className="font-display text-xs">Logged in as</div>
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold"> <div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail} {userEmail}
</div> </div>
@ -153,8 +141,8 @@ export default function DashboardNavbar({
<Menu.Item> <Menu.Item>
<div onClick={onLogout}> <div onClick={onLogout}>
<button className="block w-full"> <button className="block w-full">
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700"> <div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" /> <ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div> <div className="font-display">Log out</div>
</div> </div>
</button> </button>
@ -164,7 +152,6 @@ export default function DashboardNavbar({
</div> </div>
</Card> </Card>
</Menu.Items> </Menu.Items>
</Transition>
</Menu> </Menu>
</> </>
) : null} ) : null}

View File

@ -14,6 +14,7 @@ export default function InfoBar() {
const activeModifiers = useHidStore(state => state.activeModifiers); const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX); const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY); const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
@ -62,7 +63,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{settings.debugMode ? ( {(settings.debugMode && settings.mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span> <span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs"> <span className="text-xs">
@ -71,6 +72,17 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs">
{mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
"N/A"}
</span>
</div>
) : null}
{settings.debugMode && ( {settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span> <span className="text-xs font-semibold">USB State:</span>

View File

@ -12,7 +12,7 @@ function getRelativeTimeString(date: Date | number, lang = navigator.language):
// Get the amount of seconds between the given date and now // Get the amount of seconds between the given date and now
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000); const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
// Array reprsenting one minute, hour, day, week, month, etc in seconds // Array representing one minute, hour, day, week, month, etc in seconds
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity]; const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
// Array equivalent to the above but in the string representation of the units // Array equivalent to the above but in the string representation of the units
@ -52,7 +52,7 @@ export default function KvmCard({
return ( return (
<Card> <Card>
<div className="px-5 py-5 space-y-3"> <div className="px-5 py-5 space-y-3">
<div className="flex justify-between items-cente"> <div className="flex justify-between items-center">
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-lg font-bold leading-none text-black dark:text-white"> <div className="text-lg font-bold leading-none text-black dark:text-white">
{title} {title}

View File

@ -2,7 +2,7 @@ import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react"; import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
export default function Modal({ const Modal = React.memo(function Modal({
children, children,
className, className,
open, open,
@ -17,22 +17,25 @@ export default function Modal({
<Dialog open={open} onClose={onClose} className="relative z-10"> <Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in" className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
/> />
<div className="fixed inset-0 z-10 w-screen overflow-y-auto"> <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0"> {/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<DialogPanel <DialogPanel
transition transition
className={cx( className={cx(
"pointer-events-none relative w-full sm:my-8", "pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in", "transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
className, className,
)} )}
> >
<div className="inline-block w-full text-left pointer-events-auto"> <div className="pointer-events-auto inline-block w-full text-left">
<div className="flex justify-center" onClick={onClose}> <div className="flex justify-center" onClick={onClose}>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}> <div
className="pointer-events-none w-full"
onClick={e => e.stopPropagation()}
>
{children} {children}
</div> </div>
</div> </div>
@ -42,4 +45,6 @@ export default function Modal({
</div> </div>
</Dialog> </Dialog>
); );
} });
export default Modal;

View File

@ -19,7 +19,7 @@ type SelectMenuProps = Pick<
direction?: "vertical" | "horizontal"; direction?: "vertical" | "horizontal";
error?: string; error?: string;
fullWidth?: boolean; fullWidth?: boolean;
} & React.ComponentProps<typeof FieldLabel>; } & Partial<React.ComponentProps<typeof FieldLabel>>;
const sizes = { const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs", XS: "h-[24.5px] pl-3 pr-8 text-xs",
@ -60,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0"> <Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -69,10 +69,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0", "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900", "hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
// Invalid // Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2", "invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -82,9 +85,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// Disabled // Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80", "disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)} )}
value={value} value={value}
id={id} id={id}

View File

@ -1,6 +1,6 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
export function SectionHeader({ export function SettingsPageHeader({
title, title,
description, description,
}: { }: {
@ -8,8 +8,8 @@ export function SectionHeader({
description: string | ReactNode; description: string | ReactNode;
}) { }) {
return ( return (
<div> <div className="select-none">
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2> <h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
<div className="text-sm text-black dark:text-slate-300">{description}</div> <div className="text-sm text-black dark:text-slate-300">{description}</div>
</div> </div>
); );

View File

@ -0,0 +1,16 @@
import { ReactNode } from "react";
export function SettingsSectionHeader({
title,
description,
}: {
title: string | ReactNode;
description: string | ReactNode;
}) {
return (
<div className="select-none">
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
</div>
);
}

View File

@ -84,10 +84,23 @@ function Terminal({
if (readyState !== "open") return; if (readyState !== "open") return;
const abortController = new AbortController(); const abortController = new AbortController();
const binaryType = dataChannel.binaryType;
dataChannel.addEventListener( dataChannel.addEventListener(
"message", "message",
e => { e => {
// Handle binary data differently based on browser implementation
// Firefox sends data as blobs, chrome sends data as arraybuffer
if (binaryType === "arraybuffer") {
instance?.write(new Uint8Array(e.data)); instance?.write(new Uint8Array(e.data));
} else if (binaryType === "blob") {
const reader = new FileReader();
reader.onload = () => {
if (!instance) return;
if (!reader.result) return;
instance.write(new Uint8Array(reader.result as ArrayBuffer));
};
reader.readAsArrayBuffer(e.data);
}
}, },
{ signal: abortController.signal }, { signal: abortController.signal },
); );
@ -106,6 +119,11 @@ function Terminal({
} }
}); });
// Send initial terminal size
if (dataChannel.readyState === "open") {
dataChannel.send(JSON.stringify({ rows: instance?.rows, cols: instance?.cols }));
}
return () => { return () => {
abortController.abort(); abortController.abort();
onDataHandler?.dispose(); onDataHandler?.dispose();

View File

@ -68,7 +68,7 @@ export default function USBStateStatus({
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.log("Unsupport USB state: ", state); console.log("Unsupported USB state: ", state);
return; return;
} }

View File

@ -2,25 +2,19 @@ import { cx } from "@/cva.config";
import { Button } from "./Button"; import { Button } from "./Button";
import { GridCard } from "./Card"; import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner"; import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
interface UpdateInProgressStatusCardProps { export default function UpdateInProgressStatusCard() {
setIsUpdateDialogOpen: (isOpen: boolean) => void; const { navigateTo } = useDeviceUiNavigation();
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return ( return (
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none"> <div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
<GridCard cardClassName="!shadow-xl"> <GridCard cardClassName="!shadow-xl">
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white"> <div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
<div className="flex items-center gap-x-3"> <div className="flex items-center gap-x-3">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} /> <LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<div className="space-y-1"> <div className="space-y-1">
<div className="text-sm font-semibold leading-none transition text-ellipsis"> <div className="text-ellipsis text-sm font-semibold leading-none transition">
Update in Progress Update in Progress
</div> </div>
<div className="text-sm leading-none"> <div className="text-sm leading-none">
@ -37,10 +31,7 @@ export default function UpdateInProgressStatusCard({
className="pointer-events-auto" className="pointer-events-auto"
theme="light" theme="light"
text="View Details" text="View Details"
onClick={() => { onClick={() => navigateTo("/settings/general/update")}
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/> />
</div> </div>
</GridCard> </GridCard>

View File

@ -0,0 +1,242 @@
import { useCallback } from "react";
import { useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import Checkbox from "./Checkbox";
import { Button } from "./Button";
import { SelectMenuBasic } from "./SelectMenuBasic";
import { SettingsSectionHeader } from "./SettingsSectionHeader";
import Fieldset from "./Fieldset";
export interface USBConfig {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
export interface UsbDeviceConfig {
keyboard: boolean;
absolute_mouse: boolean;
relative_mouse: boolean;
mass_storage: boolean;
}
const defaultUsbDeviceConfig: UsbDeviceConfig = {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
};
const usbPresets = [
{
label: "Keyboard, Mouse and Mass Storage",
value: "default",
config: {
keyboard: true,
absolute_mouse: true,
relative_mouse: true,
mass_storage: true,
},
},
{
label: "Keyboard Only",
value: "keyboard_only",
config: {
keyboard: true,
absolute_mouse: false,
relative_mouse: false,
mass_storage: false,
},
},
{
label: "Custom",
value: "custom",
},
];
export function UsbDeviceSetting() {
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbDeviceConfig, setUsbDeviceConfig] =
useState<UsbDeviceConfig>(defaultUsbDeviceConfig);
const [selectedPreset, setSelectedPreset] = useState<string>("default");
const syncUsbDeviceConfig = useCallback(() => {
send("getUsbDevices", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB devices:", resp.error);
notifications.error(
`Failed to load USB devices: ${resp.error.data || "Unknown error"}`,
);
} else {
const usbConfigState = resp.result as UsbDeviceConfig;
setUsbDeviceConfig(usbConfigState);
// Set the appropriate preset based on current config
const matchingPreset = usbPresets.find(
preset =>
preset.value !== "custom" &&
preset.config &&
Object.keys(preset.config).length === Object.keys(usbConfigState).length &&
Object.keys(preset.config).every(key => {
const configKey = key as keyof typeof preset.config;
return preset.config[configKey] === usbConfigState[configKey];
}),
);
setSelectedPreset(matchingPreset ? matchingPreset.value : "custom");
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(devices: UsbDeviceConfig) => {
setLoading(true);
send("setUsbDevices", { devices }, async resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb devices: ${resp.error.data || "Unknown error"}`,
);
setLoading(false);
return;
}
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
syncUsbDeviceConfig();
notifications.success(`USB Devices updated`);
});
},
[send, syncUsbDeviceConfig],
);
const onUsbConfigItemChange = useCallback(
(key: keyof UsbDeviceConfig) => (e: React.ChangeEvent<HTMLInputElement>) => {
setUsbDeviceConfig(val => {
val[key] = e.target.checked;
handleUsbConfigChange(val);
return val;
});
},
[handleUsbConfigChange],
);
const handlePresetChange = useCallback(
async (e: React.ChangeEvent<HTMLSelectElement>) => {
const newPreset = e.target.value;
setSelectedPreset(newPreset);
if (newPreset !== "custom") {
const presetConfig = usbPresets.find(
preset => preset.value === newPreset,
)?.config;
if (presetConfig) {
handleUsbConfigChange(presetConfig);
}
}
},
[handleUsbConfigChange],
);
useEffect(() => {
syncUsbDeviceConfig();
}, [syncUsbDeviceConfig]);
return (
<Fieldset disabled={loading} className="space-y-4">
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="USB Device"
description="USB devices to emulate on the target computer"
/>
<SettingsItem
loading={loading}
title="Classes"
description="USB device classes in the composite device"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[292px]"
value={selectedPreset}
fullWidth
onChange={handlePresetChange}
options={usbPresets}
/>
</SettingsItem>
{selectedPreset === "custom" && (
<div className="ml-2 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem title="Enable Keyboard" description="Enable Keyboard">
<Checkbox
checked={usbDeviceConfig.keyboard}
onChange={onUsbConfigItemChange("keyboard")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Absolute Mouse (Pointer)"
description="Enable Absolute Mouse (Pointer)"
>
<Checkbox
checked={usbDeviceConfig.absolute_mouse}
onChange={onUsbConfigItemChange("absolute_mouse")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable Relative Mouse"
description="Enable Relative Mouse"
>
<Checkbox
checked={usbDeviceConfig.relative_mouse}
onChange={onUsbConfigItemChange("relative_mouse")}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<SettingsItem
title="Enable USB Mass Storage"
description="Sometimes it might need to be disabled to prevent issues with certain devices"
>
<Checkbox
checked={usbDeviceConfig.mass_storage}
onChange={onUsbConfigItemChange("mass_storage")}
/>
</SettingsItem>
</div>
</div>
<div className="mt-6 flex gap-x-2">
<Button
size="SM"
loading={loading}
theme="primary"
text="Update USB Classes"
onClick={() => handleUsbConfigChange(usbDeviceConfig)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={() => handleUsbConfigChange(defaultUsbDeviceConfig)}
/>
</div>
</div>
)}
</Fieldset>
);
}

View File

@ -0,0 +1,303 @@
import { useMemo } from "react";
import { useCallback } from "react";
import { Button } from "@components/Button";
import { InputFieldWithLabel } from "./InputField";
import { useEffect, useState } from "react";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import { SelectMenuBasic } from "./SelectMenuBasic";
import Fieldset from "./Fieldset";
const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&");
function generateNumber(min: number, max: number) {
return Math.floor(Math.random() * (max - min + 1) + min);
}
function generateHex(min: number, max: number) {
const len = generateNumber(min, max);
const n = (Math.random() * 0xfffff * 1000000).toString(16);
return n.slice(0, len);
}
export interface USBConfig {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
const usbConfigs = [
{
label: "JetKVM Default",
value: "USB Emulation Device",
},
{
label: "Logitech Universal Adapter",
value: "Logitech USB Input Device",
},
{
label: "Microsoft Wireless MultiMedia Keyboard",
value: "Wireless MultiMedia Keyboard",
},
{
label: "Dell Multimedia Pro Keyboard",
value: "Multimedia Pro Keyboard",
},
];
type UsbConfigMap = Record<string, USBConfig>;
export function UsbInfoSetting() {
const [send] = useJsonRpc();
const [loading, setLoading] = useState(false);
const [usbConfigProduct, setUsbConfigProduct] = useState("");
const [deviceId, setDeviceId] = useState("");
const usbConfigData: UsbConfigMap = useMemo(
() => ({
"USB Emulation Device": {
vendor_id: "0x1d6b",
product_id: "0x0104",
serial_number: deviceId,
manufacturer: "JetKVM",
product: "USB Emulation Device",
},
"Logitech USB Input Device": {
vendor_id: "0x046d",
product_id: "0xc52b",
serial_number: generatedSerialNumber,
manufacturer: "Logitech (x64)",
product: "Logitech USB Input Device",
},
"Wireless MultiMedia Keyboard": {
vendor_id: "0x045e",
product_id: "0x005f",
serial_number: generatedSerialNumber,
manufacturer: "Microsoft",
product: "Wireless MultiMedia Keyboard",
},
"Multimedia Pro Keyboard": {
vendor_id: "0x413c",
product_id: "0x2011",
serial_number: generatedSerialNumber,
manufacturer: "Dell Inc.",
product: "Multimedia Pro Keyboard",
},
}),
[deviceId],
);
const syncUsbConfigProduct = useCallback(() => {
send("getUsbConfig", {}, resp => {
if ("error" in resp) {
console.error("Failed to load USB Config:", resp.error);
notifications.error(
`Failed to load USB Config: ${resp.error.data || "Unknown error"}`,
);
} else {
console.log("syncUsbConfigProduct#getUsbConfig result:", resp.result);
const usbConfigState = resp.result as UsbConfigState;
const product = usbConfigs.map(u => u.value).includes(usbConfigState.product)
? usbConfigState.product
: "custom";
setUsbConfigProduct(product);
}
});
}, [send]);
const handleUsbConfigChange = useCallback(
(usbConfig: USBConfig) => {
setLoading(true);
send("setUsbConfig", { usbConfig }, async resp => {
if ("error" in resp) {
notifications.error(
`Failed to set usb config: ${resp.error.data || "Unknown error"}`,
);
setLoading(false);
return;
}
// We need some time to ensure the USB devices are updated
await new Promise(resolve => setTimeout(resolve, 2000));
setLoading(false);
notifications.success(
`USB Config set to ${usbConfig.manufacturer} ${usbConfig.product}`,
);
syncUsbConfigProduct();
});
},
[send, syncUsbConfigProduct],
);
useEffect(() => {
send("getDeviceID", {}, async resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get device ID: ${resp.error.data || "Unknown error"}`,
);
}
setDeviceId(resp.result as string);
});
syncUsbConfigProduct();
}, [send, syncUsbConfigProduct]);
return (
<Fieldset disabled={loading} className="space-y-4">
<SettingsItem
loading={loading}
title="Identifiers"
description="USB device identifiers exposed to the target computer"
>
<SelectMenuBasic
size="SM"
label=""
className="max-w-[192px]"
value={usbConfigProduct}
fullWidth
onChange={e => {
if (e.target.value === "custom") {
setUsbConfigProduct(e.target.value);
} else {
const usbConfig = usbConfigData[e.target.value];
handleUsbConfigChange(usbConfig);
}
}}
options={[...usbConfigs, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{usbConfigProduct === "custom" && (
<div className="ml-2 space-y-4 border-l border-slate-800/10 pl-4 dark:border-slate-300/20 ">
<USBConfigDialog
loading={loading}
onSetUsbConfig={usbConfig => handleUsbConfigChange(usbConfig)}
onRestoreToDefault={() =>
handleUsbConfigChange(usbConfigData[usbConfigs[0].value])
}
/>
</div>
)}
</Fieldset>
);
}
function USBConfigDialog({
loading,
onSetUsbConfig,
onRestoreToDefault,
}: {
loading: boolean;
onSetUsbConfig: (usbConfig: USBConfig) => void;
onRestoreToDefault: () => void;
}) {
const [usbConfigState, setUsbConfigState] = useState<USBConfig>({
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 (
<div className="">
<div className="grid grid-cols-2 gap-4">
<InputFieldWithLabel
required
label="Vendor ID"
placeholder="Enter Vendor ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.vendor_id}
onChange={e => handleUsbVendorIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product ID"
placeholder="Enter Product ID"
pattern="^0[xX][\da-fA-F]{4}$"
defaultValue={usbConfigState?.product_id}
onChange={e => handleUsbProductIdChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Serial Number"
placeholder="Enter Serial Number"
defaultValue={usbConfigState?.serial_number}
onChange={e => handleUsbSerialChange(e.target.value)}
/>
<InputFieldWithLabel
required
label="Manufacturer"
placeholder="Enter Manufacturer"
defaultValue={usbConfigState?.manufacturer}
onChange={e => handleUsbManufacturer(e.target.value)}
/>
<InputFieldWithLabel
required
label="Product Name"
placeholder="Enter Product Name"
defaultValue={usbConfigState?.product}
onChange={e => handleUsbProduct(e.target.value)}
/>
</div>
<div className="mt-6 flex gap-x-2">
<Button
loading={loading}
size="SM"
theme="primary"
text="Update USB Identifiers"
onClick={() => onSetUsbConfig(usbConfigState)}
/>
<Button
size="SM"
theme="light"
text="Restore to Default"
onClick={onRestoreToDefault}
/>
</div>
</div>
);
}

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
import { Transition } from "@headlessui/react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { LinkButton } from "@components/Button"; import { LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { motion, AnimatePresence } from "motion/react";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -12,7 +12,7 @@ interface OverlayContentProps {
function OverlayContent({ children }: OverlayContentProps) { function OverlayContent({ children }: OverlayContentProps) {
return ( return (
<GridCard cardClassName="h-full pointer-events-auto !outline-none"> <GridCard cardClassName="h-full pointer-events-auto !outline-none">
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20"> <div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
{children} {children}
</div> </div>
</GridCard> </GridCard>
@ -25,28 +25,31 @@ interface LoadingOverlayProps {
export function LoadingOverlay({ show }: LoadingOverlayProps) { export function LoadingOverlay({ show }: LoadingOverlayProps) {
return ( return (
<Transition <AnimatePresence>
show={show} {show && (
enter="transition-opacity duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 aspect-video h-full w-full"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition-opacity duration-100" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
duration: show ? 0.3 : 0.1,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1"> <div className="flex flex-col items-center justify-center gap-y-1">
<div className="flex items-center justify-center w-12 h-12 animate"> <div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" /> <LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div> </div>
<p className="text-sm text-center text-slate-700 dark:text-slate-300"> <p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream... Loading video stream...
</p> </p>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
); );
} }
@ -56,24 +59,26 @@ interface ConnectionErrorOverlayProps {
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) { export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
return ( return (
<Transition <AnimatePresence>
show={show} {show && (
enter="transition duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 z-10 aspect-video h-full w-full"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition duration-300" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
duration: 0.3,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2> <h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Verify that the device is powered on and properly connected</li> <li>Verify that the device is powered on and properly connected</li>
<li>Check all cable connections for any loose or damaged wires</li> <li>Check all cable connections for any loose or damaged wires</li>
<li>Ensure your network connection is stable and active</li> <li>Ensure your network connection is stable and active</li>
@ -93,8 +98,9 @@ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
); );
} }
@ -109,16 +115,18 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
return ( return (
<> <>
<Transition <AnimatePresence>
show={show && isNoSignal} {show && isNoSignal && (
enter="transition duration-300" <motion.div
enterFrom="opacity-0" className="absolute inset-0 w-full h-full aspect-video"
enterTo="opacity-100" initial={{ opacity: 0 }}
leave="transition-all duration-300" animate={{ opacity: 1 }}
leaveFrom="opacity-100" exit={{ opacity: 0 }}
leaveTo="opacity-0" transition={{
duration: 0.3,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
@ -126,7 +134,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li> <li>Ensure the HDMI cable securely connected at both ends</li>
<li>Ensure source device is powered on and outputting a signal</li> <li>Ensure source device is powered on and outputting a signal</li>
<li> <li>
@ -148,26 +156,30 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
<Transition </AnimatePresence>
show={show && isOtherError}
enter="transition duration-300" <AnimatePresence>
enterFrom="opacity-0" {show && isOtherError && (
enterTo="opacity-100" <motion.div
leave="transition duration-300 " className="absolute inset-0 aspect-video h-full w-full"
leaveFrom="opacity-100" initial={{ opacity: 0 }}
leaveTo="opacity-0" animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut"
}}
> >
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent> <OverlayContent>
<div className="flex flex-col items-start gap-y-1"> <div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" /> <ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300"> <div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white"> <div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2> <h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc"> <ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li> <li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li> <li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li> <li>Issues with the source device&apos;s HDMI output</li>
@ -186,8 +198,9 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
</> </>
); );
} }

View File

@ -5,7 +5,7 @@ import Card from "@components/Card";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useUiStore } from "@/hooks/stores";
import { Transition } from "@headlessui/react"; import { motion, AnimatePresence } from "motion/react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
@ -182,17 +182,17 @@ function KeyboardWrapper() {
marginBottom: virtualKeyboard ? "0px" : `-${350}px`, marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}} }}
> >
<Transition <AnimatePresence>
show={virtualKeyboard} {virtualKeyboard && (
unmount={false} <motion.div
enter="transition-all transform-gpu duration-500 ease-in-out" initial={{ opacity: 0, y: "100%" }}
enterFrom="opacity-0 translate-y-[100%]" animate={{ opacity: 1, y: "0%" }}
enterTo="opacity-100 translate-y-[0%]" exit={{ opacity: 0, y: "100%" }}
leave="transition-all duration-500 ease-in-out" transition={{
leaveFrom="opacity-100 translate-y-[0%]" duration: 0.5,
leaveTo="opacity-0 translate-y-[100%]" ease: "easeInOut",
}}
> >
<div>
<div <div
className={cx( className={cx(
!showAttachedVirtualKeyboard !showAttachedVirtualKeyboard
@ -211,8 +211,8 @@ function KeyboardWrapper() {
"rounded-none": showAttachedVirtualKeyboard, "rounded-none": showAttachedVirtualKeyboard,
})} })}
> >
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20"> <div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
<div className="absolute flex items-center left-2 gap-x-2"> <div className="absolute left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? ( {showAttachedVirtualKeyboard ? (
<Button <Button
size="XS" size="XS"
@ -245,7 +245,7 @@ function KeyboardWrapper() {
</div> </div>
<div> <div>
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row"> <div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
<Keyboard <Keyboard
baseClass="simple-keyboard-main" baseClass="simple-keyboard-main"
layoutName={layoutName} layoutName={layoutName}
@ -450,8 +450,9 @@ function KeyboardWrapper() {
</div> </div>
</Card> </Card>
</div> </div>
</div> </motion.div>
</Transition> )}
</AnimatePresence>
</div> </div>
); );
} }

View File

@ -1,10 +1,10 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
useDeviceSettingsStore,
useHidStore, useHidStore,
useMouseStore, useMouseStore,
useRTCStore, useRTCStore,
useSettingsStore, useSettingsStore,
useUiStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
@ -15,7 +15,9 @@ import Actionbar from "@components/ActionBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay"; import { HDMIErrorOverlay } from "./VideoOverlay";
import { ConnectionErrorOverlay } from "./VideoOverlay";
import { LoadingOverlay } from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
@ -27,6 +29,7 @@ export default function WebRTCVideo() {
const settings = useSettingsStore(); const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition); const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -91,19 +94,44 @@ export default function WebRTCVideo() {
); );
// Mouse-related // Mouse-related
const sendMouseMovement = useCallback( const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos;
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons }); if (settings.mouseMode !== "relative") return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (settings.mouseMode !== "relative") return;
// Send mouse movement
const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons);
},
[sendRelMouseMovement, settings.mouseMode],
);
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "absolute") return;
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar // We set that for the debug info bar
setMousePosition(x, y); setMousePosition(x, y);
}, },
[send, setMousePosition], [send, setMousePosition, settings.mouseMode],
); );
const mouseMoveHandler = useCallback( const absMouseMoveHandler = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return; if (!videoClientWidth || !videoClientHeight) return;
if (settings.mouseMode !== "absolute") return;
// Get the aspect ratios of the video element and the video stream // Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight; const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight; const videoStreamAspectRatio = videoWidth / videoHeight;
@ -138,24 +166,33 @@ export default function WebRTCVideo() {
// Send mouse movement // Send mouse movement
const { buttons } = e; const { buttons } = e;
sendMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode],
); );
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
const mouseSensitivity = useDeviceSettingsStore(state => state.mouseSensitivity);
const clampMin = useDeviceSettingsStore(state => state.clampMin);
const clampMax = useDeviceSettingsStore(state => state.clampMax);
const blockDelay = useDeviceSettingsStore(state => state.blockDelay);
const trackpadThreshold = useDeviceSettingsStore(state => state.trackpadThreshold);
const mouseWheelHandler = useCallback( const mouseWheelHandler = useCallback(
(e: WheelEvent) => { (e: WheelEvent) => {
if (blockWheelEvent) return; if (blockWheelEvent) return;
e.preventDefault();
// Define a scaling factor to adjust scrolling sensitivity // Determine if the wheel event is from a trackpad or a mouse wheel
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed const isTrackpad = Math.abs(e.deltaY) < trackpadThreshold;
// Apply appropriate sensitivity based on input device
const scrollSensitivity = isTrackpad ? trackpadSensitivity : mouseSensitivity;
// Calculate the scroll value // Calculate the scroll value
const scroll = e.deltaY * scrollSensitivity; const scroll = e.deltaY * scrollSensitivity;
// Clamp the scroll value to a reasonable range (e.g., -15 to 15) // Apply clamping
const clampedScroll = Math.max(-4, Math.min(4, scroll)); const clampedScroll = Math.max(clampMin, Math.min(clampMax, scroll));
// Round to the nearest integer // Round to the nearest integer
const roundedScroll = Math.round(clampedScroll); const roundedScroll = Math.round(clampedScroll);
@ -163,18 +200,27 @@ export default function WebRTCVideo() {
// Invert the scroll value to match expected behavior // Invert the scroll value to match expected behavior
const invertedScroll = -roundedScroll; const invertedScroll = -roundedScroll;
console.log("wheelReport", { wheelY: invertedScroll });
send("wheelReport", { wheelY: invertedScroll }); send("wheelReport", { wheelY: invertedScroll });
// Apply blocking delay
setBlockWheelEvent(true); setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), 50); setTimeout(() => setBlockWheelEvent(false), blockDelay);
}, },
[blockWheelEvent, send], [
blockDelay,
blockWheelEvent,
clampMax,
clampMin,
mouseSensitivity,
send,
trackpadSensitivity,
trackpadThreshold,
],
); );
const resetMousePosition = useCallback(() => { const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0); sendAbsMouseMovement(0, 0, 0);
}, [sendMouseMovement]); }, [sendAbsMouseMovement]);
// Keyboard-related // Keyboard-related
const handleModifierKeys = useCallback( const handleModifierKeys = useCallback(
@ -285,11 +331,6 @@ export default function WebRTCVideo() {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const prev = useHidStore.getState();
// if (document.activeElement?.id !== "videoFocusTrap") {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
setIsNumLockActive(e.getModifierState("NumLock")); setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock")); setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsScrollLockActive(e.getModifierState("ScrollLock"));
@ -336,6 +377,18 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
// there is no way to prevent this, so we need to simply force play the video when it's paused.
// Fix only works in chrome based browsers.
if (e.code === "Space") {
if (videoElm.current?.paused == true) {
console.log("Force playing video");
videoElm.current?.play();
}
}
}, []);
useEffect( useEffect(
function setupVideoEventListeners() { function setupVideoEventListeners() {
let videoElmRefValue = null; let videoElmRefValue = null;
@ -344,11 +397,14 @@ export default function WebRTCVideo() {
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal }); videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
videoElmRefValue.addEventListener( videoElmRefValue.addEventListener(
"contextmenu", "contextmenu",
(e: MouseEvent) => e.preventDefault(), (e: MouseEvent) => e.preventDefault(),
@ -364,9 +420,40 @@ export default function WebRTCVideo() {
if (videoElmRefValue) abortController.abort(); if (videoElmRefValue) abortController.abort();
}; };
}, },
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler], [
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
); );
useEffect(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
const abortController = new AbortController();
const signal = abortController.signal;
// bind to body to capture all mouse events
const body = document.querySelector("body");
if (!body) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
return () => {
abortController.abort();
body.removeEventListener("mousemove", relMouseMoveHandler);
body.removeEventListener("pointerdown", relMouseMoveHandler);
body.removeEventListener("pointerup", relMouseMoveHandler);
};
}, [settings.mouseMode, relMouseMoveHandler],
)
useEffect( useEffect(
function updateVideoStream() { function updateVideoStream() {
if (!mediaStream) return; if (!mediaStream) return;
@ -389,28 +476,8 @@ export default function WebRTCVideo() {
], ],
); );
// Focus trap management
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const sidebarView = useUiStore(state => state.sidebarView);
useEffect(() => {
setTimeout(function () {
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
// (document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
} else {
setDisableVideoFocusTrap(false);
}
}, 300);
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
return ( return (
<div className="grid w-full h-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
<fieldset disabled={peerConnectionState !== "connected"}> <fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar <Actionbar
@ -427,18 +494,18 @@ export default function WebRTCVideo() {
<div className="relative h-full"> <div className="relative h-full">
<div <div
className={cx( className={cx(
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80", "absolute inset-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]", "[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]", "[background-position:0_0,10px_10px]",
"[background-size:20px_20px]", "[background-size:20px_20px]",
)} )}
/> />
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
<div className="relative flex-grow overflow-hidden"> <div className="relative flex-grow overflow-hidden">
<div className="flex flex-col h-full"> <div className="flex h-full flex-col">
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter"> <div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden"> <div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex items-center justify-center w-full h-full"> <div className="relative flex h-full w-full items-center justify-center">
<video <video
ref={videoElm} ref={videoElm}
autoPlay={true} autoPlay={true}
@ -454,14 +521,14 @@ export default function WebRTCVideo() {
{ {
"cursor-none": settings.isCursorHidden, "cursor-none": settings.isCursorHidden,
"opacity-0": isLoading || isConnectionError || hdmiError, "opacity-0": isLoading || isConnectionError || hdmiError,
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow": "animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade" className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
> >
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md"> <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
<LoadingOverlay show={isLoading} /> <LoadingOverlay show={isLoading} />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu"; import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { useJsonRpc } from "../../hooks/useJsonRpc"; import { useJsonRpc } from "../../hooks/useJsonRpc";
@ -95,7 +95,7 @@ export function ATXPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="ATX Power Control" title="ATX Power Control"
description="Control your ATX power settings" description="Control your ATX power settings"
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu"; import { LuPower } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import FieldLabel from "../FieldLabel"; import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
@ -52,7 +52,7 @@ export function DCPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="DC Power Control" title="DC Power Control"
description="Control your DC power settings" description="Control your DC power settings"
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuTerminal } from "react-icons/lu"; import { LuTerminal } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "../SelectMenuBasic"; import { SelectMenuBasic } from "../SelectMenuBasic";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
@ -52,7 +52,7 @@ export function SerialConsole() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Serial Console" title="Serial Console"
description="Configure your serial console settings" description="Configure your serial console settings"
/> />

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { Button } from "../Button"; import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu"; import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
@ -106,7 +106,7 @@ export default function ExtensionPopover() {
) : ( ) : (
// Extensions List View // Extensions List View
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />

View File

@ -5,7 +5,7 @@ import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { useMemo, forwardRef, useEffect, useCallback } from "react"; import { useMemo, forwardRef, useEffect, useCallback } from "react";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores"; import { RemoteVirtualMediaState, useMountMediaStore, useRTCStore } from "@/hooks/stores";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { import {
LuArrowUpFromLine, LuArrowUpFromLine,
LuCheckCheck, LuCheckCheck,
@ -15,19 +15,15 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../../notifications"; import notifications from "../../notifications";
import MountMediaModal from "../MountMediaDialog";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
import { useLocation } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => { const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats); const diskDataChannelStats = useRTCStore(state => state.diskDataChannelStats);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const { const { remoteVirtualMediaState, setModalView, setRemoteVirtualMediaState } =
remoteVirtualMediaState, useMountMediaStore();
isMountMediaDialogOpen,
setModalView,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
} = useMountMediaStore();
const bytesSentPerSecond = useMemo(() => { const bytesSentPerSecond = useMemo(() => {
if (diskDataChannelStats.size < 2) return null; if (diskDataChannelStats.size < 2) return null;
@ -78,7 +74,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-700 shrink-0 dark:text-white" /> <PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-700 dark:text-white" />
</div> </div>
</Card> </Card>
</div> </div>
@ -103,20 +99,25 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<LuCheckCheck className="h-5 text-green-500" /> <LuCheckCheck className="h-5 text-green-500" />
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from Browser</h3> <h3 className="text-base font-semibold text-black dark:text-white">
Streaming from Browser
</h3>
</div> </div>
<Card className="w-auto px-2 py-1"> <Card className="w-auto px-2 py-1">
<div className="w-full text-sm text-black truncate dark:text-white"> <div className="w-full truncate text-sm text-black dark:text-white">
{formatters.truncateMiddle(filename, 50)} {formatters.truncateMiddle(filename, 50)}
</div> </div>
</Card> </Card>
</div> </div>
<div className="flex flex-col items-center my-2 gap-y-2"> <div className="my-2 flex flex-col items-center gap-y-2">
<div className="w-full text-sm text-slate-900 dark:text-slate-100"> <div className="w-full text-sm text-slate-900 dark:text-slate-100">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span>{formatters.bytes(size ?? 0)}</span> <span>{formatters.bytes(size ?? 0)}</span>
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<LuArrowUpFromLine className="h-4 text-blue-700 dark:text-blue-500" strokeWidth={2} /> <LuArrowUpFromLine
className="h-4 text-blue-700 dark:text-blue-500"
strokeWidth={2}
/>
<span> <span>
{bytesSentPerSecond !== null {bytesSentPerSecond !== null
? `${formatters.bytes(bytesSentPerSecond)}/s` ? `${formatters.bytes(bytesSentPerSecond)}/s`
@ -131,33 +132,49 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
case "HTTP": case "HTTP":
return ( return (
<div className=""> <div className="">
<div className="inline-block mb-0"> <div className="mb-0 inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuLink className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" /> <LuLink className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white">Streaming from URL</h3> <h3 className="text-base font-semibold text-black dark:text-white">
<p className="text-sm truncate text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(url, 55)}</p> Streaming from URL
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p> </h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p> <p className="truncate text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(url, 55)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div> </div>
); );
case "Storage": case "Storage":
return ( return (
<div className=""> <div className="">
<div className="inline-block mb-0"> <div className="mb-0 inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuRadioReceiver className="w-4 h-4 text-blue-700 dark:text-blue-500 shrink-0" /> <LuRadioReceiver className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-500" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-base font-semibold text-black dark:text-white">Mounted from JetKVM Storage</h3> <h3 className="text-base font-semibold text-black dark:text-white">
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(path, 50)}</p> Mounted from JetKVM Storage
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.truncateMiddle(filename, 30)}</p> </h3>
<p className="text-sm text-slate-900 dark:text-slate-100">{formatters.bytes(size ?? 0)}</p> <p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(filename, 30)}
</p>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.bytes(size ?? 0)}
</p>
</div> </div>
); );
default: default:
@ -165,18 +182,21 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
} }
}; };
const close = useClose(); const close = useClose();
const location = useLocation();
useEffect(() => { useEffect(() => {
syncRemoteVirtualMediaState(); syncRemoteVirtualMediaState();
}, [syncRemoteVirtualMediaState, isMountMediaDialogOpen]); }, [syncRemoteVirtualMediaState, location.pathname]);
const { navigateTo } = useDeviceUiNavigation();
return ( return (
<GridCard> <GridCard>
<div className="p-4 py-3 space-y-4"> <div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody"> <div ref={ref} className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4 "> <div className="h-full space-y-4 ">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Virtual Media" title="Virtual Media"
description="Mount an image to boot from or install an operating system." description="Mount an image to boot from or install an operating system."
/> />
@ -185,7 +205,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<Card> <Card>
<div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm"> <div className="flex items-center gap-x-1.5 px-2.5 py-2 text-sm">
<ExclamationTriangleIcon className="h-4 text-yellow-500" /> <ExclamationTriangleIcon className="h-4 text-yellow-500" />
<div className="flex items-center w-full text-black"> <div className="flex w-full items-center text-black">
<div>Closing this tab will unmount the image</div> <div>Closing this tab will unmount the image</div>
</div> </div>
</div> </div>
@ -193,7 +213,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null} ) : null}
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -203,7 +223,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
<div className="group"> <div className="group">
<Card> <Card>
<div className="w-full px-4 py-8"> <div className="w-full px-4 py-8">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{renderGridCardContent()} {renderGridCardContent()}
</div> </div>
</div> </div>
@ -211,8 +231,8 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div> </div>
</div> </div>
{remoteVirtualMediaState ? ( {remoteVirtualMediaState ? (
<div className="flex items-center justify-between text-xs select-none"> <div className="flex select-none items-center justify-between text-xs">
<div className="text-white select-none dark:text-slate-300"> <div className="select-none text-white dark:text-slate-300">
<span>Mounted as</span>{" "} <span>Mounted as</span>{" "}
<span className="font-semibold"> <span className="font-semibold">
{remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"} {remoteVirtualMediaState.mode === "Disk" ? "Disk" : "CD-ROM"}
@ -244,7 +264,10 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z" d="M4.99933 0.775635L0 5.77546H10L4.99933 0.775635Z"
fill="currentColor" fill="currentColor"
/> />
<path d="M10 7.49976H0V9.22453H10V7.49976Z" fill="currentColor" /> <path
d="M10 7.49976H0V9.22453H10V7.49976Z"
fill="currentColor"
/>
</g> </g>
<defs> <defs>
<clipPath id="clip0_3137_1186"> <clipPath id="clip0_3137_1186">
@ -261,16 +284,11 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</div> </div>
</div> </div>
</div> </div>
<MountMediaModal
open={isMountMediaDialogOpen}
setOpen={setIsMountMediaDialogOpen}
/>
</div> </div>
{!remoteVirtualMediaState && ( {!remoteVirtualMediaState && (
<div <div
className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn" className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -290,7 +308,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
text="Add New Media" text="Add New Media"
onClick={() => { onClick={() => {
setModalView("mode"); setModalView("mode");
setIsMountMediaDialogOpen(true); navigateTo("/mount");
}} }}
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
/> />

View File

@ -1,7 +1,7 @@
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "../../notifications"; import notifications from "../../notifications";
@ -75,7 +75,7 @@ export default function PasteModal() {
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Paste text" title="Paste text"
description="Paste text from your client to the remote host" description="Paste text from your client to the remote host"
/> />

View File

@ -1,5 +1,5 @@
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SectionHeader } from "@components/SectionHeader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
<div className="p-4 py-3 space-y-4"> <div className="p-4 py-3 space-y-4">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody">
<div className="space-y-4"> <div className="space-y-4">
<SectionHeader <SettingsPageHeader
title="Wake On LAN" title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device." description="Send a Magic Packet to wake up a remote device."
/> />

View File

@ -1,6 +1,5 @@
import SidebarHeader from "@components/SidebarHeader"; import SidebarHeader from "@components/SidebarHeader";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { useEffect } from "react";
import { useRTCStore, useUiStore } from "@/hooks/stores"; import { useRTCStore, useUiStore } from "@/hooks/stores";
import StatChart from "@components/StatChart"; import StatChart from "@components/StatChart";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
@ -37,18 +36,6 @@ function createChartArray<T, K extends keyof T>(
} }
export default function ConnectionStatsSidebar() { export default function ConnectionStatsSidebar() {
const setModalView = useUiStore(state => state.setModalView);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setModalView(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [setModalView]);
const inboundRtpStats = useRTCStore(state => state.inboundRtpStats); const inboundRtpStats = useRTCStore(state => state.inboundRtpStats);
const candidatePairStats = useRTCStore(state => state.candidatePairStats); const candidatePairStats = useRTCStore(state => state.candidatePairStats);
@ -111,9 +98,9 @@ export default function ConnectionStatsSidebar () {
}, 500); }, 500);
return ( return (
<div className="grid h-full shadow-sm grid-rows-headerBody"> <div className="grid h-full grid-rows-headerBody shadow-sm">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full px-4 py-2 pb-8 space-y-4 overflow-y-scroll bg-white dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">
{/* {/*
The entire sidebar component is always rendered, with a display none when not visible The entire sidebar component is always rendered, with a display none when not visible

File diff suppressed because it is too large Load Diff

View File

@ -20,8 +20,7 @@ const appendStatToMap = <T extends { timestamp: number }>(
}; };
// Constants and types // Constants and types
export type AvailableSidebarViews = "system" | "connection-stats"; export type AvailableSidebarViews = "connection-stats";
export type AvailableModalViews = "connection-stats" | "settings";
export type AvailableTerminalTypes = "kvm" | "serial" | "none"; export type AvailableTerminalTypes = "kvm" | "serial" | "none";
export interface User { export interface User {
@ -47,9 +46,6 @@ interface UIState {
toggleSidebarView: (view: AvailableSidebarViews) => void; toggleSidebarView: (view: AvailableSidebarViews) => void;
modalView: AvailableModalViews | null;
setModalView: (view: AvailableModalViews | null) => void;
isAttachedVirtualKeyboardVisible: boolean; isAttachedVirtualKeyboardVisible: boolean;
setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void; setAttachedVirtualKeyboardVisibility: (enabled: boolean) => void;
@ -79,9 +75,6 @@ export const useUiStore = create<UIState>(set => ({
} }
}), }),
modalView: null,
setModalView: view => set({ modalView: view }),
isAttachedVirtualKeyboardVisible: true, isAttachedVirtualKeyboardVisible: true,
setAttachedVirtualKeyboardVisibility: enabled => setAttachedVirtualKeyboardVisibility: enabled =>
set({ isAttachedVirtualKeyboardVisible: enabled }), set({ isAttachedVirtualKeyboardVisible: enabled }),
@ -204,15 +197,23 @@ export const useRTCStore = create<RTCState>(set => ({
setTerminalChannel: channel => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
interface MouseMove {
x: number;
y: number;
buttons: number;
}
interface MouseState { interface MouseState {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
mouseMove?: MouseMove;
setMouseMove: (move?: MouseMove) => void;
setMousePosition: (x: number, y: number) => void; setMousePosition: (x: number, y: number) => void;
} }
export const useMouseStore = create<MouseState>(set => ({ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
})); }));
@ -303,7 +304,8 @@ export const useSettingsStore = create(
dim_after: 10000, dim_after: 10000,
off_after: 50000, off_after: 50000,
}, },
setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }), setBacklightSettings: (settings: BacklightSettings) =>
set({ backlightSettings: settings }),
}), }),
{ {
name: "settings", name: "settings",
@ -312,6 +314,78 @@ export const useSettingsStore = create(
), ),
); );
export interface DeviceSettingsState {
trackpadSensitivity: number;
mouseSensitivity: number;
clampMin: number;
clampMax: number;
blockDelay: number;
trackpadThreshold: number;
scrollSensitivity: "low" | "default" | "high";
setScrollSensitivity: (sensitivity: DeviceSettingsState["scrollSensitivity"]) => void;
}
export const useDeviceSettingsStore = create<DeviceSettingsState>(set => ({
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
scrollSensitivity: "default",
setScrollSensitivity: sensitivity => {
const wheelSettings: Record<
DeviceSettingsState["scrollSensitivity"],
{
trackpadSensitivity: DeviceSettingsState["trackpadSensitivity"];
mouseSensitivity: DeviceSettingsState["mouseSensitivity"];
clampMin: DeviceSettingsState["clampMin"];
clampMax: DeviceSettingsState["clampMax"];
blockDelay: DeviceSettingsState["blockDelay"];
trackpadThreshold: DeviceSettingsState["trackpadThreshold"];
}
> = {
low: {
trackpadSensitivity: 2.0,
mouseSensitivity: 3.0,
clampMin: -6,
clampMax: 6,
blockDelay: 30,
trackpadThreshold: 10,
},
default: {
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
},
high: {
trackpadSensitivity: 4.0,
mouseSensitivity: 6.0,
clampMin: -9,
clampMax: 9,
blockDelay: 20,
trackpadThreshold: 10,
},
};
const settings = wheelSettings[sensitivity];
return set({
trackpadSensitivity: settings.trackpadSensitivity,
trackpadThreshold: settings.trackpadThreshold,
mouseSensitivity: settings.mouseSensitivity,
clampMin: settings.clampMin,
clampMax: settings.clampMax,
blockDelay: settings.blockDelay,
scrollSensitivity: sensitivity,
});
},
}));
export interface RemoteVirtualMediaState { export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null; source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null; mode: "CDROM" | "Disk" | null;
@ -484,8 +558,6 @@ export interface UpdateState {
| "updateCompleted" | "updateCompleted"
| "error"; | "error";
setModalView: (view: UpdateState["modalView"]) => void; setModalView: (view: UpdateState["modalView"]) => void;
isUpdateDialogOpen: boolean;
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setUpdateErrorMessage: (errorMessage: string) => void; setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null; updateErrorMessage: string | null;
} }
@ -520,12 +592,32 @@ export const useUpdateStore = create<UpdateState>(set => ({
set({ updateDialogHasBeenMinimized: hasBeenMinimized }), set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading", modalView: "loading",
setModalView: view => set({ modalView: view }), setModalView: view => set({ modalView: view }),
isUpdateDialogOpen: false,
setIsUpdateDialogOpen: isOpen => set({ isUpdateDialogOpen: isOpen }),
updateErrorMessage: null, updateErrorMessage: null,
setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }), setUpdateErrorMessage: errorMessage => set({ updateErrorMessage: errorMessage }),
})); }));
interface UsbConfigModalState {
modalView: "updateUsbConfig" | "updateUsbConfigSuccess";
errorMessage: string | null;
setModalView: (view: UsbConfigModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
}
export interface UsbConfigState {
vendor_id: string;
product_id: string;
serial_number: string;
manufacturer: string;
product: string;
}
export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
modalView: "updateUsbConfig",
errorMessage: null,
setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }),
}));
interface LocalAuthModalState { interface LocalAuthModalState {
modalView: modalView:
| "createPassword" | "createPassword"
@ -534,14 +626,26 @@ interface LocalAuthModalState {
| "creationSuccess" | "creationSuccess"
| "deleteSuccess" | "deleteSuccess"
| "updateSuccess"; | "updateSuccess";
errorMessage: string | null;
setModalView: (view: LocalAuthModalState["modalView"]) => void; setModalView: (view: LocalAuthModalState["modalView"]) => void;
setErrorMessage: (message: string | null) => void;
} }
export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({ export const useLocalAuthModalStore = create<LocalAuthModalState>(set => ({
modalView: "createPassword", modalView: "createPassword",
errorMessage: null,
setModalView: view => set({ modalView: view }), setModalView: view => set({ modalView: view }),
setErrorMessage: message => set({ errorMessage: message }), }));
export interface DeviceState {
appVersion: string | null;
systemVersion: string | null;
setAppVersion: (version: string) => void;
setSystemVersion: (version: string) => void;
}
export const useDeviceStore = create<DeviceState>(set => ({
appVersion: null,
systemVersion: null,
setAppVersion: version => set({ appVersion: version }),
setSystemVersion: version => set({ systemVersion: version }),
})); }));

View File

@ -0,0 +1,66 @@
import { useNavigate, useParams, NavigateOptions } from "react-router-dom";
import { isOnDevice } from "../main";
import { useCallback, useMemo } from "react";
/**
* Generates the correct path based on whether the app is running on device or in cloud mode
*
*/
export function getDeviceUiPath(path: string, deviceId?: string): string {
// Check if it's a relative path (starts with . or ..)
const isRelativePath = path.startsWith(".") || path === "";
// If it's a relative path, don't modify it
if (isRelativePath) return path;
// Ensure absolute path starts with a slash
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
if (isOnDevice) {
return normalizedPath;
} else {
if (!deviceId) {
console.error("No device ID provided when generating path in cloud mode");
throw new Error("Device ID is required for cloud mode path generation");
}
return `/devices/${deviceId}${normalizedPath}`;
}
}
/**
* Hook that provides context-aware navigation and path generation
* that works in both cloud and device modes.
*
* In cloud mode, paths are prefixed with /devices/:id
* In device mode, paths start from the root
* Relative paths (starting with . or ..) are preserved in both modes
* Supports all React Router navigation options
*/
export function useDeviceUiNavigation() {
const navigate = useNavigate();
const params = useParams();
// Get the device ID from params
const deviceId = useMemo(() => params.id, [params.id]);
// Use the standalone getPath function with the current deviceId
const getPath = useCallback(
(path: string): string => {
return getDeviceUiPath(path, deviceId);
},
[deviceId],
);
// Function to navigate to the correct path with all options
const navigateTo = useCallback(
(path: string, options?: NavigateOptions) => {
navigate(getPath(path), options);
},
[getPath, navigate],
);
return {
navigateTo,
getPath,
};
}

View File

@ -0,0 +1,15 @@
import { useContext } from "react";
import { FeatureFlagContext } from "../providers/FeatureFlagProvider";
export const useFeatureFlag = (minAppVersion: string) => {
const context = useContext(FeatureFlagContext);
if (!context) {
throw new Error("useFeatureFlag must be used within a FeatureFlagProvider");
}
const { isFeatureEnabled, appVersion } = context;
const isEnabled = isFeatureEnabled(minAppVersion);
return { isEnabled, appVersion };
};

View File

@ -8,17 +8,25 @@ export interface JsonRpcRequest {
id: number | string; id: number | string;
} }
type JsonRpcResponse = export interface JsonRpcError {
| { code: number;
data?: string;
message: string;
}
export interface JsonRpcSuccessResponse {
jsonrpc: string; jsonrpc: string;
result: boolean | number | object | string | []; result: boolean | number | object | string | [];
id: string | number; id: string | number;
} }
| {
export interface JsonRpcErrorResponse {
jsonrpc: string; jsonrpc: string;
error: { code: number; data?: string; message: string }; error: JsonRpcError;
id: string | number; id: string | number;
}; }
export type JsonRpcResponse = JsonRpcSuccessResponse | JsonRpcErrorResponse;
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>(); const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
let requestCounter = 0; let requestCounter = 0;

View File

@ -105,7 +105,7 @@ video::-webkit-media-controls {
} }
.controlArrows { .controlArrows {
@apply flex items-center justify-between w-full md:w-1/5; @apply flex w-full items-center justify-between md:w-1/5;
flex-flow: column; flex-flow: column;
} }
@ -191,3 +191,13 @@ video::-webkit-media-controls {
scrollbar-color: theme("colors.gray.900") #002b36; scrollbar-color: theme("colors.gray.900") #002b36;
scrollbar-width: thin; scrollbar-width: thin;
} }
.hide-scrollbar {
overflow-y: scroll;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* Internet Explorer 10+ */
}
.hide-scrollbar::-webkit-scrollbar {
display: none;
}

View File

@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import Root from "./root"; import Root from "./root";
import "./index.css"; import "./index.css";
@ -9,7 +8,7 @@ import {
RouterProvider, RouterProvider,
useRouteError, useRouteError,
} from "react-router-dom"; } from "react-router-dom";
import DeviceRoute from "@routes/devices.$id"; import DeviceRoute, { LocalDevice } from "@routes/devices.$id";
import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices"; import DevicesRoute, { loader as DeviceListLoader } from "@routes/devices";
import SetupRoute from "@routes/devices.$id.setup"; import SetupRoute from "@routes/devices.$id.setup";
import LoginRoute from "@routes/login"; import LoginRoute from "@routes/login";
@ -25,14 +24,28 @@ import DevicesAlreadyAdopted from "@routes/devices.already-adopted";
import Notifications from "./notifications"; import Notifications from "./notifications";
import LoginLocalRoute from "./routes/login-local"; import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode"; import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local"; import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password"; import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API } from "./ui.config"; import { CLOUD_API, DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
import SettingsKeyboardMouseRoute from "./routes/devices.$id.settings.mouse";
import api from "./api";
import * as SettingsIndexRoute from "./routes/devices.$id.settings._index";
import SettingsAdvancedRoute from "./routes/devices.$id.settings.advanced";
import * as SettingsAccessIndexRoute from "./routes/devices.$id.settings.access._index";
import SettingsHardwareRoute from "./routes/devices.$id.settings.hardware";
import SettingsVideoRoute from "./routes/devices.$id.settings.video";
import SettingsAppearanceRoute from "./routes/devices.$id.settings.appearance";
import * as SettingsGeneralIndexRoute from "./routes/devices.$id.settings.general._index";
import SettingsGeneralUpdateRoute from "./routes/devices.$id.settings.general.update";
import SecurityAccessLocalAuthRoute from "./routes/devices.$id.settings.access.local-auth";
export const isOnDevice = import.meta.env.MODE === "device"; export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice; export const isInCloud = !isOnDevice;
export async function checkAuth() { export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, { const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors", mode: "cors",
credentials: "include", credentials: "include",
@ -46,6 +59,27 @@ export async function checkAuth() {
return await res.json(); return await res.json();
} }
export async function checkDeviceAuth() {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
return { authMode: device.authMode };
}
throw new Error("Error fetching device");
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
}
let router; let router;
if (isOnDevice) { if (isOnDevice) {
router = createBrowserRouter([ router = createBrowserRouter([
@ -75,6 +109,73 @@ if (isOnDevice) {
errorElement: <ErrorBoundary />, errorElement: <ErrorBoundary />,
element: <DeviceRoute />, element: <DeviceRoute />,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
}, },
{ {
path: "/adopt", path: "/adopt",
@ -116,6 +217,73 @@ if (isOnDevice) {
path: "devices/:id", path: "devices/:id",
element: <DeviceRoute />, element: <DeviceRoute />,
loader: DeviceRoute.loader, loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsKeyboardMouseRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute.default />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
],
},
],
}, },
{ {
path: "devices/:id/deregister", path: "devices/:id/deregister",
@ -139,7 +307,7 @@ if (isOnDevice) {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <>
<RouterProvider router={router} /> <RouterProvider router={router} />
<Notifications <Notifications
toastOptions={{ toastOptions={{
@ -148,7 +316,7 @@ document.addEventListener("DOMContentLoaded", () => {
}} }}
max={2} max={2}
/> />
</React.StrictMode>, </>,
); );
}); });
@ -164,8 +332,8 @@ function ErrorBoundary() {
} }
return ( return (
<div className="w-full h-full"> <div className="h-full w-full">
<div className="flex items-center justify-center h-full"> <div className="flex h-full items-center justify-center">
<div className="w-full max-w-2xl"> <div className="w-full max-w-2xl">
<EmptyCard <EmptyCard
IconElm={ExclamationTriangleIcon} IconElm={ExclamationTriangleIcon}

View File

@ -0,0 +1,42 @@
import { createContext } from "react";
import semver from "semver";
interface FeatureFlagContextType {
appVersion: string | null;
isFeatureEnabled: (minVersion: string) => boolean;
}
// Create the context
export const FeatureFlagContext = createContext<FeatureFlagContextType>({
appVersion: null,
isFeatureEnabled: () => false,
});
// Provider component that fetches version and provides context
export const FeatureFlagProvider = ({
children,
appVersion,
}: {
children: React.ReactNode;
appVersion: string | null;
}) => {
const isFeatureEnabled = (minAppVersion: string) => {
// If no version is set, feature is disabled.
// The feature flag component can decide what to display as a fallback - either omit the component or like a "please upgrade to enable".
if (!appVersion) return false;
// Extract the base versions without prerelease identifier
const baseCurrentVersion = semver.coerce(appVersion)?.version;
const baseMinVersion = semver.coerce(minAppVersion)?.version;
if (!baseCurrentVersion || !baseMinVersion) return false;
return semver.gte(baseCurrentVersion, baseMinVersion);
};
const value = { appVersion, isFeatureEnabled };
return (
<FeatureFlagContext.Provider value={value}>{children}</FeatureFlagContext.Provider>
);
};

View File

@ -1,6 +1,12 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom"; import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api"; import api from "../api";
import { CLOUD_API, CLOUD_APP, SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
export interface CloudState {
connected: boolean;
url: string;
appUrl: string;
}
const loader = async ({ request }: LoaderFunctionArgs) => { const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
@ -11,18 +17,21 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const oidcGoogle = searchParams.get("oidcGoogle"); const oidcGoogle = searchParams.get("oidcGoogle");
const clientId = searchParams.get("clientId"); const clientId = searchParams.get("clientId");
const res = await api.POST( const [cloudStateResponse, registerResponse] = await Promise.all([
`${SIGNAL_API}/cloud/register`, api.GET(`${DEVICE_API}/cloud/state`),
{ api.POST(`${DEVICE_API}/cloud/register`, {
token: tempToken, token: tempToken,
cloudApi: CLOUD_API,
oidcGoogle, oidcGoogle,
clientId, clientId,
}, }),
); ]);
if (!res.ok) throw new Error("Failed to register device"); if (!cloudStateResponse.ok) throw new Error("Failed to get cloud state");
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`); const cloudState = (await cloudStateResponse.json()) as CloudState;
if (!registerResponse.ok) throw new Error("Failed to register device");
return redirect(cloudState.appUrl + `/devices/${deviceId}/setup`);
}; };
export default function AdoptRoute() { export default function AdoptRoute() {

View File

@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { import {
MountMediaState, MountMediaState,
RemoteVirtualMediaState, RemoteVirtualMediaState,
@ -21,8 +20,8 @@ import {
} from "react-icons/lu"; } from "react-icons/lu";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import { PlusCircleIcon } from "@heroicons/react/20/solid"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "./AutoHeight"; import AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "./InputField"; import { InputFieldWithLabel } from "@/components/InputField";
import DebianIcon from "@/assets/debian-icon.png"; import DebianIcon from "@/assets/debian-icon.png";
import UbuntuIcon from "@/assets/ubuntu-icon.png"; import UbuntuIcon from "@/assets/ubuntu-icon.png";
import FedoraIcon from "@/assets/fedora-icon.png"; import FedoraIcon from "@/assets/fedora-icon.png";
@ -33,34 +32,29 @@ import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid"; import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications"; import notifications from "../notifications";
import Fieldset from "./Fieldset"; import Fieldset from "@/components/Fieldset";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { SIGNAL_API } from "@/ui.config"; import { DEVICE_API } from "@/ui.config";
import { useNavigate } from "react-router-dom";
export default function MountMediaModal({ export default function MountRoute() {
open, const navigate = useNavigate();
setOpen, {
}: { /* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
open: boolean; }
setOpen: (open: boolean) => void; return <Dialog onClose={() => navigate("..")} />;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
} }
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { export function Dialog({ onClose }: { onClose: () => void }) {
const { const {
modalView, modalView,
setModalView, setModalView,
setLocalFile, setLocalFile,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState, setRemoteVirtualMediaState,
errorMessage, errorMessage,
setErrorMessage, setErrorMessage,
} = useMountMediaStore(); } = useMountMediaStore();
const navigate = useNavigate();
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null); const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false); const [mountInProgress, setMountInProgress] = useState(false);
@ -99,17 +93,13 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState(); clearMountMediaState();
syncRemoteVirtualMediaState() syncRemoteVirtualMediaState()
.then(() => { .then(() => navigate(".."))
setIsMountMediaDialogOpen(false);
})
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
}) })
.finally(() => { .finally(() => {
setMountInProgress(false); setMountInProgress(false);
}); });
setIsMountMediaDialogOpen(false);
}); });
} }
@ -123,13 +113,13 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
clearMountMediaState(); clearMountMediaState();
syncRemoteVirtualMediaState() syncRemoteVirtualMediaState()
.then(() => { .then(() => {
setIsMountMediaDialogOpen(false); navigate("..");
}) })
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
}) })
.finally(() => { .finally(() => {
// We do this beacues the mounting is too fast and the UI gets choppy // We do this because the mounting is too fast and the UI gets choppy
// and the modal exit animation for like 500ms // and the modal exit animation for like 500ms
setTimeout(() => { setTimeout(() => {
setMountInProgress(false); setMountInProgress(false);
@ -156,7 +146,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
// We need to keep the local file in the store so that the browser can // We need to keep the local file in the store so that the browser can
// continue to stream the file to the device // continue to stream the file to the device
setLocalFile(file); setLocalFile(file);
setIsMountMediaDialogOpen(false); navigate("..");
}) })
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
@ -188,16 +178,16 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<img <img
src={LogoBlueIcon} src={LogoBlueIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="h-[24px] dark:hidden block" className="block h-[24px] dark:hidden"
/> />
<img <img
src={LogoWhiteIcon} src={LogoWhiteIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="h-[24px] dark:block hidden dark:!mt-0" className="hidden h-[24px] dark:!mt-0 dark:block"
/> />
{modalView === "mode" && ( {modalView === "mode" && (
<ModeSelectionView <ModeSelectionView
onClose={() => setOpen(false)} onClose={() => onClose()}
selectedMode={selectedMode} selectedMode={selectedMode}
setSelectedMode={setSelectedMode} setSelectedMode={setSelectedMode}
/> />
@ -261,7 +251,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<ErrorView <ErrorView
errorMessage={errorMessage} errorMessage={errorMessage}
onClose={() => { onClose={() => {
setOpen(false); onClose();
setErrorMessage(null); setErrorMessage(null);
}} }}
onRetry={() => { onRetry={() => {
@ -291,7 +281,7 @@ function ModeSelectionView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="space-y-0 asnimate-fadeIn"> <div className="animate-fadeIn space-y-0">
<h2 className="text-lg font-bold leading-tight dark:text-white"> <h2 className="text-lg font-bold leading-tight dark:text-white">
Virtual Media Source Virtual Media Source
</h2> </h2>
@ -345,7 +335,7 @@ function ModeSelectionView({
)} )}
> >
<div <div
className="relative z-50 flex flex-col items-start p-4 select-none" className="relative z-50 flex select-none flex-col items-start p-4"
onClick={() => onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
} }
@ -353,7 +343,7 @@ function ModeSelectionView({
<div> <div>
<Card> <Card>
<div className="p-1"> <div className="p-1">
<Icon className="w-4 h-4 text-blue-700 shrink-0 dark:text-blue-400" /> <Icon className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -373,7 +363,7 @@ function ModeSelectionView({
value={mode} value={mode}
disabled={disabled} disabled={disabled}
checked={selectedMode === mode} checked={selectedMode === mode}
className="absolute w-4 h-4 text-blue-700 right-4 top-4" className="absolute right-4 top-4 h-4 w-4 text-blue-700"
/> />
</div> </div>
</Card> </Card>
@ -381,13 +371,13 @@ function ModeSelectionView({
))} ))}
</div> </div>
<div <div
className="flex justify-end opacity-0 animate-fadeIn" className="flex animate-fadeIn justify-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<div className="flex pt-2 gap-x-2"> <div className="flex gap-x-2 pt-2">
<Button size="MD" theme="blank" onClick={onClose} text="Cancel" /> <Button size="MD" theme="blank" onClick={onClose} text="Cancel" />
<Button <Button
size="MD" size="MD"
@ -445,18 +435,18 @@ function BrowserFileView({
className="block cursor-pointer select-none" className="block cursor-pointer select-none"
> >
<div <div
className="opacity-0 group animate-fadeIn" className="group animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
> >
<Card className="transition-all duration-300 outline-dashed hover:bg-blue-50/50"> <Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50">
<div className="w-full px-4 py-12"> <div className="w-full px-4 py-12">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{selectedFile ? ( {selectedFile ? (
<> <>
<div className="space-y-1"> <div className="space-y-1">
<LuHardDrive className="w-6 h-6 mx-auto text-blue-700" /> <LuHardDrive className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none"> <h3 className="text-sm font-semibold leading-none">
{formatters.truncateMiddle(selectedFile.name, 40)} {formatters.truncateMiddle(selectedFile.name, 40)}
</h3> </h3>
@ -467,7 +457,7 @@ function BrowserFileView({
</> </>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700" /> <PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" />
<h3 className="text-sm font-semibold leading-none"> <h3 className="text-sm font-semibold leading-none">
Click to select a file Click to select a file
</h3> </h3>
@ -491,7 +481,7 @@ function BrowserFileView({
</div> </div>
<div <div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -531,7 +521,7 @@ function UrlView({
const popularImages = [ const popularImages = [
{ {
name: "Ubuntu 24.04 LTS", name: "Ubuntu 24.04 LTS",
url: "https://releases.ubuntu.com/noble/ubuntu-24.04.1-desktop-amd64.iso", url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso",
icon: UbuntuIcon, icon: UbuntuIcon,
}, },
{ {
@ -586,7 +576,7 @@ function UrlView({
/> />
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -601,7 +591,7 @@ function UrlView({
/> />
</div> </div>
<div <div
className="flex items-end justify-between w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -627,7 +617,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" /> <hr className="border-slate-800/30 dark:border-slate-300/20" />
<div <div
className="opacity-0 animate-fadeIn" className="animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -636,7 +626,7 @@ function UrlView({
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white"> <h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images Popular images
</h2> </h2>
<Card className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20"> <Card className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{popularImages.map((image, index) => ( {popularImages.map((image, index) => (
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5"> <div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
@ -805,7 +795,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage" description="Select an image to mount from the JetKVM storage"
/> />
<div <div
className="w-full opacity-0 animate-fadeIn" className="w-full animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -816,7 +806,7 @@ function DeviceFileView({
<div className="flex items-center justify-center py-8 text-center"> <div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="w-6 h-6 mx-auto text-blue-700 dark:text-blue-500" /> <PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" />
<h3 className="text-sm font-semibold leading-none text-black dark:text-white"> <h3 className="text-sm font-semibold leading-none text-black dark:text-white">
No images available No images available
</h3> </h3>
@ -835,7 +825,7 @@ function DeviceFileView({
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20"> <div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20">
{currentFiles.map((file, index) => ( {currentFiles.map((file, index) => (
<PreUploadedImageItem <PreUploadedImageItem
key={index} key={index}
@ -847,7 +837,13 @@ function DeviceFileView({
onDelete={() => { onDelete={() => {
const selectedFile = onStorageFiles.find(f => f.name === file.name); const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return; if (!selectedFile) return;
if (
window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?",
)
) {
handleDeleteFile(selectedFile); handleDeleteFile(selectedFile);
}
}} }}
onSelect={() => handleOnSelectFile(file)} onSelect={() => handleOnSelectFile(file)}
onContinueUpload={() => onNewImageClick(file.name)} onContinueUpload={() => onNewImageClick(file.name)}
@ -888,7 +884,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? ( {onStorageFiles.length > 0 ? (
<div <div
className="flex items-end justify-between opacity-0 animate-fadeIn" className="flex animate-fadeIn items-end justify-between opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -916,7 +912,7 @@ function DeviceFileView({
</div> </div>
) : ( ) : (
<div <div
className="flex items-end justify-end opacity-0 animate-fadeIn" className="flex animate-fadeIn items-end justify-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -929,31 +925,39 @@ function DeviceFileView({
)} )}
<hr className="border-slate-800/20 dark:border-slate-300/20" /> <hr className="border-slate-800/20 dark:border-slate-300/20" />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.20s", animationDelay: "0.20s",
}} }}
> >
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">Available Storage</span> <span className="font-medium text-black dark:text-white">
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span> Available Storage
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
</span>
</div> </div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
<div <div
className="h-full transition-all duration-300 ease-in-out bg-blue-700 rounded-sm dark:bg-blue-500" className="h-full rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500"
style={{ width: `${percentageUsed}%` }} style={{ width: `${percentageUsed}%` }}
></div> ></div>
</div> </div>
<div className="flex justify-between text-sm text-slate-600"> <div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span> <span className="text-slate-700 dark:text-slate-300">
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span> {formatters.bytes(bytesUsed)} used
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
</span>
</div> </div>
</div> </div>
{onStorageFiles.length > 0 && ( {onStorageFiles.length > 0 && (
<div <div
className="w-full opacity-0 animate-fadeIn" className="w-full animate-fadeIn opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.25s", animationDelay: "0.25s",
@ -1120,7 +1124,7 @@ function UploadFileView({
alreadyUploadedBytes: number, alreadyUploadedBytes: number,
dataChannel: string, dataChannel: string,
) { ) {
const uploadUrl = `${SIGNAL_API}/storage/upload?uploadId=${dataChannel}`; const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true); xhr.open("POST", uploadUrl, true);
@ -1245,7 +1249,7 @@ function UploadFileView({
} }
/> />
<div <div
className="space-y-2 opacity-0 animate-fadeIn" className="animate-fadeIn space-y-2 opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -1261,17 +1265,18 @@ function UploadFileView({
<div className="group"> <div className="group">
<Card <Card
className={cx("transition-all duration-300", { className={cx("transition-all duration-300", {
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle", "cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
uploadState === "idle",
})} })}
> >
<div className="h-[186px] w-full px-4"> <div className="h-[186px] w-full px-4">
<div className="flex flex-col items-center justify-center h-full text-center"> <div className="flex h-full flex-col items-center justify-center text-center">
{uploadState === "idle" && ( {uploadState === "idle" && (
<div className="space-y-1"> <div className="space-y-1">
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <PlusCircleIcon className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1291,11 +1296,11 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <LuUpload className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="text-lg font-semibold text-black leading-non dark:text-white"> <h3 className="leading-non text-lg font-semibold text-black dark:text-white">
Uploading {formatters.truncateMiddle(uploadedFileName, 30)} Uploading {formatters.truncateMiddle(uploadedFileName, 30)}
</h3> </h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300"> <p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -1304,7 +1309,7 @@ function UploadFileView({
<div className="w-full space-y-2"> <div className="w-full space-y-2">
<div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700"> <div className="h-3.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-700">
<div <div
className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear" className="h-3.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
></div> ></div>
</div> </div>
@ -1325,7 +1330,7 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" /> <LuCheck className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1350,13 +1355,15 @@ function UploadFileView({
className="hidden" className="hidden"
accept=".iso, .img" accept=".iso, .img"
/> />
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>} {fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
)}
</div> </div>
{/* Display upload error if present */} {/* Display upload error if present */}
{uploadError && ( {uploadError && (
<div <div
className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn" className="mt-2 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400"
style={{ animationDuration: "0.7s" }} style={{ animationDuration: "0.7s" }}
> >
Error: {uploadError} Error: {uploadError}
@ -1364,13 +1371,13 @@ function UploadFileView({
)} )}
<div <div
className="flex items-end w-full opacity-0 animate-fadeIn" className="flex w-full animate-fadeIn items-end opacity-0"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
}} }}
> >
<div className="flex justify-end w-full space-x-2"> <div className="flex w-full justify-end space-x-2">
{uploadState === "uploading" ? ( {uploadState === "uploading" ? (
<Button <Button
size="MD" size="MD"
@ -1412,7 +1419,7 @@ function ErrorView({
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center space-x-2 text-red-600"> <div className="flex items-center space-x-2 text-red-600">
<ExclamationTriangleIcon className="w-6 h-6" /> <ExclamationTriangleIcon className="h-6 w-6" />
<h2 className="text-lg font-bold leading-tight">Mount Error</h2> <h2 className="text-lg font-bold leading-tight">Mount Error</h2>
</div> </div>
<p className="text-sm leading-snug text-slate-600"> <p className="text-sm leading-snug text-slate-600">
@ -1420,7 +1427,7 @@ function ErrorView({
</p> </p>
</div> </div>
{errorMessage && ( {errorMessage && (
<Card className="p-4 border border-red-200 bg-red-50"> <Card className="border border-red-200 bg-red-50 p-4">
<p className="text-sm font-medium text-red-800">{errorMessage}</p> <p className="text-sm font-medium text-red-800">{errorMessage}</p>
</Card> </Card>
)} )}
@ -1480,12 +1487,12 @@ function PreUploadedImageItem({
<div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400"> <div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400">
{formatters.date(new Date(uploadedAt), { month: "short" })} {formatters.date(new Date(uploadedAt), { month: "short" })}
</div> </div>
<div className="mx-1 h-[10px] w-[1px] bg-slate-300 dark:bg-slate-600 text-slate-300"></div> <div className="mx-1 h-[10px] w-[1px] bg-slate-300 text-slate-300 dark:bg-slate-600"></div>
<div className="text-gray-600 dark:text-slate-400">{size}</div> <div className="text-gray-600 dark:text-slate-400">{size}</div>
</div> </div>
</div> </div>
</div> </div>
<div className="relative flex items-center select-none gap-x-3"> <div className="relative flex select-none items-center gap-x-3">
<div <div
className={cx("opacity-0 transition-opacity duration-200", { className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering, "w-auto opacity-100": isHovering,
@ -1509,7 +1516,7 @@ function PreUploadedImageItem({
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={onSelect}
name={name} name={name}
className="w-3 h-3 text-blue-700 bg-white dark:bg-slate-800 border-slate-800/30 dark:border-slate-300/20 focus:ring-blue-500 disabled:opacity-30" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 focus:ring-blue-500 disabled:opacity-30 dark:border-slate-300/20 dark:bg-slate-800"
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
/> />
) : ( ) : (
@ -1549,7 +1556,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void; setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start space-y-1 select-none"> <div className="flex select-none flex-col items-start space-y-1">
<label className="text-sm font-semibold text-black dark:text-white">Mount as</label> <label className="text-sm font-semibold text-black dark:text-white">Mount as</label>
<div className="flex space-x-4"> <div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center"> <label htmlFor="cdrom" className="flex items-center">
@ -1559,7 +1566,7 @@ function UsbModeSelector({
name="mountType" name="mountType"
onChange={() => setUsbMode("CDROM")} onChange={() => setUsbMode("CDROM")}
checked={usbMode === "CDROM"} checked={usbMode === "CDROM"}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/> />
<span className="ml-2 text-sm font-medium text-slate-900 dark:text-white"> <span className="ml-2 text-sm font-medium text-slate-900 dark:text-white">
CD/DVD CD/DVD
@ -1573,10 +1580,10 @@ function UsbModeSelector({
disabled disabled
checked={usbMode === "Disk"} checked={usbMode === "Disk"}
onChange={() => setUsbMode("Disk")} onChange={() => setUsbMode("Disk")}
className="w-3 h-3 text-blue-700 transition-opacity bg-white border-slate-800/30 focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800" className="h-3 w-3 border-slate-800/30 bg-white text-blue-700 transition-opacity focus:ring-blue-500 disabled:opacity-30 dark:bg-slate-800"
/> />
<div className="flex flex-col ml-2 gap-y-0"> <div className="ml-2 flex flex-col gap-y-0">
<span className="text-sm font-medium leading-none opacity-50 text-slate-900 dark:text-white"> <span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
Disk Disk
</span> </span>
<div className="text-[10px] text-slate-500 dark:text-slate-400"> <div className="text-[10px] text-slate-500 dark:text-slate-400">

View File

@ -1,24 +1,23 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg"; import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg"; import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
export default function OtherSessionConnectedModal({ interface ContextType {
open, connectWebRTC: () => Promise<void>;
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} />
</Modal>
);
} }
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function OtherSessionRoute() {
const outletContext = useOutletContext<ContextType>();
const navigate = useNavigate();
// Function to handle closing the modal
const handleClose = () => {
outletContext?.connectWebRTC().then(() => navigate(".."));
};
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
return ( return (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto"> <GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10"> <div className="p-10">
@ -37,12 +36,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
this session? this session?
</p> </p>
<div className="flex items-center justify-start space-x-4"> <div className="flex items-center justify-start space-x-4">
<Button <Button size="SM" theme="primary" text="Use Here" onClick={handleClose} />
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,6 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import { getDeviceUiPath } from "../hooks/useAppNavigation";
export function loader({ params }: LoaderFunctionArgs) {
return redirect(getDeviceUiPath("/settings/general", params.id));
}

View File

@ -0,0 +1,335 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useLoaderData, useNavigate } from "react-router-dom";
import { Button, LinkButton } from "../components/Button";
import { DEVICE_API } from "../ui.config";
import api from "../api";
import { LocalDevice } from "./devices.$id";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { GridCard } from "../components/Card";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import notifications from "../notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { InputFieldWithLabel } from "../components/InputField";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsSectionHeader } from "../components/SettingsSectionHeader";
import { isOnDevice } from "../main";
import { CloudState } from "./adopt";
export const loader = async () => {
if (isOnDevice) {
const status = await api
.GET(`${DEVICE_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
return status;
}
return null;
};
export default function SettingsAccessIndexRoute() {
const loaderData = useLoaderData() as LocalDevice | null;
const { navigateTo } = useDeviceUiNavigation();
const navigate = useNavigate();
const [send] = useJsonRpc();
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudApiUrl, setCloudApiUrl] = useState("");
const [cloudAppUrl, setCloudAppUrl] = useState("");
// Use a simple string identifier for the selected provider
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as CloudState;
setAdopted(cloudState.connected);
setCloudApiUrl(cloudState.url);
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
// Find if the API URL matches any of our predefined providers
const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";
if (isAPIJetKVMProd && isAppJetKVMProd) {
setSelectedProvider("jetkvm");
} else {
setSelectedProvider("custom");
}
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
if (!isOnDevice) navigate("/");
return;
});
};
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
cloudAppUrl +
"/signup?deviceId=" +
deviceId +
`&returnTo=${returnTo.toString()}`;
});
},
[deviceId, send],
);
// Handle provider selection change
const handleProviderChange = (value: string) => {
setSelectedProvider(value);
// If selecting a predefined provider, update both URLs
if (value === "jetkvm") {
setCloudApiUrl("https://api.jetkvm.com");
setCloudAppUrl("https://app.jetkvm.com");
} else {
if (cloudApiUrl || cloudAppUrl) return;
setCloudApiUrl("");
setCloudAppUrl("");
}
};
// Fetch device ID and cloud state on component mount
useEffect(() => {
getCloudState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
}, [send, getCloudState]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
/>
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
/>
) : (
<Button
size="SM"
theme="light"
text="Enable Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
/>
)}
</SettingsItem>
{loaderData.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
>
<Button
size="SM"
theme="light"
text="Change Password"
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
/>
</SettingsItem>
)}
</div>
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
</>
)}
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]}
/>
</SettingsItem>
{selectedProvider === "custom" && (
<div className="mt-4 space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
/>
</div>
</div>
)}
</>
)}
{/* Show security info for JetKVM Cloud */}
{selectedProvider === "jetkvm" && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full dark:border-slate-600" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
)}
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<Button
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -1,30 +1,35 @@
import { GridCard } from "@/components/Card"; import { useState, useEffect } from "react";
import { useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import { InputFieldWithLabel } from "@/components/InputField";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { InputFieldWithLabel } from "./InputField";
import api from "@/api"; import api from "@/api";
import { useLocalAuthModalStore } from "@/hooks/stores"; import { useLocalAuthModalStore } from "@/hooks/stores";
import { useLocation, useRevalidator } from "react-router-dom";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function LocalAuthPasswordDialog({ export default function SecurityAccessLocalAuthRoute() {
open, const { setModalView } = useLocalAuthModalStore();
setOpen, const { navigateTo } = useDeviceUiNavigation();
}: { const location = useLocation();
open: boolean; const init = location.state?.init;
setOpen: (open: boolean) => void;
}) { useEffect(() => {
return ( if (!init) {
<Modal open={open} onClose={() => setOpen(false)}> navigateTo("..");
<Dialog setOpen={setOpen} /> } else {
</Modal> setModalView(init);
); }
}, [init, navigateTo, setModalView]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigateTo("..")} />;
} }
export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) { export function Dialog({ onClose }: { onClose: () => void }) {
const { modalView, setModalView } = useLocalAuthModalStore(); const { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => { const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") { if (password === "") {
@ -41,6 +46,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.POST("/auth/password-local", { password }); const res = await api.POST("/auth/password-local", { password });
if (res.ok) { if (res.ok) {
setModalView("creationSuccess"); setModalView("creationSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while setting the password"); setError(data.error || "An error occurred while setting the password");
@ -78,6 +85,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
if (res.ok) { if (res.ok) {
setModalView("updateSuccess"); setModalView("updateSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while changing the password"); setError(data.error || "An error occurred while changing the password");
@ -97,6 +106,8 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
const res = await api.DELETE("/auth/local-password", { password }); const res = await api.DELETE("/auth/local-password", { password });
if (res.ok) { if (res.ok) {
setModalView("deleteSuccess"); setModalView("deleteSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else { } else {
const data = await res.json(); const data = await res.json();
setError(data.error || "An error occurred while disabling the password"); setError(data.error || "An error occurred while disabling the password");
@ -107,12 +118,12 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
}; };
return ( return (
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800"> <div>
<div className="p-10"> <div>
{modalView === "createPassword" && ( {modalView === "createPassword" && (
<CreatePasswordModal <CreatePasswordModal
onSetPassword={handleCreatePassword} onSetPassword={handleCreatePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -120,7 +131,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "deletePassword" && ( {modalView === "deletePassword" && (
<DeletePasswordModal <DeletePasswordModal
onDeletePassword={handleDeletePassword} onDeletePassword={handleDeletePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -128,7 +139,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
{modalView === "updatePassword" && ( {modalView === "updatePassword" && (
<UpdatePasswordModal <UpdatePasswordModal
onUpdatePassword={handleUpdatePassword} onUpdatePassword={handleUpdatePassword}
onCancel={() => setOpen(false)} onCancel={onClose}
error={error} error={error}
/> />
)} )}
@ -137,7 +148,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Set Successfully" headline="Password Set Successfully"
description="You've successfully set up local device protection. Your device is now secure against unauthorized local access." description="You've successfully set up local device protection. Your device is now secure against unauthorized local access."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
@ -145,7 +156,7 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Protection Disabled" headline="Password Protection Disabled"
description="You've successfully disabled the password protection for local access. Remember, your device is now less secure." description="You've successfully disabled the password protection for local access. Remember, your device is now less secure."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
@ -153,11 +164,11 @@ export function Dialog({ setOpen }: { setOpen: (open: boolean) => void }) {
<SuccessModal <SuccessModal
headline="Password Updated Successfully" headline="Password Updated Successfully"
description="You've successfully changed your local device protection password. Make sure to remember your new password for future access." description="You've successfully changed your local device protection password. Make sure to remember your new password for future access."
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
</div> </div>
</GridCard> </div>
); );
} }
@ -175,13 +186,16 @@ function CreatePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div> <div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" /> <h2 className="text-lg font-semibold dark:text-white">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> Local Device Protection
</div> </h2>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access. Create a password to protect your device from unauthorized local access.
</p> </p>
@ -191,6 +205,7 @@ function CreatePasswordModal({
type="password" type="password"
placeholder="Enter a strong password" placeholder="Enter a strong password"
value={password} value={password}
autoFocus
onChange={e => setPassword(e.target.value)} onChange={e => setPassword(e.target.value)}
/> />
<InputFieldWithLabel <InputFieldWithLabel
@ -211,7 +226,7 @@ function CreatePasswordModal({
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} /> <Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </form>
</div> </div>
); );
} }
@ -229,13 +244,11 @@ function DeletePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2> <h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection. Enter your current password to disable local device protection.
</p> </p>
@ -281,13 +294,16 @@ function UpdatePasswordModal({
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<div> <div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" /> <h2 className="text-lg font-semibold dark:text-white">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" /> Change Local Device Password
</div> </h2>
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400"> <p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device Enter your current password and a new password to update your local device
protection. protection.
@ -324,7 +340,7 @@ function UpdatePasswordModal({
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} /> <Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div> </div>
{error && <p className="text-sm text-red-500">{error}</p>} {error && <p className="text-sm text-red-500">{error}</p>}
</div> </form>
</div> </div>
); );
} }
@ -339,11 +355,7 @@ function SuccessModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start justify-start w-full max-w-lg space-y-4 text-left"> <div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
</div>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2> <h2 className="text-lg font-semibold dark:text-white">{headline}</h2>

View File

@ -0,0 +1,263 @@
import { SettingsItem } from "./devices.$id.settings";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import Checkbox from "../components/Checkbox";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { useCallback, useState, useEffect } from "react";
import notifications from "../notifications";
import { TextAreaWithLabel } from "../components/TextArea";
import { isOnDevice } from "../main";
import { Button } from "../components/Button";
import { useSettingsStore } from "../hooks/stores";
import { GridCard } from "@components/Card";
export default function SettingsAdvancedRoute() {
const [send] = useJsonRpc();
const [sshKey, setSSHKey] = useState<string>("");
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const settings = useSettingsStore();
useEffect(() => {
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, resp => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [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],
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
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);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = (enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<GridCard>
<div className="flex select-none items-start gap-x-4 p-4">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>Security is weakened while active</li>
<li>Only use if you understand the risks</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use.
</div>
</div>
</div>
</GridCard>
)}
{isOnDevice && settings.developerMode && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"
description="Add your SSH public key to enable secure remote access to the device"
/>
<div className="space-y-4">
<TextAreaWithLabel
label="SSH Public Key"
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder="Enter your SSH public key"
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
The default SSH user is <strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update SSH Key"
onClick={handleUpdateSSHKey}
/>
</div>
</div>
</div>
)}
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.debugMode && (
<>
<SettingsItem
title="USB Emulation"
description="Control the USB emulation state"
>
<Button
size="SM"
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
>
<Button
size="SM"
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,53 @@
import { useCallback, useState } from "react";
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
export default function SettingsAppearanceRoute() {
const [currentTheme, setCurrentTheme] = useState(() => {
return localStorage.theme || "system";
});
const handleThemeChange = useCallback((value: string) => {
const root = document.documentElement;
if (value === "system") {
localStorage.removeItem("theme");
// Check system preference
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
root.classList.remove("light", "dark");
root.classList.add(systemTheme);
} else {
localStorage.theme = value;
root.classList.remove("light", "dark");
root.classList.add(value);
}
}, []);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your JetKVM interface"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic
size="SM"
label=""
value={currentTheme}
options={[
{ value: "system", label: "System" },
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
]}
onChange={e => {
setCurrentTheme(e.target.value);
handleThemeChange(e.target.value);
}}
/>
</SettingsItem>
</div>
);
}

View File

@ -0,0 +1,97 @@
import { SettingsPageHeader } from "../components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { useState } from "react";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "../components/Button";
import notifications from "../notifications";
import Checkbox from "../components/Checkbox";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { useDeviceStore } from "../hooks/stores";
export default function SettingsGeneralRoute() {
const [send] = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
if (!appVersion || !systemVersion) return null;
return { appVersion, systemVersion };
});
useEffect(() => {
send("getAutoUpdateState", {}, resp => {
if ("error" in resp) return;
setAutoUpdate(resp.result as boolean);
});
}, [send]);
const handleAutoUpdateChange = (enabled: boolean) => {
send("setAutoUpdateState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set auto-update: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAutoUpdate(enabled);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="General"
description="Configure device settings and update preferences"
/>
<div className="space-y-4">
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Check for Updates"
description={
currentVersions ? (
<>
App: {currentVersions.appVersion}
<br />
System: {currentVersions.systemVersion}
</>
) : (
<>
App: Loading...
<br />
System: Loading...
</>
)
}
/>
<div>
<Button
size="SM"
theme="light"
text="Check for Updates"
onClick={() => navigateTo("./update")}
/>
</div>
</div>
<div className="space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"
>
<Checkbox
checked={autoUpdate}
onChange={e => {
handleAutoUpdateChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,14 +1,44 @@
import Card, { GridCard } from "@/components/Card"; import { useLocation, useNavigate } from "react-router-dom";
import Card from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg"; import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import { UpdateState, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { CheckCircleIcon } from "@heroicons/react/20/solid"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
useEffect(() => {
if (otaState.updating) {
setModalView("updating");
} else if (otaState.error) {
setModalView("error");
} else if (updateSuccess) {
setModalView("updateCompleted");
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
export interface SystemVersionInfo { export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string }; local: { appVersion: string; systemVersion: string };
@ -17,37 +47,15 @@ export interface SystemVersionInfo {
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
} }
export default function UpdateDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
// We need to keep track of the update state in the dialog even if the dialog is minimized
const { setModalView } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
return (
<Modal open={open} onClose={() => setOpen(false)}>
<Dialog setOpen={setOpen} onConfirmUpdate={onConfirmUpdate} />
</Modal>
);
}
export function Dialog({ export function Dialog({
setOpen, onClose,
onConfirmUpdate, onConfirmUpdate,
}: { }: {
setOpen: (open: boolean) => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
}) { }) {
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
@ -73,27 +81,24 @@ export function Dialog({
}, [setModalView]); }, [setModalView]);
return ( return (
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto"> <div className="pointer-events-auto relative mx-auto text-left">
<div className="p-10"> <div>
{modalView === "error" && ( {modalView === "error" && (
<UpdateErrorState <UpdateErrorState
errorMessage={otaState.error} errorMessage={otaState.error}
onClose={() => setOpen(false)} onClose={onClose}
onRetryUpdate={() => setModalView("loading")} onRetryUpdate={() => setModalView("loading")}
/> />
)} )}
{modalView === "loading" && ( {modalView === "loading" && (
<LoadingState <LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)} )}
{modalView === "updateAvailable" && ( {modalView === "updateAvailable" && (
<UpdateAvailableState <UpdateAvailableState
onConfirmUpdate={onConfirmUpdate} onConfirmUpdate={onConfirmUpdate}
onClose={() => setOpen(false)} onClose={onClose}
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
@ -101,24 +106,20 @@ export function Dialog({
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
otaState={otaState} otaState={otaState}
onMinimizeUpgradeDialog={() => { onMinimizeUpgradeDialog={() => navigateTo("/")}
setOpen(false);
}}
/> />
)} )}
{modalView === "upToDate" && ( {modalView === "upToDate" && (
<SystemUpToDateState <SystemUpToDateState
checkUpdate={() => setModalView("loading")} checkUpdate={() => setModalView("loading")}
onClose={() => setOpen(false)} onClose={onClose}
/> />
)} )}
{modalView === "updateCompleted" && ( {modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
<UpdateCompletedState onClose={() => setOpen(false)} /> </div>
)}
</div> </div>
</GridCard>
); );
} }
@ -133,6 +134,9 @@ function LoadingState({
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
const setAppVersion = useDeviceStore(state => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
const getVersionInfo = useCallback(() => { const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => { return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => { send("getUpdateStatus", {}, async resp => {
@ -141,11 +145,13 @@ function LoadingState({
reject(new Error("Failed to check for updates")); reject(new Error("Failed to check for updates"));
} else { } else {
const result = resp.result as SystemVersionInfo; const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
resolve(result); resolve(result);
} }
}); });
}); });
}, [send]); }, [send, setAppVersion, setSystemVersion]);
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -156,14 +162,12 @@ function LoadingState({
const animationTimer = setTimeout(() => { const animationTimer = setTimeout(() => {
setProgressWidth("100%"); setProgressWidth("100%");
}, 500); }, 0);
getVersionInfo() getVersionInfo()
.then(versionInfo => { .then(versionInfo => {
if (progressBarRef.current) { // Add a small delay to ensure it's not just flickering
progressBarRef.current?.classList.add("!duration-1000"); return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
}) })
.then(versionInfo => { .then(versionInfo => {
if (!signal.aborted) { if (!signal.aborted) {
@ -183,12 +187,8 @@ function LoadingState({
}, [getVersionInfo, onFinished]); }, [getVersionInfo, onFinished]);
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div> <div className="space-y-4">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Checking for updates... Checking for updates...
@ -201,18 +201,11 @@ function LoadingState({
<div <div
ref={progressBarRef} ref={progressBarRef}
style={{ width: progressWidth }} style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out" className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out"
></div> ></div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button <Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -294,11 +287,7 @@ function UpdatingDeviceState({
}; };
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="w-full max-w-sm space-y-4"> <div className="w-full max-w-sm space-y-4">
<div className="space-y-0"> <div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
@ -308,10 +297,10 @@ function UpdatingDeviceState({
Please don{"'"}t turn off your device. This process may take a few minutes. Please don{"'"}t turn off your device. This process may take a few minutes.
</p> </p>
</div> </div>
<Card className="p-4 space-y-4"> <Card className="space-y-4 p-4">
{areAllUpdatesComplete() ? ( {areAllUpdatesComplete() ? (
<div className="flex flex-col items-center my-2 space-y-2 text-center"> <div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300"> <div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span className="font-medium text-black dark:text-white"> <span className="font-medium text-black dark:text-white">
Rebooting to complete the update... Rebooting to complete the update...
@ -321,8 +310,8 @@ function UpdatingDeviceState({
) : ( ) : (
<> <>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && ( {!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="flex flex-col items-center my-2 space-y-2 text-center"> <div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div> </div>
)} )}
@ -333,9 +322,9 @@ function UpdatingDeviceState({
Linux System Update Linux System Update
</p> </p>
{calculateOverallProgress("system") < 100 ? ( {calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : ( ) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)} )}
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -365,9 +354,9 @@ function UpdatingDeviceState({
App Update App Update
</p> </p>
{calculateOverallProgress("app") < 100 ? ( {calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : ( ) : (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" /> <CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)} )}
</div> </div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600"> <div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -390,7 +379,7 @@ function UpdatingDeviceState({
</> </>
)} )}
</Card> </Card>
<div className="flex justify-start mt-4 text-white gap-x-2"> <div className="mt-4 flex justify-start gap-x-2 text-white">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
@ -411,11 +400,7 @@ function SystemUpToDateState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
System is up to date System is up to date
@ -424,23 +409,9 @@ function SystemUpToDateState({
Your system is running the latest version. No updates are currently available. Your system is running the latest version. No updates are currently available.
</p> </p>
<div className="flex mt-4 gap-x-2"> <div className="mt-4 flex gap-x-2">
<Button <Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
size="SM" <Button size="SM" theme="blank" text="Back" onClick={onClose} />
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -457,11 +428,7 @@ function UpdateAvailableState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold text-black dark:text-white"> <p className="text-base font-semibold text-black dark:text-white">
Update available Update available
@ -495,11 +462,7 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white"> <p className="text-base font-semibold dark:text-white">
Update Completed Successfully Update Completed Successfully
@ -509,7 +472,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements! features and improvements!
</p> </p>
<div className="flex items-center justify-start"> <div className="flex items-center justify-start">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="primary" text="Back" onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -526,11 +489,7 @@ function UpdateErrorState({
onRetryUpdate: () => void; onRetryUpdate: () => void;
}) { }) {
return ( return (
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
<div>
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="mt-0 hidden h-[24px] dark:block" />
</div>
<div className="text-left"> <div className="text-left">
<p className="text-base font-semibold dark:text-white">Update Error</p> <p className="text-base font-semibold dark:text-white">Update Error</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-400"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-400">
@ -542,8 +501,8 @@ function UpdateErrorState({
</p> </p>
)} )}
<div className="flex items-center justify-start gap-x-2"> <div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Close" onClick={onClose} /> <Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} /> <Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,142 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useEffect } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "../notifications";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() {
const [send] = useJsonRpc();
const settings = useSettingsStore();
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
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);
handleBacklightSettingsSave();
};
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");
});
};
useEffect(() => {
send("getBacklightSettings", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
});
}, [send, setBacklightSettings]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
/>
<div className="space-y-4">
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
</>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
</div>
<FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting />
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbInfoSetting />
</FeatureFlag>
</div>
);
}

View File

@ -0,0 +1,185 @@
import MouseIcon from "@/assets/mouse-icon.svg";
import PointingFinger from "@/assets/pointing-finger.svg";
import { GridCard } from "@/components/Card";
import { Checkbox } from "@/components/Checkbox";
import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import { FeatureFlag } from "../components/FeatureFlag";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { SettingsItem } from "./devices.$id.settings";
type ScrollSensitivity = "low" | "default" | "high";
export default function SettingsKeyboardMouseRoute() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const mouseMode = useSettingsStore(state => state.mouseMode);
const setMouseMode = useSettingsStore(state => state.setMouseMode);
const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity);
const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity,
);
const { isEnabled: isScrollSensitivityEnabled } = useFeatureFlag("0.3.8");
const [jiggler, setJiggler] = useState(false);
const [send] = useJsonRpc();
useEffect(() => {
send("getJigglerState", {}, resp => {
if ("error" in resp) return;
setJiggler(resp.result as boolean);
});
if (isScrollSensitivityEnabled) {
send("getScrollSensitivity", {}, resp => {
if ("error" in resp) return;
setScrollSensitivity(resp.result as ScrollSensitivity);
});
}
}, [isScrollSensitivityEnabled, send, setScrollSensitivity]);
const handleJigglerChange = (enabled: boolean) => {
send("setJigglerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set jiggler state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setJiggler(enabled);
});
};
const onScrollSensitivityChange = useCallback(
(e: React.ChangeEvent<HTMLSelectElement>) => {
const sensitivity = e.target.value as ScrollSensitivity;
send("setScrollSensitivity", { sensitivity }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set scroll sensitivity: ${resp.error.data || "Unknown error"}`,
);
}
notifications.success("Scroll sensitivity set successfully");
setScrollSensitivity(sensitivity);
});
},
[send, setScrollSensitivity],
);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Mouse"
description="Configure cursor behavior and interaction settings for your device"
/>
<div className="space-y-4">
<SettingsItem
title="Hide Cursor"
description="Hide the cursor when sending mouse movements"
>
<Checkbox
checked={hideCursor}
onChange={e => setHideCursor(e.target.checked)}
/>
</SettingsItem>
<FeatureFlag minAppVersion="0.3.8" name="Scroll Sensitivity">
<SettingsItem
title="Scroll Sensitivity"
description="Adjust the scroll sensitivity"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={scrollSensitivity}
onChange={onScrollSensitivityChange}
options={
[
{ label: "Low", value: "low" },
{ label: "Default", value: "default" },
{ label: "High", value: "high" },
] as { label: string; value: ScrollSensitivity }[]
}
/>
</SettingsItem>
</FeatureFlag>
<SettingsItem
title="Jiggler"
description="Simulate movement of a computer mouse. Prevents sleep mode, standby mode or the screensaver from activating"
>
<Checkbox
checked={jiggler}
onChange={e => handleJigglerChange(e.target.checked)}
/>
</SettingsItem>
<div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex items-center gap-4">
<button
className="block group grow"
onClick={() => { setMouseMode("absolute"); }}
>
<GridCard>
<div className="flex items-center px-4 py-3 group gap-x-4">
<img
className="w-6 shrink-0 dark:invert"
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Absolute
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most convenient
</p>
</div>
{mouseMode === "absolute" && (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
</div>
</GridCard>
</button>
<button
className="block group grow"
onClick={() => { setMouseMode("relative"); }}
>
<GridCard>
<div className="flex items-center px-4 py-3 gap-x-4">
<img className="w-6 shrink-0 dark:invert" src={MouseIcon} alt="Mouse icon" />
<div className="flex items-center justify-between grow">
<div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white">
Relative
</h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible (Beta)
</p>
</div>
{mouseMode === "relative" && (
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
)}
</div>
</div>
</GridCard>
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,260 @@
import { NavLink, Outlet, useLocation } from "react-router-dom";
import Card from "@/components/Card";
import {
LuSettings,
LuKeyboard,
LuVideo,
LuCpu,
LuShieldCheck,
LuWrench,
LuArrowLeft,
LuPalette,
} from "react-icons/lu";
import { LinkButton } from "../components/Button";
import React, { useEffect, useRef, useState } from "react";
import { cx } from "../cva.config";
import { useUiStore } from "../hooks/stores";
import useKeyboard from "../hooks/useKeyboard";
import { useResizeObserver } from "../hooks/useResizeObserver";
import LoadingSpinner from "../components/LoadingSpinner";
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
export default function SettingsRoute() {
const location = useLocation();
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { sendKeyboardEvent } = useKeyboard();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [showLeftGradient, setShowLeftGradient] = useState(false);
const [showRightGradient, setShowRightGradient] = useState(false);
const { width } = useResizeObserver({ ref: scrollContainerRef });
// Handle scroll position to show/hide gradients
const handleScroll = () => {
if (scrollContainerRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.current;
// Show left gradient only if scrolled to the right
setShowLeftGradient(scrollLeft > 0);
// Show right gradient only if there's more content to scroll to the right
setShowRightGradient(scrollLeft < scrollWidth - clientWidth - 1); // -1 for rounding errors
}
};
useEffect(() => {
// Check initial scroll position
handleScroll();
// Add scroll event listener to the container
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.addEventListener("scroll", handleScroll);
}
return () => {
// Clean up event listener
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", handleScroll);
}
};
}, [width]);
useEffect(() => {
// disable focus trap
setTimeout(() => {
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
sendKeyboardEvent([], []);
setDisableVideoFocusTrap(true);
// For some reason, the focus trap is not disabled immediately
// so we need to blur the active element
(document.activeElement as HTMLElement)?.blur();
console.log("Just disabled focus trap");
}, 300);
return () => {
setDisableVideoFocusTrap(false);
};
}, [setDisableVideoFocusTrap, sendKeyboardEvent]);
return (
<div className="pointer-events-auto relative mx-auto max-w-4xl translate-x-0 transform text-left dark:text-white">
<div className="h-full">
<div className="w-full gap-x-8 gap-y-4 space-y-4 md:grid md:grid-cols-8 md:space-y-0">
<div className="w-full select-none space-y-4 md:col-span-2">
<Card className="flex w-full gap-x-4 overflow-hidden p-2 md:flex-col dark:bg-slate-800">
<div className="md:hidden">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
/>
</div>
<div className="hidden md:block">
<LinkButton
to=".."
size="SM"
theme="blank"
text="Back to KVM"
LeadingIcon={LuArrowLeft}
textAlign="left"
fullWidth
/>
</div>
</Card>
<Card className="relative overflow-hidden">
{/* Gradient overlay for left side - only visible on mobile when scrolled */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 left-0 z-10 w-8 bg-gradient-to-r from-white to-transparent transition-opacity duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showLeftGradient,
"opacity-100": showLeftGradient,
},
)}
></div>
{/* Gradient overlay for right side - only visible on mobile when there's more content */}
<div
className={cx(
"pointer-events-none absolute inset-y-0 right-0 z-10 w-8 bg-gradient-to-l from-white to-transparent transition duration-300 ease-in-out md:hidden dark:from-slate-900",
{
"opacity-0": !showRightGradient,
"opacity-100": showRightGradient,
},
)}
></div>
<div
ref={scrollContainerRef}
className="hide-scrollbar relative flex w-full gap-x-4 overflow-x-auto whitespace-nowrap p-2 md:flex-col md:overflow-visible md:whitespace-normal dark:bg-slate-800"
>
<div className="shrink-0">
<NavLink
to="general"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuSettings className="h-4 w-4 shrink-0" />
<h1>General</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="mouse"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuKeyboard className="h-4 w-4 shrink-0" />
<h1>Mouse</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="video"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuVideo className="h-4 w-4 shrink-0" />
<h1>Video</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="hardware"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuCpu className="h-4 w-4 shrink-0" />
<h1>Hardware</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="access"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuShieldCheck className="h-4 w-4 shrink-0" />
<h1>Access</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="appearance"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuPalette className="h-4 w-4 shrink-0" />
<h1>Appearance</h1>
</div>
</NavLink>
</div>
<div className="shrink-0">
<NavLink
to="advanced"
className={({ isActive }) => (isActive ? "active" : "")}
>
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 [.active_&]:bg-blue-50 [.active_&]:!text-blue-700 md:[.active_&]:bg-transparent dark:[.active_&]:bg-blue-900 dark:[.active_&]:!text-blue-200 dark:md:[.active_&]:bg-transparent">
<LuWrench className="h-4 w-4 shrink-0" />
<h1>Advanced</h1>
</div>
</NavLink>
</div>
</div>
</Card>
</div>
<div className="w-full md:col-span-6">
{/* <AutoHeight> */}
<Card className="dark:bg-slate-800">
<div
className="space-y-4 px-8 py-6"
style={{ animationDuration: "0.7s" }}
key={location.pathname} // This is a workaround to force the animation to run when the route changes
>
<Outlet />
</div>
</Card>
{/* </AutoHeight> */}
</div>
</div>
</div>
</div>
);
}
export function SettingsItem({
title,
description,
children,
className,
loading,
}: {
title: string;
description: string | React.ReactNode;
children?: React.ReactNode;
className?: string;
name?: string;
loading?: boolean;
}) {
return (
<label
className={cx(
"flex select-none items-center justify-between gap-x-8 rounded",
className,
)}
>
<div className="space-y-0.5">
<div className="flex items-center gap-x-2">
<h3 className="text-base font-semibold text-black dark:text-white">{title}</h3>
{loading && <LoadingSpinner className="h-4 w-4 text-blue-500" />}
</div>
<p className="text-sm text-slate-700 dark:text-slate-300">{description}</p>
</div>
{children ? <div>{children}</div> : null}
</label>
);
}

View File

@ -0,0 +1,185 @@
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "./devices.$id.settings";
import { Button } from "@/components/Button";
import { TextAreaWithLabel } from "@/components/TextArea";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useState, useEffect } from "react";
import notifications from "../notifications";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
const defaultEdid =
"00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b";
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
},
{
value:
"00FFFFFFFFFFFF00047265058A3F6101101E0104A53420783FC125A8554EA0260D5054BFEF80714F8140818081C081008B009500B300283C80A070B023403020360006442100001A000000FD00304C575716010A202020202020000000FC0042323436574C0A202020202020000000FF0054384E4545303033383532320A01F802031CF14F90020304050607011112131415161F2309070783010000011D8018711C1620582C250006442100009E011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E9600064421000018C344806E70B028401720A80406442100001E00000000000000000000000000000000000000000000000000000096",
label: "Acer B246WL, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0006B3872401010101021F010380342078EA6DB5A7564EA0250D5054BF6F00714F8180814081C0A9409500B300D1C0283C80A070B023403020360006442100001A000000FD00314B1E5F19000A202020202020000000FC00504132343851560A2020202020000000FF004D314C4D51533035323135370A014D02032AF14B900504030201111213141F230907078301000065030C001000681A00000101314BE6E2006A023A801871382D40582C450006442100001ECD5F80B072B0374088D0360006442100001C011D007251D01E206E28550006442100001E8C0AD08A20E02D10103E960006442100001800000000000000000000000000DC",
label: "ASUS PA248QV, 1920x1200",
},
{
value:
"00FFFFFFFFFFFF0010AC132045393639201E0103803C22782ACD25A3574B9F270D5054A54B00714F8180A9C0D1C00101010101010101023A801871382D40582C450056502100001E000000FF00335335475132330A2020202020000000FC0044454C4C204432373231480A20000000FD00384C1E5311000A202020202020018102031AB14F90050403020716010611121513141F65030C001000023A801871382D40582C450056502100001E011D8018711C1620582C250056502100009E011D007251D01E206E28550056502100001E8C0AD08A20E02D10103E960056502100001800000000000000000000000000000000000000000000000000000000004F",
label: "DELL D2721H, 1920x1080",
},
];
const streamQualityOptions = [
{ value: "1", label: "High" },
{ value: "0.5", label: "Medium" },
{ value: "0.1", label: "Low" },
];
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
useEffect(() => {
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
});
send("getEDID", {}, resp => {
if ("error" in resp) {
notifications.error(`Failed to get EDID: ${resp.error.data || "Unknown error"}`);
return;
}
const receivedEdid = resp.result as string;
const matchingEdid = edids.find(
x => x.value.toLowerCase() === receivedEdid.toLowerCase(),
);
if (matchingEdid) {
// EDID is stored in uppercase in the UI
setEdid(matchingEdid.value.toUpperCase());
// Reset custom EDID value
setCustomEdidValue(null);
} else {
setEdid("custom");
setCustomEdidValue(receivedEdid);
}
});
}, [send]);
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"}`,
);
return;
}
notifications.success(`Stream quality set to ${streamQualityOptions.find(x => x.value === factor)?.label}`);
setStreamQuality(factor);
});
};
const handleEDIDChange = (newEdid: string) => {
send("setEDID", { edid: newEdid }, resp => {
if ("error" in resp) {
notifications.error(`Failed to set EDID: ${resp.error.data || "Unknown error"}`);
return;
}
notifications.success(
`EDID set successfully to ${edids.find(x => x.value === newEdid)?.label}`,
);
// Update the EDID value in the UI
setEdid(newEdid);
});
};
return (
<div className="space-y-3">
<div className="space-y-4">
<SettingsPageHeader
title="Video"
description="Configure display settings and EDID for optimal compatibility"
/>
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
>
<SelectMenuBasic
size="SM"
label=""
value={streamQuality}
options={streamQualityOptions}
onChange={e => handleStreamQualityChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="EDID"
description="Adjust the EDID settings for the display"
>
<SelectMenuBasic
size="SM"
label=""
fullWidth
value={customEdidValue ? "custom" : edid || "asd"}
onChange={e => {
if (e.target.value === "custom") {
setEdid("custom");
setCustomEdidValue("");
} else {
setCustomEdidValue(null);
handleEDIDChange(e.target.value as string);
}
}}
options={[...edids, { value: "custom", label: "Custom" }]}
/>
</SettingsItem>
{customEdidValue !== null && (
<>
<SettingsItem
title="Custom EDID"
description="EDID details video mode compatibility. Default settings works in most cases, but unique UEFI/BIOS might need adjustments."
/>
<TextAreaWithLabel
label="EDID File"
placeholder="00F..."
rows={3}
value={customEdidValue}
onChange={e => setCustomEdidValue(e.target.value)}
/>
<div className="flex justify-start gap-x-2">
<Button
size="SM"
theme="primary"
text="Set Custom EDID"
onClick={() => handleEDIDChange(customEdidValue)}
/>
<Button
size="SM"
theme="light"
text="Restore to default"
onClick={() => {
setCustomEdidValue(null);
handleEDIDChange(defaultEdid);
}}
/>
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -1,9 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { Transition } from "@headlessui/react";
import { import {
DeviceSettingsState,
HidState, HidState,
UpdateState, UpdateState,
useDeviceSettingsStore,
useDeviceStore,
useHidStore, useHidStore,
useMountMediaStore, useMountMediaStore,
User, User,
@ -16,27 +18,33 @@ import {
import WebRTCVideo from "@components/WebRTCVideo"; import WebRTCVideo from "@components/WebRTCVideo";
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
Outlet,
Params, Params,
redirect, redirect,
useLoaderData, useLoaderData,
useLocation,
useNavigate, useNavigate,
useOutlet,
useParams, useParams,
useSearchParams, useSearchParams,
} from "react-router-dom"; } from "react-router-dom";
import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { checkAuth, isInCloud, isOnDevice } from "@/main";
import DashboardNavbar from "@components/Header"; import DashboardNavbar from "@components/Header";
import { useInterval } from "usehooks-ts"; import { useInterval } from "usehooks-ts";
import SettingsSidebar from "@/components/sidebar/settings";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats"; import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import UpdateDialog from "@components/UpdateDialog";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard"; import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api"; import api from "../api";
import { DeviceStatus } from "./welcome-local"; import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react"; import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import Terminal from "@components/Terminal"; import Terminal from "@components/Terminal";
import { CLOUD_API, SIGNAL_API } from "@/ui.config"; import { CLOUD_API, DEVICE_API } from "@/ui.config";
import Modal from "../components/Modal";
import { motion, AnimatePresence } from "motion/react";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import notifications from "../notifications";
interface LocalLoaderResp { interface LocalLoaderResp {
authMode: "password" | "noPassword" | null; authMode: "password" | "noPassword" | null;
@ -50,19 +58,20 @@ interface CloudLoaderResp {
} | null; } | null;
} }
export type AuthMode = "password" | "noPassword" | null;
export interface LocalDevice { export interface LocalDevice {
authMode: "password" | "noPassword" | null; authMode: AuthMode;
deviceId: string; deviceId: string;
} }
const deviceLoader = async () => { const deviceLoader = async () => {
const res = await api const res = await api
.GET(`${SIGNAL_API}/device/status`) .GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>); .then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome"); if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${SIGNAL_API}/device`); const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local"); if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) { if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice; const device = (await deviceRes.json()) as LocalDevice;
@ -78,9 +87,7 @@ const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> =>
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`); const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json(); const iceConfig = await iceResp.json();
const deviceResp = await api.GET( const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`);
`${CLOUD_API}/devices/${params.id}`,
);
if (!deviceResp.ok) { if (!deviceResp.ok) {
if (deviceResp.status === 404) { if (deviceResp.status === 404) {
@ -125,16 +132,7 @@ export default function KvmIdRoute() {
const setTransceiver = useRTCStore(state => state.setTransceiver); const setTransceiver = useRTCStore(state => state.setTransceiver);
const navigate = useNavigate(); const navigate = useNavigate();
const { const { otaState, setOtaState, setModalView } = useUpdateStore();
otaState,
setOtaState,
isUpdateDialogOpen,
setIsUpdateDialogOpen,
setModalView,
} = useUpdateStore();
const [isOtherSessionConnectedModalOpen, setIsOtherSessionConnectedModalOpen] =
useState(false);
const sdp = useCallback( const sdp = useCallback(
async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => { async (event: RTCPeerConnectionIceEvent, pc: RTCPeerConnection) => {
@ -143,7 +141,11 @@ export default function KvmIdRoute() {
try { try {
const sd = btoa(JSON.stringify(pc.localDescription)); const sd = btoa(JSON.stringify(pc.localDescription));
const res = await api.POST(`${SIGNAL_API}/webrtc/session`, {
const sessionUrl = isOnDevice
? `${DEVICE_API}/webrtc/session`
: `${CLOUD_API}/webrtc/session`;
const res = await api.POST(sessionUrl, {
sd, sd,
// When on device, we don't need to specify the device id, as it's already known // When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }), ...(isOnDevice ? {} : { id: params.id }),
@ -241,8 +243,7 @@ export default function KvmIdRoute() {
) { ) {
return; return;
} }
// We don't want to connect if another session is connected if (location.pathname.includes("other-session")) return;
if (isOtherSessionConnectedModalOpen) return;
connectWebRTC(); connectWebRTC();
}, 3000); }, 3000);
@ -328,11 +329,11 @@ export default function KvmIdRoute() {
const setHdmiState = useVideoStore(state => state.setHdmiState); const setHdmiState = useVideoStore(state => state.setHdmiState);
const [hasUpdated, setHasUpdated] = useState(false); const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
function onJsonRpcRequest(resp: JsonRpcRequest) { function onJsonRpcRequest(resp: JsonRpcRequest) {
if (resp.method === "otherSessionConnected") { if (resp.method === "otherSessionConnected") {
console.log("otherSessionConnected", resp.params); navigateTo("/other-session");
setIsOtherSessionConnectedModalOpen(true);
} }
if (resp.method === "usbState") { if (resp.method === "usbState") {
@ -356,7 +357,7 @@ export default function KvmIdRoute() {
if (otaState.error) { if (otaState.error) {
setModalView("error"); setModalView("error");
setIsUpdateDialogOpen(true); navigateTo("/settings/general/update");
return; return;
} }
@ -386,11 +387,9 @@ export default function KvmIdRoute() {
// When the update is successful, we need to refresh the client javascript and show a success modal // When the update is successful, we need to refresh the client javascript and show a success modal
useEffect(() => { useEffect(() => {
if (queryParams.get("updateSuccess")) { if (queryParams.get("updateSuccess")) {
setModalView("updateCompleted"); navigateTo("/settings/general/update", { state: { updateSuccess: true } });
setIsUpdateDialogOpen(true);
setQueryParams({});
} }
}, [queryParams, setIsUpdateDialogOpen, setModalView, setQueryParams]); }, [navigate, navigateTo, queryParams, setModalView, setQueryParams]);
const diskChannel = useRTCStore(state => state.diskChannel)!; const diskChannel = useRTCStore(state => state.diskChannel)!;
const file = useMountMediaStore(state => state.localFile)!; const file = useMountMediaStore(state => state.localFile)!;
@ -431,30 +430,60 @@ export default function KvmIdRoute() {
} }
}, [kvmTerminal, peerConnection, serialConsole]); }, [kvmTerminal, peerConnection, serialConsole]);
useEffect(() => { const outlet = useOutlet();
kvmTerminal?.addEventListener("message", e => { const location = useLocation();
console.log(e.data); const onModalClose = useCallback(() => {
}); if (location.pathname !== "/other-session") navigateTo("/");
}, [navigateTo, location.pathname]);
return () => { const appVersion = useDeviceStore(state => state.appVersion);
kvmTerminal?.removeEventListener("message", e => { const setAppVersion = useDeviceStore(state => state.setAppVersion);
console.log(e.data); const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
useEffect(() => {
if (appVersion) return;
send("getUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error("Failed to get device version");
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
}
}); });
}; }, [appVersion, send, setAppVersion, setSystemVersion]);
}, [kvmTerminal]);
const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity,
);
// Initialize device settings
useEffect(
function initializeDeviceSettings() {
send("getScrollSensitivity", {}, resp => {
if ("error" in resp) return;
setScrollSensitivity(resp.result as DeviceSettingsState["scrollSensitivity"]);
});
},
[send, setScrollSensitivity],
);
return ( return (
<> <FeatureFlagProvider appVersion={appVersion}>
<Transition show={!isUpdateDialogOpen && otaState.updating}> {!outlet && otaState.updating && (
<div className="pointer-events-none fixed inset-0 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"> <AnimatePresence>
<div className="transition duration-1000 ease-in data-[closed]:opacity-0"> <motion.div
<UpdateInProgressStatusCard className="pointer-events-none fixed inset-0 top-16 z-10 mx-auto flex h-full w-full max-w-xl translate-y-8 items-start justify-center"
setIsUpdateDialogOpen={setIsUpdateDialogOpen} initial={{ opacity: 0, y: -20 }}
setModalView={setModalView} animate={{ opacity: 1, y: 0 }}
/> exit={{ opacity: 0, y: -20 }}
</div> transition={{ duration: 0.3, ease: "easeInOut" }}
</div> >
</Transition> <UpdateInProgressStatusCard />
</motion.div>
</AnimatePresence>
)}
<div className="relative h-full"> <div className="relative h-full">
<FocusTrap <FocusTrap
paused={disableKeyboardFocusTrap} paused={disableKeyboardFocusTrap}
@ -484,25 +513,28 @@ export default function KvmIdRoute() {
</div> </div>
</div> </div>
</div> </div>
<UpdateDialog open={isUpdateDialogOpen} setOpen={setIsUpdateDialogOpen} />
<OtherSessionConnectedModal
open={isOtherSessionConnectedModalOpen}
setOpen={state => {
if (!state) connectWebRTC().then(r => r);
// It takes some time for the WebRTC connection to be established, so we wait a bit before closing the modal <div
setTimeout(() => { className="isolate"
setIsOtherSessionConnectedModalOpen(state); onKeyUp={e => e.stopPropagation()}
}, 1000); onKeyDown={e => {
e.stopPropagation();
if (e.key === "Escape") navigateTo("/");
}} }}
/> >
<Modal open={outlet !== null} onClose={onModalClose}>
<Outlet context={{ connectWebRTC }} />
</Modal>
</div>
{kvmTerminal && ( {kvmTerminal && (
<Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" /> <Terminal type="kvm" dataChannel={kvmTerminal} title="KVM Terminal" />
)} )}
{serialConsole && ( {serialConsole && (
<Terminal type="serial" dataChannel={serialConsole} title="Serial Console" /> <Terminal type="serial" dataChannel={serialConsole} title="Serial Console" />
)} )}
</> </FeatureFlagProvider>
); );
} }
@ -516,16 +548,22 @@ function SidebarContainer({ sidebarView }: { sidebarView: string | null }) {
style={{ width: sidebarView ? "493px" : 0 }} style={{ width: sidebarView ? "493px" : 0 }}
> >
<div className="relative w-[493px] shrink-0"> <div className="relative w-[493px] shrink-0">
<Transition show={sidebarView === "system"} unmount={false}> <AnimatePresence>
<div className="absolute inset-0"> {sidebarView === "connection-stats" && (
<SettingsSidebar /> <motion.div
</div> className="absolute inset-0"
</Transition> initial={{ opacity: 0 }}
<Transition show={sidebarView === "connection-stats"} unmount={false}> animate={{ opacity: 1 }}
<div className="absolute inset-0"> exit={{ opacity: 0 }}
transition={{
duration: 0.5,
ease: "easeInOut",
}}
>
<ConnectionStatsSidebar /> <ConnectionStatsSidebar />
</div> </motion.div>
</Transition> )}
</AnimatePresence>
</div> </div>
</div> </div>
); );

Some files were not shown because too many files have changed in this diff Show More