Compare commits

..

No commits in common. "5452d7c721b575440500d3b0dffd494ab9ef9b0f" and "4351cc8dd718aeded8d2da34f909c333c64a6e2d" have entirely different histories.

150 changed files with 4157 additions and 8379 deletions

View File

@ -6,9 +6,5 @@
// 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"
]
} }

View File

@ -1,39 +0,0 @@
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
if: "github.event.review.state == 'approved' || github.event.event_type != 'pull_request_review'"
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

View File

@ -1,37 +0,0 @@
---
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: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
with:
args: --verbose
version: v1.62.0

View File

@ -1,122 +0,0 @@
name: smoketest
on:
repository_dispatch:
types: [smoketest]
jobs:
ghbot_payload:
name: Ghbot payload
runs-on: ubuntu-latest
steps:
- name: "GH_CHECK_RUN_ID=${{ github.event.client_payload.check_run_id }}"
run: |
echo "== START GHBOT_PAYLOAD =="
cat <<'GHPAYLOAD_EOF' | base64
${{ toJson(github.event.client_payload) }}
GHPAYLOAD_EOF
echo "== END GHBOT_PAYLOAD =="
deploy_and_test:
runs-on: buildjet-4vcpu-ubuntu-2204
name: Smoke test
concurrency:
group: smoketest-jk
steps:
- name: Download artifact
run: |
wget -O /tmp/jk.zip "${{ github.event.client_payload.artifact_download_url }}"
unzip /tmp/jk.zip
- 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 "+ Waiting for 10 seconds to allow all services to start"
sleep 10
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

View File

@ -1,34 +0,0 @@
---
name: ui-lint
on:
push:
paths:
- "ui/**"
- "package.json"
- "package-lock.json"
- ".github/workflows/ui-lint.yml"
permissions:
contents: read
jobs:
ui-lint:
name: UI Lint
runs-on: buildjet-4vcpu-ubuntu-2204
steps:
- name: Checkout repository
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: "ui/package-lock.json"
- name: Install dependencies
run: |
cd ui
npm ci
- name: Lint UI
run: |
cd ui
npm run lint

View File

@ -1,22 +0,0 @@
---
linters:
enable:
- forbidigo
- goimports
- misspell
# - revive
- whitespace
issues:
exclude-rules:
- path: _test.go
linters:
- errcheck
linters-settings:
forbidigo:
forbid:
- p: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.

View File

@ -1,31 +1,17 @@
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD) VERSION_DEV := 0.3.8-dev$(shell date +%Y%m%d%H%M)
BUILDDATE ?= $(shell date -u +%FT%T%z) VERSION := 0.3.7
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.0-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.9
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="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.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: frontend build_dev dev_release: 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
@ -33,7 +19,7 @@ dev_release: frontend 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="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.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

@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
## I need help ## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord). The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
## I want to report an issue ## I want to report an issue

View File

@ -3,6 +3,7 @@ package kvm
import ( import (
"context" "context"
"errors" "errors"
"log"
"net" "net"
"os" "os"
"time" "time"
@ -93,8 +94,7 @@ 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 {
logger.Errorf("Failed to remove existing socket file %s: %v", nbdSocketPath, err) log.Fatalf("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,
}) })
logger.Infof("nbd server exited: %v", err) log.Println("nbd server exited:", 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),
}) })
logger.Infof("nbd client exited: %v", err) log.Println("nbd client exited:", 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 {
logger.Warnf("error disconnecting nbd client: %v", err) log.Println("error disconnecting nbd client:", err)
} }
_ = d.dev.Close() _ = d.dev.Close()
} }

323
cloud.go
View File

@ -4,16 +4,12 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"sync"
"time" "time"
"github.com/coder/websocket/wsjson" "github.com/coder/websocket/wsjson"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc" "github.com/coreos/go-oidc/v3/oidc"
@ -28,142 +24,6 @@ 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
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
WebsocketPingInterval = 15 * time.Second
)
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_sent",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_requests",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
},
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count",
Help: "The number of times the cloud connection has failed",
},
)
)
var (
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) { func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest var req CloudRegisterRequest
@ -184,31 +44,22 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
client := &http.Client{Timeout: CloudAPIRequestTimeout} resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload))
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 apiResp.Body.Close() defer resp.Body.Close()
if apiResp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status}) c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status})
return return
} }
var tokenResp struct { var tokenResp struct {
SecretToken string `json:"secretToken"` SecretToken string `json:"secretToken"`
} }
if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil { if err := json.NewDecoder(resp.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
} }
@ -218,7 +69,13 @@ func handleCloudRegister(c *gin.Context) {
return return
} }
if config.CloudToken == "" {
logger.Info("Starting websocket client due to adoption")
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 {
@ -248,86 +105,76 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"}) c.JSON(200, gin.H{"message": "Cloud registration successful"})
} }
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Tracef("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Infof("cloud disconnect channel is closed, no need to disconnect: %v", r)
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error { func runWebsocketClient() error {
if config.CloudToken == "" { if config.CloudToken == "" {
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set") return fmt.Errorf("cloud token is not set")
} }
wsURL, err := url.Parse(config.CloudURL) wsURL, err := url.Parse(config.CloudURL)
if err != nil { if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err) return fmt.Errorf("failed to parse config.CloudURL: %w", err)
} }
if wsURL.Scheme == "http" { if wsURL.Scheme == "http" {
wsURL.Scheme = "ws" wsURL.Scheme = "ws"
} else { } else {
wsURL.Scheme = "wss" wsURL.Scheme = "wss"
} }
header := http.Header{} header := http.Header{}
header.Set("X-Device-ID", GetDeviceID()) header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken) header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout) dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
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,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
websocketLogger.Infof("ping frame received: %v, source: %s, sourceType: cloud", payload, wsURL.Host)
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
}) })
// if the context is canceled, we don't want to return an error
if err != nil { if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Infof("websocket connection canceled")
return nil
}
return err return err
} }
defer c.CloseNow() //nolint:errcheck defer c.CloseNow()
cloudLogger.Infof("websocket connected to %s", wsURL) logger.Infof("WS connected to %v", wsURL.String())
runCtx, cancelRun := context.WithCancel(context.Background())
defer cancelRun()
go func() {
for {
time.Sleep(15 * time.Second)
err := c.Ping(runCtx)
if err != nil {
logger.Warnf("websocket ping error: %v", err)
cancelRun()
return
}
}
}()
for {
typ, msg, err := c.Read(runCtx)
if err != nil {
return err
}
if typ != websocket.MessageText {
// ignore non-text messages
continue
}
var req WebRTCSessionRequest
err = json.Unmarshal(msg, &req)
if err != nil {
logger.Warnf("unable to parse ws message: %v", string(msg))
continue
}
// set the metrics when we successfully connect to the cloud. err = handleSessionRequest(runCtx, c, req)
wsResetMetrics(true, "cloud", wsURL.Host) if err != nil {
logger.Infof("error starting new session: %v", err)
// we don't have a source for the cloud connection continue
return handleWebRTCSignalWsMessages(c, true, wsURL.Host) }
}
} }
func authenticateSession(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, CloudOidcRequestTimeout) oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute)
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 {
_ = wsjson.Write(context.Background(), c, gin.H{ fmt.Println("Failed to initialize OIDC provider:", err)
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Errorf("failed to initialize OIDC provider: %v", err)
return err return err
} }
@ -343,39 +190,13 @@ func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessi
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")
} }
return nil
}
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest, isCloudConnection bool, source string) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{ session, err := newSession(SessionConfig{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers, ICEServers: req.ICEServers,
LocalIP: req.IP,
IsCloud: true,
}) })
if err != nil { if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err}) _ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -395,41 +216,16 @@ 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{"type": "answer", "data": sd}) _ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
return nil return nil
} }
func RunWebsocketClient() { func RunWebsocketClient() {
for { for {
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.Up {
cloudLogger.Warn("waiting for network to be up, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSyncSuccess {
cloudLogger.Warn("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient() err := runWebsocketClient()
if err != nil { if err != nil {
cloudLogger.Errorf("websocket client error: %v", err) fmt.Println("Websocket client error:", err)
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second) time.Sleep(5 * time.Second)
} }
} }
@ -438,14 +234,12 @@ 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,
} }
} }
@ -460,7 +254,7 @@ func rpcDeregisterDevice() error {
} }
req.Header.Set("Authorization", "Bearer "+config.CloudToken) req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: CloudAPIRequestTimeout} client := &http.Client{Timeout: 10 * time.Second}
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)
@ -473,15 +267,12 @@ 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)
} }
cloudLogger.Infof("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
return nil return nil
} }

View File

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

View File

@ -5,8 +5,6 @@ import (
"fmt" "fmt"
"os" "os"
"sync" "sync"
"github.com/jetkvm/kvm/internal/usbgadget"
) )
type WakeOnLanDevice struct { type WakeOnLanDevice struct {
@ -15,51 +13,32 @@ 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"` AutoUpdateEnabled bool `json:"auto_update_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"` IncludePreRelease bool `json:"include_pre_release"`
IncludePreRelease bool `json:"include_pre_release"` HashedPassword string `json:"hashed_password"`
HashedPassword string `json:"hashed_password"` LocalAuthToken string `json:"local_auth_token"`
LocalAuthToken string `json:"local_auth_token"` LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` EdidString string `json:"hdmi_edid_string"`
EdidString string `json:"hdmi_edid_string"` ActiveExtension string `json:"active_extension"`
ActiveExtension string `json:"active_extension"` 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 (
@ -68,9 +47,6 @@ 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
@ -93,15 +69,6 @@ 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
} }
@ -123,9 +90,3 @@ func SaveConfig() error {
return nil return nil
} }
func ensureConfigLoaded() {
if config == nil {
LoadConfig()
}
}

View File

@ -1,5 +1,3 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status # Exit immediately if a command exits with a non-zero status
set -e set -e
@ -12,18 +10,17 @@ 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:"
echo " $0 -r 192.168.0.17" echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin" echo " $0 -r 192.168.0.17 -u admin"
exit 0
} }
# 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
@ -36,10 +33,6 @@ 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
@ -59,22 +52,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
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
# Deploy and run the application on the remote host # Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
set -e set -e
# Set the library path to include the directory where librockit.so is located # Set the library path to include the directory where librockit.so is located
@ -85,13 +73,14 @@ killall jetkvm_app || true
killall jetkvm_app_debug || true killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored # Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}" cd "$REMOTE_PATH"
# Make the new binary executable # Make the new binary executable
chmod +x jetkvm_app_debug chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug ./jetkvm_app_debug
EOF EOF
echo "Deployment complete." echo "Deployment complete."

View File

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

View File

@ -2,6 +2,7 @@ package kvm
import ( import (
"context" "context"
"fmt"
"os" "os"
"sync" "sync"
"syscall" "syscall"
@ -103,7 +104,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 {
logger.Warnf("failed to mount fuse: %v", err) fmt.Println("failed to mount fuse: %w", err)
} }
fuseServer.Wait() fuseServer.Wait()
} }

28
go.mod
View File

@ -1,4 +1,4 @@
module github.com/jetkvm/kvm module kvm
go 1.21.0 go 1.21.0
@ -7,7 +7,7 @@ toolchain go1.21.1
require ( require (
github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/semver/v3 v3.3.0
github.com/beevik/ntp v1.3.1 github.com/beevik/ntp v1.3.1
github.com/coder/websocket v1.8.13 github.com/coder/websocket v1.8.12
github.com/coreos/go-oidc/v3 v3.11.0 github.com/coreos/go-oidc/v3 v3.11.0
github.com/creack/pty v1.1.23 github.com/creack/pty v1.1.23
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
@ -15,26 +15,23 @@ 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.31.0 golang.org/x/crypto v0.28.0
golang.org/x/net v0.33.0 golang.org/x/net v0.30.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
@ -46,13 +43,12 @@ 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
@ -68,16 +64,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/prometheus/client_model v0.6.1 // indirect github.com/rogpeppe/go-internal v1.8.0 // 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.24.0 // indirect golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.28.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.36.1 // indirect google.golang.org/protobuf v1.34.0 // 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
) )

68
go.sum
View File

@ -2,14 +2,10 @@ 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,12 +14,11 @@ github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI= github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
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=
@ -47,8 +42,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.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.5.9/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=
@ -60,22 +55,20 @@ 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.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
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=
@ -87,8 +80,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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
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=
@ -125,20 +118,14 @@ 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.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/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=
@ -149,9 +136,8 @@ 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=
@ -167,27 +153,29 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.21.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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
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) { //nolint:unused func readOtpEntropy() ([]byte, error) {
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

@ -1,336 +0,0 @@
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

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

View File

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

View File

@ -1,95 +0,0 @@
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

@ -1,128 +0,0 @@
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

@ -1,92 +0,0 @@
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

@ -1,23 +0,0 @@
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",
},
}

View File

@ -1,109 +0,0 @@
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

@ -1,110 +0,0 @@
// 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

@ -1,63 +0,0 @@
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,6 +6,10 @@ 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) {
@ -16,8 +20,6 @@ func rpcGetJigglerState() bool {
} }
func init() { func init() {
ensureConfigLoaded()
go runJiggler() go runJiggler()
} }

View File

@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -14,8 +15,6 @@ 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 {
@ -47,12 +46,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 {
logger.Warnf("Error marshalling JSONRPC response: %v", err) log.Println("Error marshalling JSONRPC response:", err)
return return
} }
err = session.RPCChannel.SendText(string(responseBytes)) err = session.RPCChannel.SendText(string(responseBytes))
if err != nil { if err != nil {
logger.Warnf("Error sending JSONRPC response: %v", err) log.Println("Error sending JSONRPC response:", err)
return return
} }
} }
@ -65,16 +64,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 {
logger.Warnf("Error marshalling JSONRPC event: %v", err) log.Println("Error marshalling JSONRPC event:", err)
return return
} }
if session == nil || session.RPCChannel == nil { if session == nil || session.RPCChannel == nil {
logger.Info("RPC channel not available") log.Println("RPC channel not available")
return return
} }
err = session.RPCChannel.SendText(string(requestBytes)) err = session.RPCChannel.SendText(string(requestBytes))
if err != nil { if err != nil {
logger.Warnf("Error sending JSONRPC event: %v", err) log.Println("Error sending JSONRPC event:", err)
return return
} }
} }
@ -95,7 +94,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return return
} }
//logger.Infof("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID) //log.Printf("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{
@ -148,7 +147,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
} }
func rpcSetStreamQualityFactor(factor float64) error { func rpcSetStreamQualityFactor(factor float64) error {
logger.Infof("Setting stream quality factor to: %f", factor) log.Printf("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
@ -184,10 +183,10 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error { func rpcSetEDID(edid string) error {
if edid == "" { if edid == "" {
logger.Info("Restoring EDID to default") log.Println("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b" edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else { } else {
logger.Infof("Setting EDID to: %s", edid) log.Printf("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 {
@ -196,7 +195,8 @@ 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)
} }
logger.Infof("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec) log.Printf("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec)
// If the device started up with auto-dim and/or auto-off set to zero, the display init // 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) {
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) log.Printf("[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" {
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode) log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
return "", fmt.Errorf("invalid mode: %s", mode) return "", fmt.Errorf("invalid mode: %s", mode)
} }
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode) log.Printf("[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)
} }
logger.Infof("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode) log.Printf("[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,30 +517,27 @@ 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) {
return gadget.IsUDCBound() _, 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
} }
func rpcSetUsbEmulationState(enabled bool) error { func rpcSetUsbEmulationState(enabled bool) error {
if enabled { if enabled {
return gadget.BindUDC() return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
} else { } else {
return gadget.UnbindUDC() return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
} }
} }
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
@ -563,7 +560,7 @@ func rpcResetConfig() error {
return fmt.Errorf("failed to reset config: %w", err) return fmt.Errorf("failed to reset config: %w", err)
} }
logger.Info("Configuration reset to default") log.Println("Configuration reset to default")
return nil return nil
} }
@ -579,7 +576,7 @@ func rpcGetDCPowerState() (DCPowerState, error) {
} }
func rpcSetDCPowerState(enabled bool) error { func rpcSetDCPowerState(enabled bool) error {
logger.Infof("[jsonrpc.go:rpcSetDCPowerState] Setting DC power state to: %v", enabled) log.Printf("[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)
@ -596,18 +593,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
} }
@ -728,75 +725,11 @@ 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 {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
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},
@ -804,7 +737,6 @@ 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},
@ -832,8 +764,6 @@ 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},
@ -856,10 +786,4 @@ 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"}},
} }

3
log.go
View File

@ -5,5 +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 cloudLogger = logging.NewDefaultLoggerFactory().NewLogger("cloud") var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
var websocketLogger = logging.NewDefaultLoggerFactory().NewLogger("websocket")

22
main.go
View File

@ -2,6 +2,7 @@ package kvm
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -35,8 +36,6 @@ func Main() {
StartNativeCtrlSocketServer() StartNativeCtrlSocketServer()
StartNativeVideoSocketServer() StartNativeVideoSocketServer()
initPrometheus()
go func() { go func() {
err = ExtractAndRunNativeBin() err = ExtractAndRunNativeBin()
if err != nil { if err != nil {
@ -45,13 +44,11 @@ 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 { if config.AutoUpdateEnabled == false {
return return
} }
if currentSession != nil { if currentSession != nil {
@ -69,25 +66,24 @@ func Main() {
}() }()
//go RunFuseServer() //go RunFuseServer()
go RunWebServer() go RunWebServer()
if config.TLSMode != "" { // If the cloud token isn't set, the client won't be started by default.
go RunWebSecureServer() // However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient()
} }
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort() initSerialPort()
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
logger.Info("JetKVM Shutting Down") log.Println("JetKVM Shutting Down")
//if fuseServer != nil { //if fuseServer != nil {
// err := setMassStorageImage(" ") // err := setMassStorageImage(" ")
// if err != nil { // if err != nil {
// logger.Infof("Failed to unmount mass storage image: %v", err) // log.Printf("Failed to unmount mass storage image: %v", err)
// } // }
// err = fuseServer.Unmount() // err = fuseServer.Unmount()
// if err != nil { // if err != nil {
// logger.Infof("Failed to unmount fuse: %v", err) // log.Printf("Failed to unmount fuse: %v", err)
// } // }
// os.Exit(0) // os.Exit(0)

View File

@ -5,6 +5,8 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"kvm/resource"
"log"
"net" "net"
"os" "os"
"os/exec" "os/exec"
@ -12,8 +14,6 @@ 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)
} }
logger.Infof("sending ctrl action: %s", string(jsonData)) fmt.Println("sending ctrl action", 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 //nolint:unused var nativeCtrlSocketListener net.Listener
var nativeVideoSocketListener net.Listener //nolint:unused var nativeVideoSocketListener net.Listener
var ctrlClientConnected = make(chan struct{}) var ctrlClientConnected = make(chan struct{})
@ -104,18 +104,16 @@ 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 {
logger.Errorf("Failed to remove existing socket file %s: %v", socketPath, err) log.Fatalf("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 {
logger.Errorf("Failed to start server on %s: %v", socketPath, err) log.Fatalf("Failed to start server on %s: %v", socketPath, err)
os.Exit(1)
} }
logger.Infof("Server listening on %s", socketPath) log.Printf("Server listening on %s", socketPath)
go func() { go func() {
conn, err := listener.Accept() conn, err := listener.Accept()
@ -190,23 +188,24 @@ func handleCtrlClient(conn net.Conn) {
func handleVideoClient(conn net.Conn) { func handleVideoClient(conn net.Conn) {
defer conn.Close() defer conn.Close()
logger.Infof("Native video socket client connected: %v", conn.RemoteAddr()) log.Printf("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 {
logger.Warnf("error during read: %v", err) log.Println("error during read: %s", 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 {
logger.Warnf("error writing sample: %v", err) log.Println("Error writing sample", err)
} }
} }
} }
@ -251,7 +250,7 @@ func ExtractAndRunNativeBin() error {
} }
}() }()
logger.Infof("Binary started with PID: %d", cmd.Process.Pid) fmt.Printf("Binary started with PID: %d\n", 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 {
logger.Warnf("network: setDhcpClientState: failed to change udhcpc state: %s", err) fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err)
} }
} }
func checkNetworkState() { func checkNetworkState() {
iface, err := netlink.LinkByName(NetIfName) iface, err := netlink.LinkByName(NetIfName)
if err != nil { if err != nil {
logger.Warnf("failed to get [%s] interface: %v", NetIfName, err) fmt.Printf("failed to get [%s] interface: %v\n", 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 {
logger.Warnf("failed to get addresses for [%s]: %v", NetIfName, err) fmt.Printf("failed to get addresses for [%s]: %v\n", 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.
logger.Infof("network: state transitioned to down, removing IPv4 address %s", addr.IP.String()) fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String())
err := netlink.AddrDel(iface, &addr) err := netlink.AddrDel(iface, &addr)
if err != nil { if err != nil {
logger.Warnf("network: failed to delete %s", addr.IP.String()) fmt.Printf("network: failed to delete %s", addr.IP.String())
} }
newState.IPv4 = "..." newState.IPv4 = "..."
@ -105,9 +105,9 @@ func checkNetworkState() {
} }
if newState != networkState { if newState != networkState {
logger.Info("network state changed") fmt.Println("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 {
logger.Info("Stopping mDNS server") fmt.Printf("Stopping mDNS server\n")
err := mDNSConn.Close() err := mDNSConn.Close()
if err != nil { if err != nil {
logger.Warnf("failed to stop mDNS server: %v", err) fmt.Printf("failed to stop mDNS server: %v\n", err)
} }
} }
// Start a new server // Start a new server
logger.Info("Starting mDNS server on jetkvm.local") fmt.Printf("Starting mDNS server on jetkvm.local\n")
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 {
logger.Infof("invalid NTP server IP: %s, ignoring", server) fmt.Printf("invalid NTP server IP: %s, ignoring ... \n", server)
} }
servers = append(servers, server) servers = append(servers, server)
} }
@ -190,13 +190,11 @@ 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 {
logger.Warnf("failed to subscribe to link updates: %v", err) fmt.Println("failed to subscribe to link updates: %v", err)
return return
} }
@ -210,7 +208,7 @@ func init() {
select { select {
case update := <-updates: case update := <-updates:
if update.Link.Attrs().Name == NetIfName { if update.Link.Attrs().Name == NetIfName {
logger.Infof("link update: %+v", update) fmt.Printf("link update: %+v\n", update)
checkNetworkState() checkNetworkState()
} }
case <-ticker.C: case <-ticker.C:
@ -222,6 +220,6 @@ func init() {
}() }()
err := startMDNS() err := startMDNS()
if err != nil { if err != nil {
logger.Warnf("failed to run mDNS: %v", err) fmt.Println("failed to run mDNS: %v", err)
} }
} }

52
ntp.go
View File

@ -3,9 +3,9 @@ package kvm
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os/exec" "os/exec"
"strconv"
"time" "time"
"github.com/beevik/ntp" "github.com/beevik/ntp"
@ -21,41 +21,14 @@ const (
) )
var ( var (
builtTimestamp string timeSynced = false
timeSyncRetryInterval = 0 * time.Second timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{ defaultNTPServers = []string{
"time.cloudflare.com", "time.cloudflare.com",
"time.apple.com", "time.apple.com",
} }
) )
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
logger.Warnf("Built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
logger.Warnf("Failed to parse built timestamp: %v", err)
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
logger.Tracef("Built time: %v, now: %v", builtTime, now)
if now.Sub(builtTime) < 0 {
logger.Warnf("System time is behind the built time, time sync is needed")
return true
}
return false
}
func TimeSyncLoop() { func TimeSyncLoop() {
for { for {
if !networkState.checked { if !networkState.checked {
@ -64,19 +37,16 @@ func TimeSyncLoop() {
} }
if !networkState.Up { if !networkState.Up {
logger.Infof("Waiting for network to come up") log.Printf("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt) time.Sleep(timeSyncWaitNetUpInt)
continue continue
} }
// check if time sync is needed, but do nothing for now log.Printf("Syncing system time")
isTimeSyncNeeded()
logger.Infof("Syncing system time")
start := time.Now() start := time.Now()
err := SyncSystemTime() err := SyncSystemTime()
if err != nil { if err != nil {
logger.Warnf("Failed to sync system time: %v", err) log.Printf("Failed to sync system time: %v", err)
// retry after a delay // retry after a delay
timeSyncRetryInterval += timeSyncRetryStep timeSyncRetryInterval += timeSyncRetryStep
@ -88,8 +58,8 @@ func TimeSyncLoop() {
continue continue
} }
timeSyncSuccess = true 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
} }
} }
@ -109,20 +79,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 {
logger.Warnf("failed to get NTP servers from DHCP info: %v\n", err) log.Printf("failed to get NTP servers from DHCP info: %v\n", err)
} }
if ntpServers == nil { if ntpServers == nil {
ntpServers = defaultNTPServers ntpServers = defaultNTPServers
logger.Infof("Using default NTP servers: %v\n", ntpServers) log.Printf("Using default NTP servers: %v\n", ntpServers)
} else { } else {
logger.Infof("Using NTP servers from DHCP: %v\n", ntpServers) log.Printf("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 {
logger.Infof("NTP server [%s] returned time: %v\n", server, now) log.Printf("NTP server [%s] returned time: %v\n", server, now)
return now, nil return now, nil
} }
} }

33
ota.go
View File

@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
@ -76,7 +77,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()
logger.Infof("Checking for updates at: %s", updateUrl) fmt.Println("Checking for updates at:", updateUrl.String())
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil) req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil { if err != nil {
@ -126,13 +127,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err) return fmt.Errorf("error creating request: %w", err)
} }
// TODO: set a separate timeout for the download but keep the TLS handshake short resp, err := http.DefaultClient.Do(req)
// use Transport here will cause CA certificate validation failure so we temporarily removed it
client := http.Client{
Timeout: 10 * time.Minute,
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return fmt.Errorf("error downloading file: %w", err) return fmt.Errorf("error downloading file: %w", err)
} }
@ -235,7 +230,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
} }
hashSum := hash.Sum(nil) hashSum := hash.Sum(nil)
logger.Infof("SHA256 hash of %s: %x", path, hashSum) fmt.Printf("SHA256 hash of %s: %x\n", 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)
@ -277,7 +272,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() { func triggerOTAStateUpdate() {
go func() { go func() {
if currentSession == nil { if currentSession == nil {
logger.Info("No active RPC session, skipping update state update") log.Println("No active RPC session, skipping update state update")
return return
} }
writeJSONRPCEvent("otaState", otaState, currentSession) writeJSONRPCEvent("otaState", otaState, currentSession)
@ -285,7 +280,7 @@ func triggerOTAStateUpdate() {
} }
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error { func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
logger.Info("Trying to update...") log.Println("Trying to update...")
if otaState.Updating { if otaState.Updating {
return fmt.Errorf("update already in progress") return fmt.Errorf("update already in progress")
} }
@ -320,7 +315,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
rebootNeeded := false rebootNeeded := false
if appUpdateAvailable { if appUpdateAvailable {
logger.Infof("App update available: %s -> %s", local.AppVersion, remote.AppVersion) fmt.Printf("App update available: %s -> %s\n", 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 {
@ -346,14 +341,14 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1 otaState.AppUpdateProgress = 1
triggerOTAStateUpdate() triggerOTAStateUpdate()
logger.Info("App update downloaded") fmt.Println("App update downloaded")
rebootNeeded = true rebootNeeded = true
} else { } else {
logger.Info("App is up to date") fmt.Println("App is up to date")
} }
if systemUpdateAvailable { if systemUpdateAvailable {
logger.Infof("System update available: %s -> %s", local.SystemVersion, remote.SystemVersion) fmt.Printf("System update available: %s -> %s\n", 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)
@ -371,7 +366,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate() triggerOTAStateUpdate()
return err return err
} }
logger.Info("System update downloaded") fmt.Println("System update downloaded")
verifyFinished := time.Now() verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1 otaState.SystemVerificationProgress = 1
@ -418,17 +413,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)
} }
logger.Infof("rk_ota success, output: %s", output) fmt.Printf("rk_ota success, output: %s\n", output)
otaState.SystemUpdateProgress = 1 otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate() triggerOTAStateUpdate()
rebootNeeded = true rebootNeeded = true
} else { } else {
logger.Info("System is up to date") fmt.Println("System is up to date")
} }
if rebootNeeded { if rebootNeeded {
logger.Info("System Rebooting in 10s") fmt.Println("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()

View File

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

View File

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/bin/bash
# Check if a commit message was provided # Check if a commit message was provided
if [ -z "$1" ]; then if [ -z "$1" ]; then
@ -26,7 +26,7 @@ git checkout -b release-temp
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main git reset --soft public/main
else else
git reset --soft "$(git rev-list --max-parents=0 HEAD)" git reset --soft $(git rev-list --max-parents=0 HEAD)
fi fi
# Merge changes from main # Merge changes from main

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
} }
var buf []byte buf := make([]byte, 0)
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
} }
@ -66,6 +66,7 @@ func runATXControl() {
newLedPWRState != ledPWRState || newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState || newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState { newBtnPWRState != btnPWRState {
logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v", logger.Debugf("Status changed: HDD LED: %v, PWR LED: %v, RST BTN: %v, PWR BTN: %v",
newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState) newLedHDDState, newLedPWRState, newBtnRSTState, newBtnPWRState)
@ -121,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
} }
@ -211,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,13 +55,11 @@ 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 {
err = pty.Setsize(ptmx, &pty.Winsize{ 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)
} }
@ -76,7 +74,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

@ -1,4 +0,0 @@
# 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

View File

@ -1,4 +0,0 @@
# 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

View File

@ -1,4 +0,0 @@
# 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

6
ui/.env.development Normal file
View File

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

6
ui/.env.device Normal file
View File

@ -0,0 +1,6 @@
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>

6
ui/.env.production Normal file
View File

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

4
ui/.env.staging Normal file
View File

@ -0,0 +1,4 @@
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

@ -8,8 +8,6 @@ module.exports = {
"plugin:react-hooks/recommended", "plugin:react-hooks/recommended",
"plugin:react/recommended", "plugin:react/recommended",
"plugin:react/jsx-runtime", "plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
], ],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"], ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser", parser: "@typescript-eslint/parser",
@ -22,45 +20,5 @@ module.exports = {
}, },
rules: { rules: {
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }], "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"import/order": [
"error",
{
/**
* @description
*
* This keeps imports separate from one another, ensuring that imports are separated
* by their relative groups. As you move through the groups, imports become closer
* to the current file.
*
* @example
* ```
* import fs from 'fs';
*
* import package from 'npm-package';
*
* import xyz from '~/project-file';
*
* import index from '../';
*
* import sibling from './foo';
* ```
*/
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
},
],
},
settings: {
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
}, },
}; };

View File

@ -5,7 +5,11 @@
"useTabs": false, "useTabs": false,
"arrowParens": "avoid", "arrowParens": "avoid",
"singleQuote": false, "singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"], "plugins": [
"tailwindFunctions": ["clsx"], "prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90 "printWidth": 90
} }

View File

@ -1,19 +1,21 @@
#!/usr/bin/env 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="ws://$ip_address" npx vite dev --mode=device JETKVM_PROXY_URL="http://$ip_address" vite dev --mode=device

View File

@ -28,6 +28,7 @@
<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(

2075
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -8,13 +8,12 @@
}, },
"scripts": { "scripts": {
"dev": "./dev_device.sh", "dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=cloud-development", "dev:cloud": "vite dev --mode=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=cloud-staging", "build:staging": "tsc && vite build --mode=staging",
"build:prod": "tsc && vite build --mode=cloud-production", "build:prod": "tsc && vite build --mode=production",
"lint": "eslint './src/**/*.{ts,tsx}'", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@ -28,7 +27,6 @@
"@xterm/addon-webgl": "^0.18.0", "@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1", "cva": "^1.0.0-beta.1",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0", "framer-motion": "^11.15.0",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
@ -40,7 +38,6 @@
"react-icons": "^5.4.0", "react-icons": "^5.4.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-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.9", "react-xtermjs": "^1.0.9",
"recharts": "^2.15.0", "recharts": "^2.15.0",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@ -54,24 +51,21 @@
"@tailwindcss/typography": "^0.5.15", "@tailwindcss/typography": "^0.5.15",
"@types/react": "^18.2.66", "@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0", "@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@types/validator": "^13.12.2", "@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^8.25.0", "@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.7.2", "@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"eslint": "^8.20.0", "eslint": "^8.57.0",
"eslint-config-prettier": "^10.0.1", "eslint-plugin-react": "^7.34.1",
"eslint-plugin-import": "^2.31.0", "eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react": "^7.37.4", "eslint-plugin-react-refresh": "^0.4.6",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^5.2.0", "vite": "^5.2.0",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^4.3.2"
} }
} }

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -1,10 +1,3 @@
import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { import {
useHidStore, useHidStore,
@ -12,20 +5,24 @@ import {
useSettingsStore, useSettingsStore,
useUiStore, useUiStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container"; import Container from "@components/Container";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal"; import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index"; import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover"; import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import ExtensionPopover from "@/components/popovers/ExtensionPopover"; import MountPopopover from "./popovers/MountPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import ExtensionPopover from "./popovers/ExtensionPopover";
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);
@ -152,7 +149,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);
}} }}
@ -263,16 +260,15 @@ export default function Actionbar({
/> />
</div> </div>
<div> <div className="hidden xs:block ">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Settings" text="Settings"
LeadingIcon={LuSettings} LeadingIcon={LuSettings}
onClick={() => navigateTo("/settings")} onClick={() => toggleSidebarView("system")}
/> />
</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

@ -1,22 +1,21 @@
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import { GoogleIcon } from "@components/Icons"; import { GoogleIcon } from "@components/Icons";
import SimpleNavbar from "@components/SimpleNavbar"; import SimpleNavbar from "@components/SimpleNavbar";
import Container from "@components/Container"; import Container from "@components/Container";
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset"; import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground"; import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter"; import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config"; import { CLOUD_API } from "@/ui.config";
interface AuthLayoutProps { type AuthLayoutProps = {
title: string; title: string;
description: string; description: string;
action: string; action: string;
cta: string; cta: string;
ctaHref: string; ctaHref: string;
showCounter?: boolean; showCounter?: boolean;
} };
export default function AuthLayout({ export default function AuthLayout({
title, title,
@ -47,8 +46,8 @@ export default function AuthLayout({
} }
/> />
<Container> <Container>
<div className="isolate flex h-full w-full items-center justify-center"> <div className="flex items-center justify-center w-full h-full isolate">
<div className="-mt-16 max-w-2xl space-y-8"> <div className="max-w-2xl -mt-16 space-y-8">
{showCounter ? ( {showCounter ? (
<div className="text-center"> <div className="text-center">
<StepCounter currStepIdx={0} nSteps={2} /> <StepCounter currStepIdx={0} nSteps={2} />
@ -62,8 +61,11 @@ export default function AuthLayout({
</div> </div>
<Fieldset className="space-y-12"> <Fieldset className="space-y-12">
<div className="mx-auto max-w-sm space-y-4"> <div className="max-w-sm mx-auto space-y-4">
<form action={`${CLOUD_API}/oidc/google`} method="POST"> <form
action={`${CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/} {/*This could be the KVM ID*/}
{deviceId ? ( {deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} /> <input type="hidden" name="deviceId" value={deviceId} />

View File

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

View File

@ -1,9 +1,8 @@
import React from "react"; import React from "react";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
import ExtLink from "@/components/ExtLink"; import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner"; import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = { const sizes = {
XS: "h-[28px] px-2 text-xs", XS: "h-[28px] px-2 text-xs",
@ -102,7 +101,7 @@ const iconVariants = cva({
}, },
}); });
interface ButtonContentPropsType { type ButtonContentPropsType = {
text?: string | React.ReactNode; text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null; LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null; TrailingIcon?: React.FC<{ className: string | undefined }> | null;
@ -112,7 +111,7 @@ interface ButtonContentPropsType {
size: keyof typeof sizes; size: keyof typeof sizes;
theme: keyof typeof themes; theme: keyof typeof themes;
loading?: boolean; loading?: boolean;
} };
function ButtonContent(props: ButtonContentPropsType) { function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } = const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =

View File

@ -1,11 +1,10 @@
import React, { forwardRef } from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
interface CardPropsType { type CardPropsType = {
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} };
export const GridCard = ({ export const GridCard = ({
children, children,
@ -17,28 +16,23 @@ 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 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 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 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="isolate h-full">{children}</div> <div className="h-full isolate">{children}</div>
</div> </div>
</Card> </Card>
); );
}; };
const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => { export default function Card({ children, className }: CardPropsType) {
return ( return (
<div <div
ref={ref}
className={cx( className={cx(
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20", "w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
className, className,
)} )}
> >
{children} {children}
</div> </div>
); );
}); }
Card.displayName = "Card";
export default Card;

View File

@ -1,10 +1,10 @@
import React from "react"; import React from "react";
interface Props { type Props = {
headline: string; headline: string;
description?: string | React.ReactNode; description?: string | React.ReactNode;
Button?: React.ReactNode; Button?: React.ReactNode;
} };
export const CardHeader = ({ headline, description, Button }: Props) => { export const CardHeader = ({ headline, description, Button }: Props) => {
return ( return (

View File

@ -1,8 +1,7 @@
import type { Ref } from "react"; import type { Ref } from "react";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
const sizes = { const sizes = {
@ -53,7 +52,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>( const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel( function CheckboxWithLabel(
{ label, id, description, fullWidth, readOnly, ...props }, { label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>, ref: Ref<HTMLInputElement>,
) { ) {
return ( return (

View File

@ -1,6 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
function Container({ children, className }: { children: ReactNode; className?: string }) { function Container({ children, className }: { children: ReactNode; className?: string }) {

View File

@ -1,8 +1,8 @@
import Card from "@components/Card"; import Card from "@components/Card";
export interface CustomTooltipProps { export type CustomTooltipProps = {
payload: { payload: { date: number; stat: number }; unit: string }[]; payload: { payload: { date: number; stat: number }; unit: string }[];
} };
export default function CustomTooltip({ payload }: CustomTooltipProps) { export default function CustomTooltip({ payload }: CustomTooltipProps) {
if (payload?.length) { if (payload?.length) {

View File

@ -1,16 +1,14 @@
import React from "react";
import { GridCard } from "@/components/Card"; import { GridCard } from "@/components/Card";
import React from "react";
import { cx } from "../cva.config"; import { cx } from "../cva.config";
interface Props { type Props = {
IconElm?: React.FC<{ className: string | undefined }>; IconElm?: React.FC<any>;
headline: string; headline: string;
description?: string | React.ReactNode; description?: string | React.ReactNode;
BtnElm?: React.ReactNode; BtnElm?: React.ReactNode;
className?: string; className?: string;
} };
export default function EmptyCard({ export default function EmptyCard({
IconElm, IconElm,
@ -29,16 +27,10 @@ export default function EmptyCard({
> >
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]"> <div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2"> <div className="space-y-2">
{IconElm && ( {IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
<IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" /> <h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
)}
<h4 className="text-base font-bold leading-none text-black dark:text-white">
{headline}
</h4>
</div> </div>
<p className="mx-auto text-sm text-slate-600 dark:text-slate-400"> <p className="mx-auto text-sm text-slate-600 dark:text-slate-400">{description}</p>
{description}
</p>
</div> </div>
{BtnElm} {BtnElm}
</div> </div>

View File

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
export default function ExtLink({ export default function ExtLink({

View File

@ -1,28 +0,0 @@
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

@ -1,14 +1,13 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
interface Props { type Props = {
label: string | React.ReactNode; label: string | React.ReactNode;
id?: string; id?: string;
as?: "label" | "span"; as?: "label" | "span";
description?: string | React.ReactNode | null; description?: string | React.ReactNode | null;
disabled?: boolean; disabled?: boolean;
} };
export default function FieldLabel({ export default function FieldLabel({
label, label,
id, id,
@ -27,7 +26,7 @@ export default function FieldLabel({
> >
{label} {label}
{description && ( {description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400"> <span className="my-0.5 text-[13px] font-normal text-slate-600">
{description} {description}
</span> </span>
)} )}
@ -35,12 +34,12 @@ export default function FieldLabel({
); );
} else if (as === "span") { } else if (as === "span") {
return ( return (
<div className="flex select-none flex-col"> <div className="flex flex-col select-none">
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white"> <span className="font-display text-[13px] font-medium leading-snug text-black">
{label} {label}
</span> </span>
{description && ( {description && (
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400"> <span className="my-0.5 text-[13px] font-normal text-slate-600">
{description} {description}
</span> </span>
)} )}

View File

@ -9,7 +9,7 @@ export default function Fieldset({
disabled, disabled,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
fetcher?: FetcherWithComponents<unknown>; fetcher?: FetcherWithComponents<any>;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
}) { }) {

View File

@ -1,23 +1,20 @@
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 } from "@headlessui/react"; import { Menu, MenuButton, Transition } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
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 { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
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 USBStateStatus from "@components/USBStateStatus"; import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard"; import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
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";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -36,26 +33,28 @@ export default function DashboardNavbar({
picture, picture,
kvmName, kvmName,
}: NavbarProps) { }: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnectionState); const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
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 ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`; const logoutUrl = isOnDevice
? `${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 appropriate login page, be it the local one or the cloud one // The root route will redirect to appropiate 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 select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900"> <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">
<Container> <Container>
<div className="flex h-14 items-center justify-between"> <div className="flex items-center justify-between h-14">
<div className="flex shrink-0 items-center gap-x-8"> <div className="flex items-center shrink-0 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" />
@ -76,10 +75,10 @@ export default function DashboardNavbar({
})} })}
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-end gap-x-2"> <div className="flex items-center justify-end w-full gap-x-2">
<div className="flex shrink-0 items-center space-x-4"> <div className="flex items-center space-x-4 shrink-0">
{showConnectionStatus && ( {showConnectionStatus && (
<div className="hidden items-center gap-x-2 md:flex"> <div className="items-center hidden gap-x-2 md:flex">
<div className="w-[159px]"> <div className="w-[159px]">
<PeerConnectionStatusCard <PeerConnectionStatusCard
state={peerConnectionState} state={peerConnectionState}
@ -89,7 +88,7 @@ export default function DashboardNavbar({
<div className="hidden w-[159px] md:block"> <div className="hidden w-[159px] md:block">
<USBStateStatus <USBStateStatus
state={usbState} state={usbState}
peerConnectionState={peerConnectionState} peerConnectionState={peerConnectionState}
/> />
</div> </div>
</div> </div>
@ -106,55 +105,66 @@ export default function DashboardNavbar({
text={ text={
<> <>
{picture ? <></> : userEmail} {picture ? <></> : userEmail}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" /> <ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
</> </>
} }
LeadingIcon={({ className }) => LeadingIcon={({ className }) => (
picture && ( picture && (
<img <img
src={picture} src={picture}
alt="Avatar" alt="Avatar"
className={cx( className={cx(
className, className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700", "h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)} )}
/> />
) )
} )}
/> />
</MenuButton> </MenuButton>
</div> </div>
<Transition
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none"> as={Fragment}
<Card className="overflow-hidden"> enter="transition ease-in-out duration-75"
<div className="space-y-1 p-1 dark:text-white"> enterFrom="transform opacity-0"
{userEmail && ( enterTo="transform opacity-100"
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20"> leave="transition ease-in-out duration-75"
<Menu.Item> leaveFrom="transform opacity-75"
<div className="p-2"> leaveTo="transform opacity-0"
<div className="font-display text-xs">Logged in as</div> >
<div className="w-[200px] truncate font-display text-sm font-semibold"> <Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
{userEmail} <Card className="overflow-hidden">
<div className="p-1 space-y-1 dark:text-white">
{userEmail && (
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2">
<div className="text-xs font-display">
Logged in as
</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
</div>
</div> </div>
</Menu.Item>
</div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<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">
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div> </div>
</Menu.Item> </Menu.Item>
</div> </div>
)}
<div>
<Menu.Item>
<div onClick={onLogout}>
<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-100 dark:hover:bg-slate-700">
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
<div className="font-display">Log out</div>
</div>
</button>
</div>
</Menu.Item>
</div> </div>
</div> </Card>
</Card> </Menu.Items>
</Menu.Items> </Transition>
</Menu> </Menu>
</> </>
) : null} ) : null}

View File

@ -1,5 +1,3 @@
import { useEffect } from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { import {
useHidStore, useHidStore,
@ -8,6 +6,7 @@ import {
useSettingsStore, useSettingsStore,
useVideoStore, useVideoStore,
} from "@/hooks/stores"; } from "@/hooks/stores";
import { useEffect } from "react";
import { keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
export default function InfoBar() { export default function InfoBar() {
@ -15,7 +14,6 @@ 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)}`,
@ -64,7 +62,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? ( {settings.debugMode ? (
<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">
@ -73,17 +71,6 @@ 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

@ -1,8 +1,7 @@
import type { Ref } from "react"; import type { Ref } from "react";
import React, { forwardRef } from "react"; import React, { forwardRef } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
@ -85,7 +84,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
{(label || description) && ( {(label || description) && (
<FieldLabel label={label} id={id} description={description} /> <FieldLabel label={label} id={id} description={description} />
)} )}
<InputField ref={ref as never} id={id} {...props} /> <InputField ref={ref as any} id={id} {...props} />
</div> </div>
); );
}, },

View File

@ -1,11 +1,10 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md"; import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LuEllipsisVertical } from "react-icons/lu"; import { LuEllipsisVertical } from "react-icons/lu";
import Card from "@components/Card";
import { Button, LinkButton } from "@components/Button";
function getRelativeTimeString(date: Date | number, lang = navigator.language): string { function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
// Allow dates or times to be passed // Allow dates or times to be passed
const timeMs = typeof date === "number" ? date : date.getTime(); const timeMs = typeof date === "number" ? date : date.getTime();
@ -13,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 representing one minute, hour, day, week, month, etc in seconds // Array reprsenting 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
@ -53,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-center"> <div className="flex justify-between items-cente">
<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

@ -1,36 +1,30 @@
import { useState, useEffect } from "react"; import { GridCard } from "@/components/Card";
import { useLocation, useRevalidator } from "react-router-dom"; import { useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { InputFieldWithLabel } from "@/components/InputField"; import LogoBlueIcon from "@/assets/logo-blue.svg";
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 { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SecurityAccessLocalAuthRoute() { export default function LocalAuthPasswordDialog({
const { setModalView } = useLocalAuthModalStore(); open,
const { navigateTo } = useDeviceUiNavigation(); setOpen,
const location = useLocation(); }: {
const init = location.state?.init; open: boolean;
setOpen: (open: boolean) => void;
useEffect(() => { }) {
if (!init) { return (
navigateTo(".."); <Modal open={open} onClose={() => setOpen(false)}>
} else { <Dialog setOpen={setOpen} />
setModalView(init); </Modal>
} );
}, [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({ onClose }: { onClose: () => void }) { export function Dialog({ setOpen }: { setOpen: (open: boolean) => 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 === "") {
@ -47,14 +41,11 @@ export function Dialog({ onClose }: { onClose: () => 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");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while setting the password"); setError("An error occurred while setting the password");
} }
}; };
@ -87,14 +78,11 @@ export function Dialog({ onClose }: { onClose: () => 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");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while changing the password"); setError("An error occurred while changing the password");
} }
}; };
@ -109,25 +97,22 @@ export function Dialog({ onClose }: { onClose: () => 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");
} }
} catch (error) { } catch (error) {
console.error(error);
setError("An error occurred while disabling the password"); setError("An error occurred while disabling the password");
} }
}; };
return ( return (
<div> <GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div> <div className="p-10">
{modalView === "createPassword" && ( {modalView === "createPassword" && (
<CreatePasswordModal <CreatePasswordModal
onSetPassword={handleCreatePassword} onSetPassword={handleCreatePassword}
onCancel={onClose} onCancel={() => setOpen(false)}
error={error} error={error}
/> />
)} )}
@ -135,7 +120,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "deletePassword" && ( {modalView === "deletePassword" && (
<DeletePasswordModal <DeletePasswordModal
onDeletePassword={handleDeletePassword} onDeletePassword={handleDeletePassword}
onCancel={onClose} onCancel={() => setOpen(false)}
error={error} error={error}
/> />
)} )}
@ -143,7 +128,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "updatePassword" && ( {modalView === "updatePassword" && (
<UpdatePasswordModal <UpdatePasswordModal
onUpdatePassword={handleUpdatePassword} onUpdatePassword={handleUpdatePassword}
onCancel={onClose} onCancel={() => setOpen(false)}
error={error} error={error}
/> />
)} )}
@ -152,7 +137,7 @@ export function Dialog({ onClose }: { onClose: () => 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={onClose} onClose={() => setOpen(false)}
/> />
)} )}
@ -160,7 +145,7 @@ export function Dialog({ onClose }: { onClose: () => 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={onClose} onClose={() => setOpen(false)}
/> />
)} )}
@ -168,11 +153,11 @@ export function Dialog({ onClose }: { onClose: () => 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={onClose} onClose={() => setOpen(false)}
/> />
)} )}
</div> </div>
</div> </GridCard>
); );
} }
@ -190,16 +175,13 @@ 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 <div>
className="space-y-4" <img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
onSubmit={e => { <img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
e.preventDefault(); </div>
}} <div className="space-y-4">
>
<div> <div>
<h2 className="text-lg font-semibold dark:text-white"> <h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
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>
@ -209,7 +191,6 @@ 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
@ -230,7 +211,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>}
</form> </div>
</div> </div>
); );
} }
@ -248,11 +229,13 @@ 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"> <h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
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>
@ -298,16 +281,13 @@ 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 <div>
className="space-y-4" <img src={LogoWhiteIcon} alt="" className="h-[24px] hidden dark:block" />
onSubmit={e => { <img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
e.preventDefault(); </div>
}} <div className="space-y-4">
>
<div> <div>
<h2 className="text-lg font-semibold dark:text-white"> <h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
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.
@ -344,7 +324,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>}
</form> </div>
</div> </div>
); );
} }
@ -359,7 +339,11 @@ function SuccessModal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex w-full max-w-lg flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start w-full max-w-lg 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

@ -1,9 +1,8 @@
import React from "react"; 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";
const Modal = React.memo(function Modal({ export default function Modal({
children, children,
className, className,
open, open,
@ -15,28 +14,25 @@ const Modal = React.memo(function Modal({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<Dialog open={open} onClose={onClose} className="relative z-20"> <Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop <DialogBackdrop
transition transition
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" 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"
/> />
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
{/* TODO: This doesn't work well with other-sessions */} <div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4"> <div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel <DialogPanel
transition transition
className={cx( className={cx(
"pointer-events-none relative w-full md:my-8 md:!mt-[10vh]", "pointer-events-none relative w-full sm:my-8",
"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", "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",
className, className,
)} )}
> >
<div className="pointer-events-auto inline-block w-full text-left"> <div className="inline-block w-full text-left pointer-events-auto">
<div className="flex justify-center" onClick={onClose}> <div className="flex justify-center" onClick={onClose}>
<div <div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
className="pointer-events-none w-full"
onClick={e => e.stopPropagation()}
>
{children} {children}
</div> </div>
</div> </div>
@ -46,6 +42,4 @@ const Modal = React.memo(function Modal({
</div> </div>
</Dialog> </Dialog>
); );
}); }
export default Modal;

View File

@ -1,4 +1,16 @@
import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
import { cx } from "../cva.config";
import { import {
LuGlobe, LuGlobe,
LuLink, LuLink,
@ -7,56 +19,48 @@ import {
LuCheck, LuCheck,
LuUpload, LuUpload,
} from "react-icons/lu"; } from "react-icons/lu";
import { PlusCircleIcon , ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useNavigate } from "react-router-dom";
import Card, { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { formatters } from "@/utils"; import { formatters } from "@/utils";
import AutoHeight from "@components/AutoHeight"; import { PlusCircleIcon } from "@heroicons/react/20/solid";
import { InputFieldWithLabel } from "@/components/InputField"; import AutoHeight from "./AutoHeight";
import { InputFieldWithLabel } from "./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";
import OpenSUSEIcon from "@/assets/opensuse-icon.png"; import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png"; import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg"; import NetBootIcon from "@/assets/netboot-icon.svg";
import Fieldset from "@/components/Fieldset"; import { TrashIcon } from "@heroicons/react/16/solid";
import { DEVICE_API } from "@/ui.config";
import { useJsonRpc } from "../hooks/useJsonRpc"; import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications"; import notifications from "../notifications";
import Fieldset from "./Fieldset";
import { isOnDevice } from "../main"; import { isOnDevice } from "../main";
import { cx } from "../cva.config"; import { SIGNAL_API } from "@/ui.config";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
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({ onClose }: { onClose: () => void }) { export function Dialog({ setOpen }: { setOpen: (open: boolean) => 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);
@ -95,13 +99,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState(); clearMountMediaState();
syncRemoteVirtualMediaState() syncRemoteVirtualMediaState()
.then(() => navigate("..")) .then(() => {
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);
}); });
} }
@ -115,13 +123,13 @@ export function Dialog({ onClose }: { onClose: () => 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(() => {
// We do this because the mounting is too fast and the UI gets choppy // We do this beacues 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);
@ -148,7 +156,7 @@ export function Dialog({ onClose }: { onClose: () => 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);
navigate(".."); setIsMountMediaDialogOpen(false);
}) })
.catch(err => { .catch(err => {
triggerError(err instanceof Error ? err.message : String(err)); triggerError(err instanceof Error ? err.message : String(err));
@ -180,16 +188,16 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<img <img
src={LogoBlueIcon} src={LogoBlueIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="block h-[24px] dark:hidden" className="h-[24px] dark:hidden block"
/> />
<img <img
src={LogoWhiteIcon} src={LogoWhiteIcon}
alt="JetKVM Logo" alt="JetKVM Logo"
className="hidden h-[24px] dark:!mt-0 dark:block" className="h-[24px] dark:block hidden dark:!mt-0"
/> />
{modalView === "mode" && ( {modalView === "mode" && (
<ModeSelectionView <ModeSelectionView
onClose={() => onClose()} onClose={() => setOpen(false)}
selectedMode={selectedMode} selectedMode={selectedMode}
setSelectedMode={setSelectedMode} setSelectedMode={setSelectedMode}
/> />
@ -253,7 +261,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<ErrorView <ErrorView
errorMessage={errorMessage} errorMessage={errorMessage}
onClose={() => { onClose={() => {
onClose(); setOpen(false);
setErrorMessage(null); setErrorMessage(null);
}} }}
onRetry={() => { onRetry={() => {
@ -283,7 +291,7 @@ function ModeSelectionView({
return ( return (
<div className="w-full space-y-4"> <div className="w-full space-y-4">
<div className="animate-fadeIn space-y-0"> <div className="space-y-0 asnimate-fadeIn">
<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>
@ -337,7 +345,7 @@ function ModeSelectionView({
)} )}
> >
<div <div
className="relative z-50 flex select-none flex-col items-start p-4" className="relative z-50 flex flex-col items-start p-4 select-none"
onClick={() => onClick={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device") disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
} }
@ -345,7 +353,7 @@ function ModeSelectionView({
<div> <div>
<Card> <Card>
<div className="p-1"> <div className="p-1">
<Icon className="h-4 w-4 shrink-0 text-blue-700 dark:text-blue-400" /> <Icon className="w-4 h-4 text-blue-700 shrink-0 dark:text-blue-400" />
</div> </div>
</Card> </Card>
</div> </div>
@ -365,7 +373,7 @@ function ModeSelectionView({
value={mode} value={mode}
disabled={disabled} disabled={disabled}
checked={selectedMode === mode} checked={selectedMode === mode}
className="absolute right-4 top-4 h-4 w-4 text-blue-700" className="absolute w-4 h-4 text-blue-700 right-4 top-4"
/> />
</div> </div>
</Card> </Card>
@ -373,13 +381,13 @@ function ModeSelectionView({
))} ))}
</div> </div>
<div <div
className="flex animate-fadeIn justify-end opacity-0" className="flex justify-end opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
}} }}
> >
<div className="flex gap-x-2 pt-2"> <div className="flex pt-2 gap-x-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"
@ -437,18 +445,18 @@ function BrowserFileView({
className="block cursor-pointer select-none" className="block cursor-pointer select-none"
> >
<div <div
className="group animate-fadeIn opacity-0" className="opacity-0 group animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
> >
<Card className="outline-dashed transition-all duration-300 hover:bg-blue-50/50"> <Card className="transition-all duration-300 outline-dashed hover:bg-blue-50/50">
<div className="w-full px-4 py-12"> <div className="w-full px-4 py-12">
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex flex-col items-center justify-center h-full text-center">
{selectedFile ? ( {selectedFile ? (
<> <>
<div className="space-y-1"> <div className="space-y-1">
<LuHardDrive className="mx-auto h-6 w-6 text-blue-700" /> <LuHardDrive className="w-6 h-6 mx-auto 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>
@ -459,7 +467,7 @@ function BrowserFileView({
</> </>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-1">
<PlusCircleIcon className="mx-auto h-6 w-6 text-blue-700" /> <PlusCircleIcon className="w-6 h-6 mx-auto 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>
@ -483,7 +491,7 @@ function BrowserFileView({
</div> </div>
<div <div
className="flex w-full animate-fadeIn items-end justify-between opacity-0" className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -523,7 +531,7 @@ function UrlView({
const popularImages = [ const popularImages = [
{ {
name: "Ubuntu 24.04 LTS", name: "Ubuntu 24.04 LTS",
url: "https://releases.ubuntu.com/24.04.2/ubuntu-24.04.2-desktop-amd64.iso", url: "https://releases.ubuntu.com/noble/ubuntu-24.04.1-desktop-amd64.iso",
icon: UbuntuIcon, icon: UbuntuIcon,
}, },
{ {
@ -578,7 +586,7 @@ function UrlView({
/> />
<div <div
className="animate-fadeIn opacity-0" className="opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -593,7 +601,7 @@ function UrlView({
/> />
</div> </div>
<div <div
className="flex w-full animate-fadeIn items-end justify-between opacity-0" className="flex items-end justify-between w-full opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -619,7 +627,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="animate-fadeIn opacity-0" className="opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -628,7 +636,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="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20"> <Card className="w-full divide-y divide-y-slate-800/30 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">
@ -797,7 +805,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 animate-fadeIn opacity-0" className="w-full opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -808,7 +816,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="mx-auto h-6 w-6 text-blue-700 dark:text-blue-500" /> <PlusCircleIcon className="w-6 h-6 mx-auto 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>
@ -827,7 +835,7 @@ function DeviceFileView({
</div> </div>
</div> </div>
) : ( ) : (
<div className="divide-y-slate-800/30 w-full divide-y dark:divide-slate-300/20"> <div className="w-full divide-y divide-y-slate-800/30 dark:divide-slate-300/20">
{currentFiles.map((file, index) => ( {currentFiles.map((file, index) => (
<PreUploadedImageItem <PreUploadedImageItem
key={index} key={index}
@ -839,13 +847,7 @@ 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 ( handleDeleteFile(selectedFile);
window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?",
)
) {
handleDeleteFile(selectedFile);
}
}} }}
onSelect={() => handleOnSelectFile(file)} onSelect={() => handleOnSelectFile(file)}
onContinueUpload={() => onNewImageClick(file.name)} onContinueUpload={() => onNewImageClick(file.name)}
@ -886,7 +888,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? ( {onStorageFiles.length > 0 ? (
<div <div
className="flex animate-fadeIn items-end justify-between opacity-0" className="flex items-end justify-between opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -914,7 +916,7 @@ function DeviceFileView({
</div> </div>
) : ( ) : (
<div <div
className="flex animate-fadeIn items-end justify-end opacity-0" className="flex items-end justify-end opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.15s", animationDelay: "0.15s",
@ -927,39 +929,31 @@ 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="animate-fadeIn space-y-2 opacity-0" className="space-y-2 opacity-0 animate-fadeIn"
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"> <span className="font-medium text-black dark:text-white">Available Storage</span>
Available Storage <span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span>
</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 rounded-sm bg-blue-700 transition-all duration-300 ease-in-out dark:bg-blue-500" className="h-full transition-all duration-300 ease-in-out bg-blue-700 rounded-sm 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"> <span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span>
{formatters.bytes(bytesUsed)} used <span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span>
</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 animate-fadeIn opacity-0" className="w-full opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.25s", animationDelay: "0.25s",
@ -1126,7 +1120,7 @@ function UploadFileView({
alreadyUploadedBytes: number, alreadyUploadedBytes: number,
dataChannel: string, dataChannel: string,
) { ) {
const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`; const uploadUrl = `${SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true); xhr.open("POST", uploadUrl, true);
@ -1251,7 +1245,7 @@ function UploadFileView({
} }
/> />
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="space-y-2 opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
}} }}
@ -1267,18 +1261,17 @@ 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": "cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle",
uploadState === "idle",
})} })}
> >
<div className="h-[186px] w-full px-4"> <div className="h-[186px] w-full px-4">
<div className="flex h-full flex-col items-center justify-center text-center"> <div className="flex flex-col items-center justify-center h-full 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="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" /> <PlusCircleIcon className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1298,11 +1291,11 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuUpload className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" /> <LuUpload className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
</div> </div>
</Card> </Card>
</div> </div>
<h3 className="leading-non text-lg font-semibold text-black dark:text-white"> <h3 className="text-lg font-semibold text-black leading-non 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">
@ -1311,7 +1304,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 transition-all duration-500 ease-linear dark:bg-blue-500" className="h-3.5 rounded-full bg-blue-700 dark:bg-blue-500 transition-all duration-500 ease-linear"
style={{ width: `${uploadProgress}%` }} style={{ width: `${uploadProgress}%` }}
></div> ></div>
</div> </div>
@ -1332,7 +1325,7 @@ function UploadFileView({
<div className="inline-block"> <div className="inline-block">
<Card> <Card>
<div className="p-1"> <div className="p-1">
<LuCheck className="h-4 w-4 shrink-0 text-blue-500 dark:text-blue-400" /> <LuCheck className="w-4 h-4 text-blue-500 dark:text-blue-400 shrink-0" />
</div> </div>
</Card> </Card>
</div> </div>
@ -1357,15 +1350,13 @@ function UploadFileView({
className="hidden" className="hidden"
accept=".iso, .img" accept=".iso, .img"
/> />
{fileError && ( {fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>}
<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 animate-fadeIn truncate text-sm text-red-600 opacity-0 dark:text-red-400" className="mt-2 text-sm text-red-600 truncate opacity-0 dark:text-red-400 animate-fadeIn"
style={{ animationDuration: "0.7s" }} style={{ animationDuration: "0.7s" }}
> >
Error: {uploadError} Error: {uploadError}
@ -1373,13 +1364,13 @@ function UploadFileView({
)} )}
<div <div
className="flex w-full animate-fadeIn items-end opacity-0" className="flex items-end w-full opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
}} }}
> >
<div className="flex w-full justify-end space-x-2"> <div className="flex justify-end w-full space-x-2">
{uploadState === "uploading" ? ( {uploadState === "uploading" ? (
<Button <Button
size="MD" size="MD"
@ -1421,7 +1412,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="h-6 w-6" /> <ExclamationTriangleIcon className="w-6 h-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">
@ -1429,7 +1420,7 @@ function ErrorView({
</p> </p>
</div> </div>
{errorMessage && ( {errorMessage && (
<Card className="border border-red-200 bg-red-50 p-4"> <Card className="p-4 border border-red-200 bg-red-50">
<p className="text-sm font-medium text-red-800">{errorMessage}</p> <p className="text-sm font-medium text-red-800">{errorMessage}</p>
</Card> </Card>
)} )}
@ -1489,12 +1480,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 text-slate-300 dark:bg-slate-600"></div> <div className="mx-1 h-[10px] w-[1px] bg-slate-300 dark:bg-slate-600 text-slate-300"></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 select-none items-center gap-x-3"> <div className="relative flex items-center select-none 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,
@ -1518,7 +1509,7 @@ function PreUploadedImageItem({
checked={isSelected} checked={isSelected}
onChange={onSelect} onChange={onSelect}
name={name} name={name}
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" 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"
onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect onClick={e => e.stopPropagation()} // Prevent double-firing of onSelect
/> />
) : ( ) : (
@ -1558,7 +1549,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void; setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) { }) {
return ( return (
<div className="flex select-none flex-col items-start space-y-1"> <div className="flex flex-col items-start space-y-1 select-none">
<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">
@ -1568,7 +1559,7 @@ function UsbModeSelector({
name="mountType" name="mountType"
onChange={() => setUsbMode("CDROM")} onChange={() => setUsbMode("CDROM")}
checked={usbMode === "CDROM"} checked={usbMode === "CDROM"}
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" 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"
/> />
<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
@ -1582,10 +1573,10 @@ function UsbModeSelector({
disabled disabled
checked={usbMode === "Disk"} checked={usbMode === "Disk"}
onChange={() => setUsbMode("Disk")} onChange={() => setUsbMode("Disk")}
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" 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"
/> />
<div className="ml-2 flex flex-col gap-y-0"> <div className="flex flex-col ml-2 gap-y-0">
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white"> <span className="text-sm font-medium leading-none opacity-50 text-slate-900 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,5 +1,4 @@
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
import EmptyCard from "@/components/EmptyCard"; import EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() { export default function NotFoundPage() {

View File

@ -1,24 +1,24 @@
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";
interface ContextType { export default function OtherSessionConnectedModal({
setupPeerConnection: () => Promise<void>; open,
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?.setupPeerConnection().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,7 +37,12 @@ export default function OtherSessionRoute() {
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 size="SM" theme="primary" text="Use Here" onClick={handleClose} /> <Button
size="SM"
theme="primary"
text="Use Here"
onClick={() => setOpen(false)}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -9,22 +9,21 @@ const PeerConnectionStatusMap = {
failed: "Connection failed", failed: "Connection failed",
closed: "Closed", closed: "Closed",
new: "Connecting", new: "Connecting",
} as Record<RTCPeerConnectionState | "error" | "closing", string>; };
export type PeerConnections = keyof typeof PeerConnectionStatusMap; export type PeerConnections = keyof typeof PeerConnectionStatusMap;
type StatusProps = Record< type StatusProps = {
PeerConnections, [key in PeerConnections]: {
{
statusIndicatorClassName: string; statusIndicatorClassName: string;
} };
>; };
export default function PeerConnectionStatusCard({ export default function PeerConnectionStatusCard({
state, state,
title, title,
}: { }: {
state?: RTCPeerConnectionState | null; state?: PeerConnections;
title?: string; title?: string;
}) { }) {
if (!state) return null; if (!state) return null;

View File

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

@ -1,11 +1,8 @@
import React from "react"; import React from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import { cva } from "@/cva.config"; import clsx from "clsx";
import Card from "./Card"; import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick< type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"], JSX.IntrinsicElements["select"],
@ -22,7 +19,7 @@ type SelectMenuProps = Pick<
direction?: "vertical" | "horizontal"; direction?: "vertical" | "horizontal";
error?: string; error?: string;
fullWidth?: boolean; fullWidth?: boolean;
} & Partial<React.ComponentProps<typeof FieldLabel>>; } & 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",
@ -63,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 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -72,13 +69,10 @@ 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 transition duration-300", "block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
// Hover // Hover
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white", "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",
// 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",
@ -88,6 +82,9 @@ 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,16 +0,0 @@
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

@ -1,11 +1,10 @@
import Container from "@/components/Container";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import React from "react"; import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png"; import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg"; import LogoWhiteIcon from "@/assets/logo-white.svg";
interface Props { logoHref?: string; actionElement?: React.ReactNode } type Props = { logoHref?: string; actionElement?: React.ReactNode };
export default function SimpleNavbar({ logoHref, actionElement }: Props) { export default function SimpleNavbar({ logoHref, actionElement }: Props) {
return ( return (

View File

@ -9,7 +9,6 @@ import {
XAxis, XAxis,
YAxis, YAxis,
} from "recharts"; } from "recharts";
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip"; import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
export default function StatChart({ export default function StatChart({

View File

@ -1,5 +1,4 @@
import React from "react"; import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
interface Props { interface Props {

View File

@ -1,13 +1,12 @@
import { CheckIcon } from "@heroicons/react/16/solid"; import { CheckIcon } from "@heroicons/react/16/solid";
import { cva, cx } from "@/cva.config"; import { cva, cx } from "@/cva.config";
import Card from "@/components/Card"; import Card from "@/components/Card";
interface Props { type Props = {
nSteps: number; nSteps: number;
currStepIdx: number; currStepIdx: number;
size?: keyof typeof sizes; size?: keyof typeof sizes;
} };
const sizes = { const sizes = {
SM: "text-xs leading-[12px]", SM: "text-xs leading-[12px]",

View File

@ -1,5 +1,8 @@
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "@/cva.config";
import { useEffect } from "react"; import { useEffect } from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
@ -8,11 +11,6 @@ import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11"; import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard"; import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { Button } from "./Button";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2"); const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration // Terminal theme configuration
@ -86,23 +84,10 @@ 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 instance?.write(new Uint8Array(e.data));
// Firefox sends data as blobs, chrome sends data as arraybuffer
if (binaryType === "arraybuffer") {
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 },
); );
@ -121,11 +106,6 @@ 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

@ -1,7 +1,6 @@
import React from "react"; import React from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { FieldError } from "@/components/InputField"; import { FieldError } from "@/components/InputField";
import Card from "@/components/Card"; import Card from "@/components/Card";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";

View File

@ -1,23 +1,23 @@
import React from "react";
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png"; import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards"; import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores"; import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"]; type USBStates = HidState["usbState"];
type StatusProps = Record< type StatusProps = {
USBStates, [key in USBStates]: {
{
icon: React.FC<{ className: string | undefined }>; icon: React.FC<{ className: string | undefined }>;
iconClassName: string; iconClassName: string;
statusIndicatorClassName: string; statusIndicatorClassName: string;
} };
>; };
const USBStateMap: Record<USBStates, string> = { const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected", configured: "Connected",
attached: "Connecting", attached: "Connecting",
addressed: "Connecting", addressed: "Connecting",
@ -30,8 +30,9 @@ export default function USBStateStatus({
peerConnectionState, peerConnectionState,
}: { }: {
state: USBStates; state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null; peerConnectionState?: RTCPeerConnectionState;
}) { }) {
const StatusCardProps: StatusProps = { const StatusCardProps: StatusProps = {
configured: { configured: {
icon: ({ className }) => ( icon: ({ className }) => (
@ -67,7 +68,7 @@ export default function USBStateStatus({
}; };
const props = StatusCardProps[state]; const props = StatusCardProps[state];
if (!props) { if (!props) {
console.log("Unsupported USB state: ", state); console.log("Unsupport USB state: ", state);
return; return;
} }

View File

@ -1,45 +1,14 @@
import { useLocation, useNavigate } from "react-router-dom"; import Card, { GridCard } from "@/components/Card";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores"; import LogoBlueIcon from "@/assets/logo-blue.svg";
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 LoadingSpinner from "@/components/LoadingSpinner"; import { CheckCircleIcon } from "@heroicons/react/20/solid";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation"; import LoadingSpinner from "./LoadingSpinner";
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 };
@ -48,15 +17,37 @@ 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({
onClose, setOpen,
onConfirmUpdate, onConfirmUpdate,
}: { }: {
onClose: () => void; setOpen: (open: boolean) => 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();
@ -82,24 +73,27 @@ export function Dialog({
}, [setModalView]); }, [setModalView]);
return ( return (
<div className="pointer-events-auto relative mx-auto text-left"> <GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div> <div className="p-10">
{modalView === "error" && ( {modalView === "error" && (
<UpdateErrorState <UpdateErrorState
errorMessage={otaState.error} errorMessage={otaState.error}
onClose={onClose} onClose={() => setOpen(false)}
onRetryUpdate={() => setModalView("loading")} onRetryUpdate={() => setModalView("loading")}
/> />
)} )}
{modalView === "loading" && ( {modalView === "loading" && (
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} /> <LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)} )}
{modalView === "updateAvailable" && ( {modalView === "updateAvailable" && (
<UpdateAvailableState <UpdateAvailableState
onConfirmUpdate={onConfirmUpdate} onConfirmUpdate={onConfirmUpdate}
onClose={onClose} onClose={() => setOpen(false)}
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
@ -107,20 +101,24 @@ export function Dialog({
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
otaState={otaState} otaState={otaState}
onMinimizeUpgradeDialog={() => navigateTo("/")} onMinimizeUpgradeDialog={() => {
setOpen(false);
}}
/> />
)} )}
{modalView === "upToDate" && ( {modalView === "upToDate" && (
<SystemUpToDateState <SystemUpToDateState
checkUpdate={() => setModalView("loading")} checkUpdate={() => setModalView("loading")}
onClose={onClose} onClose={() => setOpen(false)}
/> />
)} )}
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />} {modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div> </div>
</div> </GridCard>
); );
} }
@ -135,9 +133,6 @@ 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 => {
@ -146,13 +141,11 @@ 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, setAppVersion, setSystemVersion]); }, [send]);
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
@ -163,12 +156,14 @@ function LoadingState({
const animationTimer = setTimeout(() => { const animationTimer = setTimeout(() => {
setProgressWidth("100%"); setProgressWidth("100%");
}, 0); }, 500);
getVersionInfo() getVersionInfo()
.then(versionInfo => { .then(versionInfo => {
// Add a small delay to ensure it's not just flickering if (progressBarRef.current) {
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600)); progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
}) })
.then(versionInfo => { .then(versionInfo => {
if (!signal.aborted) { if (!signal.aborted) {
@ -188,8 +183,12 @@ function LoadingState({
}, [getVersionInfo, onFinished]); }, [getVersionInfo, onFinished]);
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4"> <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="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...
@ -202,11 +201,18 @@ function LoadingState({
<div <div
ref={progressBarRef} ref={progressBarRef}
style={{ width: progressWidth }} style={{ width: progressWidth }}
className="h-2.5 bg-blue-700 transition-all duration-1000 ease-in-out" className="h-2.5 bg-blue-700 transition-all duration-[4s] ease-in-out"
></div> ></div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} /> <Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -288,7 +294,11 @@ function UpdatingDeviceState({
}; };
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] 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">
@ -298,10 +308,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="space-y-4 p-4"> <Card className="p-4 space-y-4">
{areAllUpdatesComplete() ? ( {areAllUpdatesComplete() ? (
<div className="my-2 flex flex-col items-center space-y-2 text-center"> <div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="w-6 h-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...
@ -311,8 +321,8 @@ function UpdatingDeviceState({
) : ( ) : (
<> <>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && ( {!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="my-2 flex flex-col items-center space-y-2 text-center"> <div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" /> <LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
</div> </div>
)} )}
@ -323,9 +333,9 @@ function UpdatingDeviceState({
Linux System Update Linux System Update
</p> </p>
{calculateOverallProgress("system") < 100 ? ( {calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner 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" /> <CheckCircleIcon className="w-4 h-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">
@ -355,9 +365,9 @@ function UpdatingDeviceState({
App Update App Update
</p> </p>
{calculateOverallProgress("app") < 100 ? ( {calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <LoadingSpinner 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" /> <CheckCircleIcon className="w-4 h-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">
@ -380,7 +390,7 @@ function UpdatingDeviceState({
</> </>
)} )}
</Card> </Card>
<div className="mt-4 flex justify-start gap-x-2 text-white"> <div className="flex justify-start mt-4 text-white gap-x-2">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
@ -401,7 +411,11 @@ function SystemUpToDateState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] 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
@ -410,9 +424,23 @@ 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="mt-4 flex gap-x-2"> <div className="flex mt-4 gap-x-2">
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} /> <Button
<Button size="SM" theme="blank" text="Back" onClick={onClose} /> size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div> </div>
</div> </div>
</div> </div>
@ -429,7 +457,11 @@ function UpdateAvailableState({
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] 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
@ -463,7 +495,11 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] 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
@ -473,7 +509,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="Back" onClick={onClose} /> <Button size="SM" theme="primary" text="Close" onClick={onClose} />
</div> </div>
</div> </div>
</div> </div>
@ -490,7 +526,11 @@ function UpdateErrorState({
onRetryUpdate: () => void; onRetryUpdate: () => void;
}) { }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex min-h-[140px] 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">
@ -502,8 +542,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="light" text="Back" onClick={onClose} /> <Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} /> <Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,22 +1,26 @@
import { cx } from "@/cva.config"; import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
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";
export default function UpdateInProgressStatusCard() { interface UpdateInProgressStatusCardProps {
const { navigateTo } = useDeviceUiNavigation(); setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
return ( return (
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out"> <div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
<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-ellipsis text-sm font-semibold leading-none transition"> <div className="text-sm font-semibold leading-none transition text-ellipsis">
Update in Progress Update in Progress
</div> </div>
<div className="text-sm leading-none"> <div className="text-sm leading-none">
@ -33,7 +37,10 @@ export default function UpdateInProgressStatusCard() {
className="pointer-events-auto" className="pointer-events-auto"
theme="light" theme="light"
text="View Details" text="View Details"
onClick={() => navigateTo("/settings/general/update")} onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/> />
</div> </div>
</GridCard> </GridCard>

View File

@ -1,240 +0,0 @@
import { useCallback , 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(prev => ({
...prev,
[key]: e.target.checked,
}));
},
[],
);
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

@ -1,303 +0,0 @@
import { useMemo , useCallback , useEffect, useState } from "react";
import { Button } from "@components/Button";
import { UsbConfigState } from "../hooks/stores";
import { useJsonRpc } from "../hooks/useJsonRpc";
import notifications from "../notifications";
import { SettingsItem } from "../routes/devices.$id.settings";
import { InputFieldWithLabel } from "./InputField";
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,12 +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 { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { LinkButton } from "@components/Button";
import { LuPlay } from "react-icons/lu";
import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; children: React.ReactNode;
@ -14,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 h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20"> <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">
{children} {children}
</div> </div>
</GridCard> </GridCard>
@ -25,183 +23,78 @@ interface LoadingOverlayProps {
show: boolean; show: boolean;
} }
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) { export function LoadingOverlay({ show }: LoadingOverlayProps) {
return ( return (
<AnimatePresence> <Transition
{show && ( show={show}
<motion.div enter="transition-opacity duration-300"
className="aspect-video h-full w-full" enterFrom="opacity-0"
initial={{ opacity: 0 }} enterTo="opacity-100"
animate={{ opacity: 1 }} leave="transition-opacity duration-100"
exit={{ opacity: 0 }} leaveFrom="opacity-100"
transition={{ leaveTo="opacity-0"
duration: show ? 0.3 : 0.1, >
ease: "easeInOut", <div className="absolute inset-0 w-full h-full aspect-video">
}} <OverlayContent>
> <div className="flex flex-col items-center justify-center gap-y-1">
<OverlayContent> <div className="flex items-center justify-center w-12 h-12 animate">
<div className="flex flex-col items-center justify-center gap-y-1"> <LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
Loading video stream...
</p>
</div> </div>
</OverlayContent> <p className="text-sm text-center text-slate-700 dark:text-slate-300">
</motion.div> Loading video stream...
)} </p>
</AnimatePresence> </div>
); </OverlayContent>
} </div>
</Transition>
interface LoadingConnectionOverlayProps {
show: boolean;
text: string;
}
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-center justify-center gap-y-1">
<div className="animate flex h-12 w-12 items-center justify-center">
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
</div>
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
{text}
</p>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
); );
} }
interface ConnectionErrorOverlayProps { interface ConnectionErrorOverlayProps {
show: boolean; show: boolean;
setupPeerConnection: () => Promise<void>;
} }
export function ConnectionFailedOverlay({ export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
show,
setupPeerConnection,
}: ConnectionErrorOverlayProps) {
return ( return (
<AnimatePresence> <Transition
{show && ( show={show}
<motion.div enter="transition duration-300"
className="aspect-video h-full w-full" enterFrom="opacity-0"
initial={{ opacity: 0 }} enterTo="opacity-100"
animate={{ opacity: 1 }} leave="transition duration-300"
exit={{ opacity: 0, transition: { duration: 0 } }} leaveFrom="opacity-100"
transition={{ leaveTo="opacity-0"
duration: 0.4, >
ease: "easeInOut", <div className="absolute inset-0 z-10 w-full h-full aspect-video">
}} <OverlayContent>
> <div className="flex flex-col items-start gap-y-1">
<OverlayContent> <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="flex flex-col items-start gap-y-1"> <div className="text-sm text-left text-slate-700 dark:text-slate-300">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" /> <div className="space-y-4">
<div className="text-left text-sm text-slate-700 dark:text-slate-300"> <div className="space-y-2 text-black dark:text-white">
<div className="space-y-4"> <h2 className="text-xl font-bold">Connection Issue Detected</h2>
<div className="space-y-2 text-black dark:text-white"> <ul className="pl-4 space-y-2 text-left list-disc">
<h2 className="text-xl font-bold">Connection Issue Detected</h2> <li>Verify that the device is powered on and properly connected</li>
<ul className="list-disc space-y-2 pl-4 text-left"> <li>Check all cable connections for any loose or damaged wires</li>
<li>Verify that the device is powered on and properly connected</li> <li>Ensure your network connection is stable and active</li>
<li>Check all cable connections for any loose or damaged wires</li> <li>Try restarting both the device and your computer</li>
<li>Ensure your network connection is stable and active</li> </ul>
<li>Try restarting both the device and your computer</li> </div>
</ul> <div>
</div> <LinkButton
<div className="flex items-center gap-x-2"> to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
<LinkButton theme="light"
to={"https://jetkvm.com/docs/getting-started/troubleshooting"} text="Troubleshooting Guide"
theme="primary" TrailingIcon={ArrowRightIcon}
text="Troubleshooting Guide" size="SM"
TrailingIcon={ArrowRightIcon} />
size="SM"
/>
<Button
onClick={() => setupPeerConnection()}
LeadingIcon={ArrowPathIcon}
text="Try again"
size="SM"
theme="light"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</OverlayContent> </div>
</motion.div> </OverlayContent>
)} </div>
</AnimatePresence> </Transition>
);
}
interface PeerConnectionDisconnectedOverlay {
show: boolean;
}
export function PeerConnectionDisconnectedOverlay({
show,
}: PeerConnectionDisconnectedOverlay) {
return (
<AnimatePresence>
{show && (
<motion.div
className="aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={{
duration: 0.4,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<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>Ensure your network connection is stable and active</li>
<li>Try restarting both the device and your computer</li>
</ul>
</div>
<div className="flex items-center gap-x-2">
<Card>
<div className="flex items-center gap-x-2 p-4">
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
<p className="text-sm text-slate-700 dark:text-slate-300">
Retrying connection...
</p>
</div>
</Card>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
); );
} }
@ -216,145 +109,85 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
return ( return (
<> <>
<AnimatePresence> <Transition
{show && isNoSignal && ( show={show && isNoSignal}
<motion.div enter="transition duration-300"
className="absolute inset-0 aspect-video h-full w-full" enterFrom="opacity-0"
initial={{ opacity: 0 }} enterTo="opacity-100"
animate={{ opacity: 1 }} leave="transition-all duration-300"
exit={{ opacity: 0 }} leaveFrom="opacity-100"
transition={{ leaveTo="opacity-0"
duration: 0.3, >
ease: "easeInOut", <div className="absolute inset-0 w-full h-full aspect-video">
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>Ensure the HDMI cable securely connected at both ends</li>
<li>
Ensure source device is powered on and outputting a signal
</li>
<li>
If using an adapter, it&apos;s compatible and functioning
correctly
</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{show && isOtherError && (
<motion.div
className="absolute inset-0 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="list-disc space-y-2 pl-4 text-left">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</motion.div>
)}
</AnimatePresence>
</>
);
}
interface NoAutoplayPermissionsOverlayProps {
show: boolean;
onPlayClick: () => void;
}
export function NoAutoplayPermissionsOverlay({
show,
onPlayClick,
}: NoAutoplayPermissionsOverlayProps) {
return (
<AnimatePresence>
{show && (
<motion.div
className="absolute inset-0 z-10 aspect-video h-full w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: 0.3,
ease: "easeInOut",
}}
>
<OverlayContent> <OverlayContent>
<div className="space-y-4"> <div className="flex flex-col items-start gap-y-1">
<h2 className="text-2xl font-extrabold text-black dark:text-white"> <ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
Autoplay permissions required <div className="text-sm text-left text-slate-700 dark:text-slate-300">
</h2> <div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<div className="space-y-2 text-center"> <h2 className="text-xl font-bold">No HDMI signal detected.</h2>
<div> <ul className="pl-4 space-y-2 text-left list-disc">
<Button <li>Ensure the HDMI cable securely connected at both ends</li>
size="MD" <li>Ensure source device is powered on and outputting a signal</li>
theme="primary" <li>
LeadingIcon={LuPlay} If using an adapter, it&apos;s compatible and functioning
text="Manually start stream" correctly
onClick={onPlayClick} </li>
/> </ul>
</div> </div>
<div>
<div className="text-xs text-slate-600 dark:text-slate-400"> <LinkButton
Please adjust browser settings to enable autoplay to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
</OverlayContent> </OverlayContent>
</motion.div> </div>
)} </Transition>
</AnimatePresence> <Transition
show={show && isOtherError}
enter="transition duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition duration-300 "
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="absolute inset-0 w-full h-full aspect-video">
<OverlayContent>
<div className="flex flex-col items-start gap-y-1">
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
<div className="space-y-4">
<div className="space-y-2 text-black dark:text-white">
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
<ul className="pl-4 space-y-2 text-left list-disc">
<li>A loose or faulty HDMI connection</li>
<li>Incompatible resolution or refresh rate settings</li>
<li>Issues with the source device&apos;s HDMI output</li>
</ul>
</div>
<div>
<LinkButton
to={"/help/hdmi-error"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
size="SM"
/>
</div>
</div>
</div>
</div>
</OverlayContent>
</div>
</Transition>
</>
); );
} }

View File

@ -1,15 +1,11 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard"; import Keyboard from "react-simple-keyboard";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion";
import Card from "@components/Card";
// eslint-disable-next-line import/order
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Card from "@components/Card";
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 { 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";
@ -186,277 +182,276 @@ function KeyboardWrapper() {
marginBottom: virtualKeyboard ? "0px" : `-${350}px`, marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
}} }}
> >
<AnimatePresence> <Transition
{virtualKeyboard && ( show={virtualKeyboard}
<motion.div unmount={false}
initial={{ opacity: 0, y: "100%" }} enter="transition-all transform-gpu duration-500 ease-in-out"
animate={{ opacity: 1, y: "0%" }} enterFrom="opacity-0 translate-y-[100%]"
exit={{ opacity: 0, y: "100%" }} enterTo="opacity-100 translate-y-[0%]"
transition={{ leave="transition-all duration-500 ease-in-out"
duration: 0.5, leaveFrom="opacity-100 translate-y-[0%]"
ease: "easeInOut", leaveTo="opacity-0 translate-y-[100%]"
>
<div>
<div
className={cx(
!showAttachedVirtualKeyboard
? "fixed left-0 top-0 z-50 select-none"
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}} }}
> >
<div <Card
className={cx( className={cx("overflow-hidden", {
!showAttachedVirtualKeyboard "rounded-none": showAttachedVirtualKeyboard,
? "fixed left-0 top-0 z-50 select-none" })}
: "relative",
)}
ref={keyboardRef}
style={{
...(!showAttachedVirtualKeyboard
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
: {}),
}}
> >
<Card <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">
className={cx("overflow-hidden", { <div className="absolute flex items-center left-2 gap-x-2">
"rounded-none": showAttachedVirtualKeyboard, {showAttachedVirtualKeyboard ? (
})}
>
<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 left-2 flex items-center gap-x-2">
{showAttachedVirtualKeyboard ? (
<Button
size="XS"
theme="light"
text="Detach"
onClick={() => setShowAttachedVirtualKeyboard(false)}
/>
) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button <Button
size="XS" size="XS"
theme="light" theme="light"
text="Hide" text="Detach"
LeadingIcon={ChevronDownIcon} onClick={() => setShowAttachedVirtualKeyboard(false)}
onClick={() => setVirtualKeyboard(false)}
/> />
</div> ) : (
<Button
size="XS"
theme="light"
text="Attach"
LeadingIcon={AttachIcon}
onClick={() => setShowAttachedVirtualKeyboard(true)}
/>
)}
</div> </div>
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
Virtual Keyboard
</h2>
<div className="absolute right-2">
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setVirtualKeyboard(false)}
/>
</div>
</div>
<div> <div>
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700"> <div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
<Keyboard
baseClass="simple-keyboard-main"
layoutName={layoutName}
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{
default: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}}
disableButtonHold={true}
mergeDisplay={true}
debug={false}
/>
<div className="controlArrows">
<Keyboard <Keyboard
baseClass="simple-keyboard-main" baseClass="simple-keyboard-control"
layoutName={layoutName} theme="simple-keyboard hg-theme-default hg-layout-default"
onKeyPress={onKeyDown}
buttonTheme={[
{
class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape",
},
]}
display={{
CtrlAltDelete: "Ctrl + Alt + Delete",
AltMetaEscape: "Alt + Meta + Escape",
Escape: "esc",
Tab: "tab",
Backspace: "backspace",
"(Backspace)": "backspace",
Enter: "enter",
CapsLock: "caps lock",
ShiftLeft: "shift",
ShiftRight: "shift",
ControlLeft: "ctrl",
AltLeft: "alt",
AltRight: "alt",
MetaLeft: "meta",
MetaRight: "meta",
KeyQ: "q",
KeyW: "w",
KeyE: "e",
KeyR: "r",
KeyT: "t",
KeyY: "y",
KeyU: "u",
KeyI: "i",
KeyO: "o",
KeyP: "p",
KeyA: "a",
KeyS: "s",
KeyD: "d",
KeyF: "f",
KeyG: "g",
KeyH: "h",
KeyJ: "j",
KeyK: "k",
KeyL: "l",
KeyZ: "z",
KeyX: "x",
KeyC: "c",
KeyV: "v",
KeyB: "b",
KeyN: "n",
KeyM: "m",
"(KeyQ)": "Q",
"(KeyW)": "W",
"(KeyE)": "E",
"(KeyR)": "R",
"(KeyT)": "T",
"(KeyY)": "Y",
"(KeyU)": "U",
"(KeyI)": "I",
"(KeyO)": "O",
"(KeyP)": "P",
"(KeyA)": "A",
"(KeyS)": "S",
"(KeyD)": "D",
"(KeyF)": "F",
"(KeyG)": "G",
"(KeyH)": "H",
"(KeyJ)": "J",
"(KeyK)": "K",
"(KeyL)": "L",
"(KeyZ)": "Z",
"(KeyX)": "X",
"(KeyC)": "C",
"(KeyV)": "V",
"(KeyB)": "B",
"(KeyN)": "N",
"(KeyM)": "M",
Digit1: "1",
Digit2: "2",
Digit3: "3",
Digit4: "4",
Digit5: "5",
Digit6: "6",
Digit7: "7",
Digit8: "8",
Digit9: "9",
Digit0: "0",
"(Digit1)": "!",
"(Digit2)": "@",
"(Digit3)": "#",
"(Digit4)": "$",
"(Digit5)": "%",
"(Digit6)": "^",
"(Digit7)": "&",
"(Digit8)": "*",
"(Digit9)": "(",
"(Digit0)": ")",
Minus: "-",
"(Minus)": "_",
Equal: "=",
"(Equal)": "+",
BracketLeft: "[",
BracketRight: "]",
"(BracketLeft)": "{",
"(BracketRight)": "}",
Backslash: "\\",
"(Backslash)": "|",
Semicolon: ";",
"(Semicolon)": ":",
Quote: "'",
"(Quote)": '"',
Comma: ",",
"(Comma)": "<",
Period: ".",
"(Period)": ">",
Slash: "/",
"(Slash)": "?",
Space: " ",
Backquote: "`",
"(Backquote)": "~",
IntlBackslash: "\\",
F1: "F1",
F2: "F2",
F3: "F3",
F4: "F4",
F5: "F5",
F6: "F6",
F7: "F7",
F8: "F8",
F9: "F9",
F10: "F10",
F11: "F11",
F12: "F12",
}}
layout={{ layout={{
default: [ default: ["Home Pageup", "Delete End Pagedown"],
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
shift: [
"CtrlAltDelete AltMetaEscape",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
],
}} }}
disableButtonHold={true} display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}}
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true} mergeDisplay={true}
debug={false} debug={false}
/> />
<Keyboard
<div className="controlArrows"> baseClass="simple-keyboard-arrows"
<Keyboard theme="simple-keyboard hg-theme-default hg-layout-default"
baseClass="simple-keyboard-control" display={{
theme="simple-keyboard hg-theme-default hg-layout-default" ArrowLeft: "←",
layout={{ ArrowRight: "→",
default: ["Home Pageup", "Delete End Pagedown"], ArrowUp: "↑",
}} ArrowDown: "↓",
display={{ }}
Home: "home", layout={{
Pageup: "pageup", default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
Delete: "delete", }}
End: "end", onKeyPress={onKeyDown}
Pagedown: "pagedown", debug={false}
}} />
syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false}
/>
<Keyboard
baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default"
display={{
ArrowLeft: "←",
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}}
onKeyPress={onKeyDown}
debug={false}
/>
</div>
</div> </div>
</div> </div>
</Card> </div>
</div> </Card>
</motion.div> </div>
)} </div>
</AnimatePresence> </Transition>
</div> </div>
); );
} }

View File

@ -1,11 +1,10 @@
import { useCallback, useEffect, useMemo, 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";
@ -16,25 +15,18 @@ 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,
LoadingVideoOverlay,
NoAutoplayPermissionsOverlay,
} from "./VideoOverlay";
export default function WebRTCVideo() { export default function WebRTCVideo() {
// Video and stream related refs and states // Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null); const videoElm = useRef<HTMLVideoElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream); const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// Store hooks // Store hooks
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,
@ -46,13 +38,15 @@ export default function WebRTCVideo() {
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
// HDMI and UI states // HDMI and UI states
const hdmiState = useVideoStore(state => state.hdmiState); const hdmiState = useVideoStore(state => state.hdmiState);
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isLoading = !hdmiError && !isPlaying;
const isConnectionError = ["error", "failed", "disconnected"].includes(
// console.log("peerConnection?.connectionState", peerConnection?.connectionState); peerConnectionState || "",
);
// Keyboard related states // Keyboard related states
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } = const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
@ -85,56 +79,31 @@ export default function WebRTCVideo() {
const onVideoPlaying = useCallback(() => { const onVideoPlaying = useCallback(() => {
setIsPlaying(true); setIsPlaying(true);
if (videoElm.current) updateVideoSizeStore(videoElm.current); videoElm.current && updateVideoSizeStore(videoElm.current);
}, [updateVideoSizeStore]); }, [updateVideoSizeStore]);
// On mount, get the video size // On mount, get the video size
useEffect( useEffect(
function updateVideoSizeOnMount() { function updateVideoSizeOnMount() {
if (videoElm.current) updateVideoSizeStore(videoElm.current); videoElm.current && updateVideoSizeStore(videoElm.current);
}, },
[setVideoClientSize, updateVideoSizeStore, setVideoSize], [setVideoClientSize, updateVideoSizeStore, setVideoSize],
); );
// Mouse-related // Mouse-related
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); const sendMouseMovement = useCallback(
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
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 }); 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, settings.mouseMode], [send, setMousePosition],
); );
const absMouseMoveHandler = useCallback( const mouseMoveHandler = 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;
@ -169,40 +138,24 @@ export default function WebRTCVideo() {
// Send mouse movement // Send mouse movement
const { buttons } = e; const { buttons } = e;
sendAbsMouseMovement(x, y, buttons); sendMouseMovement(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();
// Determine if the wheel event is from a trackpad or a mouse wheel // Define a scaling factor to adjust scrolling sensitivity
const isTrackpad = Math.abs(e.deltaY) < trackpadThreshold; const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
// 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;
// Apply clamping // Clamp the scroll value to a reasonable range (e.g., -15 to 15)
const clampedScroll = Math.max(clampMin, Math.min(clampMax, scroll)); const clampedScroll = Math.max(-4, Math.min(4, scroll));
// Round to the nearest integer // Round to the nearest integer
const roundedScroll = Math.round(clampedScroll); const roundedScroll = Math.round(clampedScroll);
@ -210,27 +163,18 @@ 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), blockDelay); setTimeout(() => setBlockWheelEvent(false), 50);
}, },
[ [blockWheelEvent, send],
blockDelay,
blockWheelEvent,
clampMax,
clampMin,
mouseSensitivity,
send,
trackpadSensitivity,
trackpadThreshold,
],
); );
const resetMousePosition = useCallback(() => { const resetMousePosition = useCallback(() => {
sendAbsMouseMovement(0, 0, 0); sendMouseMovement(0, 0, 0);
}, [sendAbsMouseMovement]); }, [sendMouseMovement]);
// Keyboard-related // Keyboard-related
const handleModifierKeys = useCallback( const handleModifierKeys = useCallback(
@ -341,6 +285,11 @@ 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"));
@ -365,68 +314,7 @@ export default function WebRTCVideo() {
], ],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { // Effect hooks
// 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();
}
}
}, []);
const addStreamToVideoElm = useCallback(
(mediaStream: MediaStream) => {
if (!videoElm.current) return;
const videoElmRefValue = videoElm.current;
// console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue);
},
[updateVideoSizeStore],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
const abortController = new AbortController();
const signal = abortController.signal;
peerConnection.addEventListener(
"track",
(e: RTCTrackEvent) => {
// console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]);
},
{ signal },
);
return () => {
abortController.abort();
};
},
[addStreamToVideoElm, peerConnection],
);
useEffect(
function updateVideoStream() {
if (!mediaStream) return;
console.log("Updating video stream from mediaStream");
// We set the as early as possible
addStreamToVideoElm(mediaStream);
},
[
setVideoClientSize,
mediaStream,
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
],
);
// Setup Keyboard Events
useEffect( useEffect(
function setupKeyboardEvents() { function setupKeyboardEvents() {
const abortController = new AbortController(); const abortController = new AbortController();
@ -448,113 +336,83 @@ export default function WebRTCVideo() {
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], [keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
); );
// Setup Video Event Listeners
useEffect( useEffect(
function setupVideoEventListeners() { function setupVideoEventListeners() {
const videoElmRefValue = videoElm.current; let videoElmRefValue = null;
if (!videoElmRefValue) return; if (!videoElm.current) return;
videoElmRefValue = videoElm.current;
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
// To prevent the video from being paused when the user presses a space in fullscreen mode videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
// We need to know when the video is playing to update state and video size videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
videoElmRefValue.addEventListener(
"contextmenu",
(e: MouseEvent) => e.preventDefault(),
{ signal },
);
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal }); videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
return () => {
abortController.abort();
};
},
[
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
);
// Setup Absolute Mouse Events
useEffect(
function setAbsoluteMouseModeEventListeners() {
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
// Reset the mouse position when the window is blurred or the document is hidden
const local = resetMousePosition; const local = resetMousePosition;
window.addEventListener("blur", local, { signal }); window.addEventListener("blur", local, { signal });
document.addEventListener("visibilitychange", local, { signal }); document.addEventListener("visibilitychange", local, { signal });
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
return () => { return () => {
abortController.abort(); if (videoElmRefValue) abortController.abort();
}; };
}, },
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode], [mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
); );
// Setup Relative Mouse Events
const containerRef = useRef<HTMLDivElement>(null);
useEffect( useEffect(
function setupRelativeMouseEventListeners() { function updateVideoStream() {
if (settings.mouseMode !== "relative") return; if (!mediaStream) return;
if (!videoElm.current) return;
if (peerConnection?.iceConnectionState !== "connected") return;
const abortController = new AbortController(); setTimeout(() => {
const signal = abortController.signal; if (videoElm?.current) {
videoElm.current.srcObject = mediaStream;
// We bind to the larger container in relative mode because of delta between the acceleration of the local }
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use. }, 0);
// When we get Pointer Lock support, we can remove this. updateVideoSizeStore(videoElm.current);
const containerElm = containerRef.current;
if (!containerElm) return;
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
containerElm.addEventListener("wheel", mouseWheelHandler, {
signal,
passive: true,
});
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
return () => {
abortController.abort();
};
}, },
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler], [
setVideoClientSize,
setVideoSize,
mediaStream,
updateVideoSizeStore,
peerConnection?.iceConnectionState,
],
); );
const hasNoAutoPlayPermissions = useMemo(() => { // Focus trap management
if (peerConnection?.connectionState !== "connected") return false; const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
if (isPlaying) return false; const sidebarView = useUiStore(state => state.sidebarView);
if (hdmiError) return false; useEffect(() => {
if (videoHeight === 0 || videoWidth === 0) return false; setTimeout(function () {
return true; if (["connection-stats", "system"].includes(sidebarView ?? "")) {
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]); // 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 h-full w-full grid-rows-layout"> <div className="grid w-full h-full grid-rows-layout">
<div className="min-h-[39.5px]"> <div className="min-h-[39.5px]">
<fieldset disabled={peerConnection?.connectionState !== "connected"}> <fieldset disabled={peerConnectionState !== "connected"}>
<Actionbar <Actionbar
requestFullscreen={async () => requestFullscreen={async () =>
videoElm.current?.requestFullscreen({ videoElm.current?.requestFullscreen({
@ -565,27 +423,22 @@ export default function WebRTCVideo() {
</fieldset> </fieldset>
</div> </div>
<div <div className="h-full overflow-hidden">
ref={containerRef}
className={cx("h-full overflow-hidden", {
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
})}
>
<div className="relative h-full"> <div className="relative h-full">
<div <div
className={cx( className={cx(
"absolute inset-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40", "absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
"[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 h-full flex-col"> <div className="flex flex-col h-full">
<div className="relative flex-grow overflow-hidden"> <div className="relative flex-grow overflow-hidden">
<div className="flex h-full flex-col"> <div className="flex flex-col h-full">
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden"> <div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden"> <div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center"> <div className="relative flex items-center justify-center w-full h-full">
<video <video
ref={videoElm} ref={videoElm}
autoPlay={true} autoPlay={true}
@ -599,35 +452,23 @@ export default function WebRTCVideo() {
className={cx( className={cx(
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000", "outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
{ {
"cursor-none": "cursor-none": settings.isCursorHidden,
settings.mouseMode === "absolute" && "opacity-0": isLoading || isConnectionError || hdmiError,
settings.isCursorHidden, "animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
"opacity-0":
isVideoLoading ||
hdmiError ||
peerConnectionState !== "connected",
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
/> />
{peerConnection?.connectionState == "connected" && ( <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} />
<LoadingVideoOverlay show={isVideoLoading} /> <ConnectionErrorOverlay show={isConnectionError} />
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} /> <HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
<NoAutoplayPermissionsOverlay
show={hasNoAutoPlayPermissions}
onPlayClick={() => {
videoElm.current?.play();
}}
/>
</div>
</div> </div>
)} </div>
</div> </div>
</div> </div>
<VirtualKeyboard /> <VirtualKeyboard />

View File

@ -1,13 +1,11 @@
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SectionHeader } from "@components/SectionHeader";
import { useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc"; import { useJsonRpc } from "../../hooks/useJsonRpc";
import LoadingSpinner from "../LoadingSpinner";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
@ -97,18 +95,18 @@ export function ATXPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SectionHeader
title="ATX Power Control" title="ATX Power Control"
description="Control your ATX power settings" description="Control your ATX power settings"
/> />
{atxState === null ? ( {atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3"> <Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[120px] animate-fadeIn opacity-0"> <Card className="h-[120px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <div className="p-3 space-y-4">
{/* Control Buttons */} {/* Control Buttons */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

@ -1,13 +1,12 @@
import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import { LuPower } from "react-icons/lu";
import Card from "@components/Card"; import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SectionHeader } from "@components/SectionHeader";
import FieldLabel from "../FieldLabel";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useCallback, useEffect, useState } from "react";
import notifications from "@/notifications"; import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel"; import LoadingSpinner from "../LoadingSpinner";
import LoadingSpinner from "@components/LoadingSpinner";
interface DCPowerState { interface DCPowerState {
isOn: boolean; isOn: boolean;
@ -53,18 +52,18 @@ export function DCPowerControl() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SectionHeader
title="DC Power Control" title="DC Power Control"
description="Control your DC power settings" description="Control your DC power settings"
/> />
{powerState === null ? ( {powerState === null ? (
<Card className="flex h-[160px] justify-center p-3"> <Card className="flex h-[160px] justify-center p-3">
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" /> <LoadingSpinner className="w-6 h-6 text-blue-500 dark:text-blue-400" />
</Card> </Card>
) : ( ) : (
<Card className="h-[160px] animate-fadeIn opacity-0"> <Card className="h-[160px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3"> <div className="p-3 space-y-4">
{/* Power Controls */} {/* Power Controls */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button

View File

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

View File

@ -1,20 +1,19 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SectionHeader } from "@components/SectionHeader";
import { Button } from "../Button";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl"; import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl"; import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole"; import { SerialConsole } from "@components/extensions/SerialConsole";
import { Button } from "@components/Button"; import notifications from "../../notifications";
import notifications from "@/notifications";
interface Extension { interface Extension {
id: string; id: string;
name: string; name: string;
description: string; description: string;
icon: React.ElementType; icon: any;
} }
const AVAILABLE_EXTENSIONS: Extension[] = [ const AVAILABLE_EXTENSIONS: Extension[] = [
@ -59,9 +58,7 @@ export default function ExtensionPopover() {
const handleSetActiveExtension = (extension: Extension | null) => { const handleSetActiveExtension = (extension: Extension | null) => {
send("setActiveExtension", { extensionId: extension?.id || "" }, resp => { send("setActiveExtension", { extensionId: extension?.id || "" }, resp => {
if ("error" in resp) { if ("error" in resp) {
notifications.error( notifications.error(`Failed to set active extension: ${resp.error.data || "Unknown error"}`);
`Failed to set active extension: ${resp.error.data || "Unknown error"}`,
);
return; return;
} }
setActiveExtension(extension); setActiveExtension(extension);
@ -83,7 +80,7 @@ export default function ExtensionPopover() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <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">
{activeExtension ? ( {activeExtension ? (
@ -92,7 +89,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex items-center justify-end space-x-2 opacity-0 animate-fadeIn"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -109,11 +106,11 @@ export default function ExtensionPopover() {
) : ( ) : (
// Extensions List View // Extensions List View
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SectionHeader
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="opacity-0 animate-fadeIn">
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => ( {AVAILABLE_EXTENSIONS.map(extension => (
<div <div

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