Merge remote-tracking branch 'upstream/dev' into feat/usb-audio

This commit is contained in:
Qishuai Liu 2025-06-22 00:36:01 +09:00
commit c529c903d0
No known key found for this signature in database
131 changed files with 8549 additions and 4050 deletions

View File

@ -4,11 +4,24 @@
"features": { "features": {
"ghcr.io/devcontainers/features/node:1": { "ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json // Should match what is defined in ui/package.json
"version": "21.1.0" "version": "22.15.0"
} }
}, },
"mounts": [ "mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached" "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"customizations": {
"vscode": {
"extensions": [
"bradlc.vscode-tailwindcss",
"GitHub.vscode-pull-request-github",
"dbaeumer.vscode-eslint",
"golang.go",
"ms-vscode.makefile-tools",
"esbenp.prettier-vscode",
"github.vscode-github-actions"
] ]
}
}
} }

17
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: github-actions
directory: /
schedule:
interval: monthly
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: /ui
open-pull-requests-limit: 10
schedule:
interval: monthly

View File

@ -19,21 +19,33 @@ jobs:
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: v21.1.0 node-version: "22"
cache: "npm" cache: "npm"
cache-dependency-path: "**/package-lock.json" cache-dependency-path: "**/package-lock.json"
- name: Set up Golang - name: Set up Golang
uses: actions/setup-go@v4 uses: actions/setup-go@v5
with: with:
go-version: "1.24.0" go-version: "1.24.4"
- name: Build frontend - name: Build frontend
run: | run: |
make frontend make frontend
- name: Build application - name: Build application
run: | run: |
make build_dev make build_dev
- name: Run tests
run: |
go test ./... -json > testreport.json
- name: Make test cases
run: |
make build_dev_test
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
input: "testreport.json"
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: jetkvm-app name: jetkvm-app
path: bin/jetkvm_app path: |
bin/jetkvm_app
device-tests.tar.gz

View File

@ -24,9 +24,9 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Go - name: Install Go
uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 uses: actions/setup-go@19bb51245e9c80abacb2e91cc42b33fa478b8639 # v4.2.1
with: with:
go-version: 1.24.x go-version: 1.24.4
- name: Create empty resource directory - name: Create empty resource directory
run: | run: |
mkdir -p static && touch static/.gitkeep mkdir -p static && touch static/.gitkeep

View File

@ -69,12 +69,54 @@ jobs:
CI_USER: ${{ vars.JETKVM_CI_USER }} CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }} CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }} CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
- name: Run tests
run: |
set -e
echo "+ Copying device-tests.tar.gz to remote host"
ssh jkci "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
echo "+ Running go tests"
ssh jkci ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
ssh jkci "cat /tmp/device-tests.json" > device-tests.json
- name: Set up Golang
uses: actions/setup-go@v5
with:
go-version: "1.24.4"
- name: Golang Test Report
uses: becheran/go-testreport@v0.3.2
with:
input: "device-tests.json"
- name: Deploy application - name: Deploy application
run: | run: |
set -e set -e
# Copy the binary to the remote host # Copy the binary to the remote host
echo "+ Copying the application 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" cat bin/jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
# Deploy and run the application on the remote host # Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host" echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF ssh jkci ash <<EOF
@ -108,15 +150,25 @@ jobs:
run: | run: |
echo "+ Checking the status of the device" echo "+ Checking the status of the device"
curl -v http://$CI_HOST/device/status && echo curl -v http://$CI_HOST/device/status && echo
echo "+ Waiting for 10 seconds to allow all services to start" echo "+ Waiting for 15 seconds to allow all services to start"
sleep 10 sleep 15
echo "+ Collecting logs" echo "+ Collecting logs"
ssh jkci "cat /userdata/jetkvm/last.log" > last.log local_log_tar=$(mktemp)
cat last.log ssh jkci ash > $local_log_tar <<'EOF'
log_path=$(mktemp -d)
dmesg > $log_path/dmesg.log
cp /userdata/jetkvm/last.log $log_path/last.log
tar -czf - -C $log_path .
EOF
tar -xf $local_log_tar
cat dmesg.log last.log
env: env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }} CI_HOST: ${{ vars.JETKVM_CI_HOST }}
- name: Upload logs - name: Upload logs
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: device-logs name: device-logs
path: last.log path: |
last.log
dmesg.log
device-tests.json

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
bin/* bin/*
static/* static/*
.idea .idea
.DS_Store
device-tests.tar.gz

View File

@ -4,6 +4,7 @@ linters:
- forbidigo - forbidigo
- misspell - misspell
- whitespace - whitespace
- gochecknoinits
settings: settings:
forbidigo: forbidigo:
forbid: forbid:
@ -22,6 +23,9 @@ linters:
- linters: - linters:
- errcheck - errcheck
path: _test.go path: _test.go
- linters:
- gochecknoinits
path: internal/logging/sse.go
paths: paths:
- third_party$ - third_party$
- builtin$ - builtin$

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"tailwindCSS.classFunctions": ["cva", "cx"]
}

View File

@ -2,12 +2,14 @@ BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z) BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s) BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD) REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.0-dev$(shell date +%Y%m%d%H%M) VERSION_DEV ?= 0.4.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.9 VERSION ?= 0.4.4
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm
GO_BUILD_ARGS := -tags netgo
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
GO_LDFLAGS := \ GO_LDFLAGS := \
-s -w \ -s -w \
-X $(PROMETHEUS_TAG).Branch=$(BRANCH) \ -X $(PROMETHEUS_TAG).Branch=$(BRANCH) \
@ -15,12 +17,49 @@ GO_LDFLAGS := \
-X $(PROMETHEUS_TAG).Revision=$(REVISION) \ -X $(PROMETHEUS_TAG).Revision=$(REVISION) \
-X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS) -X $(KVM_PKG_NAME).builtTimestamp=$(BUILDTS)
GO_CMD := GOOS=linux GOARCH=arm GOARM=7 go
BIN_DIR := $(shell pwd)/bin
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
hash_resource: hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource build_dev: hash_resource
@echo "Building..." @echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go $(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app cmd/main.go
build_test2json:
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@cat resource/dev_test.sh > $(BIN_DIR)/tests/run_all_tests
@for test in $(TEST_DIRS); do \
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
$(GO_CMD) test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
echo "runTest ./$$test_filename $$test_pkg_full_name" >> $(BIN_DIR)/tests/run_all_tests; \
done; \
chmod +x $(BIN_DIR)/tests/run_all_tests; \
cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
frontend: frontend:
cd ui && npm ci && npm run build:device cd ui && npm ci && npm run build:device
@ -33,7 +72,10 @@ dev_release: frontend build_dev
build_release: frontend hash_resource build_release: frontend hash_resource
@echo "Building release..." @echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go $(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
release: release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \ @if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -7,6 +7,8 @@
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm) [![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div> </div>
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively. JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.

View File

@ -53,7 +53,7 @@ func startFFmpeg() (cmd *exec.Cmd, err error) {
return return
} }
func StartNtpAudioServer(handleClient func(net.Conn)) { func StartRtpAudioServer(handleClient func(net.Conn)) {
scopedLogger := nativeLogger.With(). scopedLogger := nativeLogger.With().
Logger() Logger()

View File

@ -51,34 +51,34 @@ var (
) )
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge( metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp", Name: "jetkvm_cloud_connection_established_timestamp_seconds",
Help: "The timestamp when the cloud connection was established", Help: "The timestamp when the cloud connection was established",
}, },
) )
metricConnectionLastPingTimestamp = promauto.NewGaugeVec( metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp", Name: "jetkvm_connection_last_ping_timestamp_seconds",
Help: "The timestamp when the last ping response was received", Help: "The timestamp when the last ping response was received",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec( metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp", Name: "jetkvm_connection_last_ping_received_timestamp_seconds",
Help: "The timestamp when the last ping request was received", Help: "The timestamp when the last ping request was received",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionLastPingDuration = promauto.NewGaugeVec( metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration", Name: "jetkvm_connection_last_ping_duration_seconds",
Help: "The duration of the last ping response", Help: "The duration of the last ping response",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionPingDuration = promauto.NewHistogramVec( metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration", Name: "jetkvm_connection_ping_duration_seconds",
Help: "The duration of the ping response", Help: "The duration of the ping response",
Buckets: []float64{ Buckets: []float64{
0.1, 0.5, 1, 10, 0.1, 0.5, 1, 10,
@ -88,28 +88,28 @@ var (
) )
metricConnectionTotalPingSentCount = promauto.NewCounterVec( metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_sent", Name: "jetkvm_connection_ping_sent_total",
Help: "The total number of pings sent to the connection", Help: "The total number of pings sent to the connection",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec( metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_connection_total_ping_received", Name: "jetkvm_connection_ping_received_total",
Help: "The total number of pings received from the connection", Help: "The total number of pings received from the connection",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionSessionRequestCount = promauto.NewCounterVec( metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_connection_session_total_requests", Name: "jetkvm_connection_session_requests_total",
Help: "The total number of session requests received", Help: "The total number of session requests received",
}, },
[]string{"type", "source"}, []string{"type", "source"},
) )
metricConnectionSessionRequestDuration = promauto.NewHistogramVec( metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{ prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration", Name: "jetkvm_connection_session_request_duration_seconds",
Help: "The duration of session requests", Help: "The duration of session requests",
Buckets: []float64{ Buckets: []float64{
0.1, 0.5, 1, 10, 0.1, 0.5, 1, 10,
@ -119,7 +119,7 @@ var (
) )
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec( metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{ prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp", Name: "jetkvm_connection_last_session_request_timestamp_seconds",
Help: "The timestamp of the last session request", Help: "The timestamp of the last session request",
}, },
[]string{"type", "source"}, []string{"type", "source"},
@ -133,7 +133,7 @@ var (
) )
metricCloudConnectionFailureCount = promauto.NewCounter( metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_count", Name: "jetkvm_cloud_connection_failure_total",
Help: "The number of times the cloud connection has failed", Help: "The number of times the cloud connection has failed",
}, },
) )

View File

@ -85,8 +85,10 @@ type Config struct {
HashedPassword string `json:"hashed_password"` HashedPassword string `json:"hashed_password"`
LocalAuthToken string `json:"local_auth_token"` LocalAuthToken string `json:"local_auth_token"`
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
LocalLoopbackOnly bool `json:"local_loopback_only"`
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"` WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
KeyboardMacros []KeyboardMacro `json:"keyboard_macros"` KeyboardMacros []KeyboardMacro `json:"keyboard_macros"`
KeyboardLayout string `json:"keyboard_layout"`
EdidString string `json:"hdmi_edid_string"` EdidString string `json:"hdmi_edid_string"`
ActiveExtension string `json:"active_extension"` ActiveExtension string `json:"active_extension"`
DisplayRotation string `json:"display_rotation"` DisplayRotation string `json:"display_rotation"`
@ -109,6 +111,7 @@ var defaultConfig = &Config{
ActiveExtension: "", ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{}, KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270", DisplayRotation: "270",
KeyboardLayout: "en_US",
DisplayMaxBrightness: 64, DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes DisplayOffAfterSec: 1800, // 30 minutes

View File

@ -3,6 +3,19 @@
# 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
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Function to display help message # Function to display help message
show_help() { show_help() {
echo "Usage: $0 [options] -r <remote_ip>" echo "Usage: $0 [options] -r <remote_ip>"
@ -12,6 +25,8 @@ 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 " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build" echo " --skip-ui-build Skip frontend/UI build"
echo " --help Display this help message" echo " --help Display this help message"
echo echo
@ -26,6 +41,8 @@ REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false SKIP_UI_BUILD=false
RESET_USB_HID_DEVICE=false RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false
# Parse command line arguments # Parse command line arguments
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -46,6 +63,15 @@ while [[ $# -gt 0 ]]; do
RESET_USB_HID_DEVICE=true RESET_USB_HID_DEVICE=true
shift shift
;; ;;
--run-go-tests)
RUN_GO_TESTS=true
shift
;;
--run-go-tests-only)
RUN_GO_TESTS_ONLY=true
RUN_GO_TESTS=true
shift
;;
--help) --help)
show_help show_help
exit 0 exit 0
@ -60,26 +86,71 @@ done
# Verify required parameters # Verify required parameters
if [ -z "$REMOTE_HOST" ]; then if [ -z "$REMOTE_HOST" ]; then
echo "Error: Remote IP is a required parameter" msg_err "Error: Remote IP is a required parameter"
show_help show_help
exit 1
fi fi
# Build the development version on the host # Build the development version on the host
if [ "$SKIP_UI_BUILD" = false ]; then if [ "$SKIP_UI_BUILD" = false ]; then
msg_info "▶ Building frontend"
make frontend make frontend
fi fi
make build_dev
# Change directory to the binary output directory if [ "$RUN_GO_TESTS" = true ]; then
cd bin msg_info "▶ Building go tests"
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
if [ "$RUN_GO_TESTS_ONLY" = true ]; then
msg_info "▶ Go tests completed"
exit 0
fi
fi
msg_info "▶ Building go binary"
make build_dev
# Kill any existing instances of the application # Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < jetkvm_app ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration # Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*" ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC" ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
@ -103,7 +174,7 @@ cd "${REMOTE_PATH}"
chmod +x jetkvm_app_debug chmod +x jetkvm_app_debug
# Run the application in the background # Run the application in the background
PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug
EOF EOF
echo "Deployment complete." echo "Deployment complete."

View File

@ -339,10 +339,18 @@ func startBacklightTickers() {
return return
} }
if dimTicker == nil && config.DisplayDimAfterSec != 0 { // Stop existing tickers to prevent multiple active instances on repeated calls
if dimTicker != nil {
dimTicker.Stop()
}
if offTicker != nil {
offTicker.Stop()
}
if config.DisplayDimAfterSec != 0 {
displayLogger.Info().Msg("dim_ticker has started") displayLogger.Info().Msg("dim_ticker has started")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second) dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop()
go func() { go func() {
for { //nolint:staticcheck for { //nolint:staticcheck
@ -354,10 +362,9 @@ func startBacklightTickers() {
}() }()
} }
if offTicker == nil && config.DisplayOffAfterSec != 0 { if config.DisplayOffAfterSec != 0 {
displayLogger.Info().Msg("off_ticker has started") displayLogger.Info().Msg("off_ticker has started")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second) offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop()
go func() { go func() {
for { //nolint:staticcheck for { //nolint:staticcheck
@ -370,9 +377,7 @@ func startBacklightTickers() {
} }
} }
func init() { func initDisplay() {
ensureConfigLoaded()
go func() { go func() {
waitCtrlClientConnected() waitCtrlClientConnected()
displayLogger.Info().Msg("setting initial display contents") displayLogger.Info().Msg("setting initial display contents")

69
go.mod
View File

@ -1,32 +1,38 @@
module github.com/jetkvm/kvm module github.com/jetkvm/kvm
go 1.23.0 go 1.23.4
toolchain go1.24.3
require ( require (
github.com/Masterminds/semver/v3 v3.3.0 github.com/Masterminds/semver/v3 v3.3.1
github.com/beevik/ntp v1.3.1 github.com/beevik/ntp v1.4.3
github.com/coder/websocket v1.8.13 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/fsnotify/fsnotify v1.9.0 github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.5 github.com/gin-contrib/logger v1.2.6
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/guregu/null/v6 v6.0.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.8.0
github.com/hashicorp/go-envparse v0.1.0 github.com/pion/logging v0.2.3
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.16
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/client_golang v1.22.0
github.com/prometheus/common v0.62.0 github.com/prometheus/common v0.62.0
github.com/prometheus/procfs v0.16.1
github.com/psanford/httpreadat v0.1.0 github.com/psanford/httpreadat v0.1.0
github.com/rs/zerolog v1.34.0 github.com/rs/zerolog v1.34.0
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f
github.com/stretchr/testify v1.10.0
github.com/vishvananda/netlink v1.3.0 github.com/vishvananda/netlink v1.3.0
go.bug.st/serial v1.6.2 go.bug.st/serial v1.6.2
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.39.0
golang.org/x/net v0.38.0 golang.org/x/net v0.41.0
golang.org/x/sys v0.33.0
) )
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -38,16 +44,15 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.5 // indirect
github.com/creack/goselect v0.1.2 // indirect github.com/creack/goselect v0.1.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // 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.26.0 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/guregu/null/v6 v6.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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-colorable v0.1.14 // indirect
@ -55,31 +60,31 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // 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.10 // indirect
github.com/pion/dtls/v3 v3.0.3 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.2 // indirect github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.37 // indirect github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/randutil v0.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.14 // indirect github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.9 // indirect github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.33 // indirect github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.9 // indirect github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v3 v3.0.4 // indirect github.com/pion/srtp/v3 v3.0.5 // indirect
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.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // 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.15.0 // indirect golang.org/x/arch v0.17.0 // indirect
golang.org/x/oauth2 v0.24.0 // indirect golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.32.0 // indirect golang.org/x/text v0.26.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect google.golang.org/protobuf v1.36.6 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

138
go.sum
View File

@ -1,7 +1,7 @@
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM= github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw= github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
@ -30,16 +30,16 @@ 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/logger v1.2.5 h1:qVQI4omayQecuN4zX9ZZnsOq7w9J/ZLds3J/FMn8ypM= github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc=
github.com/gin-contrib/logger v1.2.5/go.mod h1:/bj+vNMuA2xOEQ1aRHoJ1m9+uyaaXIAxQTvM2llsc6I= github.com/gin-contrib/logger v1.2.6/go.mod h1:7niPrd7F0Nscw/zvgz8RiGJxSdbKM2yfQNy8xCHcm64=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/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.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -51,8 +51,8 @@ github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAu
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@ -60,14 +60,12 @@ github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ= github.com/guregu/null/v6 v6.0.0/go.mod h1:hrMIhIfrOZeLPZhROSn149tpw2gHkidAqxoXNyeX3iQ=
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA= github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto5zjQ3/VEnDM5rPbqIFuOhS0U0ByeA=
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.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs= github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI=
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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
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.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@ -79,7 +77,6 @@ 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/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/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=
@ -91,8 +88,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
github.com/moby/sys/mountinfo v0.6.2/go.mod h1:IJb6JQeOklcdMU9F5xQ8ZALD+CUr5VlGpwtX+VE0rpI= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
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=
@ -100,71 +97,70 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
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.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM= github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU= github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8= github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.21.0/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg= github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 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/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 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= 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.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE= github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= 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 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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=
@ -179,25 +175,23 @@ 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=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8= go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE= go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw= golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE= golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE= golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/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.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.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 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=

View File

@ -57,7 +57,22 @@ func TestValidateConfig(t *testing.T) {
} }
} }
func TestValidateIPv4StaticConfigRequired(t *testing.T) { func TestValidateIPv4StaticConfigNetmaskRequiredIfStatic(t *testing.T) {
config := &testNetworkConfig{
IPv4Static: &testIPv4StaticConfig{
Address: null.StringFrom("192.168.1.1"),
Gateway: null.StringFrom("192.168.1.1"),
},
IPv4Mode: null.StringFrom("static"),
}
err := SetDefaultsAndValidate(config)
if err == nil {
t.Fatalf("expected error, got nil")
}
}
func TestValidateIPv4StaticConfigNetmaskNotRequiredIfStatic(t *testing.T) {
config := &testNetworkConfig{ config := &testNetworkConfig{
IPv4Static: &testIPv4StaticConfig{ IPv4Static: &testIPv4StaticConfig{
Address: null.StringFrom("192.168.1.1"), Address: null.StringFrom("192.168.1.1"),
@ -66,8 +81,8 @@ func TestValidateIPv4StaticConfigRequired(t *testing.T) {
} }
err := SetDefaultsAndValidate(config) err := SetDefaultsAndValidate(config)
if err == nil { if err != nil {
t.Fatalf("expected error, got nil") t.Fatalf("expected no error, got %v", err)
} }
} }

View File

@ -95,16 +95,27 @@ func (t *TimeSync) queryMultipleHttp(urls []string, timeout time.Duration) (now
} else if errors.Is(err, context.Canceled) { } else if errors.Is(err, context.Canceled) {
metricHttpCancelCount.WithLabelValues(url).Inc() metricHttpCancelCount.WithLabelValues(url).Inc()
metricHttpTotalCancelCount.Inc() metricHttpTotalCancelCount.Inc()
results <- nil
} else { } else {
scopedLogger.Warn(). scopedLogger.Warn().
Str("error", err.Error()). Str("error", err.Error()).
Int("status", status). Int("status", status).
Msg("failed to query HTTP server") Msg("failed to query HTTP server")
results <- nil
} }
}(url) }(url)
} }
return <-results for range urls {
result := <-results
if result == nil {
continue
}
now = result
return
}
return
} }
func queryHttpTime( func queryHttpTime(

View File

@ -14,44 +14,44 @@ var (
) )
metricTimeSyncCount = promauto.NewCounter( metricTimeSyncCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_count", Name: "jetkvm_timesync_total",
Help: "The number of times the timesync has been run", Help: "The number of times the timesync has been run",
}, },
) )
metricTimeSyncSuccessCount = promauto.NewCounter( metricTimeSyncSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_success_count", Name: "jetkvm_timesync_success_total",
Help: "The number of times the timesync has been successful", Help: "The number of times the timesync has been successful",
}, },
) )
metricRTCUpdateCount = promauto.NewCounter( //nolint:unused metricRTCUpdateCount = promauto.NewCounter( //nolint:unused
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_rtc_update_count", Name: "jetkvm_timesync_rtc_update_total",
Help: "The number of times the RTC has been updated", Help: "The number of times the RTC has been updated",
}, },
) )
metricNtpTotalSuccessCount = promauto.NewCounter( metricNtpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_success_count", Name: "jetkvm_timesync_ntp_total_success_total",
Help: "The total number of successful NTP requests", Help: "The total number of successful NTP requests",
}, },
) )
metricNtpTotalRequestCount = promauto.NewCounter( metricNtpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_request_count", Name: "jetkvm_timesync_ntp_total_request_total",
Help: "The total number of NTP requests sent", Help: "The total number of NTP requests sent",
}, },
) )
metricNtpSuccessCount = promauto.NewCounterVec( metricNtpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_success_count", Name: "jetkvm_timesync_ntp_success_total",
Help: "The number of successful NTP requests", Help: "The number of successful NTP requests",
}, },
[]string{"url"}, []string{"url"},
) )
metricNtpRequestCount = promauto.NewCounterVec( metricNtpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_request_count", Name: "jetkvm_timesync_ntp_request_total",
Help: "The number of NTP requests sent to the server", Help: "The number of NTP requests sent to the server",
}, },
[]string{"url"}, []string{"url"},
@ -83,39 +83,39 @@ var (
metricHttpTotalSuccessCount = promauto.NewCounter( metricHttpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_success_count", Name: "jetkvm_timesync_http_total_success_total",
Help: "The total number of successful HTTP requests", Help: "The total number of successful HTTP requests",
}, },
) )
metricHttpTotalRequestCount = promauto.NewCounter( metricHttpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_request_count", Name: "jetkvm_timesync_http_total_request_total",
Help: "The total number of HTTP requests sent", Help: "The total number of HTTP requests sent",
}, },
) )
metricHttpTotalCancelCount = promauto.NewCounter( metricHttpTotalCancelCount = promauto.NewCounter(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_cancel_count", Name: "jetkvm_timesync_http_total_cancel_total",
Help: "The total number of HTTP requests cancelled", Help: "The total number of HTTP requests cancelled",
}, },
) )
metricHttpSuccessCount = promauto.NewCounterVec( metricHttpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_success_count", Name: "jetkvm_timesync_http_success_total",
Help: "The number of successful HTTP requests", Help: "The number of successful HTTP requests",
}, },
[]string{"url"}, []string{"url"},
) )
metricHttpRequestCount = promauto.NewCounterVec( metricHttpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_request_count", Name: "jetkvm_timesync_http_request_total",
Help: "The number of HTTP requests sent to the server", Help: "The number of HTTP requests sent to the server",
}, },
[]string{"url"}, []string{"url"},
) )
metricHttpCancelCount = promauto.NewCounterVec( metricHttpCancelCount = promauto.NewCounterVec(
prometheus.CounterOpts{ prometheus.CounterOpts{
Name: "jetkvm_timesync_http_cancel_count", Name: "jetkvm_timesync_http_cancel_total",
Help: "The number of HTTP requests cancelled", Help: "The number of HTTP requests cancelled",
}, },
[]string{"url"}, []string{"url"},

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"time" "time"
"github.com/fsnotify/fsnotify" "github.com/fsnotify/fsnotify"
@ -149,6 +150,12 @@ func (c *DHCPClient) loadLeaseFile() error {
} }
isFirstLoad := c.lease == nil isFirstLoad := c.lease == nil
// Skip processing if lease hasn't changed to avoid unnecessary wake-ups.
if reflect.DeepEqual(c.lease, lease) {
return nil
}
c.lease = lease c.lease = lease
if lease.IPAddress == nil { if lease.IPAddress == nil {

View File

@ -0,0 +1,436 @@
package usbgadget
import (
"bytes"
"fmt"
"os"
"path/filepath"
"reflect"
"time"
"github.com/prometheus/procfs"
"github.com/sourcegraph/tf-dag/dag"
)
// it's a minimalistic implementation of ansible's file module with some modifications
// to make it more suitable for our use case
// https://docs.ansible.com/ansible/latest/modules/file_module.html
// we use this to check if the files in the gadget config are in the expected state
// and to update them if they are not in the expected state
type FileState uint8
type ChangeState uint8
type FileChangeResolvedAction uint8
type ApplyFunc func(c *ChangeSet, changes []*FileChange) error
const (
FileStateUnknown FileState = iota
FileStateAbsent
FileStateDirectory
FileStateFile
FileStateFileContentMatch
FileStateFileWrite // update file content without checking
FileStateMounted
FileStateMountedConfigFS
FileStateSymlink
FileStateSymlinkInOrderConfigFS // configfs is a shithole, so we need to check if the symlinks are created in the correct order
FileStateSymlinkNotInOrderConfigFS
FileStateTouch
)
var FileStateString = map[FileState]string{
FileStateUnknown: "UNKNOWN",
FileStateAbsent: "ABSENT",
FileStateDirectory: "DIRECTORY",
FileStateFile: "FILE",
FileStateFileContentMatch: "FILE_CONTENT_MATCH",
FileStateFileWrite: "FILE_WRITE",
FileStateMounted: "MOUNTED",
FileStateMountedConfigFS: "CONFIGFS_MOUNTED",
FileStateSymlink: "SYMLINK",
FileStateSymlinkInOrderConfigFS: "SYMLINK_IN_ORDER_CONFIGFS",
FileStateTouch: "TOUCH",
}
const (
ChangeStateUnknown ChangeState = iota
ChangeStateRequired
ChangeStateNotChanged
ChangeStateChanged
ChangeStateError
)
const (
FileChangeResolvedActionUnknown FileChangeResolvedAction = iota
FileChangeResolvedActionDoNothing
FileChangeResolvedActionRemove
FileChangeResolvedActionCreateFile
FileChangeResolvedActionWriteFile
FileChangeResolvedActionUpdateFile
FileChangeResolvedActionAppendFile
FileChangeResolvedActionCreateSymlink
FileChangeResolvedActionRecreateSymlink
FileChangeResolvedActionCreateDirectoryAndSymlinks
FileChangeResolvedActionReorderSymlinks
FileChangeResolvedActionCreateDirectory
FileChangeResolvedActionRemoveDirectory
FileChangeResolvedActionTouch
FileChangeResolvedActionMountConfigFS
)
var FileChangeResolvedActionString = map[FileChangeResolvedAction]string{
FileChangeResolvedActionUnknown: "UNKNOWN",
FileChangeResolvedActionDoNothing: "DO_NOTHING",
FileChangeResolvedActionRemove: "REMOVE",
FileChangeResolvedActionCreateFile: "FILE_CREATE",
FileChangeResolvedActionWriteFile: "FILE_WRITE",
FileChangeResolvedActionUpdateFile: "FILE_UPDATE",
FileChangeResolvedActionAppendFile: "FILE_APPEND",
FileChangeResolvedActionCreateSymlink: "SYMLINK_CREATE",
FileChangeResolvedActionRecreateSymlink: "SYMLINK_RECREATE",
FileChangeResolvedActionCreateDirectoryAndSymlinks: "DIR_CREATE_AND_SYMLINKS",
FileChangeResolvedActionReorderSymlinks: "SYMLINK_REORDER",
FileChangeResolvedActionCreateDirectory: "DIR_CREATE",
FileChangeResolvedActionRemoveDirectory: "DIR_REMOVE",
FileChangeResolvedActionTouch: "TOUCH",
FileChangeResolvedActionMountConfigFS: "CONFIGFS_MOUNT",
}
type ChangeSet struct {
Changes []FileChange
}
type RequestedFileChange struct {
Component string
Key string
Path string // will be used as Key if Key is empty
ParamSymlinks []symlink
ExpectedState FileState
ExpectedContent []byte
DependsOn []string
BeforeChange []string // if the file is going to be changed, apply the change first
Description string
IgnoreErrors bool
When string // only apply the change if when meets the condition
}
type FileChange struct {
RequestedFileChange
ActualState FileState
ActualContent []byte
resolvedDeps []string
checked bool
changed ChangeState
action FileChangeResolvedAction
}
func (f *RequestedFileChange) String() string {
var s string
switch f.ExpectedState {
case FileStateDirectory:
s = fmt.Sprintf("dir: %s", f.Path)
case FileStateFile:
s = fmt.Sprintf("file: %s", f.Path)
case FileStateSymlink:
s = fmt.Sprintf("symlink: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkInOrderConfigFS:
s = fmt.Sprintf("symlink_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateSymlinkNotInOrderConfigFS:
s = fmt.Sprintf("symlink_not_in_order_configfs: %s -> %s", f.Path, f.ExpectedContent)
case FileStateAbsent:
s = fmt.Sprintf("absent: %s", f.Path)
case FileStateFileContentMatch:
s = fmt.Sprintf("file: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateFileWrite:
s = fmt.Sprintf("write: %s with content [%s]", f.Path, f.ExpectedContent)
case FileStateMountedConfigFS:
s = fmt.Sprintf("configfs: %s", f.Path)
case FileStateTouch:
s = fmt.Sprintf("touch: %s", f.Path)
case FileStateUnknown:
s = fmt.Sprintf("unknown change for %s", f.Path)
default:
s = fmt.Sprintf("unknown expected state %d for %s", f.ExpectedState, f.Path)
}
if len(f.Description) > 0 {
s += fmt.Sprintf(" (%s)", f.Description)
}
return s
}
func (f *RequestedFileChange) IsSame(other *RequestedFileChange) bool {
return f.Path == other.Path &&
f.ExpectedState == other.ExpectedState &&
reflect.DeepEqual(f.ExpectedContent, other.ExpectedContent) &&
reflect.DeepEqual(f.DependsOn, other.DependsOn) &&
f.IgnoreErrors == other.IgnoreErrors
}
func (fc *FileChange) checkIfDirIsMountPoint() error {
// check if the file is a mount point
mounts, err := procfs.GetMounts()
if err != nil {
return fmt.Errorf("failed to get mounts")
}
for _, mount := range mounts {
if mount.MountPoint == fc.Path {
fc.ActualState = FileStateMounted
fc.ActualContent = []byte(mount.Source)
if mount.FSType == "configfs" {
fc.ActualState = FileStateMountedConfigFS
}
return nil
}
}
return nil
}
// GetActualState returns the actual state of the file at the given path.
func (fc *FileChange) getActualState() error {
l := defaultLogger.With().Str("path", fc.Path).Logger()
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
fc.ActualState = FileStateAbsent
} else {
l.Warn().Err(err).Msg("failed to stat file")
fc.ActualState = FileStateUnknown
}
return nil
}
// check if the file is a symlink
if fi.Mode()&os.ModeSymlink == os.ModeSymlink {
fc.ActualState = FileStateSymlink
// get the target of the symlink
target, err := os.Readlink(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read symlink")
return fmt.Errorf("failed to read symlink")
}
// check if the target is a relative path
if !filepath.IsAbs(target) {
// make it absolute
target, err = filepath.Abs(filepath.Join(filepath.Dir(fc.Path), target))
if err != nil {
l.Warn().Err(err).Msg("failed to make symlink target absolute")
return fmt.Errorf("failed to make symlink target absolute")
}
}
fc.ActualContent = []byte(target)
return nil
}
if fi.IsDir() {
fc.ActualState = FileStateDirectory
switch fc.ExpectedState {
case FileStateMountedConfigFS:
err := fc.checkIfDirIsMountPoint()
if err != nil {
l.Warn().Err(err).Msg("failed to check if dir is mount point")
return err
}
case FileStateSymlinkInOrderConfigFS:
state, err := checkIfSymlinksInOrder(fc, &l)
if err != nil {
l.Warn().Err(err).Msg("failed to check if symlinks are in order")
return err
}
fc.ActualState = state
}
return nil
}
if fi.Mode()&os.ModeDevice == os.ModeDevice {
l.Info().Msg("file is a device")
return nil
}
// check if the file is a regular file
if fi.Mode().IsRegular() {
fc.ActualState = FileStateFile
// get the content of the file
content, err := os.ReadFile(fc.Path)
if err != nil {
l.Warn().Err(err).Msg("failed to read file")
return fmt.Errorf("failed to read file")
}
fc.ActualContent = content
return nil
}
l.Warn().Interface("file_info", fi.Mode()).Bool("is_dir", fi.IsDir()).Msg("unknown file type")
return fmt.Errorf("unknown file type")
}
func (fc *FileChange) ResetActionResolution() {
fc.checked = false
fc.action = FileChangeResolvedActionUnknown
fc.changed = ChangeStateUnknown
}
func (fc *FileChange) Action() FileChangeResolvedAction {
if !fc.checked {
fc.action = fc.getFileChangeResolvedAction()
fc.checked = true
}
return fc.action
}
func (fc *FileChange) getFileChangeResolvedAction() FileChangeResolvedAction {
l := defaultLogger.With().Str("path", fc.Path).Logger()
// some actions are not needed to be checked
switch fc.ExpectedState {
case FileStateFileWrite:
return FileChangeResolvedActionWriteFile
case FileStateTouch:
return FileChangeResolvedActionTouch
}
// get the actual state of the file
err := fc.getActualState()
if err != nil {
return FileChangeResolvedActionDoNothing
}
baseName := filepath.Base(fc.Path)
switch fc.ExpectedState {
case FileStateDirectory:
// if the file is already a directory, do nothing
if fc.ActualState == FileStateDirectory {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateDirectory
case FileStateFile:
// if the file is already a file, do nothing
if fc.ActualState == FileStateFile {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionCreateFile
case FileStateFileContentMatch:
// if the file is already a file with the expected content, do nothing
if fc.ActualState == FileStateFile {
looserMatch := baseName == "inquiry_string"
if compareFileContent(fc.ActualContent, fc.ExpectedContent, looserMatch) {
return FileChangeResolvedActionDoNothing
}
// TODO: move this to somewhere else
// this is a workaround for the fact that the file is not updated if it has no content
if baseName == "file" &&
bytes.Equal(fc.ActualContent, []byte{}) &&
bytes.Equal(fc.ExpectedContent, []byte{0x0a}) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionUpdateFile
}
return FileChangeResolvedActionCreateFile
case FileStateSymlink:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlink {
if reflect.DeepEqual(fc.ActualContent, fc.ExpectedContent) {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRecreateSymlink
}
return FileChangeResolvedActionCreateSymlink
case FileStateSymlinkInOrderConfigFS:
// if the file is already a symlink, check if the target is the same
if fc.ActualState == FileStateSymlinkInOrderConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionReorderSymlinks
case FileStateAbsent:
if fc.ActualState == FileStateAbsent {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionRemove
case FileStateMountedConfigFS:
if fc.ActualState == FileStateMountedConfigFS {
return FileChangeResolvedActionDoNothing
}
return FileChangeResolvedActionMountConfigFS
default:
l.Warn().Interface("file_change", FileStateString[fc.ExpectedState]).Msg("unknown expected state")
return FileChangeResolvedActionDoNothing
}
}
func (c *ChangeSet) AddFileChangeStruct(r RequestedFileChange) {
fc := FileChange{
RequestedFileChange: r,
}
c.Changes = append(c.Changes, fc)
}
func (c *ChangeSet) AddFileChange(component string, path string, expectedState FileState, expectedContent []byte, dependsOn []string, description string) {
c.AddFileChangeStruct(RequestedFileChange{
Component: component,
Path: path,
ExpectedState: expectedState,
ExpectedContent: expectedContent,
DependsOn: dependsOn,
Description: description,
})
}
func (c *ChangeSet) ApplyChanges() error {
r := ChangeSetResolver{
changeset: c,
g: &dag.AcyclicGraph{},
l: defaultLogger,
}
return r.Apply()
}
func (c *ChangeSet) applyChange(change *FileChange) error {
switch change.Action() {
case FileChangeResolvedActionWriteFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionUpdateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateFile:
return os.WriteFile(change.Path, change.ExpectedContent, 0644)
case FileChangeResolvedActionCreateSymlink:
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionRecreateSymlink:
if err := os.Remove(change.Path); err != nil {
return fmt.Errorf("failed to remove symlink: %w", err)
}
return os.Symlink(string(change.ExpectedContent), change.Path)
case FileChangeResolvedActionReorderSymlinks:
return recreateSymlinks(change, nil)
case FileChangeResolvedActionCreateDirectory:
return os.MkdirAll(change.Path, 0755)
case FileChangeResolvedActionRemove:
return os.Remove(change.Path)
case FileChangeResolvedActionRemoveDirectory:
return os.RemoveAll(change.Path)
case FileChangeResolvedActionTouch:
return os.Chtimes(change.Path, time.Now(), time.Now())
case FileChangeResolvedActionMountConfigFS:
return mountConfigFS(change.Path)
case FileChangeResolvedActionDoNothing:
return nil
default:
return fmt.Errorf("unknown action: %d", change.Action())
}
}
func (c *ChangeSet) Apply() error {
return c.ApplyChanges()
}

View File

@ -0,0 +1,115 @@
//go:build arm && linux
package usbgadget
import (
"os"
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
var (
usbConfig = &Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Product: "USB Emulation Device",
strictMode: true,
}
usbDevices = &Devices{
AbsoluteMouse: true,
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
}
usbGadgetName = "jetkvm"
usbGadget *UsbGadget
)
var oldAbsoluteMouseCombinedReportDesc = []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 TestUsbGadgetInit(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
}
func TestUsbGadgetStrictModeInitFail(t *testing.T) {
usbConfig.strictMode = true
u := NewUsbGadget("test", usbDevices, usbConfig, nil)
assert.Nil(t, u, "should be nil")
}
func TestUsbGadgetUDCNotBoundAfterReportDescrChanged(t *testing.T) {
assert := assert.New(t)
usbGadget = NewUsbGadget(usbGadgetName, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
// release the usb gadget and create a new one
usbGadget = nil
altGadgetConfig := defaultGadgetConfig
oldAbsoluteMouseConfig := altGadgetConfig["absolute_mouse"]
oldAbsoluteMouseConfig.reportDesc = oldAbsoluteMouseCombinedReportDesc
altGadgetConfig["absolute_mouse"] = oldAbsoluteMouseConfig
usbGadget = newUsbGadget(usbGadgetName, altGadgetConfig, usbDevices, usbConfig, nil)
assert.NotNil(usbGadget)
udcs := getUdcs()
assert.Equal(1, len(udcs), "should be only one UDC")
// check if the UDC is bound
udc := udcs[0]
assert.NotNil(udc, "UDC should exist")
udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/jetkvm/UDC")
assert.Nil(err, "usb_gadget/UDC should exist")
assert.Equal(strings.TrimSpace(udc), strings.TrimSpace(string(udcStr)), "UDC should be the same")
}

View File

@ -0,0 +1,192 @@
package usbgadget
import (
"fmt"
"github.com/rs/zerolog"
"github.com/sourcegraph/tf-dag/dag"
)
type ChangeSetResolver struct {
changeset *ChangeSet
l *zerolog.Logger
g *dag.AcyclicGraph
changesMap map[string]*FileChange
conditionalChangesMap map[string]*FileChange
orderedChanges []dag.Vertex
resolvedChanges []*FileChange
additionalResolveRequired bool
}
func (c *ChangeSetResolver) toOrderedChanges() error {
for key, change := range c.changesMap {
v := c.g.Add(key)
for _, dependsOn := range change.DependsOn {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
for _, dependsOn := range change.resolvedDeps {
c.g.Connect(dag.BasicEdge(dependsOn, v))
}
}
cycles := c.g.Cycles()
if len(cycles) > 0 {
return fmt.Errorf("cycles detected: %v", cycles)
}
orderedChanges := c.g.TopologicalOrder()
c.orderedChanges = orderedChanges
return nil
}
func (c *ChangeSetResolver) doResolveChanges(initial bool) error {
resolvedChanges := make([]*FileChange, 0)
for _, key := range c.orderedChanges {
change := c.changesMap[key.(string)]
if change == nil {
c.l.Error().Str("key", key.(string)).Msg("fileChange not found")
continue
}
if !initial {
change.ResetActionResolution()
}
resolvedAction := change.Action()
resolvedChanges = append(resolvedChanges, change)
// no need to check the triggers if there's no change
if resolvedAction == FileChangeResolvedActionDoNothing {
continue
}
if !initial {
continue
}
if change.BeforeChange != nil {
change.resolvedDeps = append(change.resolvedDeps, change.BeforeChange...)
c.additionalResolveRequired = true
// add the dependencies to the changes map
for _, dep := range change.BeforeChange {
depChange, ok := c.conditionalChangesMap[dep]
if !ok {
return fmt.Errorf("dependency %s not found", dep)
}
c.changesMap[dep] = depChange
}
}
}
c.resolvedChanges = resolvedChanges
return nil
}
func (c *ChangeSetResolver) resolveChanges(initial bool) error {
// get the ordered changes
err := c.toOrderedChanges()
if err != nil {
return err
}
// resolve the changes
err = c.doResolveChanges(initial)
if err != nil {
return err
}
for _, change := range c.resolvedChanges {
c.l.Trace().Str("change", change.String()).Msg("resolved change")
}
if !c.additionalResolveRequired || !initial {
return nil
}
return c.resolveChanges(false)
}
func (c *ChangeSetResolver) applyChanges() error {
for _, change := range c.resolvedChanges {
change.ResetActionResolution()
action := change.Action()
actionStr := FileChangeResolvedActionString[action]
l := c.l.Info()
if action == FileChangeResolvedActionDoNothing {
l = c.l.Trace()
}
l.Str("action", actionStr).Str("change", change.String()).Msg("applying change")
err := c.changeset.applyChange(change)
if err != nil {
if change.IgnoreErrors {
c.l.Warn().Str("change", change.String()).Err(err).Msg("ignoring error")
} else {
return err
}
}
}
return nil
}
func (c *ChangeSetResolver) GetChanges() ([]*FileChange, error) {
localChanges := c.changeset.Changes
changesMap := make(map[string]*FileChange)
conditionalChangesMap := make(map[string]*FileChange)
// build the map of the changes
for _, change := range localChanges {
key := change.Key
if key == "" {
key = change.Path
}
// remove it from the map first
if change.When != "" {
conditionalChangesMap[key] = &change
continue
}
if _, ok := changesMap[key]; ok {
if changesMap[key].IsSame(&change.RequestedFileChange) {
continue
}
return nil, fmt.Errorf(
"duplicate change: %s, current: %s, requested: %s",
key,
changesMap[key].String(),
change.String(),
)
}
changesMap[key] = &change
}
c.changesMap = changesMap
c.conditionalChangesMap = conditionalChangesMap
err := c.resolveChanges(true)
if err != nil {
return nil, err
}
return c.resolvedChanges, nil
}
func (c *ChangeSetResolver) Apply() error {
if _, err := c.GetChanges(); err != nil {
return err
}
return c.applyChanges()
}

View File

@ -0,0 +1,136 @@
package usbgadget
import (
"fmt"
"os"
"path"
"path/filepath"
"reflect"
"github.com/rs/zerolog"
)
type symlink struct {
Path string
Target string
}
func compareSymlinks(expected []symlink, actual []symlink) bool {
if len(expected) != len(actual) {
return false
}
return reflect.DeepEqual(expected, actual)
}
func checkIfSymlinksInOrder(fc *FileChange, logger *zerolog.Logger) (FileState, error) {
if logger == nil {
logger = defaultLogger
}
l := logger.With().Str("path", fc.Path).Logger()
if len(fc.ParamSymlinks) == 0 {
return FileStateUnknown, fmt.Errorf("no symlinks to check")
}
fi, err := os.Lstat(fc.Path)
if err != nil {
if os.IsNotExist(err) {
return FileStateAbsent, nil
} else {
l.Warn().Err(err).Msg("failed to stat file")
return FileStateUnknown, fmt.Errorf("failed to stat file")
}
}
if !fi.IsDir() {
return FileStateUnknown, fmt.Errorf("file is not a directory")
}
files, err := os.ReadDir(fc.Path)
symlinks := make([]symlink, 0)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read directory")
}
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
path := filepath.Join(fc.Path, file.Name())
target, err := os.Readlink(path)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to read symlink")
}
if !filepath.IsAbs(target) {
target = filepath.Join(fc.Path, target)
newTarget, err := filepath.Abs(target)
if err != nil {
return FileStateUnknown, fmt.Errorf("failed to get absolute path")
}
target = newTarget
}
symlinks = append(symlinks, symlink{
Path: path,
Target: target,
})
}
// compare the symlinks with the expected symlinks
if compareSymlinks(fc.ParamSymlinks, symlinks) {
return FileStateSymlinkInOrderConfigFS, nil
}
l.Trace().Interface("expected", fc.ParamSymlinks).Interface("actual", symlinks).Msg("symlinks are not in order")
return FileStateSymlinkNotInOrderConfigFS, nil
}
func recreateSymlinks(fc *FileChange, logger *zerolog.Logger) error {
if logger == nil {
logger = defaultLogger
}
// remove all symlinks
files, err := os.ReadDir(fc.Path)
if err != nil {
return fmt.Errorf("failed to read directory")
}
l := logger.With().Str("path", fc.Path).Logger()
l.Info().Msg("recreate symlinks")
for _, file := range files {
if file.Type()&os.ModeSymlink != os.ModeSymlink {
continue
}
l.Info().Str("name", file.Name()).Msg("remove symlink")
err := os.Remove(path.Join(fc.Path, file.Name()))
if err != nil {
return fmt.Errorf("failed to remove symlink")
}
}
l.Info().Interface("param-symlinks", fc.ParamSymlinks).Msg("create symlinks")
// create the symlinks
for _, symlink := range fc.ParamSymlinks {
l.Info().Str("name", symlink.Path).Str("target", symlink.Target).Msg("create symlink")
path := symlink.Path
if !filepath.IsAbs(path) {
path = filepath.Join(fc.Path, path)
}
err := os.Symlink(symlink.Target, path)
if err != nil {
l.Warn().Err(err).Msg("failed to create symlink")
return fmt.Errorf("failed to create symlink")
}
}
return nil
}

View File

@ -2,11 +2,7 @@ package usbgadget
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path"
"path/filepath"
"sort"
) )
type gadgetConfigItem struct { type gadgetConfigItem struct {
@ -179,21 +175,11 @@ func (u *UsbGadget) OverrideGadgetConfig(itemKey string, itemAttr string, value
return nil, true return nil, true
} }
func mountConfigFS() error { func mountConfigFS(path string) error {
_, err := os.Stat(gadgetPath) err := exec.Command("mount", "-t", "configfs", "none", path).Run()
// 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 { if err != nil {
return fmt.Errorf("failed to mount configfs: %w", err) return fmt.Errorf("failed to mount configfs: %w", err)
} }
} else {
return fmt.Errorf("unable to access usb gadget path: %w", err)
}
return nil return nil
} }
@ -205,26 +191,14 @@ func (u *UsbGadget) Init() error {
udcs := getUdcs() udcs := getUdcs()
if len(udcs) < 1 { if len(udcs) < 1 {
u.log.Error().Msg("no udc found, skipping USB stack init") return u.logWarn("no udc found, skipping USB stack init", nil)
return nil
} }
u.udc = udcs[0] 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 { err := u.configureUsbGadget(false)
u.log.Error().Err(err).Msg("failed to mount configfs, usb stack might not function properly") if err != nil {
} return u.logError("unable to initialize USB stack", err)
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 return nil
@ -236,143 +210,22 @@ func (u *UsbGadget) UpdateGadgetConfig() error {
u.loadGadgetConfig() u.loadGadgetConfig()
if err := u.writeGadgetConfig(); err != nil { err := u.configureUsbGadget(true)
u.log.Error().Err(err).Msg("failed to update gadget") if err != nil {
return u.logError("unable to update gadget config", err)
} }
return nil return nil
} }
func (u *UsbGadget) getOrderedConfigItems() orderedGadgetConfigItems { func (u *UsbGadget) configureUsbGadget(resetUsb bool) error {
items := make([]gadgetConfigItemWithKey, 0) return u.WithTransaction(func() error {
for key, item := range u.configMap { u.tx.MountConfigFS()
items = append(items, gadgetConfigItemWithKey{key, item}) u.tx.CreateConfigPath()
u.tx.WriteGadgetConfig()
if resetUsb {
u.tx.RebindUsb(true)
} }
return nil
sort.Slice(items, func(i, j int) bool {
return items[i].item.order < items[j].item.order
}) })
return items
}
func (u *UsbGadget) writeGadgetConfig() error {
// create kvm gadget path
err := os.MkdirAll(u.kvmGadgetPath, 0755)
if err != nil {
return err
}
u.log.Trace().Msg("writing gadget config")
for _, val := range u.getOrderedConfigItems() {
key := val.key
item := val.item
// check if the item is enabled in the config
if !u.isGadgetConfigItemEnabled(key) {
u.log.Trace().Str("key", key).Msg("disabling gadget config")
err = u.disableGadgetItemConfig(item)
if err != nil {
return err
}
continue
}
u.log.Trace().Str("key", key).Msg("writing gadget config")
err = u.writeGadgetItemConfig(item)
if err != nil {
return err
}
}
if err = u.writeUDC(); err != nil {
u.log.Error().Err(err).Msg("failed to write UDC")
return err
}
if err = u.rebindUsb(true); err != nil {
u.log.Info().Err(err).Msg("failed to rebind usb")
}
return nil
}
func (u *UsbGadget) disableGadgetItemConfig(item gadgetConfigItem) error {
// remove symlink if exists
if item.configPath == nil {
return nil
}
configPath := joinPath(u.configC1Path, item.configPath)
if _, err := os.Lstat(configPath); os.IsNotExist(err) {
u.log.Trace().Str("path", configPath).Msg("symlink does not exist")
return nil
}
if err := os.Remove(configPath); err != nil {
return fmt.Errorf("failed to remove symlink %s: %w", item.configPath, err)
}
return nil
}
func (u *UsbGadget) writeGadgetItemConfig(item gadgetConfigItem) error {
// create directory for the item
gadgetItemPath := joinPath(u.kvmGadgetPath, item.path)
err := os.MkdirAll(gadgetItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", gadgetItemPath, err)
}
if len(item.attrs) > 0 {
// write attributes for the item
err = u.writeGadgetAttrs(gadgetItemPath, item.attrs)
if err != nil {
return fmt.Errorf("failed to write attributes for %s: %w", gadgetItemPath, err)
}
}
// write report descriptor if available
if item.reportDesc != nil {
err = u.writeIfDifferent(path.Join(gadgetItemPath, "report_desc"), item.reportDesc, 0644)
if err != nil {
return err
}
}
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(u.configC1Path, item.configPath)
err = os.MkdirAll(configItemPath, 0755)
if err != nil {
return fmt.Errorf("failed to create path %s: %w", configItemPath, err)
}
err = u.writeGadgetAttrs(configItemPath, item.configAttrs)
if err != nil {
return fmt.Errorf("failed to write config attributes for %s: %w", configItemPath, err)
}
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(u.configC1Path, item.configPath)
u.log.Trace().Str("source", configPath).Str("target", gadgetItemPath).Msg("creating symlink")
if err := ensureSymlink(configPath, gadgetItemPath); err != nil {
return err
}
}
return nil
}
func (u *UsbGadget) writeGadgetAttrs(basePath string, attrs gadgetAttributes) error {
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
err := u.writeIfDifferent(filePath, []byte(val), 0644)
if err != nil {
return fmt.Errorf("failed to write to %s: %w", filePath, err)
}
}
return nil
} }

View File

@ -0,0 +1,349 @@
package usbgadget
import (
"fmt"
"path"
"path/filepath"
"sort"
"github.com/rs/zerolog"
)
// no os package should occur in this file
type UsbGadgetTransaction struct {
c *ChangeSet
// below are the fields that are needed to be set by the caller
log *zerolog.Logger
udc string
dwc3Path string
kvmGadgetPath string
configC1Path string
orderedConfigItems orderedGadgetConfigItems
isGadgetConfigItemEnabled func(key string) bool
reorderSymlinkChanges *RequestedFileChange
}
func (u *UsbGadget) newUsbGadgetTransaction(lock bool) error {
if lock {
u.txLock.Lock()
defer u.txLock.Unlock()
}
if u.tx != nil {
return fmt.Errorf("transaction already exists")
}
tx := &UsbGadgetTransaction{
c: &ChangeSet{},
log: u.log,
udc: u.udc,
dwc3Path: dwc3Path,
kvmGadgetPath: u.kvmGadgetPath,
configC1Path: u.configC1Path,
orderedConfigItems: u.getOrderedConfigItems(),
isGadgetConfigItemEnabled: u.isGadgetConfigItemEnabled,
}
u.tx = tx
return nil
}
func (u *UsbGadget) WithTransaction(fn func() error) error {
u.txLock.Lock()
defer u.txLock.Unlock()
err := u.newUsbGadgetTransaction(false)
if err != nil {
u.log.Error().Err(err).Msg("failed to create transaction")
return err
}
if err := fn(); err != nil {
u.log.Error().Err(err).Msg("transaction failed")
return err
}
result := u.tx.Commit()
u.tx = nil
return result
}
func (tx *UsbGadgetTransaction) addFileChange(component string, change RequestedFileChange) string {
change.Component = component
tx.c.AddFileChangeStruct(change)
key := change.Key
if key == "" {
key = change.Path
}
return key
}
func (tx *UsbGadgetTransaction) mkdirAll(component string, path string, description string, deps []string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateDirectory,
Description: description,
DependsOn: deps,
})
}
func (tx *UsbGadgetTransaction) removeFile(component string, path string, description string) string {
return tx.addFileChange(component, RequestedFileChange{
Path: path,
ExpectedState: FileStateAbsent,
Description: description,
})
}
func (tx *UsbGadgetTransaction) Commit() error {
tx.addFileChange("gadget-finalize", *tx.reorderSymlinkChanges)
err := tx.c.Apply()
if err != nil {
tx.log.Error().Err(err).Msg("failed to update usbgadget configuration")
return err
}
tx.log.Info().Msg("usbgadget configuration updated")
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 (tx *UsbGadgetTransaction) MountConfigFS() {
tx.addFileChange("gadget", RequestedFileChange{
Path: configFSPath,
ExpectedState: FileStateMountedConfigFS,
Description: "mount configfs",
})
}
func (tx *UsbGadgetTransaction) CreateConfigPath() {
tx.mkdirAll(
"gadget",
tx.configC1Path,
"create config path",
[]string{configFSPath},
)
}
func (tx *UsbGadgetTransaction) WriteGadgetConfig() {
// create kvm gadget path
tx.mkdirAll(
"gadget",
tx.kvmGadgetPath,
"create kvm gadget path",
[]string{tx.configC1Path},
)
deps := make([]string, 0)
deps = append(deps, tx.kvmGadgetPath)
for _, val := range tx.orderedConfigItems {
key := val.key
item := val.item
// check if the item is enabled in the config
if !tx.isGadgetConfigItemEnabled(key) {
tx.DisableGadgetItemConfig(item)
continue
}
deps = tx.writeGadgetItemConfig(item, deps)
}
tx.WriteUDC()
}
func (tx *UsbGadgetTransaction) getDisableKeys() []string {
disableKeys := make([]string, 0)
for _, item := range tx.orderedConfigItems {
if !tx.isGadgetConfigItemEnabled(item.key) {
continue
}
if item.item.configPath == nil || item.item.configAttrs != nil {
continue
}
disableKeys = append(disableKeys, fmt.Sprintf("disable-%s", item.item.device))
}
return disableKeys
}
func (tx *UsbGadgetTransaction) DisableGadgetItemConfig(item gadgetConfigItem) {
// remove symlink if exists
if item.configPath == nil {
return
}
configPath := joinPath(tx.configC1Path, item.configPath)
_ = tx.removeFile("gadget", configPath, "remove symlink: disable gadget config")
}
func (tx *UsbGadgetTransaction) writeGadgetItemConfig(item gadgetConfigItem, deps []string) []string {
component := item.device
// create directory for the item
files := make([]string, 0)
files = append(files, deps...)
gadgetItemPath := joinPath(tx.kvmGadgetPath, item.path)
if gadgetItemPath != tx.kvmGadgetPath {
gadgetItemDir := tx.mkdirAll(component, gadgetItemPath, "create gadget item directory", files)
files = append(files, gadgetItemDir)
}
beforeChange := make([]string, 0)
disableGadgetItemKey := fmt.Sprintf("disable-%s", item.device)
if item.configPath != nil && item.configAttrs == nil {
beforeChange = append(beforeChange, tx.getDisableKeys()...)
}
if len(item.attrs) > 0 {
// write attributes for the item
files = append(files, tx.writeGadgetAttrs(
gadgetItemPath,
item.attrs,
component,
beforeChange,
)...)
}
// write report descriptor if available
reportDescPath := path.Join(gadgetItemPath, "report_desc")
if item.reportDesc != nil {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: item.reportDesc,
Description: "write report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
} else {
tx.addFileChange(component, RequestedFileChange{
Path: reportDescPath,
ExpectedState: FileStateAbsent,
Description: "remove report descriptor",
BeforeChange: beforeChange,
DependsOn: files,
})
}
files = append(files, reportDescPath)
// create config directory if configAttrs are set
if len(item.configAttrs) > 0 {
configItemPath := joinPath(tx.configC1Path, item.configPath)
if configItemPath != tx.configC1Path {
configItemDir := tx.mkdirAll(component, configItemPath, "create config item directory", files)
files = append(files, configItemDir)
}
files = append(files, tx.writeGadgetAttrs(
configItemPath,
item.configAttrs,
component,
beforeChange,
)...)
}
// create symlink if configPath is set
if item.configPath != nil && item.configAttrs == nil {
configPath := joinPath(tx.configC1Path, item.configPath)
// the change will be only applied by `beforeChange`
tx.addFileChange(component, RequestedFileChange{
Key: disableGadgetItemKey,
Path: configPath,
ExpectedState: FileStateAbsent,
When: "beforeChange", // TODO: make it more flexible
Description: "remove symlink",
})
tx.addReorderSymlinkChange(configPath, gadgetItemPath, files)
}
return files
}
func (tx *UsbGadgetTransaction) writeGadgetAttrs(basePath string, attrs gadgetAttributes, component string, beforeChange []string) (files []string) {
files = make([]string, 0)
for key, val := range attrs {
filePath := filepath.Join(basePath, key)
tx.addFileChange(component, RequestedFileChange{
Path: filePath,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(val),
Description: "write gadget attribute",
DependsOn: []string{basePath},
BeforeChange: beforeChange,
})
files = append(files, filePath)
}
return files
}
func (tx *UsbGadgetTransaction) addReorderSymlinkChange(path string, target string, deps []string) {
tx.log.Trace().Str("path", path).Str("target", target).Msg("add reorder symlink change")
if tx.reorderSymlinkChanges == nil {
tx.reorderSymlinkChanges = &RequestedFileChange{
Component: "gadget-finalize",
Key: "reorder-symlinks",
Path: tx.configC1Path,
ExpectedState: FileStateSymlinkInOrderConfigFS,
Description: "order symlinks",
ParamSymlinks: []symlink{},
}
}
tx.reorderSymlinkChanges.DependsOn = append(tx.reorderSymlinkChanges.DependsOn, deps...)
tx.reorderSymlinkChanges.ParamSymlinks = append(tx.reorderSymlinkChanges.ParamSymlinks, symlink{
Path: path,
Target: target,
})
}
func (tx *UsbGadgetTransaction) WriteUDC() {
// bound the gadget to a UDC (USB Device Controller)
path := path.Join(tx.kvmGadgetPath, "UDC")
tx.addFileChange("udc", RequestedFileChange{
Key: "udc",
Path: path,
ExpectedState: FileStateFileContentMatch,
ExpectedContent: []byte(tx.udc),
DependsOn: []string{"reorder-symlinks"},
Description: "write UDC",
})
}
func (tx *UsbGadgetTransaction) RebindUsb(ignoreUnbindError bool) {
// remove the gadget from the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "unbind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "unbind UDC",
DependsOn: []string{"udc"},
IgnoreErrors: ignoreUnbindError,
})
// bind the gadget to the UDC
tx.addFileChange("udc", RequestedFileChange{
Path: path.Join(tx.dwc3Path, "bind"),
ExpectedState: FileStateFileWrite,
ExpectedContent: []byte(tx.udc),
Description: "bind UDC",
DependsOn: []string{path.Join(tx.dwc3Path, "unbind")},
})
}

View File

@ -1,8 +1,11 @@
package usbgadget package usbgadget
import ( import (
"context"
"fmt" "fmt"
"os" "os"
"reflect"
"time"
) )
var keyboardConfig = gadgetConfigItem{ var keyboardConfig = gadgetConfigItem{
@ -36,6 +39,7 @@ var keyboardReportDesc = []byte{
0x81, 0x03, /* INPUT (Cnst,Var,Abs) */ 0x81, 0x03, /* INPUT (Cnst,Var,Abs) */
0x95, 0x05, /* REPORT_COUNT (5) */ 0x95, 0x05, /* REPORT_COUNT (5) */
0x75, 0x01, /* REPORT_SIZE (1) */ 0x75, 0x01, /* REPORT_SIZE (1) */
0x05, 0x08, /* USAGE_PAGE (LEDs) */ 0x05, 0x08, /* USAGE_PAGE (LEDs) */
0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */ 0x19, 0x01, /* USAGE_MINIMUM (Num Lock) */
0x29, 0x05, /* USAGE_MAXIMUM (Kana) */ 0x29, 0x05, /* USAGE_MAXIMUM (Kana) */
@ -54,23 +58,155 @@ var keyboardReportDesc = []byte{
0xc0, /* END_COLLECTION */ 0xc0, /* END_COLLECTION */
} }
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error { const (
hidReadBufferSize = 8
// https://www.usb.org/sites/default/files/documents/hid1_11.pdf
// https://www.usb.org/sites/default/files/hut1_2.pdf
KeyboardLedMaskNumLock = 1 << 0
KeyboardLedMaskCapsLock = 1 << 1
KeyboardLedMaskScrollLock = 1 << 2
KeyboardLedMaskCompose = 1 << 3
KeyboardLedMaskKana = 1 << 4
ValidKeyboardLedMasks = KeyboardLedMaskNumLock | KeyboardLedMaskCapsLock | KeyboardLedMaskScrollLock | KeyboardLedMaskCompose | KeyboardLedMaskKana
)
// Synchronization between LED states and CAPS LOCK, NUM LOCK, SCROLL LOCK,
// COMPOSE, and KANA events is maintained by the host and NOT the keyboard. If
// using the keyboard descriptor in Appendix B, LED states are set by sending a
// 5-bit absolute report to the keyboard via a Set_Report(Output) request.
type KeyboardState struct {
NumLock bool `json:"num_lock"`
CapsLock bool `json:"caps_lock"`
ScrollLock bool `json:"scroll_lock"`
Compose bool `json:"compose"`
Kana bool `json:"kana"`
}
func getKeyboardState(b byte) KeyboardState {
// should we check if it's the correct usage page?
return KeyboardState{
NumLock: b&KeyboardLedMaskNumLock != 0,
CapsLock: b&KeyboardLedMaskCapsLock != 0,
ScrollLock: b&KeyboardLedMaskScrollLock != 0,
Compose: b&KeyboardLedMaskCompose != 0,
Kana: b&KeyboardLedMaskKana != 0,
}
}
func (u *UsbGadget) updateKeyboardState(b byte) {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
if b&^ValidKeyboardLedMasks != 0 {
u.log.Trace().Uint8("b", b).Msg("contains invalid bits, ignoring")
return
}
newState := getKeyboardState(b)
if reflect.DeepEqual(u.keyboardState, newState) {
return
}
u.log.Info().Interface("old", u.keyboardState).Interface("new", newState).Msg("keyboardState updated")
u.keyboardState = newState
if u.onKeyboardStateChange != nil {
(*u.onKeyboardStateChange)(newState)
}
}
func (u *UsbGadget) SetOnKeyboardStateChange(f func(state KeyboardState)) {
u.onKeyboardStateChange = &f
}
func (u *UsbGadget) GetKeyboardState() KeyboardState {
u.keyboardStateLock.Lock()
defer u.keyboardStateLock.Unlock()
return u.keyboardState
}
func (u *UsbGadget) listenKeyboardEvents() {
var path string
if u.keyboardHidFile != nil {
path = u.keyboardHidFile.Name()
}
l := u.log.With().Str("listener", "keyboardEvents").Str("path", path).Logger()
l.Trace().Msg("starting")
go func() {
buf := make([]byte, hidReadBufferSize)
for {
select {
case <-u.keyboardStateCtx.Done():
l.Info().Msg("context done")
return
default:
l.Trace().Msg("reading from keyboard")
if u.keyboardHidFile == nil { if u.keyboardHidFile == nil {
u.logWithSupression("keyboardHidFileNil", 100, &l, nil, "keyboardHidFile is nil")
// show the error every 100 times to avoid spamming the logs
time.Sleep(time.Second)
continue
}
// reset the counter
u.resetLogSuppressionCounter("keyboardHidFileNil")
n, err := u.keyboardHidFile.Read(buf)
if err != nil {
u.logWithSupression("keyboardHidFileRead", 100, &l, err, "failed to read")
continue
}
u.resetLogSuppressionCounter("keyboardHidFileRead")
l.Trace().Int("n", n).Bytes("buf", buf).Msg("got data from keyboard")
if n != 1 {
l.Trace().Int("n", n).Msg("expected 1 byte, got")
continue
}
u.updateKeyboardState(buf[0])
}
}
}()
}
func (u *UsbGadget) openKeyboardHidFile() error {
if u.keyboardHidFile != nil {
return nil
}
var err error var err error
u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666) u.keyboardHidFile, err = os.OpenFile("/dev/hidg0", os.O_RDWR, 0666)
if err != nil { if err != nil {
return fmt.Errorf("failed to open hidg0: %w", err) return fmt.Errorf("failed to open hidg0: %w", err)
} }
if u.keyboardStateCancel != nil {
u.keyboardStateCancel()
}
u.keyboardStateCtx, u.keyboardStateCancel = context.WithCancel(context.Background())
u.listenKeyboardEvents()
return nil
}
func (u *UsbGadget) OpenKeyboardHidFile() error {
return u.openKeyboardHidFile()
}
func (u *UsbGadget) keyboardWriteHidFile(data []byte) error {
if err := u.openKeyboardHidFile(); err != nil {
return err
} }
_, err := u.keyboardHidFile.Write(data) _, err := u.keyboardHidFile.Write(data)
if err != nil { if err != nil {
u.log.Error().Err(err).Msg("failed to write to hidg0") u.logWithSupression("keyboardWriteHidFile", 100, u.log, err, "failed to write to hidg0")
u.keyboardHidFile.Close() u.keyboardHidFile.Close()
u.keyboardHidFile = nil u.keyboardHidFile = nil
return err return err
} }
u.resetLogSuppressionCounter("keyboardWriteHidFile")
return nil return nil
} }

View File

@ -12,7 +12,7 @@ var absoluteMouseConfig = gadgetConfigItem{
configPath: []string{"hid.usb1"}, configPath: []string{"hid.usb1"},
attrs: gadgetAttributes{ attrs: gadgetAttributes{
"protocol": "2", "protocol": "2",
"subclass": "1", "subclass": "0",
"report_length": "6", "report_length": "6",
}, },
reportDesc: absoluteMouseCombinedReportDesc, reportDesc: absoluteMouseCombinedReportDesc,
@ -75,11 +75,12 @@ func (u *UsbGadget) absMouseWriteHidFile(data []byte) error {
_, err := u.absMouseHidFile.Write(data) _, err := u.absMouseHidFile.Write(data)
if err != nil { if err != nil {
u.log.Error().Err(err).Msg("failed to write to hidg1") u.logWithSupression("absMouseWriteHidFile", 100, u.log, err, "failed to write to hidg1")
u.absMouseHidFile.Close() u.absMouseHidFile.Close()
u.absMouseHidFile = nil u.absMouseHidFile = nil
return err return err
} }
u.resetLogSuppressionCounter("absMouseWriteHidFile")
return nil return nil
} }
@ -107,24 +108,16 @@ func (u *UsbGadget) AbsMouseWheelReport(wheelY int8) error {
u.absMouseLock.Lock() u.absMouseLock.Lock()
defer u.absMouseLock.Unlock() defer u.absMouseLock.Unlock()
// Accumulate the wheelY value // Only send a report if the value is non-zero
u.absMouseAccumulatedWheelY += float64(wheelY) / 8.0 if wheelY == 0 {
// Only send a report if the accumulated value is significant
if abs(u.absMouseAccumulatedWheelY) < 1.0 {
return nil return nil
} }
scaledWheelY := int8(u.absMouseAccumulatedWheelY)
err := u.absMouseWriteHidFile([]byte{ err := u.absMouseWriteHidFile([]byte{
2, // Report ID 2 2, // Report ID 2
byte(scaledWheelY), // Scaled Wheel Y (signed) byte(wheelY), // Wheel Y (signed)
}) })
// Reset the accumulator, keeping any remainder
u.absMouseAccumulatedWheelY -= float64(scaledWheelY)
u.resetUserInputTime() u.resetUserInputTime()
return err return err
} }

View File

@ -65,11 +65,12 @@ func (u *UsbGadget) relMouseWriteHidFile(data []byte) error {
_, err := u.relMouseHidFile.Write(data) _, err := u.relMouseHidFile.Write(data)
if err != nil { if err != nil {
u.log.Error().Err(err).Msg("failed to write to hidg2") u.logWithSupression("relMouseWriteHidFile", 100, u.log, err, "failed to write to hidg2")
u.relMouseHidFile.Close() u.relMouseHidFile.Close()
u.relMouseHidFile = nil u.relMouseHidFile = nil
return err return err
} }
u.resetLogSuppressionCounter("relMouseWriteHidFile")
return nil return nil
} }

27
internal/usbgadget/log.go Normal file
View File

@ -0,0 +1,27 @@
package usbgadget
import (
"errors"
)
func (u *UsbGadget) logWarn(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Warn().Err(err).Msg(msg)
return nil
}
func (u *UsbGadget) logError(msg string, err error) error {
if err == nil {
err = errors.New(msg)
}
if u.strictMode {
return err
}
u.log.Error().Err(err).Msg(msg)
return nil
}

View File

@ -50,18 +50,6 @@ func (u *UsbGadget) RebindUsb(ignoreUnbindError bool) error {
return u.rebindUsb(ignoreUnbindError) 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 // GetUsbState returns the current state of the USB gadget
func (u *UsbGadget) GetUsbState() (state string) { func (u *UsbGadget) GetUsbState() (state string) {
stateFile := path.Join("/sys/class/udc", u.udc, "state") stateFile := path.Join("/sys/class/udc", u.udc, "state")

View File

@ -3,11 +3,13 @@
package usbgadget package usbgadget
import ( import (
"context"
"os" "os"
"path" "path"
"sync" "sync"
"time" "time"
"github.com/jetkvm/kvm/internal/logging"
"github.com/rs/zerolog" "github.com/rs/zerolog"
) )
@ -29,6 +31,7 @@ type Config struct {
Manufacturer string `json:"manufacturer"` Manufacturer string `json:"manufacturer"`
Product string `json:"product"` Product string `json:"product"`
strictMode bool // when it's enabled, all warnings will be converted to errors
isEmpty bool isEmpty bool
} }
@ -58,24 +61,42 @@ type UsbGadget struct {
relMouseHidFile *os.File relMouseHidFile *os.File
relMouseLock sync.Mutex relMouseLock sync.Mutex
keyboardState KeyboardState
keyboardStateLock sync.Mutex
keyboardStateCtx context.Context
keyboardStateCancel context.CancelFunc
enabledDevices Devices enabledDevices Devices
strictMode bool // only intended for testing for now
absMouseAccumulatedWheelY float64 absMouseAccumulatedWheelY float64
lastUserInput time.Time lastUserInput time.Time
tx *UsbGadgetTransaction
txLock sync.Mutex
onKeyboardStateChange *func(state KeyboardState)
log *zerolog.Logger log *zerolog.Logger
logSuppressionCounter map[string]int
} }
const configFSPath = "/sys/kernel/config" const configFSPath = "/sys/kernel/config"
const gadgetPath = "/sys/kernel/config/usb_gadget" const gadgetPath = "/sys/kernel/config/usb_gadget"
var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) var defaultLogger = logging.GetSubsystemLogger("usbgadget")
// NewUsbGadget creates a new UsbGadget. // NewUsbGadget creates a new UsbGadget.
func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget { func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
return newUsbGadget(name, defaultGadgetConfig, enabledDevices, config, logger)
}
func newUsbGadget(name string, configMap map[string]gadgetConfigItem, enabledDevices *Devices, config *Config, logger *zerolog.Logger) *UsbGadget {
if logger == nil { if logger == nil {
logger = &defaultLogger logger = defaultLogger
} }
if enabledDevices == nil { if enabledDevices == nil {
@ -86,20 +107,30 @@ func NewUsbGadget(name string, enabledDevices *Devices, config *Config, logger *
config = &Config{isEmpty: true} config = &Config{isEmpty: true}
} }
keyboardCtx, keyboardCancel := context.WithCancel(context.Background())
g := &UsbGadget{ g := &UsbGadget{
name: name, name: name,
kvmGadgetPath: path.Join(gadgetPath, name), kvmGadgetPath: path.Join(gadgetPath, name),
configC1Path: path.Join(gadgetPath, name, "configs/c.1"), configC1Path: path.Join(gadgetPath, name, "configs/c.1"),
configMap: defaultGadgetConfig, configMap: configMap,
customConfig: *config, customConfig: *config,
configLock: sync.Mutex{}, configLock: sync.Mutex{},
keyboardLock: sync.Mutex{}, keyboardLock: sync.Mutex{},
absMouseLock: sync.Mutex{}, absMouseLock: sync.Mutex{},
relMouseLock: sync.Mutex{}, relMouseLock: sync.Mutex{},
txLock: sync.Mutex{},
keyboardStateCtx: keyboardCtx,
keyboardStateCancel: keyboardCancel,
keyboardState: KeyboardState{},
enabledDevices: *enabledDevices, enabledDevices: *enabledDevices,
lastUserInput: time.Now(), lastUserInput: time.Now(),
log: logger, log: logger,
strictMode: config.strictMode,
logSuppressionCounter: make(map[string]int),
absMouseAccumulatedWheelY: 0, absMouseAccumulatedWheelY: 0,
} }
if err := g.Init(); err != nil { if err := g.Init(); err != nil {

View File

@ -3,61 +3,104 @@ package usbgadget
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"os"
"path/filepath" "path/filepath"
) "strconv"
"strings"
// Helper function to get absolute value of float64 "github.com/rs/zerolog"
func abs(x float64) float64 { )
if x < 0 {
return -x
}
return x
}
func joinPath(basePath string, paths []string) string { func joinPath(basePath string, paths []string) string {
pathArr := append([]string{basePath}, paths...) pathArr := append([]string{basePath}, paths...)
return filepath.Join(pathArr...) return filepath.Join(pathArr...)
} }
func ensureSymlink(linkPath string, target string) error { func hexToDecimal(hex string) (int64, error) {
if _, err := os.Lstat(linkPath); err == nil { decimal, err := strconv.ParseInt(hex, 16, 64)
currentTarget, err := os.Readlink(linkPath)
if err != nil || currentTarget != target {
err = os.Remove(linkPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to remove existing symlink %s: %w", linkPath, err) return 0, err
} }
} return decimal, nil
} 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 { func decimalToOctal(decimal int64) string {
if _, err := os.Stat(filePath); err == nil { return fmt.Sprintf("%04o", decimal)
oldContent, err := os.ReadFile(filePath) }
if err == nil {
if bytes.Equal(oldContent, content) { func hexToOctal(hex string) (string, error) {
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content") hex = strings.ToLower(hex)
return nil hex = strings.Replace(hex, "0x", "", 1) //remove 0x or 0X
}
decimal, err := hexToDecimal(hex)
if len(oldContent) == len(content)+1 && if err != nil {
bytes.Equal(oldContent[:len(content)], content) && return "", err
oldContent[len(content)] == 10 { }
u.log.Trace().Str("path", filePath).Msg("skipping writing to as it already has the correct content")
return nil // Convert the decimal integer to an octal string.
} octal := decimalToOctal(decimal)
return octal, nil
u.log.Trace().Str("path", filePath).Bytes("old", oldContent).Bytes("new", content).Msg("writing to as it has different content") }
}
} func compareFileContent(oldContent []byte, newContent []byte, looserMatch bool) bool {
return os.WriteFile(filePath, content, permMode) if bytes.Equal(oldContent, newContent) {
return true
}
if len(oldContent) == len(newContent)+1 &&
bytes.Equal(oldContent[:len(newContent)], newContent) &&
oldContent[len(newContent)] == 10 {
return true
}
if len(newContent) == 4 {
if len(oldContent) < 6 || len(oldContent) > 7 {
return false
}
if len(oldContent) == 7 && oldContent[6] == 0x0a {
oldContent = oldContent[:6]
}
oldOctalValue, err := hexToOctal(string(oldContent))
if err != nil {
return false
}
if oldOctalValue == string(newContent) {
return true
}
}
if looserMatch {
oldContentStr := strings.TrimSpace(string(oldContent))
newContentStr := strings.TrimSpace(string(newContent))
return oldContentStr == newContentStr
}
return false
}
func (u *UsbGadget) logWithSupression(counterName string, every int, logger *zerolog.Logger, err error, msg string, args ...interface{}) {
if _, ok := u.logSuppressionCounter[counterName]; !ok {
u.logSuppressionCounter[counterName] = 0
} else {
u.logSuppressionCounter[counterName]++
}
l := logger.With().Int("counter", u.logSuppressionCounter[counterName]).Logger()
if u.logSuppressionCounter[counterName]%every == 0 {
if err != nil {
l.Error().Err(err).Msgf(msg, args...)
} else {
l.Error().Msgf(msg, args...)
}
}
}
func (u *UsbGadget) resetLogSuppressionCounter(counterName string) {
if _, ok := u.logSuppressionCounter[counterName]; !ok {
u.logSuppressionCounter[counterName] = 0
}
} }

View File

@ -0,0 +1,55 @@
package websecure
import (
"os"
"testing"
)
var (
fixtureEd25519Certificate = `-----BEGIN CERTIFICATE-----
MIIBQDCB86ADAgECAhQdB4qB6dV0/u1lwhJofQgkmjjV1zAFBgMrZXAwLzELMAkG
A1UEBhMCREUxIDAeBgNVBAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMB4XDTI1
MDUyMzEyNTkyN1oXDTI3MDQyMzEyNTkyN1owLzELMAkGA1UEBhMCREUxIDAeBgNV
BAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMCowBQYDK2VwAyEA9tLyoulJn7Ev
bf8kuD1ZGdA092773pCRjFEDKpXHonyjITAfMB0GA1UdDgQWBBRkmrVMfsLY57iy
r/0POP0S4QxCADAFBgMrZXADQQBfTRvqavLHDYQiKQTgbGod+Yn+fIq2lE584+1U
C4wh9peIJDFocLBEAYTQpEMKxa4s0AIRxD+a7aCS5oz0e/0I
-----END CERTIFICATE-----`
fixtureEd25519PrivateKey = `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKV08xUsLRHBfMXqZwxVRzIbViOp8G7aQGjPvoRFjujB
-----END PRIVATE KEY-----`
certStore *CertStore
certSigner *SelfSigner
)
func TestMain(m *testing.M) {
tlsStorePath, err := os.MkdirTemp("", "jktls.*")
if err != nil {
defaultLogger.Fatal().Err(err).Msg("failed to create temp directory")
}
certStore = NewCertStore(tlsStorePath, nil)
certStore.LoadCertificates()
certSigner = NewSelfSigner(
certStore,
nil,
"ci.jetkvm.com",
"JetKVM",
"JetKVM",
"JetKVM",
)
m.Run()
os.RemoveAll(tlsStorePath)
}
func TestSaveEd25519Certificate(t *testing.T) {
err, _ := certStore.ValidateAndSaveCertificate("ed25519-test.jetkvm.com", fixtureEd25519Certificate, fixtureEd25519PrivateKey, true)
if err != nil {
t.Fatalf("failed to save certificate: %v", err)
}
}

View File

@ -2,6 +2,7 @@ package websecure
import ( import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/ed25519"
"crypto/rand" "crypto/rand"
"crypto/rsa" "crypto/rsa"
"crypto/tls" "crypto/tls"
@ -37,11 +38,15 @@ func keyToFile(cert *tls.Certificate, filename string) error {
if e != nil { if e != nil {
return fmt.Errorf("failed to marshal EC private key: %v", e) return fmt.Errorf("failed to marshal EC private key: %v", e)
} }
keyBlock = pem.Block{ keyBlock = pem.Block{
Type: "EC PRIVATE KEY", Type: "EC PRIVATE KEY",
Bytes: b, Bytes: b,
} }
case ed25519.PrivateKey:
keyBlock = pem.Block{
Type: "ED25519 PRIVATE KEY",
Bytes: k,
}
default: default:
return fmt.Errorf("unknown private key type: %T", k) return fmt.Errorf("unknown private key type: %T", k)
} }

View File

@ -15,9 +15,7 @@ func rpcGetJigglerState() bool {
return jigglerEnabled return jigglerEnabled
} }
func init() { func initJiggler() {
ensureConfigLoaded()
go runJiggler() go runJiggler()
} }

View File

@ -266,9 +266,14 @@ func rpcSetDevChannelState(enabled bool) error {
func rpcGetUpdateStatus() (*UpdateStatus, error) { func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease) updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil { if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err) return nil, fmt.Errorf("error checking for updates: %w", err)
} }
updateStatus.Error = err.Error()
}
return updateStatus, nil return updateStatus, nil
} }
@ -461,7 +466,31 @@ func rpcSetTLSState(state TLSState) error {
return nil return nil
} }
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) { type RPCHandler struct {
Func interface{}
Params []string
}
// call the handler but recover from a panic to ensure our RPC thread doesn't collapse on malformed calls
func callRPCHandler(handler RPCHandler, params map[string]interface{}) (result interface{}, err error) {
// Use defer to recover from a panic
defer func() {
if r := recover(); r != nil {
// Convert the panic to an error
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("panic occurred: %v", r)
}
}
}()
// Call the handler
result, err = riskyCallRPCHandler(handler, params)
return result, err
}
func riskyCallRPCHandler(handler RPCHandler, params map[string]interface{}) (interface{}, error) {
handlerValue := reflect.ValueOf(handler.Func) handlerValue := reflect.ValueOf(handler.Func)
handlerType := handlerValue.Type() handlerType := handlerValue.Type()
@ -558,11 +587,6 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
return nil, errors.New("unexpected return values from handler") return nil, errors.New("unexpected return values from handler")
} }
type RPCHandler struct {
Func interface{}
Params []string
}
func rpcSetMassStorageMode(mode string) (string, error) { func rpcSetMassStorageMode(mode string) (string, error) {
logger.Info().Str("mode", mode).Msg("Setting mass storage mode") logger.Info().Str("mode", mode).Msg("Setting mass storage mode")
var cdrom bool var cdrom bool
@ -877,14 +901,15 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
return nil return nil
} }
var currentScrollSensitivity string = "default" func rpcGetKeyboardLayout() (string, error) {
return config.KeyboardLayout, nil
func rpcGetScrollSensitivity() (string, error) {
return currentScrollSensitivity, nil
} }
func rpcSetScrollSensitivity(sensitivity string) error { func rpcSetKeyboardLayout(layout string) error {
currentScrollSensitivity = sensitivity config.KeyboardLayout = layout
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil return nil
} }
@ -981,6 +1006,25 @@ func setKeyboardMacros(params KeyboardMacrosParams) (interface{}, error) {
return nil, nil return nil, nil
} }
func rpcGetLocalLoopbackOnly() (bool, error) {
return config.LocalLoopbackOnly, nil
}
func rpcSetLocalLoopbackOnly(enabled bool) error {
// Check if the setting is actually changing
if config.LocalLoopbackOnly == enabled {
return nil
}
// Update the setting
config.LocalLoopbackOnly = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
var rpcHandlers = map[string]RPCHandler{ var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing}, "ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}}, "reboot": {Func: rpcReboot, Params: []string{"force"}},
@ -992,6 +1036,7 @@ var rpcHandlers = map[string]RPCHandler{
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}}, "setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease}, "renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}}, "relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
@ -1053,8 +1098,10 @@ var rpcHandlers = map[string]RPCHandler{
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}}, "setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}}, "setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}}, "setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getScrollSensitivity": {Func: rpcGetScrollSensitivity}, "getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setScrollSensitivity": {Func: rpcSetScrollSensitivity, Params: []string{"sensitivity"}}, "setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros}, "getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}}, "setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
} }

View File

@ -75,8 +75,9 @@ func Main() {
} }
}() }()
// initialize usb gadget
initUsbGadget() initUsbGadget()
StartNtpAudioServer(handleAudioClient) StartRtpAudioServer(handleAudioClient)
if err := setInitialVirtualMediaState(); err != nil { if err := setInitialVirtualMediaState(); err != nil {
logger.Warn().Err(err).Msg("failed to set initial virtual media state") logger.Warn().Err(err).Msg("failed to set initial virtual media state")
@ -85,6 +86,10 @@ func Main() {
if err := initImagesFolder(); err != nil { if err := initImagesFolder(); err != nil {
logger.Warn().Err(err).Msg("failed to init images folder") logger.Warn().Err(err).Msg("failed to init images folder")
} }
initJiggler()
// initialize display
initDisplay()
go func() { go func() {
time.Sleep(15 * time.Minute) time.Sleep(15 * time.Minute)

View File

@ -8,6 +8,7 @@ import (
"io" "io"
"net" "net"
"os" "os"
"os/exec"
"sync" "sync"
"time" "time"
@ -42,6 +43,11 @@ var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{} var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
)
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) { func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
lock.Lock() lock.Lock()
defer lock.Unlock() defer lock.Unlock()
@ -130,16 +136,26 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
scopedLogger.Info().Msg("server listening") scopedLogger.Info().Msg("server listening")
go func() { go func() {
for {
conn, err := listener.Accept() conn, err := listener.Accept()
listener.Close()
if err != nil { if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket") scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
} }
if isCtrl { if isCtrl {
// check if the channel is closed
select {
case <-ctrlClientConnected:
scopedLogger.Debug().Msg("ctrl client reconnected")
default:
close(ctrlClientConnected) close(ctrlClientConnected)
scopedLogger.Debug().Msg("first native ctrl socket client connected") scopedLogger.Debug().Msg("first native ctrl socket client connected")
} }
handleClient(conn) }
go handleClient(conn)
}
}() }()
return listener return listener
@ -274,6 +290,51 @@ func handleAudioClient(conn net.Conn) {
} }
} }
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
cmd, err := startNativeBinary(binaryPath)
if err != nil {
return nil, err
}
nativeCmd = cmd
return cmd, nil
}
func restartNativeBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
nativeLogger.Info().Msg("restarting jetkvm_native binary")
cmd, err := startNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to restart binary")
}
nativeCmd = cmd
return err
}
func superviseNativeBinary(binaryPath string) error {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
if nativeCmd == nil || nativeCmd.Process == nil {
return restartNativeBinary(binaryPath)
}
err := nativeCmd.Wait()
if err == nil {
nativeLogger.Info().Err(err).Msg("jetkvm_native binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
nativeLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("jetkvm_native binary exited with error")
} else {
nativeLogger.Warn().Err(err).Msg("jetkvm_native binary exited with unknown error")
}
return restartNativeBinary(binaryPath)
}
func ExtractAndRunNativeBin() error { func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native" binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil { if err := ensureBinaryUpdated(binaryPath); err != nil {
@ -285,12 +346,28 @@ func ExtractAndRunNativeBin() error {
return fmt.Errorf("failed to make binary executable: %w", err) return fmt.Errorf("failed to make binary executable: %w", err)
} }
// Run the binary in the background // Run the binary in the background
cmd, err := startNativeBinary(binaryPath) cmd, err := startNativeBinaryWithLock(binaryPath)
if err != nil { if err != nil {
return fmt.Errorf("failed to start binary: %w", err) return fmt.Errorf("failed to start binary: %w", err)
} }
//TODO: add auto restart // check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
nativeLogger.Info().Msg("stopping native binary supervisor")
return
default:
err := superviseNativeBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to supervise native binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() { go func() {
<-appCtx.Done() <-appCtx.Done()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process") nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")

View File

@ -21,6 +21,7 @@ func networkStateChanged() {
// always restart mDNS when the network state changes // always restart mDNS when the network state changes
if mDNS != nil { if mDNS != nil {
_ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{ _ = mDNS.SetLocalNames([]string{
networkState.GetHostname(), networkState.GetHostname(),
networkState.GetFQDN(), networkState.GetFQDN(),
@ -54,14 +55,6 @@ func initNetwork() error {
OnConfigChange: func(networkConfig *network.NetworkConfig) { OnConfigChange: func(networkConfig *network.NetworkConfig) {
config.NetworkConfig = networkConfig config.NetworkConfig = networkConfig
networkStateChanged() networkStateChanged()
if mDNS != nil {
_ = mDNS.SetListenOptions(networkConfig.GetMDNSMode())
_ = mDNS.SetLocalNames([]string{
networkState.GetHostname(),
networkState.GetFQDN(),
}, true)
}
}, },
}) })

40
ota.go
View File

@ -41,6 +41,9 @@ type UpdateStatus struct {
Remote *UpdateMetadata `json:"remote"` Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
} }
const UpdateMetadataUrl = "https://api.jetkvm.com/releases" const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
@ -489,52 +492,47 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
} }
func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) { func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) {
updateStatus := &UpdateStatus{}
// Get local versions // Get local versions
systemVersionLocal, appVersionLocal, err := GetLocalVersion() systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err) return updateStatus, fmt.Errorf("error getting local version: %w", err)
}
updateStatus.Local = &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
} }
// Get remote metadata // Get remote metadata
remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease) remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease)
if err != nil { if err != nil {
return nil, fmt.Errorf("error checking for updates: %w", err) return updateStatus, fmt.Errorf("error checking for updates: %w", err)
}
// Build local UpdateMetadata
localMetadata := &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
} }
updateStatus.Remote = remoteMetadata
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil { if err != nil {
return nil, fmt.Errorf("error parsing remote system version: %w", err) return updateStatus, fmt.Errorf("error parsing remote system version: %w", err)
} }
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil { if err != nil {
return nil, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
} }
systemUpdateAvailable := systemVersionRemote.GreaterThan(systemVersionLocal) updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal)
appUpdateAvailable := appVersionRemote.GreaterThan(appVersionLocal) updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal)
// Handle pre-release updates // Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease { if isRemoteSystemPreRelease && !includePreRelease {
systemUpdateAvailable = false updateStatus.SystemUpdateAvailable = false
} }
if isRemoteAppPreRelease && !includePreRelease { if isRemoteAppPreRelease && !includePreRelease {
appUpdateAvailable = false updateStatus.AppUpdateAvailable = false
}
updateStatus := &UpdateStatus{
Local: localMetadata,
Remote: remoteMetadata,
SystemUpdateAvailable: systemUpdateAvailable,
AppUpdateAvailable: appUpdateAvailable,
} }
return updateStatus, nil return updateStatus, nil

37
resource/dev_test.sh Normal file
View File

@ -0,0 +1,37 @@
#!/bin/sh
JSON_OUTPUT=false
GET_COMMANDS=false
if [ "$1" = "-json" ]; then
JSON_OUTPUT=true
shift
fi
ADDITIONAL_ARGS=$@
EXIT_CODE=0
runTest() {
PKG_ARGS=""
if [ "$2" != "" ]; then
PKG_ARGS="-p $2"
fi
if [ "$JSON_OUTPUT" = true ]; then
./test2json $PKG_ARGS -t $1 -test.v $ADDITIONAL_ARGS | tee $1.result.json
if [ $? -ne 0 ]; then
EXIT_CODE=1
fi
else
$@
if [ $? -ne 0 ]; then
EXIT_CODE=1
fi
fi
}
function exit_with_code() {
if [ $EXIT_CODE -ne 0 ]; then
printf "\e[0;31m❌ Test failed\e[0m\n"
fi
exit $EXIT_CODE
}
trap exit_with_code EXIT

View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"bytes"
"encoding/json" "encoding/json"
"io" "io"
"os" "os"
@ -55,19 +56,24 @@ func handleTerminalChannel(d *webrtc.DataChannel) {
return return
} }
if msg.IsString { if msg.IsString {
maybeJson := bytes.TrimSpace(msg.Data)
// Cheap check to see if this resembles JSON
if len(maybeJson) > 1 && maybeJson[0] == '{' && maybeJson[len(maybeJson)-1] == '}' {
var size TerminalSize var size TerminalSize
err := json.Unmarshal([]byte(msg.Data), &size) err := json.Unmarshal(maybeJson, &size)
if err == nil { if err == nil {
err = pty.Setsize(ptmx, &pty.Winsize{ err = pty.Setsize(ptmx, &pty.Winsize{
Rows: uint16(size.Rows), Rows: uint16(size.Rows),
Cols: uint16(size.Cols), Cols: uint16(size.Cols),
}) })
if err == nil { if err == nil {
scopedLogger.Info().Int("rows", size.Rows).Int("cols", size.Cols).Msg("Set terminal size")
return return
} }
} }
scopedLogger.Warn().Err(err).Msg("Failed to parse terminal size") scopedLogger.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 {
scopedLogger.Warn().Err(err).Msg("Failed to write to pty") scopedLogger.Warn().Err(err).Msg("Failed to write to pty")

View File

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

View File

@ -6,6 +6,7 @@
"arrowParens": "avoid", "arrowParens": "avoid",
"singleQuote": false, "singleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-tailwindcss"],
"tailwindFunctions": ["clsx"], "tailwindFunctions": ["clsx", "cx"],
"printWidth": 90 "printWidth": 90,
"tailwindStylesheet": "./src/index.css"
} }

93
ui/eslint.config.cjs Normal file
View File

@ -0,0 +1,93 @@
const {
defineConfig,
globalIgnores,
} = require("eslint/config");
const globals = require("globals");
const {
fixupConfigRules,
} = require("@eslint/compat");
const tsParser = require("@typescript-eslint/parser");
const reactRefresh = require("eslint-plugin-react-refresh");
const js = require("@eslint/js");
const {
FlatCompat,
} = require("@eslint/eslintrc");
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all
});
module.exports = defineConfig([{
languageOptions: {
globals: {
...globals.browser,
},
parser: tsParser,
ecmaVersion: "latest",
sourceType: "module",
parserOptions: {
project: ["./tsconfig.json", "./tsconfig.node.json"],
tsconfigRootDir: __dirname,
ecmaFeatures: {
jsx: true
}
},
},
extends: fixupConfigRules(compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/stylistic",
"plugin:react-hooks/recommended",
"plugin:react/recommended",
"plugin:react/jsx-runtime",
"plugin:import/recommended",
"prettier",
)),
plugins: {
"react-refresh": reactRefresh,
},
rules: {
"react-refresh/only-export-components": ["warn", {
allowConstantExport: true,
}],
"import/order": ["error", {
groups: ["builtin", "external", "internal", "parent", "sibling"],
"newlines-between": "always",
}],
},
settings: {
"react": {
"version": "detect"
},
"import/resolver": {
alias: {
map: [
["@components", "./src/components"],
["@routes", "./src/routes"],
["@assets", "./src/assets"],
["@", "./src"],
],
extensions: [".ts", ".tsx", ".js", ".jsx", ".json"],
},
},
},
}, globalIgnores([
"**/dist",
"**/.eslintrc.cjs",
"**/tailwind.config.js",
"**/postcss.config.js",
])]);

3519
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,7 +4,7 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"engines": { "engines": {
"node": "21.1.0" "node": "22.15.0"
}, },
"scripts": { "scripts": {
"dev": "./dev_device.sh", "dev": "./dev_device.sh",
@ -19,21 +19,21 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.2.2", "@headlessui/react": "^2.2.3",
"@headlessui/tailwindcss": "^0.2.2", "@headlessui/tailwindcss": "^0.2.2",
"@heroicons/react": "^2.2.0", "@heroicons/react": "^2.2.0",
"@vitejs/plugin-basic-ssl": "^1.2.0", "@vitejs/plugin-basic-ssl": "^2.0.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", "@xterm/xterm": "^5.5.0",
"cva": "^1.0.0-beta.1", "cva": "^1.0.0-beta.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"eslint-import-resolver-alias": "^1.1.2", "eslint-import-resolver-alias": "^1.1.2",
"focus-trap-react": "^10.2.3", "focus-trap-react": "^11.0.3",
"framer-motion": "^11.15.0", "framer-motion": "^12.11.4",
"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": "^19.1.0", "react": "^19.1.0",
@ -42,24 +42,29 @@
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-simple-keyboard": "^3.8.71", "react-simple-keyboard": "^3.8.72",
"react-use-websocket": "^4.13.0", "react-use-websocket": "^4.13.0",
"react-xtermjs": "^1.0.10", "react-xtermjs": "^1.0.10",
"recharts": "^2.15.3", "recharts": "^2.15.3",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^3.3.0",
"usehooks-ts": "^3.1.1", "usehooks-ts": "^3.1.1",
"validator": "^13.15.0", "validator": "^13.15.0",
"zustand": "^4.5.2" "zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.9",
"@eslint/eslintrc": "^3.3.1",
"@eslint/js": "^9.26.0",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@tailwindcss/postcss": "^4.1.7",
"@tailwindcss/typography": "^0.5.16", "@tailwindcss/typography": "^0.5.16",
"@types/react": "^19.1.3", "@tailwindcss/vite": "^4.1.7",
"@types/react-dom": "^19.1.3", "@types/react": "^19.1.4",
"@types/react-dom": "^19.1.5",
"@types/semver": "^7.7.0", "@types/semver": "^7.7.0",
"@types/validator": "^13.15.0", "@types/validator": "^13.15.0",
"@typescript-eslint/eslint-plugin": "^8.32.0", "@typescript-eslint/eslint-plugin": "^8.32.1",
"@typescript-eslint/parser": "^8.32.0", "@typescript-eslint/parser": "^8.32.1",
"@vitejs/plugin-react-swc": "^3.9.0", "@vitejs/plugin-react-swc": "^3.9.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"eslint": "^9.26.0", "eslint": "^9.26.0",
@ -68,12 +73,13 @@
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.1.0",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^3.4.17", "tailwindcss": "^4.1.7",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "^5.2.0", "vite": "^6.3.5",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

View File

@ -1,6 +1,5 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {},
autoprefixer: {}, autoprefixer: {},
}, },
} }

View File

@ -89,8 +89,8 @@ export default function Actionbar({
anchor="bottom start" anchor="bottom start"
transition transition
className={cx( className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible", "z-10 flex w-[420px] origin-top flex-col overflow-visible!",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0", "flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)} )}
> >
{({ open }) => { {({ open }) => {
@ -131,8 +131,8 @@ export default function Actionbar({
anchor="bottom start" anchor="bottom start"
transition transition
className={cx( className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible", "z-10 flex w-[420px] origin-top flex-col overflow-visible!",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0", "flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)} )}
> >
{({ open }) => { {({ open }) => {
@ -183,8 +183,8 @@ export default function Actionbar({
transitionProperty: "opacity", transitionProperty: "opacity",
}} }}
className={cx( className={cx(
"z-10 flex w-[420px] origin-top flex-col !overflow-visible", "z-10 flex w-[420px] origin-top flex-col overflow-visible!",
"flex origin-top flex-col transition duration-300 ease-out data-[closed]:translate-y-8 data-[closed]:opacity-0", "flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)} )}
> >
{({ open }) => { {({ open }) => {
@ -226,8 +226,8 @@ export default function Actionbar({
anchor="bottom start" anchor="bottom start"
transition transition
className={cx( className={cx(
"z-10 flex w-[420px] flex-col !overflow-visible", "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", "flex origin-top flex-col transition duration-300 ease-out data-closed:translate-y-8 data-closed:opacity-0",
)} )}
> >
{({ open }) => { {({ open }) => {
@ -262,7 +262,23 @@ export default function Actionbar({
}} }}
/> />
</div> </div>
{/* {useSettingsStore().actionBarCtrlAltDel && (
<div className="hidden lg:block">
<Button
size="XS"
theme="light"
text="Ctrl + Alt + Del"
LeadingIcon={FaLock}
onClick={() => {
sendKeyboardEvent(
[keys["Delete"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
}}
/>
</div>
)} */}
<div> <div>
<Button <Button
size="XS" size="XS"
@ -274,7 +290,7 @@ export default function Actionbar({
</div> </div>
<div className="hidden items-center gap-x-2 lg:flex"> <div className="hidden items-center gap-x-2 lg:flex">
<div className="h-4 w-[1px] bg-slate-300 dark:bg-slate-600" /> <div className="h-4 w-px bg-slate-300 dark:bg-slate-600" />
<Button <Button
size="XS" size="XS"
theme="light" theme="light"

View File

@ -37,7 +37,7 @@ export default function AuthLayout({
<> <>
<GridBackground /> <GridBackground />
<div className="grid min-h-screen grid-rows-layout"> <div className="grid min-h-screen grid-rows-(--grid-layout)">
<SimpleNavbar <SimpleNavbar
logoHref="/" logoHref="/"
actionElement={ actionElement={

View File

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

View File

@ -16,7 +16,7 @@ const sizes = {
const themes = { const themes = {
primary: cx( primary: cx(
// Base styles // Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow", "bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow-sm",
// Hover states // Hover states
"group-hover:bg-blue-800", "group-hover:bg-blue-800",
// Active states // Active states
@ -24,7 +24,7 @@ const themes = {
), ),
danger: cx( danger: cx(
// Base styles // Base styles
"bg-red-600 text-white border-red-700 shadow-sm shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20", "bg-red-600 text-white border-red-700 shadow-xs shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states // Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600", "group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states // Active states
@ -34,7 +34,7 @@ const themes = {
), ),
light: cx( light: cx(
// Base styles // Base styles
"bg-white text-black border-slate-800/30 shadow dark:bg-slate-800 dark:border-slate-300/20 dark:text-white", "bg-white text-black border-slate-800/30 shadow-xs dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states // Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700", "group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states // Active states
@ -44,7 +44,7 @@ const themes = {
), ),
lightDanger: cx( lightDanger: cx(
// Base styles // Base styles
"bg-white text-black border-red-400/60 shadow-sm", "bg-white text-black border-red-400/60 shadow-xs",
// Hover states // Hover states
"group-hover:bg-red-50/80", "group-hover:bg-red-50/80",
// Active states // Active states
@ -56,7 +56,7 @@ const themes = {
// Base styles // Base styles
"bg-white/0 text-black border-transparent dark:text-white", "bg-white/0 text-black border-transparent dark:text-white",
// Hover states // Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600", "group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow-sm dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states // Active states
"group-active:bg-slate-100/80", "group-active:bg-slate-100/80",
), ),
@ -65,15 +65,15 @@ const themes = {
const btnVariants = cva({ const btnVariants = cva({
base: cx( base: cx(
// Base styles // Base styles
"border rounded select-none", "border rounded-sm select-none",
// Size classes // Size classes
"justify-center items-center shrink-0", "justify-center items-center shrink-0",
// Transition classes // Transition classes
"outline-none transition-all duration-200", "outline-hidden transition-all duration-200",
// Text classes // Text classes
"font-display text-center font-medium leading-tight", "font-display text-center font-medium leading-tight",
// States // States
"group-focus:outline-none group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700", "group-focus:outline-hidden group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none", "group-disabled:opacity-50 group-disabled:pointer-events-none",
), ),
@ -126,9 +126,9 @@ function ButtonContent(props: ButtonContentPropsType) {
<div <div
className={cx( className={cx(
"flex w-full min-w-0 items-center gap-x-1.5 text-center", "flex w-full min-w-0 items-center gap-x-1.5 text-center",
textAlign === "left" ? "!text-left" : "", textAlign === "left" ? "text-left!" : "",
textAlign === "center" ? "!text-center" : "", textAlign === "center" ? "text-center!" : "",
textAlign === "right" ? "!text-right" : "", textAlign === "right" ? "text-right!" : "",
)} )}
> >
{loading ? ( {loading ? (
@ -175,7 +175,7 @@ type ButtonPropsType = Pick<
export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>( export const Button = React.forwardRef<HTMLButtonElement, ButtonPropsType>(
({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => { ({ type, disabled, onClick, formNoValidate, loading, fetcher, ...props }, ref) => {
const classes = cx( const classes = cx(
"group outline-none", "group outline-hidden",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
loading ? "pointer-events-none" : "", loading ? "pointer-events-none" : "",
); );
@ -215,8 +215,8 @@ type LinkPropsType = Pick<LinkProps, "to"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean }; React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LinkButton = ({ to, ...props }: LinkPropsType) => { export const LinkButton = ({ to, ...props }: LinkPropsType) => {
const classes = cx( const classes = cx(
"group outline-none", "group outline-hidden",
props.disabled ? "pointer-events-none !opacity-70" : "", props.disabled ? "pointer-events-none opacity-70!" : "",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "", props.loading ? "pointer-events-none" : "",
props.className, props.className,
@ -241,8 +241,8 @@ type LabelPropsType = Pick<HTMLLabelElement, "htmlFor"> &
React.ComponentProps<typeof ButtonContent> & { disabled?: boolean }; React.ComponentProps<typeof ButtonContent> & { disabled?: boolean };
export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => { export const LabelButton = ({ htmlFor, ...props }: LabelPropsType) => {
const classes = cx( const classes = cx(
"group outline-none block cursor-pointer", "group outline-hidden block cursor-pointer",
props.disabled ? "pointer-events-none !opacity-70" : "", props.disabled ? "pointer-events-none opacity-70!" : "",
props.fullWidth ? "w-full" : "", props.fullWidth ? "w-full" : "",
props.loading ? "pointer-events-none" : "", props.loading ? "pointer-events-none" : "",
props.className, props.className,

View File

@ -17,8 +17,8 @@ export const GridCard = ({
return ( return (
<Card className={cx("overflow-hidden", cardClassName)}> <Card className={cx("overflow-hidden", cardClassName)}>
<div className="relative h-full"> <div className="relative h-full">
<div className="absolute inset-0 z-0 h-full w-full bg-gradient-to-tr from-blue-50/30 to-blue-50/20 transition-colors duration-300 ease-in-out dark:from-slate-800/30 dark:to-slate-800/20" /> <div className="absolute inset-0 z-0 h-full w-full bg-linear-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="isolate h-full">{children}</div> <div className="isolate h-full">{children}</div>
</div> </div>
</Card> </Card>
@ -30,7 +30,7 @@ const Card = forwardRef<HTMLDivElement, CardPropsType>(({ children, className },
<div <div
ref={ref} ref={ref}
className={cx( className={cx(
"w-full rounded border-none bg-white shadow outline outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20", "w-full rounded-sm border-none bg-white shadow-xs outline-1 outline-slate-800/30 dark:bg-slate-800 dark:outline-slate-300/20",
className, className,
)} )}
> >

View File

@ -12,10 +12,10 @@ const sizes = {
const checkboxVariants = cva({ const checkboxVariants = cva({
base: cx( base: cx(
"block rounded", "form-checkbox block rounded",
// Colors // Colors
"border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-blue-700 dark:text-blue-500 transition-colors", "border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 checked:accent-blue-700 checked:dark:accent-blue-500 transition-colors",
// Hover // Hover
"hover:bg-slate-200/50 dark:hover:bg-slate-700/50", "hover:bg-slate-200/50 dark:hover:bg-slate-700/50",
@ -24,7 +24,7 @@ const checkboxVariants = cva({
"active:bg-slate-200 dark:active:bg-slate-700", "active:bg-slate-200 dark:active:bg-slate-700",
// Focus // Focus
"focus:border-slate-300 dark:focus:border-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900", "focus:border-slate-300 dark:focus:border-slate-600 focus:outline-hidden focus:ring-2 focus:ring-blue-700 dark:focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-slate-900",
// Disabled // Disabled
"disabled:pointer-events-none disabled:opacity-30", "disabled:pointer-events-none disabled:opacity-30",
@ -41,7 +41,9 @@ const Checkbox = forwardRef<HTMLInputElement, CheckBoxProps>(function Checkbox(
ref, ref,
) { ) {
const classes = checkboxVariants({ size }); const classes = checkboxVariants({ size });
return <input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />; return (
<input ref={ref} {...props} type="checkbox" className={clsx(classes, className)} />
);
}); });
Checkbox.displayName = "Checkbox"; Checkbox.displayName = "Checkbox";

View File

@ -1,7 +1,14 @@
import { useRef } from "react"; import { useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
import { Combobox as HeadlessCombobox, ComboboxInput, ComboboxOption, ComboboxOptions } from "@headlessui/react"; import {
Combobox as HeadlessCombobox,
ComboboxInput,
ComboboxOption,
ComboboxOptions,
} from "@headlessui/react";
import { cva } from "@/cva.config"; import { cva } from "@/cva.config";
import Card from "./Card"; import Card from "./Card";
export interface ComboboxOption { export interface ComboboxOption {
@ -22,7 +29,7 @@ const comboboxVariants = cva({
type BaseProps = React.ComponentProps<typeof HeadlessCombobox>; type BaseProps = React.ComponentProps<typeof HeadlessCombobox>;
interface ComboboxProps extends Omit<BaseProps, 'displayValue'> { interface ComboboxProps extends Omit<BaseProps, "displayValue"> {
displayValue: (option: ComboboxOption) => string; displayValue: (option: ComboboxOption) => string;
onInputChange: (option: string) => void; onInputChange: (option: string) => void;
options: () => ComboboxOption[]; options: () => ComboboxOption[];
@ -48,13 +55,10 @@ export function Combobox({
const classes = comboboxVariants({ size }); const classes = comboboxVariants({ size });
return ( return (
<HeadlessCombobox <HeadlessCombobox onChange={onChange} {...otherProps}>
onChange={onChange}
{...otherProps}
>
{() => ( {() => (
<> <>
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
<ComboboxInput <ComboboxInput
ref={inputRef} ref={inputRef}
className={clsx( className={clsx(
@ -73,30 +77,31 @@ export function Combobox({
"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", "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
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" disabled &&
"pointer-events-none select-none bg-slate-50 text-slate-500/80 disabled:hover:bg-white dark:bg-slate-800 dark:text-slate-400/80 dark:disabled:hover:bg-slate-800",
)} )}
placeholder={disabled ? disabledMessage : placeholder} placeholder={disabled ? disabledMessage : placeholder}
displayValue={displayValue} displayValue={displayValue}
onChange={(event) => onInputChange(event.target.value)} onChange={event => onInputChange(event.target.value)}
disabled={disabled} disabled={disabled}
/> />
</Card> </Card>
{options().length > 0 && ( {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"> <ComboboxOptions className="hide-scrollbar absolute left-0 z-100 mt-1 max-h-60 w-full 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">
{options().map((option) => ( {options().map(option => (
<ComboboxOption <ComboboxOption
key={option.value} key={option.value}
value={option} value={option}
className={clsx( className={clsx(
// General styling // General styling
"cursor-default select-none py-2 px-4", "cursor-default select-none px-4 py-2",
// Hover and active states // Hover and active states
"hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900", "hover:bg-blue-50/80 ui-active:bg-blue-50/80 ui-active:text-blue-900",
// Dark mode // Dark mode
"dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200" "dark:text-slate-300 dark:hover:bg-slate-700 dark:ui-active:bg-slate-700 dark:ui-active:text-blue-200",
)} )}
> >
{option.label} {option.label}
@ -106,10 +111,8 @@ export function Combobox({
)} )}
{options().length === 0 && inputRef.current?.value && ( {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="absolute left-0 z-100 mt-1 w-full rounded-md bg-white px-4 py-2 text-sm shadow-lg ring-1 ring-black/5 dark:bg-slate-800 dark:ring-slate-700">
<div className="text-slate-500 dark:text-slate-400"> <div className="text-slate-500 dark:text-slate-400">{emptyMessage}</div>
{emptyMessage}
</div>
</div> </div>
)} )}
</> </>

View File

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

View File

@ -0,0 +1,212 @@
import { LuRefreshCcw } from "react-icons/lu";
import { Button } from "@/components/Button";
import { GridCard } from "@/components/Card";
import { LifeTimeLabel } from "@/routes/devices.$id.settings.network";
import { NetworkState } from "@/hooks/stores";
export default function DhcpLeaseCard({
networkState,
setShowRenewLeaseConfirm,
}: {
networkState: NetworkState;
setShowRenewLeaseConfirm: (show: boolean) => void;
}) {
return (
<GridCard>
<div className="animate-fadeIn p-4 opacity-0 animation-duration-500 text-black dark:text-white">
<div className="space-y-3">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
DHCP Lease Information
</h3>
<div className="flex gap-x-6 gap-y-2">
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
IP Address
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ip}
</span>
</div>
)}
{networkState?.dhcp_lease?.netmask && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Subnet Mask
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.netmask}
</span>
</div>
)}
{networkState?.dhcp_lease?.dns && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DNS Servers
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.dns.map(dns => <div key={dns}>{dns}</div>)}
</span>
</div>
)}
{networkState?.dhcp_lease?.broadcast && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Broadcast
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.broadcast}
</span>
</div>
)}
{networkState?.dhcp_lease?.domain && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Domain
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.domain}
</span>
</div>
)}
{networkState?.dhcp_lease?.ntp_servers &&
networkState?.dhcp_lease?.ntp_servers.length > 0 && (
<div className="flex justify-between gap-x-8 border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<div className="w-full grow text-sm text-slate-600 dark:text-slate-400">
NTP Servers
</div>
<div className="shrink text-right text-sm font-medium">
{networkState?.dhcp_lease?.ntp_servers.map(server => (
<div key={server}>{server}</div>
))}
</div>
</div>
)}
{networkState?.dhcp_lease?.hostname && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Hostname
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.hostname}
</span>
</div>
)}
</div>
<div className="flex-1 space-y-2">
{networkState?.dhcp_lease?.routers &&
networkState?.dhcp_lease?.routers.length > 0 && (
<div className="flex justify-between pt-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
Gateway
</span>
<span className="text-right text-sm font-medium">
{networkState?.dhcp_lease?.routers.map(router => (
<div key={router}>{router}</div>
))}
</span>
</div>
)}
{networkState?.dhcp_lease?.server_id && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
DHCP Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.server_id}
</span>
</div>
)}
{networkState?.dhcp_lease?.lease_expiry && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Lease Expires
</span>
<span className="text-sm font-medium">
<LifeTimeLabel
lifetime={`${networkState?.dhcp_lease?.lease_expiry}`}
/>
</span>
</div>
)}
{networkState?.dhcp_lease?.mtu && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">MTU</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.mtu}
</span>
</div>
)}
{networkState?.dhcp_lease?.ttl && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">TTL</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.ttl}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_next_server && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Next Server
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_next_server}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_server_name && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot Server Name
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_server_name}
</span>
</div>
)}
{networkState?.dhcp_lease?.bootp_file && (
<div className="flex justify-between border-t border-slate-800/10 pt-2 dark:border-slate-300/20">
<span className="text-sm text-slate-600 dark:text-slate-400">
Boot File
</span>
<span className="text-sm font-medium">
{networkState?.dhcp_lease?.bootp_file}
</span>
</div>
)}
</div>
</div>
<div>
<Button
size="SM"
theme="light"
className="text-red-500"
text="Renew DHCP Lease"
LeadingIcon={LuRefreshCcw}
onClick={() => setShowRenewLeaseConfirm(true)}
/>
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -30,7 +30,7 @@ export default function EmptyCard({
<div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]"> <div className="max-w-[90%] space-y-1.5 text-center md:max-w-[60%]">
<div className="space-y-2"> <div className="space-y-2">
{IconElm && ( {IconElm && (
<IconElm className="mx-auto h-6 w-6 text-blue-600 dark:text-blue-400" /> <IconElm className="mx-auto h-5 w-5 text-blue-600 dark:text-blue-600" />
)} )}
<h4 className="text-base font-bold leading-none text-black dark:text-white"> <h4 className="text-base font-bold leading-none text-black dark:text-white">
{headline} {headline}

View File

@ -1,8 +1,8 @@
export default function GridBackground() { export default function GridBackground() {
return ( return (
<div className="absolute w-screen h-screen overflow-hidden isolate opacity-60"> <div className="absolute isolate h-screen w-screen overflow-hidden opacity-60">
<svg <svg
className="absolute inset-x-0 top-0 -z-10 h-[64rem] w-full stroke-gray-300 [mask-image:radial-gradient(32rem_32rem_at_center,white,transparent)] dark:stroke-slate-300/20" className="absolute inset-x-0 top-0 -z-10 h-full w-full mask-radial-[32rem_32rem] mask-radial-from-white mask-radial-to-transparent mask-radial-at-center stroke-gray-300 dark:stroke-slate-300/20"
aria-hidden="true" aria-hidden="true"
> >
<defs> <defs>

View File

@ -1,12 +1,11 @@
import { Fragment, useCallback } from "react"; import { useCallback } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid"; import { ArrowLeftEndOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/16/solid";
import { Menu, MenuButton } from "@headlessui/react"; import { Button, Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
import { LuMonitorSmartphone } from "react-icons/lu"; 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 { 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";
@ -17,7 +16,7 @@ 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 { LinkButton } from "./Button";
interface NavbarProps { interface NavbarProps {
isLoggedIn: boolean; isLoggedIn: boolean;
@ -51,8 +50,12 @@ export default function DashboardNavbar({
const usbState = useHidStore(state => state.usbState); const usbState = useHidStore(state => state.usbState);
// for testing
//userEmail = "user@example.org";
//picture = "https://placehold.co/32x32"
return ( return (
<div className="w-full select-none border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900"> <div className="w-full border-b border-b-slate-800/20 bg-white select-none dark:border-b-slate-300/20 dark:bg-slate-900">
<Container> <Container>
<div className="flex h-14 items-center justify-between"> <div className="flex h-14 items-center justify-between">
<div className="flex shrink-0 items-center gap-x-8"> <div className="flex shrink-0 items-center gap-x-8">
@ -78,8 +81,9 @@ export default function DashboardNavbar({
</div> </div>
<div className="flex w-full items-center justify-end gap-x-2"> <div className="flex w-full items-center justify-end gap-x-2">
<div className="flex shrink-0 items-center space-x-4"> <div className="flex shrink-0 items-center space-x-4">
<div className="hidden items-stretch gap-x-2 md:flex">
{showConnectionStatus && ( {showConnectionStatus && (
<div className="hidden items-center gap-x-2 md:flex"> <>
<div className="w-[159px]"> <div className="w-[159px]">
<PeerConnectionStatusCard <PeerConnectionStatusCard
state={peerConnectionState} state={peerConnectionState}
@ -92,75 +96,70 @@ export default function DashboardNavbar({
peerConnectionState={peerConnectionState} peerConnectionState={peerConnectionState}
/> />
</div> </div>
</div> </>
)} )}
{isLoggedIn ? ( {isLoggedIn ? (
<> <>
<hr className="h-[20px] w-[1px] border-none bg-slate-800/20 dark:bg-slate-300/20" /> <hr className="h-[20px] w-px self-center border-none bg-slate-800/20 dark:bg-slate-300/20" />
<Menu as="div" className="relative inline-block text-left"> <div className="relative inline-block text-left">
<div> <Menu>
<MenuButton as={Fragment}> <MenuButton className="h-full">
<Button <Button className="flex h-full items-center gap-x-3 rounded-md border border-slate-800/20 bg-white px-2 py-1.5 dark:border-slate-600 dark:bg-slate-800 dark:text-white">
theme="blank" {picture ? (
size="SM"
text={
<>
{picture ? <></> : userEmail}
<ChevronDownIcon className="h-4 w-4 shrink-0 text-slate-900 dark:text-white" />
</>
}
LeadingIcon={({ className }) =>
picture && (
<img <img
src={picture} src={picture}
alt="Avatar" alt="Avatar"
className={cx( className="size-6 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700"
className,
"h-8 w-8 rounded-full border-2 border-transparent transition-colors group-hover:border-blue-700",
)}
/>
)
}
/> />
) : userEmail ? (
<span className="font-display max-w-[200px] truncate text-sm/6 font-semibold">
{userEmail}
</span>
) : null}
<ChevronDownIcon className="size-4 shrink-0 text-slate-900 dark:text-white" />
</Button>
</MenuButton> </MenuButton>
</div> <MenuItems
transition
<Menu.Items className="absolute right-0 z-50 mt-2 w-56 origin-top-right focus:outline-none"> anchor="bottom end"
className="right-0 mt-1 w-56 origin-top-right p-px focus:outline-hidden data-closed:opacity-0"
>
<MenuItem>
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<div className="space-y-1 p-1 dark:text-white">
{userEmail && ( {userEmail && (
<div className="space-y-1 p-1 dark:text-white">
<div className="border-b border-b-slate-800/20 dark:border-slate-300/20"> <div className="border-b border-b-slate-800/20 dark:border-slate-300/20">
<Menu.Item>
<div className="p-2"> <div className="p-2">
<div className="font-display text-xs">Logged in as</div> <div className="font-display text-xs">
<div className="w-[200px] truncate font-display text-sm font-semibold"> Logged in as
</div>
<div className="font-display max-w-[200px] truncate text-sm font-semibold">
{userEmail} {userEmail}
</div> </div>
</div> </div>
</Menu.Item> </div>
</div> </div>
)} )}
<div> <div
<Menu.Item> className="space-y-1 p-1 dark:text-white"
<div onClick={onLogout}> 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"> <button className="group flex w-full items-center gap-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" /> <ArrowLeftEndOnRectangleIcon className="size-4" />
<div className="font-display">Log out</div> <div className="font-display">Log out</div>
</div>
</button> </button>
</div> </div>
</Menu.Item>
</div>
</div>
</Card> </Card>
</Menu.Items> </MenuItem>
</MenuItems>
</Menu> </Menu>
</div>
</> </>
) : null} ) : null}
</div> </div>
</div> </div>
</div> </div>
</div>
</Container> </Container>
</div> </div>
); );

View File

@ -28,6 +28,7 @@ export default function InfoBar() {
const rpcDataChannel = useRTCStore(state => state.rpcDataChannel); const rpcDataChannel = useRTCStore(state => state.rpcDataChannel);
const settings = useSettingsStore(); const settings = useSettingsStore();
const showPressedKeys = useSettingsStore(state => state.showPressedKeys);
useEffect(() => { useEffect(() => {
if (!rpcDataChannel) return; if (!rpcDataChannel) return;
@ -36,9 +37,9 @@ export default function InfoBar() {
console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`); console.log(`Error on DataChannel '${rpcDataChannel.label}': ${e}`);
}, [rpcDataChannel]); }, [rpcDataChannel]);
const isCapsLockActive = useHidStore(state => state.isCapsLockActive); const keyboardLedState = useHidStore(state => state.keyboardLedState);
const isNumLockActive = useHidStore(state => state.isNumLockActive); const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const isScrollLockActive = useHidStore(state => state.isScrollLockActive); const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse); const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
@ -97,6 +98,7 @@ export default function InfoBar() {
</div> </div>
)} )}
{showPressedKeys && (
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<span className="text-xs font-semibold">Keys:</span> <span className="text-xs font-semibold">Keys:</span>
<h2 className="text-xs"> <h2 className="text-xs">
@ -110,6 +112,7 @@ export default function InfoBar() {
].join(", ")} ].join(", ")}
</h2> </h2>
</div> </div>
)}
</div> </div>
</div> </div>
<div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20"> <div className="flex items-center divide-x first:divide-l divide-slate-800/20 dark:divide-slate-300/20">
@ -118,10 +121,24 @@ export default function InfoBar() {
Relayed by Cloudflare Relayed by Cloudflare
</div> </div>
)} )}
{keyboardLedStateSyncAvailable ? (
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isCapsLockActive keyboardLedSync !== "browser"
? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20",
)}
title={"Your keyboard LED state is managed by" + (keyboardLedSync === "browser" ? " the browser" : " the host")}
>
{keyboardLedSync === "browser" ? "Browser" : "Host"}
</div>
) : null}
<div
className={cx(
"shrink-0 p-1 px-1.5 text-xs",
keyboardLedState?.caps_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -131,7 +148,7 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isNumLockActive keyboardLedState?.num_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
@ -141,13 +158,23 @@ export default function InfoBar() {
<div <div
className={cx( className={cx(
"shrink-0 p-1 px-1.5 text-xs", "shrink-0 p-1 px-1.5 text-xs",
isScrollLockActive keyboardLedState?.scroll_lock
? "text-black dark:text-white" ? "text-black dark:text-white"
: "text-slate-800/20 dark:text-slate-300/20", : "text-slate-800/20 dark:text-slate-300/20",
)} )}
> >
Scroll Lock Scroll Lock
</div> </div>
{keyboardLedState?.compose ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Compose
</div>
) : null}
{keyboardLedState?.kana ? (
<div className="shrink-0 p-1 px-1.5 text-xs">
Kana
</div>
) : null}
</div> </div>
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function InputF
"[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2", "[&:has(:user-invalid)]:ring-2 [&:has(:user-invalid)]:ring-red-600 [&:has(:user-invalid)]:ring-offset-2",
// Focus Within // Focus Within
"focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-none focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2", "focus-within:border-slate-300 dark:focus-within:border-slate-600 focus-within:outline-hidden focus-within:ring-2 focus-within:ring-blue-700 focus-within:ring-offset-2",
// Disabled Within // Disabled Within
"disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80", "disabled-within:pointer-events-none disabled-within:select-none disabled-within:bg-slate-50 dark:disabled-within:bg-slate-800 disabled-within:text-slate-500/80",

View File

@ -0,0 +1,93 @@
import { NetworkState } from "../hooks/stores";
import { LifeTimeLabel } from "../routes/devices.$id.settings.network";
import { GridCard } from "./Card";
export default function Ipv6NetworkCard({
networkState,
}: {
networkState: NetworkState;
}) {
return (
<GridCard>
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
<div className="space-y-4">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
IPv6 Information
</h3>
<div className="grid grid-cols-2 gap-x-6 gap-y-2">
{networkState?.dhcp_lease?.ip && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Link-local
</span>
<span className="text-sm font-medium">
{networkState?.ipv6_link_local}
</span>
</div>
)}
</div>
<div className="space-y-3 pt-2">
{networkState?.ipv6_addresses && networkState?.ipv6_addresses.length > 0 && (
<div className="space-y-3">
<h4 className="text-sm font-semibold">IPv6 Addresses</h4>
{networkState.ipv6_addresses.map(
addr => (
<div
key={addr.address}
className="rounded-md rounded-l-none border border-slate-500/10 border-l-blue-700/50 bg-white p-4 pl-4 backdrop-blur-sm dark:bg-transparent"
>
<div className="grid grid-cols-2 gap-x-8 gap-y-4">
<div className="col-span-2 flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Address
</span>
<span className="text-sm font-medium">{addr.address}</span>
</div>
{addr.valid_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Valid Lifetime
</span>
<span className="text-sm font-medium">
{addr.valid_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.valid_lifetime}`} />
)}
</span>
</div>
)}
{addr.preferred_lifetime && (
<div className="flex flex-col justify-between">
<span className="text-sm text-slate-600 dark:text-slate-400">
Preferred Lifetime
</span>
<span className="text-sm font-medium">
{addr.preferred_lifetime === "" ? (
<span className="text-slate-400 dark:text-slate-600">
N/A
</span>
) : (
<LifeTimeLabel lifetime={`${addr.preferred_lifetime}`} />
)}
</span>
</div>
)}
</div>
</div>
),
)}
</div>
)}
</div>
</div>
</div>
</GridCard>
);
}

View File

@ -78,7 +78,7 @@ export default function KvmCard({
)} )}
</div> </div>
</div> </div>
<div className="h-[1px] bg-slate-800/20 dark:bg-slate-300/20" /> <div className="h-px bg-slate-800/20 dark:bg-slate-300/20" />
<div className="flex justify-between"> <div className="flex justify-between">
<div> <div>
{online ? ( {online ? (
@ -111,9 +111,9 @@ export default function KvmCard({
<MenuItems <MenuItems
transition transition
className="data-[closed]:scale-95 data-[closed]:transform data-[closed]:opacity-0 data-[enter]:duration-100 data-[leave]:duration-75 data-[enter]:ease-out data-[leave]:ease-in" className="data-closed:scale-95 data-closed:transform data-closed:opacity-0 data-enter:duration-100 data-leave:duration-75 data-enter:ease-out data-leave:ease-in"
> >
<Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black ring-opacity-5 focus:outline-none"> <Card className="absolute right-0 z-10 w-56 px-1 mt-2 transition origin-top-right ring-1 ring-black/50 focus:outline-hidden">
<div className="divide-y divide-slate-800/20 dark:divide-slate-300/20"> <div className="divide-y divide-slate-800/20 dark:divide-slate-300/20">
<MenuItem> <MenuItem>
<div> <div>

View File

@ -7,7 +7,7 @@ export default function LoadingSpinner({
}) { }) {
return ( return (
<svg <svg
className={clsx(className, "flex-shrink-0 animate-spin p-[2px]")} className={clsx(className, "shrink-0 animate-spin p-[2px]")}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"

View File

@ -1,5 +1,4 @@
import { useState } from "react"; import { useState } from "react";
import { LuPlus } from "react-icons/lu"; import { LuPlus } from "react-icons/lu";
import { KeySequence } from "@/hooks/stores"; import { KeySequence } from "@/hooks/stores";
@ -7,16 +6,23 @@ import { Button } from "@/components/Button";
import { InputFieldWithLabel, FieldError } from "@/components/InputField"; import { InputFieldWithLabel, FieldError } from "@/components/InputField";
import Fieldset from "@/components/Fieldset"; import Fieldset from "@/components/Fieldset";
import { MacroStepCard } from "@/components/MacroStepCard"; import { MacroStepCard } from "@/components/MacroStepCard";
import { DEFAULT_DELAY, MAX_STEPS_PER_MACRO, MAX_KEYS_PER_STEP } from "@/constants/macros"; import {
DEFAULT_DELAY,
MAX_STEPS_PER_MACRO,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
import FieldLabel from "@/components/FieldLabel"; import FieldLabel from "@/components/FieldLabel";
interface ValidationErrors { interface ValidationErrors {
name?: string; name?: string;
steps?: Record<number, { steps?: Record<
number,
{
keys?: string; keys?: string;
modifiers?: string; modifiers?: string;
delay?: string; delay?: string;
}>; }
>;
} }
interface MacroFormProps { interface MacroFormProps {
@ -57,12 +63,14 @@ export function MacroForm({
if (!macro.steps?.length) { if (!macro.steps?.length) {
newErrors.steps = { 0: { keys: "At least one step is required" } }; newErrors.steps = { 0: { keys: "At least one step is required" } };
} else { } else {
const hasKeyOrModifier = macro.steps.some(step => const hasKeyOrModifier = macro.steps.some(
(step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0 step => (step.keys?.length || 0) > 0 || (step.modifiers?.length || 0) > 0,
); );
if (!hasKeyOrModifier) { if (!hasKeyOrModifier) {
newErrors.steps = { 0: { keys: "At least one step must have keys or modifiers" } }; newErrors.steps = {
0: { keys: "At least one step must have keys or modifiers" },
};
} }
} }
@ -87,7 +95,10 @@ export function MacroForm({
} }
}; };
const handleKeySelect = (stepIndex: number, option: { value: string | null; keys?: string[] }) => { const handleKeySelect = (
stepIndex: number,
option: { value: string | null; keys?: string[] },
) => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
if (!newSteps[stepIndex]) return; if (!newSteps[stepIndex]) return;
@ -97,7 +108,9 @@ export function MacroForm({
if (!newSteps[stepIndex].keys) { if (!newSteps[stepIndex].keys) {
newSteps[stepIndex].keys = []; newSteps[stepIndex].keys = [];
} }
const keysArray = Array.isArray(newSteps[stepIndex].keys) ? newSteps[stepIndex].keys : []; const keysArray = Array.isArray(newSteps[stepIndex].keys)
? newSteps[stepIndex].keys
: [];
if (keysArray.length >= MAX_KEYS_PER_STEP) { if (keysArray.length >= MAX_KEYS_PER_STEP) {
showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`); showTemporaryError(`Maximum of ${MAX_KEYS_PER_STEP} keys per step allowed`);
return; return;
@ -148,9 +161,9 @@ export function MacroForm({
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
const handleStepMove = (stepIndex: number, direction: 'up' | 'down') => { const handleStepMove = (stepIndex: number, direction: "up" | "down") => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
const newIndex = direction === 'up' ? stepIndex - 1 : stepIndex + 1; const newIndex = direction === "up" ? stepIndex - 1 : stepIndex + 1;
[newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]]; [newSteps[stepIndex], newSteps[newIndex]] = [newSteps[newIndex], newSteps[stepIndex]];
setMacro({ ...macro, steps: newSteps }); setMacro({ ...macro, steps: newSteps });
}; };
@ -181,7 +194,10 @@ export function MacroForm({
<div> <div>
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<FieldLabel label="Steps" description={`Keys/modifiers executed in sequence with a delay between each step.`} /> <FieldLabel
label="Steps"
description={`Keys/modifiers executed in sequence with a delay between each step.`}
/>
</div> </div>
<span className="text-slate-500 dark:text-slate-400"> <span className="text-slate-500 dark:text-slate-400">
{macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps {macro.steps?.length || 0}/{MAX_STEPS_PER_MACRO} steps
@ -199,18 +215,24 @@ export function MacroForm({
key={stepIndex} key={stepIndex}
step={step} step={step}
stepIndex={stepIndex} stepIndex={stepIndex}
onDelete={macro.steps && macro.steps.length > 1 ? () => { onDelete={
macro.steps && macro.steps.length > 1
? () => {
const newSteps = [...(macro.steps || [])]; const newSteps = [...(macro.steps || [])];
newSteps.splice(stepIndex, 1); newSteps.splice(stepIndex, 1);
setMacro(prev => ({ ...prev, steps: newSteps })); setMacro(prev => ({ ...prev, steps: newSteps }));
} : undefined} }
onMoveUp={() => handleStepMove(stepIndex, 'up')} : undefined
onMoveDown={() => handleStepMove(stepIndex, 'down')} }
onKeySelect={(option) => handleKeySelect(stepIndex, option)} onMoveUp={() => handleStepMove(stepIndex, "up")}
onKeyQueryChange={(query) => handleKeyQueryChange(stepIndex, query)} onMoveDown={() => handleStepMove(stepIndex, "down")}
keyQuery={keyQueries[stepIndex] || ''} onKeySelect={option => handleKeySelect(stepIndex, option)}
onModifierChange={(modifiers) => handleModifierChange(stepIndex, modifiers)} onKeyQueryChange={query => handleKeyQueryChange(stepIndex, query)}
onDelayChange={(delay) => handleDelayChange(stepIndex, delay)} keyQuery={keyQueries[stepIndex] || ""}
onModifierChange={modifiers =>
handleModifierChange(stepIndex, modifiers)
}
onDelayChange={delay => handleDelayChange(stepIndex, delay)}
isLastStep={stepIndex === (macro.steps?.length || 0) - 1} isLastStep={stepIndex === (macro.steps?.length || 0) - 1}
/> />
))} ))}
@ -223,10 +245,12 @@ export function MacroForm({
theme="light" theme="light"
fullWidth fullWidth
LeadingIcon={LuPlus} LeadingIcon={LuPlus}
text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ''}`} text={`Add Step ${isMaxStepsReached ? `(${MAX_STEPS_PER_MACRO} max)` : ""}`}
onClick={() => { onClick={() => {
if (isMaxStepsReached) { if (isMaxStepsReached) {
showTemporaryError(`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`); showTemporaryError(
`You can only add a maximum of ${MAX_STEPS_PER_MACRO} steps per macro.`,
);
return; return;
} }
@ -234,7 +258,7 @@ export function MacroForm({
...prev, ...prev,
steps: [ steps: [
...(prev.steps || []), ...(prev.steps || []),
{ keys: [], modifiers: [], delay: DEFAULT_DELAY } { keys: [], modifiers: [], delay: DEFAULT_DELAY },
], ],
})); }));
setErrors({}); setErrors({});
@ -257,12 +281,7 @@ export function MacroForm({
onClick={handleSubmit} onClick={handleSubmit}
disabled={isSubmitting} disabled={isSubmitting}
/> />
<Button <Button size="SM" theme="light" text="Cancel" onClick={onCancel} />
size="SM"
theme="light"
text="Cancel"
onClick={onCancel}
/>
</div> </div>
</div> </div>
</div> </div>

View File

@ -18,16 +18,18 @@ const Modal = React.memo(function Modal({
<Dialog open={open} onClose={onClose} className="relative z-20"> <Dialog open={open} onClose={onClose} className="relative z-20">
<DialogBackdrop <DialogBackdrop
transition transition
className="fixed inset-0 bg-gray-500/75 transition-opacity data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in dark:bg-slate-900/90" className="fixed inset-0 bg-gray-500/75 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-20 w-screen overflow-y-auto" style={{
scrollbarGutter: 'stable'
}}>
{/* TODO: This doesn't work well with other-sessions */} {/* TODO: This doesn't work well with other-sessions */}
<div className="flex min-h-full items-end justify-center p-4 text-center md:items-baseline md:p-4"> <div className="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 md:my-8 md:!mt-[10vh]", "pointer-events-none relative w-full md:my-8 md:mt-[10vh]!",
"transform transition-all data-[closed]:translate-y-8 data-[closed]:opacity-0 data-[enter]:duration-500 data-[leave]:duration-200 data-[enter]:ease-out data-[leave]:ease-in", "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,
)} )}
> >

View File

@ -63,7 +63,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
)} )}
> >
{label && <FieldLabel label={label} id={id} as="span" />} {label && <FieldLabel label={label} id={id} as="span" />}
<Card className="w-auto !border border-solid !border-slate-800/30 shadow outline-0 dark:!border-slate-300/30"> <Card className="w-auto border! border-solid border-slate-800/30! shadow-xs outline-0 dark:border-slate-300/30!">
<select <select
ref={ref} ref={ref}
name={name} name={name}
@ -72,7 +72,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
classes, classes,
// General styling // General styling
"block w-full cursor-pointer rounded border-none py-0 font-medium shadow-none outline-0 transition duration-300", "block w-full cursor-pointer rounded-sm 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", "hover:bg-blue-50/80 active:bg-blue-100/60 disabled:hover:bg-white",

View File

@ -23,7 +23,7 @@ const variants = cva({
export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) { export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props) {
const textStyle = variants({ size }); const textStyle = variants({ size });
return ( return (
<Card className="!inline-flex w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1"> <Card className="inline-flex! w-auto select-none items-center justify-center gap-x-2 rounded-lg p-1">
{[...Array(nSteps).keys()].map(i => { {[...Array(nSteps).keys()].map(i => {
if (i < currStepIdx) { if (i < currStepIdx) {
return ( return (
@ -44,7 +44,7 @@ export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props)
return ( return (
<div <div
className={cx( className={cx(
"rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-sm dark:border-blue-300", "rounded-md border border-blue-800 bg-blue-700 px-2 py-1 font-medium text-white shadow-xs dark:border-blue-300",
textStyle, textStyle,
)} )}
key={`${i}-${currStepIdx}`} key={`${i}-${currStepIdx}`}
@ -57,7 +57,7 @@ export default function StepCounter({ nSteps, currStepIdx, size = "MD" }: Props)
return ( return (
<Card <Card
className={cx( className={cx(
"flex items-center justify-center !rounded-full text-slate-600 dark:text-slate-400", "flex items-center justify-center rounded-full! text-slate-600 dark:text-slate-400",
textStyle, textStyle,
size === "SM" ? "h-5 w-5" : "h-6 w-6", size === "SM" ? "h-5 w-5" : "h-6 w-6",
)} )}

View File

@ -61,9 +61,9 @@ function Terminal({
dataChannel, dataChannel,
type, type,
}: { }: {
title: string; readonly title: string;
dataChannel: RTCDataChannel; readonly dataChannel: RTCDataChannel;
type: AvailableTerminalTypes; readonly type: AvailableTerminalTypes;
}) { }) {
const enableTerminal = useUiStore(state => state.terminalType == type); const enableTerminal = useUiStore(state => state.terminalType == type);
const setTerminalType = useUiStore(state => state.setTerminalType); const setTerminalType = useUiStore(state => state.setTerminalType);
@ -171,12 +171,12 @@ function Terminal({
[ [
// Base styles // Base styles
"fixed bottom-0 w-full transform transition duration-500 ease-in-out", "fixed bottom-0 w-full transform transition duration-500 ease-in-out",
"translate-y-[0px]", "-translate-y-[0px]",
], ],
{ {
"pointer-events-none translate-y-[500px] opacity-100 transition duration-300": "pointer-events-none translate-y-[500px] opacity-100 transition duration-300":
!enableTerminal, !enableTerminal,
"pointer-events-auto translate-y-[0px] opacity-100 transition duration-300": "pointer-events-auto -translate-y-[0px] opacity-100 transition duration-300":
enableTerminal, enableTerminal,
}, },
)} )}

View File

@ -17,7 +17,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
className={cx( className={cx(
"relative w-full", "relative w-full",
"invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2", "invalid-within::ring-2 invalid-within::ring-red-600 invalid-within::ring-offset-2",
"focus-within:border-slate-300 focus-within:outline-none focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600", "focus-within:border-slate-300 focus-within:outline-hidden focus-within:ring-1 focus-within:ring-blue-700 dark:focus-within:border-slate-600",
)} )}
> >
<textarea <textarea
@ -25,7 +25,7 @@ const TextArea = React.forwardRef<HTMLTextAreaElement, TextAreaProps>(
{...props} {...props}
id="asd" id="asd"
className={clsx( className={clsx(
"block w-full rounded border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm", "block w-full rounded-sm border-transparent bg-transparent text-black placeholder:text-slate-300 focus:ring-0 disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-300 dark:text-white dark:placeholder:text-slate-500 dark:disabled:bg-slate-800 sm:text-sm",
props.className, props.className,
)} )}
/> />

View File

@ -11,7 +11,7 @@ export default function UpdateInProgressStatusCard() {
return ( return (
<div className="w-full select-none opacity-100 transition-all duration-300 ease-in-out"> <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")} />

View File

@ -150,7 +150,7 @@ export function UsbDeviceSetting() {
return ( return (
<Fieldset disabled={loading} className="space-y-4"> <Fieldset disabled={loading} className="space-y-4">
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" /> <div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader <SettingsSectionHeader
title="USB Device" title="USB Device"

View File

@ -3,18 +3,18 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid"; import { ArrowPathIcon, ArrowRightIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { LuPlay } from "react-icons/lu"; import { LuPlay } from "react-icons/lu";
import { BsMouseFill } from "react-icons/bs";
import { Button, LinkButton } from "@components/Button"; import { Button, LinkButton } from "@components/Button";
import LoadingSpinner from "@components/LoadingSpinner"; import LoadingSpinner from "@components/LoadingSpinner";
import Card, { GridCard } from "@components/Card"; import Card, { GridCard } from "@components/Card";
import { BsMouseFill } from "react-icons/bs";
interface OverlayContentProps { interface OverlayContentProps {
children: React.ReactNode; readonly children: React.ReactNode;
} }
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-hidden!">
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20"> <div className="flex 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>
@ -23,7 +23,7 @@ function OverlayContent({ children }: OverlayContentProps) {
} }
interface LoadingOverlayProps { interface LoadingOverlayProps {
show: boolean; readonly show: boolean;
} }
export function LoadingVideoOverlay({ show }: LoadingOverlayProps) { export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
@ -57,8 +57,8 @@ export function LoadingVideoOverlay({ show }: LoadingOverlayProps) {
} }
interface LoadingConnectionOverlayProps { interface LoadingConnectionOverlayProps {
show: boolean; readonly show: boolean;
text: string; readonly text: string;
} }
export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) { export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverlayProps) {
return ( return (
@ -91,8 +91,8 @@ export function LoadingConnectionOverlay({ show, text }: LoadingConnectionOverla
} }
interface ConnectionErrorOverlayProps { interface ConnectionErrorOverlayProps {
show: boolean; readonly show: boolean;
setupPeerConnection: () => Promise<void>; readonly setupPeerConnection: () => Promise<void>;
} }
export function ConnectionFailedOverlay({ export function ConnectionFailedOverlay({
@ -153,7 +153,7 @@ export function ConnectionFailedOverlay({
} }
interface PeerConnectionDisconnectedOverlay { interface PeerConnectionDisconnectedOverlay {
show: boolean; readonly show: boolean;
} }
export function PeerConnectionDisconnectedOverlay({ export function PeerConnectionDisconnectedOverlay({
@ -207,8 +207,8 @@ export function PeerConnectionDisconnectedOverlay({
} }
interface HDMIErrorOverlayProps { interface HDMIErrorOverlayProps {
show: boolean; readonly show: boolean;
hdmiState: string; readonly hdmiState: string;
} }
export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) { export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
@ -242,8 +242,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
Ensure source device is powered on and outputting a signal Ensure source device is powered on and outputting a signal
</li> </li>
<li> <li>
If using an adapter, ensure it&apos;s compatible and If using an adapter, ensure it&apos;s compatible and functioning
functioning correctly correctly
</li> </li>
</ul> </ul>
</div> </div>
@ -310,8 +310,8 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
} }
interface NoAutoplayPermissionsOverlayProps { interface NoAutoplayPermissionsOverlayProps {
show: boolean; readonly show: boolean;
onPlayClick: () => void; readonly onPlayClick: () => void;
} }
export function NoAutoplayPermissionsOverlay({ export function NoAutoplayPermissionsOverlay({
@ -361,7 +361,7 @@ export function NoAutoplayPermissionsOverlay({
} }
interface PointerLockBarProps { interface PointerLockBarProps {
show: boolean; readonly show: boolean;
} }
export function PointerLockBar({ show }: PointerLockBarProps) { export function PointerLockBar({ show }: PointerLockBarProps) {
@ -369,15 +369,15 @@ export function PointerLockBar({ show }: PointerLockBarProps) {
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{show ? ( {show ? (
<motion.div <motion.div
className="absolute -top-[36px] left-0 right-0 z-20 bg-white" className="flex w-full items-center justify-between bg-transparent"
initial={{ y: 20, opacity: 0, zIndex: 0 }} initial={{ opacity: 0, zIndex: 0 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, zIndex: 20 }}
exit={{ y: 43, zIndex: 0 }} exit={{ opacity: 0, zIndex: 0 }}
transition={{ duration: 0.5, ease: "easeInOut", delay: 0.5 }} transition={{ duration: 0.5, ease: "easeInOut", delay: 0.5 }}
> >
<div> <div>
<Card className="rounded-b-none shadow-none !outline-0"> <Card className="rounded-b-none shadow-none outline-0!">
<div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-sm dark:border-slate-300/20 dark:bg-slate-800"> <div className="flex items-center justify-between border border-slate-800/50 px-4 py-2 outline-0 backdrop-blur-xs dark:border-slate-300/20 dark:bg-slate-800">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" /> <BsMouseFill className="h-4 w-4 text-blue-700 dark:text-blue-500" />
<span className="text-sm text-black dark:text-white"> <span className="text-sm text-black dark:text-white">

View File

@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useShallow } from "zustand/react/shallow";
import Keyboard from "react-simple-keyboard";
import { ChevronDownIcon } from "@heroicons/react/16/solid"; import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { motion, AnimatePresence } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Keyboard from "react-simple-keyboard";
import Card from "@components/Card"; import Card from "@components/Card";
// eslint-disable-next-line import/order // eslint-disable-next-line import/order
@ -9,12 +10,12 @@ import { Button } from "@components/Button";
import "react-simple-keyboard/build/css/index.css"; import "react-simple-keyboard/build/css/index.css";
import { useHidStore, useUiStore } from "@/hooks/stores";
import { cx } from "@/cva.config";
import { keys, modifiers, keyDisplayMap } from "@/keyboardMappings";
import useKeyboard from "@/hooks/useKeyboard";
import DetachIconRaw from "@/assets/detach-icon.svg";
import AttachIconRaw from "@/assets/attach-icon.svg"; import AttachIconRaw from "@/assets/attach-icon.svg";
import DetachIconRaw from "@/assets/detach-icon.svg";
import { cx } from "@/cva.config";
import { useHidStore, useSettingsStore, useUiStore } from "@/hooks/stores";
import useKeyboard from "@/hooks/useKeyboard";
import { keyDisplayMap, keys, modifiers } from "@/keyboardMappings";
export const DetachIcon = ({ className }: { className?: string }) => { export const DetachIcon = ({ className }: { className?: string }) => {
return <img src={DetachIconRaw} alt="Detach Icon" className={className} />; return <img src={DetachIconRaw} alt="Detach Icon" className={className} />;
@ -40,7 +41,17 @@ function KeyboardWrapper() {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 0 }); const [position, setPosition] = useState({ x: 0, y: 0 });
const [newPosition, setNewPosition] = useState({ x: 0, y: 0 }); const [newPosition, setNewPosition] = useState({ x: 0, y: 0 });
const isCapsLockActive = useHidStore(state => state.isCapsLockActive);
const isCapsLockActive = useHidStore(useShallow(state => state.keyboardLedState?.caps_lock));
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive); const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const startDrag = useCallback((e: MouseEvent | TouchEvent) => { const startDrag = useCallback((e: MouseEvent | TouchEvent) => {
@ -143,18 +154,30 @@ function KeyboardWrapper() {
return; return;
} }
if (key === "CtrlAltBackspace") {
sendKeyboardEvent(
[keys["Backspace"]],
[modifiers["ControlLeft"], modifiers["AltLeft"]],
);
setTimeout(resetKeyboardState, 100);
return;
}
if (isKeyShift || isKeyCaps) { if (isKeyShift || isKeyCaps) {
toggleLayout(); toggleLayout();
if (isCapsLockActive) { if (isCapsLockActive) {
if (!isKeyboardLedManagedByHost) {
setIsCapsLockActive(false); setIsCapsLockActive(false);
}
sendKeyboardEvent([keys["CapsLock"]], []); sendKeyboardEvent([keys["CapsLock"]], []);
return; return;
} }
} }
// Handle caps lock state change // Handle caps lock state change
if (isKeyCaps) { if (isKeyCaps && !isKeyboardLedManagedByHost) {
setIsCapsLockActive(!isCapsLockActive); setIsCapsLockActive(!isCapsLockActive);
} }
@ -173,7 +196,7 @@ function KeyboardWrapper() {
setTimeout(resetKeyboardState, 100); setTimeout(resetKeyboardState, 100);
}, },
[isCapsLockActive, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive], [isCapsLockActive, isKeyboardLedManagedByHost, sendKeyboardEvent, resetKeyboardState, setIsCapsLockActive],
); );
const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled); const virtualKeyboard = useHidStore(state => state.isVirtualKeyboardEnabled);
@ -257,13 +280,13 @@ function KeyboardWrapper() {
buttonTheme={[ buttonTheme={[
{ {
class: "combination-key", class: "combination-key",
buttons: "CtrlAltDelete AltMetaEscape", buttons: "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
}, },
]} ]}
display={keyDisplayMap} display={keyDisplayMap}
layout={{ layout={{
default: [ default: [
"CtrlAltDelete AltMetaEscape", "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", "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", "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", "Tab KeyQ KeyW KeyE KeyR KeyT KeyY KeyU KeyI KeyO KeyP BracketLeft BracketRight Backslash",
@ -272,7 +295,7 @@ function KeyboardWrapper() {
"ControlLeft AltLeft MetaLeft Space MetaRight AltRight", "ControlLeft AltLeft MetaLeft Space MetaRight AltRight",
], ],
shift: [ shift: [
"CtrlAltDelete AltMetaEscape", "CtrlAltDelete AltMetaEscape CtrlAltBackspace",
"Escape F1 F2 F3 F4 F5 F6 F7 F8 F9 F10 F11 F12", "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)", "(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)", "Tab (KeyQ) (KeyW) (KeyE) (KeyR) (KeyT) (KeyY) (KeyU) (KeyI) (KeyO) (KeyP) (BracketLeft) (BracketRight) (Backslash)",
@ -282,7 +305,7 @@ function KeyboardWrapper() {
], ],
}} }}
disableButtonHold={true} disableButtonHold={true}
mergeDisplay={true} syncInstanceInputs={true}
debug={false} debug={false}
/> />
@ -290,34 +313,25 @@ function KeyboardWrapper() {
<Keyboard <Keyboard
baseClass="simple-keyboard-control" baseClass="simple-keyboard-control"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
layoutName={layoutName}
onKeyPress={onKeyDown}
display={keyDisplayMap}
layout={{ layout={{
default: ["Home Pageup", "Delete End Pagedown"], default: ["PrintScreen ScrollLock Pause", "Insert Home Pageup", "Delete End Pagedown"],
}} shift: ["(PrintScreen) ScrollLock (Pause)", "Insert Home Pageup", "Delete End Pagedown"],
display={{
Home: "home",
Pageup: "pageup",
Delete: "delete",
End: "end",
Pagedown: "pagedown",
}} }}
syncInstanceInputs={true} syncInstanceInputs={true}
onKeyPress={onKeyDown}
mergeDisplay={true}
debug={false} debug={false}
/> />
<Keyboard <Keyboard
baseClass="simple-keyboard-arrows" baseClass="simple-keyboard-arrows"
theme="simple-keyboard hg-theme-default hg-layout-default" theme="simple-keyboard hg-theme-default hg-layout-default"
display={{ onKeyPress={onKeyDown}
ArrowLeft: "←", display={keyDisplayMap}
ArrowRight: "→",
ArrowUp: "↑",
ArrowDown: "↓",
}}
layout={{ layout={{
default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"], default: ["ArrowUp", "ArrowLeft ArrowDown ArrowRight"],
}} }}
onKeyPress={onKeyDown} syncInstanceInputs={true}
debug={false} debug={false}
/> />
</div> </div>

View File

@ -1,24 +1,22 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useDeviceSettingsStore,
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useUiStore,
useVideoStore,
} from "@/hooks/stores";
import { keys, modifiers } from "@/keyboardMappings";
import { useResizeObserver } from "usehooks-ts"; import { useResizeObserver } from "usehooks-ts";
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 MacroBar from "@/components/MacroBar";
import InfoBar from "@components/InfoBar"; import InfoBar from "@components/InfoBar";
import notifications from "@/notifications";
import useKeyboard from "@/hooks/useKeyboard"; import useKeyboard from "@/hooks/useKeyboard";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import { cx } from "@/cva.config";
import { keys, modifiers } from "@/keyboardMappings";
import {
useHidStore,
useMouseStore,
useRTCStore,
useSettingsStore,
useVideoStore,
} from "@/hooks/stores";
import { import {
HDMIErrorOverlay, HDMIErrorOverlay,
@ -48,6 +46,23 @@ export default function WebRTCVideo() {
clientHeight: videoClientHeight, clientHeight: videoClientHeight,
} = useVideoStore(); } = useVideoStore();
// Video enhancement settings
const videoSaturation = useSettingsStore(state => state.videoSaturation);
const videoBrightness = useSettingsStore(state => state.videoBrightness);
const videoContrast = useSettingsStore(state => state.videoContrast);
// HID related states
const keyboardLedStateSyncAvailable = useHidStore(state => state.keyboardLedStateSyncAvailable);
const keyboardLedSync = useSettingsStore(state => state.keyboardLedSync);
const isKeyboardLedManagedByHost = useMemo(() =>
keyboardLedSync !== "browser" && keyboardLedStateSyncAvailable,
[keyboardLedSync, keyboardLedStateSyncAvailable],
);
const setIsNumLockActive = useHidStore(state => state.setIsNumLockActive);
const setIsCapsLockActive = useHidStore(state => state.setIsCapsLockActive);
const setIsScrollLockActive = useHidStore(state => state.setIsScrollLockActive);
// RTC related states // RTC related states
const peerConnection = useRTCStore(state => state.peerConnection); const peerConnection = useRTCStore(state => state.peerConnection);
@ -56,13 +71,9 @@ export default function WebRTCVideo() {
const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState); const hdmiError = ["no_lock", "no_signal", "out_of_range"].includes(hdmiState);
const isVideoLoading = !isPlaying; const isVideoLoading = !isPlaying;
// Keyboard related states const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const { setIsNumLockActive, setIsCapsLockActive, setIsScrollLockActive } =
useHidStore();
// Misc states and hooks // Misc states and hooks
const [blockWheelEvent, setBlockWheelEvent] = useState(false);
const disableVideoFocusTrap = useUiStore(state => state.disableVideoFocusTrap);
const [send] = useJsonRpc(); const [send] = useJsonRpc();
// Video-related // Video-related
@ -100,32 +111,77 @@ export default function WebRTCVideo() {
); );
// Pointer lock and keyboard lock related // Pointer lock and keyboard lock related
const isPointerLockPossible = window.location.protocol === "https:"; const isPointerLockPossible = window.location.protocol === "https:" || window.location.hostname === "localhost";
const isFullscreenEnabled = document.fullscreenEnabled;
const checkNavigatorPermissions = useCallback(async (permissionName: string) => { const checkNavigatorPermissions = useCallback(async (permissionName: string) => {
if (!navigator.permissions || !navigator.permissions.query) {
return false; // if can't query permissions, assume NOT granted
}
try {
const name = permissionName as PermissionName; const name = permissionName as PermissionName;
const { state } = await navigator.permissions.query({ name }); const { state } = await navigator.permissions.query({ name });
return state === "granted"; return state === "granted";
} catch {
// ignore errors
}
return false; // if query fails, assume NOT granted
}, []); }, []);
const requestPointerLock = useCallback(async () => { const requestPointerLock = useCallback(async () => {
if (document.pointerLockElement) return; if (!isPointerLockPossible
|| videoElm.current === null
|| document.pointerLockElement) return;
const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock"); const isPointerLockGranted = await checkNavigatorPermissions("pointer-lock");
if (isPointerLockGranted && settings.mouseMode === "relative") { if (isPointerLockGranted && settings.mouseMode === "relative") {
videoElm.current?.requestPointerLock(); try {
await videoElm.current.requestPointerLock();
} catch {
// ignore errors
} }
}, [checkNavigatorPermissions, settings.mouseMode]); }
}, [checkNavigatorPermissions, isPointerLockPossible, settings.mouseMode]);
const requestKeyboardLock = useCallback(async () => {
if (videoElm.current === null) return;
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock");
if (isKeyboardLockGranted && "keyboard" in navigator) {
try {
// @ts-expect-error - keyboard lock is not supported in all browsers
await navigator.keyboard.lock();
} catch {
// ignore errors
}
}
}, [checkNavigatorPermissions]);
const releaseKeyboardLock = useCallback(async () => {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if ("keyboard" in navigator) {
try {
// @ts-expect-error - keyboard unlock is not supported in all browsers
await navigator.keyboard.unlock();
} catch {
// ignore errors
}
}
}, []);
useEffect(() => { useEffect(() => {
if (!isPointerLockPossible || !videoElm.current) return; if (!isPointerLockPossible || !videoElm.current) return;
const handlePointerLockChange = () => { const handlePointerLockChange = () => {
if (document.pointerLockElement) { if (document.pointerLockElement) {
notifications.success("Pointer lock Enabled, hold escape to exit"); notifications.success("Pointer lock Enabled, press escape to unlock");
setIsPointerLockActive(true); setIsPointerLockActive(true);
} else { } else {
notifications.success("Pointer lock disabled"); notifications.success("Pointer lock Disabled");
setIsPointerLockActive(false); setIsPointerLockActive(false);
} }
}; };
@ -138,27 +194,39 @@ export default function WebRTCVideo() {
return () => { return () => {
abortController.abort(); abortController.abort();
}; };
}, [isPointerLockPossible, videoElm]); }, [isPointerLockPossible]);
const requestFullscreen = useCallback(async () => { const requestFullscreen = useCallback(async () => {
videoElm.current?.requestFullscreen({ if (!isFullscreenEnabled || !videoElm.current) return;
navigationUI: "show",
});
// we do not care about pointer lock if it's for fullscreen // per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my
// see multiple messages about how to exit fullscreen. For this reason, we recommend that
// developers call lock() before they enter fullscreen:
await requestKeyboardLock();
await requestPointerLock(); await requestPointerLock();
const isKeyboardLockGranted = await checkNavigatorPermissions("keyboard-lock"); await videoElm.current.requestFullscreen({
if (isKeyboardLockGranted) { navigationUI: "show",
if ("keyboard" in navigator) { });
// @ts-ignore }, [isFullscreenEnabled, requestKeyboardLock, requestPointerLock]);
await navigator.keyboard.lock();
// setup to release the keyboard lock anytime the fullscreen ends
useEffect(() => {
if (!videoElm.current) return;
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
releaseKeyboardLock();
} }
} };
}, [requestPointerLock, checkNavigatorPermissions]);
document.addEventListener("fullscreenchange ", handleFullscreenChange);
}, [releaseKeyboardLock]);
// Mouse-related // Mouse-related
const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos); const calcDelta = (pos: number) => (Math.abs(pos) < 10 ? pos * 2 : pos);
const sendRelMouseMovement = useCallback( const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
@ -173,18 +241,13 @@ export default function WebRTCVideo() {
const relMouseMoveHandler = useCallback( const relMouseMoveHandler = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (settings.mouseMode !== "relative") return; if (settings.mouseMode !== "relative") return;
if (isPointerLockActive === false && isPointerLockPossible === true) return; if (isPointerLockActive === false && isPointerLockPossible) return;
// Send mouse movement // Send mouse movement
const { buttons } = e; const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons); sendRelMouseMovement(e.movementX, e.movementY, buttons);
}, },
[ [isPointerLockActive, isPointerLockPossible, sendRelMouseMovement, settings.mouseMode],
isPointerLockActive,
isPointerLockPossible,
sendRelMouseMovement,
settings.mouseMode,
],
); );
const sendAbsMouseMovement = useCallback( const sendAbsMouseMovement = useCallback(
@ -238,61 +301,43 @@ export default function WebRTCVideo() {
const { buttons } = e; const { buttons } = e;
sendAbsMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[ [settings.mouseMode, videoClientWidth, videoClientHeight, videoWidth, videoHeight, sendAbsMouseMovement],
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;
// Determine if the wheel event is from a trackpad or a mouse wheel if (settings.scrollThrottling && blockWheelEvent) {
const isTrackpad = Math.abs(e.deltaY) < trackpadThreshold; return;
}
// Apply appropriate sensitivity based on input device // Determine if the wheel event is an accel scroll value
const scrollSensitivity = isTrackpad ? trackpadSensitivity : mouseSensitivity; const isAccel = Math.abs(e.deltaY) >= 100;
// Calculate the scroll value // Calculate the accel scroll value
const scroll = e.deltaY * scrollSensitivity; const accelScrollValue = e.deltaY / 100;
// Apply clamping // Calculate the no accel scroll value
const clampedScroll = Math.max(clampMin, Math.min(clampMax, scroll)); const noAccelScrollValue = Math.sign(e.deltaY);
// Round to the nearest integer // Get scroll value
const roundedScroll = Math.round(clampedScroll); const scrollValue = isAccel ? accelScrollValue : noAccelScrollValue;
// Invert the scroll value to match expected behavior // Apply clamping (i.e. min and max mouse wheel hardware value)
const invertedScroll = -roundedScroll; const clampedScrollValue = Math.max(-127, Math.min(127, scrollValue));
send("wheelReport", { wheelY: invertedScroll }); // Invert the clamped scroll value to match expected behavior
const invertedScrollValue = -clampedScrollValue;
// Apply blocking delay send("wheelReport", { wheelY: invertedScrollValue });
// Apply blocking delay based of throttling settings
if (settings.scrollThrottling && !blockWheelEvent) {
setBlockWheelEvent(true); setBlockWheelEvent(true);
setTimeout(() => setBlockWheelEvent(false), blockDelay); setTimeout(() => setBlockWheelEvent(false), settings.scrollThrottling);
}
}, },
[ [send, blockWheelEvent, settings],
blockDelay,
blockWheelEvent,
clampMax,
clampMin,
mouseSensitivity,
send,
trackpadSensitivity,
trackpadThreshold,
],
); );
const resetMousePosition = useCallback(() => { const resetMousePosition = useCallback(() => {
@ -351,11 +396,7 @@ export default function WebRTCVideo() {
// which means the Alt Gr key state would then be "stuck". At this // which means the Alt Gr key state would then be "stuck". At this
// point, we would need to rely on the user to press Alt Gr again // point, we would need to rely on the user to press Alt Gr again
// to properly release the state of that modifier. // to properly release the state of that modifier.
.filter( .filter(modifier => altKey || modifier !== modifiers["AltLeft"])
modifier =>
altKey ||
(modifier !== modifiers["AltLeft"]),
)
// Meta: Keep if Meta is pressed or if the key isn't a Meta key // Meta: Keep if Meta is pressed or if the key isn't a Meta key
// Example: If metaKey is true, keep all modifiers // Example: If metaKey is true, keep all modifiers
// If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight) // If metaKey is false, filter out 0x08 (MetaLeft) and 0x80 (MetaRight)
@ -376,16 +417,11 @@ export default function WebRTCVideo() {
let code = e.code; let code = e.code;
const key = e.key; const key = e.key;
// if (document.activeElement?.id !== "videoFocusTrap") { if (!isKeyboardLedManagedByHost) {
// console.log("KEYUP: Not focusing on the video", document.activeElement);
// return;
// }
// console.log(document.activeElement);
setIsNumLockActive(e.getModifierState("NumLock")); setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock")); setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsScrollLockActive(e.getModifierState("ScrollLock"));
}
if (code == "IntlBackslash" && ["`", "~"].includes(key)) { if (code == "IntlBackslash" && ["`", "~"].includes(key)) {
code = "Backquote"; code = "Backquote";
@ -416,11 +452,12 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive, setIsNumLockActive,
setIsCapsLockActive, setIsCapsLockActive,
setIsScrollLockActive, setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
], ],
); );
@ -429,9 +466,11 @@ export default function WebRTCVideo() {
e.preventDefault(); e.preventDefault();
const prev = useHidStore.getState(); const prev = useHidStore.getState();
if (!isKeyboardLedManagedByHost) {
setIsNumLockActive(e.getModifierState("NumLock")); setIsNumLockActive(e.getModifierState("NumLock"));
setIsCapsLockActive(e.getModifierState("CapsLock")); setIsCapsLockActive(e.getModifierState("CapsLock"));
setIsScrollLockActive(e.getModifierState("ScrollLock")); setIsScrollLockActive(e.getModifierState("ScrollLock"));
}
// Filtering out the key that was just released (keys[e.code]) // Filtering out the key that was just released (keys[e.code])
const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean); const newKeys = prev.activeKeys.filter(k => k !== keys[e.code]).filter(Boolean);
@ -445,22 +484,25 @@ export default function WebRTCVideo() {
sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]); sendKeyboardEvent([...new Set(newKeys)], [...new Set(newModifiers)]);
}, },
[ [
handleModifierKeys,
sendKeyboardEvent,
isKeyboardLedManagedByHost,
setIsNumLockActive, setIsNumLockActive,
setIsCapsLockActive, setIsCapsLockActive,
setIsScrollLockActive, setIsScrollLockActive,
handleModifierKeys,
sendKeyboardEvent,
], ],
); );
const videoKeyUpHandler = useCallback((e: KeyboardEvent) => { const videoKeyUpHandler = useCallback((e: KeyboardEvent) => {
if (!videoElm.current) return;
// In fullscreen mode in chrome & safari, the space key is used to pause/play the video // 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. // 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. // Fix only works in chrome based browsers.
if (e.code === "Space") { if (e.code === "Space") {
if (videoElm.current?.paused == true) { if (videoElm.current.paused) {
console.log("Force playing video"); console.log("Force playing video");
videoElm.current?.play(); videoElm.current.play();
} }
} }
}, []); }, []);
@ -469,7 +511,6 @@ export default function WebRTCVideo() {
(mediaStream: MediaStream) => { (mediaStream: MediaStream) => {
if (!videoElm.current) return; if (!videoElm.current) return;
const videoElmRefValue = videoElm.current; const videoElmRefValue = videoElm.current;
// console.log("Adding stream to video element", videoElmRefValue);
videoElmRefValue.srcObject = mediaStream; videoElmRefValue.srcObject = mediaStream;
updateVideoSizeStore(videoElmRefValue); updateVideoSizeStore(videoElmRefValue);
}, },
@ -485,7 +526,6 @@ export default function WebRTCVideo() {
peerConnection.addEventListener( peerConnection.addEventListener(
"track", "track",
(e: RTCTrackEvent) => { (e: RTCTrackEvent) => {
// console.log("Adding stream to video element");
addStreamToVideoElm(e.streams[0]); addStreamToVideoElm(e.streams[0]);
}, },
{ signal }, { signal },
@ -501,7 +541,6 @@ export default function WebRTCVideo() {
useEffect( useEffect(
function updateVideoStream() { function updateVideoStream() {
if (!mediaStream) return; if (!mediaStream) return;
console.log("Updating video stream from mediaStream");
// We set the as early as possible // We set the as early as possible
addStreamToVideoElm(mediaStream); addStreamToVideoElm(mediaStream);
}, },
@ -523,9 +562,6 @@ export default function WebRTCVideo() {
document.addEventListener("keydown", keyDownHandler, { signal }); document.addEventListener("keydown", keyDownHandler, { signal });
document.addEventListener("keyup", keyUpHandler, { signal }); document.addEventListener("keyup", keyUpHandler, { signal });
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
window.clearKeys = () => sendKeyboardEvent([], []);
window.addEventListener("blur", resetKeyboardState, { signal }); window.addEventListener("blur", resetKeyboardState, { signal });
document.addEventListener("visibilitychange", resetKeyboardState, { signal }); document.addEventListener("visibilitychange", resetKeyboardState, { signal });
@ -533,7 +569,7 @@ export default function WebRTCVideo() {
abortController.abort(); abortController.abort();
}; };
}, },
[keyDownHandler, keyUpHandler, resetKeyboardState, sendKeyboardEvent], [keyDownHandler, keyUpHandler, resetKeyboardState],
); );
// Setup Video Event Listeners // Setup Video Event Listeners
@ -555,78 +591,41 @@ export default function WebRTCVideo() {
abortController.abort(); abortController.abort();
}; };
}, },
[ [onVideoPlaying, videoKeyUpHandler],
absMouseMoveHandler,
resetMousePosition,
onVideoPlaying,
mouseWheelHandler,
videoKeyUpHandler,
],
); );
// Setup Absolute Mouse Events // Setup Mouse Events
useEffect( useEffect(
function setAbsoluteMouseModeEventListeners() { function setMouseModeEventListeners() {
const videoElmRefValue = videoElm.current; const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return; if (!videoElmRefValue) return;
const isRelativeMouseMode = (settings.mouseMode === "relative");
if (settings.mouseMode !== "absolute") return;
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("mousemove", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", isRelativeMouseMode ? relMouseMoveHandler : absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerup", isRelativeMouseMode ? relMouseMoveHandler :absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, signal,
passive: true, passive: true,
}); });
// Reset the mouse position when the window is blurred or the document is hidden if (isRelativeMouseMode) {
const local = resetMousePosition; videoElmRefValue.addEventListener("click",
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;
// Relative mouse mode should only be active if the pointer lock is active and Pointer Lock is possible
const videoElmRefValue = videoElm.current;
if (!videoElmRefValue) return;
const abortController = new AbortController();
const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", relMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", relMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", relMouseMoveHandler, { signal });
videoElmRefValue.addEventListener(
"click",
() => { () => {
if (isPointerLockPossible && !document.pointerLockElement) { if (isPointerLockPossible && !isPointerLockActive && !document.pointerLockElement) {
requestPointerLock(); requestPointerLock();
} }
}, },
{ signal }, { signal },
); );
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { } else {
signal, // Reset the mouse position when the window is blurred or the document is hidden
passive: true, window.addEventListener("blur", resetMousePosition, { signal });
}); document.addEventListener("visibilitychange", resetMousePosition, { signal });
}
const preventContextMenu = (e: MouseEvent) => e.preventDefault(); const preventContextMenu = (e: MouseEvent) => e.preventDefault();
videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal }); videoElmRefValue.addEventListener("contextmenu", preventContextMenu, { signal });
@ -635,24 +634,18 @@ export default function WebRTCVideo() {
abortController.abort(); abortController.abort();
}; };
}, },
[ [absMouseMoveHandler, isPointerLockActive, isPointerLockPossible, mouseWheelHandler, relMouseMoveHandler, requestPointerLock, resetMousePosition, settings.mouseMode],
settings.mouseMode,
relMouseMoveHandler,
mouseWheelHandler,
disableVideoFocusTrap,
requestPointerLock,
isPointerLockPossible,
isPointerLockActive,
],
); );
const containerRef = useRef<HTMLDivElement>(null);
const hasNoAutoPlayPermissions = useMemo(() => { const hasNoAutoPlayPermissions = useMemo(() => {
if (peerConnection?.connectionState !== "connected") return false; if (peerConnection?.connectionState !== "connected") return false;
if (isPlaying) return false; if (isPlaying) return false;
if (hdmiError) return false; if (hdmiError) return false;
if (videoHeight === 0 || videoWidth === 0) return false; if (videoHeight === 0 || videoWidth === 0) return false;
return true; return true;
}, [peerConnection?.connectionState, isPlaying, hdmiError, videoHeight, videoWidth]); }, [hdmiError, isPlaying, peerConnection?.connectionState, videoHeight, videoWidth]);
const showPointerLockBar = useMemo(() => { const showPointerLockBar = useMemo(() => {
if (settings.mouseMode !== "relative") return false; if (settings.mouseMode !== "relative") return false;
@ -662,18 +655,10 @@ export default function WebRTCVideo() {
if (!isPlaying) return false; if (!isPlaying) return false;
if (videoHeight === 0 || videoWidth === 0) return false; if (videoHeight === 0 || videoWidth === 0) return false;
return true; return true;
}, [ }, [isPlaying, isPointerLockActive, isPointerLockPossible, isVideoLoading, settings.mouseMode, videoHeight, videoWidth]);
settings.mouseMode,
isPointerLockPossible,
isPointerLockActive,
isVideoLoading,
isPlaying,
videoHeight,
videoWidth,
]);
return ( return (
<div className="grid h-full w-full grid-rows-layout"> <div className="grid h-full w-full grid-rows-(--grid-layout)">
<div className="flex min-h-[39.5px] flex-col"> <div className="flex min-h-[39.5px] flex-col">
<div className="flex flex-col"> <div className="flex flex-col">
<fieldset <fieldset
@ -691,20 +676,19 @@ export default function WebRTCVideo() {
<div <div
className={cx( className={cx(
"absolute inset-0 -z-0 bg-blue-50/40 opacity-80 dark:bg-slate-800/40", "absolute inset-0 -z-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)]", "bg-[radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px),radial-gradient(var(--color-blue-300)_0.5px,transparent_0.5px)] dark:bg-[radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px),radial-gradient(var(--color-slate-700)_0.5px,transparent_0.5px)]",
"[background-position:0_0,10px_10px]", "bg-position-[0_0,10px_10px]",
"[background-size:20px_20px]", "bg-size-[20px_20px]",
)} )}
/> />
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="relative flex-grow overflow-hidden"> <div className="relative grow overflow-hidden">
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div className="grid flex-grow grid-rows-bodyFooter overflow-hidden"> <div className="grid grow grid-rows-(--grid-bodyFooter) overflow-hidden">
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<div className="relative inline-block">
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */} {/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
<PointerLockBar show={showPointerLockBar} /> <PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<video <video
ref={videoElm} ref={videoElm}
autoPlay={true} autoPlay={true}
@ -715,16 +699,19 @@ export default function WebRTCVideo() {
playsInline playsInline
disablePictureInPicture disablePictureInPicture
controlsList="nofullscreen" controlsList="nofullscreen"
style={{
filter: `saturate(${videoSaturation}) brightness(${videoBrightness}) contrast(${videoContrast})`,
}}
className={cx( className={cx(
"z-30 max-h-full min-h-[384px] min-w-[512px] max-w-full bg-black/50 object-contain transition-all duration-1000", "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{ {
"cursor-none": settings.isCursorHidden, "cursor-none": settings.isCursorHidden,
"opacity-0": "opacity-0":
isVideoLoading || isVideoLoading ||
hdmiError || hdmiError ||
peerConnectionState !== "connected", peerConnectionState !== "connected",
"!opacity-60": showPointerLockBar, "opacity-60!": showPointerLockBar,
"animate-slideUpFade border border-slate-800/30 opacity-0 shadow dark:border-slate-300/20": "animate-slideUpFade border border-slate-800/30 shadow-xs dark:border-slate-300/20":
isPlaying, isPlaying,
}, },
)} )}
@ -732,7 +719,7 @@ export default function WebRTCVideo() {
{peerConnection?.connectionState == "connected" && ( {peerConnection?.connectionState == "connected" && (
<div <div
style={{ animationDuration: "500ms" }} style={{ animationDuration: "500ms" }}
className="pointer-events-none absolute inset-0 flex animate-slideUpFade items-center justify-center opacity-0" className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center"
> >
<div className="relative h-full w-full rounded-md"> <div className="relative h-full w-full rounded-md">
<LoadingVideoOverlay show={isVideoLoading} /> <LoadingVideoOverlay show={isVideoLoading} />
@ -748,7 +735,6 @@ export default function WebRTCVideo() {
)} )}
</div> </div>
</div> </div>
</div>
<VirtualKeyboard /> <VirtualKeyboard />
</div> </div>
</div> </div>

View File

@ -84,7 +84,7 @@ export default function ExtensionPopover() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4"> <div className="space-y-4">
{activeExtension ? ( {activeExtension ? (
// Extension Control View // Extension Control View
@ -92,7 +92,7 @@ export default function ExtensionPopover() {
{renderActiveExtension()} {renderActiveExtension()}
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",
@ -113,7 +113,7 @@ export default function ExtensionPopover() {
title="Extensions" title="Extensions"
description="Load and manage your extensions" description="Load and manage your extensions"
/> />
<Card className="animate-fadeIn opacity-0"> <Card className="animate-fadeIn opacity-0" >
<div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30"> <div className="w-full divide-y divide-slate-700/30 dark:divide-slate-600/30">
{AVAILABLE_EXTENSIONS.map(extension => ( {AVAILABLE_EXTENSIONS.map(extension => (
<div <div

View File

@ -194,7 +194,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div ref={ref} className="grid h-full grid-rows-headerBody"> <div ref={ref} className="grid h-full grid-rows-(--grid-headerBody)">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -214,7 +214,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
) : null} ) : null}
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn opacity-0 space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
@ -289,7 +289,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
{!remoteVirtualMediaState && ( {!remoteVirtualMediaState && (
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { LuCornerDownLeft } from "react-icons/lu"; import { LuCornerDownLeft } from "react-icons/lu";
import { ExclamationCircleIcon } from "@heroicons/react/16/solid"; import { ExclamationCircleIcon } from "@heroicons/react/16/solid";
import { useClose } from "@headlessui/react"; import { useClose } from "@headlessui/react";
@ -8,14 +8,21 @@ import { GridCard } from "@components/Card";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useHidStore, useRTCStore, useUiStore } from "@/hooks/stores"; import { useHidStore, useRTCStore, useUiStore, useSettingsStore } from "@/hooks/stores";
import { chars, keys, modifiers } from "@/keyboardMappings"; import { keys, modifiers } from "@/keyboardMappings";
import { layouts, chars } from "@/keyboardLayouts";
import notifications from "@/notifications"; import notifications from "@/notifications";
const hidKeyboardPayload = (keys: number[], modifier: number) => { const hidKeyboardPayload = (keys: number[], modifier: number) => {
return { keys, modifier }; return { keys, modifier };
}; };
const modifierCode = (shift?: boolean, altRight?: boolean) => {
return (shift ? modifiers["ShiftLeft"] : 0)
| (altRight ? modifiers["AltRight"] : 0)
}
const noModifier = 0
export default function PasteModal() { export default function PasteModal() {
const TextAreaRef = useRef<HTMLTextAreaElement>(null); const TextAreaRef = useRef<HTMLTextAreaElement>(null);
const setPasteMode = useHidStore(state => state.setPasteModeEnabled); const setPasteMode = useHidStore(state => state.setPasteModeEnabled);
@ -27,6 +34,25 @@ export default function PasteModal() {
const [invalidChars, setInvalidChars] = useState<string[]>([]); const [invalidChars, setInvalidChars] = useState<string[]>([]);
const close = useClose(); const close = useClose();
const keyboardLayout = useSettingsStore(state => state.keyboardLayout);
const setKeyboardLayout = useSettingsStore(
state => state.setKeyboardLayout,
);
// this ensures we always get the original en_US if it hasn't been set yet
const safeKeyboardLayout = useMemo(() => {
if (keyboardLayout && keyboardLayout.length > 0)
return keyboardLayout;
return "en_US";
}, [keyboardLayout]);
useEffect(() => {
send("getKeyboardLayout", {}, resp => {
if ("error" in resp) return;
setKeyboardLayout(resp.result as string);
});
}, [send, setKeyboardLayout]);
const onCancelPasteMode = useCallback(() => { const onCancelPasteMode = useCallback(() => {
setPasteMode(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
@ -37,18 +63,32 @@ export default function PasteModal() {
setPasteMode(false); setPasteMode(false);
setDisableVideoFocusTrap(false); setDisableVideoFocusTrap(false);
if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return; if (rpcDataChannel?.readyState !== "open" || !TextAreaRef.current) return;
if (!safeKeyboardLayout) return;
if (!chars[safeKeyboardLayout]) return;
const text = TextAreaRef.current.value; const text = TextAreaRef.current.value;
try { try {
for (const char of text) { for (const char of text) {
const { key, shift } = chars[char] ?? {}; const { key, shift, altRight, deadKey, accentKey } = chars[safeKeyboardLayout][char]
if (!key) continue; if (!key) continue;
const keyz = [ keys[key] ];
const modz = [ modifierCode(shift, altRight) ];
if (deadKey) {
keyz.push(keys["Space"]);
modz.push(noModifier);
}
if (accentKey) {
keyz.unshift(keys[accentKey.key])
modz.unshift(modifierCode(accentKey.shift, accentKey.altRight))
}
for (const [index, kei] of keyz.entries()) {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
send( send(
"keyboardReport", "keyboardReport",
hidKeyboardPayload([keys[key]], shift ? modifiers["ShiftLeft"] : 0), hidKeyboardPayload([kei], modz[index]),
params => { params => {
if ("error" in params) return reject(params.error); if ("error" in params) return reject(params.error);
send("keyboardReport", hidKeyboardPayload([], 0), params => { send("keyboardReport", hidKeyboardPayload([], 0), params => {
@ -59,11 +99,12 @@ export default function PasteModal() {
); );
}); });
} }
}
} catch (error) { } catch (error) {
console.error(error); console.error(error);
notifications.error("Failed to paste text"); notifications.error("Failed to paste text");
} }
}, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode]); }, [rpcDataChannel?.readyState, send, setDisableVideoFocusTrap, setPasteMode, safeKeyboardLayout]);
useEffect(() => { useEffect(() => {
if (TextAreaRef.current) { if (TextAreaRef.current) {
@ -74,7 +115,7 @@ export default function PasteModal() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="h-full space-y-4"> <div className="h-full space-y-4">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -83,14 +124,14 @@ export default function PasteModal() {
/> />
<div <div
className="animate-fadeIn space-y-2 opacity-0" className="animate-fadeIn opacity-0 space-y-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.1s", animationDelay: "0.1s",
}} }}
> >
<div> <div>
<div className="w-full" onKeyUp={e => e.stopPropagation()}> <div className="w-full" onKeyUp={e => e.stopPropagation()} onKeyDown={e => e.stopPropagation()}>
<TextAreaWithLabel <TextAreaWithLabel
ref={TextAreaRef} ref={TextAreaRef}
label="Paste from host" label="Paste from host"
@ -113,7 +154,7 @@ export default function PasteModal() {
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments // @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)] [...new Intl.Segmenter().segment(value)]
.map(x => x.segment) .map(x => x.segment)
.filter(char => !chars[char]), .filter(char => !chars[safeKeyboardLayout][char]),
), ),
]; ];
@ -132,12 +173,17 @@ export default function PasteModal() {
)} )}
</div> </div>
</div> </div>
<div className="space-y-4">
<p className="text-xs text-slate-600 dark:text-slate-400">
Sending text using keyboard layout: {layouts[safeKeyboardLayout]}
</p>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div <div
className="flex animate-fadeIn items-center justify-end gap-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end gap-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -26,7 +26,7 @@ export default function AddDeviceForm({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div <div
className="animate-fadeIn space-y-4 opacity-0" className="animate-fadeIn opacity-0 space-y-4"
style={{ style={{
animationDuration: "0.5s", animationDuration: "0.5s",
animationFillMode: "forwards", animationFillMode: "forwards",
@ -73,7 +73,7 @@ export default function AddDeviceForm({
/> />
</div> </div>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -63,7 +63,7 @@ export default function DeviceList({
</div> </div>
</Card> </Card>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -35,7 +35,7 @@ export default function EmptyStateCard({
</div> </div>
</Card> </Card>
<div <div
className="flex animate-fadeIn items-center justify-end space-x-2 opacity-0" className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{ style={{
animationDuration: "0.7s", animationDuration: "0.7s",
animationDelay: "0.2s", animationDelay: "0.2s",

View File

@ -102,7 +102,7 @@ export default function WakeOnLanModal() {
return ( return (
<GridCard> <GridCard>
<div className="space-y-4 p-4 py-3"> <div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-headerBody"> <div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
title="Wake On LAN" title="Wake On LAN"

View File

@ -99,7 +99,7 @@ export default function ConnectionStatsSidebar() {
}, 500); }, 500);
return ( return (
<div className="grid h-full grid-rows-headerBody shadow-sm"> <div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} /> <SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900"> <div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4"> <div className="space-y-4">

View File

@ -1,6 +1,11 @@
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { MAX_STEPS_PER_MACRO, MAX_TOTAL_MACROS, MAX_KEYS_PER_STEP } from "@/constants/macros";
import {
MAX_STEPS_PER_MACRO,
MAX_TOTAL_MACROS,
MAX_KEYS_PER_STEP,
} from "@/constants/macros";
// Define the JsonRpc types for better type checking // Define the JsonRpc types for better type checking
interface JsonRpcResponse { interface JsonRpcResponse {
@ -278,6 +283,8 @@ export const useVideoStore = create<VideoState>(set => ({
}, },
})); }));
export type KeyboardLedSync = "auto" | "browser" | "host";
interface SettingsState { interface SettingsState {
isCursorHidden: boolean; isCursorHidden: boolean;
setCursorVisibility: (enabled: boolean) => void; setCursorVisibility: (enabled: boolean) => void;
@ -297,6 +304,29 @@ interface SettingsState {
backlightSettings: BacklightSettings; backlightSettings: BacklightSettings;
setBacklightSettings: (settings: BacklightSettings) => void; setBacklightSettings: (settings: BacklightSettings) => void;
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
actionBarCtrlAltDel: boolean;
setActionBarCtrlAltDel: (enabled: boolean) => void;
keyboardLedSync: KeyboardLedSync;
setKeyboardLedSync: (sync: KeyboardLedSync) => void;
scrollThrottling: number;
setScrollThrottling: (value: number) => void;
showPressedKeys: boolean;
setShowPressedKeys: (show: boolean) => void;
// Video enhancement settings
videoSaturation: number;
setVideoSaturation: (value: number) => void;
videoBrightness: number;
setVideoBrightness: (value: number) => void;
videoContrast: number;
setVideoContrast: (value: number) => void;
} }
export const useSettingsStore = create( export const useSettingsStore = create(
@ -316,8 +346,7 @@ export const useSettingsStore = create(
setDeveloperMode: enabled => set({ developerMode: enabled }), setDeveloperMode: enabled => set({ developerMode: enabled }),
displayRotation: "270", displayRotation: "270",
setDisplayRotation: (rotation: string) => setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
set({ displayRotation: rotation }),
backlightSettings: { backlightSettings: {
max_brightness: 100, max_brightness: 100,
@ -326,6 +355,29 @@ export const useSettingsStore = create(
}, },
setBacklightSettings: (settings: BacklightSettings) => setBacklightSettings: (settings: BacklightSettings) =>
set({ backlightSettings: settings }), set({ backlightSettings: settings }),
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
actionBarCtrlAltDel: false,
setActionBarCtrlAltDel: enabled => set({ actionBarCtrlAltDel: enabled }),
keyboardLedSync: "auto",
setKeyboardLedSync: sync => set({ keyboardLedSync: sync }),
scrollThrottling: 0,
setScrollThrottling: value => set({ scrollThrottling: value }),
showPressedKeys: true,
setShowPressedKeys: show => set({ showPressedKeys: show }),
// Video enhancement settings with default values (1.0 = normal)
videoSaturation: 1.0,
setVideoSaturation: value => set({ videoSaturation: value }),
videoBrightness: 1.0,
setVideoBrightness: value => set({ videoBrightness: value }),
videoContrast: 1.0,
setVideoContrast: value => set({ videoContrast: value }),
}), }),
{ {
name: "settings", name: "settings",
@ -334,78 +386,6 @@ export const useSettingsStore = create(
), ),
); );
export interface DeviceSettingsState {
trackpadSensitivity: number;
mouseSensitivity: number;
clampMin: number;
clampMax: number;
blockDelay: number;
trackpadThreshold: number;
scrollSensitivity: "low" | "default" | "high";
setScrollSensitivity: (sensitivity: DeviceSettingsState["scrollSensitivity"]) => void;
}
export const useDeviceSettingsStore = create<DeviceSettingsState>(set => ({
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
scrollSensitivity: "default",
setScrollSensitivity: sensitivity => {
const wheelSettings: Record<
DeviceSettingsState["scrollSensitivity"],
{
trackpadSensitivity: DeviceSettingsState["trackpadSensitivity"];
mouseSensitivity: DeviceSettingsState["mouseSensitivity"];
clampMin: DeviceSettingsState["clampMin"];
clampMax: DeviceSettingsState["clampMax"];
blockDelay: DeviceSettingsState["blockDelay"];
trackpadThreshold: DeviceSettingsState["trackpadThreshold"];
}
> = {
low: {
trackpadSensitivity: 2.0,
mouseSensitivity: 3.0,
clampMin: -6,
clampMax: 6,
blockDelay: 30,
trackpadThreshold: 10,
},
default: {
trackpadSensitivity: 3.0,
mouseSensitivity: 5.0,
clampMin: -8,
clampMax: 8,
blockDelay: 25,
trackpadThreshold: 10,
},
high: {
trackpadSensitivity: 4.0,
mouseSensitivity: 6.0,
clampMin: -9,
clampMax: 9,
blockDelay: 20,
trackpadThreshold: 10,
},
};
const settings = wheelSettings[sensitivity];
return set({
trackpadSensitivity: settings.trackpadSensitivity,
trackpadThreshold: settings.trackpadThreshold,
mouseSensitivity: settings.mouseSensitivity,
clampMin: settings.clampMin,
clampMax: settings.clampMax,
blockDelay: settings.blockDelay,
scrollSensitivity: sensitivity,
});
},
}));
export interface RemoteVirtualMediaState { export interface RemoteVirtualMediaState {
source: "WebRTC" | "HTTP" | "Storage" | null; source: "WebRTC" | "HTTP" | "Storage" | null;
mode: "CDROM" | "Disk" | null; mode: "CDROM" | "Disk" | null;
@ -456,6 +436,21 @@ export const useMountMediaStore = create<MountMediaState>(set => ({
setErrorMessage: message => set({ errorMessage: message }), setErrorMessage: message => set({ errorMessage: message }),
})); }));
export interface KeyboardLedState {
num_lock: boolean;
caps_lock: boolean;
scroll_lock: boolean;
compose: boolean;
kana: boolean;
};
const defaultKeyboardLedState: KeyboardLedState = {
num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: false,
};
export interface HidState { export interface HidState {
activeKeys: number[]; activeKeys: number[];
activeModifiers: number[]; activeModifiers: number[];
@ -474,18 +469,18 @@ export interface HidState {
altGrCtrlTime: number; // _altGrCtrlTime altGrCtrlTime: number; // _altGrCtrlTime
setAltGrCtrlTime: (time: number) => void; setAltGrCtrlTime: (time: number) => void;
isNumLockActive: boolean; keyboardLedState?: KeyboardLedState;
setIsNumLockActive: (enabled: boolean) => void; setKeyboardLedState: (state: KeyboardLedState) => void;
setIsNumLockActive: (active: boolean) => void;
setIsCapsLockActive: (active: boolean) => void;
setIsScrollLockActive: (active: boolean) => void;
isScrollLockActive: boolean; keyboardLedStateSyncAvailable: boolean;
setIsScrollLockActive: (enabled: boolean) => void; setKeyboardLedStateSyncAvailable: (available: boolean) => void;
isVirtualKeyboardEnabled: boolean; isVirtualKeyboardEnabled: boolean;
setVirtualKeyboardEnabled: (enabled: boolean) => void; setVirtualKeyboardEnabled: (enabled: boolean) => void;
isCapsLockActive: boolean;
setIsCapsLockActive: (enabled: boolean) => void;
isPasteModeEnabled: boolean; isPasteModeEnabled: boolean;
setPasteModeEnabled: (enabled: boolean) => void; setPasteModeEnabled: (enabled: boolean) => void;
@ -493,7 +488,7 @@ export interface HidState {
setUsbState: (state: HidState["usbState"]) => void; setUsbState: (state: HidState["usbState"]) => void;
} }
export const useHidStore = create<HidState>(set => ({ export const useHidStore = create<HidState>((set, get) => ({
activeKeys: [], activeKeys: [],
activeModifiers: [], activeModifiers: [],
updateActiveKeysAndModifiers: ({ keys, modifiers }) => { updateActiveKeysAndModifiers: ({ keys, modifiers }) => {
@ -509,18 +504,29 @@ export const useHidStore = create<HidState>(set => ({
altGrCtrlTime: 0, altGrCtrlTime: 0,
setAltGrCtrlTime: time => set({ altGrCtrlTime: time }), setAltGrCtrlTime: time => set({ altGrCtrlTime: time }),
isNumLockActive: false, setKeyboardLedState: ledState => set({ keyboardLedState: ledState }),
setIsNumLockActive: enabled => set({ isNumLockActive: enabled }), setIsNumLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.num_lock = active;
set({ keyboardLedState });
},
setIsCapsLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.caps_lock = active;
set({ keyboardLedState });
},
setIsScrollLockActive: active => {
const keyboardLedState = { ...(get().keyboardLedState || defaultKeyboardLedState) };
keyboardLedState.scroll_lock = active;
set({ keyboardLedState });
},
isScrollLockActive: false, keyboardLedStateSyncAvailable: false,
setIsScrollLockActive: enabled => set({ isScrollLockActive: enabled }), setKeyboardLedStateSyncAvailable: available => set({ keyboardLedStateSyncAvailable: available }),
isVirtualKeyboardEnabled: false, isVirtualKeyboardEnabled: false,
setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }), setVirtualKeyboardEnabled: enabled => set({ isVirtualKeyboardEnabled: enabled }),
isCapsLockActive: false,
setIsCapsLockActive: enabled => set({ isCapsLockActive: enabled }),
isPasteModeEnabled: false, isPasteModeEnabled: false,
setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }), setPasteModeEnabled: enabled => set({ isPasteModeEnabled: enabled }),
@ -726,12 +732,23 @@ export interface NetworkState {
setDhcpLeaseExpiry: (expiry: Date) => void; setDhcpLeaseExpiry: (expiry: Date) => void;
} }
export type IPv6Mode =
export type IPv6Mode = "disabled" | "slaac" | "dhcpv6" | "slaac_and_dhcpv6" | "static" | "link_local" | "unknown"; | "disabled"
| "slaac"
| "dhcpv6"
| "slaac_and_dhcpv6"
| "static"
| "link_local"
| "unknown";
export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown"; export type IPv4Mode = "disabled" | "static" | "dhcp" | "unknown";
export type LLDPMode = "disabled" | "basic" | "all" | "unknown"; export type LLDPMode = "disabled" | "basic" | "all" | "unknown";
export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown"; export type mDNSMode = "disabled" | "auto" | "ipv4_only" | "ipv6_only" | "unknown";
export type TimeSyncMode = "ntp_only" | "ntp_and_http" | "http_only" | "custom" | "unknown"; export type TimeSyncMode =
| "ntp_only"
| "ntp_and_http"
| "http_only"
| "custom"
| "unknown";
export interface NetworkSettings { export interface NetworkSettings {
hostname: string; hostname: string;
@ -756,7 +773,7 @@ export const useNetworkStateStore = create<NetworkState>((set, get) => ({
lease.lease_expiry = expiry; lease.lease_expiry = expiry;
set({ dhcp_lease: lease }); set({ dhcp_lease: lease });
} },
})); }));
export interface KeySequenceStep { export interface KeySequenceStep {
@ -778,8 +795,20 @@ export interface MacrosState {
initialized: boolean; initialized: boolean;
loadMacros: () => Promise<void>; loadMacros: () => Promise<void>;
saveMacros: (macros: KeySequence[]) => Promise<void>; saveMacros: (macros: KeySequence[]) => Promise<void>;
sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void) | null; sendFn:
setSendFn: (sendFn: ((method: string, params: unknown, callback?: ((resp: JsonRpcResponse) => void) | undefined) => void)) => void; | ((
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void)
| null;
setSendFn: (
sendFn: (
method: string,
params: unknown,
callback?: ((resp: JsonRpcResponse) => void) | undefined,
) => void,
) => void;
} }
export const generateMacroId = () => { export const generateMacroId = () => {
@ -792,7 +821,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
initialized: false, initialized: false,
sendFn: null, sendFn: null,
setSendFn: (sendFn) => { setSendFn: sendFn => {
set({ sendFn }); set({ sendFn });
}, },
@ -809,7 +838,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
sendFn("getKeyboardMacros", {}, (response) => { sendFn("getKeyboardMacros", {}, response => {
if (response.error) { if (response.error) {
console.error("Error loading macros:", response.error); console.error("Error loading macros:", response.error);
reject(new Error(response.error.message)); reject(new Error(response.error.message));
@ -829,7 +858,7 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
set({ set({
macros: sortedMacros, macros: sortedMacros,
initialized: true initialized: true,
}); });
resolve(); resolve();
@ -856,15 +885,23 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
for (const macro of macros) { for (const macro of macros) {
if (macro.steps.length > MAX_STEPS_PER_MACRO) { if (macro.steps.length > MAX_STEPS_PER_MACRO) {
console.error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); console.error(
throw new Error(`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`); `Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
);
throw new Error(
`Cannot save: macro "${macro.name}" exceeds maximum of ${MAX_STEPS_PER_MACRO} steps`,
);
} }
for (let i = 0; i < macro.steps.length; i++) { for (let i = 0; i < macro.steps.length; i++) {
const step = macro.steps[i]; const step = macro.steps[i];
if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) { if (step.keys && step.keys.length > MAX_KEYS_PER_STEP) {
console.error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); console.error(
throw new Error(`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`); `Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
);
throw new Error(
`Cannot save: macro "${macro.name}" step ${i + 1} exceeds maximum of ${MAX_KEYS_PER_STEP} keys`,
);
} }
} }
} }
@ -874,18 +911,23 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
try { try {
const macrosWithSortOrder = macros.map((macro, index) => ({ const macrosWithSortOrder = macros.map((macro, index) => ({
...macro, ...macro,
sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index sortOrder: macro.sortOrder !== undefined ? macro.sortOrder : index,
})); }));
const response = await new Promise<JsonRpcResponse>((resolve) => { const response = await new Promise<JsonRpcResponse>(resolve => {
sendFn("setKeyboardMacros", { params: { macros: macrosWithSortOrder } }, (response) => { sendFn(
"setKeyboardMacros",
{ params: { macros: macrosWithSortOrder } },
response => {
resolve(response); resolve(response);
}); },
);
}); });
if (response.error) { if (response.error) {
console.error("Error saving macros:", response.error); console.error("Error saving macros:", response.error);
const errorMessage = typeof response.error.data === 'string' const errorMessage =
typeof response.error.data === "string"
? response.error.data ? response.error.data
: response.error.message || "Failed to save macros"; : response.error.message || "Failed to save macros";
throw new Error(errorMessage); throw new Error(errorMessage);
@ -899,5 +941,5 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
} finally { } finally {
set({ loading: false }); set({ loading: false });
} }
} },
})); }));

View File

@ -1,6 +1,144 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities; @config "../tailwind.config.js";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@headlessui/tailwindcss";
/* Dark mode uses CSS selector instead of prefers-color-scheme */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--font-sans: "Circular", sans-serif;
--font-display: "Circular", sans-serif;
--font-serif: "Circular", serif;
--font-mono: "Source Code Pro Variable", monospace;
--grid-layout: auto 1fr auto;
--grid-headerBody: auto 1fr;
--grid-bodyFooter: 1fr auto;
--grid-sidebar: 1fr minmax(360px, 25%);
--breakpoint-xs: 480px;
--breakpoint-2xl: 1440px;
--breakpoint-3xl: 1920px;
--breakpoint-4xl: 2560px;
/* Migrated animations */
--animate-enter: enter 0.2s ease-out;
--animate-leave: leave 0.15s ease-in forwards;
--animate-fadeInScale: fadeInScale 1s ease-out forwards;
--animate-fadeInScaleFloat:
fadeInScaleFloat 1s ease-out forwards, float 3s ease-in-out infinite;
--animate-fadeIn: fadeIn 1s ease-out forwards;
--animate-slideUpFade: slideUpFade 1s ease-out forwards;
--container-8xl: 88rem;
--container-9xl: 96rem;
--container-10xl: 104rem;
--container-11xl: 112rem;
--container-12xl: 120rem;
/* Migrated keyframes */
@keyframes enter {
0% {
opacity: 0;
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes leave {
0% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(0.9);
}
}
@keyframes fadeInScale {
0% {
opacity: 0;
transform: scale(0.98);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeInScaleFloat {
0% {
opacity: 0;
transform: scale(0.98) translateY(10px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
@keyframes fadeIn {
0% {
opacity: 0;
transform: translateY(10px);
}
70% {
opacity: 0.8;
transform: translateY(1px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slideUpFade {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
}
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility max-width-* {
max-width: --modifier(--container-*, [length], [*]);
}
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility animation-delay-* {
animation-delay: --value(integer)ms;
}
/* If we don't ignore this, Prettier will add a space between the value and the `ms`. Rendering the utility invalid. */
/* prettier-ignore */
@utility animation-duration-* {
animation-duration: --value(integer)ms;
}
html { html {
@apply scroll-smooth; @apply scroll-smooth;
@ -15,13 +153,13 @@ body {
@property --grid-color-start { @property --grid-color-start {
syntax: "<color>"; syntax: "<color>";
initial-value: theme("colors.blue.50/10"); initial-value: var(--color-blue-50/10);
inherits: false; inherits: false;
} }
@property --grid-color-end { @property --grid-color-end {
syntax: "<color>"; syntax: "<color>";
initial-value: theme("colors.blue.50/100"); initial-value: var(--color-blue-50/100);
inherits: false; inherits: false;
} }
@ -37,8 +175,8 @@ body {
} }
.group:hover .grid-card { .group:hover .grid-card {
--grid-color-start: theme("colors.blue.100/50"); --grid-color-start: var(--color-blue-100/50);
--grid-color-end: theme("colors.blue.50/50"); --grid-color-end: var(--color-blue-50/50);
} }
video::-webkit-media-controls { video::-webkit-media-controls {
@ -46,11 +184,11 @@ video::-webkit-media-controls {
} }
.hg-theme-default { .hg-theme-default {
@apply !font-display font-normal; @apply font-display! font-normal;
} }
.hg-theme-default .hg-button { .hg-theme-default .hg-button {
@apply border !border-b border-slate-800/25 !border-b-slate-800/25 !shadow-sm; @apply border border-b! border-slate-800/25 border-b-slate-800/25! shadow-xs!;
} }
.hg-theme-default .hg-button span { .hg-theme-default .hg-button span {
@ -122,7 +260,7 @@ video::-webkit-media-controls {
} }
.hg-button { .hg-button {
@apply dark:!bg-slate-800 dark:text-white; @apply dark:bg-slate-800! dark:text-white;
} }
.simple-keyboard-control .hg-button { .simple-keyboard-control .hg-button {
@ -170,25 +308,21 @@ video::-webkit-media-controls {
} }
.hg-button.hg-standardBtn[data-skbtn="Space"] { .hg-button.hg-standardBtn[data-skbtn="Space"] {
@apply md:!w-[350px]; @apply md:w-[350px]!;
} }
.hg-theme-default .hg-row .combination-key { .hg-theme-default .hg-row .combination-key {
@apply inline-flex !h-auto !w-auto flex-grow-0 py-1 text-xs; @apply inline-flex h-auto! w-auto! grow-0 py-1 text-xs;
}
.hg-theme-default .hg-row:has(.combination-key) {
/*margin-bottom: 100px !important;*/
} }
.hg-theme-default .hg-row .hg-button-container, .hg-theme-default .hg-row .hg-button-container,
.hg-theme-default .hg-row .hg-button:not(:last-child) { .hg-theme-default .hg-row .hg-button:not(:last-child) {
@apply !mr-[2px] md:!mr-[5px]; @apply mr-[2px]! md:mr-[5px]!;
} }
/* Hide the scrollbar by setting the scrollbar color to the background color */ /* Hide the scrollbar by setting the scrollbar color to the background color */
.xterm .xterm-viewport { .xterm .xterm-viewport {
scrollbar-color: theme("colors.gray.900") #002b36; scrollbar-color: var(--color-gray-900) #002b36;
scrollbar-width: thin; scrollbar-width: thin;
} }

45
ui/src/keyboardLayouts.ts Normal file
View File

@ -0,0 +1,45 @@
import { chars as chars_fr_BE, name as name_fr_BE } from "@/keyboardLayouts/fr_BE"
import { chars as chars_cs_CZ, name as name_cs_CZ } from "@/keyboardLayouts/cs_CZ"
import { chars as chars_en_UK, name as name_en_UK } from "@/keyboardLayouts/en_UK"
import { chars as chars_en_US, name as name_en_US } from "@/keyboardLayouts/en_US"
import { chars as chars_fr_FR, name as name_fr_FR } from "@/keyboardLayouts/fr_FR"
import { chars as chars_de_DE, name as name_de_DE } from "@/keyboardLayouts/de_DE"
import { chars as chars_it_IT, name as name_it_IT } from "@/keyboardLayouts/it_IT"
import { chars as chars_nb_NO, name as name_nb_NO } from "@/keyboardLayouts/nb_NO"
import { chars as chars_es_ES, name as name_es_ES } from "@/keyboardLayouts/es_ES"
import { chars as chars_sv_SE, name as name_sv_SE } from "@/keyboardLayouts/sv_SE"
import { chars as chars_fr_CH, name as name_fr_CH } from "@/keyboardLayouts/fr_CH"
import { chars as chars_de_CH, name as name_de_CH } from "@/keyboardLayouts/de_CH"
interface KeyInfo { key: string | number; shift?: boolean, altRight?: boolean }
export type KeyCombo = KeyInfo & { deadKey?: boolean, accentKey?: KeyInfo }
export const layouts: Record<string, string> = {
be_FR: name_fr_BE,
cs_CZ: name_cs_CZ,
en_UK: name_en_UK,
en_US: name_en_US,
fr_FR: name_fr_FR,
de_DE: name_de_DE,
it_IT: name_it_IT,
nb_NO: name_nb_NO,
es_ES: name_es_ES,
sv_SE: name_sv_SE,
fr_CH: name_fr_CH,
de_CH: name_de_CH,
}
export const chars: Record<string, Record<string, KeyCombo>> = {
be_FR: chars_fr_BE,
cs_CZ: chars_cs_CZ,
en_UK: chars_en_UK,
en_US: chars_en_US,
fr_FR: chars_fr_FR,
de_DE: chars_de_DE,
it_IT: chars_it_IT,
nb_NO: chars_nb_NO,
es_ES: chars_es_ES,
sv_SE: chars_sv_SE,
fr_CH: chars_fr_CH,
de_CH: chars_de_CH,
};

View File

@ -0,0 +1,244 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Čeština";
const keyTrema = { key: "Backslash" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Digit3", shift: true, altRight: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyCaron = { key: "Equal", shift: true } // caron or haček (inverted hat), mark ˇ placed above the letter
const keyGrave = { key: "Digit7", shift: true, altRight: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Digit1", shift: true, altRight: true } // tilde, mark ~ placed above the letter
const keyRing = { key: "Backquote", shift: true } // kroužek (little ring), mark ° placed above the letter
const keyOverdot = { key: "Digit8", shift: true, altRight: true } // overdot (dot above), mark ˙ placed above the letter
const keyHook = { key: "Digit6", shift: true, altRight: true } // ogonoek (little hook), mark ˛ placed beneath a letter
const keyCedille = { key: "Equal", shift: true, altRight: true } // accent cedille (cedilla), mark ¸ placed beneath a letter
export const chars = {
A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
"Ȧ": { key: "KeyA", shift: true, accentKey: keyOverdot },
"Ą": { key: "KeyA", shift: true, accentKey: keyHook },
B: { key: "KeyB", shift: true },
"Ḃ": { key: "KeyB", shift: true, accentKEy: keyOverdot },
C: { key: "KeyC", shift: true },
"Č": { key: "KeyC", shift: true, accentKey: keyCaron },
"Ċ": { key: "KeyC", shift: true, accentKey: keyOverdot },
"Ç": { key: "KeyC", shift: true, accentKey: keyCedille },
D: { key: "KeyD", shift: true },
"Ď": { key: "KeyD", shift: true, accentKey: keyCaron },
"Ḋ": { key: "KeyD", shift: true, accentKey: keyOverdot },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"Ě": { key: "KeyE", shift: true, accentKey: keyCaron },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
"Ė": { key: "KeyE", shift: true, accentKEy: keyOverdot },
"Ę": { key: "KeyE", shift: true, accentKey: keyHook },
F: { key: "KeyF", shift: true },
"Ḟ": { key: "KeyF", shift: true, accentKey: keyOverdot },
G: { key: "KeyG", shift: true },
"Ġ": { key: "KeyG", shift: true, accentKey: keyOverdot },
H: { key: "KeyH", shift: true },
"Ḣ": { key: "KeyH", shift: true, accentKey: keyOverdot },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
"İ": { key: "KeyI", shift: true, accentKey: keyOverdot },
"Į": { key: "KeyI", shift: true, accentKey: keyHook },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
"Ŀ": { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
"Ṁ": { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
"Ň": { key: "KeyN", shift: true, accentKey: keyCaron },
"Ñ": { key: "KeyN", shift: true, accentKey: keyTilde },
"Ṅ": { key: "KeyN", shift: true, accentKEy: keyOverdot },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
"Ȯ": { key: "KeyO", shift: true, accentKey: keyOverdot },
"Ǫ": { key: "KeyO", shift: true, accentKey: keyHook },
P: { key: "KeyP", shift: true },
"Ṗ": { key: "KeyP", shift: true, accentKey: keyOverdot },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
"Ř": { key: "KeyR", shift: true, accentKey: keyCaron },
"Ṙ": { key: "KeyR", shift: true, accentKey: keyOverdot },
S: { key: "KeyS", shift: true },
"Š": { key: "KeyS", shift: true, accentKey: keyCaron },
"Ṡ": { key: "KeyS", shift: true, accentKey: keyOverdot },
T: { key: "KeyT", shift: true },
"Ť": { key: "KeyT", shift: true, accentKey: keyCaron },
"Ṫ": { key: "KeyT", shift: true, accentKey: keyOverdot },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
"Ů": { key: "KeyU", shift: true, accentKey: keyRing },
"Ų": { key: "KeyU", shift: true, accentKey: keyHook },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
"Ẇ": { key: "KeyW", shift: true, accentKey: keyOverdot },
X: { key: "KeyX", shift: true },
"Ẋ": { key: "KeyX", shift: true, accentKey: keyOverdot },
Y: { key: "KeyY", shift: true },
"Ý": { key: "KeyY", shift: true, accentKey: keyAcute },
"Ẏ": { key: "KeyY", shift: true, accentKey: keyOverdot },
Z: { key: "KeyZ", shift: true },
"Ż": { key: "KeyZ", shift: true, accentKey: keyOverdot },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave },
"ã": { key: "KeyA", accentKey: keyTilde },
"ȧ": { key: "KeyA", accentKey: keyOverdot },
"ą": { key: "KeyA", accentKey: keyHook },
b: { key: "KeyB" },
"{": { key: "KeyB", altRight: true },
"ḃ": { key: "KeyB", accentKey: keyOverdot },
c: { key: "KeyC" },
"&": { key: "KeyC", altRight: true },
"ç": { key: "KeyC", accentKey: keyCedille },
"ċ": { key: "KeyC", accentKey: keyOverdot },
d: { key: "KeyD" },
"ď": { key: "KeyD", accentKey: keyCaron },
"ḋ": { key: "KeyD", accentKey: keyOverdot },
"Đ": { key: "KeyD", altRight: true },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"ê": { key: "KeyE", accentKey: keyHat },
"ẽ": { key: "KeyE", accentKey: keyTilde },
"è": { key: "KeyE", accentKey: keyGrave },
"ė": { key: "KeyE", accentKey: keyOverdot },
"ę": { key: "KeyE", accentKey: keyHook },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
"ḟ": { key: "KeyF", accentKey: keyOverdot },
"[": { key: "KeyF", altRight: true },
g: { key: "KeyG" },
"ġ": { key: "KeyG", accentKey: keyOverdot },
"]": { key: "KeyF", altRight: true },
h: { key: "KeyH" },
"ḣ": { key: "KeyH", accentKey: keyOverdot },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
"ĩ": { key: "KeyI", accentKey: keyTilde },
"ı": { key: "KeyI", accentKey: keyOverdot },
"į": { key: "KeyI", accentKey: keyHook },
j: { key: "KeyJ" },
"ȷ": { key: "KeyJ", accentKey: keyOverdot },
k: { key: "KeyK" },
"ł": { key: "KeyK", altRight: true },
l: { key: "KeyL" },
"ŀ": { key: "KeyL", accentKey: keyOverdot },
"Ł": { key: "KeyL", altRight: true },
m: { key: "KeyM" },
"ṁ": { key: "KeyM", accentKey: keyOverdot },
n: { key: "KeyN" },
"}": { key: "KeyN", altRight: true },
"ň": { key: "KeyN", accentKey: keyCaron },
"ñ": { key: "KeyN", accentKey: keyTilde },
"ṅ": { key: "KeyN", accentKey: keyOverdot },
o: { key: "KeyO" },
"ö": { key: "Key0", accentKey: keyTrema },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
"õ": { key: "KeyO", accentKey: keyTilde },
"ȯ": { key: "KeyO", accentKey: keyOverdot },
"ǫ": { key: "KeyO", accentKey: keyHook },
p: { key: "KeyP" },
"ṗ": { key: "KeyP", accentKey: keyOverdot },
q: { key: "KeyQ" },
r: { key: "KeyR" },
"ṙ": { key: "KeyR", accentKey: keyOverdot },
s: { key: "KeyS" },
"ṡ": { key: "KeyS", accentKey: keyOverdot },
"đ": { key: "KeyS", altRight: true },
t: { key: "KeyT" },
"ť": { key: "KeyT", accentKey: keyCaron },
"ṫ": { key: "KeyT", accentKey: keyOverdot },
u: { key: "KeyU" },
"ü": { key: "KeyU", accentKey: keyTrema },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
"ũ": { key: "KeyU", accentKey: keyTilde },
"ų": { key: "KeyU", accentKey: keyHook },
v: { key: "KeyV" },
"@": { key: "KeyV", altRight: true },
w: { key: "KeyW" },
"ẇ": { key: "KeyW", accentKey: keyOverdot },
x: { key: "KeyX" },
"#": { key: "KeyX", altRight: true },
"ẋ": { key: "KeyX", accentKey: keyOverdot },
y: { key: "KeyY" },
"ẏ": { key: "KeyY", accentKey: keyOverdot },
z: { key: "KeyZ" },
"ż": { key: "KeyZ", accentKey: keyOverdot },
";": { key: "Backquote" },
"°": { key: "Backquote", shift: true, deadKey: true },
"+": { key: "Digit1" },
1: { key: "Digit1", shift: true },
"ě": { key: "Digit2" },
2: { key: "Digit2", shift: true },
"š": { key: "Digit3" },
3: { key: "Digit3", shift: true },
"č": { key: "Digit4" },
4: { key: "Digit4", shift: true },
"ř": { key: "Digit5" },
5: { key: "Digit5", shift: true },
"ž": { key: "Digit6" },
6: { key: "Digit6", shift: true },
"ý": { key: "Digit7" },
7: { key: "Digit7", shift: true },
"á": { key: "Digit8" },
8: { key: "Digit8", shift: true },
"í": { key: "Digit9" },
9: { key: "Digit9", shift: true },
"é": { key: "Digit0" },
0: { key: "Digit0", shift: true },
"=": { key: "Minus" },
"%": { key: "Minus", shift: true },
"ú": { key: "BracketLeft" },
"/": { key: "BracketLeft", shift: true },
")": { key: "BracketRight" },
"(": { key: "BracketRight", shift: true },
"ů": { key: "Semicolon" },
"\"": { key: "Semicolon", shift: true },
"§": { key: "Quote" },
"!": { key: "Quote", shift: true },
"'": { key: "Backslash", shift: true },
",": { key: "Comma" },
"?": { key: "Comma", shift: true },
"<": { key: "Comma", altRight: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
">": { key: "Period", altRight: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"*": { key: "Slash", altRight: true },
"\\": { key: "IntlBackslash" },
"|": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,165 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Schwiizerdütsch";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Minus", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Equal" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Equal", altRight: true } // tilde, mark ~ placed above the letter
export const chars = {
A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"ã": { key: "KeyA", accentKey: keyTilde },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"ê": { key: "KeyE", accentKey: keyHat },
"ẽ": { key: "KeyE", accentKey: keyTilde },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
"ĩ": { key: "KeyI", accentKey: keyTilde },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
"õ": { key: "KeyO", accentKey: keyTilde },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
"ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"§": { key: "Backquote" },
"°": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"+": { key: "Digit1", shift: true },
"|": { key: "Digit1", altRight: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"*": { key: "Digit3", shift: true },
"#": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
"ç": { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"'": { key: "Minus" },
"?": { key: "Minus", shift: true },
"^": { key: "Equal", deadKey: true },
"`": { key: "Equal", shift: true },
"~": { key: "Equal", altRight: true, deadKey: true },
"ü": { key: "BracketLeft" },
"è": { key: "BracketLeft", shift: true },
"[": { key: "BracketLeft", altRight: true },
"!": { key: "BracketRight", shift: true },
"]": { key: "BracketRight", altRight: true },
"ö": { key: "Semicolon" },
"é": { key: "Semicolon", shift: true },
"ä": { key: "Quote" },
"à": { key: "Quote", shift: true },
"{": { key: "Quote", altRight: true },
"$": { key: "Backslash" },
"£": { key: "Backslash", shift: true },
"}": { key: "Backslash", altRight: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"\\": { key: "IntlBackslash", altRight: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,152 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Deutsch";
const keyAcute = { key: "Equal" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "Backquote" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
export const chars = {
A: { key: "KeyA", shift: true },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave},
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"é": { key: "KeyE", accentKey: keyAcute},
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
"µ": { key: "KeyM", altRight: true },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
p: { key: "KeyP" },
q: { key: "KeyQ" },
"@": { key: "KeyQ", altRight: true },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"°": { key: "Backquote", shift: true },
"^": { key: "Backquote", deadKey: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
"²": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"§": { key: "Digit3", shift: true },
"³": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
"$": { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
"[": { key: "Digit8", altRight: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
"]": { key: "Digit9", altRight: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"}": { key: "Digit0", altRight: true },
"ß": { key: "Minus" },
"?": { key: "Minus", shift: true },
"\\": { key: "Minus", altRight: true },
"´": { key: "Equal", deadKey: true },
"`": { key: "Equal", shift: true, deadKey: true },
"ü": { key: "BracketLeft" },
"Ü": { key: "BracketLeft", shift: true },
"+": { key: "BracketRight" },
"*": { key: "BracketRight", shift: true },
"~": { key: "BracketRight", altRight: true },
"ö": { key: "Semicolon" },
"Ö": { key: "Semicolon", shift: true },
"ä": { key: "Quote" },
"Ä": { key: "Quote", shift: true },
"#": { key: "Backslash" },
"'": { key: "Backslash", shift: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"|": { key: "IntlBackslash", altRight: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,107 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "English (UK)";
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" },
z: { key: "KeyZ" },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
3: { key: "Digit3" },
"£": { key: "Digit3", shift: true },
4: { key: "Digit4" },
$: { key: "Digit4", shift: true },
"€": { key: "Digit4", altRight: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"^": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"&": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"*": { key: "Digit8", shift: true },
9: { key: "Digit9" },
"(": { key: "Digit9", shift: true },
0: { key: "Digit0" },
")": { key: "Digit0", shift: true },
"-": { key: "Minus" },
_: { key: "Minus", shift: true },
"=": { key: "Equal" },
"+": { key: "Equal", shift: true },
"'": { key: "Quote" },
'@': { key: "Quote", shift: true },
",": { key: "Comma" },
"<": { key: "Comma", shift: true },
"/": { key: "Slash" },
"?": { key: "Slash", shift: true },
".": { key: "Period" },
">": { key: "Period", shift: true },
";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true },
"[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true },
"]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true },
"#": { key: "Backslash" },
"~": { key: "Backslash", shift: true },
"`": { key: "Backquote" },
"¬": { key: "Backquote", shift: true },
"\\": { key: "IntlBackslash" },
"|": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>

View File

@ -0,0 +1,113 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "English (US)";
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" },
z: { key: "KeyZ" },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
"@": { key: "Digit2", shift: true },
3: { key: "Digit3" },
"#": { key: "Digit3", shift: true },
4: { key: "Digit4" },
$: { key: "Digit4", shift: true },
"%": { key: "Digit5", shift: true },
5: { key: "Digit5" },
"^": { key: "Digit6", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit7", shift: true },
7: { key: "Digit7" },
"*": { key: "Digit8", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit9", shift: true },
9: { key: "Digit9" },
")": { key: "Digit0", shift: true },
0: { key: "Digit0" },
"-": { key: "Minus" },
_: { key: "Minus", shift: true },
"=": { key: "Equal" },
"+": { key: "Equal", shift: true },
"'": { key: "Quote" },
'"': { key: "Quote", shift: true },
",": { key: "Comma" },
"<": { key: "Comma", shift: true },
"/": { key: "Slash" },
"?": { key: "Slash", shift: true },
".": { key: "Period" },
">": { key: "Period", shift: true },
";": { key: "Semicolon" },
":": { key: "Semicolon", shift: true },
"[": { key: "BracketLeft" },
"{": { key: "BracketLeft", shift: true },
"]": { key: "BracketRight" },
"}": { key: "BracketRight", shift: true },
"\\": { key: "Backslash" },
"|": { key: "Backslash", shift: true },
"`": { key: "Backquote" },
"~": { key: "Backquote", shift: true },
"§": { key: "IntlBackslash" },
"±": { key: "IntlBackslash", shift: true },
" ": { key: "Space", shift: false },
"\n": { key: "Enter", shift: false },
Enter: { key: "Enter", shift: false },
Tab: { key: "Tab", shift: false },
PrintScreen: { key: "Prt Sc", shift: false },
SystemRequest: { key: "Prt Sc", shift: true },
ScrollLock: { key: "ScrollLock", shift: false},
Pause: { key: "Pause", shift: false },
Break: { key: "Pause", shift: true },
Insert: { key: "Insert", shift: false },
Delete: { key: "Delete", shift: false },
} as Record<string, KeyCombo>

View File

@ -0,0 +1,168 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Español";
const keyTrema = { key: "Quote", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Quote" } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "BracketRight" } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Key4", altRight: true } // tilde, mark ~ placed above the letter
export const chars = {
A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave },
"ã": { key: "KeyA", accentKey: keyTilde },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"é": { key: "KeyE", accentKey: keyAcute },
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"ẽ": { key: "KeyE", accentKey: keyTilde },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
"ĩ": { key: "KeyI", accentKey: keyTilde },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ö": { key: "KeyO", accentKey: keyTrema },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
"õ": { key: "KeyO", accentKey: keyTilde },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ü": { key: "KeyU", accentKey: keyTrema },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
"ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" },
z: { key: "KeyZ" },
"º": { key: "Backquote" },
"ª": { key: "Backquote", shift: true },
"\\": { key: "Backquote", altRight: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
"|": { key: "Digit1", altRight: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"·": { key: "Digit3", shift: true },
"#": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
"$": { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
"¬": { key: "Digit6", altRight: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"'": { key: "Minus" },
"?": { key: "Minus", shift: true },
"¡": { key: "Equal", deadKey: true },
"¿": { key: "Equal", shift: true },
"[": { key: "BracketLeft", altRight: true },
"+": { key: "BracketRight" },
"*": { key: "BracketRight", shift: true },
"]": { key: "BracketRight", altRight: true },
"ñ": { key: "Semicolon" },
"Ñ": { key: "Semicolon", shift: true },
"{": { key: "Quote", altRight: true },
"ç": { key: "Backslash" },
"Ç": { key: "Backslash", shift: true },
"}": { key: "Backslash", altRight: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,167 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Belgisch Nederlands";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyAcute = { key: "Semicolon", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyGrave = { key: "Quote", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "Slash", altRight: true } // tilde, mark ~ placed above the letter
export const chars = {
A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat },
"Á": { key: "KeyQ", shift: true, accentKey: keyAcute },
"À": { key: "KeyQ", shift: true, accentKey: keyGrave },
"Ã": { key: "KeyQ", shift: true, accentKey: keyTilde },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "Semicolon", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
P: { key: "KeyP", shift: true },
Q: { key: "KeyA", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyQ" },
"ä": { key: "KeyQ", accentKey: keyTrema },
"â": { key: "KeyQ", accentKey: keyHat },
"á": { key: "KeyQ", accentKey: keyAcute },
"ã": { key: "KeyQ", accentKey: keyTilde },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"ê": { key: "KeyE", accentKey: keyHat },
"ẽ": { key: "KeyE", accentKey: keyTilde },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"î": { key: "KeyI", accentKey: keyHat },
"í": { key: "KeyI", accentKey: keyAcute },
"ì": { key: "KeyI", accentKey: keyGrave },
"ĩ": { key: "KeyI", accentKey: keyTilde },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "Semicolon" },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ö": { key: "KeyO", accentKey: keyTrema },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
"õ": { key: "KeyO", accentKey: keyTilde },
p: { key: "KeyP" },
q: { key: "KeyA" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ü": { key: "KeyU", accentKey: keyTrema },
"û": { key: "KeyU", accentKey: keyHat },
"ú": { key: "KeyU", accentKey: keyAcute },
"ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"²": { key: "Backquote" },
"³": { key: "Backquote", shift: true },
"&": { key: "Digit1" },
1: { key: "Digit1", shift: true },
"|": { key: "Digit1", altRight: true },
"é": { key: "Digit2" },
2: { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
"\"": { key: "Digit3" },
3: { key: "Digit3", shift: true },
"#": { key: "Digit3", altRight: true },
"'": { key: "Digit4" },
4: { key: "Digit4", shift: true },
"(": { key: "Digit5" },
5: { key: "Digit5", shift: true },
"§": { key: "Digit6" },
6: { key: "Digit6", shift: true },
"^": { key: "Digit6", altRight: true },
"è": { key: "Digit7" },
7: { key: "Digit7", shift: true },
"!": { key: "Digit8" },
8: { key: "Digit8", shift: true },
"ç": { key: "Digit9" },
9: { key: "Digit9", shift: true },
"{": { key: "Digit9", altRight: true },
"à": { key: "Digit0" },
0: { key: "Digit0", shift: true },
"}": { key: "Digit0", altRight: true },
")": { key: "Minus" },
"°": { key: "Minus", shift: true },
"-": { key: "Equal", deadKey: true },
"_": { key: "Equal", shift: true },
"[": { key: "BracketLeft", altRight: true },
"$": { key: "BracketRight" },
"*": { key: "BracketRight", altRight: true },
"]": { key: "BracketRight", altRight: true },
"ù": { key: "Quote" },
"%": { key: "Quote", shift: true },
"µ": { key: "Backslash" },
"£": { key: "Backslash", shift: true },
",": { key: "KeyM" },
"?": { key: "KeyM", shift: true },
";": { key: "Comma" },
".": { key: "Comma", shift: true },
":": { key: "Period" },
"/": { key: "Period", shift: true },
"=": { key: "Slash" },
"+": { key: "Slash", shift: true },
"~": { key: "Slash", deadKey: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
"\\": { key: "IntlBackslash", altRight: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,15 @@
import { KeyCombo } from "../keyboardLayouts"
import { chars as chars_de_CH } from "./de_CH"
export const name = "Français de Suisse";
export const chars = {
...chars_de_CH,
"è": { key: "BracketLeft" },
"ü": { key: "BracketLeft", shift: true },
"é": { key: "Semicolon" },
"ö": { key: "Semicolon", shift: true },
"à": { key: "Quote" },
"ä": { key: "Quote", shift: true },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,139 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Français";
const keyTrema = { key: "BracketLeft", shift: true } // tréma (umlaut), two dots placed above a vowel
const keyHat = { key: "BracketLeft" } // accent circonflexe (accent hat), mark ^ placed above the letter
export const chars = {
A: { key: "KeyQ", shift: true },
"Ä": { key: "KeyQ", shift: true, accentKey: keyTrema },
"Â": { key: "KeyQ", shift: true, accentKey: keyHat },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "Semicolon", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
P: { key: "KeyP", shift: true },
Q: { key: "KeyA", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
V: { key: "KeyV", shift: true },
W: { key: "KeyZ", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyW", shift: true },
a: { key: "KeyQ" },
"ä": { key: "KeyQ", accentKey: keyTrema },
"â": { key: "KeyQ", accentKey: keyHat },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"ê": { key: "KeyE", accentKey: keyHat },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"î": { key: "KeyI", accentKey: keyHat },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "Semicolon" },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ö": { key: "KeyO", accentKey: keyTrema },
"ô": { key: "KeyO", accentKey: keyHat },
p: { key: "KeyP" },
q: { key: "KeyA" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ü": { key: "KeyU", accentKey: keyTrema },
"û": { key: "KeyU", accentKey: keyHat },
v: { key: "KeyV" },
w: { key: "KeyZ" },
x: { key: "KeyX" },
y: { key: "KeyY" },
z: { key: "KeyW" },
"²": { key: "Backquote" },
"&": { key: "Digit1" },
1: { key: "Digit1", shift: true },
"é": { key: "Digit2" },
2: { key: "Digit2", shift: true },
"~": { key: "Digit2", altRight: true },
"\"": { key: "Digit3" },
3: { key: "Digit3", shift: true },
"#": { key: "Digit3", altRight: true },
"'": { key: "Digit4" },
4: { key: "Digit4", shift: true },
"{": { key: "Digit4", altRight: true },
"(": { key: "Digit5" },
5: { key: "Digit5", shift: true },
"[": { key: "Digit5", altRight: true },
"-": { key: "Digit6" },
6: { key: "Digit6", shift: true },
"|": { key: "Digit6", altRight: true },
"è": { key: "Digit7" },
7: { key: "Digit7", shift: true },
"`": { key: "Digit7", altRight: true },
"_": { key: "Digit8" },
8: { key: "Digit8", shift: true },
"\\": { key: "Digit8", altRight: true },
"ç": { key: "Digit9" },
9: { key: "Digit9", shift: true },
"^": { key: "Digit9", altRight: true },
"à": { key: "Digit0" },
0: { key: "Digit0", shift: true },
"@": { key: "Digit0", altRight: true },
")": { key: "Minus" },
"°": { key: "Minus", shift: true },
"]": { key: "Minus", altRight: true },
"=": { key: "Equal" },
"+": { key: "Equal", shift: true },
"}": { key: "Equal", altRight: true },
"$": { key: "BracketRight" },
"£": { key: "BracketRight", shift: true },
"¤": { key: "BracketRight", altRight: true },
"ù": { key: "Quote" },
"%": { key: "Quote", shift: true },
"*": { key: "Backslash" },
"µ": { key: "Backslash", shift: true },
",": { key: "KeyM" },
"?": { key: "KeyM", shift: true },
";": { key: "Comma" },
".": { key: "Comma", shift: true },
":": { key: "Period" },
"/": { key: "Period", shift: true },
"!": { key: "Slash" },
"§": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,113 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Italiano";
export const chars = {
A: { key: "KeyA", shift: true },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyY", shift: true },
Z: { key: "KeyZ", shift: true },
a: { key: "KeyA" },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyY" },
z: { key: "KeyZ" },
"\\": { key: "Backquote" },
"|": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
3: { key: "Digit3" },
"£": { key: "Digit3", shift: true },
4: { key: "Digit4" },
"$": { key: "Digit4", shift: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"'": { key: "Minus" },
"?": { key: "Minus", shift: true },
"ì": { key: "Equal" },
"^": { key: "Equal", shift: true },
"è": { key: "BracketLeft" },
"é": { key: "BracketLeft", shift: true },
"[": { key: "BracketLeft", altRight: true },
"{": { key: "BracketLeft", shift: true, altRight: true },
"+": { key: "BracketRight" },
"*": { key: "BracketRight", shift: true },
"]": { key: "BracketRight", altRight: true },
"}": { key: "BracketRight", shift: true, altRight: true },
"ò": { key: "Semicolon" },
"ç": { key: "Semicolon", shift: true },
"@": { key: "Semicolon", altRight: true },
"à": { key: "Quote" },
"°": { key: "Quote", shift: true },
"#": { key: "Quote", altRight: true },
"ù": { key: "Backslash" },
"§": { key: "Backslash", shift: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

View File

@ -0,0 +1,167 @@
import { KeyCombo } from "../keyboardLayouts"
export const name = "Norsk bokmål";
const keyTrema = { key: "BracketRight" } // tréma (umlaut), two dots placed above a vowel
const keyAcute = { key: "Equal", altRight: true } // accent aigu (acute accent), mark ´ placed above the letter
const keyHat = { key: "BracketRight", shift: true } // accent circonflexe (accent hat), mark ^ placed above the letter
const keyGrave = { key: "Equal", shift: true } // accent grave, mark ` placed above the letter
const keyTilde = { key: "BracketRight", altRight: true } // tilde, mark ~ placed above the letter
export const chars = {
A: { key: "KeyA", shift: true },
"Ä": { key: "KeyA", shift: true, accentKey: keyTrema },
"Á": { key: "KeyA", shift: true, accentKey: keyAcute },
"Â": { key: "KeyA", shift: true, accentKey: keyHat },
"À": { key: "KeyA", shift: true, accentKey: keyGrave },
"Ã": { key: "KeyA", shift: true, accentKey: keyTilde },
B: { key: "KeyB", shift: true },
C: { key: "KeyC", shift: true },
D: { key: "KeyD", shift: true },
E: { key: "KeyE", shift: true },
"Ë": { key: "KeyE", shift: true, accentKey: keyTrema },
"É": { key: "KeyE", shift: true, accentKey: keyAcute },
"Ê": { key: "KeyE", shift: true, accentKey: keyHat },
"È": { key: "KeyE", shift: true, accentKey: keyGrave },
"Ẽ": { key: "KeyE", shift: true, accentKey: keyTilde },
F: { key: "KeyF", shift: true },
G: { key: "KeyG", shift: true },
H: { key: "KeyH", shift: true },
I: { key: "KeyI", shift: true },
"Ï": { key: "KeyI", shift: true, accentKey: keyTrema },
"Í": { key: "KeyI", shift: true, accentKey: keyAcute },
"Î": { key: "KeyI", shift: true, accentKey: keyHat },
"Ì": { key: "KeyI", shift: true, accentKey: keyGrave },
"Ĩ": { key: "KeyI", shift: true, accentKey: keyTilde },
J: { key: "KeyJ", shift: true },
K: { key: "KeyK", shift: true },
L: { key: "KeyL", shift: true },
M: { key: "KeyM", shift: true },
N: { key: "KeyN", shift: true },
O: { key: "KeyO", shift: true },
"Ö": { key: "KeyO", shift: true, accentKey: keyTrema },
"Ó": { key: "KeyO", shift: true, accentKey: keyAcute },
"Ô": { key: "KeyO", shift: true, accentKey: keyHat },
"Ò": { key: "KeyO", shift: true, accentKey: keyGrave },
"Õ": { key: "KeyO", shift: true, accentKey: keyTilde },
P: { key: "KeyP", shift: true },
Q: { key: "KeyQ", shift: true },
R: { key: "KeyR", shift: true },
S: { key: "KeyS", shift: true },
T: { key: "KeyT", shift: true },
U: { key: "KeyU", shift: true },
"Ü": { key: "KeyU", shift: true, accentKey: keyTrema },
"Ú": { key: "KeyU", shift: true, accentKey: keyAcute },
"Û": { key: "KeyU", shift: true, accentKey: keyHat },
"Ù": { key: "KeyU", shift: true, accentKey: keyGrave },
"Ũ": { key: "KeyU", shift: true, accentKey: keyTilde },
V: { key: "KeyV", shift: true },
W: { key: "KeyW", shift: true },
X: { key: "KeyX", shift: true },
Y: { key: "KeyZ", shift: true },
Z: { key: "KeyY", shift: true },
a: { key: "KeyA" },
"ä": { key: "KeyA", accentKey: keyTrema },
"á": { key: "KeyA", accentKey: keyAcute },
"â": { key: "KeyA", accentKey: keyHat },
"à": { key: "KeyA", accentKey: keyGrave },
"ã": { key: "KeyA", accentKey: keyTilde },
b: { key: "KeyB" },
c: { key: "KeyC" },
d: { key: "KeyD" },
e: { key: "KeyE" },
"ë": { key: "KeyE", accentKey: keyTrema },
"é": { key: "KeyE", accentKey: keyAcute },
"ê": { key: "KeyE", accentKey: keyHat },
"è": { key: "KeyE", accentKey: keyGrave },
"ẽ": { key: "KeyE", accentKey: keyTilde },
"€": { key: "KeyE", altRight: true },
f: { key: "KeyF" },
g: { key: "KeyG" },
h: { key: "KeyH" },
i: { key: "KeyI" },
"ï": { key: "KeyI", accentKey: keyTrema },
"í": { key: "KeyI", accentKey: keyAcute },
"î": { key: "KeyI", accentKey: keyHat },
"ì": { key: "KeyI", accentKey: keyGrave },
"ĩ": { key: "KeyI", accentKey: keyTilde },
j: { key: "KeyJ" },
k: { key: "KeyK" },
l: { key: "KeyL" },
m: { key: "KeyM" },
n: { key: "KeyN" },
o: { key: "KeyO" },
"ö": { key: "KeyO", accentKey: keyTrema },
"ó": { key: "KeyO", accentKey: keyAcute },
"ô": { key: "KeyO", accentKey: keyHat },
"ò": { key: "KeyO", accentKey: keyGrave },
"õ": { key: "KeyO", accentKey: keyTilde },
p: { key: "KeyP" },
q: { key: "KeyQ" },
r: { key: "KeyR" },
s: { key: "KeyS" },
t: { key: "KeyT" },
u: { key: "KeyU" },
"ü": { key: "KeyU", accentKey: keyTrema },
"ú": { key: "KeyU", accentKey: keyAcute },
"û": { key: "KeyU", accentKey: keyHat },
"ù": { key: "KeyU", accentKey: keyGrave },
"ũ": { key: "KeyU", accentKey: keyTilde },
v: { key: "KeyV" },
w: { key: "KeyW" },
x: { key: "KeyX" },
y: { key: "KeyZ" },
z: { key: "KeyY" },
"|": { key: "Backquote" },
"§": { key: "Backquote", shift: true },
1: { key: "Digit1" },
"!": { key: "Digit1", shift: true },
2: { key: "Digit2" },
"\"": { key: "Digit2", shift: true },
"@": { key: "Digit2", altRight: true },
3: { key: "Digit3" },
"#": { key: "Digit3", shift: true },
"£": { key: "Digit3", altRight: true },
4: { key: "Digit4" },
"¤": { key: "Digit4", shift: true },
"$": { key: "Digit4", altRight: true },
5: { key: "Digit5" },
"%": { key: "Digit5", shift: true },
6: { key: "Digit6" },
"&": { key: "Digit6", shift: true },
7: { key: "Digit7" },
"/": { key: "Digit7", shift: true },
"{": { key: "Digit7", altRight: true },
8: { key: "Digit8" },
"(": { key: "Digit8", shift: true },
"[": { key: "Digit8", altRight: true },
9: { key: "Digit9" },
")": { key: "Digit9", shift: true },
"]": { key: "Digit9", altRight: true },
0: { key: "Digit0" },
"=": { key: "Digit0", shift: true },
"}": { key: "Digit0", altRight: true },
"+": { key: "Minus" },
"?": { key: "Minus", shift: true },
"\\": { key: "Equal" },
"å": { key: "BracketLeft" },
"Å": { key: "BracketLeft", shift: true },
"ø": { key: "Semicolon" },
"Ø": { key: "Semicolon", shift: true },
"æ": { key: "Quote" },
"Æ": { key: "Quote", shift: true },
"'": { key: "Backslash" },
"*": { key: "Backslash", shift: true },
",": { key: "Comma" },
";": { key: "Comma", shift: true },
".": { key: "Period" },
":": { key: "Period", shift: true },
"-": { key: "Slash" },
"_": { key: "Slash", shift: true },
"<": { key: "IntlBackslash" },
">": { key: "IntlBackslash", shift: true },
" ": { key: "Space" },
"\n": { key: "Enter" },
Enter: { key: "Enter" },
Tab: { key: "Tab" },
} as Record<string, KeyCombo>;

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