mirror of https://github.com/jetkvm/kvm.git
Merge remote-tracking branch 'upstream/dev' into keyboard-layouts
This commit is contained in:
commit
95c14102cd
|
@ -6,5 +6,9 @@
|
||||||
// Should match what is defined in ui/package.json
|
// Should match what is defined in ui/package.json
|
||||||
"version": "21.1.0"
|
"version": "21.1.0"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"mounts": [
|
||||||
|
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
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
|
|
@ -0,0 +1,37 @@
|
||||||
|
---
|
||||||
|
name: golangci-lint
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths:
|
||||||
|
- "go.sum"
|
||||||
|
- "go.mod"
|
||||||
|
- "**.go"
|
||||||
|
- ".github/workflows/golangci-lint.yml"
|
||||||
|
- ".golangci.yml"
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions: # added using https://github.com/step-security/secure-repo
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
golangci:
|
||||||
|
permissions:
|
||||||
|
contents: read # for actions/checkout to fetch code
|
||||||
|
pull-requests: read # for golangci/golangci-lint-action to fetch pull requests
|
||||||
|
name: lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- name: Install Go
|
||||||
|
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0
|
||||||
|
with:
|
||||||
|
go-version: 1.23.x
|
||||||
|
- name: Create empty resource directory
|
||||||
|
run: |
|
||||||
|
mkdir -p static && touch static/.gitkeep
|
||||||
|
- name: Lint
|
||||||
|
uses: golangci/golangci-lint-action@971e284b6050e8a5849b72094c50ab08da042db8 # v6.1.1
|
||||||
|
with:
|
||||||
|
args: --verbose
|
||||||
|
version: v1.62.0
|
|
@ -0,0 +1,122 @@
|
||||||
|
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
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
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
|
|
@ -0,0 +1,22 @@
|
||||||
|
---
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- forbidigo
|
||||||
|
- goimports
|
||||||
|
- misspell
|
||||||
|
# - revive
|
||||||
|
- whitespace
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: _test.go
|
||||||
|
linters:
|
||||||
|
- errcheck
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
forbidigo:
|
||||||
|
forbid:
|
||||||
|
- p: ^fmt\.Print.*$
|
||||||
|
msg: Do not commit print statements. Use logger package.
|
||||||
|
- p: ^log\.(Fatal|Panic|Print)(f|ln)?.*$
|
||||||
|
msg: Do not commit log statements. Use logger package.
|
24
Makefile
24
Makefile
|
@ -1,17 +1,31 @@
|
||||||
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
|
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
VERSION := 0.3.4
|
BUILDDATE ?= $(shell date -u +%FT%T%z)
|
||||||
|
BUILDTS ?= $(shell date -u +%s)
|
||||||
|
REVISION ?= $(shell git rev-parse HEAD)
|
||||||
|
VERSION_DEV := 0.3.9-dev$(shell date +%Y%m%d%H%M)
|
||||||
|
VERSION := 0.3.8
|
||||||
|
|
||||||
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
|
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
|
|
||||||
|
GO_LDFLAGS := \
|
||||||
|
-s -w \
|
||||||
|
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
|
||||||
|
-X $(PROMETHEUS_TAG).BuildDate=$(BUILDDATE) \
|
||||||
|
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \
|
||||||
|
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
|
||||||
|
|
||||||
hash_resource:
|
hash_resource:
|
||||||
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
|
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
|
||||||
|
|
||||||
build_dev: hash_resource
|
build_dev: hash_resource
|
||||||
@echo "Building..."
|
@echo "Building..."
|
||||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
|
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
cd ui && npm ci && npm run build:device
|
cd ui && npm ci && npm run build:device
|
||||||
|
|
||||||
dev_release: build_dev
|
dev_release: frontend build_dev
|
||||||
@echo "Uploading release..."
|
@echo "Uploading release..."
|
||||||
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
|
||||||
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
||||||
|
@ -19,7 +33,7 @@ dev_release: build_dev
|
||||||
|
|
||||||
build_release: frontend hash_resource
|
build_release: frontend hash_resource
|
||||||
@echo "Building release..."
|
@echo "Building release..."
|
||||||
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
|
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
|
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
|
||||||
|
|
|
@ -23,7 +23,7 @@ We welcome contributions from the community! Whether it's improving the firmware
|
||||||
|
|
||||||
## I need help
|
## I need help
|
||||||
|
|
||||||
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://discord.gg/8MaAhua7NW).
|
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).
|
||||||
|
|
||||||
## I want to report an issue
|
## I want to report an issue
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ package kvm
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
@ -17,8 +16,8 @@ type remoteImageBackend struct {
|
||||||
|
|
||||||
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
|
func (r remoteImageBackend) ReadAt(p []byte, off int64) (n int, err error) {
|
||||||
virtualMediaStateMutex.RLock()
|
virtualMediaStateMutex.RLock()
|
||||||
logger.Debugf("currentVirtualMediaState is %v", currentVirtualMediaState)
|
logger.Debug().Interface("currentVirtualMediaState", currentVirtualMediaState).Msg("currentVirtualMediaState")
|
||||||
logger.Debugf("read size: %d, off: %d", len(p), off)
|
logger.Debug().Int64("read size", int64(len(p))).Int64("off", off).Msg("read size and off")
|
||||||
if currentVirtualMediaState == nil {
|
if currentVirtualMediaState == nil {
|
||||||
return 0, errors.New("image not mounted")
|
return 0, errors.New("image not mounted")
|
||||||
}
|
}
|
||||||
|
@ -94,7 +93,8 @@ func (d *NBDDevice) Start() error {
|
||||||
// Remove the socket file if it already exists
|
// Remove the socket file if it already exists
|
||||||
if _, err := os.Stat(nbdSocketPath); err == nil {
|
if _, err := os.Stat(nbdSocketPath); err == nil {
|
||||||
if err := os.Remove(nbdSocketPath); err != nil {
|
if err := os.Remove(nbdSocketPath); err != nil {
|
||||||
log.Fatalf("Failed to remove existing socket file %s: %v", nbdSocketPath, err)
|
nativeLogger.Warn().Err(err).Str("socket_path", nbdSocketPath).Msg("Failed to remove existing socket file")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -134,7 +134,7 @@ func (d *NBDDevice) runServerConn() {
|
||||||
MaximumBlockSize: uint32(16 * 1024),
|
MaximumBlockSize: uint32(16 * 1024),
|
||||||
SupportsMultiConn: false,
|
SupportsMultiConn: false,
|
||||||
})
|
})
|
||||||
log.Println("nbd server exited:", err)
|
nativeLogger.Info().Err(err).Msg("nbd server exited")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *NBDDevice) runClientConn() {
|
func (d *NBDDevice) runClientConn() {
|
||||||
|
@ -142,14 +142,14 @@ func (d *NBDDevice) runClientConn() {
|
||||||
ExportName: "jetkvm",
|
ExportName: "jetkvm",
|
||||||
BlockSize: uint32(4 * 1024),
|
BlockSize: uint32(4 * 1024),
|
||||||
})
|
})
|
||||||
log.Println("nbd client exited:", err)
|
nativeLogger.Info().Err(err).Msg("nbd client exited")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *NBDDevice) Close() {
|
func (d *NBDDevice) Close() {
|
||||||
if d.dev != nil {
|
if d.dev != nil {
|
||||||
err := client.Disconnect(d.dev)
|
err := client.Disconnect(d.dev)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("error disconnecting nbd client:", err)
|
nativeLogger.Warn().Err(err).Msg("error disconnecting nbd client")
|
||||||
}
|
}
|
||||||
_ = d.dev.Close()
|
_ = d.dev.Close()
|
||||||
}
|
}
|
||||||
|
|
330
cloud.go
330
cloud.go
|
@ -4,16 +4,21 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"github.com/coder/websocket/wsjson"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/coder/websocket/wsjson"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
|
||||||
"github.com/coreos/go-oidc/v3/oidc"
|
"github.com/coreos/go-oidc/v3/oidc"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/coder/websocket"
|
"github.com/coder/websocket"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CloudRegisterRequest struct {
|
type CloudRegisterRequest struct {
|
||||||
|
@ -23,6 +28,142 @@ type CloudRegisterRequest struct {
|
||||||
ClientId string `json:"clientId"`
|
ClientId string `json:"clientId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// CloudWebSocketConnectTimeout is the timeout for the websocket connection to the cloud
|
||||||
|
CloudWebSocketConnectTimeout = 1 * time.Minute
|
||||||
|
// CloudAPIRequestTimeout is the timeout for cloud API requests
|
||||||
|
CloudAPIRequestTimeout = 10 * time.Second
|
||||||
|
// CloudOidcRequestTimeout is the timeout for OIDC token verification requests
|
||||||
|
// should be lower than the websocket response timeout set in cloud-api
|
||||||
|
CloudOidcRequestTimeout = 10 * time.Second
|
||||||
|
// WebsocketPingInterval is the interval at which the websocket client sends ping messages to the cloud
|
||||||
|
WebsocketPingInterval = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricCloudConnectionStatus = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_status",
|
||||||
|
Help: "The status of the cloud connection",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_established_timestamp",
|
||||||
|
Help: "The timestamp when the cloud connection was established",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_ping_timestamp",
|
||||||
|
Help: "The timestamp when the last ping response was received",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_ping_received_timestamp",
|
||||||
|
Help: "The timestamp when the last ping request was received",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastPingDuration = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_ping_duration",
|
||||||
|
Help: "The duration of the last ping response",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionPingDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "jetkvm_connection_ping_duration",
|
||||||
|
Help: "The duration of the ping response",
|
||||||
|
Buckets: []float64{
|
||||||
|
0.1, 0.5, 1, 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_connection_total_ping_sent",
|
||||||
|
Help: "The total number of pings sent to the connection",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_connection_total_ping_received",
|
||||||
|
Help: "The total number of pings received from the connection",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionSessionRequestCount = promauto.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_connection_session_total_requests",
|
||||||
|
Help: "The total number of session requests received",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "jetkvm_connection_session_request_duration",
|
||||||
|
Help: "The duration of session requests",
|
||||||
|
Buckets: []float64{
|
||||||
|
0.1, 0.5, 1, 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_session_request_timestamp",
|
||||||
|
Help: "The timestamp of the last session request",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
|
||||||
|
prometheus.GaugeOpts{
|
||||||
|
Name: "jetkvm_connection_last_session_request_duration",
|
||||||
|
Help: "The duration of the last session request",
|
||||||
|
},
|
||||||
|
[]string{"type", "source"},
|
||||||
|
)
|
||||||
|
metricCloudConnectionFailureCount = promauto.NewCounter(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "jetkvm_cloud_connection_failure_count",
|
||||||
|
Help: "The number of times the cloud connection has failed",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cloudDisconnectChan chan error
|
||||||
|
cloudDisconnectLock = &sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func wsResetMetrics(established bool, sourceType string, source string) {
|
||||||
|
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
|
||||||
|
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
|
||||||
|
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
|
||||||
|
|
||||||
|
if sourceType != "cloud" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if established {
|
||||||
|
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
|
||||||
|
metricCloudConnectionStatus.Set(1)
|
||||||
|
} else {
|
||||||
|
metricCloudConnectionEstablishedTimestamp.Set(-1)
|
||||||
|
metricCloudConnectionStatus.Set(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func handleCloudRegister(c *gin.Context) {
|
func handleCloudRegister(c *gin.Context) {
|
||||||
var req CloudRegisterRequest
|
var req CloudRegisterRequest
|
||||||
|
|
||||||
|
@ -43,22 +184,31 @@ func handleCloudRegister(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.Post(req.CloudAPI+"/devices/token", "application/json", bytes.NewBuffer(jsonPayload))
|
client := &http.Client{Timeout: CloudAPIRequestTimeout}
|
||||||
|
|
||||||
|
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
apiResp, err := client.Do(apiReq)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer apiResp.Body.Close()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if apiResp.StatusCode != http.StatusOK {
|
||||||
c.JSON(resp.StatusCode, gin.H{"error": "Failed to exchange token: " + resp.Status})
|
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var tokenResp struct {
|
var tokenResp struct {
|
||||||
SecretToken string `json:"secretToken"`
|
SecretToken string `json:"secretToken"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil {
|
||||||
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
|
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -69,7 +219,6 @@ func handleCloudRegister(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
config.CloudToken = tokenResp.SecretToken
|
config.CloudToken = tokenResp.SecretToken
|
||||||
config.CloudURL = req.CloudAPI
|
|
||||||
|
|
||||||
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -99,76 +248,91 @@ func handleCloudRegister(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "Cloud registration successful"})
|
c.JSON(200, gin.H{"message": "Cloud registration successful"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func disconnectCloud(reason error) {
|
||||||
|
cloudDisconnectLock.Lock()
|
||||||
|
defer cloudDisconnectLock.Unlock()
|
||||||
|
|
||||||
|
if cloudDisconnectChan == nil {
|
||||||
|
cloudLogger.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 {
|
func runWebsocketClient() error {
|
||||||
if config.CloudToken == "" {
|
if config.CloudToken == "" {
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
return fmt.Errorf("cloud token is not set")
|
return fmt.Errorf("cloud token is not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL, err := url.Parse(config.CloudURL)
|
wsURL, err := url.Parse(config.CloudURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
|
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if wsURL.Scheme == "http" {
|
if wsURL.Scheme == "http" {
|
||||||
wsURL.Scheme = "ws"
|
wsURL.Scheme = "ws"
|
||||||
} else {
|
} else {
|
||||||
wsURL.Scheme = "wss"
|
wsURL.Scheme = "wss"
|
||||||
}
|
}
|
||||||
|
|
||||||
header := http.Header{}
|
header := http.Header{}
|
||||||
header.Set("X-Device-ID", GetDeviceID())
|
header.Set("X-Device-ID", GetDeviceID())
|
||||||
|
header.Set("X-App-Version", builtAppVersion)
|
||||||
header.Set("Authorization", "Bearer "+config.CloudToken)
|
header.Set("Authorization", "Bearer "+config.CloudToken)
|
||||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), time.Minute)
|
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
|
||||||
|
|
||||||
|
scopedLogger := websocketLogger.With().
|
||||||
|
Str("source", wsURL.Host).
|
||||||
|
Str("sourceType", "cloud").
|
||||||
|
Logger()
|
||||||
|
|
||||||
defer cancelDial()
|
defer cancelDial()
|
||||||
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
|
c, _, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
|
||||||
HTTPHeader: header,
|
HTTPHeader: header,
|
||||||
|
OnPingReceived: func(ctx context.Context, payload []byte) bool {
|
||||||
|
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
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
// if the context is canceled, we don't want to return an error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
cloudLogger.Info().Msg("websocket connection canceled")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer c.CloseNow()
|
defer c.CloseNow() //nolint:errcheck
|
||||||
logger.Infof("WS connected to %v", wsURL.String())
|
cloudLogger.Info().Str("url", wsURL.String()).Msg("websocket connected")
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
err = handleSessionRequest(runCtx, c, req)
|
// set the metrics when we successfully connect to the cloud.
|
||||||
if err != nil {
|
wsResetMetrics(true, "cloud", wsURL.Host)
|
||||||
logger.Infof("error starting new session: %v", err)
|
|
||||||
continue
|
// we don't have a source for the cloud connection
|
||||||
}
|
return handleWebRTCSignalWsMessages(c, true, wsURL.Host, &scopedLogger)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
|
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
|
||||||
oidcCtx, cancelOIDC := context.WithTimeout(ctx, time.Minute)
|
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
|
||||||
defer cancelOIDC()
|
defer cancelOIDC()
|
||||||
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
|
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Failed to initialize OIDC provider:", err)
|
_ = wsjson.Write(context.Background(), c, gin.H{
|
||||||
|
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
|
||||||
|
})
|
||||||
|
cloudLogger.Warn().Err(err).Msg("failed to initialize OIDC provider")
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,10 +348,40 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
||||||
|
|
||||||
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
|
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
|
||||||
if config.GoogleIdentity != googleIdentity {
|
if config.GoogleIdentity != googleIdentity {
|
||||||
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"})
|
||||||
return fmt.Errorf("google identity mismatch")
|
return fmt.Errorf("google identity mismatch")
|
||||||
}
|
}
|
||||||
|
|
||||||
session, err := newSession()
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest, isCloudConnection bool, source string) error {
|
||||||
|
var sourceType string
|
||||||
|
if isCloudConnection {
|
||||||
|
sourceType = "cloud"
|
||||||
|
} else {
|
||||||
|
sourceType = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
|
||||||
|
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
|
||||||
|
metricConnectionSessionRequestDuration.WithLabelValues(sourceType, source).Observe(v)
|
||||||
|
}))
|
||||||
|
defer timer.ObserveDuration()
|
||||||
|
|
||||||
|
// If the message is from the cloud, we need to authenticate the session.
|
||||||
|
if isCloudConnection {
|
||||||
|
if err := authenticateSession(ctx, c, req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, err := newSession(SessionConfig{
|
||||||
|
ws: c,
|
||||||
|
IsCloud: isCloudConnection,
|
||||||
|
LocalIP: req.IP,
|
||||||
|
ICEServers: req.ICEServers,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
_ = wsjson.Write(context.Background(), c, gin.H{"error": err})
|
||||||
return err
|
return err
|
||||||
|
@ -206,16 +400,41 @@ func handleSessionRequest(ctx context.Context, c *websocket.Conn, req WebRTCSess
|
||||||
_ = peerConn.Close()
|
_ = peerConn.Close()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloudLogger.Info().Interface("session", session).Msg("new session accepted")
|
||||||
|
cloudLogger.Trace().Interface("session", session).Msg("new session accepted")
|
||||||
currentSession = session
|
currentSession = session
|
||||||
_ = wsjson.Write(context.Background(), c, gin.H{"sd": sd})
|
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunWebsocketClient() {
|
func RunWebsocketClient() {
|
||||||
for {
|
for {
|
||||||
|
// If the cloud token is not set, we don't need to run the websocket client.
|
||||||
|
if config.CloudToken == "" {
|
||||||
|
time.Sleep(5 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the network is not up, well, we can't connect to the cloud.
|
||||||
|
if !networkState.Up {
|
||||||
|
cloudLogger.Warn().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()
|
err := runWebsocketClient()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Websocket client error:", err)
|
cloudLogger.Warn().Err(err).Msg("websocket client error")
|
||||||
|
metricCloudConnectionStatus.Set(0)
|
||||||
|
metricCloudConnectionFailureCount.Inc()
|
||||||
time.Sleep(5 * time.Second)
|
time.Sleep(5 * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -224,12 +443,14 @@ func RunWebsocketClient() {
|
||||||
type CloudState struct {
|
type CloudState struct {
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
AppURL string `json:"appUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetCloudState() CloudState {
|
func rpcGetCloudState() CloudState {
|
||||||
return CloudState{
|
return CloudState{
|
||||||
Connected: config.CloudToken != "" && config.CloudURL != "",
|
Connected: config.CloudToken != "" && config.CloudURL != "",
|
||||||
URL: config.CloudURL,
|
URL: config.CloudURL,
|
||||||
|
AppURL: config.CloudAppURL,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -244,7 +465,7 @@ func rpcDeregisterDevice() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
|
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
|
||||||
client := &http.Client{Timeout: 10 * time.Second}
|
client := &http.Client{Timeout: CloudAPIRequestTimeout}
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to send deregister request: %w", err)
|
return fmt.Errorf("failed to send deregister request: %w", err)
|
||||||
|
@ -257,12 +478,15 @@ func rpcDeregisterDevice() error {
|
||||||
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
|
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
|
||||||
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
|
||||||
config.CloudToken = ""
|
config.CloudToken = ""
|
||||||
config.CloudURL = ""
|
|
||||||
config.GoogleIdentity = ""
|
config.GoogleIdentity = ""
|
||||||
|
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
|
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
|
||||||
|
disconnectCloud(fmt.Errorf("device deregistered"))
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"kvm"
|
"github.com/jetkvm/kvm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
156
config.go
156
config.go
|
@ -4,6 +4,9 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
)
|
)
|
||||||
|
|
||||||
type WakeOnLanDevice struct {
|
type WakeOnLanDevice struct {
|
||||||
|
@ -11,56 +14,167 @@ type WakeOnLanDevice struct {
|
||||||
MacAddress string `json:"macAddress"`
|
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 {
|
type Config struct {
|
||||||
CloudURL string `json:"cloud_url"`
|
CloudURL string `json:"cloud_url"`
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudAppURL string `json:"cloud_app_url"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
CloudToken string `json:"cloud_token"`
|
||||||
JigglerEnabled bool `json:"jiggler_enabled"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
JigglerEnabled bool `json:"jiggler_enabled"`
|
||||||
KeyboardLayout string `json:"keyboard_layout"`
|
AutoUpdateEnabled bool `json:"auto_update_enabled"`
|
||||||
KeyboardMappingEnabled bool `json:"keyboard_mapping_enabled"`
|
KeyboardLayout string `json:"keyboard_layout"`
|
||||||
IncludePreRelease bool `json:"include_pre_release"`
|
KeyboardMappingEnabled bool `json:"keyboard_mapping_enabled"`
|
||||||
HashedPassword string `json:"hashed_password"`
|
IncludePreRelease bool `json:"include_pre_release"`
|
||||||
LocalAuthToken string `json:"local_auth_token"`
|
HashedPassword string `json:"hashed_password"`
|
||||||
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
|
LocalAuthToken string `json:"local_auth_token"`
|
||||||
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const configPath = "/userdata/kvm_config.json"
|
const configPath = "/userdata/kvm_config.json"
|
||||||
|
|
||||||
var defaultConfig = &Config{
|
var defaultConfig = &Config{
|
||||||
CloudURL: "https://api.jetkvm.com",
|
CloudURL: "https://api.jetkvm.com",
|
||||||
|
CloudAppURL: "https://app.jetkvm.com",
|
||||||
AutoUpdateEnabled: true, // Set a default value
|
AutoUpdateEnabled: true, // Set a default value
|
||||||
KeyboardLayout: "en-US",
|
KeyboardLayout: "en-US",
|
||||||
KeyboardMappingEnabled: false,
|
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,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var config *Config
|
var (
|
||||||
|
config *Config
|
||||||
|
configLock = &sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
func LoadConfig() {
|
func LoadConfig() {
|
||||||
|
configLock.Lock()
|
||||||
|
defer configLock.Unlock()
|
||||||
|
|
||||||
if config != nil {
|
if config != nil {
|
||||||
|
logger.Info().Msg("config already loaded, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// load the default config
|
||||||
|
config = defaultConfig
|
||||||
|
|
||||||
file, err := os.Open(configPath)
|
file, err := os.Open(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("default config file doesn't exist, using default")
|
logger.Debug().Msg("default config file doesn't exist, using default")
|
||||||
config = defaultConfig
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var loadedConfig Config
|
// load and merge the default config with the user config
|
||||||
|
loadedConfig := *defaultConfig
|
||||||
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
if err := json.NewDecoder(file).Decode(&loadedConfig); err != nil {
|
||||||
logger.Errorf("config file JSON parsing failed, %v", err)
|
logger.Warn().Err(err).Msg("config file JSON parsing failed")
|
||||||
config = defaultConfig
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// merge the user config with the default config
|
||||||
|
if loadedConfig.UsbConfig == nil {
|
||||||
|
loadedConfig.UsbConfig = defaultConfig.UsbConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadedConfig.UsbDevices == nil {
|
||||||
|
loadedConfig.UsbDevices = defaultConfig.UsbDevices
|
||||||
|
}
|
||||||
|
|
||||||
config = &loadedConfig
|
config = &loadedConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func SaveConfig() error {
|
func SaveConfig() error {
|
||||||
|
configLock.Lock()
|
||||||
|
defer configLock.Unlock()
|
||||||
|
|
||||||
|
logger.Trace().Str("path", configPath).Msg("Saving config")
|
||||||
|
|
||||||
file, err := os.Create(configPath)
|
file, err := os.Create(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create config file: %w", err)
|
return fmt.Errorf("failed to create config file: %w", err)
|
||||||
|
@ -75,3 +189,9 @@ func SaveConfig() error {
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureConfigLoaded() {
|
||||||
|
if config == nil {
|
||||||
|
LoadConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
# Exit immediately if a command exits with a non-zero status
|
# Exit immediately if a command exits with a non-zero status
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
@ -10,17 +12,18 @@ show_help() {
|
||||||
echo
|
echo
|
||||||
echo "Optional:"
|
echo "Optional:"
|
||||||
echo " -u, --user <remote_user> Remote username (default: root)"
|
echo " -u, --user <remote_user> Remote username (default: root)"
|
||||||
|
echo " --skip-ui-build Skip frontend/UI build"
|
||||||
echo " --help Display this help message"
|
echo " --help Display this help message"
|
||||||
echo
|
echo
|
||||||
echo "Example:"
|
echo "Example:"
|
||||||
echo " $0 -r 192.168.0.17"
|
echo " $0 -r 192.168.0.17"
|
||||||
echo " $0 -r 192.168.0.17 -u admin"
|
echo " $0 -r 192.168.0.17 -u admin"
|
||||||
exit 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
REMOTE_USER="root"
|
REMOTE_USER="root"
|
||||||
REMOTE_PATH="/userdata/jetkvm/bin"
|
REMOTE_PATH="/userdata/jetkvm/bin"
|
||||||
|
SKIP_UI_BUILD=false
|
||||||
|
|
||||||
# Parse command line arguments
|
# Parse command line arguments
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
|
@ -33,6 +36,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
REMOTE_USER="$2"
|
REMOTE_USER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--skip-ui-build)
|
||||||
|
SKIP_UI_BUILD=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--help)
|
--help)
|
||||||
show_help
|
show_help
|
||||||
exit 0
|
exit 0
|
||||||
|
@ -52,17 +59,22 @@ if [ -z "$REMOTE_HOST" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the development version on the host
|
# Build the development version on the host
|
||||||
make frontend
|
if [ "$SKIP_UI_BUILD" = false ]; then
|
||||||
|
make frontend
|
||||||
|
fi
|
||||||
make build_dev
|
make build_dev
|
||||||
|
|
||||||
# Change directory to the binary output directory
|
# Change directory to the binary output directory
|
||||||
cd bin
|
cd bin
|
||||||
|
|
||||||
|
# Kill any existing instances of the application
|
||||||
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
|
||||||
|
|
||||||
# Copy the binary to the remote host
|
# Copy the binary to the remote host
|
||||||
cat jetkvm_app | ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > $REMOTE_PATH/jetkvm_app_debug"
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app
|
||||||
|
|
||||||
# Deploy and run the application on the remote host
|
# Deploy and run the application on the remote host
|
||||||
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash <<EOF
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Set the library path to include the directory where librockit.so is located
|
# Set the library path to include the directory where librockit.so is located
|
||||||
|
@ -74,14 +86,13 @@ killall jetkvm_app_debug || true
|
||||||
killall jetkvm_native || true
|
killall jetkvm_native || true
|
||||||
|
|
||||||
# Navigate to the directory where the binary will be stored
|
# Navigate to the directory where the binary will be stored
|
||||||
cd "$REMOTE_PATH"
|
cd "${REMOTE_PATH}"
|
||||||
|
|
||||||
# Make the new binary executable
|
# Make the new binary executable
|
||||||
chmod +x jetkvm_app_debug
|
chmod +x jetkvm_app_debug
|
||||||
|
|
||||||
# Run the application in the background
|
# Run the application in the background
|
||||||
./jetkvm_app_debug
|
PION_LOG_TRACE=jetkvm,cloud,websocket ./jetkvm_app_debug
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "Deployment complete."
|
echo "Deployment complete."
|
||||||
|
|
183
display.go
183
display.go
|
@ -1,17 +1,30 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"os"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var currentScreen = "ui_Boot_Screen"
|
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) {
|
func switchToScreen(screen string) {
|
||||||
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
|
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("failed to switch to screen %s: %v", screen, err)
|
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
currentScreen = screen
|
currentScreen = screen
|
||||||
|
@ -27,7 +40,7 @@ func updateLabelIfChanged(objName string, newText string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchToScreenIfDifferent(screenName string) {
|
func switchToScreenIfDifferent(screenName string) {
|
||||||
fmt.Println("switching screen from", currentScreen, screenName)
|
displayLogger.Info().Str("from", currentScreen).Str("to", screenName).Msg("switching screen")
|
||||||
if currentScreen != screenName {
|
if currentScreen != screenName {
|
||||||
switchToScreen(screenName)
|
switchToScreen(screenName)
|
||||||
}
|
}
|
||||||
|
@ -61,11 +74,12 @@ var displayInited = false
|
||||||
|
|
||||||
func requestDisplayUpdate() {
|
func requestDisplayUpdate() {
|
||||||
if !displayInited {
|
if !displayInited {
|
||||||
fmt.Println("display not inited, skipping updates")
|
displayLogger.Info().Msg("display not inited, skipping updates")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
fmt.Println("display updating........................")
|
wakeDisplay(false)
|
||||||
|
displayLogger.Info().Msg("display updating")
|
||||||
//TODO: only run once regardless how many pending updates
|
//TODO: only run once regardless how many pending updates
|
||||||
updateDisplay()
|
updateDisplay()
|
||||||
}()
|
}()
|
||||||
|
@ -83,14 +97,169 @@ func updateStaticContents() {
|
||||||
updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID())
|
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:gosimple
|
||||||
|
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:gosimple
|
||||||
|
select {
|
||||||
|
case <-offTicker.C:
|
||||||
|
tick_displayOff()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
waitCtrlClientConnected()
|
waitCtrlClientConnected()
|
||||||
fmt.Println("setting initial display contents")
|
displayLogger.Info().Msg("setting initial display contents")
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(500 * time.Millisecond)
|
||||||
updateStaticContents()
|
updateStaticContents()
|
||||||
displayInited = true
|
displayInited = true
|
||||||
fmt.Println("display inited")
|
displayLogger.Info().Msg("display inited")
|
||||||
|
startBacklightTickers()
|
||||||
|
wakeDisplay(true)
|
||||||
requestDisplayUpdate()
|
requestDisplayUpdate()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go watchTsEvents()
|
||||||
}
|
}
|
||||||
|
|
3
fuse.go
3
fuse.go
|
@ -2,7 +2,6 @@ package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
@ -104,7 +103,7 @@ func RunFuseServer() {
|
||||||
var err error
|
var err error
|
||||||
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
|
fuseServer, err = fs.Mount(fuseMountPoint, &FuseRoot{}, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("failed to mount fuse: %w", err)
|
logger.Warn().Err(err).Msg("failed to mount fuse")
|
||||||
}
|
}
|
||||||
fuseServer.Wait()
|
fuseServer.Wait()
|
||||||
}
|
}
|
||||||
|
|
61
go.mod
61
go.mod
|
@ -1,52 +1,59 @@
|
||||||
module kvm
|
module github.com/jetkvm/kvm
|
||||||
|
|
||||||
go 1.21.0
|
go 1.23.0
|
||||||
|
|
||||||
toolchain go1.21.1
|
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Masterminds/semver/v3 v3.3.0
|
github.com/Masterminds/semver/v3 v3.3.0
|
||||||
github.com/beevik/ntp v1.3.1
|
github.com/beevik/ntp v1.3.1
|
||||||
github.com/coder/websocket v1.8.12
|
github.com/coder/websocket v1.8.13
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0
|
github.com/coreos/go-oidc/v3 v3.11.0
|
||||||
github.com/creack/pty v1.1.23
|
github.com/creack/pty v1.1.23
|
||||||
github.com/gin-gonic/gin v1.9.1
|
github.com/gin-contrib/logger v1.2.5
|
||||||
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
|
||||||
github.com/hanwen/go-fuse/v2 v2.5.1
|
github.com/hanwen/go-fuse/v2 v2.5.1
|
||||||
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965
|
github.com/hashicorp/go-envparse v0.1.0
|
||||||
github.com/pion/logging v0.2.2
|
github.com/pion/logging v0.2.2
|
||||||
github.com/pion/mdns/v2 v2.0.7
|
github.com/pion/mdns/v2 v2.0.7
|
||||||
github.com/pion/webrtc/v4 v4.0.0
|
github.com/pion/webrtc/v4 v4.0.0
|
||||||
github.com/pojntfx/go-nbd v0.3.2
|
github.com/pojntfx/go-nbd v0.3.2
|
||||||
|
github.com/prometheus/client_golang v1.21.0
|
||||||
|
github.com/prometheus/common v0.62.0
|
||||||
github.com/psanford/httpreadat v0.1.0
|
github.com/psanford/httpreadat v0.1.0
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/vishvananda/netlink v1.3.0
|
github.com/vishvananda/netlink v1.3.0
|
||||||
golang.org/x/crypto v0.28.0
|
go.bug.st/serial v1.6.2
|
||||||
golang.org/x/net v0.30.0
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/net v0.38.0
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // 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/go-jose/go-jose/v4 v4.0.2 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/compress v1.17.11 // indirect
|
||||||
github.com/kr/pretty v0.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/pilebones/go-udev v0.9.0 // indirect
|
github.com/pilebones/go-udev v0.9.0 // indirect
|
||||||
github.com/pion/datachannel v1.5.9 // indirect
|
github.com/pion/datachannel v1.5.9 // indirect
|
||||||
github.com/pion/dtls/v3 v3.0.3 // indirect
|
github.com/pion/dtls/v3 v3.0.3 // indirect
|
||||||
|
@ -61,16 +68,16 @@ require (
|
||||||
github.com/pion/stun/v3 v3.0.0 // indirect
|
github.com/pion/stun/v3 v3.0.0 // indirect
|
||||||
github.com/pion/transport/v3 v3.0.7 // indirect
|
github.com/pion/transport/v3 v3.0.7 // indirect
|
||||||
github.com/pion/turn/v4 v4.0.0 // indirect
|
github.com/pion/turn/v4 v4.0.0 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.8.0 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/vishvananda/netns v0.0.4 // indirect
|
github.com/vishvananda/netns v0.0.4 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.15.0 // indirect
|
||||||
golang.org/x/oauth2 v0.21.0 // indirect
|
golang.org/x/oauth2 v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.26.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.19.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.0 // indirect
|
google.golang.org/protobuf v1.36.6 // indirect
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|
138
go.sum
138
go.sum
|
@ -2,32 +2,40 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
|
||||||
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
|
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
|
||||||
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
|
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
|
||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
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/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
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 h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
|
||||||
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
|
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.5/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/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo=
|
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||||
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
|
||||||
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
|
||||||
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/logger v1.2.5/go.mod h1:/bj+vNMuA2xOEQ1aRHoJ1m9+uyaaXIAxQTvM2llsc6I=
|
||||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
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/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk=
|
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-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
@ -36,12 +44,13 @@ 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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
@ -49,24 +58,33 @@ 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/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 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
|
||||||
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||||
|
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
|
||||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
|
|
||||||
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
|
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
github.com/mattn/go-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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=
|
github.com/moby/sys/mountinfo v0.6.2 h1:BzJjoreD5BMFNmD9Rus6gdd1pLuecOFPt8wC+Vygl78=
|
||||||
|
@ -76,10 +94,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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl9Q=
|
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/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
|
||||||
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA=
|
||||||
|
@ -114,14 +132,24 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
|
||||||
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
|
||||||
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
|
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
|
||||||
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
|
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.21.0 h1:DIsaGmiaBkSangBgMtWdNfxbMNdku5IK6iNhrEqWvdA=
|
||||||
|
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||||
|
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
|
||||||
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
github.com/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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
@ -132,8 +160,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
|
@ -144,34 +173,33 @@ github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1Y
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
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 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
|
||||||
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||||
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
|
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
|
||||||
|
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
||||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
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
12
hw.go
|
@ -14,7 +14,7 @@ func extractSerialNumber() (string, error) {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := regexp.Compile("Serial\\s*:\\s*(\\S+)")
|
r, err := regexp.Compile(`Serial\s*:\s*(\S+)`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to compile regex: %w", err)
|
return "", fmt.Errorf("failed to compile regex: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -27,7 +27,7 @@ func extractSerialNumber() (string, error) {
|
||||||
return matches[1], nil
|
return matches[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readOtpEntropy() ([]byte, error) {
|
func readOtpEntropy() ([]byte, error) { //nolint:unused
|
||||||
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
|
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -42,7 +42,7 @@ func GetDeviceID() string {
|
||||||
deviceIDOnce.Do(func() {
|
deviceIDOnce.Do(func() {
|
||||||
serial, err := extractSerialNumber()
|
serial, err := extractSerialNumber()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn("unknown serial number, the program likely not running on RV1106")
|
logger.Warn().Msg("unknown serial number, the program likely not running on RV1106")
|
||||||
deviceID = "unknown_device_id"
|
deviceID = "unknown_device_id"
|
||||||
} else {
|
} else {
|
||||||
deviceID = serial
|
deviceID = serial
|
||||||
|
@ -54,7 +54,7 @@ func GetDeviceID() string {
|
||||||
func runWatchdog() {
|
func runWatchdog() {
|
||||||
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
|
file, err := os.OpenFile("/dev/watchdog", os.O_WRONLY, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("unable to open /dev/watchdog: %v, skipping watchdog reset", err)
|
logger.Warn().Err(err).Msg("unable to open /dev/watchdog, skipping watchdog reset")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
@ -65,13 +65,13 @@ func runWatchdog() {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_, err = file.Write([]byte{0})
|
_, err = file.Write([]byte{0})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error writing to /dev/watchdog, system may reboot: %v", err)
|
logger.Warn().Err(err).Msg("error writing to /dev/watchdog, system may reboot")
|
||||||
}
|
}
|
||||||
case <-appCtx.Done():
|
case <-appCtx.Done():
|
||||||
//disarm watchdog with magic value
|
//disarm watchdog with magic value
|
||||||
_, err := file.Write([]byte("V"))
|
_, err := file.Write([]byte("V"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to disarm watchdog, system may reboot: %v", err)
|
logger.Warn().Err(err).Msg("failed to disarm watchdog, system may reboot")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,336 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
type gadgetConfigItem struct {
|
||||||
|
order uint
|
||||||
|
device string
|
||||||
|
path []string
|
||||||
|
attrs gadgetAttributes
|
||||||
|
configAttrs gadgetAttributes
|
||||||
|
configPath []string
|
||||||
|
reportDesc []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type gadgetAttributes map[string]string
|
||||||
|
|
||||||
|
type gadgetConfigItemWithKey struct {
|
||||||
|
key string
|
||||||
|
item gadgetConfigItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderedGadgetConfigItems []gadgetConfigItemWithKey
|
||||||
|
|
||||||
|
var defaultGadgetConfig = map[string]gadgetConfigItem{
|
||||||
|
"base": {
|
||||||
|
order: 0,
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"bcdUSB": "0x0200", // USB 2.0
|
||||||
|
"idVendor": "0x1d6b", // The Linux Foundation
|
||||||
|
"idProduct": "0104", // Multifunction Composite Gadget
|
||||||
|
"bcdDevice": "0100",
|
||||||
|
},
|
||||||
|
configAttrs: gadgetAttributes{
|
||||||
|
"MaxPower": "250", // in unit of 2mA
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"base_info": {
|
||||||
|
order: 1,
|
||||||
|
path: []string{"strings", "0x409"},
|
||||||
|
configPath: []string{"strings", "0x409"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"serialnumber": "",
|
||||||
|
"manufacturer": "JetKVM",
|
||||||
|
"product": "JetKVM USB Emulation Device",
|
||||||
|
},
|
||||||
|
configAttrs: gadgetAttributes{
|
||||||
|
"configuration": "Config 1: HID",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// keyboard HID
|
||||||
|
"keyboard": keyboardConfig,
|
||||||
|
// mouse HID
|
||||||
|
"absolute_mouse": absoluteMouseConfig,
|
||||||
|
// relative mouse HID
|
||||||
|
"relative_mouse": relativeMouseConfig,
|
||||||
|
// mass storage
|
||||||
|
"mass_storage_base": massStorageBaseConfig,
|
||||||
|
"mass_storage_lun0": massStorageLun0Config,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
|
||||||
|
switch itemKey {
|
||||||
|
case "absolute_mouse":
|
||||||
|
return u.enabledDevices.AbsoluteMouse
|
||||||
|
case "relative_mouse":
|
||||||
|
return u.enabledDevices.RelativeMouse
|
||||||
|
case "keyboard":
|
||||||
|
return u.enabledDevices.Keyboard
|
||||||
|
case "mass_storage_base":
|
||||||
|
return u.enabledDevices.MassStorage
|
||||||
|
case "mass_storage_lun0":
|
||||||
|
return u.enabledDevices.MassStorage
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) loadGadgetConfig() {
|
||||||
|
if u.customConfig.isEmpty {
|
||||||
|
u.log.Trace().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
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
const dwc3Path = "/sys/bus/platform/drivers/dwc3"
|
|
@ -0,0 +1,11 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func (u *UsbGadget) resetUserInputTime() {
|
||||||
|
u.lastUserInput = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) GetLastUserInputTime() time.Time {
|
||||||
|
return u.lastUserInput
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var keyboardConfig = gadgetConfigItem{
|
||||||
|
order: 1000,
|
||||||
|
device: "hid.usb0",
|
||||||
|
path: []string{"functions", "hid.usb0"},
|
||||||
|
configPath: []string{"hid.usb0"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"protocol": "1",
|
||||||
|
"subclass": "1",
|
||||||
|
"report_length": "8",
|
||||||
|
},
|
||||||
|
reportDesc: keyboardReportDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source: https://www.kernel.org/doc/Documentation/usb/gadget_hid.txt
|
||||||
|
var keyboardReportDesc = []byte{
|
||||||
|
0x05, 0x01, /* USAGE_PAGE (Generic Desktop) */
|
||||||
|
0x09, 0x06, /* USAGE (Keyboard) */
|
||||||
|
0xa1, 0x01, /* COLLECTION (Application) */
|
||||||
|
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
|
||||||
|
0x19, 0xe0, /* USAGE_MINIMUM (Keyboard LeftControl) */
|
||||||
|
0x29, 0xe7, /* USAGE_MAXIMUM (Keyboard Right GUI) */
|
||||||
|
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
|
||||||
|
0x25, 0x01, /* LOGICAL_MAXIMUM (1) */
|
||||||
|
0x75, 0x01, /* REPORT_SIZE (1) */
|
||||||
|
0x95, 0x08, /* REPORT_COUNT (8) */
|
||||||
|
0x81, 0x02, /* INPUT (Data,Var,Abs) */
|
||||||
|
0x95, 0x01, /* REPORT_COUNT (1) */
|
||||||
|
0x75, 0x08, /* REPORT_SIZE (8) */
|
||||||
|
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
|
||||||
|
0x95, 0x05, /* REPORT_COUNT (5) */
|
||||||
|
0x75, 0x01, /* REPORT_SIZE (1) */
|
||||||
|
0x05, 0x08, /* USAGE_PAGE (LEDs) */
|
||||||
|
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
|
||||||
|
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
|
||||||
|
0x91, 0x02, /* OUTPUT (Data,Var,Abs) */
|
||||||
|
0x95, 0x01, /* REPORT_COUNT (1) */
|
||||||
|
0x75, 0x03, /* REPORT_SIZE (3) */
|
||||||
|
0x91, 0x03, /* OUTPUT (Cnst,Var,Abs) */
|
||||||
|
0x95, 0x06, /* REPORT_COUNT (6) */
|
||||||
|
0x75, 0x08, /* REPORT_SIZE (8) */
|
||||||
|
0x15, 0x00, /* LOGICAL_MINIMUM (0) */
|
||||||
|
0x25, 0x65, /* LOGICAL_MAXIMUM (101) */
|
||||||
|
0x05, 0x07, /* USAGE_PAGE (Keyboard) */
|
||||||
|
0x19, 0x00, /* USAGE_MINIMUM (Reserved) */
|
||||||
|
0x29, 0x65, /* USAGE_MAXIMUM (Keyboard Application) */
|
||||||
|
0x81, 0x00, /* INPUT (Data,Ary,Abs) */
|
||||||
|
0xc0, /* END_COLLECTION */
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
|
||||||
|
if u.keyboardHidFile == nil {
|
||||||
|
var err error
|
||||||
|
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open hidg0: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := u.keyboardHidFile.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
u.log.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
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var absoluteMouseConfig = gadgetConfigItem{
|
||||||
|
order: 1001,
|
||||||
|
device: "hid.usb1",
|
||||||
|
path: []string{"functions", "hid.usb1"},
|
||||||
|
configPath: []string{"hid.usb1"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"protocol": "2",
|
||||||
|
"subclass": "1",
|
||||||
|
"report_length": "6",
|
||||||
|
},
|
||||||
|
reportDesc: absoluteMouseCombinedReportDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
var absoluteMouseCombinedReportDesc = []byte{
|
||||||
|
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
|
||||||
|
0x09, 0x02, // Usage (Mouse)
|
||||||
|
0xA1, 0x01, // Collection (Application)
|
||||||
|
|
||||||
|
// Report ID 1: Absolute Mouse Movement
|
||||||
|
0x85, 0x01, // Report ID (1)
|
||||||
|
0x09, 0x01, // Usage (Pointer)
|
||||||
|
0xA1, 0x00, // Collection (Physical)
|
||||||
|
0x05, 0x09, // Usage Page (Button)
|
||||||
|
0x19, 0x01, // Usage Minimum (0x01)
|
||||||
|
0x29, 0x03, // Usage Maximum (0x03)
|
||||||
|
0x15, 0x00, // Logical Minimum (0)
|
||||||
|
0x25, 0x01, // Logical Maximum (1)
|
||||||
|
0x75, 0x01, // Report Size (1)
|
||||||
|
0x95, 0x03, // Report Count (3)
|
||||||
|
0x81, 0x02, // Input (Data, Var, Abs)
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x75, 0x05, // Report Size (5)
|
||||||
|
0x81, 0x03, // Input (Cnst, Var, Abs)
|
||||||
|
0x05, 0x01, // Usage Page (Generic Desktop Ctrls)
|
||||||
|
0x09, 0x30, // Usage (X)
|
||||||
|
0x09, 0x31, // Usage (Y)
|
||||||
|
0x16, 0x00, 0x00, // Logical Minimum (0)
|
||||||
|
0x26, 0xFF, 0x7F, // Logical Maximum (32767)
|
||||||
|
0x36, 0x00, 0x00, // Physical Minimum (0)
|
||||||
|
0x46, 0xFF, 0x7F, // Physical Maximum (32767)
|
||||||
|
0x75, 0x10, // Report Size (16)
|
||||||
|
0x95, 0x02, // Report Count (2)
|
||||||
|
0x81, 0x02, // Input (Data, Var, Abs)
|
||||||
|
0xC0, // End Collection
|
||||||
|
|
||||||
|
// Report ID 2: Relative Wheel Movement
|
||||||
|
0x85, 0x02, // Report ID (2)
|
||||||
|
0x09, 0x38, // Usage (Wheel)
|
||||||
|
0x15, 0x81, // Logical Minimum (-127)
|
||||||
|
0x25, 0x7F, // Logical Maximum (127)
|
||||||
|
0x75, 0x08, // Report Size (8)
|
||||||
|
0x95, 0x01, // Report Count (1)
|
||||||
|
0x81, 0x06, // Input (Data, Var, Rel)
|
||||||
|
|
||||||
|
0xC0, // End Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
|
||||||
|
if u.absMouseHidFile == nil {
|
||||||
|
var err error
|
||||||
|
u.absMouseHidFile, err = os.OpenFile("/dev/hidg1", os.O_RDWR, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := u.absMouseHidFile.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
u.log.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
|
||||||
|
}
|
|
@ -0,0 +1,92 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var relativeMouseConfig = gadgetConfigItem{
|
||||||
|
order: 1002,
|
||||||
|
device: "hid.usb2",
|
||||||
|
path: []string{"functions", "hid.usb2"},
|
||||||
|
configPath: []string{"hid.usb2"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"protocol": "2",
|
||||||
|
"subclass": "1",
|
||||||
|
"report_length": "4",
|
||||||
|
},
|
||||||
|
reportDesc: relativeMouseCombinedReportDesc,
|
||||||
|
}
|
||||||
|
|
||||||
|
// from: https://github.com/NicoHood/HID/blob/b16be57caef4295c6cd382a7e4c64db5073647f7/src/SingleReport/BootMouse.cpp#L26
|
||||||
|
var relativeMouseCombinedReportDesc = []byte{
|
||||||
|
0x05, 0x01, // USAGE_PAGE (Generic Desktop) 54
|
||||||
|
0x09, 0x02, // USAGE (Mouse)
|
||||||
|
0xa1, 0x01, // COLLECTION (Application)
|
||||||
|
|
||||||
|
// Pointer and Physical are required by Apple Recovery
|
||||||
|
0x09, 0x01, // USAGE (Pointer)
|
||||||
|
0xa1, 0x00, // COLLECTION (Physical)
|
||||||
|
|
||||||
|
// 8 Buttons
|
||||||
|
0x05, 0x09, // USAGE_PAGE (Button)
|
||||||
|
0x19, 0x01, // USAGE_MINIMUM (Button 1)
|
||||||
|
0x29, 0x08, // USAGE_MAXIMUM (Button 8)
|
||||||
|
0x15, 0x00, // LOGICAL_MINIMUM (0)
|
||||||
|
0x25, 0x01, // LOGICAL_MAXIMUM (1)
|
||||||
|
0x95, 0x08, // REPORT_COUNT (8)
|
||||||
|
0x75, 0x01, // REPORT_SIZE (1)
|
||||||
|
0x81, 0x02, // INPUT (Data,Var,Abs)
|
||||||
|
|
||||||
|
// X, Y, Wheel
|
||||||
|
0x05, 0x01, // USAGE_PAGE (Generic Desktop)
|
||||||
|
0x09, 0x30, // USAGE (X)
|
||||||
|
0x09, 0x31, // USAGE (Y)
|
||||||
|
0x09, 0x38, // USAGE (Wheel)
|
||||||
|
0x15, 0x81, // LOGICAL_MINIMUM (-127)
|
||||||
|
0x25, 0x7f, // LOGICAL_MAXIMUM (127)
|
||||||
|
0x75, 0x08, // REPORT_SIZE (8)
|
||||||
|
0x95, 0x03, // REPORT_COUNT (3)
|
||||||
|
0x81, 0x06, // INPUT (Data,Var,Rel)
|
||||||
|
|
||||||
|
// End
|
||||||
|
0xc0, // End Collection (Physical)
|
||||||
|
0xc0, // End Collection
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
|
||||||
|
if u.relMouseHidFile == nil {
|
||||||
|
var err error
|
||||||
|
u.relMouseHidFile, err = os.OpenFile("/dev/hidg2", os.O_RDWR, 0666)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open hidg1: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := u.relMouseHidFile.Write(data)
|
||||||
|
if err != nil {
|
||||||
|
u.log.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
|
||||||
|
}
|
|
@ -0,0 +1,23 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
var massStorageBaseConfig = gadgetConfigItem{
|
||||||
|
order: 3000,
|
||||||
|
device: "mass_storage.usb0",
|
||||||
|
path: []string{"functions", "mass_storage.usb0"},
|
||||||
|
configPath: []string{"mass_storage.usb0"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"stall": "1",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var massStorageLun0Config = gadgetConfigItem{
|
||||||
|
order: 3001,
|
||||||
|
path: []string{"functions", "mass_storage.usb0", "lun.0"},
|
||||||
|
attrs: gadgetAttributes{
|
||||||
|
"cdrom": "1",
|
||||||
|
"ro": "1",
|
||||||
|
"removable": "1",
|
||||||
|
"file": "\n",
|
||||||
|
"inquiry_string": "JetKVM Virtual Media",
|
||||||
|
},
|
||||||
|
}
|
|
@ -0,0 +1,109 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getUdcs() []string {
|
||||||
|
var udcs []string
|
||||||
|
|
||||||
|
files, err := os.ReadDir("/sys/devices/platform/usbdrd")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if !file.IsDir() || !strings.HasSuffix(file.Name(), ".usb") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
udcs = append(udcs, file.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
return udcs
|
||||||
|
}
|
||||||
|
|
||||||
|
func rebindUsb(udc string, ignoreUnbindError bool) error {
|
||||||
|
err := os.WriteFile(path.Join(dwc3Path, "unbind"), []byte(udc), 0644)
|
||||||
|
if err != nil && !ignoreUnbindError {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = os.WriteFile(path.Join(dwc3Path, "bind"), []byte(udc), 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) rebindUsb(ignoreUnbindError bool) error {
|
||||||
|
u.log.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
|
||||||
|
}
|
|
@ -0,0 +1,110 @@
|
||||||
|
// Package usbgadget provides a high-level interface to manage USB gadgets
|
||||||
|
// THIS PACKAGE IS FOR INTERNAL USE ONLY AND ITS API MAY CHANGE WITHOUT NOTICE
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/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
|
||||||
|
}
|
|
@ -0,0 +1,63 @@
|
||||||
|
package usbgadget
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Helper function to get absolute value of float64
|
||||||
|
func abs(x float64) float64 {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinPath(basePath string, paths []string) string {
|
||||||
|
pathArr := append([]string{basePath}, paths...)
|
||||||
|
return filepath.Join(pathArr...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSymlink(linkPath string, target string) error {
|
||||||
|
if _, err := os.Lstat(linkPath); err == nil {
|
||||||
|
currentTarget, err := os.Readlink(linkPath)
|
||||||
|
if err != nil || currentTarget != target {
|
||||||
|
err = os.Remove(linkPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to check if symlink exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(target, linkPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to create symlink from %s to %s: %w", linkPath, target, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *UsbGadget) writeIfDifferent(filePath string, content []byte, permMode os.FileMode) error {
|
||||||
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
|
oldContent, err := os.ReadFile(filePath)
|
||||||
|
if err == nil {
|
||||||
|
if bytes.Equal(oldContent, content) {
|
||||||
|
u.log.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)
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package websecure
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultLogger = zerolog.New(os.Stdout).With().Str("component", "websecure").Logger()
|
|
@ -0,0 +1,191 @@
|
||||||
|
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
|
||||||
|
}
|
|
@ -0,0 +1,175 @@
|
||||||
|
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")
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
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)
|
||||||
|
}
|
10
jiggler.go
10
jiggler.go
|
@ -6,10 +6,6 @@ import (
|
||||||
|
|
||||||
var lastUserInput = time.Now()
|
var lastUserInput = time.Now()
|
||||||
|
|
||||||
func resetUserInputTime() {
|
|
||||||
lastUserInput = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
var jigglerEnabled = false
|
var jigglerEnabled = false
|
||||||
|
|
||||||
func rpcSetJigglerState(enabled bool) {
|
func rpcSetJigglerState(enabled bool) {
|
||||||
|
@ -20,6 +16,8 @@ func rpcGetJigglerState() bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
|
||||||
go runJiggler()
|
go runJiggler()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -30,11 +28,11 @@ func runJiggler() {
|
||||||
//TODO: change to rel mouse
|
//TODO: change to rel mouse
|
||||||
err := rpcAbsMouseReport(1, 1, 0)
|
err := rpcAbsMouseReport(1, 1, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to jiggle mouse: %v", err)
|
logger.Warn().Err(err).Msg("Failed to jiggle mouse")
|
||||||
}
|
}
|
||||||
err = rpcAbsMouseReport(0, 0, 0)
|
err = rpcAbsMouseReport(0, 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to reset mouse position: %v", err)
|
logger.Warn().Err(err).Msg("Failed to reset mouse position")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
521
jsonrpc.go
521
jsonrpc.go
|
@ -5,13 +5,17 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/usbgadget"
|
||||||
)
|
)
|
||||||
|
|
||||||
type JSONRPCRequest struct {
|
type JSONRPCRequest struct {
|
||||||
|
@ -34,15 +38,21 @@ type JSONRPCEvent struct {
|
||||||
Params interface{} `json:"params,omitempty"`
|
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) {
|
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
|
||||||
responseBytes, err := json.Marshal(response)
|
responseBytes, err := json.Marshal(response)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error marshalling JSONRPC response:", err)
|
logger.Warn().Err(err).Msg("Error marshalling JSONRPC response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = session.RPCChannel.SendText(string(responseBytes))
|
err = session.RPCChannel.SendText(string(responseBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error sending JSONRPC response:", err)
|
logger.Warn().Err(err).Msg("Error sending JSONRPC response")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,16 +65,16 @@ func writeJSONRPCEvent(event string, params interface{}, session *Session) {
|
||||||
}
|
}
|
||||||
requestBytes, err := json.Marshal(request)
|
requestBytes, err := json.Marshal(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error marshalling JSONRPC event:", err)
|
logger.Warn().Err(err).Msg("Error marshalling JSONRPC event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if session == nil || session.RPCChannel == nil {
|
if session == nil || session.RPCChannel == nil {
|
||||||
log.Println("RPC channel not available")
|
logger.Info().Msg("RPC channel not available")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
err = session.RPCChannel.SendText(string(requestBytes))
|
err = session.RPCChannel.SendText(string(requestBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error sending JSONRPC event:", err)
|
logger.Warn().Err(err).Msg("Error sending JSONRPC event")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -85,7 +95,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
//log.Printf("Received RPC request: Method=%s, Params=%v, ID=%d", request.Method, request.Params, request.ID)
|
logger.Trace().Str("method", request.Method).Interface("params", request.Params).Interface("id", request.ID).Msg("Received RPC request")
|
||||||
handler, ok := rpcHandlers[request.Method]
|
handler, ok := rpcHandlers[request.Method]
|
||||||
if !ok {
|
if !ok {
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
|
@ -100,6 +110,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Trace().Str("method", request.Method).Interface("id", request.ID).Msg("Calling RPC handler")
|
||||||
result, err := callRPCHandler(handler, request.Params)
|
result, err := callRPCHandler(handler, request.Params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errorResponse := JSONRPCResponse{
|
errorResponse := JSONRPCResponse{
|
||||||
|
@ -115,6 +126,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Trace().Interface("result", result).Interface("id", request.ID).Msg("RPC handler returned")
|
||||||
response := JSONRPCResponse{
|
response := JSONRPCResponse{
|
||||||
JSONRPC: "2.0",
|
JSONRPC: "2.0",
|
||||||
Result: result,
|
Result: result,
|
||||||
|
@ -155,6 +167,30 @@ func rpcSetKeyboardMappingState(enabled bool) (bool, error) {
|
||||||
return enabled, nil
|
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
|
var streamFactor = 1.0
|
||||||
|
|
||||||
func rpcGetStreamQualityFactor() (float64, error) {
|
func rpcGetStreamQualityFactor() (float64, error) {
|
||||||
|
@ -162,7 +198,7 @@ func rpcGetStreamQualityFactor() (float64, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetStreamQualityFactor(factor float64) error {
|
func rpcSetStreamQualityFactor(factor float64) error {
|
||||||
log.Printf("Setting stream quality factor to: %f", factor)
|
logger.Info().Float64("factor", factor).Msg("Setting stream quality factor")
|
||||||
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
|
var _, err = CallCtrlAction("set_video_quality_factor", map[string]interface{}{"quality_factor": factor})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -198,15 +234,19 @@ func rpcGetEDID() (string, error) {
|
||||||
|
|
||||||
func rpcSetEDID(edid string) error {
|
func rpcSetEDID(edid string) error {
|
||||||
if edid == "" {
|
if edid == "" {
|
||||||
log.Println("Restoring EDID to default")
|
logger.Info().Msg("Restoring EDID to default")
|
||||||
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
|
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Setting EDID to: %s", edid)
|
logger.Info().Str("edid", edid).Msg("Setting EDID")
|
||||||
}
|
}
|
||||||
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
|
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": edid})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save EDID to config, allowing it to be restored on reboot.
|
||||||
|
config.EdidString = edid
|
||||||
|
_ = SaveConfig()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -237,12 +277,58 @@ func rpcTryUpdate() error {
|
||||||
go func() {
|
go func() {
|
||||||
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("failed to try update: %v", err)
|
logger.Warn().Err(err).Msg("failed to try update")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
return nil
|
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 (
|
const (
|
||||||
devModeFile = "/userdata/jetkvm/devmode.enable"
|
devModeFile = "/userdata/jetkvm/devmode.enable"
|
||||||
sshKeyDir = "/userdata/dropbear/.ssh"
|
sshKeyDir = "/userdata/dropbear/.ssh"
|
||||||
|
@ -282,7 +368,7 @@ func rpcSetDevModeState(enabled bool) error {
|
||||||
return fmt.Errorf("failed to create devmode file: %w", err)
|
return fmt.Errorf("failed to create devmode file: %w", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("dev mode already enabled")
|
logger.Debug().Msg("dev mode already enabled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -291,7 +377,7 @@ func rpcSetDevModeState(enabled bool) error {
|
||||||
return fmt.Errorf("failed to remove devmode file: %w", err)
|
return fmt.Errorf("failed to remove devmode file: %w", err)
|
||||||
}
|
}
|
||||||
} else if os.IsNotExist(err) {
|
} else if os.IsNotExist(err) {
|
||||||
logger.Debug("dev mode already disabled")
|
logger.Debug().Msg("dev mode already disabled")
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
return fmt.Errorf("error checking dev mode file: %w", err)
|
return fmt.Errorf("error checking dev mode file: %w", err)
|
||||||
|
@ -301,7 +387,7 @@ func rpcSetDevModeState(enabled bool) error {
|
||||||
cmd := exec.Command("dropbear.sh")
|
cmd := exec.Command("dropbear.sh")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("Failed to start/stop SSH: %v, %v", err, output)
|
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
|
||||||
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
|
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -339,6 +425,23 @@ func rpcSetSSHKeyState(sshKey string) error {
|
||||||
return nil
|
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) {
|
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
|
||||||
handlerValue := reflect.ValueOf(handler.Func)
|
handlerValue := reflect.ValueOf(handler.Func)
|
||||||
handlerType := handlerValue.Type()
|
handlerType := handlerValue.Type()
|
||||||
|
@ -403,7 +506,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
|
||||||
}
|
}
|
||||||
args[i] = reflect.ValueOf(newStruct).Elem()
|
args[i] = reflect.ValueOf(newStruct).Elem()
|
||||||
} else {
|
} else {
|
||||||
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
|
return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
args[i] = convertedValue.Convert(paramType)
|
args[i] = convertedValue.Convert(paramType)
|
||||||
|
@ -442,23 +545,23 @@ type RPCHandler struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetMassStorageMode(mode string) (string, error) {
|
func rpcSetMassStorageMode(mode string) (string, error) {
|
||||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
|
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
|
||||||
var cdrom bool
|
var cdrom bool
|
||||||
if mode == "cdrom" {
|
if mode == "cdrom" {
|
||||||
cdrom = true
|
cdrom = true
|
||||||
} else if mode != "file" {
|
} else if mode != "file" {
|
||||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Invalid mode provided: %s", mode)
|
logger.Info().Str("mode", mode).Msg("Invalid mode provided")
|
||||||
return "", fmt.Errorf("invalid mode: %s", mode)
|
return "", fmt.Errorf("invalid mode: %s", mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Setting mass storage mode to: %s", mode)
|
logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
|
||||||
|
|
||||||
err := setMassStorageMode(cdrom)
|
err := setMassStorageMode(cdrom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
|
return "", fmt.Errorf("failed to set mass storage mode: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[jsonrpc.go:rpcSetMassStorageMode] Mass storage mode set to %s", mode)
|
logger.Info().Str("mode", mode).Msg("Mass storage mode set")
|
||||||
|
|
||||||
// Get the updated mode after setting
|
// Get the updated mode after setting
|
||||||
return rpcGetMassStorageMode()
|
return rpcGetMassStorageMode()
|
||||||
|
@ -481,29 +584,31 @@ func rpcIsUpdatePending() (bool, error) {
|
||||||
return IsUpdatePending(), nil
|
return IsUpdatePending(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
var udcFilePath = filepath.Join("/sys/bus/platform/drivers/dwc3", udc)
|
|
||||||
|
|
||||||
func rpcGetUsbEmulationState() (bool, error) {
|
func rpcGetUsbEmulationState() (bool, error) {
|
||||||
_, err := os.Stat(udcFilePath)
|
return gadget.IsUDCBound()
|
||||||
if err != nil {
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
return false, fmt.Errorf("error checking USB emulation state: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetUsbEmulationState(enabled bool) error {
|
func rpcSetUsbEmulationState(enabled bool) error {
|
||||||
if enabled {
|
if enabled {
|
||||||
return os.WriteFile("/sys/bus/platform/drivers/dwc3/bind", []byte(udc), 0644)
|
return gadget.BindUDC()
|
||||||
} else {
|
} else {
|
||||||
return os.WriteFile("/sys/bus/platform/drivers/dwc3/unbind", []byte(udc), 0644)
|
return gadget.UnbindUDC()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetWakeOnLanDevices() ([]WakeOnLanDevice, error) {
|
func rpcGetUsbConfig() (usbgadget.Config, error) {
|
||||||
LoadConfig()
|
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) {
|
||||||
if config.WakeOnLanDevices == nil {
|
if config.WakeOnLanDevices == nil {
|
||||||
return []WakeOnLanDevice{}, nil
|
return []WakeOnLanDevice{}, nil
|
||||||
}
|
}
|
||||||
|
@ -515,30 +620,352 @@ type SetWakeOnLanDevicesParams struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
|
func rpcSetWakeOnLanDevices(params SetWakeOnLanDevicesParams) error {
|
||||||
LoadConfig()
|
|
||||||
config.WakeOnLanDevices = params.Devices
|
config.WakeOnLanDevices = params.Devices
|
||||||
return SaveConfig()
|
return SaveConfig()
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcResetConfig() error {
|
func rpcResetConfig() error {
|
||||||
LoadConfig()
|
|
||||||
config = defaultConfig
|
config = defaultConfig
|
||||||
if err := SaveConfig(); err != nil {
|
if err := SaveConfig(); err != nil {
|
||||||
return fmt.Errorf("failed to reset config: %w", err)
|
return fmt.Errorf("failed to reset config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Configuration reset to default")
|
logger.Info().Msg("Configuration reset to default")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: replace this crap with code generator
|
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
|
||||||
|
}
|
||||||
|
if config.ActiveExtension == "atx-power" {
|
||||||
|
_ = unmountATXControl()
|
||||||
|
} else if config.ActiveExtension == "dc-power" {
|
||||||
|
_ = unmountDCControl()
|
||||||
|
}
|
||||||
|
config.ActiveExtension = extensionId
|
||||||
|
if err := SaveConfig(); err != nil {
|
||||||
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
|
}
|
||||||
|
if extensionId == "atx-power" {
|
||||||
|
_ = mountATXControl()
|
||||||
|
} else if extensionId == "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
|
||||||
|
}
|
||||||
|
|
||||||
var rpcHandlers = map[string]RPCHandler{
|
var rpcHandlers = map[string]RPCHandler{
|
||||||
"ping": {Func: rpcPing},
|
"ping": {Func: rpcPing},
|
||||||
|
"reboot": {Func: rpcReboot, Params: []string{"force"}},
|
||||||
"getDeviceID": {Func: rpcGetDeviceID},
|
"getDeviceID": {Func: rpcGetDeviceID},
|
||||||
"deregisterDevice": {Func: rpcDeregisterDevice},
|
"deregisterDevice": {Func: rpcDeregisterDevice},
|
||||||
"getCloudState": {Func: rpcGetCloudState},
|
"getCloudState": {Func: rpcGetCloudState},
|
||||||
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
|
||||||
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
|
||||||
|
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
|
||||||
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
|
||||||
"getVideoState": {Func: rpcGetVideoState},
|
"getVideoState": {Func: rpcGetVideoState},
|
||||||
"getUSBState": {Func: rpcGetUSBState},
|
"getUSBState": {Func: rpcGetUSBState},
|
||||||
|
@ -565,11 +992,15 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
|
||||||
|
"getTLSState": {Func: rpcGetTLSState},
|
||||||
|
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
|
||||||
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
|
||||||
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
"getMassStorageMode": {Func: rpcGetMassStorageMode},
|
||||||
"isUpdatePending": {Func: rpcIsUpdatePending},
|
"isUpdatePending": {Func: rpcIsUpdatePending},
|
||||||
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
|
||||||
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
|
||||||
|
"getUsbConfig": {Func: rpcGetUsbConfig},
|
||||||
|
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
|
||||||
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
|
||||||
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
|
||||||
"getStorageSpace": {Func: rpcGetStorageSpace},
|
"getStorageSpace": {Func: rpcGetStorageSpace},
|
||||||
|
@ -582,4 +1013,22 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
|
||||||
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
|
||||||
"resetConfig": {Func: rpcResetConfig},
|
"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"}},
|
||||||
}
|
}
|
||||||
|
|
172
log.go
172
log.go
|
@ -1,8 +1,170 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import "github.com/pion/logging"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
// we use logging framework from pion
|
"github.com/pion/logging"
|
||||||
// ref: https://github.com/pion/webrtc/wiki/Debugging-WebRTC
|
"github.com/rs/zerolog"
|
||||||
var logger = logging.NewDefaultLoggerFactory().NewLogger("jetkvm")
|
)
|
||||||
var usbLogger = logging.NewDefaultLoggerFactory().NewLogger("usb")
|
|
||||||
|
var (
|
||||||
|
defaultLogOutput 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
defaultLogLevel = zerolog.ErrorLevel
|
||||||
|
rootLogger = zerolog.New(defaultLogOutput).With().
|
||||||
|
Str("scope", "jetkvm").
|
||||||
|
Timestamp().
|
||||||
|
Stack().
|
||||||
|
Logger()
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
scopeLevels map[string]zerolog.Level
|
||||||
|
scopeLevelMutex = sync.Mutex{}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
logger = getLogger("jetkvm")
|
||||||
|
cloudLogger = getLogger("cloud")
|
||||||
|
websocketLogger = getLogger("websocket")
|
||||||
|
nativeLogger = getLogger("native")
|
||||||
|
ntpLogger = getLogger("ntp")
|
||||||
|
displayLogger = getLogger("display")
|
||||||
|
usbLogger = getLogger("usb")
|
||||||
|
ginLogger = getLogger("gin")
|
||||||
|
websecureLogger = getLogger("websecure")
|
||||||
|
)
|
||||||
|
|
||||||
|
func updateLogLevel() {
|
||||||
|
scopeLevelMutex.Lock()
|
||||||
|
defer scopeLevelMutex.Unlock()
|
||||||
|
|
||||||
|
logLevels := 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeLevels = make(map[string]zerolog.Level)
|
||||||
|
|
||||||
|
for name, level := range logLevels {
|
||||||
|
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" {
|
||||||
|
if defaultLogLevel > level {
|
||||||
|
defaultLogLevel = level
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scopes := strings.Split(strings.ToLower(env), ",")
|
||||||
|
for _, scope := range scopes {
|
||||||
|
scopeLevels[scope] = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLogger(scope string) zerolog.Logger {
|
||||||
|
if scopeLevels == nil {
|
||||||
|
updateLogLevel()
|
||||||
|
}
|
||||||
|
|
||||||
|
l := rootLogger.With().Str("component", scope).Logger()
|
||||||
|
|
||||||
|
// if the scope is not in the map, use the default level from the root logger
|
||||||
|
if level, ok := scopeLevels[scope]; ok {
|
||||||
|
return l.Level(level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.Level(defaultLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 := getLogger(subsystem).With().
|
||||||
|
Str("scope", "pion").
|
||||||
|
Str("component", subsystem).
|
||||||
|
Logger()
|
||||||
|
|
||||||
|
return pionLogger{logger: &logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultLoggerFactory = &pionLoggerFactory{}
|
||||||
|
|
37
main.go
37
main.go
|
@ -2,7 +2,6 @@ package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
@ -18,17 +17,17 @@ func Main() {
|
||||||
var cancel context.CancelFunc
|
var cancel context.CancelFunc
|
||||||
appCtx, cancel = context.WithCancel(context.Background())
|
appCtx, cancel = context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
logger.Info("Starting JetKvm")
|
logger.Info().Msg("Starting JetKvm")
|
||||||
go runWatchdog()
|
go runWatchdog()
|
||||||
go confirmCurrentSystem()
|
go confirmCurrentSystem()
|
||||||
|
|
||||||
http.DefaultClient.Timeout = 1 * time.Minute
|
http.DefaultClient.Timeout = 1 * time.Minute
|
||||||
LoadConfig()
|
LoadConfig()
|
||||||
logger.Debug("config loaded")
|
logger.Debug().Msg("config loaded")
|
||||||
|
|
||||||
err := rootcerts.UpdateDefaultTransport()
|
err := rootcerts.UpdateDefaultTransport()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to load CA certs: %v", err)
|
logger.Warn().Err(err).Msg("failed to load CA certs")
|
||||||
}
|
}
|
||||||
|
|
||||||
go TimeSyncLoop()
|
go TimeSyncLoop()
|
||||||
|
@ -36,49 +35,63 @@ func Main() {
|
||||||
StartNativeCtrlSocketServer()
|
StartNativeCtrlSocketServer()
|
||||||
StartNativeVideoSocketServer()
|
StartNativeVideoSocketServer()
|
||||||
|
|
||||||
|
initPrometheus()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
err = ExtractAndRunNativeBin()
|
err = ExtractAndRunNativeBin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to extract and run native bin: %v", err)
|
logger.Warn().Err(err).Msg("failed to extract and run native bin")
|
||||||
//TODO: prepare an error message screen buffer to show on kvm screen
|
//TODO: prepare an error message screen buffer to show on kvm screen
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
initUsbGadget()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
time.Sleep(15 * time.Minute)
|
time.Sleep(15 * time.Minute)
|
||||||
for {
|
for {
|
||||||
logger.Debugf("UPDATING - Auto update enabled: %v", config.AutoUpdateEnabled)
|
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
|
||||||
if config.AutoUpdateEnabled == false {
|
if !config.AutoUpdateEnabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
logger.Debugf("skipping update since a session is active")
|
logger.Debug().Msg("skipping update since a session is active")
|
||||||
time.Sleep(1 * time.Minute)
|
time.Sleep(1 * time.Minute)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to auto update: %v", err)
|
logger.Warn().Err(err).Msg("failed to auto update")
|
||||||
}
|
}
|
||||||
time.Sleep(1 * time.Hour)
|
time.Sleep(1 * time.Hour)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
//go RunFuseServer()
|
//go RunFuseServer()
|
||||||
go RunWebServer()
|
go RunWebServer()
|
||||||
|
|
||||||
|
go RunWebSecureServer()
|
||||||
|
// Web secure server is started only if TLS mode is enabled
|
||||||
|
if config.TLSMode != "" {
|
||||||
|
startWebSecureServer()
|
||||||
|
}
|
||||||
|
|
||||||
|
// As websocket client already checks if the cloud token is set, we can start it here.
|
||||||
go RunWebsocketClient()
|
go RunWebsocketClient()
|
||||||
|
|
||||||
|
initSerialPort()
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sigs
|
<-sigs
|
||||||
log.Println("JetKVM Shutting Down")
|
logger.Info().Msg("JetKVM Shutting Down")
|
||||||
//if fuseServer != nil {
|
//if fuseServer != nil {
|
||||||
// err := setMassStorageImage(" ")
|
// err := setMassStorageImage(" ")
|
||||||
// if err != nil {
|
// if err != nil {
|
||||||
// log.Printf("Failed to unmount mass storage image: %v", err)
|
// logger.Infof("Failed to unmount mass storage image: %v", err)
|
||||||
// }
|
// }
|
||||||
// err = fuseServer.Unmount()
|
// err = fuseServer.Unmount()
|
||||||
// if err != nil {
|
// if err != nil {
|
||||||
// log.Printf("Failed to unmount fuse: %v", err)
|
// logger.Infof("Failed to unmount fuse: %v", err)
|
||||||
// }
|
// }
|
||||||
|
|
||||||
// os.Exit(0)
|
// os.Exit(0)
|
||||||
|
|
113
native.go
113
native.go
|
@ -5,14 +5,16 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"kvm/resource"
|
|
||||||
"log"
|
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/resource"
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
|
||||||
"github.com/pion/webrtc/v4/pkg/media"
|
"github.com/pion/webrtc/v4/pkg/media"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,6 +35,19 @@ type CtrlResponse struct {
|
||||||
Data json.RawMessage `json:"data,omitempty"`
|
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)
|
type EventHandler func(event CtrlResponse)
|
||||||
|
|
||||||
var seq int32 = 1
|
var seq int32 = 1
|
||||||
|
@ -60,7 +75,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
|
||||||
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
|
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("sending ctrl action", string(jsonData))
|
nativeLogger.Info().Str("action", ctrlAction.Action).Msg("sending ctrl action")
|
||||||
|
|
||||||
err = WriteCtrlMessage(jsonData)
|
err = WriteCtrlMessage(jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -90,8 +105,8 @@ func WriteCtrlMessage(message []byte) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var nativeCtrlSocketListener net.Listener
|
var nativeCtrlSocketListener net.Listener //nolint:unused
|
||||||
var nativeVideoSocketListener net.Listener
|
var nativeVideoSocketListener net.Listener //nolint:unused
|
||||||
|
|
||||||
var ctrlClientConnected = make(chan struct{})
|
var ctrlClientConnected = make(chan struct{})
|
||||||
|
|
||||||
|
@ -103,26 +118,28 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
|
||||||
// Remove the socket file if it already exists
|
// Remove the socket file if it already exists
|
||||||
if _, err := os.Stat(socketPath); err == nil {
|
if _, err := os.Stat(socketPath); err == nil {
|
||||||
if err := os.Remove(socketPath); err != nil {
|
if err := os.Remove(socketPath); err != nil {
|
||||||
log.Fatalf("Failed to remove existing socket file %s: %v", socketPath, err)
|
nativeLogger.Warn().Err(err).Str("socket_path", socketPath).Msg("Failed to remove existing socket file")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
listener, err := net.Listen("unixpacket", socketPath)
|
listener, err := net.Listen("unixpacket", socketPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to start server on %s: %v", socketPath, err)
|
nativeLogger.Warn().Err(err).Str("socket_path", socketPath).Msg("Failed to start server")
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Server listening on %s", socketPath)
|
nativeLogger.Info().Str("socket_path", socketPath).Msg("Server listening")
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
conn, err := listener.Accept()
|
conn, err := listener.Accept()
|
||||||
listener.Close()
|
listener.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to accept sock: %v", err)
|
nativeLogger.Warn().Err(err).Str("socket_path", socketPath).Msg("failed to accept sock")
|
||||||
}
|
}
|
||||||
if isCtrl {
|
if isCtrl {
|
||||||
close(ctrlClientConnected)
|
close(ctrlClientConnected)
|
||||||
logger.Debug("first native ctrl socket client connected")
|
nativeLogger.Debug().Msg("first native ctrl socket client connected")
|
||||||
}
|
}
|
||||||
handleClient(conn)
|
handleClient(conn)
|
||||||
}()
|
}()
|
||||||
|
@ -132,40 +149,45 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
|
||||||
|
|
||||||
func StartNativeCtrlSocketServer() {
|
func StartNativeCtrlSocketServer() {
|
||||||
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
|
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
|
||||||
logger.Debug("native app ctrl sock started")
|
nativeLogger.Debug().Msg("native app ctrl sock started")
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartNativeVideoSocketServer() {
|
func StartNativeVideoSocketServer() {
|
||||||
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
|
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
|
||||||
logger.Debug("native app video sock started")
|
nativeLogger.Debug().Msg("native app video sock started")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCtrlClient(conn net.Conn) {
|
func handleCtrlClient(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
logger.Debug("native socket client connected")
|
nativeLogger.Debug().Msg("native socket client connected")
|
||||||
if ctrlSocketConn != nil {
|
if ctrlSocketConn != nil {
|
||||||
logger.Debugf("closing existing native socket connection")
|
nativeLogger.Debug().Msg("closing existing native socket connection")
|
||||||
ctrlSocketConn.Close()
|
ctrlSocketConn.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
ctrlSocketConn = conn
|
ctrlSocketConn = conn
|
||||||
|
|
||||||
|
// Restore HDMI EDID if applicable
|
||||||
|
go restoreHdmiEdid()
|
||||||
|
|
||||||
readBuf := make([]byte, 4096)
|
readBuf := make([]byte, 4096)
|
||||||
for {
|
for {
|
||||||
n, err := conn.Read(readBuf)
|
n, err := conn.Read(readBuf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error reading from ctrl sock: %v", err)
|
nativeLogger.Warn().Err(err).Msg("error reading from ctrl sock")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
readMsg := string(readBuf[:n])
|
readMsg := string(readBuf[:n])
|
||||||
logger.Tracef("ctrl sock msg: %v", readMsg)
|
|
||||||
ctrlResp := CtrlResponse{}
|
ctrlResp := CtrlResponse{}
|
||||||
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
err = json.Unmarshal([]byte(readMsg), &ctrlResp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("error parsing ctrl sock msg: %v", err)
|
nativeLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing ctrl sock msg")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
nativeLogger.Trace().Interface("data", ctrlResp).Msg("ctrl sock msg")
|
||||||
|
|
||||||
if ctrlResp.Seq != 0 {
|
if ctrlResp.Seq != 0 {
|
||||||
responseChan, ok := ongoingRequests[ctrlResp.Seq]
|
responseChan, ok := ongoingRequests[ctrlResp.Seq]
|
||||||
if ok {
|
if ok {
|
||||||
|
@ -178,30 +200,29 @@ func handleCtrlClient(conn net.Conn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debug("ctrl sock disconnected")
|
nativeLogger.Debug().Msg("ctrl sock disconnected")
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleVideoClient(conn net.Conn) {
|
func handleVideoClient(conn net.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
log.Printf("Native video socket client connected: %v", conn.RemoteAddr())
|
nativeLogger.Info().Str("addr", conn.RemoteAddr().String()).Msg("Native video socket client connected")
|
||||||
|
|
||||||
inboundPacket := make([]byte, maxFrameSize)
|
inboundPacket := make([]byte, maxFrameSize)
|
||||||
lastFrame := time.Now()
|
lastFrame := time.Now()
|
||||||
for {
|
for {
|
||||||
n, err := conn.Read(inboundPacket)
|
n, err := conn.Read(inboundPacket)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("error during read: %s", err)
|
nativeLogger.Warn().Err(err).Msg("error during read")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sinceLastFrame := now.Sub(lastFrame)
|
sinceLastFrame := now.Sub(lastFrame)
|
||||||
lastFrame = now
|
lastFrame = now
|
||||||
//fmt.Println("Video packet received", n, sinceLastFrame)
|
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
err := currentSession.VideoTrack.WriteSample(media.Sample{Data: inboundPacket[:n], Duration: sinceLastFrame})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("Error writing sample", err)
|
nativeLogger.Warn().Err(err).Msg("error writing sample")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -220,9 +241,25 @@ func ExtractAndRunNativeBin() error {
|
||||||
// Run the binary in the background
|
// Run the binary in the background
|
||||||
cmd := exec.Command(binaryPath)
|
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
|
// Redirect stdout and stderr to the current process
|
||||||
cmd.Stdout = os.Stdout
|
cmd.Stdout = nativeStdout
|
||||||
cmd.Stderr = os.Stderr
|
cmd.Stderr = nativeStderr
|
||||||
|
|
||||||
|
// Set the process group ID so we can kill the process and its children when this process exits
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||||
|
Setpgid: true,
|
||||||
|
Pdeathsig: syscall.SIGKILL,
|
||||||
|
}
|
||||||
|
|
||||||
// Start the command
|
// Start the command
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
|
@ -232,28 +269,28 @@ func ExtractAndRunNativeBin() error {
|
||||||
//TODO: add auto restart
|
//TODO: add auto restart
|
||||||
go func() {
|
go func() {
|
||||||
<-appCtx.Done()
|
<-appCtx.Done()
|
||||||
logger.Infof("killing process PID: %d", cmd.Process.Pid)
|
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
|
||||||
err := cmd.Process.Kill()
|
err := cmd.Process.Kill()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("failed to kill process: %v", err)
|
nativeLogger.Warn().Err(err).Msg("failed to kill process")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
fmt.Printf("Binary started with PID: %d\n", cmd.Process.Pid)
|
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("Binary started")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldOverwrite(destPath string, srcHash []byte) bool {
|
func shouldOverwrite(destPath string, srcHash []byte) bool {
|
||||||
if srcHash == nil {
|
if srcHash == nil {
|
||||||
logger.Debug("error reading embedded jetkvm_native.sha256, doing overwriting")
|
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
dstHash, err := os.ReadFile(destPath + ".sha256")
|
dstHash, err := os.ReadFile(destPath + ".sha256")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("error reading existing jetkvm_native.sha256, doing overwriting")
|
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,13 +306,13 @@ func ensureBinaryUpdated(destPath string) error {
|
||||||
|
|
||||||
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Debug("error reading embedded jetkvm_native.sha256, proceeding with update")
|
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
|
||||||
srcHash = nil
|
srcHash = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = os.Stat(destPath)
|
_, err = os.Stat(destPath)
|
||||||
if shouldOverwrite(destPath, srcHash) || err != nil {
|
if shouldOverwrite(destPath, srcHash) || err != nil {
|
||||||
logger.Info("writing jetkvm_native")
|
nativeLogger.Info().Msg("writing jetkvm_native")
|
||||||
_ = os.Remove(destPath)
|
_ = os.Remove(destPath)
|
||||||
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
|
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -292,8 +329,20 @@ func ensureBinaryUpdated(destPath string) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Info("jetkvm_native updated")
|
nativeLogger.Info().Msg("jetkvm_native updated")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restore the HDMI EDID value from the config.
|
||||||
|
// Called after successful connection to jetkvm_native.
|
||||||
|
func restoreHdmiEdid() {
|
||||||
|
if config.EdidString != "" {
|
||||||
|
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
|
||||||
|
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
|
||||||
|
if err != nil {
|
||||||
|
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
140
network.go
140
network.go
|
@ -1,22 +1,35 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/hashicorp/go-envparse"
|
||||||
"github.com/pion/mdns/v2"
|
"github.com/pion/mdns/v2"
|
||||||
"golang.org/x/net/ipv4"
|
"golang.org/x/net/ipv4"
|
||||||
"golang.org/x/net/ipv6"
|
"golang.org/x/net/ipv6"
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/vishvananda/netlink"
|
"github.com/vishvananda/netlink"
|
||||||
"github.com/vishvananda/netlink/nl"
|
"github.com/vishvananda/netlink/nl"
|
||||||
)
|
)
|
||||||
|
|
||||||
var networkState struct {
|
var mDNSConn *mdns.Conn
|
||||||
|
|
||||||
|
var networkState NetworkState
|
||||||
|
|
||||||
|
type NetworkState struct {
|
||||||
Up bool
|
Up bool
|
||||||
IPv4 string
|
IPv4 string
|
||||||
IPv6 string
|
IPv6 string
|
||||||
MAC string
|
MAC string
|
||||||
|
|
||||||
|
checked bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocalIpInfo struct {
|
type LocalIpInfo struct {
|
||||||
|
@ -25,44 +38,93 @@ type LocalIpInfo struct {
|
||||||
MAC string
|
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
|
||||||
|
if active {
|
||||||
|
signal = "-SIGUSR1"
|
||||||
|
} else {
|
||||||
|
signal = "-SIGUSR2"
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func checkNetworkState() {
|
func checkNetworkState() {
|
||||||
iface, err := netlink.LinkByName("eth0")
|
iface, err := netlink.LinkByName(NetIfName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed to get eth0 interface: %v\n", err)
|
logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get interface")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newState := struct {
|
newState := NetworkState{
|
||||||
Up bool
|
|
||||||
IPv4 string
|
|
||||||
IPv6 string
|
|
||||||
MAC string
|
|
||||||
}{
|
|
||||||
Up: iface.Attrs().OperState == netlink.OperUp,
|
Up: iface.Attrs().OperState == netlink.OperUp,
|
||||||
MAC: iface.Attrs().HardwareAddr.String(),
|
MAC: iface.Attrs().HardwareAddr.String(),
|
||||||
|
|
||||||
|
checked: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
|
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("failed to get addresses for eth0: %v\n", err)
|
logger.Warn().Err(err).Str("interface", NetIfName).Msg("failed to get addresses")
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the link is going down, put udhcpc into idle mode.
|
||||||
|
// If the link is coming back up, activate udhcpc and force it to renew the lease.
|
||||||
|
if newState.Up != networkState.Up {
|
||||||
|
setDhcpClientState(newState.Up)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, addr := range addrs {
|
for _, addr := range addrs {
|
||||||
if addr.IP.To4() != nil {
|
if addr.IP.To4() != nil {
|
||||||
newState.IPv4 = addr.IP.String()
|
if !newState.Up && networkState.Up {
|
||||||
|
// If the network is going down, remove all IPv4 addresses from the interface.
|
||||||
|
logger.Info().Str("address", addr.IP.String()).Msg("network: state transitioned to down, removing IPv4 address")
|
||||||
|
err := netlink.AddrDel(iface, &addr)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Str("address", addr.IP.String()).Msg("network: failed to delete address")
|
||||||
|
}
|
||||||
|
|
||||||
|
newState.IPv4 = "..."
|
||||||
|
} else {
|
||||||
|
newState.IPv4 = addr.IP.String()
|
||||||
|
}
|
||||||
} else if addr.IP.To16() != nil && newState.IPv6 == "" {
|
} else if addr.IP.To16() != nil && newState.IPv6 == "" {
|
||||||
newState.IPv6 = addr.IP.String()
|
newState.IPv6 = addr.IP.String()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if newState != networkState {
|
if newState != networkState {
|
||||||
|
logger.Info().Msg("network state changed")
|
||||||
|
// restart MDNS
|
||||||
|
_ = startMDNS()
|
||||||
networkState = newState
|
networkState = newState
|
||||||
fmt.Println("network state changed")
|
|
||||||
requestDisplayUpdate()
|
requestDisplayUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startMDNS() error {
|
func startMDNS() error {
|
||||||
|
// If server was previously running, stop it
|
||||||
|
if mDNSConn != nil {
|
||||||
|
logger.Info().Msg("Stopping mDNS server")
|
||||||
|
err := mDNSConn.Close()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("failed to stop mDNS server")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start a new server
|
||||||
|
logger.Info().Msg("Starting mDNS server on jetkvm.local")
|
||||||
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
|
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -83,22 +145,59 @@ func startMDNS() error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
|
mDNSConn, err = mdns.Server(ipv4.NewPacketConn(l4), ipv6.NewPacketConn(l6), &mdns.Config{
|
||||||
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
|
LocalNames: []string{"jetkvm.local"}, //TODO: make it configurable
|
||||||
|
LoggerFactory: defaultLoggerFactory,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
mDNSConn = nil
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
//defer server.Close()
|
//defer server.Close()
|
||||||
return nil
|
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 init() {
|
func init() {
|
||||||
|
ensureConfigLoaded()
|
||||||
|
|
||||||
updates := make(chan netlink.LinkUpdate)
|
updates := make(chan netlink.LinkUpdate)
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
|
|
||||||
if err := netlink.LinkSubscribe(updates, done); err != nil {
|
if err := netlink.LinkSubscribe(updates, done); err != nil {
|
||||||
fmt.Println("failed to subscribe to link updates: %v", err)
|
logger.Warn().Err(err).Msg("failed to subscribe to link updates")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -111,8 +210,8 @@ func init() {
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case update := <-updates:
|
case update := <-updates:
|
||||||
if update.Link.Attrs().Name == "eth0" {
|
if update.Link.Attrs().Name == NetIfName {
|
||||||
fmt.Printf("link update: %+v\n", update)
|
logger.Info().Interface("update", update).Msg("link update")
|
||||||
checkNetworkState()
|
checkNetworkState()
|
||||||
}
|
}
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
|
@ -122,9 +221,8 @@ func init() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
fmt.Println("Starting mDNS server")
|
|
||||||
err := startMDNS()
|
err := startMDNS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("failed to run mDNS: %v", err)
|
logger.Warn().Err(err).Msg("failed to run mDNS")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
103
ntp.go
103
ntp.go
|
@ -3,28 +3,96 @@ package kvm
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/beevik/ntp"
|
"github.com/beevik/ntp"
|
||||||
)
|
)
|
||||||
|
|
||||||
var timeSynced = false
|
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
|
||||||
|
}
|
||||||
|
|
||||||
func TimeSyncLoop() {
|
func TimeSyncLoop() {
|
||||||
for {
|
for {
|
||||||
fmt.Println("Syncing system time")
|
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")
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
err := SyncSystemTime()
|
err := SyncSystemTime()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to sync system time: %v", err)
|
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
|
||||||
|
}
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
|
timeSyncSuccess = true
|
||||||
timeSynced = true
|
ntpLogger.Info().Str("now", time.Now().Format(time.RFC3339)).
|
||||||
time.Sleep(1 * time.Hour) //once the first sync is done, sync every hour
|
Str("time_taken", time.Since(start).String()).
|
||||||
|
Msg("Time sync successful")
|
||||||
|
time.Sleep(timeSyncInterval) // after the first sync is done
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,13 +109,22 @@ func SyncSystemTime() (err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func queryNetworkTime() (*time.Time, error) {
|
func queryNetworkTime() (*time.Time, error) {
|
||||||
ntpServers := []string{
|
ntpServers, err := getNTPServersFromDHCPInfo()
|
||||||
"time.cloudflare.com",
|
if err != nil {
|
||||||
"time.apple.com",
|
ntpLogger.Error().Str("error", err.Error()).Msg("failed to get NTP servers from DHCP info")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ntpServers == nil {
|
||||||
|
ntpServers = defaultNTPServers
|
||||||
|
ntpLogger.Info().Str("ntp_servers", fmt.Sprintf("%v", ntpServers)).Msg("Using default NTP servers")
|
||||||
|
} else {
|
||||||
|
ntpLogger.Info().Str("ntp_servers", fmt.Sprintf("%v", ntpServers)).Msg("Using NTP servers from DHCP")
|
||||||
|
}
|
||||||
|
|
||||||
for _, server := range ntpServers {
|
for _, server := range ntpServers {
|
||||||
now, err := queryNtpServer(server, 2*time.Second)
|
now, err := queryNtpServer(server, timeSyncTimeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
ntpLogger.Info().Str("ntp_server", server).Str("time", now.Format(time.RFC3339)).Msg("NTP server returned time")
|
||||||
return now, nil
|
return now, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -56,11 +133,13 @@ func queryNetworkTime() (*time.Time, error) {
|
||||||
"http://cloudflare.com",
|
"http://cloudflare.com",
|
||||||
}
|
}
|
||||||
for _, url := range httpUrls {
|
for _, url := range httpUrls {
|
||||||
now, err := queryHttpTime(url, 2*time.Second)
|
now, err := queryHttpTime(url, timeSyncTimeout)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
ntpLogger.Info().Str("http_url", url).Str("time", now.Format(time.RFC3339)).Msg("HTTP server returned time")
|
||||||
return now, nil
|
return now, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ntpLogger.Error().Msg("failed to query network time")
|
||||||
return nil, errors.New("failed to query network time")
|
return nil, errors.New("failed to query network time")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
36
ota.go
36
ota.go
|
@ -8,7 +8,6 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
@ -77,7 +76,7 @@ func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease
|
||||||
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
|
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
|
||||||
updateUrl.RawQuery = query.Encode()
|
updateUrl.RawQuery = query.Encode()
|
||||||
|
|
||||||
fmt.Println("Checking for updates at:", updateUrl.String())
|
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
|
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -127,7 +126,13 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
|
||||||
return fmt.Errorf("error creating request: %w", err)
|
return fmt.Errorf("error creating request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := http.DefaultClient.Do(req)
|
// 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error downloading file: %w", err)
|
return fmt.Errorf("error downloading file: %w", err)
|
||||||
}
|
}
|
||||||
|
@ -230,7 +235,7 @@ func verifyFile(path string, expectedHash string, verifyProgress *float32) error
|
||||||
}
|
}
|
||||||
|
|
||||||
hashSum := hash.Sum(nil)
|
hashSum := hash.Sum(nil)
|
||||||
fmt.Printf("SHA256 hash of %s: %x\n", path, hashSum)
|
logger.Info().Str("path", path).Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
|
||||||
|
|
||||||
if hex.EncodeToString(hashSum) != expectedHash {
|
if hex.EncodeToString(hashSum) != expectedHash {
|
||||||
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
|
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
|
||||||
|
@ -272,7 +277,7 @@ var otaState = OTAState{}
|
||||||
func triggerOTAStateUpdate() {
|
func triggerOTAStateUpdate() {
|
||||||
go func() {
|
go func() {
|
||||||
if currentSession == nil {
|
if currentSession == nil {
|
||||||
log.Println("No active RPC session, skipping update state update")
|
logger.Info().Msg("No active RPC session, skipping update state update")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
writeJSONRPCEvent("otaState", otaState, currentSession)
|
||||||
|
@ -280,7 +285,7 @@ func triggerOTAStateUpdate() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
|
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
|
||||||
log.Println("Trying to update...")
|
logger.Info().Msg("Trying to update...")
|
||||||
if otaState.Updating {
|
if otaState.Updating {
|
||||||
return fmt.Errorf("update already in progress")
|
return fmt.Errorf("update already in progress")
|
||||||
}
|
}
|
||||||
|
@ -315,7 +320,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||||
rebootNeeded := false
|
rebootNeeded := false
|
||||||
|
|
||||||
if appUpdateAvailable {
|
if appUpdateAvailable {
|
||||||
fmt.Printf("App update available: %s -> %s\n", local.AppVersion, remote.AppVersion)
|
logger.Info().Str("local", local.AppVersion).Str("remote", remote.AppVersion).Msg("App update available")
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
|
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -341,14 +346,15 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||||
otaState.AppUpdateProgress = 1
|
otaState.AppUpdateProgress = 1
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
|
|
||||||
fmt.Println("App update downloaded")
|
logger.Info().Msg("App update downloaded")
|
||||||
rebootNeeded = true
|
rebootNeeded = true
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("App is up to date")
|
logger.Info().Msg("App is up to date")
|
||||||
}
|
}
|
||||||
|
|
||||||
if systemUpdateAvailable {
|
if systemUpdateAvailable {
|
||||||
fmt.Printf("System update available: %s -> %s\n", local.SystemVersion, remote.SystemVersion)
|
logger.Info().Str("local", local.SystemVersion).Str("remote", remote.SystemVersion).Msg("System update available")
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
|
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
||||||
|
@ -366,7 +372,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Println("System update downloaded")
|
logger.Info().Msg("System update downloaded")
|
||||||
verifyFinished := time.Now()
|
verifyFinished := time.Now()
|
||||||
otaState.SystemVerifiedAt = &verifyFinished
|
otaState.SystemVerifiedAt = &verifyFinished
|
||||||
otaState.SystemVerificationProgress = 1
|
otaState.SystemVerificationProgress = 1
|
||||||
|
@ -413,17 +419,17 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
|
||||||
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
|
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("rk_ota success, output: %s\n", output)
|
logger.Info().Str("output", output).Msg("rk_ota success")
|
||||||
otaState.SystemUpdateProgress = 1
|
otaState.SystemUpdateProgress = 1
|
||||||
otaState.SystemUpdatedAt = &verifyFinished
|
otaState.SystemUpdatedAt = &verifyFinished
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate()
|
||||||
rebootNeeded = true
|
rebootNeeded = true
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("System is up to date")
|
logger.Info().Msg("System is up to date")
|
||||||
}
|
}
|
||||||
|
|
||||||
if rebootNeeded {
|
if rebootNeeded {
|
||||||
fmt.Println("System Rebooting in 10s...")
|
logger.Info().Msg("System Rebooting in 10s")
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
cmd := exec.Command("reboot")
|
cmd := exec.Command("reboot")
|
||||||
err := cmd.Start()
|
err := cmd.Start()
|
||||||
|
@ -498,6 +504,6 @@ func IsUpdatePending() bool {
|
||||||
func confirmCurrentSystem() {
|
func confirmCurrentSystem() {
|
||||||
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
|
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warnf("failed to set current partition in A/B setup: %s", string(output))
|
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
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"))
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
# Check if a commit message was provided
|
# Check if a commit message was provided
|
||||||
if [ -z "$1" ]; then
|
if [ -z "$1" ]; then
|
||||||
|
@ -26,7 +26,7 @@ git checkout -b release-temp
|
||||||
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
|
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
|
||||||
git reset --soft public/main
|
git reset --soft public/main
|
||||||
else
|
else
|
||||||
git reset --soft $(git rev-list --max-parents=0 HEAD)
|
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Merge changes from main
|
# Merge changes from main
|
||||||
|
|
|
@ -44,12 +44,12 @@ func (w *WebRTCDiskReader) Read(ctx context.Context, offset int64, size int64) (
|
||||||
return nil, errors.New("not active session")
|
return nil, errors.New("not active session")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.Debugf("reading from webrtc %v", string(jsonBytes))
|
logger.Debug().Str("request", string(jsonBytes)).Msg("reading from webrtc")
|
||||||
err = currentSession.DiskChannel.SendText(string(jsonBytes))
|
err = currentSession.DiskChannel.SendText(string(jsonBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
buf := make([]byte, 0)
|
var buf []byte
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case data := <-diskReadChan:
|
case data := <-diskReadChan:
|
||||||
|
|
|
@ -0,0 +1,267 @@
|
||||||
|
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() {
|
||||||
|
reader := bufio.NewReader(port)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Error reading from serial port")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Each line should be 4 binary digits + newline
|
||||||
|
if len(line) != 5 {
|
||||||
|
logger.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 {
|
||||||
|
logger.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() {
|
||||||
|
reader := bufio.NewReader(port)
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
logger.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 {
|
||||||
|
logger.Warn().Str("line", line).Msg("Invalid line")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse new states
|
||||||
|
powerState, err := strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Invalid power state")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dcState.IsOn = powerState == 1
|
||||||
|
milliVolts, err := strconv.ParseFloat(parts[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Invalid voltage")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
volts := milliVolts / 1000 // Convert mV to V
|
||||||
|
|
||||||
|
milliAmps, err := strconv.ParseFloat(parts[2], 64)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Invalid current")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
amps := milliAmps / 1000 // Convert mA to A
|
||||||
|
|
||||||
|
milliWatts, err := strconv.ParseFloat(parts[3], 64)
|
||||||
|
if err != nil {
|
||||||
|
logger.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()
|
||||||
|
if config.ActiveExtension == "atx-power" {
|
||||||
|
_ = mountATXControl()
|
||||||
|
} else if config.ActiveExtension == "dc-power" {
|
||||||
|
_ = mountDCControl()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func reopenSerialPort() error {
|
||||||
|
if port != nil {
|
||||||
|
port.Close()
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
port, err = serial.Open(serialPortPath, defaultMode)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn().Err(err).Msg("Error opening serial port")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleSerialChannel(d *webrtc.DataChannel) {
|
||||||
|
d.OnOpen(func() {
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for {
|
||||||
|
n, err := port.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
if err != io.EOF {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to read from serial port")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = d.Send(buf[:n])
|
||||||
|
if err != nil {
|
||||||
|
logger.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 {
|
||||||
|
logger.Warn().Err(err).Msg("Failed to write to serial")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
d.OnClose(func() {
|
||||||
|
|
||||||
|
})
|
||||||
|
}
|
18
terminal.go
18
terminal.go
|
@ -23,7 +23,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
||||||
var err error
|
var err error
|
||||||
ptmx, err = pty.Start(cmd)
|
ptmx, err = pty.Start(cmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to start pty: %v", err)
|
logger.Warn().Err(err).Msg("Failed to start pty")
|
||||||
d.Close()
|
d.Close()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -34,13 +34,13 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
||||||
n, err := ptmx.Read(buf)
|
n, err := ptmx.Read(buf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err != io.EOF {
|
if err != io.EOF {
|
||||||
logger.Errorf("Failed to read from pty: %v", err)
|
logger.Warn().Err(err).Msg("Failed to read from pty")
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
err = d.Send(buf[:n])
|
err = d.Send(buf[:n])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to send pty output: %v", err)
|
logger.Warn().Err(err).Msg("Failed to send pty output")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -55,17 +55,19 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
||||||
var size TerminalSize
|
var size TerminalSize
|
||||||
err := json.Unmarshal([]byte(msg.Data), &size)
|
err := json.Unmarshal([]byte(msg.Data), &size)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
pty.Setsize(ptmx, &pty.Winsize{
|
err = pty.Setsize(ptmx, &pty.Winsize{
|
||||||
Rows: uint16(size.Rows),
|
Rows: uint16(size.Rows),
|
||||||
Cols: uint16(size.Cols),
|
Cols: uint16(size.Cols),
|
||||||
})
|
})
|
||||||
return
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
logger.Errorf("Failed to parse terminal size: %v", err)
|
logger.Warn().Err(err).Msg("Failed to parse terminal size")
|
||||||
}
|
}
|
||||||
_, err := ptmx.Write(msg.Data)
|
_, err := ptmx.Write(msg.Data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("Failed to write to pty: %v", err)
|
logger.Warn().Err(err).Msg("Failed to write to pty")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -74,7 +76,7 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
|
||||||
ptmx.Close()
|
ptmx.Close()
|
||||||
}
|
}
|
||||||
if cmd != nil && cmd.Process != nil {
|
if cmd != nil && cmd.Process != nil {
|
||||||
cmd.Process.Kill()
|
_ = cmd.Process.Kill()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||||
|
|
||||||
|
# We use this for all the cloud API requests from the browser
|
||||||
|
VITE_CLOUD_API=http://localhost:3000
|
|
@ -0,0 +1,4 @@
|
||||||
|
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||||
|
|
||||||
|
# We use this for all the cloud API requests from the browser
|
||||||
|
VITE_CLOUD_API=https://api.jetkvm.com
|
|
@ -0,0 +1,4 @@
|
||||||
|
# No need for VITE_CLOUD_APP it's only needed for the device build
|
||||||
|
|
||||||
|
# We use this for all the cloud API requests from the browser
|
||||||
|
VITE_CLOUD_API=https://staging-api.jetkvm.com
|
|
@ -1,4 +0,0 @@
|
||||||
VITE_SIGNAL_API=http://localhost:3000
|
|
||||||
|
|
||||||
VITE_CLOUD_APP=http://localhost:5173
|
|
||||||
VITE_CLOUD_API=http://localhost:3000
|
|
|
@ -1,4 +0,0 @@
|
||||||
VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
|
|
||||||
|
|
||||||
VITE_CLOUD_APP=https://app.jetkvm.com
|
|
||||||
VITE_CLOUD_API=https://api.jetkvm.com
|
|
|
@ -1,4 +0,0 @@
|
||||||
VITE_SIGNAL_API=https://api.jetkvm.com
|
|
||||||
|
|
||||||
VITE_CLOUD_APP=https://app.jetkvm.com
|
|
||||||
VITE_CLOUD_API=https://api.jetkvm.com
|
|
|
@ -8,6 +8,8 @@ module.exports = {
|
||||||
"plugin:react-hooks/recommended",
|
"plugin:react-hooks/recommended",
|
||||||
"plugin:react/recommended",
|
"plugin:react/recommended",
|
||||||
"plugin:react/jsx-runtime",
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"prettier",
|
||||||
],
|
],
|
||||||
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
|
ignorePatterns: ["dist", ".eslintrc.cjs", "tailwind.config.js", "postcss.config.js"],
|
||||||
parser: "@typescript-eslint/parser",
|
parser: "@typescript-eslint/parser",
|
||||||
|
@ -20,5 +22,45 @@ module.exports = {
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
|
"import/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @description
|
||||||
|
*
|
||||||
|
* This keeps imports separate from one another, ensuring that imports are separated
|
||||||
|
* by their relative groups. As you move through the groups, imports become closer
|
||||||
|
* to the current file.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```
|
||||||
|
* import fs from 'fs';
|
||||||
|
*
|
||||||
|
* import package from 'npm-package';
|
||||||
|
*
|
||||||
|
* import xyz from '~/project-file';
|
||||||
|
*
|
||||||
|
* import index from '../';
|
||||||
|
*
|
||||||
|
* import sibling from './foo';
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
groups: ["builtin", "external", "internal", "parent", "sibling"],
|
||||||
|
"newlines-between": "always",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
"import/resolver": {
|
||||||
|
alias: {
|
||||||
|
map: [
|
||||||
|
["@components", "./src/components"],
|
||||||
|
["@routes", "./src/routes"],
|
||||||
|
["@assets", "./src/assets"],
|
||||||
|
["@", "./src"],
|
||||||
|
],
|
||||||
|
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -5,11 +5,7 @@
|
||||||
"useTabs": false,
|
"useTabs": false,
|
||||||
"arrowParens": "avoid",
|
"arrowParens": "avoid",
|
||||||
"singleQuote": false,
|
"singleQuote": false,
|
||||||
"plugins": [
|
"plugins": ["prettier-plugin-tailwindcss"],
|
||||||
"prettier-plugin-tailwindcss"
|
"tailwindFunctions": ["clsx"],
|
||||||
],
|
|
||||||
"tailwindFunctions": [
|
|
||||||
"clsx"
|
|
||||||
],
|
|
||||||
"printWidth": 90
|
"printWidth": 90
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/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
|
File diff suppressed because it is too large
Load Diff
|
@ -7,24 +7,30 @@
|
||||||
"node": "21.1.0"
|
"node": "21.1.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --mode=development",
|
"dev": "./dev_device.sh",
|
||||||
|
"dev:cloud": "vite dev --mode=cloud-development",
|
||||||
"build": "npm run build:prod",
|
"build": "npm run build:prod",
|
||||||
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
"build:device": "tsc && vite build --mode=device --emptyOutDir",
|
||||||
"build:prod": "tsc && vite build --mode=production",
|
"build:staging": "tsc && vite build --mode=cloud-staging",
|
||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"build:prod": "tsc && vite build --mode=cloud-production",
|
||||||
|
"lint": "eslint './src/**/*.{ts,tsx}'",
|
||||||
|
"lint:fix": "eslint './src/**/*.{ts,tsx}' --fix",
|
||||||
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@headlessui/react": "^2.1.10",
|
"@headlessui/react": "^2.2.0",
|
||||||
"@headlessui/tailwindcss": "^0.2.0",
|
"@headlessui/tailwindcss": "^0.2.1",
|
||||||
"@heroicons/react": "^2.1.3",
|
"@heroicons/react": "^2.2.0",
|
||||||
"@xterm/addon-clipboard": "^0.1.0",
|
"@xterm/addon-clipboard": "^0.1.0",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/addon-unicode11": "^0.8.0",
|
"@xterm/addon-unicode11": "^0.8.0",
|
||||||
"@xterm/addon-web-links": "^0.11.0",
|
"@xterm/addon-web-links": "^0.11.0",
|
||||||
"@xterm/addon-webgl": "^0.18.0",
|
"@xterm/addon-webgl": "^0.18.0",
|
||||||
|
"@xterm/xterm": "^5.5.0",
|
||||||
"cva": "^1.0.0-beta.1",
|
"cva": "^1.0.0-beta.1",
|
||||||
|
"eslint-import-resolver-alias": "^1.1.2",
|
||||||
"focus-trap-react": "^10.2.3",
|
"focus-trap-react": "^10.2.3",
|
||||||
"framer-motion": "^11.0.28",
|
"framer-motion": "^11.15.0",
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"mini-svg-data-uri": "^1.4.4",
|
"mini-svg-data-uri": "^1.4.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
@ -34,33 +40,38 @@
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "^5.4.0",
|
||||||
"react-router-dom": "^6.22.3",
|
"react-router-dom": "^6.22.3",
|
||||||
"react-simple-keyboard": "^3.7.112",
|
"react-simple-keyboard": "^3.7.112",
|
||||||
"recharts": "^2.12.6",
|
"react-use-websocket": "^4.13.0",
|
||||||
"tailwind-merge": "^2.2.2",
|
"react-xtermjs": "^1.0.9",
|
||||||
|
"recharts": "^2.15.0",
|
||||||
|
"tailwind-merge": "^2.5.5",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"validator": "^13.12.0",
|
"validator": "^13.12.0",
|
||||||
"xterm": "^5.3.0",
|
"xterm": "^5.3.0",
|
||||||
"zustand": "^4.5.2"
|
"zustand": "^4.5.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"@tailwindcss/forms": "^0.5.9",
|
||||||
"@tailwindcss/typography": "^0.5.12",
|
"@tailwindcss/typography": "^0.5.15",
|
||||||
"@types/react": "^18.2.66",
|
"@types/react": "^18.2.66",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/semver": "^7.5.8",
|
||||||
"@types/validator": "^13.12.2",
|
"@types/validator": "^13.12.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
"@typescript-eslint/eslint-plugin": "^8.25.0",
|
||||||
"@typescript-eslint/parser": "^7.2.0",
|
"@typescript-eslint/parser": "^8.25.0",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.7.2",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.20.0",
|
||||||
"eslint-plugin-react": "^7.34.1",
|
"eslint-config-prettier": "^10.0.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-import": "^2.31.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.6",
|
"eslint-plugin-react": "^7.37.4",
|
||||||
"postcss": "^8.4.38",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"prettier": "^3.2.5",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"prettier-plugin-tailwindcss": "^0.5.13",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.3",
|
"prettier": "^3.4.2",
|
||||||
"typescript": "^5.2.2",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.2",
|
||||||
"vite": "^5.2.0",
|
"vite": "^5.2.0",
|
||||||
"vite-tsconfig-paths": "^4.3.2"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because one or more lines are too long
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 10 KiB |
|
@ -1,34 +1,38 @@
|
||||||
|
import { MdOutlineContentPasteGo } from "react-icons/md";
|
||||||
|
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
||||||
|
import { FaKeyboard } from "react-icons/fa6";
|
||||||
|
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
||||||
|
import { Fragment, useCallback, useRef } from "react";
|
||||||
|
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
useMountMediaStore,
|
useMountMediaStore,
|
||||||
useUiStore,
|
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
|
useUiStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { MdOutlineContentPasteGo } from "react-icons/md";
|
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import PasteModal from "@/components/popovers/PasteModal";
|
import PasteModal from "@/components/popovers/PasteModal";
|
||||||
import { FaKeyboard } from "react-icons/fa6";
|
|
||||||
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
|
||||||
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
|
import MountPopopover from "@/components/popovers/MountPopover";
|
||||||
import MountPopopover from "./popovers/MountPopover";
|
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { CommandLineIcon } from "@heroicons/react/20/solid";
|
|
||||||
|
|
||||||
export default function Actionbar({
|
export default function Actionbar({
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
}: {
|
}: {
|
||||||
requestFullscreen: () => Promise<void>;
|
requestFullscreen: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
|
||||||
|
|
||||||
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
const setVirtualKeyboard = useHidStore(state => state.setVirtualKeyboardEnabled);
|
||||||
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
const toggleSidebarView = useUiStore(state => state.toggleSidebarView);
|
||||||
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
const setDisableFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
||||||
const enableTerminal = useUiStore(state => state.enableTerminal);
|
const terminalType = useUiStore(state => state.terminalType);
|
||||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
const setTerminalType = useUiStore(state => state.setTerminalType);
|
||||||
const remoteVirtualMediaState = useMountMediaStore(
|
const remoteVirtualMediaState = useMountMediaStore(
|
||||||
state => state.remoteVirtualMediaState,
|
state => state.remoteVirtualMediaState,
|
||||||
);
|
);
|
||||||
|
@ -53,7 +57,7 @@ export default function Actionbar({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container className="bg-white border-b border-b-slate-800/20 dark:bg-slate-900 dark:border-b-slate-300/20">
|
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||||
<div
|
<div
|
||||||
onKeyUp={e => e.stopPropagation()}
|
onKeyUp={e => e.stopPropagation()}
|
||||||
onKeyDown={e => e.stopPropagation()}
|
onKeyDown={e => e.stopPropagation()}
|
||||||
|
@ -66,7 +70,7 @@ export default function Actionbar({
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Web Terminal"
|
text="Web Terminal"
|
||||||
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
|
||||||
onClick={() => setEnableTerminal(!enableTerminal)}
|
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Popover>
|
<Popover>
|
||||||
|
@ -92,7 +96,7 @@ export default function Actionbar({
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
checkIfStateChanged(open);
|
checkIfStateChanged(open);
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xl mx-auto">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<PasteModal />
|
<PasteModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -134,7 +138,7 @@ export default function Actionbar({
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
checkIfStateChanged(open);
|
checkIfStateChanged(open);
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xl mx-auto">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<MountPopopover />
|
<MountPopopover />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -148,7 +152,7 @@ export default function Actionbar({
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Wake on Lan"
|
text="Wake on LAN"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setDisableFocusTrap(true);
|
setDisableFocusTrap(true);
|
||||||
}}
|
}}
|
||||||
|
@ -186,7 +190,7 @@ export default function Actionbar({
|
||||||
{({ open }) => {
|
{({ open }) => {
|
||||||
checkIfStateChanged(open);
|
checkIfStateChanged(open);
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-xl mx-auto">
|
<div className="mx-auto w-full max-w-xl">
|
||||||
<WakeOnLanModal />
|
<WakeOnLanModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -206,6 +210,33 @@ export default function Actionbar({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
|
<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">
|
<div className="block lg:hidden">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
@ -232,16 +263,17 @@ export default function Actionbar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden xs:block ">
|
<div>
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Settings"
|
text="Settings"
|
||||||
LeadingIcon={LuSettings}
|
LeadingIcon={LuSettings}
|
||||||
onClick={() => toggleSidebarView("system")}
|
onClick={() => navigateTo("/settings")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="items-center hidden gap-x-2 lg:flex">
|
|
||||||
|
<div className="hidden items-center gap-x-2 lg:flex">
|
||||||
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
|
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" />
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
|
|
|
@ -1,20 +1,22 @@
|
||||||
|
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { Button, LinkButton } from "@components/Button";
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import { GoogleIcon } from "@components/Icons";
|
import { GoogleIcon } from "@components/Icons";
|
||||||
import SimpleNavbar from "@components/SimpleNavbar";
|
import SimpleNavbar from "@components/SimpleNavbar";
|
||||||
import Container from "@components/Container";
|
import Container from "@components/Container";
|
||||||
import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
|
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import GridBackground from "@components/GridBackground";
|
import GridBackground from "@components/GridBackground";
|
||||||
import StepCounter from "@components/StepCounter";
|
import StepCounter from "@components/StepCounter";
|
||||||
|
import { CLOUD_API } from "@/ui.config";
|
||||||
|
|
||||||
type AuthLayoutProps = {
|
interface AuthLayoutProps {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
action: string;
|
action: string;
|
||||||
cta: string;
|
cta: string;
|
||||||
ctaHref: string;
|
ctaHref: string;
|
||||||
showCounter?: boolean;
|
showCounter?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function AuthLayout({
|
export default function AuthLayout({
|
||||||
title,
|
title,
|
||||||
|
@ -45,8 +47,8 @@ export default function AuthLayout({
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex items-center justify-center w-full h-full isolate">
|
<div className="isolate flex h-full w-full items-center justify-center">
|
||||||
<div className="max-w-2xl -mt-16 space-y-8">
|
<div className="-mt-16 max-w-2xl space-y-8">
|
||||||
{showCounter ? (
|
{showCounter ? (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<StepCounter currStepIdx={0} nSteps={2} />
|
<StepCounter currStepIdx={0} nSteps={2} />
|
||||||
|
@ -60,11 +62,8 @@ export default function AuthLayout({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Fieldset className="space-y-12">
|
<Fieldset className="space-y-12">
|
||||||
<div className="max-w-sm mx-auto space-y-4">
|
<div className="mx-auto max-w-sm space-y-4">
|
||||||
<form
|
<form action={`${CLOUD_API}/oidc/google`} method="POST">
|
||||||
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
|
|
||||||
method="POST"
|
|
||||||
>
|
|
||||||
{/*This could be the KVM ID*/}
|
{/*This could be the KVM ID*/}
|
||||||
{deviceId ? (
|
{deviceId ? (
|
||||||
<input type="hidden" name="deviceId" value={deviceId} />
|
<input type="hidden" name="deviceId" value={deviceId} />
|
||||||
|
|
|
@ -22,7 +22,7 @@ const AutoHeight = ({ children, ...props }: { children: React.ReactNode }) => {
|
||||||
{...props}
|
{...props}
|
||||||
height={height}
|
height={height}
|
||||||
duration={300}
|
duration={300}
|
||||||
contentClassName="auto-content pointer-events-none"
|
contentClassName="h-fit"
|
||||||
contentRef={contentDiv}
|
contentRef={contentDiv}
|
||||||
disableDisplayNone
|
disableDisplayNone
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
||||||
|
|
||||||
import ExtLink from "@/components/ExtLink";
|
import ExtLink from "@/components/ExtLink";
|
||||||
import LoadingSpinner from "@/components/LoadingSpinner";
|
import LoadingSpinner from "@/components/LoadingSpinner";
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
import { FetcherWithComponents, Link, LinkProps, useNavigation } from "react-router-dom";
|
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
XS: "h-[28px] px-2 text-xs",
|
XS: "h-[28px] px-2 text-xs",
|
||||||
|
@ -101,7 +102,7 @@ const iconVariants = cva({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type ButtonContentPropsType = {
|
interface ButtonContentPropsType {
|
||||||
text?: string | React.ReactNode;
|
text?: string | React.ReactNode;
|
||||||
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
|
LeadingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||||
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
|
TrailingIcon?: React.FC<{ className: string | undefined }> | null;
|
||||||
|
@ -111,7 +112,7 @@ type ButtonContentPropsType = {
|
||||||
size: keyof typeof sizes;
|
size: keyof typeof sizes;
|
||||||
theme: keyof typeof themes;
|
theme: keyof typeof themes;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
function ButtonContent(props: ButtonContentPropsType) {
|
function ButtonContent(props: ButtonContentPropsType) {
|
||||||
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
|
const { text, LeadingIcon, TrailingIcon, fullWidth, className, textAlign, loading } =
|
||||||
|
@ -156,7 +157,16 @@ function ButtonContent(props: ButtonContentPropsType) {
|
||||||
|
|
||||||
type ButtonPropsType = Pick<
|
type ButtonPropsType = Pick<
|
||||||
JSX.IntrinsicElements["button"],
|
JSX.IntrinsicElements["button"],
|
||||||
"type" | "disabled" | "onClick" | "name" | "value" | "formNoValidate" | "onMouseLeave"
|
| "type"
|
||||||
|
| "disabled"
|
||||||
|
| "onClick"
|
||||||
|
| "name"
|
||||||
|
| "value"
|
||||||
|
| "formNoValidate"
|
||||||
|
| "onMouseLeave"
|
||||||
|
| "onMouseDown"
|
||||||
|
| "onMouseUp"
|
||||||
|
| "onMouseLeave"
|
||||||
> &
|
> &
|
||||||
React.ComponentProps<typeof ButtonContent> & {
|
React.ComponentProps<typeof ButtonContent> & {
|
||||||
fetcher?: FetcherWithComponents<unknown>;
|
fetcher?: FetcherWithComponents<unknown>;
|
||||||
|
@ -179,6 +189,9 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
|
||||||
type={type}
|
type={type}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onMouseDown={props?.onMouseDown}
|
||||||
|
onMouseUp={props?.onMouseUp}
|
||||||
|
onMouseLeave={props?.onMouseLeave}
|
||||||
name={props.name}
|
name={props.name}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import React from "react";
|
import React, { forwardRef } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
type CardPropsType = {
|
interface CardPropsType {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const GridCard = ({
|
export const GridCard = ({
|
||||||
children,
|
children,
|
||||||
|
@ -16,23 +17,28 @@ export const GridCard = ({
|
||||||
return (
|
return (
|
||||||
<Card className={cx("overflow-hidden", cardClassName)}>
|
<Card className={cx("overflow-hidden", cardClassName)}>
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div className="absolute inset-0 z-0 w-full h-full transition-colors duration-300 ease-in-out bg-gradient-to-tr from-blue-50/30 to-blue-50/20 dark:from-slate-800/30 dark:to-slate-800/20" />
|
<div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" />
|
||||||
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
|
<div className="absolute inset-0 z-0 h-full w-full rotate-0 bg-grid-blue-100/[25%] dark:bg-grid-slate-700/[7%]" />
|
||||||
<div className="h-full isolate">{children}</div>
|
<div className="isolate h-full">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Card({ children, className }: CardPropsType) {
|
const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className }, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={ref}
|
||||||
className={cx(
|
className={cx(
|
||||||
"w-full rounded border-none dark:bg-slate-800 dark:outline-slate-300/20 bg-white shadow outline outline-1 outline-slate-800/30",
|
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
|
export default Card;
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
headline: string;
|
headline: string;
|
||||||
description?: string | React.ReactNode;
|
description?: string | React.ReactNode;
|
||||||
Button?: React.ReactNode;
|
Button?: React.ReactNode;
|
||||||
};
|
}
|
||||||
|
|
||||||
export const CardHeader = ({ headline, description, Button }: Props) => {
|
export const CardHeader = ({ headline, description, Button }: Props) => {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
|
@ -36,11 +37,11 @@ type CheckBoxProps = {
|
||||||
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
|
} & Omit<JSX.IntrinsicElements["input"], "size" | "type">;
|
||||||
|
|
||||||
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
|
||||||
{ size = "MD", ...props },
|
{ size = "MD", className, ...props },
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
const classes = checkboxVariants({ size });
|
const classes = checkboxVariants({ size });
|
||||||
return <input ref={ref} {...props} type="checkbox" className={classes} />;
|
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />;
|
||||||
});
|
});
|
||||||
Checkbox.displayName = "Checkbox";
|
Checkbox.displayName = "Checkbox";
|
||||||
|
|
||||||
|
@ -52,7 +53,7 @@ type CheckboxWithLabelProps = React.ComponentProps<typeof FieldLabel> &
|
||||||
|
|
||||||
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
|
const CheckboxWithLabel = forwardRef<HTMLInputElement, CheckboxWithLabelProps>(
|
||||||
function CheckboxWithLabel(
|
function CheckboxWithLabel(
|
||||||
{ label, id, description, as, fullWidth, readOnly, ...props },
|
{ label, id, description, fullWidth, readOnly, ...props },
|
||||||
ref: Ref<HTMLInputElement>,
|
ref: Ref<HTMLInputElement>,
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -0,0 +1,119 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,106 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,4 +1,6 @@
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import React, { ReactNode } from "react";
|
import React, { ReactNode } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
function Container({ children, className }: { children: ReactNode; className?: string }) {
|
function Container({ children, className }: { children: ReactNode; className?: string }) {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import Card from "@components/Card";
|
import Card from "@components/Card";
|
||||||
|
|
||||||
export type CustomTooltipProps = {
|
export interface CustomTooltipProps {
|
||||||
payload: { payload: { date: number; stat: number }; unit: string }[];
|
payload: { payload: { date: number; stat: number }; unit: string }[];
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
export default function CustomTooltip({ payload }: CustomTooltipProps) {
|
||||||
if (payload?.length) {
|
if (payload?.length) {
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { GridCard } from "@/components/Card";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import { GridCard } from "@/components/Card";
|
||||||
|
|
||||||
import { cx } from "../cva.config";
|
import { cx } from "../cva.config";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
IconElm?: React.FC<any>;
|
IconElm?: React.FC<{ className: string | undefined }>;
|
||||||
headline: string;
|
headline: string;
|
||||||
description?: string | React.ReactNode;
|
description?: string | React.ReactNode;
|
||||||
BtnElm?: React.ReactNode;
|
BtnElm?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default function EmptyCard({
|
export default function EmptyCard({
|
||||||
IconElm,
|
IconElm,
|
||||||
|
@ -27,10 +29,16 @@ export default function EmptyCard({
|
||||||
>
|
>
|
||||||
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{IconElm && <IconElm className="w-6 h-6 mx-auto text-blue-600 dark:text-blue-400" />}
|
{IconElm && (
|
||||||
<h4 className="text-base font-bold leading-none text-black dark:text-white">{headline}</h4>
|
<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>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{BtnElm}
|
{BtnElm}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
export default function ExtLink({
|
export default function ExtLink({
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
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;
|
||||||
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
label: string | React.ReactNode;
|
label: string | React.ReactNode;
|
||||||
id?: string;
|
id?: string;
|
||||||
as?: "label" | "span";
|
as?: "label" | "span";
|
||||||
description?: string | React.ReactNode | null;
|
description?: string | React.ReactNode | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
};
|
}
|
||||||
export default function FieldLabel({
|
export default function FieldLabel({
|
||||||
label,
|
label,
|
||||||
id,
|
id,
|
||||||
|
@ -26,7 +27,7 @@ export default function FieldLabel({
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600">
|
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -34,12 +35,12 @@ export default function FieldLabel({
|
||||||
);
|
);
|
||||||
} else if (as === "span") {
|
} else if (as === "span") {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col select-none">
|
<div className="flex select-none flex-col">
|
||||||
<span className="font-display text-[13px] font-medium leading-snug text-black">
|
<span className="font-display text-[13px] font-medium leading-snug text-black dark:text-white">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{description && (
|
{description && (
|
||||||
<span className="my-0.5 text-[13px] font-normal text-slate-600">
|
<span className="my-0.5 text-[13px] font-normal text-slate-600 dark:text-slate-400">
|
||||||
{description}
|
{description}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
@ -48,4 +49,4 @@ export default function FieldLabel({
|
||||||
} else {
|
} else {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -9,7 +9,7 @@ export default function Fieldset({
|
||||||
disabled,
|
disabled,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
fetcher?: FetcherWithComponents<any>;
|
fetcher?: FetcherWithComponents<unknown>;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
import { Fragment, useCallback } from "react";
|
import { Fragment, useCallback } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
import { Menu, MenuButton, Transition } from "@headlessui/react";
|
import { Menu, MenuButton } from "@headlessui/react";
|
||||||
|
import { LuMonitorSmartphone } from "react-icons/lu";
|
||||||
|
|
||||||
import Container from "@/components/Container";
|
import Container from "@/components/Container";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { LuMonitorSmartphone } from "react-icons/lu";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
import LogoBlueIcon from "@/assets/logo-blue.svg";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
import USBStateStatus from "@components/USBStateStatus";
|
import USBStateStatus from "@components/USBStateStatus";
|
||||||
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
|
||||||
|
import { CLOUD_API, DEVICE_API } from "@/ui.config";
|
||||||
|
|
||||||
import api from "../api";
|
import api from "../api";
|
||||||
import { isOnDevice } from "../main";
|
import { isOnDevice } from "../main";
|
||||||
|
|
||||||
import { Button, LinkButton } from "./Button";
|
import { Button, LinkButton } from "./Button";
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
|
@ -32,28 +36,26 @@ export default function DashboardNavbar({
|
||||||
picture,
|
picture,
|
||||||
kvmName,
|
kvmName,
|
||||||
}: NavbarProps) {
|
}: NavbarProps) {
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnection?.connectionState);
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
const setUser = useUserStore(state => state.setUser);
|
const setUser = useUserStore(state => state.setUser);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const onLogout = useCallback(async () => {
|
const onLogout = useCallback(async () => {
|
||||||
const logoutUrl = isOnDevice
|
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
|
||||||
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
|
|
||||||
: `${import.meta.env.VITE_CLOUD_API}/logout`;
|
|
||||||
const res = await api.POST(logoutUrl);
|
const res = await api.POST(logoutUrl);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
// The root route will redirect to appropiate login page, be it the local one or the cloud one
|
// The root route will redirect to appropriate login page, be it the local one or the cloud one
|
||||||
navigate("/");
|
navigate("/");
|
||||||
}, [navigate, setUser]);
|
}, [navigate, setUser]);
|
||||||
|
|
||||||
const usbState = useHidStore(state => state.usbState);
|
const usbState = useHidStore(state => state.usbState);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full bg-white border-b select-none border-b-slate-800/20 dark:border-b-slate-300/20 dark:bg-slate-900">
|
<div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
|
||||||
<Container>
|
<Container>
|
||||||
<div className="flex items-center justify-between h-14">
|
<div className="flex h-14 items-center justify-between">
|
||||||
<div className="flex items-center shrink-0 gap-x-8">
|
<div className="flex shrink-0 items-center gap-x-8">
|
||||||
<div className="inline-block shrink-0">
|
<div className="inline-block shrink-0">
|
||||||
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
|
||||||
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
|
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
|
||||||
|
@ -74,10 +76,10 @@ export default function DashboardNavbar({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-end w-full gap-x-2">
|
<div className="flex w-full items-center justify-end gap-x-2">
|
||||||
<div className="flex items-center space-x-4 shrink-0">
|
<div className="flex shrink-0 items-center space-x-4">
|
||||||
{showConnectionStatus && (
|
{showConnectionStatus && (
|
||||||
<div className="items-center hidden gap-x-2 md:flex">
|
<div className="hidden items-center gap-x-2 md:flex">
|
||||||
<div className="w-[159px]">
|
<div className="w-[159px]">
|
||||||
<PeerConnectionStatusCard
|
<PeerConnectionStatusCard
|
||||||
state={peerConnectionState}
|
state={peerConnectionState}
|
||||||
|
@ -87,7 +89,7 @@ export default function DashboardNavbar({
|
||||||
<div className="hidden w-[159px] md:block">
|
<div className="hidden w-[159px] md:block">
|
||||||
<USBStateStatus
|
<USBStateStatus
|
||||||
state={usbState}
|
state={usbState}
|
||||||
peerConnectionState={peerConnectionState}
|
peerConnectionState={peerConnectionState}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -104,66 +106,55 @@ export default function DashboardNavbar({
|
||||||
text={
|
text={
|
||||||
<>
|
<>
|
||||||
{picture ? <></> : userEmail}
|
{picture ? <></> : userEmail}
|
||||||
<ChevronDownIcon className="w-4 h-4 shrink-0 text-slate-900 dark:text-white" />
|
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
LeadingIcon={({ className }) => (
|
LeadingIcon={({ className }) =>
|
||||||
picture && (
|
picture && (
|
||||||
<img
|
<img
|
||||||
src={picture}
|
src={picture}
|
||||||
alt="Avatar"
|
alt="Avatar"
|
||||||
className={cx(
|
className={cx(
|
||||||
className,
|
className,
|
||||||
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
|
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
</div>
|
</div>
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none">
|
||||||
enter="transition ease-in-out duration-75"
|
<Card className="overflow-hidden">
|
||||||
enterFrom="transform opacity-0"
|
<div className="space-y-1 p-1 dark:text-white">
|
||||||
enterTo="transform opacity-100"
|
{userEmail && (
|
||||||
leave="transition ease-in-out duration-75"
|
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
|
||||||
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>
|
<Menu.Item>
|
||||||
<div onClick={onLogout}>
|
<div className="p-2">
|
||||||
<button className="block w-full">
|
<div className="font-display text-xs">Logged in as</div>
|
||||||
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-600 dark:hover:bg-slate-700">
|
<div className="w-[200px] truncate font-display text-sm font-semibold">
|
||||||
<ArrowLeftEndOnRectangleIcon className="w-4 h-4" />
|
{userEmail}
|
||||||
<div className="font-display">Log out</div>
|
</div>
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<Menu.Item>
|
||||||
|
<div onClick={onLogout}>
|
||||||
|
<button className="block w-full">
|
||||||
|
<div className="flex items-center gap-x-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700">
|
||||||
|
<ArrowLeftEndOnRectangleIcon className="h-4 w-4" />
|
||||||
|
<div className="font-display">Log out</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Menu.Item>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</Menu.Items>
|
</Card>
|
||||||
</Transition>
|
</Menu.Items>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import {
|
import {
|
||||||
useHidStore,
|
useHidStore,
|
||||||
|
@ -7,7 +9,6 @@ import {
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
useKeyboardMappingsStore,
|
useKeyboardMappingsStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
export default function InfoBar() {
|
export default function InfoBar() {
|
||||||
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
|
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
|
||||||
|
@ -25,6 +26,7 @@ export default function InfoBar() {
|
||||||
const activeModifiers = useHidStore(state => state.activeModifiers);
|
const activeModifiers = useHidStore(state => state.activeModifiers);
|
||||||
const mouseX = useMouseStore(state => state.mouseX);
|
const mouseX = useMouseStore(state => state.mouseX);
|
||||||
const mouseY = useMouseStore(state => state.mouseY);
|
const mouseY = useMouseStore(state => state.mouseY);
|
||||||
|
const mouseMove = useMouseStore(state => state.mouseMove);
|
||||||
|
|
||||||
const videoClientSize = useVideoStore(
|
const videoClientSize = useVideoStore(
|
||||||
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
|
||||||
|
@ -73,7 +75,7 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{settings.debugMode ? (
|
{(settings.debugMode && settings.mouseMode == "absolute") ? (
|
||||||
<div className="flex w-[118px] items-center gap-x-1">
|
<div className="flex w-[118px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">Pointer:</span>
|
<span className="text-xs font-semibold">Pointer:</span>
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
|
@ -82,6 +84,17 @@ export default function InfoBar() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{(settings.debugMode && settings.mouseMode == "relative") ? (
|
||||||
|
<div className="flex w-[118px] items-center gap-x-1">
|
||||||
|
<span className="text-xs font-semibold">Last Move:</span>
|
||||||
|
<span className="text-xs">
|
||||||
|
{mouseMove ?
|
||||||
|
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
|
||||||
|
"N/A"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{settings.debugMode && (
|
{settings.debugMode && (
|
||||||
<div className="flex w-[156px] items-center gap-x-1">
|
<div className="flex w-[156px] items-center gap-x-1">
|
||||||
<span className="text-xs font-semibold">USB State:</span>
|
<span className="text-xs font-semibold">USB State:</span>
|
||||||
|
|
|
@ -1,7 +1,8 @@
|
||||||
import type { Ref } from "react";
|
import type { Ref } from "react";
|
||||||
import React, { forwardRef } from "react";
|
import React, { forwardRef } from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
|
@ -84,7 +85,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
|
||||||
{(label || description) && (
|
{(label || description) && (
|
||||||
<FieldLabel label={label} id={id} description={description} />
|
<FieldLabel label={label} id={id} description={description} />
|
||||||
)}
|
)}
|
||||||
<InputField ref={ref as any} id={id} {...props} />
|
<InputField ref={ref as never} id={id} {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { Button, LinkButton } from "@components/Button";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { MdConnectWithoutContact } from "react-icons/md";
|
import { MdConnectWithoutContact } from "react-icons/md";
|
||||||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { LuEllipsisVertical } from "react-icons/lu";
|
import { LuEllipsisVertical } from "react-icons/lu";
|
||||||
|
|
||||||
|
import Card from "@components/Card";
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
|
|
||||||
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
|
function getRelativeTimeString(date: Date | number, lang = navigator.language): string {
|
||||||
// Allow dates or times to be passed
|
// Allow dates or times to be passed
|
||||||
const timeMs = typeof date === "number" ? date : date.getTime();
|
const timeMs = typeof date === "number" ? date : date.getTime();
|
||||||
|
@ -12,7 +13,7 @@ function getRelativeTimeString(date: Date | number, lang = navigator.language):
|
||||||
// Get the amount of seconds between the given date and now
|
// Get the amount of seconds between the given date and now
|
||||||
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
|
const deltaSeconds = Math.round((timeMs - Date.now()) / 1000);
|
||||||
|
|
||||||
// Array reprsenting one minute, hour, day, week, month, etc in seconds
|
// Array representing one minute, hour, day, week, month, etc in seconds
|
||||||
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
|
const cutoffs = [60, 3600, 86400, 86400 * 7, 86400 * 30, 86400 * 365, Infinity];
|
||||||
|
|
||||||
// Array equivalent to the above but in the string representation of the units
|
// Array equivalent to the above but in the string representation of the units
|
||||||
|
@ -52,7 +53,7 @@ export default function KvmCard({
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="px-5 py-5 space-y-3">
|
<div className="px-5 py-5 space-y-3">
|
||||||
<div className="flex justify-between items-cente">
|
<div className="flex justify-between items-center">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="text-lg font-bold leading-none text-black dark:text-white">
|
<div className="text-lg font-bold leading-none text-black dark:text-white">
|
||||||
{title}
|
{title}
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,271 @@
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,235 @@
|
||||||
|
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 { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
|
||||||
|
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(keys)
|
||||||
|
.filter(key => !modifierKeyPrefixes.some(prefix => key.startsWith(prefix)))
|
||||||
|
.map(key => ({
|
||||||
|
value: key,
|
||||||
|
label: keyDisplayMap[key] || key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const modifierOptions = Object.keys(modifiers).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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,8 +1,9 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
import { Dialog, DialogBackdrop, DialogPanel } from "@headlessui/react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
export default function Modal({
|
const Modal = React.memo(function Modal({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
open,
|
open,
|
||||||
|
@ -14,25 +15,28 @@ export default function Modal({
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={onClose} className="relative z-10">
|
<Dialog open={open} onClose={onClose} className="relative z-20">
|
||||||
<DialogBackdrop
|
<DialogBackdrop
|
||||||
transition
|
transition
|
||||||
className="fixed inset-0 bg-gray-500/75 dark:bg-slate-900/90 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in"
|
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90"
|
||||||
/>
|
/>
|
||||||
|
<div className="fixed inset-0 z-20 w-screen overflow-y-auto">
|
||||||
<div className="fixed inset-0 z-10 w-screen overflow-y-auto">
|
{/* TODO: This doesn't work well with other-sessions */}
|
||||||
<div className="flex items-end justify-center min-h-full p-4 text-center sm:items-center sm:p-0">
|
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4">
|
||||||
<DialogPanel
|
<DialogPanel
|
||||||
transition
|
transition
|
||||||
className={cx(
|
className={cx(
|
||||||
"pointer-events-none relative w-full sm:my-8",
|
"pointer-events-none relative w-full md:my-8 md:!mt-[10vh]",
|
||||||
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-300 data-[leave]:duration-300 data-[enter]:ease-out data-[leave]:ease-in",
|
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="inline-block w-full text-left pointer-events-auto">
|
<div className="pointer-events-auto inline-block w-full text-left">
|
||||||
<div className="flex justify-center" onClick={onClose}>
|
<div className="flex justify-center" onClick={onClose}>
|
||||||
<div className="w-full pointer-events-none" onClick={e => e.stopPropagation()}>
|
<div
|
||||||
|
className="pointer-events-none w-full"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -42,4 +46,6 @@ export default function Modal({
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default Modal;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
import EmptyCard from "@/components/EmptyCard";
|
import EmptyCard from "@/components/EmptyCard";
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage() {
|
||||||
|
|
|
@ -9,21 +9,22 @@ const PeerConnectionStatusMap = {
|
||||||
failed: "Connection failed",
|
failed: "Connection failed",
|
||||||
closed: "Closed",
|
closed: "Closed",
|
||||||
new: "Connecting",
|
new: "Connecting",
|
||||||
};
|
} as Record<RTCPeerConnectionState | "error" | "closing", string>;
|
||||||
|
|
||||||
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
export type PeerConnections = keyof typeof PeerConnectionStatusMap;
|
||||||
|
|
||||||
type StatusProps = {
|
type StatusProps = Record<
|
||||||
[key in PeerConnections]: {
|
PeerConnections,
|
||||||
|
{
|
||||||
statusIndicatorClassName: string;
|
statusIndicatorClassName: string;
|
||||||
};
|
}
|
||||||
};
|
>;
|
||||||
|
|
||||||
export default function PeerConnectionStatusCard({
|
export default function PeerConnectionStatusCard({
|
||||||
state,
|
state,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
state?: PeerConnections;
|
state?: RTCPeerConnectionState | null;
|
||||||
title?: string;
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
if (!state) return null;
|
if (!state) return null;
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import Card from "./Card";
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { cva } from "@/cva.config";
|
import { cva } from "@/cva.config";
|
||||||
|
|
||||||
|
import Card from "./Card";
|
||||||
|
|
||||||
|
|
||||||
type SelectMenuProps = Pick<
|
type SelectMenuProps = Pick<
|
||||||
JSX.IntrinsicElements["select"],
|
JSX.IntrinsicElements["select"],
|
||||||
"disabled" | "onChange" | "name" | "value"
|
"disabled" | "onChange" | "name" | "value"
|
||||||
|
@ -19,7 +22,7 @@ type SelectMenuProps = Pick<
|
||||||
direction?: "vertical" | "horizontal";
|
direction?: "vertical" | "horizontal";
|
||||||
error?: string;
|
error?: string;
|
||||||
fullWidth?: boolean;
|
fullWidth?: boolean;
|
||||||
} & React.ComponentProps<typeof FieldLabel>;
|
} & Partial<React.ComponentProps<typeof FieldLabel>>;
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
XS: "h-[24.5px] pl-3 pr-8 text-xs",
|
||||||
|
@ -61,7 +64,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{label && <FieldLabel label={label} id={id} as="span" />}
|
{label && <FieldLabel label={label} id={id} as="span" />}
|
||||||
<Card className="w-auto !border border-solid !border-slate-800/30 dark:!border-slate-300/30 shadow outline-0">
|
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30">
|
||||||
<select
|
<select
|
||||||
ref={ref}
|
ref={ref}
|
||||||
name={name}
|
name={name}
|
||||||
|
@ -70,10 +73,13 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||||
classes,
|
classes,
|
||||||
|
|
||||||
// General styling
|
// General styling
|
||||||
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0",
|
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300",
|
||||||
|
|
||||||
// Hover
|
// Hover
|
||||||
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white dark:hover:bg-slate-800/80 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-900",
|
"hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",
|
||||||
|
|
||||||
|
// Dark mode
|
||||||
|
"dark:bg-slate-800 dark:text-white dark:hover:bg-slate-700 dark:active:bg-slate-800/60 dark:disabled:hover:bg-slate-800",
|
||||||
|
|
||||||
// Invalid
|
// Invalid
|
||||||
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
|
"invalid:ring-2 invalid:ring-red-600 invalid:ring-offset-2",
|
||||||
|
@ -83,9 +89,6 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
|
||||||
|
|
||||||
// Disabled
|
// Disabled
|
||||||
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
|
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
|
||||||
|
|
||||||
// Dark mode text
|
|
||||||
"dark:bg-slate-900 dark:text-white"
|
|
||||||
)}
|
)}
|
||||||
value={value}
|
value={value}
|
||||||
id={id}
|
id={id}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
export function SectionHeader({
|
export function SettingsPageHeader({
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: {
|
}: {
|
||||||
|
@ -8,8 +8,8 @@ export function SectionHeader({
|
||||||
description: string | ReactNode;
|
description: string | ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="select-none">
|
||||||
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
|
<h2 className=" text-xl font-extrabold text-black dark:text-white">{title}</h2>
|
||||||
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
<div className="text-sm text-black dark:text-slate-300">{description}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function SettingsSectionHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
}: {
|
||||||
|
title: string | ReactNode;
|
||||||
|
description: string | ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="select-none">
|
||||||
|
<h2 className="text-lg font-bold text-black dark:text-white">{title}</h2>
|
||||||
|
<div className="text-sm text-slate-700 dark:text-slate-300">{description}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,11 @@
|
||||||
import Container from "@/components/Container";
|
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
|
import Container from "@/components/Container";
|
||||||
import LogoBlueIcon from "@/assets/logo-blue.png";
|
import LogoBlueIcon from "@/assets/logo-blue.png";
|
||||||
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
import LogoWhiteIcon from "@/assets/logo-white.svg";
|
||||||
|
|
||||||
type Props = { logoHref?: string; actionElement?: React.ReactNode };
|
interface Props { logoHref?: string; actionElement?: React.ReactNode }
|
||||||
|
|
||||||
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
export default function SimpleNavbar({ logoHref, actionElement }: Props) {
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -9,6 +9,7 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
|
||||||
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
import CustomTooltip, { CustomTooltipProps } from "@components/CustomTooltip";
|
||||||
|
|
||||||
export default function StatChart({
|
export default function StatChart({
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
import { CheckIcon } from "@heroicons/react/16/solid";
|
import { CheckIcon } from "@heroicons/react/16/solid";
|
||||||
|
|
||||||
import { cva, cx } from "@/cva.config";
|
import { cva, cx } from "@/cva.config";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
nSteps: number;
|
nSteps: number;
|
||||||
currStepIdx: number;
|
currStepIdx: number;
|
||||||
size?: keyof typeof sizes;
|
size?: keyof typeof sizes;
|
||||||
};
|
}
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
SM: "text-xs leading-[12px]",
|
SM: "text-xs leading-[12px]",
|
||||||
|
|
|
@ -1,32 +1,192 @@
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
import { useUiStore, useRTCStore } from "@/hooks/stores";
|
|
||||||
import { XTerm } from "./Xterm";
|
|
||||||
import { Button } from "./Button";
|
|
||||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
import { cx } from "../cva.config";
|
import { useEffect } from "react";
|
||||||
import { Transition } from "@headlessui/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";
|
||||||
|
|
||||||
function TerminalWrapper() {
|
import { cx } from "@/cva.config";
|
||||||
const enableTerminal = useUiStore(state => state.enableTerminal);
|
import { AvailableTerminalTypes, useUiStore } from "@/hooks/stores";
|
||||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
|
||||||
const terminalChannel = useRTCStore(state => state.terminalChannel);
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div onKeyDown={e => e.stopPropagation()} onKeyUp={e => e.stopPropagation()}>
|
<div
|
||||||
<Transition show={enableTerminal} appear>
|
onKeyDown={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onKeyUp={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
<div
|
<div
|
||||||
className={cx([
|
className={cx(
|
||||||
// Base styles
|
[
|
||||||
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
|
// Base styles
|
||||||
"translate-y-[0px]",
|
"fixed bottom-0 w-full transform transition duration-500 ease-in-out",
|
||||||
"data-[enter]:translate-y-[500px]",
|
"translate-y-[0px]",
|
||||||
"data-[closed]:translate-y-[500px]",
|
],
|
||||||
])}
|
{
|
||||||
|
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
|
||||||
|
!enableTerminal,
|
||||||
|
"pointer-events-auto translate-y-[0px] opacity-100 transition duration-300":
|
||||||
|
enableTerminal,
|
||||||
|
},
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="h-[500px] w-full bg-[#0f172a]">
|
<div className="h-[500px] w-full bg-[#0f172a]">
|
||||||
<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">
|
<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">
|
||||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||||
Web Terminal
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="absolute right-2">
|
<div className="absolute right-2">
|
||||||
<Button
|
<Button
|
||||||
|
@ -34,18 +194,19 @@ function TerminalWrapper() {
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Hide"
|
text="Hide"
|
||||||
LeadingIcon={ChevronDownIcon}
|
LeadingIcon={ChevronDownIcon}
|
||||||
onClick={() => setEnableTerminal(false)}
|
onClick={() => setTerminalType("none")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-[calc(100%-36px)] p-3">
|
<div className="h-[calc(100%-36px)] p-3">
|
||||||
<XTerm terminalChannel={terminalChannel} />
|
<div ref={ref} style={{ height: "100%", width: "100%" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TerminalWrapper;
|
export default Terminal;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import FieldLabel from "@/components/FieldLabel";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
|
import FieldLabel from "@/components/FieldLabel";
|
||||||
import { FieldError } from "@/components/InputField";
|
import { FieldError } from "@/components/InputField";
|
||||||
import Card from "@/components/Card";
|
import Card from "@/components/Card";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
|
@ -1,23 +1,23 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
import KeyboardAndMouseConnectedIcon from "@/assets/keyboard-and-mouse-connected.png";
|
||||||
import React from "react";
|
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import StatusCard from "@components/StatusCards";
|
import StatusCard from "@components/StatusCards";
|
||||||
import { HidState } from "@/hooks/stores";
|
import { HidState } from "@/hooks/stores";
|
||||||
|
|
||||||
type USBStates = HidState["usbState"];
|
type USBStates = HidState["usbState"];
|
||||||
|
|
||||||
type StatusProps = {
|
type StatusProps = Record<
|
||||||
[key in USBStates]: {
|
USBStates,
|
||||||
|
{
|
||||||
icon: React.FC<{ className: string | undefined }>;
|
icon: React.FC<{ className: string | undefined }>;
|
||||||
iconClassName: string;
|
iconClassName: string;
|
||||||
statusIndicatorClassName: string;
|
statusIndicatorClassName: string;
|
||||||
};
|
}
|
||||||
};
|
>;
|
||||||
|
|
||||||
const USBStateMap: {
|
const USBStateMap: Record<USBStates, string> = {
|
||||||
[key in USBStates]: string;
|
|
||||||
} = {
|
|
||||||
configured: "Connected",
|
configured: "Connected",
|
||||||
attached: "Connecting",
|
attached: "Connecting",
|
||||||
addressed: "Connecting",
|
addressed: "Connecting",
|
||||||
|
@ -30,9 +30,8 @@ export default function USBStateStatus({
|
||||||
peerConnectionState,
|
peerConnectionState,
|
||||||
}: {
|
}: {
|
||||||
state: USBStates;
|
state: USBStates;
|
||||||
peerConnectionState?: RTCPeerConnectionState;
|
peerConnectionState?: RTCPeerConnectionState | null;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
const StatusCardProps: StatusProps = {
|
const StatusCardProps: StatusProps = {
|
||||||
configured: {
|
configured: {
|
||||||
icon: ({ className }) => (
|
icon: ({ className }) => (
|
||||||
|
@ -68,7 +67,7 @@ export default function USBStateStatus({
|
||||||
};
|
};
|
||||||
const props = StatusCardProps[state];
|
const props = StatusCardProps[state];
|
||||||
if (!props) {
|
if (!props) {
|
||||||
console.log("Unsupport USB state: ", state);
|
console.log("Unsupported USB state: ", state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,22 @@
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
|
||||||
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { GridCard } from "./Card";
|
import { GridCard } from "./Card";
|
||||||
import LoadingSpinner from "./LoadingSpinner";
|
import LoadingSpinner from "./LoadingSpinner";
|
||||||
import { UpdateState } from "@/hooks/stores";
|
|
||||||
|
|
||||||
interface UpdateInProgressStatusCardProps {
|
export default function UpdateInProgressStatusCard() {
|
||||||
setIsUpdateDialogOpen: (isOpen: boolean) => void;
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
setModalView: (view: UpdateState["modalView"]) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function UpdateInProgressStatusCard({
|
|
||||||
setIsUpdateDialogOpen,
|
|
||||||
setModalView,
|
|
||||||
}: UpdateInProgressStatusCardProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full transition-all duration-300 ease-in-out opacity-100 select-none">
|
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out">
|
||||||
<GridCard cardClassName="!shadow-xl">
|
<GridCard cardClassName="!shadow-xl">
|
||||||
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
|
<div className="flex items-center justify-between gap-x-3 px-2.5 py-2.5 text-black dark:text-white">
|
||||||
<div className="flex items-center gap-x-3">
|
<div className="flex items-center gap-x-3">
|
||||||
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
|
<LoadingSpinner className={cx("h-5 w-5", "shrink-0 text-blue-700")} />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-sm font-semibold leading-none transition text-ellipsis">
|
<div className="text-ellipsis text-sm font-semibold leading-none transition">
|
||||||
Update in Progress
|
Update in Progress
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm leading-none">
|
<div className="text-sm leading-none">
|
||||||
|
@ -37,10 +33,7 @@ export default function UpdateInProgressStatusCard({
|
||||||
className="pointer-events-auto"
|
className="pointer-events-auto"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="View Details"
|
text="View Details"
|
||||||
onClick={() => {
|
onClick={() => navigateTo("/settings/general/update")}
|
||||||
setModalView("updating");
|
|
||||||
setIsUpdateDialogOpen(true);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</GridCard>
|
||||||
|
|
|
@ -0,0 +1,240 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,303 @@
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,12 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import { ArrowRightIcon } from "@heroicons/react/16/solid";
|
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
|
||||||
import { LinkButton } from "@components/Button";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { LuPlay } from "react-icons/lu";
|
||||||
|
|
||||||
|
import { Button, LinkButton } from "@components/Button";
|
||||||
import LoadingSpinner from "@components/LoadingSpinner";
|
import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import { GridCard } from "@components/Card";
|
import Card, { GridCard } from "@components/Card";
|
||||||
|
|
||||||
interface OverlayContentProps {
|
interface OverlayContentProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -12,7 +14,7 @@ interface OverlayContentProps {
|
||||||
function OverlayContent({ children }: OverlayContentProps) {
|
function OverlayContent({ children }: OverlayContentProps) {
|
||||||
return (
|
return (
|
||||||
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
|
<GridCard cardClassName="h-full pointer-events-auto !outline-none">
|
||||||
<div className="flex flex-col items-center justify-center w-full h-full border rounded-md border-slate-800/30 dark:border-slate-300/20">
|
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</GridCard>
|
||||||
|
@ -23,78 +25,183 @@ interface LoadingOverlayProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LoadingOverlay({ show }: LoadingOverlayProps) {
|
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<AnimatePresence>
|
||||||
show={show}
|
{show && (
|
||||||
enter="transition-opacity duration-300"
|
<motion.div
|
||||||
enterFrom="opacity-0"
|
className="aspect-video h-full w-full"
|
||||||
enterTo="opacity-100"
|
initial={{ opacity: 0 }}
|
||||||
leave="transition-opacity duration-100"
|
animate={{ opacity: 1 }}
|
||||||
leaveFrom="opacity-100"
|
exit={{ opacity: 0 }}
|
||||||
leaveTo="opacity-0"
|
transition={{
|
||||||
>
|
duration: show ? 0.3 : 0.1,
|
||||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
ease: "easeInOut",
|
||||||
<OverlayContent>
|
}}
|
||||||
<div className="flex flex-col items-center justify-center gap-y-1">
|
>
|
||||||
<div className="flex items-center justify-center w-12 h-12 animate">
|
<OverlayContent>
|
||||||
<LoadingSpinner className="w-8 h-8 text-blue-800 dark:text-blue-200" />
|
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||||
|
<div className="animate flex h-12 w-12 items-center justify-center">
|
||||||
|
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
Loading video stream...
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-center text-slate-700 dark:text-slate-300">
|
</OverlayContent>
|
||||||
Loading video stream...
|
</motion.div>
|
||||||
</p>
|
)}
|
||||||
</div>
|
</AnimatePresence>
|
||||||
</OverlayContent>
|
);
|
||||||
</div>
|
}
|
||||||
</Transition>
|
|
||||||
|
interface LoadingConnectionOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="flex flex-col items-center justify-center gap-y-1">
|
||||||
|
<div className="animate flex h-12 w-12 items-center justify-center">
|
||||||
|
<LoadingSpinner className="h-8 w-8 text-blue-800 dark:text-blue-200" />
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConnectionErrorOverlayProps {
|
interface ConnectionErrorOverlayProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
|
setupPeerConnection: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConnectionErrorOverlay({ show }: ConnectionErrorOverlayProps) {
|
export function ConnectionFailedOverlay({
|
||||||
|
show,
|
||||||
|
setupPeerConnection,
|
||||||
|
}: ConnectionErrorOverlayProps) {
|
||||||
return (
|
return (
|
||||||
<Transition
|
<AnimatePresence>
|
||||||
show={show}
|
{show && (
|
||||||
enter="transition duration-300"
|
<motion.div
|
||||||
enterFrom="opacity-0"
|
className="aspect-video h-full w-full"
|
||||||
enterTo="opacity-100"
|
initial={{ opacity: 0 }}
|
||||||
leave="transition duration-300"
|
animate={{ opacity: 1 }}
|
||||||
leaveFrom="opacity-100"
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
leaveTo="opacity-0"
|
transition={{
|
||||||
>
|
duration: 0.4,
|
||||||
<div className="absolute inset-0 z-10 w-full h-full aspect-video">
|
ease: "easeInOut",
|
||||||
<OverlayContent>
|
}}
|
||||||
<div className="flex flex-col items-start gap-y-1">
|
>
|
||||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
<OverlayContent>
|
||||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
<div className="space-y-4">
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
<div className="space-y-2 text-black dark:text-white">
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
<div className="space-y-4">
|
||||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
<li>Verify that the device is powered on and properly connected</li>
|
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||||
<li>Check all cable connections for any loose or damaged wires</li>
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
<li>Ensure your network connection is stable and active</li>
|
<li>Verify that the device is powered on and properly connected</li>
|
||||||
<li>Try restarting both the device and your computer</li>
|
<li>Check all cable connections for any loose or damaged wires</li>
|
||||||
</ul>
|
<li>Ensure your network connection is stable and active</li>
|
||||||
</div>
|
<li>Try restarting both the device and your computer</li>
|
||||||
<div>
|
</ul>
|
||||||
<LinkButton
|
</div>
|
||||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
<div className="flex items-center gap-x-2">
|
||||||
theme="light"
|
<LinkButton
|
||||||
text="Troubleshooting Guide"
|
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||||
TrailingIcon={ArrowRightIcon}
|
theme="primary"
|
||||||
size="SM"
|
text="Troubleshooting Guide"
|
||||||
/>
|
TrailingIcon={ArrowRightIcon}
|
||||||
|
size="SM"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => setupPeerConnection()}
|
||||||
|
LeadingIcon={ArrowPathIcon}
|
||||||
|
text="Try again"
|
||||||
|
size="SM"
|
||||||
|
theme="light"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</OverlayContent>
|
||||||
</OverlayContent>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
</Transition>
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PeerConnectionDisconnectedOverlay {
|
||||||
|
show: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PeerConnectionDisconnectedOverlay({
|
||||||
|
show,
|
||||||
|
}: PeerConnectionDisconnectedOverlay) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
|
<h2 className="text-xl font-bold">Connection Issue Detected</h2>
|
||||||
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
|
<li>Verify that the device is powered on and properly connected</li>
|
||||||
|
<li>Check all cable connections for any loose or damaged wires</li>
|
||||||
|
<li>Ensure your network connection is stable and active</li>
|
||||||
|
<li>Try restarting both the device and your computer</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center gap-x-2 p-4">
|
||||||
|
<LoadingSpinner className="h-4 w-4 text-blue-800 dark:text-blue-200" />
|
||||||
|
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||||
|
Retrying connection...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,85 +216,145 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Transition
|
<AnimatePresence>
|
||||||
show={show && isNoSignal}
|
{show && isNoSignal && (
|
||||||
enter="transition duration-300"
|
<motion.div
|
||||||
enterFrom="opacity-0"
|
className="absolute inset-0 aspect-video h-full w-full"
|
||||||
enterTo="opacity-100"
|
initial={{ opacity: 0 }}
|
||||||
leave="transition-all duration-300"
|
animate={{ opacity: 1 }}
|
||||||
leaveFrom="opacity-100"
|
exit={{ opacity: 0 }}
|
||||||
leaveTo="opacity-0"
|
transition={{
|
||||||
>
|
duration: 0.3,
|
||||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
ease: "easeInOut",
|
||||||
<OverlayContent>
|
}}
|
||||||
<div className="flex flex-col items-start gap-y-1">
|
>
|
||||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
<OverlayContent>
|
||||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
<div className="space-y-4">
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
<div className="space-y-2 text-black dark:text-white">
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
<div className="space-y-4">
|
||||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
<li>Ensure the HDMI cable securely connected at both ends</li>
|
<h2 className="text-xl font-bold">No HDMI signal detected.</h2>
|
||||||
<li>Ensure source device is powered on and outputting a signal</li>
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
<li>
|
<li>Ensure the HDMI cable securely connected at both ends</li>
|
||||||
If using an adapter, it's compatible and functioning
|
<li>
|
||||||
correctly
|
Ensure source device is powered on and outputting a signal
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
<li>
|
||||||
</div>
|
If using an adapter, it's compatible and functioning
|
||||||
<div>
|
correctly
|
||||||
<LinkButton
|
</li>
|
||||||
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
</ul>
|
||||||
theme="light"
|
</div>
|
||||||
text="Learn more"
|
<div>
|
||||||
TrailingIcon={ArrowRightIcon}
|
<LinkButton
|
||||||
size="SM"
|
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||||
/>
|
theme="light"
|
||||||
|
text="Learn more"
|
||||||
|
TrailingIcon={ArrowRightIcon}
|
||||||
|
size="SM"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</OverlayContent>
|
||||||
</OverlayContent>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
</Transition>
|
</AnimatePresence>
|
||||||
<Transition
|
|
||||||
show={show && isOtherError}
|
<AnimatePresence>
|
||||||
enter="transition duration-300"
|
{show && isOtherError && (
|
||||||
enterFrom="opacity-0"
|
<motion.div
|
||||||
enterTo="opacity-100"
|
className="absolute inset-0 aspect-video h-full w-full"
|
||||||
leave="transition duration-300 "
|
initial={{ opacity: 0 }}
|
||||||
leaveFrom="opacity-100"
|
animate={{ opacity: 1 }}
|
||||||
leaveTo="opacity-0"
|
exit={{ opacity: 0 }}
|
||||||
>
|
transition={{
|
||||||
<div className="absolute inset-0 w-full h-full aspect-video">
|
duration: 0.3,
|
||||||
<OverlayContent>
|
ease: "easeInOut",
|
||||||
<div className="flex flex-col items-start gap-y-1">
|
}}
|
||||||
<ExclamationTriangleIcon className="w-12 h-12 text-yellow-500" />
|
>
|
||||||
<div className="text-sm text-left text-slate-700 dark:text-slate-300">
|
<OverlayContent>
|
||||||
<div className="space-y-4">
|
<div className="flex flex-col items-start gap-y-1">
|
||||||
<div className="space-y-2 text-black dark:text-white">
|
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||||
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
|
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
<ul className="pl-4 space-y-2 text-left list-disc">
|
<div className="space-y-4">
|
||||||
<li>A loose or faulty HDMI connection</li>
|
<div className="space-y-2 text-black dark:text-white">
|
||||||
<li>Incompatible resolution or refresh rate settings</li>
|
<h2 className="text-xl font-bold">HDMI signal error detected.</h2>
|
||||||
<li>Issues with the source device's HDMI output</li>
|
<ul className="list-disc space-y-2 pl-4 text-left">
|
||||||
</ul>
|
<li>A loose or faulty HDMI connection</li>
|
||||||
</div>
|
<li>Incompatible resolution or refresh rate settings</li>
|
||||||
<div>
|
<li>Issues with the source device's HDMI output</li>
|
||||||
<LinkButton
|
</ul>
|
||||||
to={"/help/hdmi-error"}
|
</div>
|
||||||
theme="light"
|
<div>
|
||||||
text="Learn more"
|
<LinkButton
|
||||||
TrailingIcon={ArrowRightIcon}
|
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
|
||||||
size="SM"
|
theme="light"
|
||||||
/>
|
text="Learn more"
|
||||||
|
TrailingIcon={ArrowRightIcon}
|
||||||
|
size="SM"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</OverlayContent>
|
||||||
</OverlayContent>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
</Transition>
|
</AnimatePresence>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NoAutoplayPermissionsOverlayProps {
|
||||||
|
show: boolean;
|
||||||
|
onPlayClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NoAutoplayPermissionsOverlay({
|
||||||
|
show,
|
||||||
|
onPlayClick,
|
||||||
|
}: NoAutoplayPermissionsOverlayProps) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{show && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute inset-0 z-10 aspect-video h-full w-full"
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.3,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<OverlayContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-2xl font-extrabold text-black dark:text-white">
|
||||||
|
Autoplay permissions required
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-center">
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="MD"
|
||||||
|
theme="primary"
|
||||||
|
LeadingIcon={LuPlay}
|
||||||
|
text="Manually start stream"
|
||||||
|
onClick={onPlayClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
Please adjust browser settings to enable autoplay
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</OverlayContent>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,12 +1,17 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import Keyboard from "react-simple-keyboard";
|
import Keyboard from "react-simple-keyboard";
|
||||||
import { Button } from "@components/Button";
|
|
||||||
import Card from "@components/Card";
|
|
||||||
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
import { ChevronDownIcon } from "@heroicons/react/16/solid";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
import Card from "@components/Card";
|
||||||
|
// eslint-disable-next-line import/order
|
||||||
|
import { Button } from "@components/Button";
|
||||||
|
|
||||||
import "react-simple-keyboard/build/css/index.css";
|
import "react-simple-keyboard/build/css/index.css";
|
||||||
|
|
||||||
import { useHidStore, useUiStore, useKeyboardMappingsStore } from "@/hooks/stores";
|
import { useHidStore, useUiStore, useKeyboardMappingsStore } from "@/hooks/stores";
|
||||||
import { Transition } from "@headlessui/react";
|
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
|
import { keyDisplayMap } from "@/keyboardMappings/KeyboardLayouts";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import DetachIconRaw from "@/assets/detach-icon.svg";
|
import DetachIconRaw from "@/assets/detach-icon.svg";
|
||||||
import AttachIconRaw from "@/assets/attach-icon.svg";
|
import AttachIconRaw from "@/assets/attach-icon.svg";
|
||||||
|
@ -20,40 +25,40 @@ const AttachIcon = ({ className }: { className?: string }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
function KeyboardWrapper() {
|
function KeyboardWrapper() {
|
||||||
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
|
const [keys, setKeys] = useState(useKeyboardMappingsStore.keys);
|
||||||
const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
|
const [chars, setChars] = useState(useKeyboardMappingsStore.chars);
|
||||||
const [modifiers, setModifiers] = useState(useKeyboardMappingsStore.modifiers);
|
const [modifiers, setModifiers] = useState(useKeyboardMappingsStore.modifiers);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribeKeyboardStore = useKeyboardMappingsStore.subscribe(() => {
|
const unsubscribeKeyboardStore = useKeyboardMappingsStore.subscribe(() => {
|
||||||
setKeys(useKeyboardMappingsStore.keys);
|
setKeys(useKeyboardMappingsStore.keys);
|
||||||
setChars(useKeyboardMappingsStore.chars);
|
setChars(useKeyboardMappingsStore.chars);
|
||||||
setModifiers(useKeyboardMappingsStore.modifiers);
|
setModifiers(useKeyboardMappingsStore.modifiers);
|
||||||
setMappingsEnabled(useKeyboardMappingsStore.getMappingState());
|
setMappingsEnabled(useKeyboardMappingsStore.getMappingState());
|
||||||
});
|
});
|
||||||
return unsubscribeKeyboardStore; // Cleanup on unmount
|
return unsubscribeKeyboardStore; // Cleanup on unmount
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [layoutName, setLayoutName] = useState("default");
|
const [layoutName, setLayoutName] = useState("default");
|
||||||
const [mappingsEnabled, setMappingsEnabled] = useState(useKeyboardMappingsStore.getMappingState());
|
const [mappingsEnabled, setMappingsEnabled] = useState(useKeyboardMappingsStore.getMappingState());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mappingsEnabled) {
|
if (mappingsEnabled) {
|
||||||
if (layoutName == "default" ) {
|
if (layoutName == "default" ) {
|
||||||
setLayoutName("mappedLower")
|
setLayoutName("mappedLower")
|
||||||
|
}
|
||||||
|
if (layoutName == "shift") {
|
||||||
|
setLayoutName("mappedUpper")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (layoutName == "mappedLower") {
|
||||||
|
setLayoutName("default")
|
||||||
|
}
|
||||||
|
if (layoutName == "mappedUpper") {
|
||||||
|
setLayoutName("shift")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (layoutName == "shift") {
|
}, [mappingsEnabled, layoutName]);
|
||||||
setLayoutName("mappedUpper")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (layoutName == "mappedLower") {
|
|
||||||
setLayoutName("default")
|
|
||||||
}
|
|
||||||
if (layoutName == "mappedUpper") {
|
|
||||||
setLayoutName("shift")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mappingsEnabled, layoutName]);
|
|
||||||
|
|
||||||
const keyboardRef = useRef<HTMLDivElement>(null);
|
const keyboardRef = useRef<HTMLDivElement>(null);
|
||||||
const showAttachedVirtualKeyboard = useUiStore(
|
const showAttachedVirtualKeyboard = useUiStore(
|
||||||
|
@ -246,406 +251,169 @@ function KeyboardWrapper() {
|
||||||
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
|
marginBottom: virtualKeyboard ? "0px" : `-${350}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Transition
|
<AnimatePresence>
|
||||||
show={virtualKeyboard}
|
{virtualKeyboard && (
|
||||||
unmount={false}
|
<motion.div
|
||||||
enter="transition-all transform-gpu duration-500 ease-in-out"
|
initial={{ opacity: 0, y: "100%" }}
|
||||||
enterFrom="opacity-0 translate-y-[100%]"
|
animate={{ opacity: 1, y: "0%" }}
|
||||||
enterTo="opacity-100 translate-y-[0%]"
|
exit={{ opacity: 0, y: "100%" }}
|
||||||
leave="transition-all duration-500 ease-in-out"
|
transition={{
|
||||||
leaveFrom="opacity-100 translate-y-[0%]"
|
duration: 0.5,
|
||||||
leaveTo="opacity-0 translate-y-[100%]"
|
ease: "easeInOut",
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className={cx(
|
|
||||||
!showAttachedVirtualKeyboard
|
|
||||||
? "fixed left-0 top-0 z-50 select-none"
|
|
||||||
: "relative",
|
|
||||||
)}
|
|
||||||
ref={keyboardRef}
|
|
||||||
style={{
|
|
||||||
...(!showAttachedVirtualKeyboard
|
|
||||||
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
|
|
||||||
: {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Card
|
<div
|
||||||
className={cx("overflow-hidden", {
|
className={cx(
|
||||||
"rounded-none": showAttachedVirtualKeyboard,
|
!showAttachedVirtualKeyboard
|
||||||
})}
|
? "fixed left-0 top-0 z-50 select-none"
|
||||||
|
: "relative",
|
||||||
|
)}
|
||||||
|
ref={keyboardRef}
|
||||||
|
style={{
|
||||||
|
...(!showAttachedVirtualKeyboard
|
||||||
|
? { transform: `translate(${newPosition.x}px, ${newPosition.y}px)` }
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center px-2 py-1 bg-white border-b dark:bg-slate-800 border-b-slate-800/30 dark:border-b-slate-300/20">
|
<Card
|
||||||
<div className="absolute flex items-center left-2 gap-x-2">
|
className={cx("overflow-hidden", {
|
||||||
{showAttachedVirtualKeyboard ? (
|
"rounded-none": showAttachedVirtualKeyboard,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center border-b border-b-slate-800/30 bg-white px-2 py-1 dark:border-b-slate-300/20 dark:bg-slate-800">
|
||||||
|
<div className="absolute left-2 flex items-center gap-x-2">
|
||||||
|
{showAttachedVirtualKeyboard ? (
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="Detach"
|
||||||
|
onClick={() => setShowAttachedVirtualKeyboard(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="XS"
|
||||||
|
theme="light"
|
||||||
|
text="Attach"
|
||||||
|
LeadingIcon={AttachIcon}
|
||||||
|
onClick={() => setShowAttachedVirtualKeyboard(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
||||||
|
Virtual Keyboard
|
||||||
|
</h2>
|
||||||
|
<div className="absolute right-2">
|
||||||
<Button
|
<Button
|
||||||
size="XS"
|
size="XS"
|
||||||
theme="light"
|
theme="light"
|
||||||
text="Detach"
|
text="Hide"
|
||||||
onClick={() => setShowAttachedVirtualKeyboard(false)}
|
LeadingIcon={ChevronDownIcon}
|
||||||
/>
|
onClick={() => setVirtualKeyboard(false)}
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="XS"
|
|
||||||
theme="light"
|
|
||||||
text="Attach"
|
|
||||||
LeadingIcon={AttachIcon}
|
|
||||||
onClick={() => setShowAttachedVirtualKeyboard(true)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h2 className="select-none self-center font-sans text-[12px] text-slate-700 dark:text-slate-300">
|
|
||||||
Virtual Keyboard
|
|
||||||
</h2>
|
|
||||||
<div className="absolute right-2">
|
|
||||||
<Button
|
|
||||||
size="XS"
|
|
||||||
theme="light"
|
|
||||||
text="Hide"
|
|
||||||
LeadingIcon={ChevronDownIcon}
|
|
||||||
onClick={() => setVirtualKeyboard(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex flex-col dark:bg-slate-700 bg-blue-50/80 md:flex-row">
|
|
||||||
<Keyboard
|
|
||||||
baseClass="simple-keyboard-main"
|
|
||||||
layoutName={layoutName}
|
|
||||||
onKeyPress={onKeyDown}
|
|
||||||
buttonTheme={[
|
|
||||||
{
|
|
||||||
class: "combination-key",
|
|
||||||
buttons: "CtrlAltDelete AltMetaEscape",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
display={{
|
|
||||||
CtrlAltDelete: "Ctrl + Alt + Delete",
|
|
||||||
AltMetaEscape: "Alt + Meta + Escape",
|
|
||||||
Escape: "esc",
|
|
||||||
Tab: "tab",
|
|
||||||
Backspace: "backspace",
|
|
||||||
"(Backspace)": "backspace",
|
|
||||||
Enter: "enter",
|
|
||||||
CapsLock: "caps lock",
|
|
||||||
ShiftLeft: "shift",
|
|
||||||
ShiftRight: "shift",
|
|
||||||
ControlLeft: "ctrl",
|
|
||||||
AltLeft: "alt",
|
|
||||||
AltRight: "alt",
|
|
||||||
MetaLeft: "meta",
|
|
||||||
MetaRight: "meta",
|
|
||||||
KeyQ: "q",
|
|
||||||
KeyW: "w",
|
|
||||||
KeyE: "e",
|
|
||||||
KeyR: "r",
|
|
||||||
KeyT: "t",
|
|
||||||
KeyY: "y",
|
|
||||||
KeyU: "u",
|
|
||||||
KeyI: "i",
|
|
||||||
KeyO: "o",
|
|
||||||
KeyP: "p",
|
|
||||||
KeyA: "a",
|
|
||||||
KeyS: "s",
|
|
||||||
KeyD: "d",
|
|
||||||
KeyF: "f",
|
|
||||||
KeyG: "g",
|
|
||||||
KeyH: "h",
|
|
||||||
KeyJ: "j",
|
|
||||||
KeyK: "k",
|
|
||||||
KeyL: "l",
|
|
||||||
KeyZ: "z",
|
|
||||||
KeyX: "x",
|
|
||||||
KeyC: "c",
|
|
||||||
KeyV: "v",
|
|
||||||
KeyB: "b",
|
|
||||||
KeyN: "n",
|
|
||||||
KeyM: "m",
|
|
||||||
|
|
||||||
"(KeyQ)": "Q",
|
|
||||||
"(KeyW)": "W",
|
|
||||||
"(KeyE)": "E",
|
|
||||||
"(KeyR)": "R",
|
|
||||||
"(KeyT)": "T",
|
|
||||||
"(KeyY)": "Y",
|
|
||||||
"(KeyU)": "U",
|
|
||||||
"(KeyI)": "I",
|
|
||||||
"(KeyO)": "O",
|
|
||||||
"(KeyP)": "P",
|
|
||||||
"(KeyA)": "A",
|
|
||||||
"(KeyS)": "S",
|
|
||||||
"(KeyD)": "D",
|
|
||||||
"(KeyF)": "F",
|
|
||||||
"(KeyG)": "G",
|
|
||||||
"(KeyH)": "H",
|
|
||||||
"(KeyJ)": "J",
|
|
||||||
"(KeyK)": "K",
|
|
||||||
"(KeyL)": "L",
|
|
||||||
"(KeyZ)": "Z",
|
|
||||||
"(KeyX)": "X",
|
|
||||||
"(KeyC)": "C",
|
|
||||||
"(KeyV)": "V",
|
|
||||||
"(KeyB)": "B",
|
|
||||||
"(KeyN)": "N",
|
|
||||||
"(KeyM)": "M",
|
|
||||||
Digit1: "1",
|
|
||||||
Digit2: "2",
|
|
||||||
Digit3: "3",
|
|
||||||
Digit4: "4",
|
|
||||||
Digit5: "5",
|
|
||||||
Digit6: "6",
|
|
||||||
Digit7: "7",
|
|
||||||
Digit8: "8",
|
|
||||||
Digit9: "9",
|
|
||||||
Digit0: "0",
|
|
||||||
|
|
||||||
"(Digit1)": "!",
|
|
||||||
"(Digit2)": "@",
|
|
||||||
"(Digit3)": "#",
|
|
||||||
"(Digit4)": "$",
|
|
||||||
"(Digit5)": "%",
|
|
||||||
"(Digit6)": "^",
|
|
||||||
"(Digit7)": "&",
|
|
||||||
"(Digit8)": "*",
|
|
||||||
"(Digit9)": "(",
|
|
||||||
"(Digit0)": ")",
|
|
||||||
Minus: "-",
|
|
||||||
"(Minus)": "_",
|
|
||||||
|
|
||||||
Equal: "=",
|
|
||||||
"(Equal)": "+",
|
|
||||||
BracketLeft: "[",
|
|
||||||
BracketRight: "]",
|
|
||||||
"(BracketLeft)": "{",
|
|
||||||
"(BracketRight)": "}",
|
|
||||||
Backslash: "\\",
|
|
||||||
"(Backslash)": "|",
|
|
||||||
|
|
||||||
Semicolon: ";",
|
|
||||||
"(Semicolon)": ":",
|
|
||||||
Quote: "'",
|
|
||||||
"(Quote)": '"',
|
|
||||||
Comma: ",",
|
|
||||||
"(Comma)": "<",
|
|
||||||
Period: ".",
|
|
||||||
"(Period)": ">",
|
|
||||||
Slash: "/",
|
|
||||||
"(Slash)": "?",
|
|
||||||
Space: " ",
|
|
||||||
Backquote: "`",
|
|
||||||
"(Backquote)": "~",
|
|
||||||
IntlBackslash: "\\",
|
|
||||||
|
|
||||||
F1: "F1",
|
|
||||||
F2: "F2",
|
|
||||||
F3: "F3",
|
|
||||||
F4: "F4",
|
|
||||||
F5: "F5",
|
|
||||||
F6: "F6",
|
|
||||||
F7: "F7",
|
|
||||||
F8: "F8",
|
|
||||||
F9: "F9",
|
|
||||||
F10: "F10",
|
|
||||||
F11: "F11",
|
|
||||||
F12: "F12",
|
|
||||||
|
|
||||||
"q": "q",
|
|
||||||
"w": "w",
|
|
||||||
"e": "e",
|
|
||||||
"r": "r",
|
|
||||||
"t": "t",
|
|
||||||
"y": "y",
|
|
||||||
"u": "u",
|
|
||||||
"i": "i",
|
|
||||||
"o": "o",
|
|
||||||
"p": "p",
|
|
||||||
"a": "a",
|
|
||||||
"s": "s",
|
|
||||||
"d": "d",
|
|
||||||
"f": "f",
|
|
||||||
"g": "g",
|
|
||||||
"h": "h",
|
|
||||||
"j": "j",
|
|
||||||
"k": "k",
|
|
||||||
"l": "l",
|
|
||||||
"z": "z",
|
|
||||||
"x": "x",
|
|
||||||
"c": "c",
|
|
||||||
"v": "v",
|
|
||||||
"b": "b",
|
|
||||||
"n": "n",
|
|
||||||
"m": "m",
|
|
||||||
|
|
||||||
"Q": "Q",
|
|
||||||
"W": "W",
|
|
||||||
"E": "E",
|
|
||||||
"R": "R",
|
|
||||||
"T": "T",
|
|
||||||
"Y": "Y",
|
|
||||||
"U": "U",
|
|
||||||
"I": "I",
|
|
||||||
"O": "O",
|
|
||||||
"P": "P",
|
|
||||||
"A": "A",
|
|
||||||
"S": "S",
|
|
||||||
"D": "D",
|
|
||||||
"F": "F",
|
|
||||||
"G": "G",
|
|
||||||
"H": "H",
|
|
||||||
"J": "J",
|
|
||||||
"K": "K",
|
|
||||||
"L": "L",
|
|
||||||
"Z": "Z",
|
|
||||||
"X": "X",
|
|
||||||
"C": "C",
|
|
||||||
"V": "V",
|
|
||||||
"B": "B",
|
|
||||||
"N": "N",
|
|
||||||
"M": "M",
|
|
||||||
|
|
||||||
"1": "1",
|
|
||||||
"2": "2",
|
|
||||||
"3": "3",
|
|
||||||
"4": "4",
|
|
||||||
"5": "5",
|
|
||||||
"6": "6",
|
|
||||||
"7": "7",
|
|
||||||
"8": "8",
|
|
||||||
"9": "9",
|
|
||||||
"0": "0",
|
|
||||||
|
|
||||||
"!": "!",
|
|
||||||
"@": "@",
|
|
||||||
"#": "#",
|
|
||||||
"$": "$",
|
|
||||||
"%": "%",
|
|
||||||
"^": "^",
|
|
||||||
"&": "&",
|
|
||||||
"*": "*",
|
|
||||||
"(": "(",
|
|
||||||
")": ")",
|
|
||||||
|
|
||||||
"-": "-",
|
|
||||||
"_": "_",
|
|
||||||
|
|
||||||
"=": "=",
|
|
||||||
"+": "+",
|
|
||||||
|
|
||||||
"[": "[",
|
|
||||||
"]": "]",
|
|
||||||
"{": "{",
|
|
||||||
"}": "}",
|
|
||||||
|
|
||||||
"|": "|",
|
|
||||||
|
|
||||||
";": ";",
|
|
||||||
":": ":",
|
|
||||||
|
|
||||||
"'": "'",
|
|
||||||
"\"": "\"",
|
|
||||||
|
|
||||||
",": ",",
|
|
||||||
"<": "<",
|
|
||||||
|
|
||||||
".": ".",
|
|
||||||
">": ">",
|
|
||||||
|
|
||||||
"/": "/",
|
|
||||||
"?": "?",
|
|
||||||
|
|
||||||
"`": "`",
|
|
||||||
"~": "~",
|
|
||||||
|
|
||||||
"\\": "\\"
|
|
||||||
}}
|
|
||||||
layout={{
|
|
||||||
default: [
|
|
||||||
"CtrlAltDelete AltMetaEscape",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
|
||||||
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
|
||||||
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
|
||||||
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
|
||||||
],
|
|
||||||
shift: [
|
|
||||||
"CtrlAltDelete AltMetaEscape",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
|
||||||
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
|
||||||
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
|
||||||
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
|
||||||
],
|
|
||||||
mappedLower: [
|
|
||||||
"CtrlAltDelete AltMetaEscape",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"` 1 2 3 4 5 6 7 8 9 0 - = Backspace",
|
|
||||||
"Tab q w e r t y u i o p [ ] \\",
|
|
||||||
"CapsLock a s d f g h j k l ; ' Enter",
|
|
||||||
"ShiftLeft z x c v b n m , . / ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight"
|
|
||||||
],
|
|
||||||
|
|
||||||
mappedUpper: [
|
|
||||||
"CtrlAltDelete AltMetaEscape",
|
|
||||||
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
|
||||||
"~ ! @ # $ % ^ & * ( ) _ + Backspace",
|
|
||||||
"Tab Q W E R T Y U I O P { } |",
|
|
||||||
"CapsLock A S D F G H J K L : \" Enter",
|
|
||||||
"ShiftLeft Z X C V B N M < > ? ShiftRight",
|
|
||||||
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight"
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
disableButtonHold={true}
|
|
||||||
mergeDisplay={true}
|
|
||||||
debug={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="controlArrows">
|
|
||||||
<Keyboard
|
|
||||||
baseClass="simple-keyboard-control"
|
|
||||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
|
||||||
layout={{
|
|
||||||
default: ["Home Pageup", "Delete End Pagedown"],
|
|
||||||
}}
|
|
||||||
display={{
|
|
||||||
Home: "home",
|
|
||||||
Pageup: "pageup",
|
|
||||||
Delete: "delete",
|
|
||||||
End: "end",
|
|
||||||
Pagedown: "pagedown",
|
|
||||||
}}
|
|
||||||
syncInstanceInputs={true}
|
|
||||||
onKeyPress={onKeyDown}
|
|
||||||
mergeDisplay={true}
|
|
||||||
debug={false}
|
|
||||||
/>
|
|
||||||
<Keyboard
|
|
||||||
baseClass="simple-keyboard-arrows"
|
|
||||||
theme="simple-keyboard hg-theme-default hg-layout-default"
|
|
||||||
display={{
|
|
||||||
ArrowLeft: "←",
|
|
||||||
ArrowRight: "→",
|
|
||||||
ArrowUp: "↑",
|
|
||||||
ArrowDown: "↓",
|
|
||||||
}}
|
|
||||||
layout={{
|
|
||||||
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
|
||||||
}}
|
|
||||||
onKeyPress={onKeyDown}
|
|
||||||
debug={false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
<div>
|
||||||
</div>
|
<div className="flex flex-col bg-blue-50/80 md:flex-row dark:bg-slate-700">
|
||||||
</div>
|
<Keyboard
|
||||||
</Transition>
|
baseClass="simple-keyboard-main"
|
||||||
|
layoutName={layoutName}
|
||||||
|
onKeyPress={onKeyDown}
|
||||||
|
buttonTheme={[
|
||||||
|
{
|
||||||
|
class: "combination-key",
|
||||||
|
buttons: "CtrlAltDelete AltMetaEscape",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
display={keyDisplayMap}
|
||||||
|
layout={{
|
||||||
|
default: [
|
||||||
|
"CtrlAltDelete AltMetaEscape",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"Backquote Digit1 Digit2 Digit3 Digit4 Digit5 Digit6 Digit7 Digit8 Digit9 Digit0 Minus Equal Backspace",
|
||||||
|
"Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
|
||||||
|
"CapsLock KeyA KeyS KeyD KeyF KeyG KeyH KeyJ KeyK KeyL Semicolon Quote Enter",
|
||||||
|
"ShiftLeft KeyZ KeyX KeyC KeyV KeyB KeyN KeyM Comma Period Slash ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||||
|
],
|
||||||
|
shift: [
|
||||||
|
"CtrlAltDelete AltMetaEscape",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"(Backquote) (Digit1) (Digit2) (Digit3) (Digit4) (Digit5) (Digit6) (Digit7) (Digit8) (Digit9) (Digit0) (Minus) (Equal) (Backspace)",
|
||||||
|
"Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
|
||||||
|
"CapsLock (KeyA) (KeyS) (KeyD) (KeyF) (KeyG) (KeyH) (KeyJ) (KeyK) (KeyL) (Semicolon) (Quote) Enter",
|
||||||
|
"ShiftLeft (KeyZ) (KeyX) (KeyC) (KeyV) (KeyB) (KeyN) (KeyM) (Comma) (Period) (Slash) ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
|
||||||
|
],
|
||||||
|
mappedLower: [
|
||||||
|
"CtrlAltDelete AltMetaEscape",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"` 1 2 3 4 5 6 7 8 9 0 - = Backspace",
|
||||||
|
"Tab q w e r t y u i o p [ ] \\",
|
||||||
|
"CapsLock a s d f g h j k l ; ' Enter",
|
||||||
|
"ShiftLeft z x c v b n m , . / ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight"
|
||||||
|
],
|
||||||
|
|
||||||
|
mappedUpper: [
|
||||||
|
"CtrlAltDelete AltMetaEscape",
|
||||||
|
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12",
|
||||||
|
"~ ! @ # $ % ^ & * ( ) _ + Backspace",
|
||||||
|
"Tab Q W E R T Y U I O P { } |",
|
||||||
|
"CapsLock A S D F G H J K L : \" Enter",
|
||||||
|
"ShiftLeft Z X C V B N M < > ? ShiftRight",
|
||||||
|
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight"
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
disableButtonHold={true}
|
||||||
|
mergeDisplay={true}
|
||||||
|
debug={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="controlArrows">
|
||||||
|
<Keyboard
|
||||||
|
baseClass="simple-keyboard-control"
|
||||||
|
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||||
|
layout={{
|
||||||
|
default: ["Home Pageup", "Delete End Pagedown"],
|
||||||
|
}}
|
||||||
|
display={{
|
||||||
|
Home: "home",
|
||||||
|
Pageup: "pageup",
|
||||||
|
Delete: "delete",
|
||||||
|
End: "end",
|
||||||
|
Pagedown: "pagedown",
|
||||||
|
}}
|
||||||
|
syncInstanceInputs={true}
|
||||||
|
onKeyPress={onKeyDown}
|
||||||
|
mergeDisplay={true}
|
||||||
|
debug={false}
|
||||||
|
/>
|
||||||
|
<Keyboard
|
||||||
|
baseClass="simple-keyboard-arrows"
|
||||||
|
theme="simple-keyboard hg-theme-default hg-layout-default"
|
||||||
|
display={{
|
||||||
|
ArrowLeft: "←",
|
||||||
|
ArrowRight: "→",
|
||||||
|
ArrowUp: "↑",
|
||||||
|
ArrowDown: "↓",
|
||||||
|
}}
|
||||||
|
layout={{
|
||||||
|
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
|
||||||
|
}}
|
||||||
|
onKeyPress={onKeyDown}
|
||||||
|
debug={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default KeyboardWrapper;
|
export default KeyboardWrapper;
|
|
@ -1,10 +1,11 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
useDeviceSettingsStore,
|
||||||
useHidStore,
|
useHidStore,
|
||||||
useMouseStore,
|
useMouseStore,
|
||||||
useRTCStore,
|
useRTCStore,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
useUiStore,
|
|
||||||
useVideoStore,
|
useVideoStore,
|
||||||
useKeyboardMappingsStore,
|
useKeyboardMappingsStore,
|
||||||
} from "@/hooks/stores";
|
} from "@/hooks/stores";
|
||||||
|
@ -12,10 +13,16 @@ import { useResizeObserver } from "@/hooks/useResizeObserver";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import VirtualKeyboard from "@components/VirtualKeyboard";
|
import VirtualKeyboard from "@components/VirtualKeyboard";
|
||||||
import Actionbar from "@components/ActionBar";
|
import Actionbar from "@components/ActionBar";
|
||||||
|
import MacroBar from "@/components/MacroBar";
|
||||||
import InfoBar from "@components/InfoBar";
|
import InfoBar from "@components/InfoBar";
|
||||||
import useKeyboard from "@/hooks/useKeyboard";
|
import useKeyboard from "@/hooks/useKeyboard";
|
||||||
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
import { useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { ConnectionErrorOverlay, HDMIErrorOverlay, LoadingOverlay } from "./VideoOverlay";
|
|
||||||
|
import {
|
||||||
|
HDMIErrorOverlay,
|
||||||
|
LoadingVideoOverlay,
|
||||||
|
NoAutoplayPermissionsOverlay,
|
||||||
|
} from "./VideoOverlay";
|
||||||
|
|
||||||
// TODO Implement keyboard lock API to resolve #127
|
// TODO Implement keyboard lock API to resolve #127
|
||||||
// https://developer.chrome.com/docs/capabilities/web-apis/keyboard-lock
|
// https://developer.chrome.com/docs/capabilities/web-apis/keyboard-lock
|
||||||
|
@ -45,29 +52,31 @@ export default function WebRTCVideo() {
|
||||||
const videoElm = useRef<HTMLVideoElement>(null);
|
const videoElm = useRef<HTMLVideoElement>(null);
|
||||||
const mediaStream = useRTCStore(state => state.mediaStream);
|
const mediaStream = useRTCStore(state => state.mediaStream);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
||||||
|
|
||||||
// Store hooks
|
// Store hooks
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
|
||||||
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
const setMousePosition = useMouseStore(state => state.setMousePosition);
|
||||||
|
const setMouseMove = useMouseStore(state => state.setMouseMove);
|
||||||
const {
|
const {
|
||||||
setClientSize: setVideoClientSize,
|
setClientSize: setVideoClientSize,
|
||||||
setSize: setVideoSize,
|
setSize: setVideoSize,
|
||||||
|
width: videoWidth,
|
||||||
|
height: videoHeight,
|
||||||
clientWidth: videoClientWidth,
|
clientWidth: videoClientWidth,
|
||||||
clientHeight: videoClientHeight,
|
clientHeight: videoClientHeight,
|
||||||
} = useVideoStore();
|
} = useVideoStore();
|
||||||
|
|
||||||
// RTC related states
|
// RTC related states
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
const peerConnection = useRTCStore(state => state.peerConnection);
|
||||||
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
|
|
||||||
|
|
||||||
// HDMI and UI states
|
// HDMI and UI states
|
||||||
const hdmiState = useVideoStore(state => state.hdmiState);
|
const hdmiState = useVideoStore(state => state.hdmiState);
|
||||||
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
|
||||||
const isLoading = !hdmiError && !isPlaying;
|
const isVideoLoading = !isPlaying;
|
||||||
const isConnectionError = ["error", "failed", "disconnected"].includes(
|
|
||||||
peerConnectionState || "",
|
// console.log("peerConnection?.connectionState", peerConnection?.connectionState);
|
||||||
);
|
|
||||||
|
|
||||||
// Keyboard related states
|
// Keyboard related states
|
||||||
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
|
||||||
|
@ -100,61 +109,124 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
const onVideoPlaying = useCallback(() => {
|
const onVideoPlaying = useCallback(() => {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||||
}, [updateVideoSizeStore]);
|
}, [updateVideoSizeStore]);
|
||||||
|
|
||||||
// On mount, get the video size
|
// On mount, get the video size
|
||||||
useEffect(
|
useEffect(
|
||||||
function updateVideoSizeOnMount() {
|
function updateVideoSizeOnMount() {
|
||||||
videoElm.current && updateVideoSizeStore(videoElm.current);
|
if (videoElm.current) updateVideoSizeStore(videoElm.current);
|
||||||
},
|
},
|
||||||
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
[setVideoClientSize, updateVideoSizeStore, setVideoSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mouse-related
|
// Mouse-related
|
||||||
const sendMouseMovement = useCallback(
|
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
|
||||||
|
const sendRelMouseMovement = useCallback(
|
||||||
(x: number, y: number, buttons: number) => {
|
(x: number, y: number, buttons: number) => {
|
||||||
send("absMouseReport", { x, y, buttons });
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
// if we ignore the event, double-click will not work
|
||||||
|
// if (x === 0 && y === 0 && buttons === 0) return;
|
||||||
|
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
|
||||||
|
setMouseMove({ x, y, buttons });
|
||||||
|
},
|
||||||
|
[send, setMouseMove, settings.mouseMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const relMouseMoveHandler = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
|
||||||
|
// Send mouse movement
|
||||||
|
const { buttons } = e;
|
||||||
|
sendRelMouseMovement(e.movementX, e.movementY, buttons);
|
||||||
|
},
|
||||||
|
[sendRelMouseMovement, settings.mouseMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendAbsMouseMovement = useCallback(
|
||||||
|
(x: number, y: number, buttons: number) => {
|
||||||
|
if (settings.mouseMode !== "absolute") return;
|
||||||
|
send("absMouseReport", { x, y, buttons });
|
||||||
// We set that for the debug info bar
|
// We set that for the debug info bar
|
||||||
setMousePosition(x, y);
|
setMousePosition(x, y);
|
||||||
},
|
},
|
||||||
[send, setMousePosition],
|
[send, setMousePosition, settings.mouseMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mouseMoveHandler = useCallback(
|
const absMouseMoveHandler = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (!videoClientWidth || !videoClientHeight) return;
|
if (!videoClientWidth || !videoClientHeight) return;
|
||||||
const { buttons } = e;
|
if (settings.mouseMode !== "absolute") return;
|
||||||
|
|
||||||
// Clamp mouse position within the video boundaries
|
// Get the aspect ratios of the video element and the video stream
|
||||||
const currMouseX = Math.min(Math.max(1, e.offsetX), videoClientWidth);
|
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
|
||||||
const currMouseY = Math.min(Math.max(1, e.offsetY), videoClientHeight);
|
const videoStreamAspectRatio = videoWidth / videoHeight;
|
||||||
|
|
||||||
// Normalize mouse position to 0-32767 range (HID absolute coordinate system)
|
// Calculate the effective video display area
|
||||||
const x = Math.round((currMouseX / videoClientWidth) * 32767);
|
let effectiveWidth = videoClientWidth;
|
||||||
const y = Math.round((currMouseY / videoClientHeight) * 32767);
|
let effectiveHeight = videoClientHeight;
|
||||||
|
let offsetX = 0;
|
||||||
|
let offsetY = 0;
|
||||||
|
|
||||||
|
if (videoElementAspectRatio > videoStreamAspectRatio) {
|
||||||
|
// Pillarboxing: black bars on the left and right
|
||||||
|
effectiveWidth = videoClientHeight * videoStreamAspectRatio;
|
||||||
|
offsetX = (videoClientWidth - effectiveWidth) / 2;
|
||||||
|
} else if (videoElementAspectRatio < videoStreamAspectRatio) {
|
||||||
|
// Letterboxing: black bars on the top and bottom
|
||||||
|
effectiveHeight = videoClientWidth / videoStreamAspectRatio;
|
||||||
|
offsetY = (videoClientHeight - effectiveHeight) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp mouse position within the effective video boundaries
|
||||||
|
const clampedX = Math.min(Math.max(offsetX, e.offsetX), offsetX + effectiveWidth);
|
||||||
|
const clampedY = Math.min(Math.max(offsetY, e.offsetY), offsetY + effectiveHeight);
|
||||||
|
|
||||||
|
// Map clamped mouse position to the video stream's coordinate system
|
||||||
|
const relativeX = (clampedX - offsetX) / effectiveWidth;
|
||||||
|
const relativeY = (clampedY - offsetY) / effectiveHeight;
|
||||||
|
|
||||||
|
// Convert to HID absolute coordinate system (0-32767 range)
|
||||||
|
const x = Math.round(relativeX * 32767);
|
||||||
|
const y = Math.round(relativeY * 32767);
|
||||||
|
|
||||||
// Send mouse movement
|
// Send mouse movement
|
||||||
sendMouseMovement(x, y, buttons);
|
const { buttons } = e;
|
||||||
|
sendAbsMouseMovement(x, y, buttons);
|
||||||
},
|
},
|
||||||
[sendMouseMovement, videoClientHeight, videoClientWidth],
|
[
|
||||||
|
sendAbsMouseMovement,
|
||||||
|
videoClientHeight,
|
||||||
|
videoClientWidth,
|
||||||
|
videoWidth,
|
||||||
|
videoHeight,
|
||||||
|
settings.mouseMode,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
|
||||||
|
const mouseSensitivity = useDeviceSettingsStore(state => state.mouseSensitivity);
|
||||||
|
const clampMin = useDeviceSettingsStore(state => state.clampMin);
|
||||||
|
const clampMax = useDeviceSettingsStore(state => state.clampMax);
|
||||||
|
const blockDelay = useDeviceSettingsStore(state => state.blockDelay);
|
||||||
|
const trackpadThreshold = useDeviceSettingsStore(state => state.trackpadThreshold);
|
||||||
|
|
||||||
const mouseWheelHandler = useCallback(
|
const mouseWheelHandler = useCallback(
|
||||||
(e: WheelEvent) => {
|
(e: WheelEvent) => {
|
||||||
if (blockWheelEvent) return;
|
if (blockWheelEvent) return;
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// TODO this should be user controllable
|
// Determine if the wheel event is from a trackpad or a mouse wheel
|
||||||
// Define a scaling factor to adjust scrolling sensitivity
|
const isTrackpad = Math.abs(e.deltaY) < trackpadThreshold;
|
||||||
const scrollSensitivity = 0.8; // Adjust this value to change scroll speed
|
|
||||||
|
// Apply appropriate sensitivity based on input device
|
||||||
|
const scrollSensitivity = isTrackpad ? trackpadSensitivity : mouseSensitivity;
|
||||||
|
|
||||||
// Calculate the scroll value
|
// Calculate the scroll value
|
||||||
const scroll = e.deltaY * scrollSensitivity;
|
const scroll = e.deltaY * scrollSensitivity;
|
||||||
|
|
||||||
// Clamp the scroll value to a reasonable range (e.g., -15 to 15)
|
// Apply clamping
|
||||||
const clampedScroll = Math.max(-4, Math.min(4, scroll));
|
const clampedScroll = Math.max(clampMin, Math.min(clampMax, scroll));
|
||||||
|
|
||||||
// Round to the nearest integer
|
// Round to the nearest integer
|
||||||
const roundedScroll = Math.round(clampedScroll);
|
const roundedScroll = Math.round(clampedScroll);
|
||||||
|
@ -164,16 +236,25 @@ export default function WebRTCVideo() {
|
||||||
|
|
||||||
send("wheelReport", { wheelY: invertedScroll });
|
send("wheelReport", { wheelY: invertedScroll });
|
||||||
|
|
||||||
// TODO this is making scrolling feel slow and sluggish, also throwing a violation in chrome
|
// Apply blocking delay
|
||||||
setBlockWheelEvent(true);
|
setBlockWheelEvent(true);
|
||||||
setTimeout(() => setBlockWheelEvent(false), 50);
|
setTimeout(() => setBlockWheelEvent(false), blockDelay);
|
||||||
},
|
},
|
||||||
[blockWheelEvent, send],
|
[
|
||||||
|
blockDelay,
|
||||||
|
blockWheelEvent,
|
||||||
|
clampMax,
|
||||||
|
clampMin,
|
||||||
|
mouseSensitivity,
|
||||||
|
send,
|
||||||
|
trackpadSensitivity,
|
||||||
|
trackpadThreshold,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const resetMousePosition = useCallback(() => {
|
const resetMousePosition = useCallback(() => {
|
||||||
sendMouseMovement(0, 0, 0);
|
sendAbsMouseMovement(0, 0, 0);
|
||||||
}, [sendMouseMovement]);
|
}, [sendAbsMouseMovement]);
|
||||||
|
|
||||||
// TODO this needs reworked ot work with mappings
|
// TODO this needs reworked ot work with mappings
|
||||||
// Keyboard-related
|
// Keyboard-related
|
||||||
|
@ -323,11 +404,6 @@ export default function WebRTCVideo() {
|
||||||
console.log(e)
|
console.log(e)
|
||||||
const prev = useHidStore.getState();
|
const prev = useHidStore.getState();
|
||||||
|
|
||||||
// if (document.activeElement?.id !== "videoFocusTrap") {
|
|
||||||
// console.log("KEYUP: Not focusing on the video", document.activeElement);
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
setIsNumLockActive(e.getModifierState("NumLock"));
|
setIsNumLockActive(e.getModifierState("NumLock"));
|
||||||
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
setIsCapsLockActive(e.getModifierState("CapsLock"));
|
||||||
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
setIsScrollLockActive(e.getModifierState("ScrollLock"));
|
||||||
|
@ -472,7 +548,68 @@ export default function WebRTCVideo() {
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Effect hooks
|
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
|
||||||
|
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video
|
||||||
|
// there is no way to prevent this, so we need to simply force play the video when it's paused.
|
||||||
|
// Fix only works in chrome based browsers.
|
||||||
|
if (e.code === "Space") {
|
||||||
|
if (videoElm.current?.paused == true) {
|
||||||
|
console.log("Force playing video");
|
||||||
|
videoElm.current?.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addStreamToVideoElm = useCallback(
|
||||||
|
(mediaStream: MediaStream) => {
|
||||||
|
if (!videoElm.current) return;
|
||||||
|
const videoElmRefValue = videoElm.current;
|
||||||
|
// console.log("Adding stream to video element", videoElmRefValue);
|
||||||
|
videoElmRefValue.srcObject = mediaStream;
|
||||||
|
updateVideoSizeStore(videoElmRefValue);
|
||||||
|
},
|
||||||
|
[updateVideoSizeStore],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateVideoStreamOnNewTrack() {
|
||||||
|
if (!peerConnection) return;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
peerConnection.addEventListener(
|
||||||
|
"track",
|
||||||
|
(e: RTCTrackEvent) => {
|
||||||
|
// console.log("Adding stream to video element");
|
||||||
|
addStreamToVideoElm(e.streams[0]);
|
||||||
|
},
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[addStreamToVideoElm, peerConnection],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
function updateVideoStream() {
|
||||||
|
if (!mediaStream) return;
|
||||||
|
console.log("Updating video stream from mediaStream");
|
||||||
|
// We set the as early as possible
|
||||||
|
addStreamToVideoElm(mediaStream);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
setVideoClientSize,
|
||||||
|
mediaStream,
|
||||||
|
updateVideoSizeStore,
|
||||||
|
peerConnection,
|
||||||
|
addStreamToVideoElm,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup Keyboard Events
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupKeyboardEvents() {
|
function setupKeyboardEvents() {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
|
@ -495,109 +632,147 @@ export default function WebRTCVideo() {
|
||||||
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
|
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Setup Video Event Listeners
|
||||||
useEffect(
|
useEffect(
|
||||||
function setupVideoEventListeners() {
|
function setupVideoEventListeners() {
|
||||||
let videoElmRefValue = null;
|
const videoElmRefValue = videoElm.current;
|
||||||
if (!videoElm.current) return;
|
if (!videoElmRefValue) return;
|
||||||
videoElmRefValue = videoElm.current;
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const signal = abortController.signal;
|
const signal = abortController.signal;
|
||||||
|
|
||||||
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
|
// To prevent the video from being paused when the user presses a space in fullscreen mode
|
||||||
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
|
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
|
||||||
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
|
|
||||||
|
|
||||||
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { signal });
|
// We need to know when the video is playing to update state and video size
|
||||||
videoElmRefValue.addEventListener(
|
|
||||||
"contextmenu",
|
|
||||||
(e: MouseEvent) => e.preventDefault(),
|
|
||||||
{ signal },
|
|
||||||
);
|
|
||||||
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
|
videoElmRefValue.addEventListener("playing", onVideoPlaying, { signal });
|
||||||
|
|
||||||
const local = resetMousePosition;
|
|
||||||
window.addEventListener("blur", local, { signal });
|
|
||||||
document.addEventListener("visibilitychange", local, { signal });
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (videoElmRefValue) abortController.abort();
|
abortController.abort();
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[mouseMoveHandler, resetMousePosition, onVideoPlaying, mouseWheelHandler],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
function updateVideoStream() {
|
|
||||||
if (!mediaStream) return;
|
|
||||||
if (!videoElm.current) return;
|
|
||||||
if (peerConnection?.iceConnectionState !== "connected") return;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (videoElm?.current) {
|
|
||||||
videoElm.current.srcObject = mediaStream;
|
|
||||||
}
|
|
||||||
}, 0);
|
|
||||||
updateVideoSizeStore(videoElm.current);
|
|
||||||
},
|
|
||||||
[
|
[
|
||||||
setVideoClientSize,
|
absMouseMoveHandler,
|
||||||
setVideoSize,
|
resetMousePosition,
|
||||||
mediaStream,
|
onVideoPlaying,
|
||||||
updateVideoSizeStore,
|
mouseWheelHandler,
|
||||||
peerConnection?.iceConnectionState,
|
videoKeyUpHandler,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Focus trap management
|
// Setup Absolute Mouse Events
|
||||||
const setDisableVideoFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
useEffect(
|
||||||
const sidebarView = useUiStore(state => state.sidebarView);
|
function setAbsoluteMouseModeEventListeners() {
|
||||||
useEffect(() => {
|
const videoElmRefValue = videoElm.current;
|
||||||
setTimeout(function () {
|
if (!videoElmRefValue) return;
|
||||||
if (["connection-stats", "system"].includes(sidebarView ?? "")) {
|
|
||||||
// Reset keyboard state. Incase the user is pressing a key while enabling the sidebar
|
|
||||||
sendKeyboardEvent([], []);
|
|
||||||
setDisableVideoFocusTrap(true);
|
|
||||||
|
|
||||||
// For some reason, the focus trap is not disabled immediately
|
if (settings.mouseMode !== "absolute") return;
|
||||||
// so we need to blur the active element
|
|
||||||
// (document.activeElement as HTMLElement)?.blur();
|
const abortController = new AbortController();
|
||||||
console.log("Just disabled focus trap");
|
const signal = abortController.signal;
|
||||||
} else {
|
|
||||||
setDisableVideoFocusTrap(false);
|
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
|
||||||
}
|
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
|
||||||
}, 300);
|
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
|
||||||
}, [sendKeyboardEvent, setDisableVideoFocusTrap, sidebarView]);
|
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
|
||||||
|
signal,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset the mouse position when the window is blurred or the document is hidden
|
||||||
|
const local = resetMousePosition;
|
||||||
|
window.addEventListener("blur", local, { signal });
|
||||||
|
document.addEventListener("visibilitychange", local, { signal });
|
||||||
|
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
|
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[absMouseMoveHandler, mouseWheelHandler, resetMousePosition, settings.mouseMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Setup Relative Mouse Events
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(
|
||||||
|
function setupRelativeMouseEventListeners() {
|
||||||
|
if (settings.mouseMode !== "relative") return;
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const signal = abortController.signal;
|
||||||
|
|
||||||
|
// We bind to the larger container in relative mode because of delta between the acceleration of the local
|
||||||
|
// mouse and the mouse movement of the remote mouse. This simply makes it a bit less painful to use.
|
||||||
|
// When we get Pointer Lock support, we can remove this.
|
||||||
|
const containerElm = containerRef.current;
|
||||||
|
if (!containerElm) return;
|
||||||
|
|
||||||
|
containerElm.addEventListener("mousemove", relMouseMoveHandler, { signal });
|
||||||
|
containerElm.addEventListener("pointerdown", relMouseMoveHandler, { signal });
|
||||||
|
containerElm.addEventListener("pointerup", relMouseMoveHandler, { signal });
|
||||||
|
|
||||||
|
containerElm.addEventListener("wheel", mouseWheelHandler, {
|
||||||
|
signal,
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const preventContextMenu = (e: MouseEvent) => e.preventDefault();
|
||||||
|
containerElm.addEventListener("contextmenu", preventContextMenu, { signal });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
abortController.abort();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[settings.mouseMode, relMouseMoveHandler, mouseWheelHandler],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasNoAutoPlayPermissions = useMemo(() => {
|
||||||
|
if (peerConnection?.connectionState !== "connected") return false;
|
||||||
|
if (isPlaying) return false;
|
||||||
|
if (hdmiError) return false;
|
||||||
|
if (videoHeight === 0 || videoWidth === 0) return false;
|
||||||
|
return true;
|
||||||
|
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid w-full h-full grid-rows-layout">
|
<div className="grid h-full w-full grid-rows-layout">
|
||||||
<div className="min-h-[39.5px]">
|
<div className="min-h-[39.5px] flex flex-col">
|
||||||
<fieldset disabled={peerConnectionState !== "connected"}>
|
<div className="flex flex-col">
|
||||||
<Actionbar
|
<fieldset disabled={peerConnection?.connectionState !== "connected"} className="contents">
|
||||||
requestFullscreen={async () =>
|
<Actionbar
|
||||||
videoElm.current?.requestFullscreen({
|
requestFullscreen={async () =>
|
||||||
navigationUI: "show",
|
videoElm.current?.requestFullscreen({
|
||||||
})
|
navigationUI: "show",
|
||||||
}
|
})
|
||||||
/>
|
}
|
||||||
</fieldset>
|
/>
|
||||||
|
<MacroBar />
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-full overflow-hidden">
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={cx("h-full overflow-hidden", {
|
||||||
|
"cursor-none": settings.mouseMode === "relative" && settings.isCursorHidden,
|
||||||
|
})}
|
||||||
|
>
|
||||||
<div className="relative h-full">
|
<div className="relative h-full">
|
||||||
<div
|
<div
|
||||||
className={cx(
|
className={cx(
|
||||||
"absolute inset-0 bg-blue-50/40 dark:bg-slate-800/40 opacity-80",
|
"absolute inset-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40",
|
||||||
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
|
"[background-image:radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px),radial-gradient(theme(colors.blue.300)_0.5px,transparent_0.5px)] dark:[background-image:radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px),radial-gradient(theme(colors.slate.700)_0.5px,transparent_0.5px)]",
|
||||||
"[background-position:0_0,10px_10px]",
|
"[background-position:0_0,10px_10px]",
|
||||||
"[background-size:20px_20px]",
|
"[background-size:20px_20px]",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col">
|
||||||
<div className="relative flex-grow overflow-hidden">
|
<div className="relative flex-grow overflow-hidden">
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex h-full flex-col">
|
||||||
<div className="grid flex-grow overflow-hidden grid-rows-bodyFooter">
|
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden">
|
||||||
<div className="relative flex items-center justify-center mx-4 my-2 overflow-hidden">
|
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
|
||||||
<div className="relative flex items-center justify-center w-full h-full">
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
<video
|
<video
|
||||||
ref={videoElm}
|
ref={videoElm}
|
||||||
autoPlay={true}
|
autoPlay={true}
|
||||||
|
@ -609,25 +784,37 @@ export default function WebRTCVideo() {
|
||||||
disablePictureInPicture
|
disablePictureInPicture
|
||||||
controlsList="nofullscreen"
|
controlsList="nofullscreen"
|
||||||
className={cx(
|
className={cx(
|
||||||
"outline-50 max-h-full max-w-full rounded-md object-contain transition-all duration-1000",
|
"outline-50 max-h-full max-w-full object-contain transition-all duration-1000",
|
||||||
{
|
{
|
||||||
"cursor-none": settings.isCursorHidden,
|
"cursor-none":
|
||||||
"opacity-0": isLoading || isConnectionError || hdmiError,
|
settings.mouseMode === "absolute" &&
|
||||||
"animate-slideUpFade border border-slate-800/30 dark:border-slate-300/20 opacity-0 shadow":
|
settings.isCursorHidden,
|
||||||
|
"opacity-0":
|
||||||
|
isVideoLoading ||
|
||||||
|
hdmiError ||
|
||||||
|
peerConnectionState !== "connected",
|
||||||
|
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20":
|
||||||
isPlaying,
|
isPlaying,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
{peerConnection?.connectionState == "connected" && (
|
||||||
style={{ animationDuration: "500ms" }}
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center opacity-0 pointer-events-none animate-slideUpFade"
|
style={{ animationDuration: "500ms" }}
|
||||||
>
|
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0"
|
||||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
>
|
||||||
<LoadingOverlay show={isLoading} />
|
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||||
<ConnectionErrorOverlay show={isConnectionError} />
|
<LoadingVideoOverlay show={isVideoLoading} />
|
||||||
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
<HDMIErrorOverlay show={hdmiError} hdmiState={hdmiState} />
|
||||||
|
<NoAutoplayPermissionsOverlay
|
||||||
|
show={hasNoAutoPlayPermissions}
|
||||||
|
onPlayClick={() => {
|
||||||
|
videoElm.current?.play();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VirtualKeyboard />
|
<VirtualKeyboard />
|
||||||
|
|
|
@ -1,201 +0,0 @@
|
||||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
||||||
import { Terminal } from "xterm";
|
|
||||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
|
||||||
import { WebglAddon } from "@xterm/addon-webgl";
|
|
||||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
|
||||||
import { FitAddon } from "@xterm/addon-fit";
|
|
||||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
|
||||||
|
|
||||||
import "xterm/css/xterm.css";
|
|
||||||
import { useRTCStore, useUiStore } from "../hooks/stores";
|
|
||||||
|
|
||||||
const isWebGl2Supported = !!document.createElement("canvas").getContext("webgl2");
|
|
||||||
|
|
||||||
// Add this debounce function at the top of the file
|
|
||||||
function debounce(func: (...args: any[]) => void, wait: number) {
|
|
||||||
let timeout: number | null = null;
|
|
||||||
return (...args: any[]) => {
|
|
||||||
if (timeout) clearTimeout(timeout);
|
|
||||||
timeout = setTimeout(() => func(...args), wait);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
// Add these configurations:
|
|
||||||
convertEol: true,
|
|
||||||
linuxMode: false, // Disable Linux mode which might affect line endings
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
interface XTermProps {
|
|
||||||
terminalChannel: RTCDataChannel | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function XTerm({ terminalChannel }: XTermProps) {
|
|
||||||
const xtermRef = useRef<Terminal | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const terminalElmRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
||||||
const setEnableTerminal = useUiStore(state => state.setEnableTerminal);
|
|
||||||
const setDisableKeyboardFocusTrap = useUiStore(state => state.setDisableVideoFocusTrap);
|
|
||||||
const peerConnection = useRTCStore(state => state.peerConnection);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setDisableKeyboardFocusTrap(true);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
setDisableKeyboardFocusTrap(false);
|
|
||||||
};
|
|
||||||
}, [setDisableKeyboardFocusTrap]);
|
|
||||||
|
|
||||||
const initializeTerminalAddons = (term: Terminal) => {
|
|
||||||
const fitAddon = new FitAddon();
|
|
||||||
term.loadAddon(fitAddon);
|
|
||||||
term.loadAddon(new ClipboardAddon());
|
|
||||||
term.loadAddon(new Unicode11Addon());
|
|
||||||
term.loadAddon(new WebLinksAddon());
|
|
||||||
term.unicode.activeVersion = "11";
|
|
||||||
|
|
||||||
if (isWebGl2Supported) {
|
|
||||||
const webGl2Addon = new WebglAddon();
|
|
||||||
webGl2Addon.onContextLoss(() => webGl2Addon.dispose());
|
|
||||||
term.loadAddon(webGl2Addon);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fitAddon;
|
|
||||||
};
|
|
||||||
|
|
||||||
const setupTerminalChannel = (
|
|
||||||
term: Terminal,
|
|
||||||
channel: RTCDataChannel,
|
|
||||||
abortController: AbortController,
|
|
||||||
) => {
|
|
||||||
channel.onopen = () => {
|
|
||||||
// Handle terminal input
|
|
||||||
term.onData(data => {
|
|
||||||
if (channel.readyState === "open") {
|
|
||||||
channel.send(data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Handle terminal output
|
|
||||||
channel.addEventListener(
|
|
||||||
"message",
|
|
||||||
(event: MessageEvent) => {
|
|
||||||
term.write(new Uint8Array(event.data));
|
|
||||||
},
|
|
||||||
{ signal: abortController.signal },
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send initial terminal size
|
|
||||||
if (channel.readyState === "open") {
|
|
||||||
channel.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!terminalElmRef.current) return;
|
|
||||||
|
|
||||||
// Ensure the container has dimensions before initializing
|
|
||||||
if (!terminalElmRef.current.offsetHeight || !terminalElmRef.current.offsetWidth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const term = new Terminal(TERMINAL_CONFIG);
|
|
||||||
const fitAddon = initializeTerminalAddons(term);
|
|
||||||
const abortController = new AbortController();
|
|
||||||
|
|
||||||
// Setup escape key handler
|
|
||||||
term.onKey(e => {
|
|
||||||
const { domEvent } = e;
|
|
||||||
if (domEvent.key === "Escape") {
|
|
||||||
setEnableTerminal(false);
|
|
||||||
setDisableKeyboardFocusTrap(false);
|
|
||||||
domEvent.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let elm: HTMLDivElement | null = terminalElmRef.current;
|
|
||||||
// Initialize terminal
|
|
||||||
setTimeout(() => {
|
|
||||||
if (elm) {
|
|
||||||
console.log("opening terminal");
|
|
||||||
term.open(elm);
|
|
||||||
fitAddon.fit();
|
|
||||||
}
|
|
||||||
}, 800);
|
|
||||||
|
|
||||||
xtermRef.current = term;
|
|
||||||
fitAddonRef.current = fitAddon;
|
|
||||||
|
|
||||||
// Setup resize handling
|
|
||||||
const debouncedResizeHandler = debounce(() => fitAddon.fit(), 100);
|
|
||||||
const resizeObserver = new ResizeObserver(debouncedResizeHandler);
|
|
||||||
resizeObserver.observe(terminalElmRef.current);
|
|
||||||
|
|
||||||
// Focus terminal after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
term.focus();
|
|
||||||
terminalElmRef.current?.focus();
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Setup terminal channel if available
|
|
||||||
const channel = peerConnection?.createDataChannel("terminal");
|
|
||||||
if (channel) {
|
|
||||||
setupTerminalChannel(term, channel, abortController);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
return () => {
|
|
||||||
resizeObserver.disconnect();
|
|
||||||
abortController.abort();
|
|
||||||
term.dispose();
|
|
||||||
elm = null;
|
|
||||||
xtermRef.current = null;
|
|
||||||
fitAddonRef.current = null;
|
|
||||||
};
|
|
||||||
}, [peerConnection, setDisableKeyboardFocusTrap, setEnableTerminal, terminalChannel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full" ref={containerRef}>
|
|
||||||
<div
|
|
||||||
className="w-full h-full terminal-container"
|
|
||||||
ref={terminalElmRef}
|
|
||||||
style={{ display: "flex", minHeight: "100%" }}
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue