Compare commits

..

1 Commits

Author SHA1 Message Date
William Johnstone 9b8f3ea79e
Merge 8732a6aff8 into 951173ba19 2025-02-13 19:23:43 +01:00
166 changed files with 4695 additions and 13778 deletions

View File

@ -6,9 +6,5 @@
// Should match what is defined in ui/package.json
"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.24.x
- name: Create empty resource directory
run: |
mkdir -p static && touch static/.gitkeep
- name: Lint
uses: golangci/golangci-lint-action@1481404843c368bc19ca9406f87d6e0fc97bdcfd # v7.0.0
with:
args: --verbose
version: v2.0.2

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,37 +0,0 @@
version: "2"
linters:
enable:
- forbidigo
- misspell
- whitespace
settings:
forbidigo:
forbid:
- pattern: ^fmt\.Print.*$
msg: Do not commit print statements. Use logger package.
- pattern: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
msg: Do not commit log statements. Use logger package.
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
- linters:
- errcheck
path: _test.go
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@ -1,31 +1,17 @@
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.8
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
GO_LDFLAGS := \
-s -w \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.4
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_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:
cd ui && npm ci && npm run build:device
dev_release: frontend build_dev
dev_release: build_dev
@echo "Uploading release..."
@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
@ -33,7 +19,7 @@ dev_release: frontend build_dev
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_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:
@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
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

View File

@ -3,13 +3,13 @@ package kvm
import (
"context"
"errors"
"log"
"net"
"os"
"time"
"github.com/pojntfx/go-nbd/pkg/client"
"github.com/pojntfx/go-nbd/pkg/server"
"github.com/rs/zerolog"
)
type remoteImageBackend struct {
@ -17,8 +17,8 @@ type remoteImageBackend struct {
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
virtualMediaStateMutex.RLock()
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
logger.Debug().Int64("read size", int64(len(p))).Int64("off", off).Msg("read size and off")
logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
logger.Debugf("read size: %d, off: %d", len(p), off)
if currentVirtualMediaState == nil {
return 0, errors.New("image not mounted")
}
@ -34,17 +34,16 @@ func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
readLen = mountedImageSize - off
}
var data []byte
switch source {
case WebRTC:
if source == WebRTC {
data, err = webRTCDiskReader.Read(ctx, off, readLen)
if err != nil {
return 0, err
}
n = copy(p, data)
return n, nil
case HTTP:
} else if source == HTTP {
return httpRangeReader.ReadAt(p, off)
default:
} else {
return 0, errors.New("unknown image source")
}
}
@ -74,8 +73,6 @@ type NBDDevice struct {
serverConn net.Conn
clientConn net.Conn
dev *os.File
l *zerolog.Logger
}
func NewNBDDevice() *NBDDevice {
@ -94,19 +91,10 @@ func (d *NBDDevice) Start() error {
return err
}
if d.l == nil {
scopedLogger := nbdLogger.With().
Str("socket_path", nbdSocketPath).
Str("device_path", nbdDevicePath).
Logger()
d.l = &scopedLogger
}
// Remove the socket file if it already exists
if _, err := os.Stat(nbdSocketPath); err == nil {
if err := os.Remove(nbdSocketPath); err != nil {
d.l.Error().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
log.Fatalf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
}
}
@ -146,8 +134,7 @@ func (d *NBDDevice) runServerConn() {
MaximumBlockSize: uint32(16 * 1024),
SupportsMultiConn: false,
})
d.l.Info().Err(err).Msg("nbd server exited")
log.Println("nbd server exited:", err)
}
func (d *NBDDevice) runClientConn() {
@ -155,14 +142,14 @@ func (d *NBDDevice) runClientConn() {
ExportName: "jetkvm",
BlockSize: uint32(4 * 1024),
})
d.l.Info().Err(err).Msg("nbd client exited")
log.Println("nbd client exited:", err)
}
func (d *NBDDevice) Close() {
if d.dev != nil {
err := client.Disconnect(d.dev)
if err != nil {
d.l.Warn().Err(err).Msg("error disconnecting nbd client")
log.Println("error disconnecting nbd client:", err)
}
_ = d.dev.Close()
}

363
cloud.go
View File

@ -4,23 +4,17 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/coder/websocket/wsjson"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
)
type CloudRegisterRequest struct {
@ -30,142 +24,6 @@ type CloudRegisterRequest struct {
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) {
var req CloudRegisterRequest
@ -186,31 +44,22 @@ func handleCloudRegister(c *gin.Context) {
return
}
client := &http.Client{Timeout: CloudAPIRequestTimeout}
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiResp, err := client.Do(apiReq)
resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
return
}
defer apiResp.Body.Close()
defer resp.Body.Close()
if apiResp.StatusCode != http.StatusOK {
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
if resp.StatusCode != http.StatusOK {
c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status})
return
}
var tokenResp struct {
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()})
return
}
@ -220,7 +69,13 @@ func handleCloudRegister(c *gin.Context) {
return
}
if config.CloudToken == "" {
logger.Info("Starting websocket client due to adoption")
go RunWebsocketClient()
}
config.CloudToken = tokenResp.SecretToken
config.CloudURL = req.CloudAPI
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil {
@ -250,114 +105,76 @@ func handleCloudRegister(c *gin.Context) {
c.JSON(200, gin.H{"message": "Cloud registration successful"})
}
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Trace().Msg("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.Warn().Interface("reason", r).Msg("cloud disconnect channel is closed, no need to disconnect")
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error {
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set")
}
wsURL, err := url.Parse(config.CloudURL)
if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
l := websocketLogger.With().
Str("source", wsURL.Host).
Str("sourceType", "cloud").
Logger()
scopedLogger := &l
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
defer cancelDial()
c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
scopedLogger.Info().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received")
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
return true
},
})
// get the request id from the response header
connectionId := resp.Header.Get("X-Request-ID")
if connectionId == "" {
connectionId = resp.Header.Get("Cf-Ray")
}
if connectionId == "" {
connectionId = uuid.New().String()
scopedLogger.Warn().
Str("connectionId", connectionId).
Msg("no connection id received from the server, generating a new one")
}
lWithConnectionId := scopedLogger.With().
Str("connectionID", connectionId).
Logger()
scopedLogger = &lWithConnectionId
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Info().Msg("websocket connection canceled")
return nil
}
return err
}
defer c.CloseNow() //nolint:errcheck
cloudLogger.Info().
Str("url", wsURL.String()).
Str("connectionID", connectionId).
Msg("websocket connected")
defer c.CloseNow()
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.
wsResetMetrics(true, "cloud", wsURL.Host)
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, wsURL.Host, connectionId, scopedLogger)
err = handleSessionRequest(runCtx, c, req)
if err != nil {
logger.Infof("error starting new session: %v", err)
continue
}
}
}
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Warn().Err(err).Msg("failed to initialize OIDC provider")
fmt.Println("Failed to initialize OIDC provider:", err)
return err
}
@ -373,47 +190,13 @@ func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessi
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
if config.GoogleIdentity != googleIdentity {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "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,
scopedLogger *zerolog.Logger,
) 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{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
Logger: scopedLogger,
LocalIP: req.IP,
IsCloud: true,
})
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
@ -433,41 +216,16 @@ func handleSessionRequest(
_ = peerConn.Close()
}()
}
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
currentSession = session
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
return nil
}
func RunWebsocketClient() {
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().Msg("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().Msg("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient()
if err != nil {
cloudLogger.Warn().Err(err).Msg("websocket client error")
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
fmt.Println("Websocket client error:", err)
time.Sleep(5 * time.Second)
}
}
@ -476,14 +234,12 @@ func RunWebsocketClient() {
type CloudState struct {
Connected bool `json:"connected"`
URL string `json:"url,omitempty"`
AppURL string `json:"appUrl,omitempty"`
}
func rpcGetCloudState() CloudState {
return CloudState{
Connected: config.CloudToken != "" && config.CloudURL != "",
URL: config.CloudURL,
AppURL: config.CloudAppURL,
}
}
@ -498,7 +254,7 @@ func rpcDeregisterDevice() error {
}
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: CloudAPIRequestTimeout}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send deregister request: %w", err)
@ -511,15 +267,12 @@ func rpcDeregisterDevice() error {
// (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) {
config.CloudToken = ""
config.CloudURL = ""
config.GoogleIdentity = ""
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
}
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
return nil
}

View File

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

167
config.go
View File

@ -4,9 +4,6 @@ import (
"encoding/json"
"fmt"
"os"
"sync"
"github.com/jetkvm/kvm/internal/usbgadget"
)
type WakeOnLanDevice struct {
@ -14,171 +11,55 @@ type WakeOnLanDevice struct {
MacAddress string `json:"macAddress"`
}
// Constants for keyboard macro limits
const (
MaxMacrosPerDevice = 25
MaxStepsPerMacro = 10
MaxKeysPerStep = 10
MinStepDelay = 50
MaxStepDelay = 2000
)
type KeyboardMacroStep struct {
Keys []string `json:"keys"`
Modifiers []string `json:"modifiers"`
Delay int `json:"delay"`
}
func (s *KeyboardMacroStep) Validate() error {
if len(s.Keys) > MaxKeysPerStep {
return fmt.Errorf("too many keys in step (max %d)", MaxKeysPerStep)
}
if s.Delay < MinStepDelay {
s.Delay = MinStepDelay
} else if s.Delay > MaxStepDelay {
s.Delay = MaxStepDelay
}
return nil
}
type KeyboardMacro struct {
ID string `json:"id"`
Name string `json:"name"`
Steps []KeyboardMacroStep `json:"steps"`
SortOrder int `json:"sortOrder,omitempty"`
}
func (m *KeyboardMacro) Validate() error {
if m.Name == "" {
return fmt.Errorf("macro name cannot be empty")
}
if len(m.Steps) == 0 {
return fmt.Errorf("macro must have at least one step")
}
if len(m.Steps) > MaxStepsPerMacro {
return fmt.Errorf("too many steps in macro (max %d)", MaxStepsPerMacro)
}
for i := range m.Steps {
if err := m.Steps[i].Validate(); err != nil {
return fmt.Errorf("invalid step %d: %w", i+1, err)
}
}
return nil
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
KeyboardLayout string `json:"keyboard_layout"`
KeyboardMappingEnabled bool `json:"keyboard_mapping_enabled"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"`
DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
TLSMode string `json:"tls_mode"` // options: "self-signed", "user-defined", ""
UsbConfig *usbgadget.Config `json:"usb_config"`
UsbDevices *usbgadget.Devices `json:"usb_devices"`
DefaultLogLevel string `json:"default_log_level"`
CloudURL string `json:"cloud_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
AutoUpdateEnabled bool `json:"auto_update_enabled"`
KeyboardLayout string `json:"keyboard_layout"`
IncludePreRelease bool `json:"include_pre_release"`
HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
EdidString string `json:"hdmi_edid_string"`
}
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "en-US",
KeyboardMappingEnabled: false,
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 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,
},
DefaultLogLevel: "INFO",
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
KeyboardLayout: "us",
}
var (
config *Config
configLock = &sync.Mutex{}
)
var config *Config
func LoadConfig() {
configLock.Lock()
defer configLock.Unlock()
if config != nil {
logger.Info().Msg("config already loaded, skipping")
return
}
// load the default config
config = defaultConfig
file, err := os.Open(configPath)
if err != nil {
logger.Debug().Msg("default config file doesn't exist, using default")
logger.Debug("default config file doesn't exist, using default")
config = defaultConfig
return
}
defer file.Close()
// load and merge the default config with the user config
loadedConfig := *defaultConfig
var loadedConfig Config
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
logger.Warn().Err(err).Msg("config file JSON parsing failed")
logger.Errorf("config file JSON parsing failed, %v", err)
config = defaultConfig
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
rootLogger.UpdateLogLevel()
}
func SaveConfig() error {
configLock.Lock()
defer configLock.Unlock()
logger.Trace().Str("path", configPath).Msg("Saving config")
file, err := os.Create(configPath)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
@ -193,9 +74,3 @@ func SaveConfig() error {
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
set -e
@ -12,18 +10,17 @@ show_help() {
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --skip-ui-build Skip frontend/UI build"
echo " --help Display this help message"
echo
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
exit 0
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
@ -36,10 +33,6 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
;;
--help)
show_help
exit 0
@ -59,22 +52,17 @@ if [ -z "$REMOTE_HOST" ]; then
fi
# Build the development version on the host
if [ "$SKIP_UI_BUILD" = false ]; then
make frontend
fi
make frontend
make build_dev
# Change directory to the binary output directory
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
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
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -86,13 +74,14 @@ killall jetkvm_app_debug || true
killall jetkvm_native || true
# Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}"
cd "$REMOTE_PATH"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
./jetkvm_app_debug
EOF
echo "Deployment complete."

View File

@ -1,31 +1,17 @@
package kvm
import (
"errors"
"fmt"
"os"
"strconv"
"sync"
"log"
"time"
)
var currentScreen = "ui_Boot_Screen"
var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
var (
dimTicker *time.Ticker
offTicker *time.Ticker
)
const (
touchscreenDevice string = "/dev/input/event1"
backlightControlClass string = "/sys/class/backlight/backlight/brightness"
)
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
log.Printf("failed to switch to screen %s: %v", screen, err)
return
}
currentScreen = screen
@ -41,7 +27,7 @@ func updateLabelIfChanged(objName string, newText string) {
}
func switchToScreenIfDifferent(screenName string) {
displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen")
fmt.Println("switching screen from", currentScreen, screenName)
if currentScreen != screenName {
switchToScreen(screenName)
}
@ -71,22 +57,15 @@ func updateDisplay() {
}
}
var (
displayInited = false
displayUpdateLock = sync.Mutex{}
)
var displayInited = false
func requestDisplayUpdate() {
displayUpdateLock.Lock()
defer displayUpdateLock.Unlock()
if !displayInited {
displayLogger.Info().Msg("display not inited, skipping updates")
fmt.Println("display not inited, skipping updates")
return
}
go func() {
wakeDisplay(false)
displayLogger.Info().Msg("display updating")
fmt.Println("display updating........................")
//TODO: only run once regardless how many pending updates
updateDisplay()
}()
@ -104,169 +83,14 @@ func updateStaticContents() {
updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID())
}
// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
// the backlight brightness of the JetKVM hardware's display.
func setDisplayBrightness(brightness int) error {
// NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64.
// The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!).
if brightness > 100 || brightness < 0 {
return errors.New("brightness value out of bounds, must be between 0 and 100")
}
// Check the display backlight class is available
if _, err := os.Stat(backlightControlClass); errors.Is(err, os.ErrNotExist) {
return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware")
}
// Set the value
bs := []byte(strconv.Itoa(brightness))
err := os.WriteFile(backlightControlClass, bs, 0644)
if err != nil {
return err
}
displayLogger.Info().Int("brightness", brightness).Msg("set brightness")
return nil
}
// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness
// of the display by half of the max brightness.
func tick_displayDim() {
err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to dim display")
}
dimTicker.Stop()
backlightState = 1
}
// tick_displayOff() is called when the off ticker expires, it turns off the display
// by setting the brightness to zero.
func tick_displayOff() {
err := setDisplayBrightness(0)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to turn off display")
}
offTicker.Stop()
backlightState = 2
}
// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display
// last woke, ready for displayTimeoutTick to put the display back in the dim/off states.
// Set force to true to skip the backlight state check, this should be done if altering the tickers.
func wakeDisplay(force bool) {
if backlightState == 0 && !force {
return
}
// Don't try to wake up if the display is turned off.
if config.DisplayMaxBrightness == 0 {
return
}
err := setDisplayBrightness(config.DisplayMaxBrightness)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to wake display")
}
if config.DisplayDimAfterSec != 0 {
dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
}
if config.DisplayOffAfterSec != 0 {
offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
}
backlightState = 0
}
// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the
// touchscreen interface still works even with LCD dimming/off.
// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight
// control should be hoisted up to jetkvm_native.
func watchTsEvents() {
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to open touchscreen device")
return
}
defer ts.Close()
// This buffer is set to 24 bytes as that's the normal size of events on /dev/input
// Reference: https://www.kernel.org/doc/Documentation/input/input.txt
// This could potentially be set higher, to require multiple events to wake the display.
buf := make([]byte, 24)
for {
_, err := ts.Read(buf)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to read from touchscreen device")
return
}
wakeDisplay(false)
}
}
// startBacklightTickers starts the two tickers for dimming and switching off the display
// if they're not already set. This is done separately to the init routine as the "never dim"
// option has the value set to zero, but time.NewTicker only accept positive values.
func startBacklightTickers() {
// Don't start the tickers if the display is switched off.
// Set the display to off if that's the case.
if config.DisplayMaxBrightness == 0 {
_ = setDisplayBrightness(0)
return
}
if dimTicker == nil && config.DisplayDimAfterSec != 0 {
displayLogger.Info().Msg("dim_ticker has started")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop()
go func() {
for { //nolint:staticcheck
select {
case <-dimTicker.C:
tick_displayDim()
}
}
}()
}
if offTicker == nil && config.DisplayOffAfterSec != 0 {
displayLogger.Info().Msg("off_ticker has started")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop()
go func() {
for { //nolint:staticcheck
select {
case <-offTicker.C:
tick_displayOff()
}
}
}()
}
}
func init() {
ensureConfigLoaded()
go func() {
waitCtrlClientConnected()
displayLogger.Info().Msg("setting initial display contents")
fmt.Println("setting initial display contents")
time.Sleep(500 * time.Millisecond)
updateStaticContents()
displayInited = true
displayLogger.Info().Msg("display inited")
startBacklightTickers()
wakeDisplay(true)
fmt.Println("display inited")
requestDisplayUpdate()
}()
go watchTsEvents()
}

View File

@ -2,6 +2,7 @@ package kvm
import (
"context"
"fmt"
"os"
"sync"
"syscall"
@ -37,7 +38,7 @@ func (f *WebRTCStreamFile) Getattr(ctx context.Context, fh fs.FileHandle, out *f
f.mu.Lock()
defer f.mu.Unlock()
out.Attr = f.Attr
out.Size = f.size
out.Attr.Size = f.size
return fs.OK
}
@ -103,7 +104,7 @@ func RunFuseServer() {
var err error
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
if err != nil {
logger.Warn().Err(err).Msg("failed to mount fuse")
fmt.Println("failed to mount fuse: %w", err)
}
fuseServer.Wait()
}

61
go.mod
View File

@ -1,59 +1,52 @@
module github.com/jetkvm/kvm
module kvm
go 1.23.0
go 1.21.0
toolchain go1.21.1
require (
github.com/Masterminds/semver/v3 v3.3.0
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/creack/pty v1.1.23
github.com/gin-contrib/logger v1.2.5
github.com/gin-gonic/gin v1.10.0
github.com/gin-gonic/gin v1.9.1
github.com/google/uuid v1.6.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1
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/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.21.0
github.com/prometheus/common v0.62.0
github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0
github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2
golang.org/x/crypto v0.36.0
golang.org/x/net v0.38.0
golang.org/x/crypto v0.28.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
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect
@ -68,16 +61,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.15.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

138
go.sum
View File

@ -2,40 +2,32 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
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/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
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/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM=
github.com/gin-contrib/logger v1.2.5/go.mod h1:/bj+vNMuA2xOEQ1aRHoJ1m9+uyaaXIAxQTvM2llsc6I=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@ -44,13 +36,12 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -58,33 +49,24 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
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/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.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
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/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
@ -94,10 +76,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
@ -132,24 +114,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/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.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/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -160,9 +132,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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@ -173,33 +144,34 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

12
hw.go
View File

@ -14,7 +14,7 @@ func extractSerialNumber() (string, error) {
return "", err
}
r, err := regexp.Compile(`Serial\s*:\s*(\S+)`)
r, err := regexp.Compile("Serial\\s*:\\s*(\\S+)")
if err != nil {
return "", fmt.Errorf("failed to compile regex: %w", err)
}
@ -27,7 +27,7 @@ func extractSerialNumber() (string, error) {
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")
if err != nil {
return nil, err
@ -42,7 +42,7 @@ func GetDeviceID() string {
deviceIDOnce.Do(func() {
serial, err := extractSerialNumber()
if err != nil {
logger.Warn().Msg("unknown serial number, the program likely not running on RV1106")
logger.Warn("unknown serial number, the program likely not running on RV1106")
deviceID = "unknown_device_id"
} else {
deviceID = serial
@ -54,7 +54,7 @@ func GetDeviceID() string {
func runWatchdog() {
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
if err != nil {
watchdogLogger.Warn().Err(err).Msg("unable to open /dev/watchdog, skipping watchdog reset")
logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
return
}
defer file.Close()
@ -65,13 +65,13 @@ func runWatchdog() {
case <-ticker.C:
_, err = file.Write([]byte{0})
if err != nil {
watchdogLogger.Warn().Err(err).Msg("error writing to /dev/watchdog, system may reboot")
logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
}
case <-appCtx.Done():
//disarm watchdog with magic value
_, err := file.Write([]byte("V"))
if err != nil {
watchdogLogger.Warn().Err(err).Msg("failed to disarm watchdog, system may reboot")
logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
}
return
}

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().Msg("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().Msg("no udc found, skipping USB stack init")
return nil
}
u.udc = udcs[0]
_, err := os.Stat(u.kvmGadgetPath)
if err == nil {
u.log.Info().Msg("usb gadget already exists")
}
if err := mountConfigFS(); err != nil {
u.log.Error().Err(err).Msg("failed to mount configfs, usb stack might not function properly")
}
if err := os.MkdirAll(u.configC1Path, 0755); err != nil {
u.log.Error().Err(err).Msg("failed to create config path")
}
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to start gadget")
}
return nil
}
func (u *UsbGadget) UpdateGadgetConfig() error {
u.configLock.Lock()
defer u.configLock.Unlock()
u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil {
u.log.Error().Err(err).Msg("failed to update gadget")
}
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.Trace().Msg("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.Trace().Str("key", key).Msg("disabling gadget config")
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Trace().Str("key", key).Msg("writing gadget config")
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Error().Err(err).Msg("failed to write UDC")
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Info().Err(err).Msg("failed to rebind usb")
}
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.Trace().Str("path", configPath).Msg("symlink does not exist")
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.Trace().Str("source", configPath).Str("target", gadgetItemPath).Msg("creating symlink")
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.Error().Err(err).Msg("failed to write to hidg0")
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.Error().Err(err).Msg("failed to write to hidg1")
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.Error().Err(err).Msg("failed to write to hidg2")
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.Info().Str("udc", u.udc).Msg("rebinding USB gadget to 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.Trace().Str("udc", u.udc).Str("path", path).Msg("writing UDC")
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.Trace().Err(err).Msg("failed to read usb state")
}
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/rs/zerolog"
)
// 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 *zerolog.Logger
}
const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel)
// NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *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 {
logger.Error().Err(err).Msg("failed to init USB gadget")
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.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
if len(oldContent) == len(content)+1 &&
bytes.Equal(oldContent[:len(content)], content) &&
oldContent[len(content)] == 10 {
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil
}
u.log.Trace().Str("path", filePath).Bytes("old", oldContent).Bytes("new", content).Msg("writing to as it has different content")
}
}
return os.WriteFile(filePath, content, permMode)
}

View File

@ -1,9 +0,0 @@
package websecure
import (
"os"
"github.com/rs/zerolog"
)
var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger()

View File

@ -1,191 +0,0 @@
package websecure
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"net"
"strings"
"time"
"github.com/rs/zerolog"
"golang.org/x/net/idna"
)
const selfSignerCAMagicName = "__ca__"
type SelfSigner struct {
store *CertStore
log *zerolog.Logger
caInfo pkix.Name
DefaultDomain string
DefaultOrg string
DefaultOU string
}
func NewSelfSigner(
store *CertStore,
log *zerolog.Logger,
defaultDomain,
defaultOrg,
defaultOU,
caName string,
) *SelfSigner {
return &SelfSigner{
store: store,
log: log,
DefaultDomain: defaultDomain,
DefaultOrg: defaultOrg,
DefaultOU: defaultOU,
caInfo: pkix.Name{
CommonName: caName,
Organization: []string{defaultOrg},
OrganizationalUnit: []string{defaultOU},
},
}
}
func (s *SelfSigner) getCA() *tls.Certificate {
return s.createSelfSignedCert(selfSignerCAMagicName)
}
func (s *SelfSigner) createSelfSignedCert(hostname string) *tls.Certificate {
if tlsCert := s.store.certificates[hostname]; tlsCert != nil {
return tlsCert
}
// check if hostname is the CA magic name
var ca *tls.Certificate
if hostname != selfSignerCAMagicName {
ca = s.getCA()
if ca == nil {
s.log.Error().Msg("Failed to get CA certificate")
return nil
}
}
s.log.Info().Str("hostname", hostname).Msg("Creating self-signed certificate")
// lock the store while creating the certificate (do not move upwards)
s.store.certLock.Lock()
defer s.store.certLock.Unlock()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate private key")
return nil
}
notBefore := time.Now()
notAfter := notBefore.AddDate(1, 0, 0)
serialNumber, err := generateSerialNumber()
if err != nil {
s.log.Error().Err(err).Msg("Failed to generate serial number")
return nil
}
dnsName := hostname
ip := net.ParseIP(hostname)
if ip != nil {
dnsName = s.DefaultDomain
}
// set up CSR
isCA := hostname == selfSignerCAMagicName
subject := pkix.Name{
CommonName: hostname,
Organization: []string{s.DefaultOrg},
OrganizationalUnit: []string{s.DefaultOU},
}
keyUsage := x509.KeyUsageDigitalSignature
extKeyUsage := []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
// check if hostname is the CA magic name, and if so, set the subject to the CA info
if isCA {
subject = s.caInfo
keyUsage |= x509.KeyUsageCertSign
extKeyUsage = append(extKeyUsage, x509.ExtKeyUsageClientAuth)
notAfter = notBefore.AddDate(10, 0, 0)
}
cert := x509.Certificate{
SerialNumber: serialNumber,
Subject: subject,
NotBefore: notBefore,
NotAfter: notAfter,
IsCA: isCA,
KeyUsage: keyUsage,
ExtKeyUsage: extKeyUsage,
BasicConstraintsValid: true,
}
// set up DNS names and IP addresses
if !isCA {
cert.DNSNames = []string{dnsName}
if ip != nil {
cert.IPAddresses = []net.IP{ip}
}
}
// set up parent certificate
parent := &cert
parentPriv := priv
if ca != nil {
parent, err = x509.ParseCertificate(ca.Certificate[0])
if err != nil {
s.log.Error().Err(err).Msg("Failed to parse parent certificate")
return nil
}
parentPriv = ca.PrivateKey.(*ecdsa.PrivateKey)
}
certBytes, err := x509.CreateCertificate(rand.Reader, &cert, parent, &priv.PublicKey, parentPriv)
if err != nil {
s.log.Error().Err(err).Msg("Failed to create certificate")
return nil
}
tlsCert := &tls.Certificate{
Certificate: [][]byte{certBytes},
PrivateKey: priv,
}
if ca != nil {
tlsCert.Certificate = append(tlsCert.Certificate, ca.Certificate...)
}
s.store.certificates[hostname] = tlsCert
s.store.saveCertificate(hostname)
return tlsCert
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *SelfSigner) GetCertificate(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
var hostname string
if info.ServerName != "" && info.ServerName != selfSignerCAMagicName {
hostname = info.ServerName
} else {
hostname = strings.Split(info.Conn.LocalAddr().String(), ":")[0]
}
s.log.Info().Str("hostname", hostname).Strs("supported_protos", info.SupportedProtos).Msg("TLS handshake")
// convert hostname to punycode
h, err := idna.Lookup.ToASCII(hostname)
if err != nil {
s.log.Warn().Str("hostname", hostname).Err(err).Str("remote_addr", info.Conn.RemoteAddr().String()).Msg("Hostname is not valid")
hostname = s.DefaultDomain
} else {
hostname = h
}
cert := s.createSelfSignedCert(hostname)
return cert, nil
}

View File

@ -1,175 +0,0 @@
package websecure
import (
"crypto/tls"
"fmt"
"os"
"path"
"strings"
"sync"
"github.com/rs/zerolog"
)
type CertStore struct {
certificates map[string]*tls.Certificate
certLock *sync.Mutex
storePath string
log *zerolog.Logger
}
func NewCertStore(storePath string, log *zerolog.Logger) *CertStore {
if log == nil {
log = &defaultLogger
}
return &CertStore{
certificates: make(map[string]*tls.Certificate),
certLock: &sync.Mutex{},
storePath: storePath,
log: log,
}
}
func (s *CertStore) ensureStorePath() error {
// check if directory exists
stat, err := os.Stat(s.storePath)
if err == nil {
if stat.IsDir() {
return nil
}
return fmt.Errorf("TLS store path exists but is not a directory: %s", s.storePath)
}
if os.IsNotExist(err) {
s.log.Trace().Str("path", s.storePath).Msg("TLS store directory does not exist, creating directory")
err = os.MkdirAll(s.storePath, 0755)
if err != nil {
return fmt.Errorf("failed to create TLS store path: %w", err)
}
return nil
}
return fmt.Errorf("failed to check TLS store path: %w", err)
}
func (s *CertStore) LoadCertificates() {
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
files, err := os.ReadDir(s.storePath)
if err != nil {
s.log.Error().Err(err).Msg("Failed to read TLS directory")
return
}
for _, file := range files {
if file.IsDir() {
continue
}
if strings.HasSuffix(file.Name(), ".crt") {
s.loadCertificate(strings.TrimSuffix(file.Name(), ".crt"))
}
}
}
func (s *CertStore) loadCertificate(hostname string) {
s.certLock.Lock()
defer s.certLock.Unlock()
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
cert, err := tls.LoadX509KeyPair(crtFile, keyFile)
if err != nil {
s.log.Error().Err(err).Str("hostname", hostname).Msg("Failed to load certificate")
return
}
s.certificates[hostname] = &cert
s.log.Info().Str("hostname", hostname).Msg("Loaded certificate")
}
// GetCertificate returns the certificate for the given hostname
// returns nil if the certificate is not found
func (s *CertStore) GetCertificate(hostname string) *tls.Certificate {
s.certLock.Lock()
defer s.certLock.Unlock()
return s.certificates[hostname]
}
// ValidateAndSaveCertificate validates the certificate and saves it to the store
// returns are:
// - error: if the certificate is invalid or if there's any error during saving the certificate
// - error: if there's any warning or error during saving the certificate
func (s *CertStore) ValidateAndSaveCertificate(hostname string, cert string, key string, ignoreWarning bool) (error, error) {
tlsCert, err := tls.X509KeyPair([]byte(cert), []byte(key))
if err != nil {
return fmt.Errorf("failed to parse certificate: %w", err), nil
}
// this can be skipped as current implementation supports one custom certificate only
if tlsCert.Leaf != nil {
// add recover to avoid panic
defer func() {
if r := recover(); r != nil {
s.log.Error().Interface("recovered", r).Msg("Failed to verify hostname")
}
}()
if err = tlsCert.Leaf.VerifyHostname(hostname); err != nil {
if !ignoreWarning {
return nil, fmt.Errorf("certificate does not match hostname: %w", err)
}
s.log.Warn().Err(err).Msg("Certificate does not match hostname")
}
}
s.certLock.Lock()
s.certificates[hostname] = &tlsCert
s.certLock.Unlock()
s.saveCertificate(hostname)
return nil, nil
}
func (s *CertStore) saveCertificate(hostname string) {
// check if certificate already exists
tlsCert := s.certificates[hostname]
if tlsCert == nil {
s.log.Error().Str("hostname", hostname).Msg("Certificate for hostname does not exist, skipping saving certificate")
return
}
err := s.ensureStorePath()
if err != nil {
s.log.Error().Err(err).Msg("Failed to ensure store path")
return
}
keyFile := path.Join(s.storePath, hostname+".key")
crtFile := path.Join(s.storePath, hostname+".crt")
if err := keyToFile(tlsCert, keyFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save key file")
return
}
if err := certToFile(tlsCert, crtFile); err != nil {
s.log.Error().Err(err).Msg("Failed to save certificate")
return
}
s.log.Info().Str("hostname", hostname).Msg("Saved certificate")
}

View File

@ -1,80 +0,0 @@
package websecure
import (
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"encoding/pem"
"fmt"
"math/big"
"os"
)
var serialNumberLimit = new(big.Int).Lsh(big.NewInt(1), 4096)
func withSecretFile(filename string, f func(*os.File) error) error {
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer file.Close()
return f(file)
}
func keyToFile(cert *tls.Certificate, filename string) error {
var keyBlock pem.Block
switch k := cert.PrivateKey.(type) {
case *rsa.PrivateKey:
keyBlock = pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(k),
}
case *ecdsa.PrivateKey:
b, e := x509.MarshalECPrivateKey(k)
if e != nil {
return fmt.Errorf("failed to marshal EC private key: %v", e)
}
keyBlock = pem.Block{
Type: "EC PRIVATE KEY",
Bytes: b,
}
default:
return fmt.Errorf("unknown private key type: %T", k)
}
err := withSecretFile(filename, func(file *os.File) error {
return pem.Encode(file, &keyBlock)
})
if err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}
return nil
}
func certToFile(cert *tls.Certificate, filename string) error {
return withSecretFile(filename, func(file *os.File) error {
for _, c := range cert.Certificate {
block := pem.Block{
Type: "CERTIFICATE",
Bytes: c,
}
err := pem.Encode(file, &block)
if err != nil {
return fmt.Errorf("failed to save certificate: %w", err)
}
}
return nil
})
}
func generateSerialNumber() (*big.Int, error) {
return rand.Int(rand.Reader, serialNumberLimit)
}

View File

@ -6,6 +6,10 @@ import (
var lastUserInput = time.Now()
func resetUserInputTime() {
lastUserInput = time.Now()
}
var jigglerEnabled = false
func rpcSetJigglerState(enabled bool) {
@ -16,8 +20,6 @@ func rpcGetJigglerState() bool {
}
func init() {
ensureConfigLoaded()
go runJiggler()
}
@ -28,11 +30,11 @@ func runJiggler() {
//TODO: change to rel mouse
err := rpcAbsMouseReport(1, 1, 0)
if err != nil {
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
logger.Warnf("Failed to jiggle mouse: %v", err)
}
err = rpcAbsMouseReport(0, 0, 0)
if err != nil {
logger.Warn().Err(err).Msg("Failed to reset mouse position")
logger.Warnf("Failed to reset mouse position: %v", err)
}
}
}

View File

@ -5,17 +5,13 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget"
)
type JSONRPCRequest struct {
@ -38,21 +34,15 @@ type JSONRPCEvent struct {
Params interface{} `json:"params,omitempty"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
OffAfter int `json:"off_after"`
}
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response)
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
log.Println("Error marshalling JSONRPC response:", err)
return
}
err = session.RPCChannel.SendText(string(responseBytes))
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error sending JSONRPC response")
log.Println("Error sending JSONRPC response:", err)
return
}
}
@ -65,24 +55,16 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
}
requestBytes, err := json.Marshal(request)
if err != nil {
jsonRpcLogger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
log.Println("Error marshalling JSONRPC event:", err)
return
}
if session == nil || session.RPCChannel == nil {
jsonRpcLogger.Info().Msg("RPC channel not available")
log.Println("RPC channel not available")
return
}
requestString := string(requestBytes)
scopedLogger := jsonRpcLogger.With().
Str("data", requestString).
Logger()
scopedLogger.Info().Msg("sending JSONRPC event")
err = session.RPCChannel.SendText(requestString)
err = session.RPCChannel.SendText(string(requestBytes))
if err != nil {
scopedLogger.Warn().Err(err).Msg("error sending JSONRPC event")
log.Println("Error sending JSONRPC event:", err)
return
}
}
@ -91,11 +73,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
var request JSONRPCRequest
err := json.Unmarshal(message.Data, &request)
if err != nil {
jsonRpcLogger.Warn().
Str("data", string(message.Data)).
Err(err).
Msg("Error unmarshalling JSONRPC request")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -108,13 +85,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger := jsonRpcLogger.With().
Str("method", request.Method).
Interface("params", request.Params).
Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request")
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
handler, ok := rpcHandlers[request.Method]
if !ok {
errorResponse := JSONRPCResponse{
@ -129,10 +100,8 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Msg("Calling RPC handler")
result, err := callRPCHandler(handler, request.Params)
if err != nil {
scopedLogger.Error().Err(err).Msg("Error calling RPC handler")
errorResponse := JSONRPCResponse{
JSONRPC: "2.0",
Error: map[string]interface{}{
@ -146,8 +115,6 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return
}
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
response := JSONRPCResponse{
JSONRPC: "2.0",
Result: result,
@ -176,42 +143,6 @@ func rpcSetKeyboardLayout(KeyboardLayout string) (string, error) {
return KeyboardLayout, nil
}
func rpcGetKeyboardMappingState() (bool, error) {
return config.KeyboardMappingEnabled, nil
}
func rpcSetKeyboardMappingState(enabled bool) (bool, error) {
config.KeyboardMappingEnabled = enabled
if err := SaveConfig(); err != nil {
return config.KeyboardMappingEnabled, fmt.Errorf("failed to save config: %w", err)
}
return enabled, nil
}
func rpcReboot(force bool) error {
logger.Info().Msg("Got reboot request from JSONRPC, rebooting...")
args := []string{}
if force {
args = append(args, "-f")
}
cmd := exec.Command("reboot", args...)
err := cmd.Start()
if err != nil {
logger.Error().Err(err).Msg("failed to reboot")
return fmt.Errorf("failed to reboot: %w", err)
}
// If the reboot command is successful, exit the program after 5 seconds
go func() {
time.Sleep(5 * time.Second)
os.Exit(0)
}()
return nil
}
var streamFactor = 1.0
func rpcGetStreamQualityFactor() (float64, error) {
@ -219,7 +150,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
}
func rpcSetStreamQualityFactor(factor float64) error {
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
log.Printf("Setting stream quality factor to: %f", factor)
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
if err != nil {
return err
@ -255,10 +186,10 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error {
if edid == "" {
logger.Info().Msg("Restoring EDID to default")
log.Println("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else {
logger.Info().Str("edid", edid).Msg("Setting EDID")
log.Printf("Setting EDID to: %s", edid)
}
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
if err != nil {
@ -266,8 +197,10 @@ func rpcSetEDID(edid string) error {
}
// Save EDID to config, allowing it to be restored on reboot.
LoadConfig()
config.EdidString = edid
_ = SaveConfig()
SaveConfig()
return nil
}
@ -298,58 +231,12 @@ func rpcTryUpdate() error {
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
logger.Warnf("failed to try update: %v", err)
}
}()
return nil
}
func rpcSetBacklightSettings(params BacklightSettings) error {
blConfig := params
// NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with.
if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 {
return fmt.Errorf("maxBrightness must be between 0 and 255")
}
if blConfig.DimAfter < 0 {
return fmt.Errorf("dimAfter must be a positive integer")
}
if blConfig.OffAfter < 0 {
return fmt.Errorf("offAfter must be a positive integer")
}
config.DisplayMaxBrightness = blConfig.MaxBrightness
config.DisplayDimAfterSec = blConfig.DimAfter
config.DisplayOffAfterSec = blConfig.OffAfter
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
logger.Info().Int("max_brightness", config.DisplayMaxBrightness).Int("dim_after", config.DisplayDimAfterSec).Int("off_after", config.DisplayOffAfterSec).Msg("rpc: display: settings applied")
// If the device started up with auto-dim and/or auto-off set to zero, the display init
// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
startBacklightTickers()
// Wake the display after the settings are altered, this ensures the tickers
// are reset to the new settings, and will bring the display up to maxBrightness.
// Calling with force set to true, to ignore the current state of the display, and force
// it to reset the tickers.
wakeDisplay(true)
return nil
}
func rpcGetBacklightSettings() (*BacklightSettings, error) {
return &BacklightSettings{
MaxBrightness: config.DisplayMaxBrightness,
DimAfter: int(config.DisplayDimAfterSec),
OffAfter: int(config.DisplayOffAfterSec),
}, nil
}
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
@ -389,7 +276,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to create devmode file: %w", err)
}
} else {
logger.Debug().Msg("dev mode already enabled")
logger.Debug("dev mode already enabled")
return nil
}
} else {
@ -398,7 +285,7 @@ func rpcSetDevModeState(enabled bool) error {
return fmt.Errorf("failed to remove devmode file: %w", err)
}
} else if os.IsNotExist(err) {
logger.Debug().Msg("dev mode already disabled")
logger.Debug("dev mode already disabled")
return nil
} else {
return fmt.Errorf("error checking dev mode file: %w", err)
@ -408,7 +295,7 @@ func rpcSetDevModeState(enabled bool) error {
cmd := exec.Command("dropbear.sh")
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
logger.Warnf("Failed to start/stop SSH: %v, %v", err, output)
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
}
@ -446,23 +333,6 @@ func rpcSetSSHKeyState(sshKey string) error {
return nil
}
func rpcGetTLSState() TLSState {
return getTLSState()
}
func rpcSetTLSState(state TLSState) error {
err := setTLSState(state)
if err != nil {
return fmt.Errorf("failed to set TLS state: %w", err)
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type()
@ -527,7 +397,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
}
args[i] = reflect.ValueOf(newStruct).Elem()
} else {
return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind())
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
}
} else {
args[i] = convertedValue.Convert(paramType)
@ -566,23 +436,23 @@ type RPCHandler struct {
}
func rpcSetMassStorageMode(mode string) (string, error) {
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
var cdrom bool
if mode == "cdrom" {
cdrom = true
} else if mode != "file" {
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
return "", fmt.Errorf("invalid mode: %s", mode)
}
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
err := setMassStorageMode(cdrom)
if err != nil {
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
}
logger.Info().Str("mode", mode).Msg("Mass storage mode set")
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
// Get the updated mode after setting
return rpcGetMassStorageMode()
@ -605,31 +475,29 @@ func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil
}
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
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 {
if enabled {
return gadget.BindUDC()
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
} 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) {
LoadConfig()
if config.WakeOnLanDevices == nil {
return []WakeOnLanDevice{}, nil
}
@ -641,417 +509,69 @@ type SetWakeOnLanDevicesParams struct {
}
func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
LoadConfig()
config.WakeOnLanDevices = params.Devices
return SaveConfig()
}
func rpcResetConfig() error {
LoadConfig()
config = defaultConfig
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to reset config: %w", err)
}
logger.Info().Msg("Configuration reset to default")
log.Println("Configuration reset to default")
return nil
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
return dcState, nil
}
func rpcSetDCPowerState(enabled bool) error {
logger.Info().Bool("enabled", enabled).Msg("Setting DC power state")
err := setDCPowerState(enabled)
if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
func rpcSetActiveExtension(extensionId string) error {
if config.ActiveExtension == extensionId {
return nil
}
switch config.ActiveExtension {
case "atx-power":
_ = unmountATXControl()
case "dc-power":
_ = unmountDCControl()
}
config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
switch extensionId {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
return nil
}
func rpcSetATXPowerAction(action string) error {
logger.Debug().Str("action", action).Msg("Executing ATX power action")
switch action {
case "power-short":
logger.Debug().Msg("Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug().Msg("Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug().Msg("Simulating reset button press")
return pressATXResetButton(200 * time.Millisecond)
default:
return fmt.Errorf("invalid action: %s", action)
}
}
type ATXState struct {
Power bool `json:"power"`
HDD bool `json:"hdd"`
}
func rpcGetATXState() (ATXState, error) {
state := ATXState{
Power: ledPWRState,
HDD: ledHDDState,
}
return state, nil
}
type SerialSettings struct {
BaudRate string `json:"baudRate"`
DataBits string `json:"dataBits"`
StopBits string `json:"stopBits"`
Parity string `json:"parity"`
}
func rpcGetSerialSettings() (SerialSettings, error) {
settings := SerialSettings{
BaudRate: strconv.Itoa(serialPortMode.BaudRate),
DataBits: strconv.Itoa(serialPortMode.DataBits),
StopBits: "1",
Parity: "none",
}
switch serialPortMode.StopBits {
case serial.OneStopBit:
settings.StopBits = "1"
case serial.OnePointFiveStopBits:
settings.StopBits = "1.5"
case serial.TwoStopBits:
settings.StopBits = "2"
}
switch serialPortMode.Parity {
case serial.NoParity:
settings.Parity = "none"
case serial.OddParity:
settings.Parity = "odd"
case serial.EvenParity:
settings.Parity = "even"
case serial.MarkParity:
settings.Parity = "mark"
case serial.SpaceParity:
settings.Parity = "space"
}
return settings, nil
}
var serialPortMode = defaultMode
func rpcSetSerialSettings(settings SerialSettings) error {
baudRate, err := strconv.Atoi(settings.BaudRate)
if err != nil {
return fmt.Errorf("invalid baud rate: %v", err)
}
dataBits, err := strconv.Atoi(settings.DataBits)
if err != nil {
return fmt.Errorf("invalid data bits: %v", err)
}
var stopBits serial.StopBits
switch settings.StopBits {
case "1":
stopBits = serial.OneStopBit
case "1.5":
stopBits = serial.OnePointFiveStopBits
case "2":
stopBits = serial.TwoStopBits
default:
return fmt.Errorf("invalid stop bits: %s", settings.StopBits)
}
var parity serial.Parity
switch settings.Parity {
case "none":
parity = serial.NoParity
case "odd":
parity = serial.OddParity
case "even":
parity = serial.EvenParity
case "mark":
parity = serial.MarkParity
case "space":
parity = serial.SpaceParity
default:
return fmt.Errorf("invalid parity: %s", settings.Parity)
}
serialPortMode = &serial.Mode{
BaudRate: baudRate,
DataBits: dataBits,
StopBits: stopBits,
Parity: parity,
}
_ = port.SetMode(serialPortMode)
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
}
func getKeyboardMacros() (interface{}, error) {
macros := make([]KeyboardMacro, len(config.KeyboardMacros))
copy(macros, config.KeyboardMacros)
return macros, nil
}
type KeyboardMacrosParams struct {
Macros []interface{} `json:"macros"`
}
func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
if params.Macros == nil {
return nil, fmt.Errorf("missing or invalid macros parameter")
}
newMacros := make([]KeyboardMacro, 0, len(params.Macros))
for i, item := range params.Macros {
macroMap, ok := item.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("invalid macro at index %d", i)
}
id, _ := macroMap["id"].(string)
if id == "" {
id = fmt.Sprintf("macro-%d", time.Now().UnixNano())
}
name, _ := macroMap["name"].(string)
sortOrder := i + 1
if sortOrderFloat, ok := macroMap["sortOrder"].(float64); ok {
sortOrder = int(sortOrderFloat)
}
steps := []KeyboardMacroStep{}
if stepsArray, ok := macroMap["steps"].([]interface{}); ok {
for _, stepItem := range stepsArray {
stepMap, ok := stepItem.(map[string]interface{})
if !ok {
continue
}
step := KeyboardMacroStep{}
if keysArray, ok := stepMap["keys"].([]interface{}); ok {
for _, k := range keysArray {
if keyStr, ok := k.(string); ok {
step.Keys = append(step.Keys, keyStr)
}
}
}
if modsArray, ok := stepMap["modifiers"].([]interface{}); ok {
for _, m := range modsArray {
if modStr, ok := m.(string); ok {
step.Modifiers = append(step.Modifiers, modStr)
}
}
}
if delay, ok := stepMap["delay"].(float64); ok {
step.Delay = int(delay)
}
steps = append(steps, step)
}
}
macro := KeyboardMacro{
ID: id,
Name: name,
Steps: steps,
SortOrder: sortOrder,
}
if err := macro.Validate(); err != nil {
return nil, fmt.Errorf("invalid macro at index %d: %w", i, err)
}
newMacros = append(newMacros, macro)
}
config.KeyboardMacros = newMacros
if err := SaveConfig(); err != nil {
return nil, err
}
return nil, nil
}
// TODO: replace this crap with code generator
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"setKeyboardMappingState": {Func: rpcSetKeyboardMappingState, Params: []string{"enabled"}},
"getKeyboardMappingState": {Func: rpcGetKeyboardMappingState},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"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"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"ping": {Func: rpcPing},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"kbLayout"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
}

293
log.go
View File

@ -1,291 +1,8 @@
package kvm
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
import "github.com/pion/logging"
"github.com/pion/logging"
"github.com/rs/zerolog"
)
type Logger struct {
l *zerolog.Logger
scopeLoggers map[string]*zerolog.Logger
scopeLevels map[string]zerolog.Level
scopeLevelMutex sync.Mutex
defaultLogLevelFromEnv zerolog.Level
defaultLogLevelFromConfig zerolog.Level
defaultLogLevel zerolog.Level
}
const (
defaultLogLevel = zerolog.ErrorLevel
)
type logOutput struct {
mu *sync.Mutex
}
func (w *logOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
// TODO: write to file or syslog
return len(p), nil
}
var (
consoleLogOutput io.Writer = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
PartsOrder: []string{"time", "level", "scope", "component", "message"},
FieldsExclude: []string{"scope", "component"},
FormatPartValueByName: func(value interface{}, name string) string {
val := fmt.Sprintf("%s", value)
if name == "component" {
if value == nil {
return "-"
}
}
return val
},
}
fileLogOutput io.Writer = &logOutput{mu: &sync.Mutex{}}
defaultLogOutput = zerolog.MultiLevelWriter(consoleLogOutput, fileLogOutput)
zerologLevels = map[string]zerolog.Level{
"DISABLE": zerolog.Disabled,
"NOLEVEL": zerolog.NoLevel,
"PANIC": zerolog.PanicLevel,
"FATAL": zerolog.FatalLevel,
"ERROR": zerolog.ErrorLevel,
"WARN": zerolog.WarnLevel,
"INFO": zerolog.InfoLevel,
"DEBUG": zerolog.DebugLevel,
"TRACE": zerolog.TraceLevel,
}
rootZerologLogger = zerolog.New(defaultLogOutput).With().
Str("scope", "jetkvm").
Timestamp().
Stack().
Logger()
rootLogger = NewLogger(rootZerologLogger)
)
func NewLogger(zerologLogger zerolog.Logger) *Logger {
return &Logger{
l: &zerologLogger,
scopeLoggers: make(map[string]*zerolog.Logger),
scopeLevels: make(map[string]zerolog.Level),
scopeLevelMutex: sync.Mutex{},
defaultLogLevelFromEnv: -2,
defaultLogLevelFromConfig: -2,
defaultLogLevel: defaultLogLevel,
}
}
func (l *Logger) updateLogLevel() {
l.scopeLevelMutex.Lock()
defer l.scopeLevelMutex.Unlock()
l.scopeLevels = make(map[string]zerolog.Level)
finalDefaultLogLevel := l.defaultLogLevel
for name, level := range zerologLevels {
env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name))
if env == "" {
env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name))
}
if env == "" {
env = os.Getenv(fmt.Sprintf("PIONS_LOG_%s", name))
}
if env == "" {
continue
}
if strings.ToLower(env) == "all" {
l.defaultLogLevelFromEnv = level
if finalDefaultLogLevel > level {
finalDefaultLogLevel = level
}
continue
}
scopes := strings.Split(strings.ToLower(env), ",")
for _, scope := range scopes {
l.scopeLevels[scope] = level
}
}
l.defaultLogLevel = finalDefaultLogLevel
}
func (l *Logger) getScopeLoggerLevel(scope string) zerolog.Level {
if l.scopeLevels == nil {
l.updateLogLevel()
}
var scopeLevel zerolog.Level
if l.defaultLogLevelFromConfig != -2 {
scopeLevel = l.defaultLogLevelFromConfig
}
if l.defaultLogLevelFromEnv != -2 {
scopeLevel = l.defaultLogLevelFromEnv
}
// if the scope is not in the map, use the default level from the root logger
if level, ok := l.scopeLevels[scope]; ok {
scopeLevel = level
}
return scopeLevel
}
func (l *Logger) newScopeLogger(scope string) zerolog.Logger {
scopeLevel := l.getScopeLoggerLevel(scope)
logger := l.l.Level(scopeLevel).With().Str("component", scope).Logger()
return logger
}
func (l *Logger) getLogger(scope string) *zerolog.Logger {
logger, ok := l.scopeLoggers[scope]
if !ok || logger == nil {
scopeLogger := l.newScopeLogger(scope)
l.scopeLoggers[scope] = &scopeLogger
}
return l.scopeLoggers[scope]
}
func (l *Logger) UpdateLogLevel() {
needUpdate := false
if config != nil && config.DefaultLogLevel != "" {
if logLevel, ok := zerologLevels[config.DefaultLogLevel]; ok {
l.defaultLogLevelFromConfig = logLevel
} else {
l.l.Warn().Str("logLevel", config.DefaultLogLevel).Msg("invalid defaultLogLevel from config, using ERROR")
}
if l.defaultLogLevelFromConfig != l.defaultLogLevel {
needUpdate = true
}
}
l.updateLogLevel()
if needUpdate {
for scope, logger := range l.scopeLoggers {
currentLevel := logger.GetLevel()
targetLevel := l.getScopeLoggerLevel(scope)
if currentLevel != targetLevel {
*logger = l.newScopeLogger(scope)
}
}
}
}
func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) error {
if l == nil {
l = rootLogger.getLogger("jetkvm")
}
l.Error().Err(err).Msgf(format, args...)
if err == nil {
return fmt.Errorf(format, args...)
}
err_msg := err.Error() + ": %v"
err_args := append(args, err)
return fmt.Errorf(err_msg, err_args...)
}
var (
logger = rootLogger.getLogger("jetkvm")
cloudLogger = rootLogger.getLogger("cloud")
websocketLogger = rootLogger.getLogger("websocket")
webrtcLogger = rootLogger.getLogger("webrtc")
nativeLogger = rootLogger.getLogger("native")
nbdLogger = rootLogger.getLogger("nbd")
ntpLogger = rootLogger.getLogger("ntp")
jsonRpcLogger = rootLogger.getLogger("jsonrpc")
watchdogLogger = rootLogger.getLogger("watchdog")
websecureLogger = rootLogger.getLogger("websecure")
otaLogger = rootLogger.getLogger("ota")
serialLogger = rootLogger.getLogger("serial")
terminalLogger = rootLogger.getLogger("terminal")
displayLogger = rootLogger.getLogger("display")
wolLogger = rootLogger.getLogger("wol")
usbLogger = rootLogger.getLogger("usb")
// external components
ginLogger = rootLogger.getLogger("gin")
)
type pionLogger struct {
logger *zerolog.Logger
}
// Print all messages except trace.
func (c pionLogger) Trace(msg string) {
c.logger.Trace().Msg(msg)
}
func (c pionLogger) Tracef(format string, args ...interface{}) {
c.logger.Trace().Msgf(format, args...)
}
func (c pionLogger) Debug(msg string) {
c.logger.Debug().Msg(msg)
}
func (c pionLogger) Debugf(format string, args ...interface{}) {
c.logger.Debug().Msgf(format, args...)
}
func (c pionLogger) Info(msg string) {
c.logger.Info().Msg(msg)
}
func (c pionLogger) Infof(format string, args ...interface{}) {
c.logger.Info().Msgf(format, args...)
}
func (c pionLogger) Warn(msg string) {
c.logger.Warn().Msg(msg)
}
func (c pionLogger) Warnf(format string, args ...interface{}) {
c.logger.Warn().Msgf(format, args...)
}
func (c pionLogger) Error(msg string) {
c.logger.Error().Msg(msg)
}
func (c pionLogger) Errorf(format string, args ...interface{}) {
c.logger.Error().Msgf(format, args...)
}
// customLoggerFactory satisfies the interface logging.LoggerFactory
// This allows us to create different loggers per subsystem. So we can
// add custom behavior.
type pionLoggerFactory struct{}
func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
logger := rootLogger.getLogger(subsystem).With().
Str("scope", "pion").
Str("component", subsystem).
Logger()
return pionLogger{logger: &logger}
}
var defaultLoggerFactory = &pionLoggerFactory{}
// we use logging framework from pion
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")

47
main.go
View File

@ -2,6 +2,7 @@ package kvm
import (
"context"
"log"
"net/http"
"os"
"os/signal"
@ -14,88 +15,74 @@ import (
var appCtx context.Context
func Main() {
LoadConfig()
logger.Debug().Msg("config loaded")
var cancel context.CancelFunc
appCtx, cancel = context.WithCancel(context.Background())
defer cancel()
logger.Info().Msg("starting JetKvm")
logger.Info("Starting JetKvm")
go runWatchdog()
go confirmCurrentSystem()
http.DefaultClient.Timeout = 1 * time.Minute
LoadConfig()
logger.Debug("config loaded")
err := rootcerts.UpdateDefaultTransport()
if err != nil {
logger.Warn().Err(err).Msg("failed to load CA certs")
logger.Errorf("failed to load CA certs: %v", err)
}
initNetwork()
go TimeSyncLoop()
StartNativeCtrlSocketServer()
StartNativeVideoSocketServer()
initPrometheus()
go func() {
err = ExtractAndRunNativeBin()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract and run native bin")
logger.Errorf("failed to extract and run native bin: %v", err)
//TODO: prepare an error message screen buffer to show on kvm screen
}
}()
initUsbGadget()
go func() {
time.Sleep(15 * time.Minute)
for {
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
if config.AutoUpdateEnabled == false {
return
}
if currentSession != nil {
logger.Debug().Msg("skipping update since a session is active")
logger.Debugf("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to auto update")
logger.Errorf("failed to auto update: %v", err)
}
time.Sleep(1 * time.Hour)
}
}()
//go RunFuseServer()
go RunWebServer()
go RunWebSecureServer()
// Web secure server is started only if TLS mode is enabled
if config.TLSMode != "" {
startWebSecureServer()
// If the cloud token isn't set, the client won't be started by default.
// However, if the user adopts the device via the web interface, handleCloudRegister will start the client.
if config.CloudToken != "" {
go RunWebsocketClient()
}
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info().Msg("JetKVM Shutting Down")
log.Println("JetKVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// 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()
// if err != nil {
// logger.Infof("Failed to unmount fuse: %v", err)
// log.Printf("Failed to unmount fuse: %v", err)
// }
// os.Exit(0)

125
native.go
View File

@ -3,9 +3,10 @@ package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"kvm/resource"
"log"
"net"
"os"
"os/exec"
@ -13,9 +14,6 @@ import (
"syscall"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/rs/zerolog"
"github.com/pion/webrtc/v4/pkg/media"
)
@ -36,19 +34,6 @@ type CtrlResponse struct {
Data json.RawMessage `json:"data,omitempty"`
}
type nativeOutput struct {
mu *sync.Mutex
logger *zerolog.Event
}
func (w *nativeOutput) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
w.logger.Msg(string(p))
return len(p), nil
}
type EventHandler func(event CtrlResponse)
var seq int32 = 1
@ -76,33 +61,25 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := nativeLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending ctrl action")
fmt.Println("sending ctrl action", string(jsonData))
err = WriteCtrlMessage(jsonData)
if err != nil {
delete(ongoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing ctrl message", err)
return nil, fmt.Errorf("error writing ctrl message: %w", err)
}
select {
case response := <-responseChan:
delete(ongoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error native response: %s",
errors.New(response.Error),
)
return nil, fmt.Errorf("error native response: %s", response.Error)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(ongoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
return nil, fmt.Errorf("timeout waiting for response")
}
}
@ -114,8 +91,8 @@ func WriteCtrlMessage(message []byte) error {
return err
}
var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener //nolint:unused
var nativeCtrlSocketListener net.Listener
var nativeVideoSocketListener net.Listener
var ctrlClientConnected = make(chan struct{})
@ -124,35 +101,29 @@ func waitCtrlClientConnected() {
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
log.Fatalf("Failed to remove existing socket file %s: %v", socketPath, err)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
log.Fatalf("Failed to start server on %s: %v", socketPath, err)
}
scopedLogger.Info().Msg("server listening")
log.Printf("Server listening on %s", socketPath)
go func() {
conn, err := listener.Accept()
listener.Close()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
logger.Errorf("failed to accept sock: %v", err)
}
if isCtrl {
close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected")
logger.Debug("first native ctrl socket client connected")
}
handleClient(conn)
}()
@ -162,25 +133,20 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
nativeLogger.Debug().Msg("native app ctrl sock started")
logger.Debug("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
nativeLogger.Debug().Msg("native app video sock started")
logger.Debug("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
scopedLogger.Info().Msg("native ctrl socket client connected")
logger.Debug("native socket client connected")
if ctrlSocketConn != nil {
scopedLogger.Debug().Msg("closing existing native socket connection")
logger.Debugf("closing existing native socket connection")
ctrlSocketConn.Close()
}
@ -193,19 +159,17 @@ func handleCtrlClient(conn net.Conn) {
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from ctrl sock")
logger.Errorf("error reading from ctrl sock: %v", err)
break
}
readMsg := string(readBuf[:n])
logger.Tracef("ctrl sock msg: %v", readMsg)
ctrlResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
logger.Warnf("error parsing ctrl sock msg: %v", err)
continue
}
scopedLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
if ctrlResp.Seq != 0 {
responseChan, ok := ongoingRequests[ctrlResp.Seq]
if ok {
@ -218,34 +182,30 @@ func handleCtrlClient(conn net.Conn) {
}
}
scopedLogger.Debug().Msg("ctrl sock disconnected")
logger.Debug("ctrl sock disconnected")
}
func handleVideoClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
scopedLogger.Info().Msg("native video socket client connected")
log.Printf("Native video socket client connected: %v", conn.RemoteAddr())
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error during read")
log.Println("error during read: %s", err)
return
}
now := time.Now()
sinceLastFrame := now.Sub(lastFrame)
lastFrame = now
//fmt.Println("Video packet received", n, sinceLastFrame)
if currentSession != nil {
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
if err != nil {
scopedLogger.Warn().Err(err).Msg("error writing sample")
log.Println("Error writing sample", err)
}
}
}
@ -264,19 +224,9 @@ func ExtractAndRunNativeBin() error {
// Run the binary in the background
cmd := exec.Command(binaryPath)
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
@ -292,28 +242,28 @@ func ExtractAndRunNativeBin() error {
//TODO: add auto restart
go func() {
<-appCtx.Done()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
logger.Infof("killing process PID: %d", cmd.Process.Pid)
err := cmd.Process.Kill()
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to kill process")
logger.Errorf("failed to kill process: %v", err)
return
}
}()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("jetkvm_native binary started")
fmt.Printf("Binary started with PID: %d\n", cmd.Process.Pid)
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
@ -329,13 +279,13 @@ func ensureBinaryUpdated(destPath string) error {
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
nativeLogger.Info().Msg("writing jetkvm_native")
logger.Info("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
@ -352,7 +302,7 @@ func ensureBinaryUpdated(destPath string) error {
return err
}
}
nativeLogger.Info().Msg("jetkvm_native updated")
logger.Info("jetkvm_native updated")
}
return nil
@ -361,11 +311,12 @@ func ensureBinaryUpdated(destPath string) error {
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
LoadConfig()
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
logger.Infof("Restoring HDMI EDID to %v", config.EdidString)
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
logger.Errorf("Failed to restore HDMI EDID: %v", err)
}
}
}

View File

@ -1,19 +1,13 @@
package kvm
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"os/exec"
"github.com/hashicorp/go-envparse"
"github.com/pion/mdns/v2"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"net"
"os/exec"
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
@ -21,15 +15,11 @@ import (
var mDNSConn *mdns.Conn
var networkState NetworkState
type NetworkState struct {
var networkState struct {
Up bool
IPv4 string
IPv6 string
MAC string
checked bool
}
type LocalIpInfo struct {
@ -38,45 +28,43 @@ type LocalIpInfo struct {
MAC string
}
const (
NetIfName = "eth0"
DHCPLeaseFile = "/run/udhcpc.%s.info"
)
// setDhcpClientState sends signals to udhcpc to change it's current mode
// of operation. Setting active to true will force udhcpc to renew the DHCP lease.
// Setting active to false will put udhcpc into idle mode.
func setDhcpClientState(active bool) {
var signal string
var signal string;
if active {
signal = "-SIGUSR1"
} else {
signal = "-SIGUSR2"
}
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc");
if err := cmd.Run(); err != nil {
logger.Warn().Err(err).Msg("network: setDhcpClientState: failed to change udhcpc state")
fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err)
}
}
func checkNetworkState() {
iface, err := netlink.LinkByName(NetIfName)
iface, err := netlink.LinkByName("eth0")
if err != nil {
logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get interface")
fmt.Printf("failed to get eth0 interface: %v\n", err)
return
}
newState := NetworkState{
newState := struct {
Up bool
IPv4 string
IPv6 string
MAC string
}{
Up: iface.Attrs().OperState == netlink.OperUp,
MAC: iface.Attrs().HardwareAddr.String(),
checked: true,
}
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
if err != nil {
logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get addresses")
fmt.Printf("failed to get addresses for eth0: %v\n", err)
}
// If the link is going down, put udhcpc into idle mode.
@ -89,10 +77,10 @@ func checkNetworkState() {
if addr.IP.To4() != nil {
if !newState.Up && networkState.Up {
// If the network is going down, remove all IPv4 addresses from the interface.
logger.Info().Str("address", addr.IP.String()).Msg("network: state transitioned to down, removing IPv4 address")
fmt.Printf("network: state transitioned to down, removing IPv4 address %s\n", addr.IP.String())
err := netlink.AddrDel(iface, &addr)
if err != nil {
logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("network: failed to delete address")
fmt.Printf("network: failed to delete %s", addr.IP.String())
}
newState.IPv4 = "..."
@ -105,34 +93,26 @@ func checkNetworkState() {
}
if newState != networkState {
logger.Info().
Interface("newState", newState).
Interface("oldState", networkState).
Msg("network state changed")
// restart MDNS
_ = startMDNS()
fmt.Println("network state changed")
//restart MDNS
startMDNS()
networkState = newState
requestDisplayUpdate()
}
}
func startMDNS() error {
// If server was previously running, stop it
//If server was previously running, stop it
if mDNSConn != nil {
logger.Info().Msg("stopping mDNS server")
fmt.Printf("Stopping mDNS server\n")
err := mDNSConn.Close()
if err != nil {
logger.Warn().Err(err).Msg("failed to stop mDNS server")
fmt.Printf("failed to stop mDNS server: %v\n", err)
}
}
// Start a new server
hostname := "jetkvm.local"
scopedLogger := logger.With().Str("hostname", hostname).Logger()
scopedLogger.Info().Msg("starting mDNS server")
//Start a new server
fmt.Printf("Starting mDNS server on jetkvm.local\n")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil {
return err
@ -154,11 +134,9 @@ func startMDNS() error {
}
mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
LocalNames: []string{hostname}, //TODO: make it configurable
LoggerFactory: defaultLoggerFactory,
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
})
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start mDNS server")
mDNSConn = nil
return err
}
@ -166,47 +144,12 @@ func startMDNS() error {
return nil
}
func getNTPServersFromDHCPInfo() ([]string, error) {
buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName))
if err != nil {
// do not return error if file does not exist
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to load udhcpc info: %w", err)
}
// parse udhcpc info
env, err := envparse.Parse(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("failed to parse udhcpc info: %w", err)
}
val, ok := env["ntpsrv"]
if !ok {
return nil, nil
}
var servers []string
for _, server := range strings.Fields(val) {
if net.ParseIP(server) == nil {
logger.Info().Str("server", server).Msg("invalid NTP server IP, ignoring")
}
servers = append(servers, server)
}
return servers, nil
}
func initNetwork() {
ensureConfigLoaded()
func init() {
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(updates, done); err != nil {
logger.Warn().Err(err).Msg("failed to subscribe to link updates")
fmt.Println("failed to subscribe to link updates: %v", err)
return
}
@ -219,8 +162,8 @@ func initNetwork() {
for {
select {
case update := <-updates:
if update.Link.Attrs().Name == NetIfName {
logger.Info().Interface("update", update).Msg("link update")
if update.Link.Attrs().Name == "eth0" {
fmt.Printf("link update: %+v\n", update)
checkNetworkState()
}
case <-ticker.C:
@ -232,6 +175,6 @@ func initNetwork() {
}()
err := startMDNS()
if err != nil {
logger.Warn().Err(err).Msg("failed to run mDNS")
fmt.Println("failed to run mDNS: %v", err)
}
}

127
ntp.go
View File

@ -1,97 +1,30 @@
package kvm
import (
"errors"
"fmt"
"log"
"net/http"
"os/exec"
"strconv"
"time"
"github.com/beevik/ntp"
)
const (
timeSyncRetryStep = 5 * time.Second
timeSyncRetryMaxInt = 1 * time.Minute
timeSyncWaitNetChkInt = 100 * time.Millisecond
timeSyncWaitNetUpInt = 3 * time.Second
timeSyncInterval = 1 * time.Hour
timeSyncTimeout = 2 * time.Second
)
var (
builtTimestamp string
timeSyncRetryInterval = 0 * time.Second
timeSyncSuccess = false
defaultNTPServers = []string{
"time.cloudflare.com",
"time.apple.com",
}
)
func isTimeSyncNeeded() bool {
if builtTimestamp == "" {
ntpLogger.Warn().Msg("Built timestamp is not set, time sync is needed")
return true
}
ts, err := strconv.Atoi(builtTimestamp)
if err != nil {
ntpLogger.Warn().Str("error", err.Error()).Msg("Failed to parse built timestamp")
return true
}
// builtTimestamp is UNIX timestamp in seconds
builtTime := time.Unix(int64(ts), 0)
now := time.Now()
ntpLogger.Debug().Str("built_time", builtTime.Format(time.RFC3339)).Str("now", now.Format(time.RFC3339)).Msg("Built time and now")
if now.Sub(builtTime) < 0 {
ntpLogger.Warn().Msg("System time is behind the built time, time sync is needed")
return true
}
return false
}
var timeSynced = false
func TimeSyncLoop() {
for {
if !networkState.checked {
time.Sleep(timeSyncWaitNetChkInt)
continue
}
if !networkState.Up {
ntpLogger.Info().Msg("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt)
continue
}
// check if time sync is needed, but do nothing for now
isTimeSyncNeeded()
ntpLogger.Info().Msg("Syncing system time")
fmt.Println("Syncing system time")
start := time.Now()
err := SyncSystemTime()
if err != nil {
ntpLogger.Error().Str("error", err.Error()).Msg("Failed to sync system time")
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
log.Printf("Failed to sync system time: %v", err)
continue
}
timeSyncSuccess = true
ntpLogger.Info().Str("now", time.Now().Format(time.RFC3339)).
Str("time_taken", time.Since(start).String()).
Msg("Time sync successful")
time.Sleep(timeSyncInterval) // after the first sync is done
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
timeSynced = true
time.Sleep(1 * time.Hour) //once the first sync is done, sync every hour
}
}
@ -108,59 +41,27 @@ func SyncSystemTime() (err error) {
}
func queryNetworkTime() (*time.Time, error) {
ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil {
ntpLogger.Info().Err(err).Msg("failed to get NTP servers from DHCP info")
ntpServers := []string{
"time.cloudflare.com",
"time.apple.com",
}
if ntpServers == nil {
ntpServers = defaultNTPServers
ntpLogger.Info().
Interface("ntp_servers", ntpServers).
Msg("Using default NTP servers")
} else {
ntpLogger.Info().
Interface("ntp_servers", ntpServers).
Msg("Using NTP servers from DHCP")
}
for _, server := range ntpServers {
now, err := queryNtpServer(server, timeSyncTimeout)
now, err := queryNtpServer(server, 2*time.Second)
if err == nil {
ntpLogger.Info().
Str("ntp_server", server).
Str("time", now.Format(time.RFC3339)).
Msg("NTP server returned time")
return now, nil
} else {
ntpLogger.Error().
Str("ntp_server", server).
Str("error", err.Error()).
Msg("failed to query NTP server")
}
}
httpUrls := []string{
"http://apple.com",
"http://cloudflare.com",
}
for _, url := range httpUrls {
now, err := queryHttpTime(url, timeSyncTimeout)
now, err := queryHttpTime(url, 2*time.Second)
if err == nil {
ntpLogger.Info().
Str("http_url", url).
Str("time", now.Format(time.RFC3339)).
Msg("HTTP server returned time")
return now, nil
} else {
ntpLogger.Error().
Str("http_url", url).
Str("error", err.Error()).
Msg("failed to query HTTP server")
}
}
return nil, ErrorfL(ntpLogger, "failed to query network time, all NTP servers and HTTP servers failed", nil)
return nil, errors.New("failed to query network time")
}
func queryNtpServer(server string, timeout time.Duration) (now *time.Time, err error) {

82
ota.go
View File

@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@ -16,7 +17,6 @@ import (
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
)
type UpdateMetadata struct {
@ -77,7 +77,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateUrl.RawQuery = query.Encode()
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
fmt.Println("Checking for updates at:", updateUrl.String())
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil {
@ -127,13 +127,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error creating request: %w", err)
}
// TODO: set a separate timeout for the download but keep the TLS handshake short
// 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)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
@ -192,11 +186,7 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return nil
}
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil {
scopedLogger = otaLogger
}
func verifyFile(path string, expectedHash string, verifyProgress *float32) error {
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
@ -240,7 +230,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32, scope
}
hashSum := hash.Sum(nil)
scopedLogger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
fmt.Printf("SHA256 hash of %s: %x\n", path, hashSum)
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
@ -282,7 +272,7 @@ var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
logger.Info().Msg("No active RPC session, skipping update state update")
log.Println("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
@ -290,12 +280,7 @@ func triggerOTAStateUpdate() {
}
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger()
scopedLogger.Info().Msg("Trying to update...")
log.Println("Trying to update...")
if otaState.Updating {
return fmt.Errorf("update already in progress")
}
@ -313,7 +298,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
if err != nil {
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
scopedLogger.Error().Err(err).Msg("Error checking for updates")
return fmt.Errorf("error checking for updates: %w", err)
}
@ -331,15 +315,11 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
rebootNeeded := false
if appUpdateAvailable {
scopedLogger.Info().
Str("local", local.AppVersion).
Str("remote", remote.AppVersion).
Msg("App update available")
fmt.Printf("App update available: %s -> %s\n", local.AppVersion, remote.AppVersion)
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return err
}
@ -348,15 +328,9 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile(
"/userdata/jetkvm/jetkvm_app.update",
remote.AppHash,
&otaState.AppVerificationProgress,
&scopedLogger,
)
err = verifyFile("/userdata/jetkvm/jetkvm_app.update", remote.AppHash, &otaState.AppVerificationProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return err
}
@ -367,22 +341,17 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("App update downloaded")
fmt.Println("App update downloaded")
rebootNeeded = true
} else {
scopedLogger.Info().Msg("App is up to date")
fmt.Println("App is up to date")
}
if systemUpdateAvailable {
scopedLogger.Info().
Str("local", local.SystemVersion).
Str("remote", remote.SystemVersion).
Msg("System update available")
fmt.Printf("System update available: %s -> %s\n", local.SystemVersion, remote.SystemVersion)
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return err
}
@ -391,25 +360,18 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
otaState.SystemDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile(
"/userdata/jetkvm/update_system.tar",
remote.SystemHash,
&otaState.SystemVerificationProgress,
&scopedLogger,
)
err = verifyFile("/userdata/jetkvm/update_system.tar", remote.SystemHash, &otaState.SystemVerificationProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return err
}
scopedLogger.Info().Msg("System update downloaded")
fmt.Println("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
@ -417,7 +379,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
err = cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
@ -449,30 +410,25 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
output := b.String()
if err != nil {
otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output)
scopedLogger.Error().
Err(err).
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
fmt.Printf("rk_ota success, output: %s\n", output)
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
triggerOTAStateUpdate()
rebootNeeded = true
} else {
scopedLogger.Info().Msg("System is up to date")
fmt.Println("System is up to date")
}
if rebootNeeded {
scopedLogger.Info().Msg("System Rebooting in 10s")
fmt.Println("System Rebooting in 10s...")
time.Sleep(10 * time.Second)
cmd := exec.Command("reboot")
err := cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Failed to start reboot: %v", err)
scopedLogger.Error().Err(err).Msg("Failed to start reboot")
return fmt.Errorf("failed to start reboot: %w", err)
} else {
os.Exit(0)
@ -542,6 +498,6 @@ func IsUpdatePending() bool {
func confirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
if err != nil {
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
}
}

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
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
git reset --soft public/main
else
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
git reset --soft $(git rev-list --max-parents=0 HEAD)
fi
# Merge changes from main

View File

@ -44,12 +44,12 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
return nil, errors.New("not active session")
}
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
logger.Debugf("reading from webrtc %v", string(jsonBytes))
err = currentSession.DiskChannel.SendText(string(jsonBytes))
if err != nil {
return nil, err
}
var buf []byte
buf := make([]byte, 0)
for {
select {
case data := <-diskReadChan:

287
serial.go
View File

@ -1,287 +0,0 @@
package kvm
import (
"bufio"
"io"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
)
const serialPortPath = "/dev/ttyS3"
var port serial.Port
func mountATXControl() error {
_ = port.SetMode(defaultMode)
go runATXControl()
return nil
}
func unmountATXControl() error {
_ = reopenSerialPort()
return nil
}
var (
ledHDDState bool
ledPWRState bool
btnRSTState bool
btnPWRState bool
)
func runATXControl() {
scopedLogger := serialLogger.With().Str("service", "atx_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
scopedLogger.Warn().Int("length", len(line)).Msg("Invalid line length")
continue
}
// Parse new states
newLedHDDState := line[0] == '0'
newLedPWRState := line[1] == '0'
newBtnRSTState := line[2] == '1'
newBtnPWRState := line[3] == '1'
if currentSession != nil {
writeJSONRPCEvent("atxState", ATXState{
Power: newLedPWRState,
HDD: newLedHDDState,
}, currentSession)
}
if newLedHDDState != ledHDDState ||
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
scopedLogger.Debug().
Bool("hdd", newLedHDDState).
Bool("pwr", newLedPWRState).
Bool("rst", newBtnRSTState).
Bool("pwr", newBtnPWRState).
Msg("Status changed")
// Update states
ledHDDState = newLedHDDState
ledPWRState = newLedPWRState
btnRSTState = newBtnRSTState
btnPWRState = newBtnPWRState
}
}
}
func pressATXPowerButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_PWR_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_PWR_OFF\n"))
if err != nil {
return err
}
return nil
}
func pressATXResetButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_RST_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_RST_OFF\n"))
if err != nil {
return err
}
return nil
}
func mountDCControl() error {
_ = port.SetMode(defaultMode)
go runDCControl()
return nil
}
func unmountDCControl() error {
_ = reopenSerialPort()
return nil
}
var dcState DCPowerState
func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power state")
continue
}
dcState.IsOn = powerState == 1
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid current")
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power")
continue
}
watts := milliWatts / 1000 // Convert mW to W
dcState.Voltage = volts
dcState.Current = amps
dcState.Power = watts
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
}
}
func setDCPowerState(on bool) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "PWR_OFF\n"
if on {
command = "PWR_ON\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{
BaudRate: 115200,
DataBits: 8,
Parity: serial.NoParity,
StopBits: serial.OneStopBit,
}
func initSerialPort() {
_ = reopenSerialPort()
switch config.ActiveExtension {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
}
func reopenSerialPort() error {
if port != nil {
port.Close()
}
var err error
port, err = serial.Open(serialPortPath, defaultMode)
if err != nil {
serialLogger.Error().
Err(err).
Str("path", serialPortPath).
Interface("mode", defaultMode).
Msg("Error opening serial port")
}
return nil
}
func handleSerialChannel(d *webrtc.DataChannel) {
scopedLogger := serialLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
d.OnOpen(func() {
go func() {
buf := make([]byte, 1024)
for {
n, err := port.Read(buf)
if err != nil {
if err != io.EOF {
scopedLogger.Warn().Err(err).Msg("Failed to read from serial port")
}
break
}
err = d.Send(buf[:n])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
break
}
}
}()
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
if port == nil {
return
}
_, err := port.Write(msg.Data)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to write to serial")
}
})
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Serial channel error")
})
d.OnClose(func() {
scopedLogger.Info().Msg("Serial channel closed")
})
}

View File

@ -16,9 +16,6 @@ type TerminalSize struct {
}
func handleTerminalChannel(d *webrtc.DataChannel) {
scopedLogger := terminalLogger.With().
Uint16("data_channel_id", *d.ID()).Logger()
var ptmx *os.File
var cmd *exec.Cmd
d.OnOpen(func() {
@ -26,7 +23,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var err error
ptmx, err = pty.Start(cmd)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to start pty")
logger.Errorf("Failed to start pty: %v", err)
d.Close()
return
}
@ -37,13 +34,13 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
n, err := ptmx.Read(buf)
if err != nil {
if err != io.EOF {
scopedLogger.Warn().Err(err).Msg("Failed to read from pty")
logger.Errorf("Failed to read from pty: %v", err)
}
break
}
err = d.Send(buf[:n])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to send pty output")
logger.Errorf("Failed to send pty output: %v", err)
break
}
}
@ -58,19 +55,17 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
var size TerminalSize
err := json.Unmarshal([]byte(msg.Data), &size)
if err == nil {
err = pty.Setsize(ptmx, &pty.Winsize{
pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows),
Cols: uint16(size.Cols),
})
if err == nil {
return
}
return
}
scopedLogger.Warn().Err(err).Msg("Failed to parse terminal size")
logger.Errorf("Failed to parse terminal size: %v", err)
}
_, err := ptmx.Write(msg.Data)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to write to pty")
logger.Errorf("Failed to write to pty: %v", err)
}
})
@ -79,12 +74,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
ptmx.Close()
}
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
cmd.Process.Kill()
}
scopedLogger.Info().Msg("Terminal channel closed")
})
d.OnError(func(err error) {
scopedLogger.Warn().Err(err).Msg("Terminal channel error")
})
}

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

4
ui/.env.development Normal file
View File

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

4
ui/.env.device Normal file
View File

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

4
ui/.env.production Normal file
View File

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

View File

@ -8,8 +8,6 @@ module.exports = {
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
],
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
parser: "@typescript-eslint/parser",
@ -22,45 +20,5 @@ module.exports = {
},
rules: {
"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,
"arrowParens": "avoid",
"singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"],
"plugins": [
"prettier-plugin-tailwindcss"
],
"tailwindFunctions": [
"clsx"
],
"printWidth": 90
}
}

View File

@ -1,19 +0,0 @@
#!/usr/bin/env 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
echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │"
echo "└──────────────────────────────────────┘"
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device

2366
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,30 +7,25 @@
"node": "21.1.0"
},
"scripts": {
"dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=cloud-development",
"dev": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"build:staging": "tsc && vite build --mode=cloud-staging",
"build:prod": "tsc && vite build --mode=cloud-production",
"lint": "eslint './src/**/*.{ts,tsx}'",
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
"preview": "vite preview"
"dev:device": "vite dev --mode=device",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"@headlessui/react": "^2.2.0",
"@headlessui/tailwindcss": "^0.2.1",
"@heroicons/react": "^2.2.0",
"@headlessui/react": "^2.1.10",
"@headlessui/tailwindcss": "^0.2.0",
"@heroicons/react": "^2.1.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/addon-webgl": "^0.18.0",
"@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1",
"eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3",
"framer-motion": "^11.15.0",
"framer-motion": "^11.0.28",
"lodash.throttle": "^4.1.1",
"mini-svg-data-uri": "^1.4.4",
"react": "^18.2.0",
@ -40,38 +35,33 @@
"react-icons": "^5.4.0",
"react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.7.112",
"react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.9",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"recharts": "^2.12.6",
"tailwind-merge": "^2.2.2",
"usehooks-ts": "^3.1.0",
"validator": "^13.12.0",
"xterm": "^5.3.0",
"zustand": "^4.5.2"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.9",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.12",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.3.0",
"@types/semver": "^7.5.8",
"@types/validator": "^13.12.2",
"@typescript-eslint/eslint-plugin": "^8.25.0",
"@typescript-eslint/parser": "^8.25.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"autoprefixer": "^10.4.20",
"eslint": "^8.20.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.4",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"postcss": "^8.4.49",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.1",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"prettier": "^3.2.5",
"prettier-plugin-tailwindcss": "^0.5.13",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"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,42 +1,40 @@
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 {
useHidStore,
useMountMediaStore,
useSettingsStore,
useUiStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { MdOutlineContentPasteGo } from "react-icons/md";
import Container from "@components/Container";
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { cx } from "@/cva.config";
import PasteModal from "@/components/popovers/PasteModal";
import { FaKeyboard } from "react-icons/fa6";
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import MountPopopover from "./popovers/MountPopover";
import { Fragment, useCallback, useRef } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
export default function Actionbar({
requestFullscreen,
}: {
requestFullscreen: () => Promise<void>;
}) {
const { navigateTo } = useDeviceUiNavigation();
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const terminalType = useUiStore(state => state.terminalType);
const setTerminalType = useUiStore(state => state.setTerminalType);
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const remoteVirtualMediaState = useMountMediaStore(
state => state.remoteVirtualMediaState,
);
const developerMode = useSettingsStore(state => state.developerMode);
const hdmiState = useVideoStore(state => state.hdmiState);
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
@ -57,7 +55,7 @@ export default function Actionbar({
);
return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
<div
onKeyUp={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
@ -70,7 +68,7 @@ export default function Actionbar({
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
onClick={() => setEnableTerminal(!enableTerminal)}
/>
)}
<Popover>
@ -96,7 +94,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<div className="w-full max-w-xl mx-auto">
<PasteModal />
</div>
);
@ -138,7 +136,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<div className="w-full max-w-xl mx-auto">
<MountPopopover />
</div>
);
@ -152,7 +150,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake on LAN"
text="Wake on Lan"
onClick={() => {
setDisableFocusTrap(true);
}}
@ -190,7 +188,7 @@ export default function Actionbar({
{({ open }) => {
checkIfStateChanged(open);
return (
<div className="mx-auto w-full max-w-xl">
<div className="w-full max-w-xl mx-auto">
<WakeOnLanModal />
</div>
);
@ -210,33 +208,6 @@ export default function Actionbar({
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<Popover>
<PopoverButton as={Fragment}>
<Button
size="XS"
theme="light"
text="Extension"
LeadingIcon={LuCable}
onClick={() => {
setDisableFocusTrap(true);
}}
/>
</PopoverButton>
<PopoverPanel
anchor="bottom start"
transition
className={cx(
"z-10 flex w-[420px] flex-col !overflow-visible",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0",
)}
>
{({ open }) => {
checkIfStateChanged(open);
return <ExtensionPopover />;
}}
</PopoverPanel>
</Popover>
<div className="block lg:hidden">
<Button
size="XS"
@ -263,22 +234,22 @@ export default function Actionbar({
/>
</div>
<div>
<div className="hidden xs:block ">
<Button
size="XS"
theme="light"
text="Settings"
LeadingIcon={LuSettings}
onClick={() => navigateTo("/settings")}
onClick={() => toggleSidebarView("system")}
/>
</div>
<div className="hidden items-center gap-x-2 lg:flex">
<div className="items-center hidden gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
<Button
size="XS"
theme="light"
text="Fullscreen"
disabled={hdmiState !== 'ready'}
LeadingIcon={LuMaximize}
onClick={() => requestFullscreen()}
/>

View File

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

View File

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

View File

@ -1,9 +1,8 @@
import React from "react";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
import ExtLink from "@/components/ExtLink";
import LoadingSpinner from "@/components/LoadingSpinner";
import { cva, cx } from "@/cva.config";
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
const sizes = {
XS: "h-[28px] px-2 text-xs",
@ -102,7 +101,7 @@ const iconVariants = cva({
},
});
interface ButtonContentPropsType {
type ButtonContentPropsType = {
text?: string | React.ReactNode;
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
@ -112,7 +111,7 @@ interface ButtonContentPropsType {
size: keyof typeof sizes;
theme: keyof typeof themes;
loading?: boolean;
}
};
function ButtonContent(props: ButtonContentPropsType) {
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
@ -157,16 +156,7 @@ function ButtonContent(props: ButtonContentPropsType) {
type ButtonPropsType = Pick<
JSX.IntrinsicElements["button"],
| "type"
| "disabled"
| "onClick"
| "name"
| "value"
| "formNoValidate"
| "onMouseLeave"
| "onMouseDown"
| "onMouseUp"
| "onMouseLeave"
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
> &
React.ComponentProps<typeof ButtonContent> & {
fetcher?: FetcherWithComponents<unknown>;
@ -189,9 +179,6 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
type={type}
disabled={disabled}
onClick={onClick}
onMouseDown={props?.onMouseDown}
onMouseUp={props?.onMouseUp}
onMouseLeave={props?.onMouseLeave}
name={props.name}
value={props.value}
>

View File

@ -1,11 +1,10 @@
import React, { forwardRef } from "react";
import React from "react";
import { cx } from "@/cva.config";
interface CardPropsType {
type CardPropsType = {
children: React.ReactNode;
className?: string;
}
};
export const GridCard = ({
children,
@ -17,28 +16,23 @@ export const GridCard = ({
return (
<Card className={cx("overflow-hidden", cardClassName)}>
<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="isolate h-full">{children}</div>
<div className="h-full isolate">{children}</div>
</div>
</Card>
);
};
const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
export default function Card({ children, className }: CardPropsType) {
return (
<div
ref={ref}
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,
)}
>
{children}
</div>
);
});
Card.displayName = "Card";
export default Card;
}

View File

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

View File

@ -1,8 +1,7 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
const sizes = {
@ -37,11 +36,11 @@ type CheckBoxProps = {
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
{ size = "MD", className, ...props },
{ size = "MD", ...props },
ref,
) {
const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
return <input ref={ref} {...props} type="checkbox" className={classes} />;
});
Checkbox.displayName = "Checkbox";
@ -53,7 +52,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
function CheckboxWithLabel(
{ label, id, description, fullWidth, readOnly, ...props },
{ label, id, description, as, fullWidth, readOnly, ...props },
ref: Ref<HTMLInputElement>,
) {
return (

View File

@ -1,119 +0,0 @@
import { useRef } from "react";
import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react";
import { cva } from "@/cva.config";
import Card from "./Card";
export interface ComboboxOption {
value: string;
label: string;
}
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
} as const;
const comboboxVariants = cva({
variants: { size: sizes },
});
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> {
displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void;
options: () => ComboboxOption[];
placeholder?: string;
emptyMessage?: string;
size?: keyof typeof sizes;
disabledMessage?: string;
}
export function Combobox({
onInputChange,
displayValue,
options,
disabled = false,
placeholder = "Search...",
emptyMessage = "No results found",
size = "MD",
onChange,
disabledMessage = "Input disabled",
...otherProps
}: ComboboxProps) {
const inputRef = useRef<HTMLInputElement>(null);
const classes = comboboxVariants({ size });
return (
<HeadlessCombobox
onChange={onChange}
{...otherProps}
>
{() => (
<>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
<ComboboxInput
ref={inputRef}
className={clsx(
classes,
// General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
// Hover
"hover:bg-blue-50/80 active:bg-blue-100/60",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60",
// Focus
"focus:outline-blue-600 focus:ring-2 focus:ring-blue-700 focus:ring-offset-2 dark:focus:outline-blue-500 dark:focus:ring-blue-500",
// Disabled
disabled && "pointer-events-none select-none bg-slate-50 text-slate-500/80 dark:bg-slate-800 dark:text-slate-400/80 disabled:hover:bg-white dark:disabled:hover:bg-slate-800"
)}
placeholder={disabled ? disabledMessage : placeholder}
displayValue={displayValue}
onChange={(event) => onInputChange(event.target.value)}
disabled={disabled}
/>
</Card>
{options().length > 0 && (
<ComboboxOptions className="absolute left-0 z-[100] mt-1 w-full max-h-60 overflow-auto rounded-md bg-white py-1 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700 hide-scrollbar">
{options().map((option) => (
<ComboboxOption
key={option.value}
value={option}
className={clsx(
// General styling
"cursor-default select-none py-2 px-4",
// Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
// Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200"
)}
>
{option.label}
</ComboboxOption>
))}
</ComboboxOptions>
)}
{options().length === 0 && inputRef.current?.value && (
<div className="absolute left-0 z-[100] mt-1 w-full rounded-md bg-white dark:bg-slate-800 py-2 px-4 text-sm shadow-lg ring-1 ring-black/5 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400">
{emptyMessage}
</div>
</div>
)}
</>
)}
</HeadlessCombobox>
);
}

View File

@ -1,106 +0,0 @@
import { ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from "@heroicons/react/24/outline";
import { cx } from "@/cva.config";
import { Button } from "@/components/Button";
import Modal from "@/components/Modal";
type Variant = "danger" | "success" | "warning" | "info";
interface ConfirmDialogProps {
open: boolean;
onClose: () => void;
title: string;
description: string;
variant?: Variant;
confirmText?: string;
cancelText?: string | null;
onConfirm: () => void;
isConfirming?: boolean;
}
const variantConfig = {
danger: {
icon: ExclamationTriangleIcon,
iconClass: "text-red-600",
iconBgClass: "bg-red-100",
buttonTheme: "danger",
},
success: {
icon: CheckCircleIcon,
iconClass: "text-green-600",
iconBgClass: "bg-green-100",
buttonTheme: "primary",
},
warning: {
icon: ExclamationTriangleIcon,
iconClass: "text-yellow-600",
iconBgClass: "bg-yellow-100",
buttonTheme: "lightDanger",
},
info: {
icon: InformationCircleIcon,
iconClass: "text-blue-600",
iconBgClass: "bg-blue-100",
buttonTheme: "primary",
},
} as Record<Variant, {
icon: React.ElementType;
iconClass: string;
iconBgClass: string;
buttonTheme: "danger" | "primary" | "blank" | "light" | "lightDanger";
}>;
export function ConfirmDialog({
open,
onClose,
title,
description,
variant = "info",
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
isConfirming = false,
}: ConfirmDialogProps) {
const { icon: Icon, iconClass, iconBgClass, buttonTheme } = variantConfig[variant];
return (
<Modal open={open} onClose={onClose}>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800 pointer-events-auto">
<div className="space-y-4">
<div className="sm:flex sm:items-start">
<div className={cx("mx-auto flex size-12 shrink-0 items-center justify-center rounded-full sm:mx-0 sm:size-10", iconBgClass)}>
<Icon aria-hidden="true" className={cx("size-6", iconClass)} />
</div>
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg font-bold leading-tight text-black dark:text-white">
{title}
</h2>
<div className="mt-2 text-sm leading-snug text-slate-600 dark:text-slate-400">
{description}
</div>
</div>
</div>
<div className="flex justify-end gap-x-2">
{cancelText && (
<Button
size="SM"
theme="blank"
text={cancelText}
onClick={onClose}
/>
)}
<Button
size="SM"
theme={buttonTheme}
text={isConfirming ? `${confirmText}...` : confirmText}
onClick={onConfirm}
disabled={isConfirming}
/>
</div>
</div>
</div>
</div>
</Modal>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,22 +1,18 @@
import { Fragment, useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu";
import { Menu, MenuButton, Transition } from "@headlessui/react";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { LuMonitorSmartphone } from "react-icons/lu";
import { cx } from "@/cva.config";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
interface NavbarProps {
@ -36,26 +32,28 @@ export default function DashboardNavbar({
picture,
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
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, setUser]);
const usbState = useHidStore(state => state.usbState);
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>
<div className="flex h-14 items-center justify-between">
<div className="flex shrink-0 items-center gap-x-8">
<div className="flex items-center justify-between h-14">
<div className="flex items-center shrink-0 gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
@ -76,10 +74,10 @@ export default function DashboardNavbar({
})}
</div>
</div>
<div className="flex w-full items-center justify-end gap-x-2">
<div className="flex shrink-0 items-center space-x-4">
<div className="flex items-center justify-end w-full gap-x-2">
<div className="flex items-center space-x-4 shrink-0">
{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]">
<PeerConnectionStatusCard
state={peerConnectionState}
@ -89,7 +87,7 @@ export default function DashboardNavbar({
<div className="hidden w-[159px] md:block">
<USBStateStatus
state={usbState}
peerConnectionState={peerConnectionState}
peerConnectionState={peerConnectionState}
/>
</div>
</div>
@ -106,55 +104,66 @@ export default function DashboardNavbar({
text={
<>
{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 && (
<img
src={picture}
alt="Avatar"
className={cx(
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
}
)}
/>
</MenuButton>
</div>
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<div className="space-y-1 p-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="font-display text-xs">Logged in as</div>
<div className="w-[200px] truncate font-display text-sm font-semibold">
{userEmail}
<Transition
as={Fragment}
enter="transition ease-in-out duration-75"
enterFrom="transform opacity-0"
enterTo="transform opacity-100"
leave="transition ease-in-out duration-75"
leaveFrom="transform opacity-75"
leaveTo="transform opacity-0"
>
<Menu.Items className="absolute right-0 z-50 w-56 mt-2 origin-top-right focus:outline-none">
<Card className="overflow-hidden">
<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>
</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>
</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-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>
</Card>
</Menu.Items>
</Card>
</Menu.Items>
</Transition>
</Menu>
</>
) : null}

View File

@ -1,5 +1,3 @@
import { useEffect, useState } from "react";
import { cx } from "@/cva.config";
import {
useHidStore,
@ -9,6 +7,7 @@ import {
useVideoStore,
useKeyboardMappingsStore,
} from "@/hooks/stores";
import { useEffect, useState } from "react";
export default function InfoBar() {
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
@ -26,7 +25,6 @@ export default function InfoBar() {
const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
@ -75,7 +73,7 @@ export default function InfoBar() {
</div>
) : null}
{(settings.debugMode && settings.mouseMode == "absolute") ? (
{settings.debugMode ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs">
@ -84,17 +82,6 @@ export default function InfoBar() {
</div>
) : 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 && (
<div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span>

View File

@ -1,8 +1,7 @@
import type { Ref } from "react";
import React, { forwardRef } from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import clsx from "clsx";
import Card from "@/components/Card";
import { cva } from "@/cva.config";
@ -85,7 +84,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
{(label || 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>
);
},

View File

@ -1,11 +1,10 @@
import { Button, LinkButton } from "@components/Button";
import Card from "@components/Card";
import { MdConnectWithoutContact } from "react-icons/md";
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { Link } from "react-router-dom";
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 {
// Allow dates or times to be passed
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
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];
// Array equivalent to the above but in the string representation of the units
@ -53,7 +52,7 @@ export default function KvmCard({
return (
<Card>
<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="text-lg font-bold leading-none text-black dark:text-white">
{title}

View File

@ -1,36 +1,30 @@
import { useState, useEffect } from "react";
import { useLocation, useRevalidator } from "react-router-dom";
import { GridCard } from "@/components/Card";
import { useState } from "react";
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 { useLocalAuthModalStore } from "@/hooks/stores";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SecurityAccessLocalAuthRoute() {
const { setModalView } = useLocalAuthModalStore();
const { navigateTo } = useDeviceUiNavigation();
const location = useLocation();
const init = location.state?.init;
useEffect(() => {
if (!init) {
navigateTo("..");
} else {
setModalView(init);
}
}, [init, navigateTo, setModalView]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigateTo("..")} />;
export default function LocalAuthPasswordDialog({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
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 { modalView, setModalView } = useLocalAuthModalStore();
const [error, setError] = useState<string | null>(null);
const revalidator = useRevalidator();
const handleCreatePassword = async (password: string, confirmPassword: string) => {
if (password === "") {
@ -47,14 +41,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
const res = await api.POST("/auth/password-local", { password });
if (res.ok) {
setModalView("creationSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while setting the password");
}
} catch (error) {
console.error(error);
setError("An error occurred while setting the password");
}
};
@ -87,14 +78,11 @@ export function Dialog({ onClose }: { onClose: () => void }) {
if (res.ok) {
setModalView("updateSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while changing the password");
}
} catch (error) {
console.error(error);
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 });
if (res.ok) {
setModalView("deleteSuccess");
// The rest of the app needs to revalidate the device authMode
revalidator.revalidate();
} else {
const data = await res.json();
setError(data.error || "An error occurred while disabling the password");
}
} catch (error) {
console.error(error);
setError("An error occurred while disabling the password");
}
};
return (
<div>
<div>
<GridCard cardClassName="relative max-w-lg mx-auto text-left pointer-events-auto dark:bg-slate-800">
<div className="p-10">
{modalView === "createPassword" && (
<CreatePasswordModal
onSetPassword={handleCreatePassword}
onCancel={onClose}
onCancel={() => setOpen(false)}
error={error}
/>
)}
@ -135,7 +120,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "deletePassword" && (
<DeletePasswordModal
onDeletePassword={handleDeletePassword}
onCancel={onClose}
onCancel={() => setOpen(false)}
error={error}
/>
)}
@ -143,7 +128,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
{modalView === "updatePassword" && (
<UpdatePasswordModal
onUpdatePassword={handleUpdatePassword}
onCancel={onClose}
onCancel={() => setOpen(false)}
error={error}
/>
)}
@ -152,7 +137,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<SuccessModal
headline="Password Set Successfully"
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
headline="Password Protection Disabled"
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
headline="Password Updated Successfully"
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>
</GridCard>
);
}
@ -190,16 +175,13 @@ function CreatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<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>
<h2 className="text-lg font-semibold dark:text-white">
Local Device Protection
</h2>
<h2 className="text-lg font-semibold dark:text-white">Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Create a password to protect your device from unauthorized local access.
</p>
@ -209,7 +191,6 @@ function CreatePasswordModal({
type="password"
placeholder="Enter a strong password"
value={password}
autoFocus
onChange={e => setPassword(e.target.value)}
/>
<InputFieldWithLabel
@ -230,7 +211,7 @@ function CreatePasswordModal({
<Button size="SM" theme="light" text="Not Now" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
</div>
</div>
);
}
@ -248,11 +229,13 @@ function DeletePasswordModal({
return (
<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>
<h2 className="text-lg font-semibold dark:text-white">
Disable Local Device Protection
</h2>
<h2 className="text-lg font-semibold dark:text-white">Disable Local Device Protection</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password to disable local device protection.
</p>
@ -298,16 +281,13 @@ function UpdatePasswordModal({
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<form
className="space-y-4"
onSubmit={e => {
e.preventDefault();
}}
>
<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>
<h2 className="text-lg font-semibold dark:text-white">
Change Local Device Password
</h2>
<h2 className="text-lg font-semibold dark:text-white">Change Local Device Password</h2>
<p className="text-sm text-slate-600 dark:text-slate-400">
Enter your current password and a new password to update your local device
protection.
@ -344,7 +324,7 @@ function UpdatePasswordModal({
<Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</form>
</div>
</div>
);
}
@ -359,7 +339,11 @@ function SuccessModal({
onClose: () => void;
}) {
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>
<h2 className="text-lg font-semibold dark:text-white">{headline}</h2>

View File

@ -1,48 +0,0 @@
import { useEffect } from "react";
import { LuCommand } from "react-icons/lu";
import { Button } from "@components/Button";
import Container from "@components/Container";
import { useMacrosStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function MacroBar() {
const { macros, initialized, loadMacros, setSendFn } = useMacrosStore();
const { executeMacro } = useKeyboard();
const [send] = useJsonRpc();
useEffect(() => {
setSendFn(send);
if (!initialized) {
loadMacros();
}
}, [initialized, loadMacros, setSendFn, send]);
if (macros.length === 0) {
return null;
}
return (
<Container className="bg-white dark:bg-slate-900 border-b border-b-slate-800/20 dark:border-b-slate-300/20">
<div className="flex items-center gap-x-2 py-1.5">
<div className="absolute -ml-5">
<LuCommand className="h-4 w-4 text-slate-500" />
</div>
<div className="flex flex-wrap gap-2">
{macros.map(macro => (
<Button
key={macro.id}
aria-label={macro.name}
size="XS"
theme="light"
text={macro.name}
onClick={() => executeMacro(macro.steps)}
/>
))}
</div>
</div>
</Container>
);
}

View File

@ -1,271 +0,0 @@
import { useState } from "react";
import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores";
import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
interface ValidationErrors {
name?: string;
steps?: Record<number, {
keys?: string;
modifiers?: string;
delay?: string;
}>;
}
interface MacroFormProps {
initialData: Partial<KeySequence>;
onSubmit: (macro: Partial<KeySequence>) => Promise<void>;
onCancel: () => void;
isSubmitting?: boolean;
submitText?: string;
}
export function MacroForm({
initialData,
onSubmit,
onCancel,
isSubmitting = false,
submitText = "Save Macro",
}: MacroFormProps) {
const [macro, setMacro] = useState<Partial<KeySequence>>(initialData);
const [keyQueries, setKeyQueries] = useState<Record<number, string>>({});
const [errors, setErrors] = useState<ValidationErrors>({});
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const showTemporaryError = (message: string) => {
setErrorMessage(message);
setTimeout(() => setErrorMessage(null), 3000);
};
const validateForm = (): boolean => {
const newErrors: ValidationErrors = {};
// Name validation
if (!macro.name?.trim()) {
newErrors.name = "Name is required";
} else if (macro.name.trim().length > 50) {
newErrors.name = "Name must be less than 50 characters";
}
if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } };
} else {
const hasKeyOrModifier = macro.steps.some(step =>
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0
);
if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } };
}
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validateForm()) {
showTemporaryError("Please fix the validation errors");
return;
}
try {
await onSubmit(macro);
} catch (error) {
if (error instanceof Error) {
showTemporaryError(error.message);
} else {
showTemporaryError("An error occurred while saving");
}
}
};
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => {
const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return;
if (option.keys) {
newSteps[stepIndex].keys = option.keys;
} else if (option.value) {
if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = [];
}
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : [];
if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return;
}
newSteps[stepIndex].keys = [...keysArray, option.value];
}
setMacro({ ...macro, steps: newSteps });
if (errors.steps?.[stepIndex]?.keys) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleKeyQueryChange = (stepIndex: number, query: string) => {
setKeyQueries(prev => ({ ...prev, [stepIndex]: query }));
};
const handleModifierChange = (stepIndex: number, modifiers: string[]) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].modifiers = modifiers;
setMacro({ ...macro, steps: newSteps });
// Clear step errors when modifiers are added
if (errors.steps?.[stepIndex]?.keys && modifiers.length > 0) {
const newErrors = { ...errors };
delete newErrors.steps?.[stepIndex].keys;
if (Object.keys(newErrors.steps?.[stepIndex] || {}).length === 0) {
delete newErrors.steps?.[stepIndex];
}
if (Object.keys(newErrors.steps || {}).length === 0) {
delete newErrors.steps;
}
setErrors(newErrors);
}
};
const handleDelayChange = (stepIndex: number, delay: number) => {
const newSteps = [...(macro.steps || [])];
newSteps[stepIndex].delay = delay;
setMacro({ ...macro, steps: newSteps });
};
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => {
const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps });
};
const isMaxStepsReached = (macro.steps?.length || 0) >= MAX_STEPS_PER_MACRO;
return (
<>
<div className="space-y-4">
<Fieldset>
<InputFieldWithLabel
type="text"
label="Macro Name"
placeholder="Macro Name"
value={macro.name}
error={errors.name}
onChange={e => {
setMacro(prev => ({ ...prev, name: e.target.value }));
if (errors.name) {
const newErrors = { ...errors };
delete newErrors.name;
setErrors(newErrors);
}
}}
/>
</Fieldset>
<div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1">
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} />
</div>
<span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
</span>
</div>
{errors.steps && errors.steps[0]?.keys && (
<div className="mt-2">
<FieldError error={errors.steps[0].keys} />
</div>
)}
<Fieldset>
<div className="mt-2 space-y-4">
{(macro.steps || []).map((step, stepIndex) => (
<MacroStepCard
key={stepIndex}
step={step}
stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => {
const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps }));
} : undefined}
onMoveUp={() => handleStepMove(stepIndex, 'up')}
onMoveDown={() => handleStepMove(stepIndex, 'down')}
onKeySelect={(option) => handleKeySelect(stepIndex, option)}
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)}
keyQuery={keyQueries[stepIndex] || ''}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/>
))}
</div>
</Fieldset>
<div className="mt-4">
<Button
size="MD"
theme="light"
fullWidth
LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`}
onClick={() => {
if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`);
return;
}
setMacro(prev => ({
...prev,
steps: [
...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY }
],
}));
setErrors({});
}}
disabled={isMaxStepsReached}
/>
</div>
{errorMessage && (
<div className="mt-4">
<FieldError error={errorMessage} />
</div>
)}
<div className="mt-6 flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={isSubmitting ? "Saving..." : submitText}
onClick={handleSubmit}
disabled={isSubmitting}
/>
<Button
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div>
</div>
</div>
</>
);
}

View File

@ -1,236 +0,0 @@
import { LuArrowUp, LuArrowDown, LuX, LuTrash2 } from "react-icons/lu";
import { Button } from "@/components/Button";
import { Combobox } from "@/components/Combobox";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import Card from "@/components/Card";
import { keyDisplayMap } from "@/keyboardMappings/KeyboardLayouts";
import { keysUS, modifiersUS } from '../keyboardMappings/layouts/us';
import { MAX_KEYS_PER_STEP, DEFAULT_DELAY } from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel";
// Filter out modifier keys since they're handled in the modifiers section
const modifierKeyPrefixes = ['Alt', 'Control', 'Shift', 'Meta'];
const keyOptions = Object.keys(keysUS)
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
.map(key => ({
value: key,
label: keyDisplayMap[key] || key,
}));
const modifierOptions = Object.keys(modifiersUS).map(modifier => ({
value: modifier,
label: modifier.replace(/^(Control|Alt|Shift|Meta)(Left|Right)$/, "$1 $2"),
}));
const groupedModifiers: Record<string, typeof modifierOptions> = {
Control: modifierOptions.filter(mod => mod.value.startsWith('Control')),
Shift: modifierOptions.filter(mod => mod.value.startsWith('Shift')),
Alt: modifierOptions.filter(mod => mod.value.startsWith('Alt')),
Meta: modifierOptions.filter(mod => mod.value.startsWith('Meta')),
};
const basePresetDelays = [
{ value: "50", label: "50ms" },
{ value: "100", label: "100ms" },
{ value: "200", label: "200ms" },
{ value: "300", label: "300ms" },
{ value: "500", label: "500ms" },
{ value: "750", label: "750ms" },
{ value: "1000", label: "1000ms" },
{ value: "1500", label: "1500ms" },
{ value: "2000", label: "2000ms" },
];
const PRESET_DELAYS = basePresetDelays.map(delay => {
if (parseInt(delay.value, 10) === DEFAULT_DELAY) {
return { ...delay, label: "Default" };
}
return delay;
});
interface MacroStep {
keys: string[];
modifiers: string[];
delay: number;
}
interface MacroStepCardProps {
step: MacroStep;
stepIndex: number;
onDelete?: () => void;
onMoveUp?: () => void;
onMoveDown?: () => void;
onKeySelect: (option: { value: string | null; keys?: string[] }) => void;
onKeyQueryChange: (query: string) => void;
keyQuery: string;
onModifierChange: (modifiers: string[]) => void;
onDelayChange: (delay: number) => void;
isLastStep: boolean;
}
const ensureArray = <T,>(arr: T[] | null | undefined): T[] => {
return Array.isArray(arr) ? arr : [];
};
export function MacroStepCard({
step,
stepIndex,
onDelete,
onMoveUp,
onMoveDown,
onKeySelect,
onKeyQueryChange,
keyQuery,
onModifierChange,
onDelayChange,
isLastStep
}: MacroStepCardProps) {
const getFilteredKeys = () => {
const selectedKeys = ensureArray(step.keys);
const availableKeys = keyOptions.filter(option => !selectedKeys.includes(option.value));
if (keyQuery === '') {
return availableKeys;
} else {
return availableKeys.filter(option => option.label.toLowerCase().includes(keyQuery.toLowerCase()));
}
};
return (
<Card className="p-4">
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="flex h-6 w-5 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
{stepIndex + 1}
</span>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center gap-1">
<Button
size="XS"
theme="light"
onClick={onMoveUp}
disabled={stepIndex === 0}
LeadingIcon={LuArrowUp}
/>
<Button
size="XS"
theme="light"
onClick={onMoveDown}
disabled={isLastStep}
LeadingIcon={LuArrowDown}
/>
</div>
{onDelete && (
<Button
size="XS"
theme="light"
className="text-red-500 dark:text-red-400"
text="Delete"
LeadingIcon={LuTrash2}
onClick={onDelete}
/>
)}
</div>
</div>
<div className="space-y-4 mt-2">
<div className="w-full flex flex-col gap-2">
<FieldLabel label="Modifiers" />
<div className="inline-flex flex-wrap gap-3">
{Object.entries(groupedModifiers).map(([group, mods]) => (
<div key={group} className="relative min-w-[120px] rounded-md border border-slate-200 dark:border-slate-700 p-2">
<span className="absolute -top-2.5 left-2 px-1 text-xs font-medium bg-white dark:bg-slate-800 text-slate-500 dark:text-slate-400">
{group}
</span>
<div className="flex flex-wrap gap-4 pt-1">
{mods.map(option => (
<Button
key={option.value}
size="XS"
theme={ensureArray(step.modifiers).includes(option.value) ? "primary" : "light"}
text={option.label.split(' ')[1] || option.label}
onClick={() => {
const modifiersArray = ensureArray(step.modifiers);
const isSelected = modifiersArray.includes(option.value);
const newModifiers = isSelected
? modifiersArray.filter(m => m !== option.value)
: [...modifiersArray, option.value];
onModifierChange(newModifiers);
}}
/>
))}
</div>
</div>
))}
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Keys" description={`Maximum ${MAX_KEYS_PER_STEP} keys per step.`} />
</div>
{ensureArray(step.keys) && step.keys.length > 0 && (
<div className="flex flex-wrap gap-1 pb-2">
{step.keys.map((key, keyIndex) => (
<span
key={keyIndex}
className="inline-flex items-center py-0.5 rounded-md bg-blue-100 px-1 text-xs font-medium text-blue-800 dark:bg-blue-900/40 dark:text-blue-200"
>
<span className="px-1">
{keyDisplayMap[key] || key}
</span>
<Button
size="XS"
className=""
theme="blank"
onClick={() => {
const newKeys = ensureArray(step.keys).filter((_, i) => i !== keyIndex);
onKeySelect({ value: null, keys: newKeys });
}}
LeadingIcon={LuX}
/>
</span>
))}
</div>
)}
<div className="relative w-full">
<Combobox
onChange={(value: { value: string; label: string }) => {
onKeySelect(value);
onKeyQueryChange('');
}}
displayValue={() => keyQuery}
onInputChange={onKeyQueryChange}
options={getFilteredKeys}
disabledMessage="Max keys reached"
size="SM"
immediate
disabled={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP}
placeholder={ensureArray(step.keys).length >= MAX_KEYS_PER_STEP ? "Max keys reached" : "Search for key..."}
emptyMessage="No matching keys found"
/>
</div>
</div>
<div className="w-full flex flex-col gap-1">
<div className="flex items-center gap-1">
<FieldLabel label="Step Duration" description="Time to wait before executing the next step." />
</div>
<div className="flex items-center gap-3">
<SelectMenuBasic
size="SM"
fullWidth
value={step.delay.toString()}
onChange={(e) => onDelayChange(parseInt(e.target.value, 10))}
options={PRESET_DELAYS}
/>
</div>
</div>
</div>
</Card>
);
}

View File

@ -1,9 +1,8 @@
import React from "react";
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
import { cx } from "@/cva.config";
const Modal = React.memo(function Modal({
export default function Modal({
children,
className,
open,
@ -15,28 +14,25 @@ const Modal = React.memo(function Modal({
onClose: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} className="relative z-20">
<Dialog open={open} onClose={onClose} className="relative z-10">
<DialogBackdrop
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="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
<DialogPanel
transition
className={cx(
"pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
"pointer-events-none relative w-full sm:my-8",
"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,
)}
>
<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="pointer-events-none w-full"
onClick={e => e.stopPropagation()}
>
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>
@ -46,6 +42,4 @@ const Modal = React.memo(function Modal({
</div>
</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 { 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 {
LuGlobe,
LuLink,
@ -7,56 +19,47 @@ import {
LuCheck,
LuUpload,
} 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 AutoHeight from "@components/AutoHeight";
import { InputFieldWithLabel } from "@/components/InputField";
import { PlusCircleIcon } from "@heroicons/react/20/solid";
import AutoHeight from "./AutoHeight";
import { InputFieldWithLabel } from "./InputField";
import DebianIcon from "@/assets/debian-icon.png";
import UbuntuIcon from "@/assets/ubuntu-icon.png";
import FedoraIcon from "@/assets/fedora-icon.png";
import OpenSUSEIcon from "@/assets/opensuse-icon.png";
import ArchIcon from "@/assets/arch-icon.png";
import NetBootIcon from "@/assets/netboot-icon.svg";
import Fieldset from "@/components/Fieldset";
import { DEVICE_API } from "@/ui.config";
import { TrashIcon } from "@heroicons/react/16/solid";
import { useJsonRpc } from "../hooks/useJsonRpc";
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import Fieldset from "./Fieldset";
import { isOnDevice } from "../main";
import { cx } from "../cva.config";
import {
MountMediaState,
RemoteVirtualMediaState,
useMountMediaStore,
useRTCStore,
} from "../hooks/stores";
export default function MountRoute() {
const navigate = useNavigate();
{
/* 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("..")} />;
export default function MountMediaModal({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) {
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 {
modalView,
setModalView,
setLocalFile,
setIsMountMediaDialogOpen,
setRemoteVirtualMediaState,
errorMessage,
setErrorMessage,
} = useMountMediaStore();
const navigate = useNavigate();
const [incompleteFileName, setIncompleteFileName] = useState<string | null>(null);
const [mountInProgress, setMountInProgress] = useState(false);
@ -95,13 +98,17 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => navigate(".."))
.then(() => {
setIsMountMediaDialogOpen(false);
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
setMountInProgress(false);
});
setIsMountMediaDialogOpen(false);
});
}
@ -115,13 +122,13 @@ export function Dialog({ onClose }: { onClose: () => void }) {
clearMountMediaState();
syncRemoteVirtualMediaState()
.then(() => {
navigate("..");
setIsMountMediaDialogOpen(false);
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
})
.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
setTimeout(() => {
setMountInProgress(false);
@ -148,7 +155,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
// We need to keep the local file in the store so that the browser can
// continue to stream the file to the device
setLocalFile(file);
navigate("..");
setIsMountMediaDialogOpen(false);
})
.catch(err => {
triggerError(err instanceof Error ? err.message : String(err));
@ -180,16 +187,16 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<img
src={LogoBlueIcon}
alt="JetKVM Logo"
className="block h-[24px] dark:hidden"
className="h-[24px] dark:hidden block"
/>
<img
src={LogoWhiteIcon}
alt="JetKVM Logo"
className="hidden h-[24px] dark:!mt-0 dark:block"
className="h-[24px] dark:block hidden dark:!mt-0"
/>
{modalView === "mode" && (
<ModeSelectionView
onClose={() => onClose()}
onClose={() => setOpen(false)}
selectedMode={selectedMode}
setSelectedMode={setSelectedMode}
/>
@ -253,7 +260,7 @@ export function Dialog({ onClose }: { onClose: () => void }) {
<ErrorView
errorMessage={errorMessage}
onClose={() => {
onClose();
setOpen(false);
setErrorMessage(null);
}}
onRetry={() => {
@ -283,7 +290,7 @@ function ModeSelectionView({
return (
<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">
Virtual Media Source
</h2>
@ -337,7 +344,7 @@ function ModeSelectionView({
)}
>
<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={() =>
disabled ? null : setSelectedMode(mode as "browser" | "url" | "device")
}
@ -345,7 +352,7 @@ function ModeSelectionView({
<div>
<Card>
<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>
</Card>
</div>
@ -365,7 +372,7 @@ function ModeSelectionView({
value={mode}
disabled={disabled}
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>
</Card>
@ -373,13 +380,13 @@ function ModeSelectionView({
))}
</div>
<div
className="flex animate-fadeIn justify-end opacity-0"
className="flex justify-end opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
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"
@ -437,18 +444,18 @@ function BrowserFileView({
className="block cursor-pointer select-none"
>
<div
className="group animate-fadeIn opacity-0"
className="opacity-0 group animate-fadeIn"
style={{
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="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 ? (
<>
<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">
{formatters.truncateMiddle(selectedFile.name, 40)}
</h3>
@ -459,7 +466,7 @@ function BrowserFileView({
</>
) : (
<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">
Click to select a file
</h3>
@ -483,7 +490,7 @@ function BrowserFileView({
</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={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -523,7 +530,7 @@ function UrlView({
const popularImages = [
{
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,
},
{
@ -578,7 +585,7 @@ function UrlView({
/>
<div
className="animate-fadeIn opacity-0"
className="opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
}}
@ -593,7 +600,7 @@ function UrlView({
/>
</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={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -619,7 +626,7 @@ function UrlView({
<hr className="border-slate-800/30 dark:border-slate-300/20" />
<div
className="animate-fadeIn opacity-0"
className="opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
@ -628,7 +635,7 @@ function UrlView({
<h2 className="mb-2 text-sm font-semibold text-black dark:text-white">
Popular images
</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) => (
<div key={index} className="flex items-center justify-between gap-x-4 p-3.5">
<div className="flex items-center gap-x-4">
@ -797,7 +804,7 @@ function DeviceFileView({
description="Select an image to mount from the JetKVM storage"
/>
<div
className="w-full animate-fadeIn opacity-0"
className="w-full opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.1s",
@ -808,7 +815,7 @@ function DeviceFileView({
<div className="flex items-center justify-center py-8 text-center">
<div className="space-y-3">
<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">
No images available
</h3>
@ -827,7 +834,7 @@ function DeviceFileView({
</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) => (
<PreUploadedImageItem
key={index}
@ -839,13 +846,7 @@ function DeviceFileView({
onDelete={() => {
const selectedFile = onStorageFiles.find(f => f.name === file.name);
if (!selectedFile) return;
if (
window.confirm(
"Are you sure you want to delete " + selectedFile.name + "?",
)
) {
handleDeleteFile(selectedFile);
}
handleDeleteFile(selectedFile);
}}
onSelect={() => handleOnSelectFile(file)}
onContinueUpload={() => onNewImageClick(file.name)}
@ -886,7 +887,7 @@ function DeviceFileView({
{onStorageFiles.length > 0 ? (
<div
className="flex animate-fadeIn items-end justify-between opacity-0"
className="flex items-end justify-between opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -914,7 +915,7 @@ function DeviceFileView({
</div>
) : (
<div
className="flex animate-fadeIn items-end justify-end opacity-0"
className="flex items-end justify-end opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.15s",
@ -927,39 +928,31 @@ function DeviceFileView({
)}
<hr className="border-slate-800/20 dark:border-slate-300/20" />
<div
className="animate-fadeIn space-y-2 opacity-0"
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.20s",
}}
>
<div className="flex justify-between text-sm">
<span className="font-medium text-black dark:text-white">
Available Storage
</span>
<span className="text-slate-700 dark:text-slate-300">
{percentageUsed}% used
</span>
<span className="font-medium text-black dark:text-white">Available Storage</span>
<span className="text-slate-700 dark:text-slate-300">{percentageUsed}% used</span>
</div>
<div className="h-3.5 w-full overflow-hidden rounded-sm bg-slate-200 dark:bg-slate-700">
<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}%` }}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600">
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesUsed)} used
</span>
<span className="text-slate-700 dark:text-slate-300">
{formatters.bytes(bytesFree)} free
</span>
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesUsed)} used</span>
<span className="text-slate-700 dark:text-slate-300">{formatters.bytes(bytesFree)} free</span>
</div>
</div>
{onStorageFiles.length > 0 && (
<div
className="w-full animate-fadeIn opacity-0"
className="w-full opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
animationDelay: "0.25s",
@ -1126,7 +1119,7 @@ function UploadFileView({
alreadyUploadedBytes: number,
dataChannel: string,
) {
const uploadUrl = `${DEVICE_API}/storage/upload?uploadId=${dataChannel}`;
const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);
@ -1251,7 +1244,7 @@ function UploadFileView({
}
/>
<div
className="animate-fadeIn space-y-2 opacity-0"
className="space-y-2 opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
}}
@ -1267,18 +1260,17 @@ function UploadFileView({
<div className="group">
<Card
className={cx("transition-all duration-300", {
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50":
uploadState === "idle",
"cursor-pointer hover:bg-blue-900/50 dark:hover:bg-blue-900/50": uploadState === "idle",
})}
>
<div className="h-[186px] w-full px-4">
<div className="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" && (
<div className="space-y-1">
<div className="inline-block">
<Card>
<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>
</Card>
</div>
@ -1298,11 +1290,11 @@ function UploadFileView({
<div className="inline-block">
<Card>
<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>
</Card>
</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)}
</h3>
<p className="text-xs leading-none text-slate-700 dark:text-slate-300">
@ -1311,7 +1303,7 @@ function UploadFileView({
<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 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}%` }}
></div>
</div>
@ -1332,7 +1324,7 @@ function UploadFileView({
<div className="inline-block">
<Card>
<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>
</Card>
</div>
@ -1357,15 +1349,13 @@ function UploadFileView({
className="hidden"
accept=".iso, .img"
/>
{fileError && (
<p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>
)}
{fileError && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{fileError}</p>}
</div>
{/* Display upload error if present */}
{uploadError && (
<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" }}
>
Error: {uploadError}
@ -1373,13 +1363,13 @@ function UploadFileView({
)}
<div
className="flex w-full animate-fadeIn items-end opacity-0"
className="flex items-end w-full opacity-0 animate-fadeIn"
style={{
animationDuration: "0.7s",
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" ? (
<Button
size="MD"
@ -1421,7 +1411,7 @@ function ErrorView({
<div className="w-full space-y-4">
<div className="space-y-2">
<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>
</div>
<p className="text-sm leading-snug text-slate-600">
@ -1429,7 +1419,7 @@ function ErrorView({
</p>
</div>
{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>
</Card>
)}
@ -1489,12 +1479,12 @@ function PreUploadedImageItem({
<div className="flex items-center gap-x-1 text-slate-600 dark:text-slate-400">
{formatters.date(new Date(uploadedAt), { month: "short" })}
</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>
</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
className={cx("opacity-0 transition-opacity duration-200", {
"w-auto opacity-100": isHovering,
@ -1518,7 +1508,7 @@ function PreUploadedImageItem({
checked={isSelected}
onChange={onSelect}
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
/>
) : (
@ -1558,7 +1548,7 @@ function UsbModeSelector({
setUsbMode: (mode: RemoteVirtualMediaState["mode"]) => void;
}) {
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>
<div className="flex space-x-4">
<label htmlFor="cdrom" className="flex items-center">
@ -1568,7 +1558,7 @@ function UsbModeSelector({
name="mountType"
onChange={() => setUsbMode("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">
CD/DVD
@ -1582,10 +1572,10 @@ function UsbModeSelector({
disabled
checked={usbMode === "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">
<span className="text-sm font-medium leading-none text-slate-900 opacity-50 dark:text-white">
<div className="flex flex-col ml-2 gap-y-0">
<span className="text-sm font-medium leading-none opacity-50 text-slate-900 dark:text-white">
Disk
</span>
<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 EmptyCard from "@/components/EmptyCard";
export default function NotFoundPage() {

View File

@ -1,24 +1,24 @@
import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import Modal from "@components/Modal";
interface ContextType {
setupPeerConnection: () => Promise<void>;
export default function OtherSessionConnectedModal({
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 (
<GridCard cardClassName="relative mx-auto max-w-md text-left pointer-events-auto">
<div className="p-10">
@ -37,7 +37,12 @@ export default function OtherSessionRoute() {
this session?
</p>
<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>

View File

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

View File

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

View File

@ -1,11 +1,8 @@
import React from "react";
import clsx from "clsx";
import FieldLabel from "@/components/FieldLabel";
import { cva } from "@/cva.config";
import clsx from "clsx";
import Card from "./Card";
import { cva } from "@/cva.config";
type SelectMenuProps = Pick<
JSX.IntrinsicElements["select"],
@ -22,12 +19,11 @@ type SelectMenuProps = Pick<
direction?: "vertical" | "horizontal";
error?: string;
fullWidth?: boolean;
} & Partial<React.ComponentProps<typeof FieldLabel>>;
} & React.ComponentProps<typeof FieldLabel>;
const sizes = {
XS: "h-[24.5px] pl-3 pr-8 text-xs",
SM: "h-[32px] pl-3 pr-8 text-[13px]",
SM_Wide: "h-[32px] pl-3 pr-8 mr-5 text-[13px]",
MD: "h-[40px] pl-4 pr-10 text-sm",
LG: "h-[48px] pl-4 pr-10 px-5 text-base",
};
@ -64,7 +60,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)}
>
{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
ref={ref}
name={name}
@ -73,13 +69,10 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes,
// 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:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
// Dark mode
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
"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",
// Invalid
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
@ -89,6 +82,9 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// 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",
// Dark mode text
"dark:bg-slate-900 dark:text-white"
)}
value={value}
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 React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png";
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) {
return (

View File

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

View File

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

View File

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

View File

@ -1,192 +1,32 @@
import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { WebglAddon } from "@xterm/addon-webgl";
import { Unicode11Addon } from "@xterm/addon-unicode11";
import { ClipboardAddon } from "@xterm/addon-clipboard";
import { cx } from "@/cva.config";
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
import { useUiStore, useRTCStore } from "@/hooks/stores";
import { XTerm } from "./Xterm";
import { Button } from "./Button";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { cx } from "../cva.config";
import { Transition } from "@headlessui/react";
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
// Terminal theme configuration
const SOLARIZED_THEME = {
background: "#0f172a", // Solarized base03
foreground: "#839496", // Solarized base0
cursor: "#93a1a1", // Solarized base1
cursorAccent: "#002b36", // Solarized base03
black: "#073642", // Solarized base02
red: "#dc322f", // Solarized red
green: "#859900", // Solarized green
yellow: "#b58900", // Solarized yellow
blue: "#268bd2", // Solarized blue
magenta: "#d33682", // Solarized magenta
cyan: "#2aa198", // Solarized cyan
white: "#eee8d5", // Solarized base2
brightBlack: "#002b36", // Solarized base03
brightRed: "#cb4b16", // Solarized orange
brightGreen: "#586e75", // Solarized base01
brightYellow: "#657b83", // Solarized base00
brightBlue: "#839496", // Solarized base0
brightMagenta: "#6c71c4", // Solarized violet
brightCyan: "#93a1a1", // Solarized base1
brightWhite: "#fdf6e3", // Solarized base3
} as const;
const TERMINAL_CONFIG = {
theme: SOLARIZED_THEME,
fontFamily: "'Fira Code', Menlo, Monaco, 'Courier New', monospace",
fontSize: 13,
allowProposedApi: true,
scrollback: 1000,
cursorBlink: true,
smoothScrollDuration: 100,
macOptionIsMeta: true,
macOptionClickForcesSelection: true,
convertEol: true,
linuxMode: false,
// Add these configurations:
cursorStyle: "block",
rendererType: "canvas", // Ensure we're using the canvas renderer
} as const;
function Terminal({
title,
dataChannel,
type,
}: {
title: string;
dataChannel: RTCDataChannel;
type: AvailableTerminalTypes;
}) {
const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType);
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
useEffect(() => {
setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal);
}, 500);
return () => {
setDisableKeyboardFocusTrap(false);
};
}, [enableTerminal, instance, ref, setDisableKeyboardFocusTrap, type]);
const readyState = dataChannel.readyState;
useEffect(() => {
if (readyState !== "open") return;
const abortController = new AbortController();
const binaryType = dataChannel.binaryType;
dataChannel.addEventListener(
"message",
e => {
// Handle binary data differently based on browser implementation
// Firefox sends data as blobs, chrome sends data as arraybuffer
if (binaryType === "arraybuffer") {
instance?.write(new Uint8Array(e.data));
} 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 },
);
const onDataHandler = instance?.onData(data => {
dataChannel.send(data);
});
// Setup escape key handler
const onKeyHandler = instance?.onKey(e => {
const { domEvent } = e;
if (domEvent.key === "Escape") {
setTerminalType("none");
setDisableKeyboardFocusTrap(false);
domEvent.preventDefault();
}
});
// Send initial terminal size
if (dataChannel.readyState === "open") {
dataChannel.send(JSON.stringify({ rows: instance?.rows, cols: instance?.cols }));
}
return () => {
abortController.abort();
onDataHandler?.dispose();
onKeyHandler?.dispose();
};
}, [dataChannel, instance, readyState, setDisableKeyboardFocusTrap, setTerminalType]);
useEffect(() => {
if (!instance) return;
// Load the fit addon
const fitAddon = new FitAddon();
instance?.loadAddon(fitAddon);
instance?.loadAddon(new ClipboardAddon());
instance?.loadAddon(new Unicode11Addon());
instance?.loadAddon(new WebLinksAddon());
instance.unicode.activeVersion = "11";
if (isWebGl2Supported) {
const webGl2Addon = new WebglAddon();
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
instance?.loadAddon(webGl2Addon);
}
const handleResize = () => fitAddon.fit();
// Handle resize event
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, [ref, instance, dataChannel]);
function TerminalWrapper() {
const enableTerminal = useUiStore(state => state.enableTerminal);
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
const terminalChannel = useRTCStore(state => state.terminalChannel);
return (
<div
onKeyDown={e => {
e.stopPropagation();
}}
onKeyUp={e => e.stopPropagation()}
>
<div>
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
<Transition show={enableTerminal} appear>
<div
className={cx(
[
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
],
{
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal,
"pointer-events-auto translate-y-[0px] opacity-100 transition duration-300":
enableTerminal,
},
)}
className={cx([
// Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]",
"data-[enter]:translate-y-[500px]",
"data-[closed]:translate-y-[500px]",
])}
>
<div className="h-[500px] w-full bg-[#0f172a]">
<div className="flex items-center justify-center border-y border-y-slate-800/30 bg-white px-2 py-1 dark:border-y-slate-300/20 dark:bg-slate-800">
<div className="flex items-center justify-center px-2 py-1 bg-white dark:bg-slate-800 border-y border-y-slate-800/30 dark:border-y-slate-300/20">
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
{title}
Web Terminal
</h2>
<div className="absolute right-2">
<Button
@ -194,19 +34,18 @@ function Terminal({
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setTerminalType("none")}
onClick={() => setEnableTerminal(false)}
/>
</div>
</div>
<div className="h-[calc(100%-36px)] p-3">
<div ref={ref} style={{ height: "100%", width: "100%" }} />
<XTerm terminalChannel={terminalChannel} />
</div>
</div>
</div>
</div>
</Transition>
</div>
);
}
export default Terminal;
export default TerminalWrapper;

View File

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

View File

@ -1,23 +1,23 @@
import React from "react";
import { cx } from "@/cva.config";
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
import React from "react";
import LoadingSpinner from "@components/LoadingSpinner";
import StatusCard from "@components/StatusCards";
import { HidState } from "@/hooks/stores";
type USBStates = HidState["usbState"];
type StatusProps = Record<
USBStates,
{
type StatusProps = {
[key in USBStates]: {
icon: React.FC<{ className: string | undefined }>;
iconClassName: string;
statusIndicatorClassName: string;
}
>;
};
};
const USBStateMap: Record<USBStates, string> = {
const USBStateMap: {
[key in USBStates]: string;
} = {
configured: "Connected",
attached: "Connecting",
addressed: "Connecting",
@ -30,8 +30,9 @@ export default function USBStateStatus({
peerConnectionState,
}: {
state: USBStates;
peerConnectionState?: RTCPeerConnectionState | null;
peerConnectionState?: RTCPeerConnectionState;
}) {
const StatusCardProps: StatusProps = {
configured: {
icon: ({ className }) => (
@ -67,7 +68,7 @@ export default function USBStateStatus({
};
const props = StatusCardProps[state];
if (!props) {
console.log("Unsupported USB state: ", state);
console.log("Unsupport USB state: ", state);
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 { CheckCircleIcon } from "@heroicons/react/20/solid";
import Card from "@/components/Card";
import { useJsonRpc } from "@/hooks/useJsonRpc";
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 LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const [send] = useJsonRpc();
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView]);
useEffect(() => {
if (otaState.updating) {
setModalView("updating");
} else if (otaState.error) {
setModalView("error");
} else if (updateSuccess) {
setModalView("updateCompleted");
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
{
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
}
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
}
import { CheckCircleIcon } from "@heroicons/react/20/solid";
import LoadingSpinner from "./LoadingSpinner";
export interface SystemVersionInfo {
local: { appVersion: string; systemVersion: string };
@ -48,15 +17,37 @@ export interface SystemVersionInfo {
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({
onClose,
setOpen,
onConfirmUpdate,
}: {
onClose: () => void;
setOpen: (open: boolean) => void;
onConfirmUpdate: () => void;
}) {
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
@ -82,24 +73,27 @@ export function Dialog({
}, [setModalView]);
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
<GridCard cardClassName="mx-auto relative max-w-md text-left pointer-events-auto">
<div className="p-10">
{modalView === "error" && (
<UpdateErrorState
errorMessage={otaState.error}
onClose={onClose}
onClose={() => setOpen(false)}
onRetryUpdate={() => setModalView("loading")}
/>
)}
{modalView === "loading" && (
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
<LoadingState
onFinished={onFinishedLoading}
onCancelCheck={() => setOpen(false)}
/>
)}
{modalView === "updateAvailable" && (
<UpdateAvailableState
onConfirmUpdate={onConfirmUpdate}
onClose={onClose}
onClose={() => setOpen(false)}
versionInfo={versionInfo!}
/>
)}
@ -107,20 +101,24 @@ export function Dialog({
{modalView === "updating" && (
<UpdatingDeviceState
otaState={otaState}
onMinimizeUpgradeDialog={() => navigateTo("/")}
onMinimizeUpgradeDialog={() => {
setOpen(false);
}}
/>
)}
{modalView === "upToDate" && (
<SystemUpToDateState
checkUpdate={() => setModalView("loading")}
onClose={onClose}
onClose={() => setOpen(false)}
/>
)}
{modalView === "updateCompleted" && <UpdateCompletedState onClose={onClose} />}
{modalView === "updateCompleted" && (
<UpdateCompletedState onClose={() => setOpen(false)} />
)}
</div>
</div>
</GridCard>
);
}
@ -135,9 +133,6 @@ function LoadingState({
const abortControllerRef = useRef<AbortController | null>(null);
const [send] = useJsonRpc();
const setAppVersion = useDeviceStore(state => state.setAppVersion);
const setSystemVersion = useDeviceStore(state => state.setSystemVersion);
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, async resp => {
@ -146,13 +141,11 @@ function LoadingState({
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
resolve(result);
}
});
});
}, [send, setAppVersion, setSystemVersion]);
}, [send]);
const progressBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@ -163,12 +156,14 @@ function LoadingState({
const animationTimer = setTimeout(() => {
setProgressWidth("100%");
}, 0);
}, 500);
getVersionInfo()
.then(versionInfo => {
// Add a small delay to ensure it's not just flickering
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
if (progressBarRef.current) {
progressBarRef.current?.classList.add("!duration-1000");
}
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 1000));
})
.then(versionInfo => {
if (!signal.aborted) {
@ -188,8 +183,12 @@ function LoadingState({
}, [getVersionInfo, onFinished]);
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="space-y-4">
<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="max-w-sm space-y-4">
<div className="space-y-0">
<p className="text-base font-semibold text-black dark:text-white">
Checking for updates...
@ -202,11 +201,18 @@ function LoadingState({
<div
ref={progressBarRef}
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 className="mt-4">
<Button size="SM" theme="light" text="Cancel" onClick={onCancelCheck} />
<Button
size="SM"
theme="light"
text="Cancel"
onClick={() => {
onCancelCheck();
}}
/>
</div>
</div>
</div>
@ -288,7 +294,11 @@ function UpdatingDeviceState({
};
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="space-y-0">
<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.
</p>
</div>
<Card className="space-y-4 p-4">
<Card className="p-4 space-y-4">
{areAllUpdatesComplete() ? (
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<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">
<span className="font-medium text-black dark:text-white">
Rebooting to complete the update...
@ -311,8 +321,8 @@ function UpdatingDeviceState({
) : (
<>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex flex-col items-center my-2 space-y-2 text-center">
<LoadingSpinner className="w-6 h-6 text-blue-700 dark:text-blue-500" />
</div>
)}
@ -323,9 +333,9 @@ function UpdatingDeviceState({
Linux System Update
</p>
{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 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
</p>
{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 className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
@ -380,7 +390,7 @@ function UpdatingDeviceState({
</>
)}
</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
size="XS"
theme="light"
@ -401,7 +411,11 @@ function SystemUpToDateState({
onClose: () => void;
}) {
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">
<p className="text-base font-semibold text-black dark:text-white">
System is up to date
@ -410,9 +424,23 @@ function SystemUpToDateState({
Your system is running the latest version. No updates are currently available.
</p>
<div className="mt-4 flex gap-x-2">
<Button size="SM" theme="light" text="Check Again" onClick={checkUpdate} />
<Button size="SM" theme="blank" text="Back" onClick={onClose} />
<div className="flex mt-4 gap-x-2">
<Button
size="SM"
theme="light"
text="Check Again"
onClick={() => {
checkUpdate();
}}
/>
<Button
size="SM"
theme="blank"
text="Close"
onClick={() => {
onClose();
}}
/>
</div>
</div>
</div>
@ -429,7 +457,11 @@ function UpdateAvailableState({
onClose: () => void;
}) {
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">
<p className="text-base font-semibold text-black dark:text-white">
Update available
@ -463,7 +495,11 @@ function UpdateAvailableState({
function UpdateCompletedState({ onClose }: { onClose: () => void }) {
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">
<p className="text-base font-semibold dark:text-white">
Update Completed Successfully
@ -473,7 +509,7 @@ function UpdateCompletedState({ onClose }: { onClose: () => void }) {
features and improvements!
</p>
<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>
@ -490,7 +526,11 @@ function UpdateErrorState({
onRetryUpdate: () => void;
}) {
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">
<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">
@ -502,8 +542,8 @@ function UpdateErrorState({
</p>
)}
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="light" text="Back" onClick={onClose} />
<Button size="SM" theme="blank" text="Retry" onClick={onRetryUpdate} />
<Button size="SM" theme="primary" text="Close" onClick={onClose} />
<Button size="SM" theme="primary" text="Retry" onClick={onRetryUpdate} />
</div>
</div>
</div>

View File

@ -1,22 +1,26 @@
import { cx } from "@/cva.config";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
import { Button } from "./Button";
import { GridCard } from "./Card";
import LoadingSpinner from "./LoadingSpinner";
import { UpdateState } from "@/hooks/stores";
export default function UpdateInProgressStatusCard() {
const { navigateTo } = useDeviceUiNavigation();
interface UpdateInProgressStatusCardProps {
setIsUpdateDialogOpen: (isOpen: boolean) => void;
setModalView: (view: UpdateState["modalView"]) => void;
}
export default function UpdateInProgressStatusCard({
setIsUpdateDialogOpen,
setModalView,
}: UpdateInProgressStatusCardProps) {
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">
<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">
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
<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
</div>
<div className="text-sm leading-none">
@ -33,7 +37,10 @@ export default function UpdateInProgressStatusCard() {
className="pointer-events-auto"
theme="light"
text="View Details"
onClick={() => navigateTo("/settings/general/update")}
onClick={() => {
setModalView("updating");
setIsUpdateDialogOpen(true);
}}
/>
</div>
</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>
);
}

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