fix: remove localization from 0.4.10

This commit is contained in:
Siyuan Miao 2025-11-19 15:49:00 +00:00
parent 5717761d16
commit 161c272099
11 changed files with 809 additions and 554 deletions

11
go.mod
View File

@ -5,6 +5,7 @@ go 1.24.4
require (
github.com/Masterminds/semver/v3 v3.4.0
github.com/beevik/ntp v1.4.3
github.com/caarlos0/env/v11 v11.3.1
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.15.0
github.com/creack/pty v1.1.24
@ -34,6 +35,8 @@ require (
golang.org/x/crypto v0.42.0
golang.org/x/net v0.44.0
golang.org/x/sys v0.36.0
google.golang.org/grpc v1.76.0
google.golang.org/protobuf v1.36.10
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
@ -42,14 +45,13 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/caarlos0/env/v11 v11.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
@ -82,7 +84,6 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
@ -90,11 +91,7 @@ require (
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

40
go.sum
View File

@ -46,8 +46,12 @@ github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.16.6 h1:zI2Ya9sqvuLcgqJgV79LwoJXM8h20Z/drtB7ATbpRWo=
github.com/go-co-op/gocron/v2 v2.16.6/go.mod h1:zAfC/GFQ668qHxOVl/D68Jh5Ce7sDqX6TJnSQyRkRBc=
github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY=
github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw=
github.com/go-jose/go-jose/v4 v4.1.2 h1:TK/7NqRQZfgAh+Td8AlsrvtPoUyiHh0LqVvokh+1vHI=
github.com/go-jose/go-jose/v4 v4.1.2/go.mod h1:22cg9HWM1pOlnRiY+9cQYJ9XHmya1bYW8OeDM6Ku6Oo=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
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/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -59,6 +63,8 @@ github.com/go-playground/validator/v10 v10.27.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/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
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=
@ -160,8 +166,6 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
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/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -185,6 +189,18 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
@ -195,25 +211,25 @@ golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

11
hw.go
View File

@ -8,6 +8,8 @@ import (
"sync"
"time"
"os/exec"
"github.com/jetkvm/kvm/internal/ota"
)
@ -30,14 +32,6 @@ func extractSerialNumber() (string, error) {
return matches[1], nil
}
<<<<<<< HEAD
func readOtpEntropy() ([]byte, error) { //nolint:unused
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
if err != nil {
return nil, err
}
return content[0x17:0x1C], nil
=======
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
logger.Info().Dur("delayMs", delay).Msg("reboot requested")
@ -69,7 +63,6 @@ func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Dur
}()
return nil
>>>>>>> 752fb55 (refactor: OTA (#912))
}
var deviceID string

View File

@ -12,14 +12,12 @@ show_help() {
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --gdb-port <port> GDB debug port (default: 2345)"
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-native-build Skip native build"
echo " --disable-docker Disable docker build"
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
echo " --native-binary Build and deploy the native binary (FOR DEBUGGING ONLY)"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
@ -28,6 +26,31 @@ show_help() {
echo " $0 -r 192.168.0.17 -u admin"
}
# Function to check if device is pingable
check_ping() {
local host=$1
msg_info "▶ Checking if device is reachable at ${host}..."
if ! ping -c 3 -W 5 "${host}" > /dev/null 2>&1; then
msg_err "Error: Cannot reach device at ${host}"
msg_err "Please verify the IP address and network connectivity"
exit 1
fi
msg_info "✓ Device is reachable"
}
# Function to check if SSH is accessible
check_ssh() {
local user=$1
local host=$2
msg_info "▶ Checking SSH connectivity to ${user}@${host}..."
if ! ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${user}@${host}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
msg_err "Error: Cannot establish SSH connection to ${user}@${host}"
msg_err "Please verify SSH access and credentials"
exit 1
fi
msg_info "✓ SSH connection successful"
}
# Default values
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
REMOTE_USER="root"
@ -35,8 +58,6 @@ REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
SKIP_UI_BUILD_RELEASE=0
SKIP_NATIVE_BUILD=0
GDB_DEBUG_PORT=2345
BUILD_NATIVE_BINARY=false
ENABLE_SYNC_TRACE=0
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
@ -58,10 +79,6 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2"
shift 2
;;
--gdb-port)
GDB_DEBUG_PORT="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
@ -74,6 +91,11 @@ while [[ $# -gt 0 ]]; do
RESET_USB_HID_DEVICE=true
shift
;;
--enable-sync-trace)
ENABLE_SYNC_TRACE=1
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES},synctrace"
shift
;;
--disable-docker)
BUILD_IN_DOCKER=false
shift
@ -91,10 +113,6 @@ while [[ $# -gt 0 ]]; do
RUN_GO_TESTS=true
shift
;;
--native-binary)
BUILD_NATIVE_BINARY=true
shift
;;
-i|--install)
INSTALL_APP=true
shift
@ -123,10 +141,6 @@ fi
# Check device connectivity before proceeding
check_ping "${REMOTE_HOST}"
check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
function sshdev() {
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "$@"
return $?
}
# check if the current CPU architecture is x86_64
if [ "$(uname -m)" != "x86_64" ]; then
@ -138,34 +152,6 @@ if [ "$BUILD_IN_DOCKER" = true ]; then
build_docker_image
fi
if [ "$BUILD_NATIVE_BINARY" = true ]; then
msg_info "▶ Building native binary"
CMAKE_BUILD_TYPE=Debug make build_native
msg_info "▶ Checking if GDB is available on remote host"
if ! sshdev "command -v gdbserver > /dev/null 2>&1"; then
msg_warn "Error: gdbserver is not installed on the remote host"
tar -czf - -C /opt/jetkvm-native-buildkit/gdb/ . | sshdev "tar -xzf - -C /usr/bin"
msg_info "✓ gdbserver installed on remote host"
fi
msg_info "▶ Stopping any existing instances of jetkvm_native_debug on remote host"
sshdev "killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug gdbserver || true >> /dev/null 2>&1"
sshdev "cat > ${REMOTE_PATH}/jetkvm_native_debug" < internal/native/cgo/build/jknative-bin
sshdev -t ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
cd ${REMOTE_PATH}
killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug || true
sleep 5
echo 'V' > /dev/watchdog
chmod +x jetkvm_native_debug
gdbserver localhost:${GDB_DEBUG_PORT} ./jetkvm_native_debug
EOF
exit 0
fi
# Build the development version on the host
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
# check if static/index.html exists
@ -174,7 +160,7 @@ if [[ "$SKIP_UI_BUILD" = true && ! -f "static/index.html" ]]; then
SKIP_UI_BUILD=false
fi
if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then
if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then
msg_info "▶ Building frontend"
make frontend SKIP_UI_BUILD=0
SKIP_UI_BUILD_RELEASE=1
@ -187,13 +173,13 @@ fi
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
sshdev "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
sshdev ash << 'EOF'
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
@ -230,33 +216,39 @@ fi
if [ "$INSTALL_APP" = true ]
then
msg_info "▶ Building release binary"
do_make build_release SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_release \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater.
sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
sshdev "reboot"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE}
do_make build_dev \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application
sshdev "killall jetkvm_app_debug || true"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
sshdev "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
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
sshdev "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
sshdev "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
sshdev ash << EOF
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
@ -266,6 +258,17 @@ export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Wait until both binaries are killed, max 10 seconds
i=1
while [ \$i -le 10 ]; do
echo "Waiting for jetkvm_app and jetkvm_app_debug to be killed, \$i/10 ..."
if ! pgrep -f "jetkvm_app" > /dev/null && ! pgrep -f "jetkvm_app_debug" > /dev/null; then
break
fi
sleep 1
i=\$((i + 1))
done
# Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}"

View File

@ -572,14 +572,21 @@ export interface OtaState {
export interface UpdateState {
isUpdatePending: boolean;
setIsUpdatePending: (isPending: boolean) => void;
updateDialogHasBeenMinimized: boolean;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
otaState: OtaState;
setOtaState: (state: OtaState) => void;
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
modalView: UpdateModalViews;
setModalView: (view: UpdateModalViews) => void;
setUpdateErrorMessage: (errorMessage: string) => void;
updateErrorMessage: string | null;
setUpdateErrorMessage: (errorMessage: string) => void;
shouldReload: boolean;
setShouldReload: (reloadRequired: boolean) => void;
}
export const useUpdateStore = create<UpdateState>(set => ({
@ -610,11 +617,16 @@ export const useUpdateStore = create<UpdateState>(set => ({
updateDialogHasBeenMinimized: false,
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) =>
set({ updateDialogHasBeenMinimized: hasBeenMinimized }),
modalView: "loading",
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
updateErrorMessage: null,
setUpdateErrorMessage: (errorMessage: string) =>
set({ updateErrorMessage: errorMessage }),
shouldReload: false,
setShouldReload: (reloadRequired: boolean) => set({ shouldReload: reloadRequired }),
}));
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";

View File

@ -1,7 +1,8 @@
import { useCallback } from "react";
import { useDeviceStore } from "@/hooks/stores";
import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc";
import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc";
import notifications from "@/notifications";
export interface VersionInfo {
@ -17,19 +18,6 @@ export interface SystemVersionInfo {
error?: string;
}
export interface VersionInfo {
appVersion: string;
systemVersion: string;
}
export interface SystemVersionInfo {
local: VersionInfo;
remote?: VersionInfo;
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
error?: string;
}
export function useVersion() {
const {
appVersion,
@ -37,51 +25,40 @@ export function useVersion() {
setAppVersion,
setSystemVersion,
} = useDeviceStore();
const { send } = useJsonRpc();
const getVersionInfo = useCallback(() => {
return new Promise<SystemVersionInfo>((resolve, reject) => {
send("getUpdateStatus", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(`Failed to check for updates: ${resp.error}`);
reject(new Error("Failed to check for updates"));
} else {
const result = resp.result as SystemVersionInfo;
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
if (result.error) {
notifications.error(`Failed to check for updates: ${result.error}`);
reject(new Error("Failed to check for updates"));
} else {
resolve(result);
}
}
});
});
}, [send, setAppVersion, setSystemVersion]);
const getVersionInfo = useCallback(async () => {
try {
const result = await getUpdateStatus();
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
return result;
} catch (error) {
const jsonRpcError = error as JsonRpcError;
notifications.error(`Failed to check for updates: ${jsonRpcError.message}`);
throw jsonRpcError;
}
}, [setAppVersion, setSystemVersion]);
const getLocalVersion = useCallback(() => {
return new Promise<VersionInfo>((resolve, reject) => {
send("getLocalVersion", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.log(resp.error)
if (resp.error.code === RpcMethodNotFound) {
console.warn("Failed to get device version, using legacy version");
return getVersionInfo().then(result => resolve(result.local)).catch(reject);
}
console.error("Failed to get device version N", resp.error);
notifications.error(`Failed to get device version: ${resp.error}`);
reject(new Error("Failed to get device version"));
} else {
const result = resp.result as VersionInfo;
const getLocalVersion = useCallback(async () => {
try {
const result = await getLocalVersionRpc();
setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion);
return result;
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion);
resolve(result);
}
});
});
}, [send, setAppVersion, setSystemVersion, getVersionInfo]);
if (jsonRpcError.code === RpcMethodNotFound) {
console.error("Failed to get local version, using legacy remote version");
const result = await getVersionInfo();
return result.local;
}
console.error("Failed to get device version", jsonRpcError);
notifications.error(`Failed to get device version: ${jsonRpcError.message}`);
throw jsonRpcError;
}
}, [setAppVersion, setSystemVersion, getVersionInfo]);
return {
getVersionInfo,
@ -89,4 +66,4 @@ export function useVersion() {
appVersion,
systemVersion,
};
}
}

View File

@ -1,22 +1,23 @@
import { useLoaderData, useNavigate } from "react-router";
import type { LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import { useCallback, useEffect, useState } from "react";
import { useLoaderData, useNavigate, type LoaderFunction } from "react-router";
import { ShieldCheckIcon } from "@heroicons/react/24/outline";
import api from "@/api";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { GridCard } from "@/components/Card";
import { Button, LinkButton } from "@/components/Button";
import { InputFieldWithLabel } from "@/components/InputField";
import { SelectMenuBasic } from "@/components/SelectMenuBasic";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { GridCard } from "@components/Card";
import { Button, LinkButton } from "@components/Button";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsSectionHeader } from "@/components/SettingsSectionHeader";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea";
import api from "@/api";
import notifications from "@/notifications";
import { DEVICE_API } from "@/ui.config";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { isOnDevice } from "@/main";
import { TextAreaWithLabel } from "@components/TextArea";
import { m } from "@localizations/messages.js";
import { LocalDevice } from "./devices.$id";
import { CloudState } from "./adopt";
@ -92,13 +93,13 @@ export default function SettingsAccessIndexRoute() {
send("deregisterDevice", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
m.access_failed_deregister({ error: resp.error.data || m.unknown_error() }),
);
return;
}
getCloudState();
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
// In cloud mode, we need to navigate to the device overview page, as we don't have a connection anymore
if (!isOnDevice) navigate("/");
return;
});
@ -107,14 +108,14 @@ export default function SettingsAccessIndexRoute() {
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
notifications.error(m.access_no_device_id());
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
m.access_failed_update_cloud_url({ error: resp.error.data || m.unknown_error() }),
);
return;
}
@ -160,12 +161,12 @@ export default function SettingsAccessIndexRoute() {
send("setTLSState", { state }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update TLS settings: ${resp.error.data || "Unknown error"}`,
m.access_failed_update_tls({ error: resp.error.data || m.unknown_error() }),
);
return;
}
notifications.success("TLS settings updated successfully");
notifications.success(m.access_tls_updated());
});
}, [send]);
@ -206,22 +207,22 @@ export default function SettingsAccessIndexRoute() {
return (
<div className="space-y-4">
<SettingsPageHeader
title="Access"
description="Manage the Access Control of the device"
title={m.access_title()}
description={m.access_description()}
/>
{loaderData?.authMode && (
<>
<div className="space-y-4">
<SettingsSectionHeader
title="Local"
description="Manage the mode of local access to the device"
title={m.access_local_title()}
description={m.access_local_description()}
/>
<>
<SettingsItem
title="HTTPS Mode"
title={m.access_https_mode_title()}
badge="Experimental"
description="Configure secure HTTPS access to your device"
description={m.access_https_description()}
>
<SelectMenuBasic
size="SM"
@ -229,52 +230,43 @@ export default function SettingsAccessIndexRoute() {
onChange={e => handleTlsModeChange(e.target.value)}
disabled={tlsMode === "unknown"}
options={[
{ value: "disabled", label: "Disabled" },
{ value: "self-signed", label: "Self-signed" },
{ value: "custom", label: "Custom" },
{ value: "disabled", label: m.access_tls_disabled() },
{ value: "self-signed", label: m.access_tls_self_signed() },
{ value: "custom", label: m.access_tls_custom() },
]}
/>
</SettingsItem>
{tlsMode === "custom" && (
<div className="mt-4 space-y-4">
<div className="space-y-4">
<SettingsItem
title="TLS Certificate"
description="Paste your TLS certificate below. For certificate chains, include the entire chain (leaf, intermediate, and root certificates)."
/>
<div className="space-y-4">
<TextAreaWithLabel
label="Certificate"
rows={3}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
/>
</div>
<div className="space-y-4">
<div className="space-y-4">
<TextAreaWithLabel
label="Private Key"
description="For security reasons, it will not be displayed after saving."
rows={3}
placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
/>
</div>
</div>
</div>
<NestedSettingsGroup className="mt-4">
<SettingsItem
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
/>
<TextAreaWithLabel
label={m.access_certificate_label()}
rows={3}
placeholder={
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
}
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
/>
<TextAreaWithLabel
label={m.access_private_key_label()}
description={m.access_private_key_description()}
rows={3}
placeholder={
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
}
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
/>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text="Update TLS Settings"
text={m.access_update_tls_settings()}
onClick={handleCustomTlsUpdate}
/>
</div>
@ -282,14 +274,14 @@ export default function SettingsAccessIndexRoute() {
)}
<SettingsItem
title="Authentication Mode"
description={`Current mode: ${loaderData.authMode === "password" ? "Password protected" : "No password"}`}
title={m.access_authentication_mode_title()}
description={loaderData.authMode === "password" ? m.access_auth_mode_password() : m.access_auth_mode_no_password()}
>
{loaderData.authMode === "password" ? (
<Button
size="SM"
theme="light"
text="Disable Protection"
text={m.access_disable_protection()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "deletePassword" } });
}}
@ -298,7 +290,7 @@ export default function SettingsAccessIndexRoute() {
<Button
size="SM"
theme="light"
text="Enable Password"
text={m.access_enable_password()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "createPassword" } });
}}
@ -309,13 +301,13 @@ export default function SettingsAccessIndexRoute() {
{loaderData.authMode === "password" && (
<SettingsItem
title="Change Password"
description="Update your device access password"
title={m.access_change_password_title()}
description={m.access_change_password_description()}
>
<Button
size="SM"
theme="light"
text="Change Password"
text={m.access_change_password_button()}
onClick={() => {
navigateTo("./local-auth", { state: { init: "updatePassword" } });
}}
@ -330,23 +322,23 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-4">
<SettingsSectionHeader
title="Remote"
description="Manage the mode of Remote access to the device"
description={m.access_remote_description()}
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
title={m.access_cloud_provider_title()}
description={m.access_cloud_provider_description()}
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
{ value: "jetkvm", label: m.access_provider_jetkvm() },
{ value: "custom", label: m.access_provider_custom() },
]}
/>
</SettingsItem>
@ -356,7 +348,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
label={m.access_cloud_api_url_label()}
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
@ -365,7 +357,7 @@ export default function SettingsAccessIndexRoute() {
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
label={m.access_cloud_app_url_label()}
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
@ -384,26 +376,26 @@ export default function SettingsAccessIndexRoute() {
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
{m.access_cloud_security_title()}
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
<li>{m.access_security_encryption()}</li>
<li>{m.access_security_zero_trust()}</li>
<li>{m.access_security_oidc()}</li>
<li>{m.access_security_streams()}</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
{m.access_security_open_source()}{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
{m.access_github_link()}
</a>
.
</div>
@ -415,7 +407,7 @@ export default function SettingsAccessIndexRoute() {
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
text={m.access_learn_security()}
/>
</div>
</div>
@ -429,32 +421,32 @@ export default function SettingsAccessIndexRoute() {
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
text={m.access_adopt_kvm()}
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud
{m.access_adopted_message()}
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
text={m.access_deregister()}
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
m.access_confirm_deregister(),
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
notifications.error(m.access_no_device_id());
}
}}
/>

View File

@ -19,7 +19,7 @@ export default function SettingsGeneralUpdateRoute() {
const [searchParams] = useSearchParams();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
const { send } = useJsonRpc();
const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || undefined, [searchParams]);
@ -37,6 +37,7 @@ export default function SettingsGeneralUpdateRoute() {
}, [navigate, setShouldReload, shouldReload]);
const onConfirmUpdate = useCallback(() => {
setShouldReload(true);
send("tryUpdate", {});
setModalView("updating");
}, [send, setModalView, setShouldReload]);
@ -66,7 +67,7 @@ export default function SettingsGeneralUpdateRoute() {
} else {
setModalView("loading");
}
}, [otaState.updating, otaState.error, setModalView, updateSuccess]);
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
return <Dialog
onClose={onClose}
@ -77,8 +78,6 @@ export default function SettingsGeneralUpdateRoute() {
/>;
}
export function Dialog({
onClose,
onConfirmUpdate,
@ -120,11 +119,6 @@ export function Dialog({
[setModalView, forceCustomUpdate],
);
// Reset modal view when dialog is opened
useEffect(() => {
setVersionInfo(null);
}, [setModalView]);
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
@ -189,6 +183,7 @@ function LoadingState({
const abortControllerRef = useRef<AbortController | null>(null);
const { getVersionInfo } = useVersion();
const { setModalView } = useUpdateStore();
const progressBarRef = useRef<HTMLDivElement>(null);
@ -204,28 +199,29 @@ function LoadingState({
}, [customAppVersion, customSystemVersion, getVersionInfo]);
useEffect(() => {
setProgressWidth("0%");
abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal;
const animationTimer = setTimeout(() => {
// we start the progress bar animation after a tiny delay to avoid react warnings
setProgressWidth("100%");
}, 0);
checkUpdate()
.then(async versionInfo => {
// Add a small delay to ensure it's not just flickering
return new Promise(resolve => setTimeout(() => resolve(versionInfo), 600));
await sleep(600);
return versionInfo
})
.then(versionInfo => {
if (!signal.aborted) {
onFinished(versionInfo as SystemVersionInfo);
onFinished(versionInfo);
}
})
.catch(error => {
if (!signal.aborted) {
console.error("LoadingState: Error fetching version info", error);
setModalView("error");
}
});
@ -268,72 +264,94 @@ function UpdatingDeviceState({
otaState: UpdateState["otaState"];
onMinimizeUpgradeDialog: () => void;
}) {
const formatProgress = (progress: number) => `${Math.round(progress)}%`;
interface ProgressSummary {
system: UpdatePart;
app: UpdatePart;
areAllUpdatesComplete: boolean;
};
const calculateOverallProgress = (type: "system" | "app") => {
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
const verificationProgress = Math.round(
(otaState[`${type}VerificationProgress`] || 0) * 100,
);
if (!downloadProgress && !updateProgress && !verificationProgress) {
return 0;
}
console.log(
`For ${type}:\n` +
` Download Progress: ${downloadProgress}% (${otaState[`${type}DownloadProgress`]})\n` +
` Update Progress: ${updateProgress}% (${otaState[`${type}UpdateProgress`]})\n` +
` Verification Progress: ${verificationProgress}% (${otaState[`${type}VerificationProgress`]})`,
);
if (type === "app") {
// App: 65% download, 34% verification, 1% update(There is no "real" update for the app)
return Math.min(
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
100,
const progress = useMemo<ProgressSummary>(() => {
const calculateOverallProgress = (type: "system" | "app") => {
const downloadProgress = Math.round((otaState[`${type}DownloadProgress`] || 0) * 100);
const updateProgress = Math.round((otaState[`${type}UpdateProgress`] || 0) * 100);
const verificationProgress = Math.round(
(otaState[`${type}VerificationProgress`] || 0) * 100,
);
if (!downloadProgress && !updateProgress && !verificationProgress) {
return 0;
}
if (type === "app") {
// App: 55% download, 54% verification, 1% update(There is no "real" update for the app)
return Math.round(Math.min(
downloadProgress * 0.55 + verificationProgress * 0.54 + updateProgress * 0.01,
100,
));
} else {
// System: 10% download, 10% verification, 80% update
return Math.round(Math.min(
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
100,
));
}
};
const getUpdateStatus = (type: "system" | "app") => {
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verifiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
} else if (!verifiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
} else {
return `Awaiting reboot`;
}
};
const isUpdateComplete = (type: "system" | "app") => {
return !!otaState[`${type}UpdatedAt`];
};
const systemUpdatePending = otaState.systemUpdatePending
const systemUpdateComplete = isUpdateComplete("system");
const appUpdatePending = otaState.appUpdatePending
const appUpdateComplete = isUpdateComplete("app");
let areAllUpdatesComplete: boolean;
if (!systemUpdatePending && !appUpdatePending) {
areAllUpdatesComplete = false;
} else if (systemUpdatePending && appUpdatePending) {
areAllUpdatesComplete = systemUpdateComplete && appUpdateComplete;
} else {
// System: 10% download, 90% update
return Math.min(
downloadProgress * 0.1 + verificationProgress * 0.1 + updateProgress * 0.8,
100,
);
areAllUpdatesComplete = systemUpdatePending ? systemUpdateComplete : appUpdateComplete;
}
};
const getUpdateStatus = (type: "system" | "app") => {
const downloadFinishedAt = otaState[`${type}DownloadFinishedAt`];
const verfiedAt = otaState[`${type}VerifiedAt`];
const updatedAt = otaState[`${type}UpdatedAt`];
return {
system: {
pending: systemUpdatePending,
status: getUpdateStatus("system"),
progress: calculateOverallProgress("system"),
complete: systemUpdateComplete,
},
app: {
pending: appUpdatePending,
status: getUpdateStatus("app"),
progress: calculateOverallProgress("app"),
complete: appUpdateComplete,
},
areAllUpdatesComplete,
};
}, [otaState]);
if (!otaState.metadataFetchedAt) {
return "Fetching update information...";
} else if (!downloadFinishedAt) {
return `Downloading ${type} update...`;
} else if (!verfiedAt) {
return `Verifying ${type} update...`;
} else if (!updatedAt) {
return `Installing ${type} update...`;
} else {
return `Awaiting reboot`;
}
};
const isUpdateComplete = (type: "system" | "app") => {
return !!otaState[`${type}UpdatedAt`];
};
const areAllUpdatesComplete = () => {
if (otaState.systemUpdatePending && otaState.appUpdatePending) {
return isUpdateComplete("system") && isUpdateComplete("app");
}
return (
(otaState.systemUpdatePending && isUpdateComplete("system")) ||
(otaState.appUpdatePending && isUpdateComplete("app"))
);
};
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
@ -347,7 +365,7 @@ function UpdatingDeviceState({
</p>
</div>
<Card className="space-y-4 p-4">
{areAllUpdatesComplete() ? (
{progress.areAllUpdatesComplete ? (
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
@ -358,72 +376,22 @@ function UpdatingDeviceState({
</div>
) : (
<>
{!(otaState.systemUpdatePending || otaState.appUpdatePending) && (
{!(progress.system.pending || progress.app.pending) && (
<div className="my-2 flex flex-col items-center space-y-2 text-center">
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
</div>
)}
{otaState.systemUpdatePending && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
Linux System Update
</p>
{calculateOverallProgress("system") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("system")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("system")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("system"))}</span>
) : null}
</div>
</div>
{progress.system.pending && (
<UpdatingStatusCard label="Linux System Update" part={progress.system} />
)}
{otaState.appUpdatePending && (
<>
{otaState.systemUpdatePending && (
<hr className="dark:border-slate-600" />
)}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-black dark:text-white">
App Update
</p>
{calculateOverallProgress("app") < 100 ? (
<LoadingSpinner className="h-4 w-4 text-blue-700 dark:text-blue-500" />
) : (
<CheckCircleIcon className="h-4 w-4 text-blue-700 dark:text-blue-500" />
)}
</div>
<div className="h-2.5 w-full overflow-hidden rounded-full bg-slate-300 dark:bg-slate-600">
<div
className="h-2.5 rounded-full bg-blue-700 transition-all duration-500 ease-linear dark:bg-blue-500"
style={{
width: formatProgress(calculateOverallProgress("app")),
}}
></div>
</div>
<div className="flex justify-between text-sm text-slate-600 dark:text-slate-300">
<span>{getUpdateStatus("app")}</span>
{calculateOverallProgress("system") < 100 ? (
<span>{formatProgress(calculateOverallProgress("app"))}</span>
) : null}
</div>
</div>
</>
{progress.system.pending && progress.app.pending && (
<hr className="dark:border-slate-600" />
)}
{progress.app.pending && (
<UpdatingStatusCard label="App Update" part={progress.app} />
)}
</>
)}
@ -481,7 +449,7 @@ function UpdateAvailableState({
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
Update available
Update Available
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
A new update is available to enhance system performance and improve
@ -490,24 +458,24 @@ function UpdateAvailableState({
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? (
<>
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.systemVersion}
<span className="font-semibold">Linux System Update</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.systemVersion}
<br />
</>
) : null}
{versionInfo?.appUpdateAvailable ? (
<>
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.appVersion}
<span className="font-semibold">App Update</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300"></span> {versionInfo?.remote?.appVersion}
</>
) : null}
{versionInfo?.willDisableAutoUpdate ? (
<p className="mb-4 text-sm text-red-600 dark:text-red-400">
{m.general_update_will_disable_auto_update_description()}
You{"'"}re about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.
</p>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirm} />
<Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirm} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
</div>
</div>
</div>

View File

@ -1,228 +1,502 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores";
import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { SystemVersionInfo } from "@hooks/useVersion";
import { Button } from "@components/Button";
import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { Checkbox } from "@components/Checkbox";
import { isOnDevice } from "@/main";
import notifications from "@/notifications";
import { sleep } from "@/utils";
import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsHardwareRoute() {
export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore();
const [devChannel, setDevChannel] = useState(false);
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const [updateTarget, setUpdateTarget] = useState<string>("app");
const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>("");
const [resetConfig, setResetConfig] = useState(false);
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false);
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation);
handleDisplayRotationSave();
};
const handleDisplayRotationSave = () => {
send("setDisplayRotation", { params: { rotation: settings.displayRotation } }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set display orientation: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Display orientation updated successfully");
});
};
const { setBacklightSettings } = useSettingsStore();
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
// value to never.
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
settings.dim_after = 0;
}
setBacklightSettings(settings);
handleBacklightSettingsSave();
};
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Backlight settings updated successfully");
});
};
const handlePowerSavingChange = (enabled: boolean) => {
setPowerSavingEnabled(enabled);
const duration = enabled ? 90 : -1;
send("setVideoSleepMode", { duration }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set power saving mode: ${resp.error.data || "Unknown error"}`,
);
setPowerSavingEnabled(!enabled); // Revert on error
return;
}
notifications.success(`Power saving mode ${enabled ? "enabled" : "disabled"}`);
});
};
useEffect(() => {
send("getBacklightSettings", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
return notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
send("getDevModeState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
}, [send, setBacklightSettings]);
useEffect(() => {
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to get power saving mode:", resp.error);
return;
}
const result = resp.result as { enabled: boolean; duration: number };
setPowerSavingEnabled(result.duration >= 0);
send("getSSHKeyState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
});
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getLocalLoopbackOnly", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
});
}, [send, setDeveloperMode]);
const getUsbEmulationState = useCallback(() => {
send("getUsbEmulationState", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) return;
setUsbEmulationEnabled(resp.result as boolean);
});
}, [send]);
const handleUsbEmulationToggle = useCallback(
(enabled: boolean) => {
send("setUsbEmulationState", { enabled: enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} USB emulation: ${resp.error.data || "Unknown error"}`,
);
return;
}
setUsbEmulationEnabled(enabled);
getUsbEmulationState();
});
},
[getUsbEmulationState, send],
);
const handleResetConfig = useCallback(() => {
send("resetConfig", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to reset configuration: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Configuration reset to default successfully");
});
}, [send]);
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to update SSH key: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("SSH key updated successfully");
});
}, [send, sshKey]);
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = useCallback(
(enabled: boolean) => {
send("setDevChannelState", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
},
[send, setDevChannel],
);
const applyLoopbackOnlyMode = useCallback(
(enabled: boolean) => {
send("setLocalLoopbackOnly", { enabled }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to ${enabled ? "enable" : "disable"} loopback-only mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setLocalLoopbackOnly(enabled);
if (enabled) {
notifications.success(
"Loopback-only mode enabled. Restart your device to apply.",
);
} else {
notifications.success(
"Loopback-only mode disabled. Restart your device to apply.",
);
}
});
},
[send, setLocalLoopbackOnly],
);
const handleLoopbackOnlyModeChange = useCallback(
(enabled: boolean) => {
// If trying to enable loopback-only mode, show warning first
if (enabled) {
setShowLoopbackWarning(true);
} else {
// If disabling, just proceed
applyLoopbackOnlyMode(false);
}
},
[applyLoopbackOnlyMode, setShowLoopbackWarning],
);
const confirmLoopbackModeEnable = useCallback(() => {
applyLoopbackOnlyMode(true);
setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
notifications.error(
m.advanced_error_version_update({
error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error())
}),
{ duration: 1000 * 15 } // 15 seconds
);
setCustomVersionUpdateLoading(false);
}, []);
const handleCustomVersionUpdate = useCallback(async () => {
const components: UpdateComponents = {};
if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion;
if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion;
let versionInfo: SystemVersionInfo | undefined;
try {
// we do not need to set it to false if check succeeds,
// because it will be redirected to the update page later
setCustomVersionUpdateLoading(true);
versionInfo = await checkUpdateComponents({
components,
}, devChannel);
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
handleVersionUpdateError(jsonRpcError);
return;
}
let hasUpdate = false;
const pageParams = new URLSearchParams();
if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) {
hasUpdate = true;
pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
}
if (components.system && versionInfo?.remote?.systemVersion && versionInfo?.systemUpdateAvailable) {
hasUpdate = true;
pageParams.set("custom_system_version", versionInfo.remote?.systemVersion);
}
pageParams.set("reset_config", resetConfig.toString());
if (!hasUpdate) {
handleVersionUpdateError("No update available");
return;
}
// Navigate to update page
navigateTo(`/settings/general/update?${pageParams.toString()}`);
}, [
updateTarget, appVersion, systemVersion, devChannel,
navigateTo, resetConfig, handleVersionUpdateError,
setCustomVersionUpdateLoading
]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
title="Advanced"
description="Access additional settings for troubleshooting and customization"
/>
<div className="space-y-4">
<SettingsItem
title="Display Orientation"
description="Set the orientation of the display"
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.displayRotation.toString()}
options={[
{ value: "270", label: "Normal" },
{ value: "90", label: "Inverted" },
]}
<Checkbox
checked={devChannel}
onChange={e => {
settings.displayRotation = e.target.value;
handleDisplayRotationChange(settings.displayRotation);
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Display Brightness"
description="Set the brightness of the display"
title="Developer Mode"
description="Enable advanced features for developers"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode ? (
<NestedSettingsGroup>
<GridCard>
<div className="flex items-start gap-x-4 p-4 select-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.advanced_developer_mode_enabled_title()}
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
{m.advanced_developer_mode_warning_advanced()}
</div>
</div>
</div>
</GridCard>
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div>
</div>
)}
<FeatureFlag minAppVersion="0.4.10" name="version-update">
<div className="space-y-4">
<SettingsItem
title={m.advanced_version_update_title()}
description={m.advanced_version_update_description()}
/>
<SelectMenuBasic
label={m.advanced_version_update_target_label()}
options={[
{ value: "app", label: m.advanced_version_update_target_app() },
{ value: "system", label: m.advanced_version_update_target_system() },
{ value: "both", label: m.advanced_version_update_target_both() },
]}
value={updateTarget}
onChange={e => setUpdateTarget(e.target.value)}
/>
{(updateTarget === "app" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_app_label()}
placeholder="0.4.9"
value={appVersion}
onChange={e => setAppVersion(e.target.value)}
/>
)}
{(updateTarget === "system" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_system_label()}
placeholder="0.4.9"
value={systemVersion}
onChange={e => setSystemVersion(e.target.value)}
/>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_version_update_helper()}{" "}
<a
href="https://github.com/jetkvm/kvm/releases"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 hover:underline dark:text-blue-500"
>
{m.advanced_version_update_github_link()}
</a>
</p>
<div>
<CheckboxWithLabel
label={m.advanced_version_update_reset_config_label()}
description={m.advanced_version_update_reset_config_description()}
checked={resetConfig}
onChange={e => setResetConfig(e.target.checked)}
/>
</div>
<div>
<CheckboxWithLabel
label="I understand version changes may break my device and require factory reset"
checked={versionChangeAcknowledged}
onChange={e => setVersionChangeAcknowledged(e.target.checked)}
/>
</div>
<Button
size="SM"
theme="primary"
text={m.advanced_version_update_button()}
disabled={
(updateTarget === "app" && !appVersion) ||
(updateTarget === "system" && !systemVersion) ||
(updateTarget === "both" && (!appVersion || !systemVersion)) ||
!versionChangeAcknowledged ||
customVersionUpdateLoading
}
loading={customVersionUpdateLoading}
onClick={handleCustomVersionUpdate}
/>
</div>
</FeatureFlag>
</NestedSettingsGroup>
) : null}
<SettingsItem
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
>
<Checkbox
checked={localLoopbackOnly}
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title="Troubleshooting Mode"
description="Diagnostic tools and additional controls for troubleshooting and development purposes"
>
<Checkbox
defaultChecked={settings.debugMode}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
settings.setDebugMode(e.target.checked);
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
<>
{settings.debugMode && (
<NestedSettingsGroup>
<SettingsItem
title="Dim Display After"
description="Set how long to wait before dimming the display"
title="USB Emulation"
description="Control the USB emulation state"
>
<SelectMenuBasic
<Button
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
}}
theme="light"
text={
usbEmulationEnabled ? "Disable USB Emulation" : "Enable USB Emulation"
}
onClick={() => handleUsbEmulationToggle(!usbEmulationEnabled)}
/>
</SettingsItem>
<SettingsItem
title="Turn off Display After"
description="Period of inactivity before display automatically turns off"
title="Reset Configuration"
description="Reset configuration to default. This will log you out."
>
<SelectMenuBasic
<Button
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value);
handleBacklightSettingsChange(settings.backlightSettings);
theme="light"
text="Reset Config"
onClick={() => {
handleResetConfig();
window.location.reload();
}}
/>
</SettingsItem>
</NestedSettingsGroup>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
</div>
<FeatureFlag minAppVersion="0.4.9">
<div className="space-y-4">
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="Power Saving"
description="Reduce power consumption when not in use"
/>
<SettingsItem
badge="Experimental"
title="HDMI Sleep Mode"
description="Turn off capture after 90 seconds of inactivity"
>
<Checkbox
checked={powerSavingEnabled}
onChange={(e) => handlePowerSavingChange(e.target.checked)}
/>
</SettingsItem>
</div>
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting />
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8">
<UsbInfoSetting />
</FeatureFlag>
<ConfirmDialog
open={showLoopbackWarning}
onClose={() => {
setShowLoopbackWarning(false);
}}
title="Enable Loopback-Only Mode?"
description={
<>
<p>
WARNING: This will restrict web interface access to localhost (127.0.0.1)
only.
</p>
<p>Before enabling this feature, make sure you have either:</p>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>SSH access configured and tested</li>
<li>Cloud access enabled and working</li>
</ul>
</>
}
variant="warning"
confirmText="I Understand, Enable Anyway"
onConfirm={confirmLoopbackModeEnable}
/>
</div>
);
}
}

View File

@ -1,3 +1,5 @@
import { KeySequence } from "@hooks/stores";
export const formatters = {
date: (date: Date, options?: Intl.DateTimeFormatOptions) =>
new Intl.DateTimeFormat("en-US", {
@ -37,6 +39,7 @@ export const formatters = {
...(options || {}),
});
// Note, do not translate the unit names in DIVISIONS, as they must match Intl.RelativeTimeFormatUnit
const DIVISIONS: {
amount: number;
name: Intl.RelativeTimeFormatUnit;
@ -243,3 +246,22 @@ export function isChromeOS() {
/* ChromeOS sets navigator.platform to Linux :/ */
return !!navigator.userAgent.match(" CrOS ");
}
export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] {
return macros.map((macro, index) => ({
...macro,
sortOrder: index + 1,
}));
}
export function deleteCookie(name: string, domain?: string, path = "/") {
const domainPart = domain ? `; domain=${domain}` : "";
// max-age=0 removes the cookie immediately in modern browsers
document.cookie = `${name}=; path=${path}; max-age=0${domainPart}`;
// fallback: set an expires in the past for older agents
document.cookie = `${name}=; path=${path}; expires=Thu, 01 Jan 1970 00:00:00 GMT${domainPart}`;
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}

View File

@ -22,6 +22,7 @@
/* Import Aliases */
"paths": {
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@routes/*": ["./src/routes/*"],
"@assets/*": ["./src/assets/*"],
"@/*": ["./src/*"]