diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh index 079c8cdc..e2ff43e6 100755 --- a/.devcontainer/install-deps.sh +++ b/.devcontainer/install-deps.sh @@ -14,6 +14,7 @@ set -ex export DEBIAN_FRONTEND=noninteractive sudo apt-get update && \ sudo apt-get install -y --no-install-recommends \ + iputils-ping \ build-essential \ device-tree-compiler \ gperf g++-multilib gcc-multilib \ @@ -33,4 +34,4 @@ wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSIO sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \ rm buildkit.tar.zst popd -rm -rf "${BUILDKIT_TMPDIR}" \ No newline at end of file +rm -rf "${BUILDKIT_TMPDIR}" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f45c03bc..5da3229c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -37,7 +37,7 @@ jobs: restore-keys: | jetkvm-cgo-${{ hashFiles('internal/native/cgo/**/*.c', 'internal/native/cgo/**/*.h', 'internal/native/cgo/**/*.patch', 'internal/native/cgo/**/*.txt', 'internal/native/cgo/**/*.sh', '!internal/native/cgo/build/**') }} - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "22" cache: "npm" @@ -63,7 +63,7 @@ jobs: with: input: "testreport.json" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: jetkvm-app path: | diff --git a/.github/workflows/smoketest.yml b/.github/workflows/smoketest.yml index f328c613..e49914a9 100644 --- a/.github/workflows/smoketest.yml +++ b/.github/workflows/smoketest.yml @@ -165,7 +165,7 @@ jobs: env: CI_HOST: ${{ vars.JETKVM_CI_HOST }} - name: Upload logs - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: device-logs path: | diff --git a/.github/workflows/ui-lint.yml b/.github/workflows/ui-lint.yml index 97d6af8c..dc0da33d 100644 --- a/.github/workflows/ui-lint.yml +++ b/.github/workflows/ui-lint.yml @@ -19,7 +19,7 @@ jobs: - name: Checkout repository uses: actions/checkout@v5 - name: Set up Node.js - uses: actions/setup-node@v5 + uses: actions/setup-node@v6 with: node-version: "22" cache: "npm" diff --git a/config.go b/config.go index 3d74145b..7dc3db30 100644 --- a/config.go +++ b/config.go @@ -190,7 +190,8 @@ func getDefaultConfig() Config { _ = confparser.SetDefaultsAndValidate(c) return c }(), - DefaultLogLevel: "INFO", + DefaultLogLevel: "INFO", + VideoQualityFactor: 1.0, } } diff --git a/display.go b/display.go index 042bf122..68723b59 100644 --- a/display.go +++ b/display.go @@ -305,11 +305,11 @@ func wakeDisplay(force bool, reason string) { displayLogger.Warn().Err(err).Msg("failed to wake display") } - if config.DisplayDimAfterSec != 0 { + if config.DisplayDimAfterSec != 0 && dimTicker != nil { dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second) } - if config.DisplayOffAfterSec != 0 { + if config.DisplayOffAfterSec != 0 && offTicker != nil { offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second) } backlightState = 0 diff --git a/go.mod b/go.mod index e4ada2c0..404215b0 100644 --- a/go.mod +++ b/go.mod @@ -4,26 +4,27 @@ go 1.24.4 require ( github.com/Masterminds/semver/v3 v3.4.0 - github.com/beevik/ntp v1.4.3 + github.com/beevik/ntp v1.5.0 github.com/coder/websocket v1.8.14 - github.com/coreos/go-oidc/v3 v3.15.0 + github.com/coreos/go-oidc/v3 v3.16.0 github.com/creack/pty v1.1.24 github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377 github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 github.com/gin-gonic/gin v1.10.1 - github.com/go-co-op/gocron/v2 v2.16.6 + github.com/go-co-op/gocron/v2 v2.17.0 github.com/google/uuid v1.6.0 github.com/guregu/null/v6 v6.0.0 github.com/gwatts/rootcerts v0.0.0-20250901182336-dc5ae18bd79f github.com/insomniacslk/dhcp v0.0.0-20250919081422-f80a1952f48e + github.com/mdlayher/ndp v1.1.0 github.com/pion/logging v0.2.4 github.com/pion/mdns/v2 v2.0.7 - github.com/pion/webrtc/v4 v4.1.4 + github.com/pion/webrtc/v4 v4.1.6 github.com/pojntfx/go-nbd v0.3.2 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.66.1 - github.com/prometheus/procfs v0.17.0 + github.com/prometheus/common v0.67.2 + github.com/prometheus/procfs v0.19.2 github.com/psanford/httpreadat v0.1.0 github.com/rs/xid v1.6.0 github.com/rs/zerolog v1.34.0 @@ -32,9 +33,9 @@ require ( github.com/vearutop/statigz v1.5.0 github.com/vishvananda/netlink v1.3.1 go.bug.st/serial v1.6.4 - golang.org/x/crypto v0.42.0 - golang.org/x/net v0.44.0 - golang.org/x/sys v0.36.0 + golang.org/x/crypto v0.43.0 + golang.org/x/net v0.46.0 + golang.org/x/sys v0.37.0 ) replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b @@ -49,7 +50,7 @@ require ( 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.3 // 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 @@ -61,7 +62,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mdlayher/ndp v1.1.0 // indirect github.com/mdlayher/packet v1.1.2 // indirect github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -73,15 +73,15 @@ require ( github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.7 // indirect github.com/pion/ice/v4 v4.0.10 // indirect - github.com/pion/interceptor v0.1.40 // indirect + github.com/pion/interceptor v0.1.41 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/rtcp v1.2.15 // indirect - github.com/pion/rtp v1.8.22 // indirect - github.com/pion/sctp v1.8.39 // indirect + github.com/pion/rtp v1.8.23 // indirect + github.com/pion/sctp v1.8.40 // indirect github.com/pion/sdp/v3 v3.0.16 // indirect - github.com/pion/srtp/v3 v3.0.7 // indirect + github.com/pion/srtp/v3 v3.0.8 // 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.8 // indirect github.com/pion/turn/v4 v4.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -92,11 +92,11 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.29.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/text v0.30.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9df5e759..1cb90138 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= -github.com/beevik/ntp v1.4.3 h1:PlbTvE5NNy4QHmA4Mg57n7mcFTmr1W1j3gcK7L1lqho= -github.com/beevik/ntp v1.4.3/go.mod h1:Unr8Zg+2dRn7d8bHFuehIMSvvUYssHMxW3Q5Nx4RW5Q= +github.com/beevik/ntp v1.5.0 h1:y+uj/JjNwlY2JahivxYvtmv4ehfi3h74fAuABB9ZSM4= +github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= 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/bool64/dev v0.2.39 h1:kP8DnMGlWXhGYJEZE/J0l/gVBdbuhoPGL+MJG4QbofE= @@ -20,8 +20,8 @@ github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/coreos/go-oidc/v3 v3.15.0 h1:R6Oz8Z4bqWR7VFQ+sPSvZPQv4x8M+sJkDO5ojgwlyAg= -github.com/coreos/go-oidc/v3 v3.15.0/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= +github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= +github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= @@ -42,10 +42,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= 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-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4= +github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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= @@ -124,8 +124,8 @@ github.com/pion/dtls/v3 v3.0.7 h1:bItXtTYYhZwkPFk4t1n3Kkf5TDrfj6+4wG+CZR8uI9Q= github.com/pion/dtls/v3 v3.0.7/go.mod h1:uDlH5VPrgOQIw59irKYkMudSFprY9IEFCqz/eTz16f8= github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4= github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw= -github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4= -github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic= +github.com/pion/interceptor v0.1.41 h1:NpvX3HgWIukTf2yTBVjVGFXtpSpWgXjqz7IIpu7NsOw= +github.com/pion/interceptor v0.1.41/go.mod h1:nEt4187unvRXJFyjiw00GKo+kIuXMWQI9K89fsosDLY= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= @@ -134,22 +134,22 @@ 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/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo= github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0= -github.com/pion/rtp v1.8.22 h1:8NCVDDF+uSJmMUkjLJVnIr/HX7gPesyMV1xFt5xozXc= -github.com/pion/rtp v1.8.22/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= -github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE= -github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE= +github.com/pion/rtp v1.8.23 h1:kxX3bN4nM97DPrVBGq5I/Xcl332HnTHeP1Swx3/MCnU= +github.com/pion/rtp v1.8.23/go.mod h1:rF5nS1GqbR7H/TCpKwylzeq6yDM+MM6k+On5EgeThEM= +github.com/pion/sctp v1.8.40 h1:bqbgWYOrUhsYItEnRObUYZuzvOMsVplS3oNgzedBlG8= +github.com/pion/sctp v1.8.40/go.mod h1:SPBBUENXE6ThkEksN5ZavfAhFYll+h+66ZiG6IZQuzo= github.com/pion/sdp/v3 v3.0.16 h1:0dKzYO6gTAvuLaAKQkC02eCPjMIi4NuAr/ibAwrGDCo= github.com/pion/sdp/v3 v3.0.16/go.mod h1:9tyKzznud3qiweZcD86kS0ff1pGYB3VX+Bcsmkx6IXo= -github.com/pion/srtp/v3 v3.0.7 h1:QUElw0A/FUg3MP8/KNMZB3i0m8F9XeMnTum86F7S4bs= -github.com/pion/srtp/v3 v3.0.7/go.mod h1:qvnHeqbhT7kDdB+OGB05KA/P067G3mm7XBfLaLiaNF0= +github.com/pion/srtp/v3 v3.0.8 h1:RjRrjcIeQsilPzxvdaElN0CpuQZdMvcl9VZ5UY9suUM= +github.com/pion/srtp/v3 v3.0.8/go.mod h1:2Sq6YnDH7/UDCvkSoHSDNDeyBcFgWL0sAVycVbAsXFg= 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/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.8 h1:oI3myyYnTKUSTthu/NZZ8eu2I5sHbxbUNNFW62olaYc= +github.com/pion/transport/v3 v3.0.8/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/turn/v4 v4.1.1 h1:9UnY2HB99tpDyz3cVVZguSxcqkJ1DsTSZ+8TGruh4fc= github.com/pion/turn/v4 v4.1.1/go.mod h1:2123tHk1O++vmjI5VSD0awT50NywDAq5A2NNNU4Jjs8= -github.com/pion/webrtc/v4 v4.1.4 h1:/gK1ACGHXQmtyVVbJFQDxNoODg4eSRiFLB7t9r9pg8M= -github.com/pion/webrtc/v4 v4.1.4/go.mod h1:Oab9npu1iZtQRMic3K3toYq5zFPvToe/QBw7dMI2ok4= +github.com/pion/webrtc/v4 v4.1.6 h1:srHH2HwvCGwPba25EYJgUzgLqCQoXl1VCUnrGQMSzUw= +github.com/pion/webrtc/v4 v4.1.6/go.mod h1:wKecGRlkl3ox/As/MYghJL+b/cVXMEhoPMJWPuGQFhU= 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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -157,10 +157,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -202,16 +202,16 @@ go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= 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= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -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/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= +golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -220,14 +220,14 @@ 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.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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/native/cgo/ctrl.c b/internal/native/cgo/ctrl.c index 0c10ee15..547d5694 100644 --- a/internal/native/cgo/ctrl.c +++ b/internal/native/cgo/ctrl.c @@ -306,7 +306,7 @@ int jetkvm_ui_add_flag(const char *obj_name, const char *flag_name) { if (obj == NULL) { return -1; } - + lv_obj_flag_t flag_val = str_to_lv_obj_flag(flag_name); if (flag_val == 0) { @@ -368,7 +368,7 @@ void jetkvm_video_stop() { } int jetkvm_video_set_quality_factor(float quality_factor) { - if (quality_factor < 0 || quality_factor > 1) { + if (quality_factor <= 0 || quality_factor > 1) { return -1; } video_set_quality_factor(quality_factor); @@ -417,4 +417,4 @@ void jetkvm_crash() { // let's call a function that will crash the program int* p = 0; *p = 0; -} \ No newline at end of file +} diff --git a/internal/native/cgo/video.c b/internal/native/cgo/video.c index 917e9163..857acbbb 100644 --- a/internal/native/cgo/video.c +++ b/internal/native/cgo/video.c @@ -235,7 +235,7 @@ int video_init(float factor) { detect_sleep_mode(); - if (factor < 0 || factor > 1) { + if (factor <= 0 || factor > 1) { factor = 1.0f; } quality_factor = factor; diff --git a/internal/native/native.go b/internal/native/native.go index 2a9055ce..3b1cc0b4 100644 --- a/internal/native/native.go +++ b/internal/native/native.go @@ -69,7 +69,7 @@ func NewNative(opts NativeOptions) *Native { sleepModeSupported := isSleepModeSupported() defaultQualityFactor := opts.DefaultQualityFactor - if defaultQualityFactor < 0 || defaultQualityFactor > 1 { + if defaultQualityFactor <= 0 || defaultQualityFactor > 1 { defaultQualityFactor = 1.0 } diff --git a/jsonrpc.go b/jsonrpc.go index 8857be75..7e2540ce 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -177,10 +177,8 @@ func rpcReboot(force bool) error { return hwReboot(force, nil, 0) } -var streamFactor = 1.0 - func rpcGetStreamQualityFactor() (float64, error) { - return streamFactor, nil + return config.VideoQualityFactor, nil } func rpcSetStreamQualityFactor(factor float64) error { @@ -190,7 +188,10 @@ func rpcSetStreamQualityFactor(factor float64) error { return err } - streamFactor = factor + config.VideoQualityFactor = factor + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } return nil } diff --git a/main.go b/main.go index 43784614..8e989919 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( var appCtx context.Context func Main() { + logger.Log().Msg("JetKVM Starting Up") LoadConfig() var cancel context.CancelFunc @@ -81,16 +82,16 @@ func Main() { startVideoSleepModeTicker() go func() { + // wait for 15 minutes before starting auto-update checks + // this is to avoid interfering with initial setup processes + // and to ensure the system is stable before checking for updates time.Sleep(15 * time.Minute) - for { - logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING") - if !config.AutoUpdateEnabled { - return - } - if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() { - logger.Debug().Msg("system time is not synced, will retry in 30 seconds") - time.Sleep(30 * time.Second) + for { + logger.Info().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("auto-update check") + if !config.AutoUpdateEnabled { + logger.Debug().Msg("auto-update disabled") + time.Sleep(5 * time.Minute) // we'll check if auto-updates are enabled in five minutes continue } @@ -100,6 +101,12 @@ func Main() { continue } + if isTimeSyncNeeded() || !timeSync.IsSyncSuccess() { + logger.Debug().Msg("system time is not synced, will retry in 30 seconds") + time.Sleep(30 * time.Second) + continue + } + includePreRelease := config.IncludePreRelease err = otaState.TryUpdate(context.Background(), ota.UpdateParams{ DeviceID: GetDeviceID(), @@ -112,6 +119,7 @@ func Main() { time.Sleep(1 * time.Hour) } }() + //go RunFuseServer() go RunWebServer() @@ -128,7 +136,8 @@ func Main() { sigs := make(chan os.Signal, 1) signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) <-sigs - logger.Info().Msg("JetKVM Shutting Down") + + logger.Log().Msg("JetKVM Shutting Down") //if fuseServer != nil { // err := setMassStorageImage(" ") // if err != nil { diff --git a/network.go b/network.go index af3ef862..25e562a0 100644 --- a/network.go +++ b/network.go @@ -28,6 +28,11 @@ func (s *RpcNetworkSettings) ToNetworkConfig() *types.NetworkConfig { return &s.NetworkConfig } +type PostRebootAction struct { + HealthCheck string `json:"healthCheck"` + RedirectTo string `json:"redirectTo"` +} + func toRpcNetworkSettings(config *types.NetworkConfig) *RpcNetworkSettings { return &RpcNetworkSettings{ NetworkConfig: *config, @@ -198,7 +203,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re if newIPv4Mode == "static" && oldIPv4Mode != "static" { postRebootAction = &ota.PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), - RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 mode changed to static, reboot required") } @@ -215,7 +220,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { postRebootAction = &ota.PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), - RedirectUrl: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), + RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } l.Info().Interface("postRebootAction", postRebootAction).Msg("IPv4 static config changed, reboot required") diff --git a/pkg/nmlite/jetdhcpc/client.go b/pkg/nmlite/jetdhcpc/client.go index 155ea249..102d3bee 100644 --- a/pkg/nmlite/jetdhcpc/client.go +++ b/pkg/nmlite/jetdhcpc/client.go @@ -111,6 +111,7 @@ type Client struct { var ( defaultTimerDuration = 1 * time.Second defaultLinkUpTimeout = 30 * time.Second + defaultDHCPTimeout = 5 * time.Second // DHCP request timeout (not link up timeout) maxRenewalAttemptDuration = 2 * time.Hour ) @@ -125,11 +126,11 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge } if cfg.Timeout == 0 { - cfg.Timeout = defaultLinkUpTimeout + cfg.Timeout = defaultDHCPTimeout } if cfg.Retries == 0 { - cfg.Retries = 3 + cfg.Retries = 4 } return &Client{ @@ -153,9 +154,15 @@ func NewClient(ctx context.Context, ifaces []string, c *Config, l *zerolog.Logge }, nil } -func resetTimer(t *time.Timer, l *zerolog.Logger) { - l.Debug().Dur("delay", defaultTimerDuration).Msg("will retry later") - t.Reset(defaultTimerDuration) +func resetTimer(t *time.Timer, attempt int, l *zerolog.Logger) { + // Exponential backoff: 1s, 2s, 4s, 8s, max 8s + backoffAttempt := attempt + if backoffAttempt > 3 { + backoffAttempt = 3 + } + delay := time.Duration(1<=6.9.0" @@ -790,22 +790,22 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/compat": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.0.tgz", - "integrity": "sha512-DEzm5dKeDBPm3r08Ixli/0cmxr8LkRdwxMRUIJBlSCpAwSrvFEJpVBzV+66JhDxiaqKxnRzCXhtiMiczF7Hglg==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@eslint/compat/-/compat-1.4.1.tgz", + "integrity": "sha512-cfO82V9zxxGBxcQDr1lfaYB7wykTa0b00mGa36FrJl7iTFd0Z2cHfEYuxcBRP/iNijCsWsEkA+jzT8hGYmv33w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.16.0" + "@eslint/core": "^0.17.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -819,6 +819,19 @@ } } }, + "node_modules/@eslint/compat/node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@eslint/config-array": { "version": "0.21.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", @@ -893,9 +906,10 @@ } }, "node_modules/@eslint/js": { - "version": "9.38.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", - "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "version": "9.39.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.0.tgz", + "integrity": "sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1374,9 +1388,9 @@ } }, "node_modules/@reduxjs/toolkit": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.1.tgz", - "integrity": "sha512-sETJ3qO72y7L7WiR5K54UFLT3jRzAtqeBPVO15xC3bGA6kDqCH8m/v7BKCPH4czydXzz/1lPEGLvew7GjOO3Qw==", + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.2.tgz", + "integrity": "sha512-ZAYu/NXkl/OhqTz7rfPaAhY0+e8Fr15jqNxte/2exKUxvHyQ/hcqmdekiN1f+Lcw3pE+34FCgX+26zcUE3duCg==", "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -1400,9 +1414,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.35", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", - "integrity": "sha512-slYrCpoxJUqzFDDNlvrOYRazQUNRvWPjXA17dAOISY3rDMxX6k8K4cj2H+hEYMHF81HO3uNd5rHVigAWRM5dSg==", + "version": "1.0.0-beta.43", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz", + "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==", "dev": true, "license": "MIT" }, @@ -1728,15 +1742,15 @@ "license": "MIT" }, "node_modules/@swc/core": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", - "integrity": "sha512-WezcBo8a0Dg2rnR82zhwoR6aRNxeTGfK5QCD6TQ+kg3xx/zNT02s/0o+81h/3zhvFSB24NtqEr8FTw88O5W/JQ==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.21.tgz", + "integrity": "sha512-umBaSb65O1v6Lt8RV3o5srw0nKr25amf/yRIGFPug63sAerL9n2UkmfGywA1l1aN81W7faXIynF0JmlQ2wPSdw==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.24" + "@swc/types": "^0.1.25" }, "engines": { "node": ">=10" @@ -1746,16 +1760,16 @@ "url": "https://opencollective.com/swc" }, "optionalDependencies": { - "@swc/core-darwin-arm64": "1.13.5", - "@swc/core-darwin-x64": "1.13.5", - "@swc/core-linux-arm-gnueabihf": "1.13.5", - "@swc/core-linux-arm64-gnu": "1.13.5", - "@swc/core-linux-arm64-musl": "1.13.5", - "@swc/core-linux-x64-gnu": "1.13.5", - "@swc/core-linux-x64-musl": "1.13.5", - "@swc/core-win32-arm64-msvc": "1.13.5", - "@swc/core-win32-ia32-msvc": "1.13.5", - "@swc/core-win32-x64-msvc": "1.13.5" + "@swc/core-darwin-arm64": "1.13.21", + "@swc/core-darwin-x64": "1.13.21", + "@swc/core-linux-arm-gnueabihf": "1.13.21", + "@swc/core-linux-arm64-gnu": "1.13.21", + "@swc/core-linux-arm64-musl": "1.13.21", + "@swc/core-linux-x64-gnu": "1.13.21", + "@swc/core-linux-x64-musl": "1.13.21", + "@swc/core-win32-arm64-msvc": "1.13.21", + "@swc/core-win32-ia32-msvc": "1.13.21", + "@swc/core-win32-x64-msvc": "1.13.21" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" @@ -1767,9 +1781,9 @@ } }, "node_modules/@swc/core-darwin-arm64": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.5.tgz", - "integrity": "sha512-lKNv7SujeXvKn16gvQqUQI5DdyY8v7xcoO3k06/FJbHJS90zEwZdQiMNRiqpYw/orU543tPaWgz7cIYWhbopiQ==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.21.tgz", + "integrity": "sha512-0jaz9r7f0PDK8OyyVooadv8dkFlQmVmBK6DtAnWSRjkCbNt4sdqsc9ZkyEDJXaxOVcMQ3pJx/Igniyw5xqACLw==", "cpu": [ "arm64" ], @@ -1784,9 +1798,9 @@ } }, "node_modules/@swc/core-darwin-x64": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.5.tgz", - "integrity": "sha512-ILd38Fg/w23vHb0yVjlWvQBoE37ZJTdlLHa8LRCFDdX4WKfnVBiblsCU9ar4QTMNdeTBEX9iUF4IrbNWhaF1Ng==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.21.tgz", + "integrity": "sha512-pLeZn+NTGa7oW/ysD6oM82BjKZl71WNJR9BKXRsOhrNQeUWv55DCoZT2P4DzeU5Xgjmos+iMoDLg/9R6Ngc0PA==", "cpu": [ "x64" ], @@ -1801,9 +1815,9 @@ } }, "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.5.tgz", - "integrity": "sha512-Q6eS3Pt8GLkXxqz9TAw+AUk9HpVJt8Uzm54MvPsqp2yuGmY0/sNaPPNVqctCX9fu/Nu8eaWUen0si6iEiCsazQ==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.21.tgz", + "integrity": "sha512-p9aYzTmP7qVDPkXxnbekOfbT11kxnPiuLrUbgpN/vn6sxXDCObMAiY63WlDR0IauBK571WUdmgb04goe/xTQWw==", "cpu": [ "arm" ], @@ -1818,9 +1832,9 @@ } }, "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.5.tgz", - "integrity": "sha512-aNDfeN+9af+y+M2MYfxCzCy/VDq7Z5YIbMqRI739o8Ganz6ST+27kjQFd8Y/57JN/hcnUEa9xqdS3XY7WaVtSw==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.21.tgz", + "integrity": "sha512-yRqFoGlCwEX1nS7OajBE23d0LPeONmFAgoe4rgRYvaUb60qGxIJoMMdvF2g3dum9ZyVDYAb3kP09hbXFbMGr4A==", "cpu": [ "arm64" ], @@ -1835,9 +1849,9 @@ } }, "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.5.tgz", - "integrity": "sha512-9+ZxFN5GJag4CnYnq6apKTnnezpfJhCumyz0504/JbHLo+Ue+ZtJnf3RhyA9W9TINtLE0bC4hKpWi8ZKoETyOQ==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.21.tgz", + "integrity": "sha512-wu5EGA86gtdYMW69eU80jROzArzD3/6G6zzK0VVR+OFt/0zqbajiiszIpaniOVACObLfJEcShQ05B3q0+CpUEg==", "cpu": [ "arm64" ], @@ -1852,9 +1866,9 @@ } }, "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.5.tgz", - "integrity": "sha512-WD530qvHrki8Ywt/PloKUjaRKgstQqNGvmZl54g06kA+hqtSE2FTG9gngXr3UJxYu/cNAjJYiBifm7+w4nbHbA==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.21.tgz", + "integrity": "sha512-AoGGVPNXH3C4S7WlJOxN1nGW5nj//J9uKysS7CIBotRmHXfHO4wPK3TVFRTA4cuouAWBBn7O8m3A99p/GR+iaw==", "cpu": [ "x64" ], @@ -1869,9 +1883,9 @@ } }, "node_modules/@swc/core-linux-x64-musl": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.5.tgz", - "integrity": "sha512-Luj8y4OFYx4DHNQTWjdIuKTq2f5k6uSXICqx+FSabnXptaOBAbJHNbHT/06JZh6NRUouaf0mYXN0mcsqvkhd7Q==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.21.tgz", + "integrity": "sha512-cBy2amuDuxMZnEq16MqGu+DUlEFqI+7F/OACNlk7zEJKq48jJKGEMqJz3X2ucJE5jqUIg6Pos6Uo/y+vuWQymQ==", "cpu": [ "x64" ], @@ -1886,9 +1900,9 @@ } }, "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.5.tgz", - "integrity": "sha512-cZ6UpumhF9SDJvv4DA2fo9WIzlNFuKSkZpZmPG1c+4PFSEMy5DFOjBSllCvnqihCabzXzpn6ykCwBmHpy31vQw==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.21.tgz", + "integrity": "sha512-2xfR5gnqBGOMOlY3s1QiFTXZaivTILMwX67FD2uzT6OCbT/3lyAM/4+3BptBXD8pUkkOGMFLsdeHw4fbO1GrpQ==", "cpu": [ "arm64" ], @@ -1903,9 +1917,9 @@ } }, "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.5.tgz", - "integrity": "sha512-C5Yi/xIikrFUzZcyGj9L3RpKljFvKiDMtyDzPKzlsDrKIw2EYY+bF88gB6oGY5RGmv4DAX8dbnpRAqgFD0FMEw==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.21.tgz", + "integrity": "sha512-0pkpgKlBDwUImWTQxLakKbzZI6TIGVVAxk658oxrY8VK+hxRy2iezFY6m5Urmeds47M/cnW3dO+OY4C2caOF8A==", "cpu": [ "ia32" ], @@ -1920,9 +1934,9 @@ } }, "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.5.tgz", - "integrity": "sha512-YrKdMVxbYmlfybCSbRtrilc6UA8GF5aPmGKBdPvjrarvsmf4i7ZHGCEnLtfOMd3Lwbs2WUZq3WdMbozYeLU93Q==", + "version": "1.13.21", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.21.tgz", + "integrity": "sha512-DAnIw2J95TOW4Kr7NBx12vlZPW3QndbpFMmuC7x+fPoozoLpEscaDkiYhk7/sTtY9pubPMfHFPBORlbqyQCfOQ==", "cpu": [ "x64" ], @@ -1976,49 +1990,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz", - "integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", - "jiti": "^2.6.0", + "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.19", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.15" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz", - "integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", "dev": true, "license": "MIT", "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-arm64": "4.1.15", - "@tailwindcss/oxide-darwin-x64": "4.1.15", - "@tailwindcss/oxide-freebsd-x64": "4.1.15", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.15", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.15", - "@tailwindcss/oxide-linux-x64-musl": "4.1.15", - "@tailwindcss/oxide-wasm32-wasi": "4.1.15", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.15", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.15" + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz", - "integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", "cpu": [ "arm64" ], @@ -2033,9 +2047,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz", - "integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", "cpu": [ "arm64" ], @@ -2050,9 +2064,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz", - "integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", "cpu": [ "x64" ], @@ -2067,9 +2081,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz", - "integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", "cpu": [ "x64" ], @@ -2084,9 +2098,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz", - "integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", "cpu": [ "arm" ], @@ -2101,9 +2115,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz", - "integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", "cpu": [ "arm64" ], @@ -2118,9 +2132,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz", - "integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", "cpu": [ "arm64" ], @@ -2135,9 +2149,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz", - "integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", "cpu": [ "x64" ], @@ -2152,9 +2166,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz", - "integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", "cpu": [ "x64" ], @@ -2169,9 +2183,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz", - "integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2198,10 +2212,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.5.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.0.7", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@tybys/wasm-util": "^0.10.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz", - "integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", "cpu": [ "arm64" ], @@ -2216,9 +2290,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz", - "integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", "cpu": [ "x64" ], @@ -2233,17 +2307,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz", - "integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz", + "integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.15", - "@tailwindcss/oxide": "4.1.15", + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", "postcss": "^8.4.41", - "tailwindcss": "4.1.15" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/typography": { @@ -2260,15 +2334,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.15.tgz", - "integrity": "sha512-B6s60MZRTUil+xKoZoGe6i0Iar5VuW+pmcGlda2FX+guDuQ1G1sjiIy1W0frneVpeL/ZjZ4KEgWZHNrIm++2qA==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz", + "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.15", - "@tailwindcss/oxide": "4.1.15", - "tailwindcss": "4.1.15" + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "tailwindcss": "4.1.16" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" @@ -2704,13 +2778,13 @@ } }, "node_modules/@vitejs/plugin-react-swc": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", - "integrity": "sha512-Ff690TUck0Anlh7wdIcnsVMhofeEVgm44Y4OYdeeEEPSKyZHzDI9gfVBvySEhDfXtBp8tLCbfsVKPWEMEjq8/g==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.2.0.tgz", + "integrity": "sha512-/tesahXD1qpkGC6FzMoFOJj0RyZdw9xLELOL+6jbElwmWfwOnIVy+IfpY+o9JfD9PKaR/Eyb6DNrvbXpuvA+8Q==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "1.0.0-beta.35", + "@rolldown/pluginutils": "1.0.0-beta.43", "@swc/core": "^1.13.5" }, "engines": { @@ -3062,9 +3136,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", - "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", + "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -3095,9 +3169,9 @@ } }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, "funding": [ { @@ -3115,11 +3189,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -3658,9 +3732,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.237", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", - "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "version": "1.5.240", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.240.tgz", + "integrity": "sha512-OBwbZjWgrCOH+g6uJsA2/7Twpas2OlepS9uvByJjR2datRDuKGYeD+nP8lBBks2qnB7bGJNHDUx7c/YLaT3QMQ==", "dev": true, "license": "ISC" }, @@ -3849,9 +3923,9 @@ } }, "node_modules/es-toolkit": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.40.0.tgz", - "integrity": "sha512-8o6w0KFmU0CiIl0/Q/BCEOabF2IJaELM1T2PWj6e8KqzHv1gdx+7JtFnDwOx1kJH/isJ5NwlDG1nCr1HrRF94Q==", + "version": "1.41.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.41.0.tgz", + "integrity": "sha512-bDd3oRmbVgqZCJS6WmeQieOrzpl3URcWBUVDXxOELlUW2FuW+0glPOz1n0KnRie+PdyvUZcXz2sOn00c6pPRIA==", "license": "MIT", "workspaces": [ "docs", @@ -4183,17 +4257,17 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz", - "integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", - "zod": "^3.22.4 || ^4.0.0", - "zod-validation-error": "^3.0.3 || ^4.0.0" + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" }, "engines": { "node": ">=18" @@ -4268,6 +4342,18 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/eslint-visitor-keys": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", @@ -5818,9 +5904,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.19", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", - "integrity": "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5936,9 +6022,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.25", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", - "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -6519,9 +6605,9 @@ } }, "node_modules/react-router": { - "version": "7.9.4", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", - "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", + "version": "7.9.5", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.5.tgz", + "integrity": "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -6541,9 +6627,9 @@ } }, "node_modules/react-simple-keyboard": { - "version": "3.8.130", - "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.130.tgz", - "integrity": "sha512-sq51zg3fe4NPCRyDLYyAtot8+pIn9DmC+YqAEqx5FOIpHUC86Qvv2/0F2KvhLNDvgZ+5s4w649YKf1gWK8LiIQ==", + "version": "3.8.131", + "resolved": "https://registry.npmjs.org/react-simple-keyboard/-/react-simple-keyboard-3.8.131.tgz", + "integrity": "sha512-gICYtaV38AU/E1PTTwzJOF6s5fu6Nu3GZQwnaSNB4VGOO3UwOn8rioDEFBLvjMWpP8kwfWp2of8xywY647rTxA==", "license": "MIT", "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", @@ -7167,9 +7253,9 @@ } }, "node_modules/tabbable": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", - "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.3.0.tgz", + "integrity": "sha512-EIHvdY5bPLuWForiR/AN2Bxngzpuwn1is4asboytXtpTgsArc+WmSJKVLlhdh71u7jFcryDqB2A8lQvj78MkyQ==", "license": "MIT" }, "node_modules/tailwind-merge": { @@ -7183,9 +7269,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.15", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz", - "integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "license": "MIT" }, "node_modules/tapable": { @@ -7478,9 +7564,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "dev": true, "funding": [ { @@ -7601,9 +7687,9 @@ } }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/ui/package.json b/ui/package.json index a33acf6c..6c1016d6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,7 +1,7 @@ { "name": "kvm-ui", "private": true, - "version": "2025.10.20.1400", + "version": "2025.10.24.2140", "type": "module", "engines": { "node": "^22.20.0" @@ -20,7 +20,7 @@ "i18n:resort": "python3 tools/resort_messages.py", "i18n:validate": "inlang validate --project ./localization/jetKVM.UI.inlang", "i18n:compile": "paraglide-js compile --project ./localization/jetKVM.UI.inlang --outdir ./localization/paraglide", - "i18n:machine-translate": "inlang machine translate --project ./localization/jetKVM.UI.inlang", + "i18n:machine-translate": "inlang machine translate --project ./localization/jetKVM.UI.inlang && npm run i18n:resort", "i18n:audit": "npm run i18n:find-dupes && npm run i18n:find-excess && npm run i18n:find-unused", "i18n:find-excess": "python3 ./tools/find_excess_messages.py", "i18n:find-unused": "python3 ./tools/find_unused_messages.py", @@ -50,8 +50,8 @@ "react-hook-form": "^7.65.0", "react-hot-toast": "^2.6.0", "react-icons": "^5.5.0", - "react-router": "^7.9.4", - "react-simple-keyboard": "^3.8.130", + "react-router": "^7.9.5", + "react-simple-keyboard": "^3.8.131", "react-use-websocket": "^4.13.0", "react-xtermjs": "^1.0.10", "recharts": "^3.3.0", @@ -61,40 +61,40 @@ "zustand": "^4.5.2" }, "devDependencies": { - "@eslint/compat": "^1.4.0", + "@eslint/compat": "^1.4.1", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "^9.38.0", + "@eslint/js": "^9.39.0", "@inlang/cli": "^3.0.12", "@inlang/paraglide-js": "^2.4.0", "@inlang/plugin-m-function-matcher": "^2.1.0", "@inlang/plugin-message-format": "^4.0.0", "@inlang/sdk": "^2.4.9", "@tailwindcss/forms": "^0.5.10", - "@tailwindcss/postcss": "^4.1.15", + "@tailwindcss/postcss": "^4.1.16", "@tailwindcss/typography": "^0.5.19", - "@tailwindcss/vite": "^4.1.15", + "@tailwindcss/vite": "^4.1.16", "@types/react": "^19.2.2", "@types/react-dom": "^19.2.2", "@types/semver": "^7.7.1", "@types/validator": "^13.15.3", "@typescript-eslint/eslint-plugin": "^8.46.2", "@typescript-eslint/parser": "^8.46.2", - "@vitejs/plugin-react-swc": "^4.1.0", + "@vitejs/plugin-react-swc": "^4.2.0", "autoprefixer": "^10.4.21", "eslint": "^9.38.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", "eslint-plugin-prettier": "^5.5.4", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.0", + "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.4.0", "postcss": "^8.5.6", "prettier": "^3.6.2", "prettier-plugin-tailwindcss": "^0.7.1", - "tailwindcss": "^4.1.15", + "tailwindcss": "^4.1.16", "typescript": "^5.9.3", - "vite": "^7.1.11", + "vite": "^7.1.12", "vite-tsconfig-paths": "^5.1.4" } } diff --git a/ui/src/components/Button.tsx b/ui/src/components/Button.tsx index fcb0a614..6ae358f2 100644 --- a/ui/src/components/Button.tsx +++ b/ui/src/components/Button.tsx @@ -212,7 +212,7 @@ export const Button = React.forwardRef( Button.displayName = "Button"; type LinkPropsType = Pick & - React.ComponentProps & { disabled?: boolean }; + React.ComponentProps & { disabled?: boolean, reloadDocument?: boolean }; export const LinkButton = ({ to, ...props }: LinkPropsType) => { const classes = cx( "group outline-hidden", @@ -230,7 +230,7 @@ export const LinkButton = ({ to, ...props }: LinkPropsType) => { ); } else { return ( - + ); diff --git a/ui/src/components/UsbDeviceSetting.tsx b/ui/src/components/UsbDeviceSetting.tsx index 006159cf..2fd6eaeb 100644 --- a/ui/src/components/UsbDeviceSetting.tsx +++ b/ui/src/components/UsbDeviceSetting.tsx @@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; import Fieldset from "@components/Fieldset"; import notifications from "@/notifications"; +import { sleep } from "@/utils"; export interface USBConfig { vendor_id: string; @@ -108,7 +109,7 @@ export function UsbDeviceSetting() { } // We need some time to ensure the USB devices are updated - await new Promise(resolve => setTimeout(resolve, 2000)); + await sleep(2000); setLoading(false); syncUsbDeviceConfig(); notifications.success(m.usb_device_updated()); diff --git a/ui/src/components/UsbInfoSetting.tsx b/ui/src/components/UsbInfoSetting.tsx index dc3aa277..13b185cd 100644 --- a/ui/src/components/UsbInfoSetting.tsx +++ b/ui/src/components/UsbInfoSetting.tsx @@ -9,6 +9,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; const generatedSerialNumber = [generateNumber(1, 9), generateHex(7, 7), 0, 1].join("&"); @@ -123,7 +124,7 @@ export function UsbInfoSetting() { } // We need some time to ensure the USB devices are updated - await new Promise(resolve => setTimeout(resolve, 2000)); + await sleep(2000); setLoading(false); notifications.success( m.usb_config_set_success({ manufacturer: usbConfig.manufacturer, product: usbConfig.product }), diff --git a/ui/src/components/VideoOverlay.tsx b/ui/src/components/VideoOverlay.tsx index e59c0987..e84cfc04 100644 --- a/ui/src/components/VideoOverlay.tsx +++ b/ui/src/components/VideoOverlay.tsx @@ -13,6 +13,7 @@ import { useRTCStore, PostRebootAction } from "@/hooks/stores"; import LogoBlue from "@/assets/logo-blue.svg"; import LogoWhite from "@/assets/logo-white.svg"; import { isOnDevice } from "@/main"; +import { sleep } from "@/utils"; interface OverlayContentProps { @@ -474,8 +475,18 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro if (response.ok) { // Device is available, redirect to the specified URL - console.log('Device is available, redirecting to:', postRebootAction.redirectUrl); - window.location.href = postRebootAction.redirectUrl; + console.log('Device is available, redirecting to:', postRebootAction.redirectTo); + + // URL constructor handles all cases elegantly: + // - Absolute paths: resolved against current origin + // - Protocol-relative URLs: resolved with current protocol + // - Fully qualified URLs: used as-is + const targetUrl = new URL(postRebootAction.redirectTo, window.location.origin); + clearInterval(intervalId); // Stop polling before redirect + + window.location.href = targetUrl.href; + // Add 1s delay between setting location.href and calling reload() to prevent reload from interrupting the navigation. + await sleep(1000); window.location.reload(); } } catch (err) { @@ -529,15 +540,15 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro
-

{hasTimedOut ? "Unable to Reconnect" : "Device is Rebooting"}

+

{hasTimedOut ? m.video_overlay_reboot_unable_to_reconnect() : m.video_overlay_reboot_device_is_rebooting()}

{hasTimedOut ? ( <> - The device may have restarted with a different IP address. Check the JetKVM's physical display to find the current IP address and reconnect. + {m.video_overlay_reboot_different_ip_message()} ) : ( <> - Please wait while the device restarts. This usually takes 20-30 seconds. + {m.video_overlay_reboot_please_wait_message()} )} @@ -550,7 +561,7 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro <>

- Waiting for device to restart... + {m.video_overlay_reboot_waiting_for_restart()}

) : ( @@ -558,7 +569,7 @@ export function RebootingOverlay({ show, postRebootAction }: RebootingOverlayPro

- Automatic Reconnection Timed Out + {m.video_overlay_reboot_timeout_message()}

diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 6dd17d20..9f7fd226 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -21,7 +21,7 @@ interface JsonRpcResponse { export type PostRebootAction = { healthCheck: string; - redirectUrl: string; + redirectTo: string; } | null; // Utility function to append stats to a Map @@ -116,7 +116,7 @@ export interface RTCState { peerConnection: RTCPeerConnection | null; setPeerConnection: (pc: RTCState["peerConnection"]) => void; - setRpcDataChannel: (channel: RTCDataChannel) => void; + setRpcDataChannel: (channel: RTCDataChannel | null) => void; rpcDataChannel: RTCDataChannel | null; hidRpcDisabled: boolean; @@ -178,41 +178,42 @@ export const useRTCStore = create(set => ({ setPeerConnection: (pc: RTCState["peerConnection"]) => set({ peerConnection: pc }), rpcDataChannel: null, - setRpcDataChannel: (channel: RTCDataChannel) => set({ rpcDataChannel: channel }), + setRpcDataChannel: channel => set({ rpcDataChannel: channel }), hidRpcDisabled: false, - setHidRpcDisabled: (disabled: boolean) => set({ hidRpcDisabled: disabled }), + setHidRpcDisabled: disabled => set({ hidRpcDisabled: disabled }), rpcHidProtocolVersion: null, - setRpcHidProtocolVersion: (version: number | null) => set({ rpcHidProtocolVersion: version }), + setRpcHidProtocolVersion: version => set({ rpcHidProtocolVersion: version }), rpcHidChannel: null, - setRpcHidChannel: (channel: RTCDataChannel) => set({ rpcHidChannel: channel }), + setRpcHidChannel: channel => set({ rpcHidChannel: channel }), rpcHidUnreliableChannel: null, - setRpcHidUnreliableChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableChannel: channel }), + setRpcHidUnreliableChannel: channel => set({ rpcHidUnreliableChannel: channel }), rpcHidUnreliableNonOrderedChannel: null, - setRpcHidUnreliableNonOrderedChannel: (channel: RTCDataChannel) => set({ rpcHidUnreliableNonOrderedChannel: channel }), + setRpcHidUnreliableNonOrderedChannel: channel => + set({ rpcHidUnreliableNonOrderedChannel: channel }), transceiver: null, - setTransceiver: (transceiver: RTCRtpTransceiver) => set({ transceiver }), + setTransceiver: transceiver => set({ transceiver }), peerConnectionState: null, - setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }), + setPeerConnectionState: state => set({ peerConnectionState: state }), mediaStream: null, - setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }), + setMediaStream: stream => set({ mediaStream: stream }), videoStreamStats: null, - appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }), + appendVideoStreamStats: stats => set({ videoStreamStats: stats }), videoStreamStatsHistory: new Map(), isTurnServerInUse: false, - setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }), + setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }), inboundRtpStats: new Map(), - appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => { + appendInboundRtpStats: stats => { set(prevState => ({ inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats), })); @@ -220,7 +221,7 @@ export const useRTCStore = create(set => ({ clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }), candidatePairStats: new Map(), - appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => { + appendCandidatePairStats: stats => { set(prevState => ({ candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats), })); @@ -228,21 +229,21 @@ export const useRTCStore = create(set => ({ clearCandidatePairStats: () => set({ candidatePairStats: new Map() }), localCandidateStats: new Map(), - appendLocalCandidateStats: (stats: RTCIceCandidateStats) => { + appendLocalCandidateStats: stats => { set(prevState => ({ localCandidateStats: appendStatToMap(stats, prevState.localCandidateStats), })); }, remoteCandidateStats: new Map(), - appendRemoteCandidateStats: (stats: RTCIceCandidateStats) => { + appendRemoteCandidateStats: stats => { set(prevState => ({ remoteCandidateStats: appendStatToMap(stats, prevState.remoteCandidateStats), })); }, diskDataChannelStats: new Map(), - appendDiskDataChannelStats: (stats: RTCDataChannelStats) => { + appendDiskDataChannelStats: stats => { set(prevState => ({ diskDataChannelStats: appendStatToMap(stats, prevState.diskDataChannelStats), })); @@ -250,7 +251,7 @@ export const useRTCStore = create(set => ({ // Add these new properties to the store implementation terminalChannel: null, - setTerminalChannel: (channel: RTCDataChannel) => set({ terminalChannel: channel }), + setTerminalChannel: channel => set({ terminalChannel: channel }), })); export interface MouseMove { @@ -270,12 +271,20 @@ export interface MouseState { export const useMouseStore = create(set => ({ mouseX: 0, mouseY: 0, - setMouseMove: (move?: MouseMove) => set({ mouseMove: move }), - setMousePosition: (x: number, y: number) => set({ mouseX: x, mouseY: y }), + setMouseMove: move => set({ mouseMove: move }), + setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), })); -export type HdmiStates = "ready" | "no_signal" | "no_lock" | "out_of_range" | "connecting"; -export type HdmiErrorStates = Extract +export type HdmiStates = + | "ready" + | "no_signal" + | "no_lock" + | "out_of_range" + | "connecting"; +export type HdmiErrorStates = Extract< + VideoState["hdmiState"], + "no_signal" | "no_lock" | "out_of_range" +>; export interface HdmiState { ready: boolean; @@ -290,10 +299,7 @@ export interface VideoState { setClientSize: (width: number, height: number) => void; setSize: (width: number, height: number) => void; hdmiState: HdmiStates; - setHdmiState: (state: { - ready: boolean; - error?: HdmiErrorStates; - }) => void; + setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void; } export const useVideoStore = create(set => ({ @@ -304,7 +310,8 @@ export const useVideoStore = create(set => ({ clientHeight: 0, // The video element's client size - setClientSize: (clientWidth: number, clientHeight: number) => set({ clientWidth, clientHeight }), + setClientSize: (clientWidth: number, clientHeight: number) => + set({ clientWidth, clientHeight }), // Resolution setSize: (width: number, height: number) => set({ width, height }), @@ -451,13 +458,15 @@ export interface MountMediaState { export const useMountMediaStore = create(set => ({ remoteVirtualMediaState: null, - setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => set({ remoteVirtualMediaState: state }), + setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => + set({ remoteVirtualMediaState: state }), modalView: "mode", setModalView: (view: MountMediaState["modalView"]) => set({ modalView: view }), isMountMediaDialogOpen: false, - setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => set({ isMountMediaDialogOpen: isOpen }), + setIsMountMediaDialogOpen: (isOpen: MountMediaState["isMountMediaDialogOpen"]) => + set({ isMountMediaDialogOpen: isOpen }), uploadedFiles: [], addUploadedFile: (file: { name: string; size: string; uploadedAt: string }) => @@ -474,7 +483,7 @@ export interface KeyboardLedState { compose: boolean; kana: boolean; shift: boolean; // Optional, as not all keyboards have a shift LED -}; +} export const hidKeyBufferSize = 6; export const hidErrorRollOver = 0x01; @@ -509,14 +518,23 @@ export interface HidState { } export const useHidStore = create(set => ({ - keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false, shift: false } as KeyboardLedState, - setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }), + keyboardLedState: { + num_lock: false, + caps_lock: false, + scroll_lock: false, + compose: false, + kana: false, + shift: false, + } as KeyboardLedState, + setKeyboardLedState: (ledState: KeyboardLedState): void => + set({ keyboardLedState: ledState }), keysDownState: { modifier: 0, keys: [0, 0, 0, 0, 0, 0] } as KeysDownState, setKeysDownState: (state: KeysDownState): void => set({ keysDownState: state }), isVirtualKeyboardEnabled: false, - setVirtualKeyboardEnabled: (enabled: boolean): void => set({ isVirtualKeyboardEnabled: enabled }), + setVirtualKeyboardEnabled: (enabled: boolean): void => + set({ isVirtualKeyboardEnabled: enabled }), isPasteInProgress: false, setPasteModeEnabled: (enabled: boolean): void => set({ isPasteInProgress: enabled }), @@ -569,19 +587,23 @@ export interface OtaState { systemUpdateProgress: number; systemUpdatedAt: string | null; -}; +} 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 + + modalView: UpdateModalViews; setModalView: (view: UpdateModalViews) => void; - setUpdateErrorMessage: (errorMessage: string) => void; + updateErrorMessage: string | null; + setUpdateErrorMessage: (errorMessage: string) => void; } export const useUpdateStore = create(set => ({ @@ -612,15 +634,16 @@ export const useUpdateStore = create(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 }), + setUpdateErrorMessage: (errorMessage: string) => + set({ updateErrorMessage: errorMessage }), })); -export type UsbConfigModalViews = - | "updateUsbConfig" - | "updateUsbConfigSuccess"; +export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess"; export interface UsbConfigModalState { modalView: UsbConfigModalViews; @@ -828,12 +851,12 @@ export interface MacrosState { loadMacros: () => Promise; saveMacros: (macros: KeySequence[]) => Promise; sendFn: - | (( - method: string, - params: unknown, - callback?: ((resp: JsonRpcResponse) => void) | undefined, - ) => void) - | null; + | (( + method: string, + params: unknown, + callback?: ((resp: JsonRpcResponse) => void) | undefined, + ) => void) + | null; setSendFn: ( sendFn: ( method: string, @@ -973,5 +996,5 @@ export const useMacrosStore = create((set, get) => ({ } finally { set({ loading: false }); } - } + }, })); diff --git a/ui/src/hooks/useKeyboard.ts b/ui/src/hooks/useKeyboard.ts index ee9573b5..4c4d2d43 100644 --- a/ui/src/hooks/useKeyboard.ts +++ b/ui/src/hooks/useKeyboard.ts @@ -16,6 +16,7 @@ import { import { useHidRpc } from "@/hooks/useHidRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { hidKeyToModifierMask, keys, modifiers } from "@/keyboardMappings"; +import { sleep } from "@/utils"; const MACRO_RESET_KEYBOARD_STATE = { keys: new Array(hidKeyBufferSize).fill(0), @@ -31,8 +32,6 @@ export interface MacroStep { export type MacroSteps = MacroStep[]; -const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); - export default function useKeyboard() { const { send } = useJsonRpc(); const { rpcDataChannel } = useRTCStore(); @@ -97,24 +96,23 @@ export default function useKeyboard() { [send, setKeysDownState], ); - const sendKeystrokeLegacy = useCallback(async (keys: number[], modifier: number, ac?: AbortController) => { - return await new Promise((resolve, reject) => { - const abortListener = () => { - reject(new Error("Keyboard report aborted")); - }; + const sendKeystrokeLegacy = useCallback( + async (keys: number[], modifier: number, ac?: AbortController) => { + return await new Promise((resolve, reject) => { + const abortListener = () => { + reject(new Error("Keyboard report aborted")); + }; - ac?.signal?.addEventListener("abort", abortListener); + ac?.signal?.addEventListener("abort", abortListener); - send( - "keyboardReport", - { keys, modifier }, - params => { + send("keyboardReport", { keys, modifier }, params => { if ("error" in params) return reject(params.error); resolve(); - }, - ); - }); - }, [send]); + }); + }); + }, + [send], + ); const KEEPALIVE_INTERVAL = 50; @@ -149,7 +147,6 @@ export default function useKeyboard() { } }, [rpcHidReady, sendKeyboardEventHidRpc, handleLegacyKeyboardReport, cancelKeepAlive]); - // IMPORTANT: See the keyPressReportApiAvailable comment above for the reason this exists function simulateDeviceSideKeyHandlingForLegacyDevices( state: KeysDownState, @@ -200,7 +197,9 @@ export default function useKeyboard() { // If we reach here it means we didn't find an empty slot or the key in the buffer if (overrun) { if (press) { - console.warn(`keyboard buffer overflow current keys ${keys}, key: ${key} not added`); + console.warn( + `keyboard buffer overflow current keys ${keys}, key: ${key} not added`, + ); // Fill all key slots with ErrorRollOver (0x01) to indicate overflow keys.length = hidKeyBufferSize; keys.fill(hidErrorRollOver); @@ -284,85 +283,92 @@ export default function useKeyboard() { // After the delay, the keys and modifiers are released and the next step is executed. // If a step has no keys or modifiers, it is treated as a delay-only step. // A small pause is added between steps to ensure that the device can process the events. - const executeMacroRemote = useCallback(async ( - steps: MacroSteps, - ) => { - const macro: KeyboardMacroStep[] = []; + const executeMacroRemote = useCallback( + async (steps: MacroSteps) => { + const macro: KeyboardMacroStep[] = []; - for (const [_, step] of steps.entries()) { - const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); - const modifierMask: number = (step.modifiers || []) + for (const [_, step] of steps.entries()) { + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []) - .map(mod => modifiers[mod]) + .map(mod => modifiers[mod]) - .reduce((acc, val) => acc + val, 0); + .reduce((acc, val) => acc + val, 0); - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { - macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); - macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); - } - } - - sendKeyboardMacroEventHidRpc(macro); - }, [sendKeyboardMacroEventHidRpc]); - - const executeMacroClientSide = useCallback(async (steps: MacroSteps) => { - const promises: (() => Promise)[] = []; - - const ac = new AbortController(); - setAbortController(ac); - - for (const [_, step] of steps.entries()) { - const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); - const modifierMask: number = (step.modifiers || []) - .map(mod => modifiers[mod]) - .reduce((acc, val) => acc + val, 0); - - // If the step has keys and/or modifiers, press them and hold for the delay - if (keyValues.length > 0 || modifierMask > 0) { - promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); - promises.push(() => resetKeyboardState()); - promises.push(() => sleep(step.delay || 100)); - } - } - - const runAll = async () => { - for (const promise of promises) { - // Check if we've been aborted before executing each promise - if (ac.signal.aborted) { - throw new Error("Macro execution aborted"); + // If the step has keys and/or modifiers, press them and hold for the delay + if (keyValues.length > 0 || modifierMask > 0) { + macro.push({ keys: keyValues, modifier: modifierMask, delay: 20 }); + macro.push({ ...MACRO_RESET_KEYBOARD_STATE, delay: step.delay || 100 }); } - await promise(); } - } - return await new Promise((resolve, reject) => { - // Set up abort listener - const abortListener = () => { - reject(new Error("Macro execution aborted")); + sendKeyboardMacroEventHidRpc(macro); + }, + [sendKeyboardMacroEventHidRpc], + ); + + const executeMacroClientSide = useCallback( + async (steps: MacroSteps) => { + const promises: (() => Promise)[] = []; + + const ac = new AbortController(); + setAbortController(ac); + + for (const [_, step] of steps.entries()) { + const keyValues = (step.keys || []).map(key => keys[key]).filter(Boolean); + const modifierMask: number = (step.modifiers || []) + .map(mod => modifiers[mod]) + .reduce((acc, val) => acc + val, 0); + + // If the step has keys and/or modifiers, press them and hold for the delay + if (keyValues.length > 0 || modifierMask > 0) { + promises.push(() => sendKeystrokeLegacy(keyValues, modifierMask, ac)); + promises.push(() => resetKeyboardState()); + promises.push(() => sleep(step.delay || 100)); + } + } + + const runAll = async () => { + for (const promise of promises) { + // Check if we've been aborted before executing each promise + if (ac.signal.aborted) { + throw new Error("Macro execution aborted"); + } + await promise(); + } }; - ac.signal.addEventListener("abort", abortListener); + return await new Promise((resolve, reject) => { + // Set up abort listener + const abortListener = () => { + reject(new Error("Macro execution aborted")); + }; - runAll() - .then(() => { - ac.signal.removeEventListener("abort", abortListener); - resolve(); - }) - .catch((error) => { - ac.signal.removeEventListener("abort", abortListener); - reject(error); - }); - }); - }, [sendKeystrokeLegacy, resetKeyboardState, setAbortController]); + ac.signal.addEventListener("abort", abortListener); - const executeMacro = useCallback(async (steps: MacroSteps) => { - if (rpcHidReady) { - return executeMacroRemote(steps); - } - return executeMacroClientSide(steps); - }, [rpcHidReady, executeMacroRemote, executeMacroClientSide]); + runAll() + .then(() => { + ac.signal.removeEventListener("abort", abortListener); + resolve(); + }) + .catch(error => { + ac.signal.removeEventListener("abort", abortListener); + reject(error); + }); + }); + }, + [sendKeystrokeLegacy, resetKeyboardState, setAbortController], + ); + + const executeMacro = useCallback( + async (steps: MacroSteps) => { + if (rpcHidReady) { + return executeMacroRemote(steps); + } + return executeMacroClientSide(steps); + }, + [rpcHidReady, executeMacroRemote, executeMacroClientSide], + ); const cancelExecuteMacro = useCallback(async () => { if (abortController.current) { @@ -375,5 +381,11 @@ export default function useKeyboard() { cancelOngoingKeyboardMacroHidRpc(); }, [rpcHidReady, cancelOngoingKeyboardMacroHidRpc, abortController]); - return { handleKeyPress, resetKeyboardState, executeMacro, cleanup, cancelExecuteMacro }; + return { + handleKeyPress, + resetKeyboardState, + executeMacro, + cleanup, + cancelExecuteMacro, + }; } diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index cdde3a0c..3935607b 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -2,7 +2,8 @@ import { useCallback, useMemo } from "react"; import semver from "semver"; 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"; import { m } from "@localizations/messages.js"; @@ -28,17 +29,6 @@ export function useVersion() { setAppVersion, setSystemVersion, } = useDeviceStore(); - const { send } = useJsonRpc(); - const getVersionInfo = useCallback(() => { - return new Promise((resolve, reject) => { - send("getUpdateStatus", {}, (resp: JsonRpcResponse) => { - if ("error" in resp) { - notifications.error(m.updates_failed_check({ error: String(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(m.updates_failed_check({ error: String(result.error) })); @@ -87,4 +77,4 @@ export function useVersion() { systemVersion, isOnDevVersion, }; -} \ No newline at end of file +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 6482c85a..b3001a69 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -73,10 +73,10 @@ export async function checkDeviceAuth() { .GET(`${DEVICE_API}/device/status`) .then(res => res.json() as Promise); - if (!res.isSetup) return redirect("/welcome"); + if (!res.isSetup) throw redirect("/welcome"); const deviceRes = await api.GET(`${DEVICE_API}/device`); - if (deviceRes.status === 401) return redirect("/login-local"); + if (deviceRes.status === 401) throw redirect("/login-local"); if (deviceRes.ok) { const device = (await deviceRes.json()) as LocalDevice; return { authMode: device.authMode }; @@ -86,7 +86,7 @@ export async function checkDeviceAuth() { } export async function checkAuth() { - return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth(); + return isOnDevice ? checkDeviceAuth() : checkCloudAuth(); } let router; diff --git a/ui/src/routes/devices.$id.deregister.tsx b/ui/src/routes/devices.$id.deregister.tsx index d0b821db..07db5d7d 100644 --- a/ui/src/routes/devices.$id.deregister.tsx +++ b/ui/src/routes/devices.$id.deregister.tsx @@ -58,7 +58,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { return { device, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { user }; } }; diff --git a/ui/src/routes/devices.$id.rename.tsx b/ui/src/routes/devices.$id.rename.tsx index b61dc45a..a7191764 100644 --- a/ui/src/routes/devices.$id.rename.tsx +++ b/ui/src/routes/devices.$id.rename.tsx @@ -54,7 +54,7 @@ const loader: LoaderFunction = async ({ params }: LoaderFunctionArgs) => { return { device, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { user }; } }; diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 0cb49c81..9b2d3cd3 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -99,7 +99,7 @@ export default function SettingsAccessIndexRoute() { } 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; }); diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 272fc5d0..c90cba64 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -16,6 +16,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { isOnDevice } from "@/main"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); @@ -429,8 +430,10 @@ export default function SettingsAdvancedRoute() { size="SM" theme="light" text={m.advanced_reset_config_button()} - onClick={() => { + onClick={async () => { handleResetConfig(); + // Add 2s delay between resetting the configuration and calling reload() to prevent reload from interrupting the RPC call to reset things. + await sleep(2000); window.location.reload(); }} /> diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx index 84be89c7..fc0feeaa 100644 --- a/ui/src/routes/devices.$id.settings.general.reboot.tsx +++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx @@ -4,16 +4,25 @@ import { useNavigate } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { Button } from "@components/Button"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; export default function SettingsGeneralRebootRoute() { const navigate = useNavigate(); const { send } = useJsonRpc(); + + const onClose = useCallback(async () => { + navigate(".."); // back to the devices.$id.settings page + // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. + await sleep(1000); + window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded + }, [navigate]); + const onConfirmUpdate = useCallback(() => { send("reboot", { force: true}); }, [send]); - return navigate("..")} onConfirmUpdate={onConfirmUpdate} />; + return ; } export function Dialog({ diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index a232f76c..79e32b8a 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -4,12 +4,14 @@ import { useLocation, useNavigate, useSearchParams } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { UpdateState, useUpdateStore } from "@hooks/stores"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; -import { SystemVersionInfo, useVersion } from "@hooks/useVersion"; +import { useVersion } from "@hooks/useVersion"; import { Button } from "@components/Button"; import Card from "@components/Card"; import LoadingSpinner from "@components/LoadingSpinner"; -import UpdatingStatusCard, { type UpdatePart} from "@components/UpdatingStatusCard"; +import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard"; import { m } from "@localizations/messages.js"; +import { sleep } from "@/utils"; +import { SystemVersionInfo } from "@/utils/jsonrpc"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); @@ -52,7 +54,7 @@ export default function SettingsGeneralUpdateRoute() { } else { setModalView("loading"); } - }, [otaState.updating, otaState.error, setModalView, updateSuccess]); + }, [otaState.error, otaState.updating, setModalView, updateSuccess]); return navigate("..")} @@ -178,13 +180,14 @@ function LoadingState({ }, 0); getVersionInfo() - .then(versionInfo => { + .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 => { diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 77905482..3527f269 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -47,10 +47,10 @@ export default function SettingsHardwareRoute() { } setBacklightSettings(settings); - handleBacklightSettingsSave(); + handleBacklightSettingsSave(settings); }; - const handleBacklightSettingsSave = () => { + const handleBacklightSettingsSave = (backlightSettings: BacklightSettings) => { send("setBacklightSettings", { params: backlightSettings }, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( @@ -82,7 +82,7 @@ export default function SettingsHardwareRoute() { const duration = enabled ? 90 : -1; send("setVideoSleepMode", { duration }, (resp: JsonRpcResponse) => { if ("error" in resp) { - notifications.error(m.hardware_power_saving_failed_error({ error: resp.error.data ||m.unknown_error() })); + notifications.error(m.hardware_power_saving_failed_error({ error: resp.error.data || m.unknown_error() })); setPowerSavingEnabled(!enabled); // Attempt to revert on error return; } diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx index bf73902e..1e2bb6b3 100644 --- a/ui/src/routes/devices.$id.tsx +++ b/ui/src/routes/devices.$id.tsx @@ -1,7 +1,6 @@ import { lazy, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Outlet, - redirect, useLoaderData, useLocation, useNavigate, @@ -16,7 +15,7 @@ import { motion, AnimatePresence } from "framer-motion"; import useWebSocket from "react-use-websocket"; import { cx } from "@/cva.config"; -import { CLOUD_API, DEVICE_API } from "@/ui.config"; +import { CLOUD_API } from "@/ui.config"; import api from "@/api"; import { checkAuth, isInCloud, isOnDevice } from "@/main"; import { @@ -51,9 +50,14 @@ import { RebootingOverlay, } from "@components/VideoOverlay"; import { FeatureFlagProvider } from "@providers/FeatureFlagProvider"; -import { DeviceStatus } from "@routes/welcome-local"; import { m } from "@localizations/messages.js"; +export type AuthMode = "password" | "noPassword" | null; + +interface LocalLoaderResp { + authMode: AuthMode; +} + interface CloudLoaderResp { deviceName: string; user: User | null; @@ -62,35 +66,20 @@ interface CloudLoaderResp { } | null; } -export type AuthMode = "password" | "noPassword" | null; export interface LocalDevice { authMode: AuthMode; deviceId: string; } const deviceLoader = async () => { - const res = await api - .GET(`${DEVICE_API}/device/status`) - .then(res => res.json() as Promise); - - if (!res.isSetup) return redirect("/welcome"); - - const deviceRes = await api.GET(`${DEVICE_API}/device`); - if (deviceRes.status === 401) return redirect("/login-local"); - if (deviceRes.ok) { - const device = (await deviceRes.json()) as LocalDevice; - return { authMode: device.authMode }; - } - - throw new Error("Error fetching device"); + const device = await checkAuth(); + return { authMode: device.authMode } as LocalLoaderResp; }; const cloudLoader = async (params: Params): Promise => { const user = await checkAuth(); - const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`); const iceConfig = await iceResp.json(); - const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`); if (!deviceResp.ok) { @@ -105,11 +94,11 @@ const cloudLoader = async (params: Params): Promise => device: { id: string; name: string; user: { googleId: string } }; }; - return { user, iceConfig, deviceName: device.name || device.id }; + return { user, iceConfig, deviceName: device.name || device.id } as CloudLoaderResp; }; const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => { - return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params); + return isOnDevice ? deviceLoader() : cloudLoader(params); }; export default function KvmIdRoute() { @@ -185,7 +174,7 @@ export default function KvmIdRoute() { try { await pc.setRemoteDescription(new RTCSessionDescription(remoteDescription)); - console.log("[setRemoteSessionDescription] Remote description set successfully"); + console.log("[setRemoteSessionDescription] Remote description set successfully to: " + remoteDescription.sdp); setLoadingMessage(m.establishing_secure_connection()); } catch (error) { console.error( @@ -230,9 +219,14 @@ export default function KvmIdRoute() { const ignoreOffer = useRef(false); const isSettingRemoteAnswerPending = useRef(false); const makingOffer = useRef(false); - + const reconnectAttemptsRef = useRef(2000); const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const reconnectInterval = (attempt: number) => { + // Exponential backoff with a max of 10 seconds between attempts + return Math.min(500 * 2 ** attempt, 10000); + } + const { sendMessage, getWebSocket } = useWebSocket( isOnDevice ? `${wsProtocol}//${window.location.host}/webrtc/signaling/client` @@ -240,10 +234,10 @@ export default function KvmIdRoute() { { heartbeat: true, retryOnError: true, - reconnectAttempts: 2000, - reconnectInterval: 1000, + reconnectAttempts: reconnectAttemptsRef.current, + reconnectInterval: reconnectInterval, onReconnectStop: (numAttempts: number) => { - console.debug("Reconnect stopped", numAttempts); + console.debug("Reconnect stopped after ", numAttempts, "attempts"); cleanupAndStopReconnecting(); }, @@ -261,6 +255,7 @@ export default function KvmIdRoute() { console.error("[Websocket] onError", event); // We don't want to close everything down, we wait for the reconnect to stop instead }, + onOpen() { console.debug("[Websocket] onOpen"); // We want to clear the reboot state when the websocket connection is opened @@ -293,6 +288,7 @@ export default function KvmIdRoute() { */ const parsedMessage = JSON.parse(message.data); + if (parsedMessage.type === "device-metadata") { const { deviceVersion } = parsedMessage.data; console.debug("[Websocket] Received device-metadata message"); @@ -309,10 +305,12 @@ export default function KvmIdRoute() { console.log("[Websocket] Device is using new signaling"); isLegacySignalingEnabled.current = false; } + setupPeerConnection(); } if (!peerConnection) return; + if (parsedMessage.type === "answer") { console.debug("[Websocket] Received answer"); const readyForOffer = @@ -559,8 +557,9 @@ export default function KvmIdRoute() { clearCandidatePairStats(); setSidebarView(null); setPeerConnection(null); + setRpcDataChannel(null); }; - }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView]); + }, [clearCandidatePairStats, clearInboundRtpStats, setPeerConnection, setSidebarView, setRpcDataChannel]); // TURN server usage detection useEffect(() => { @@ -677,6 +676,13 @@ export default function KvmIdRoute() { return; } + + // This is to prevent the otaState from handling page refreshes after an update + // We've recently implemented a new general rebooting flow, so we don't need to handle this specific ota-rebooting case + // However, with old devices, we wont get the `willReboot` message, so we need to keep this for backwards compatibility + // only for the cloud version with an old device + if (rebootState?.isRebooting) return; + const currentUrl = new URL(window.location.href); currentUrl.search = ""; currentUrl.searchParams.set("updateSuccess", "true"); diff --git a/ui/src/routes/devices.tsx b/ui/src/routes/devices.tsx index f658c73e..c6972e0e 100644 --- a/ui/src/routes/devices.tsx +++ b/ui/src/routes/devices.tsx @@ -16,7 +16,7 @@ interface LoaderData { devices: { id: string; name: string; online: boolean; lastSeen: string }[]; user: User; } -const loader: LoaderFunction = async ()=> { +const loader: LoaderFunction = async () => { const user = await checkAuth(); try { @@ -30,7 +30,7 @@ const loader: LoaderFunction = async ()=> { return { devices, user }; } catch (e) { console.error(e); - return { devices: [] }; + return { devices: [], user }; } }; diff --git a/ui/src/utils.ts b/ui/src/utils.ts index 1e73ada0..29d31ac1 100644 --- a/ui/src/utils.ts +++ b/ui/src/utils.ts @@ -1,7 +1,6 @@ import { KeySequence } from "@hooks/stores"; -import { getLocale } from '@localizations/runtime.js'; +import { getLocale , locales } from "@localizations/runtime.js"; import { m } from "@localizations/messages.js"; -import { locales } from '@localizations/runtime.js'; export const formatters = { date: (date: Date, options?: Intl.DateTimeFormatOptions) => @@ -47,14 +46,14 @@ export const formatters = { amount: number; name: Intl.RelativeTimeFormatUnit; }[] = [ - { amount: 60, name: "seconds" }, - { amount: 60, name: "minutes" }, - { amount: 24, name: "hours" }, - { amount: 7, name: "days" }, - { amount: 4.34524, name: "weeks" }, - { amount: 12, name: "months" }, - { amount: Number.POSITIVE_INFINITY, name: "years" }, - ]; + { amount: 60, name: "seconds" }, + { amount: 60, name: "minutes" }, + { amount: 24, name: "hours" }, + { amount: 7, name: "days" }, + { amount: 4.34524, name: "weeks" }, + { amount: 12, name: "months" }, + { amount: Number.POSITIVE_INFINITY, name: "years" }, + ]; let duration = (date.valueOf() - new Date().valueOf()) / 1000; @@ -255,27 +254,41 @@ export function normalizeSortOrders(macros: KeySequence[]): KeySequence[] { ...macro, sortOrder: index + 1, })); -}; +} -type LocaleCode = typeof locales[number]; +type LocaleCode = (typeof locales)[number]; -export function map_locale_code_to_name(currentLocale: LocaleCode, locale: string): [string, string] { - // the first is the name in the current app locale (e.g. Inglese), - // the second is the name in the language of the locale itself (e.g. English) - switch (locale) { - case '': return [m.locale_auto(), ""]; - case 'en': return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })]; - case 'da': return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })]; - case 'de': return [m.locale_de({}, { locale: currentLocale }), m.locale_de({}, { locale })]; - case 'es': return [m.locale_es({}, { locale: currentLocale }), m.locale_es({}, { locale })]; - case 'fr': return [m.locale_fr({}, { locale: currentLocale }), m.locale_fr({}, { locale })]; - case 'it': return [m.locale_it({}, { locale: currentLocale }), m.locale_it({}, { locale })]; - case 'nb': return [m.locale_nb({}, { locale: currentLocale }), m.locale_nb({}, { locale })]; - case 'sv': return [m.locale_sv({}, { locale: currentLocale }), m.locale_sv({}, { locale })]; - case 'zh': return [m.locale_zh({}, { locale: currentLocale }), m.locale_zh({}, { locale })]; - default: return [locale, ""]; - } +export function map_locale_code_to_name( + currentLocale: LocaleCode, + locale: string, +): [string, string] { + // the first is the name in the current app locale (e.g. Inglese), + // the second is the name in the language of the locale itself (e.g. English) + switch (locale) { + case "": + return [m.locale_auto(), ""]; + case "en": + return [m.locale_en({}, { locale: currentLocale }), m.locale_en({}, { locale })]; + case "da": + return [m.locale_da({}, { locale: currentLocale }), m.locale_da({}, { locale })]; + case "de": + return [m.locale_de({}, { locale: currentLocale }), m.locale_de({}, { locale })]; + case "es": + return [m.locale_es({}, { locale: currentLocale }), m.locale_es({}, { locale })]; + case "fr": + return [m.locale_fr({}, { locale: currentLocale }), m.locale_fr({}, { locale })]; + case "it": + return [m.locale_it({}, { locale: currentLocale }), m.locale_it({}, { locale })]; + case "nb": + return [m.locale_nb({}, { locale: currentLocale }), m.locale_nb({}, { locale })]; + case "sv": + return [m.locale_sv({}, { locale: currentLocale }), m.locale_sv({}, { locale })]; + case "zh": + return [m.locale_zh({}, { locale: currentLocale }), m.locale_zh({}, { locale })]; + default: + return [locale, ""]; } +} export function deleteCookie(name: string, domain?: string, path = "/") { const domainPart = domain ? `; domain=${domain}` : ""; @@ -283,4 +296,8 @@ export function deleteCookie(name: string, domain?: string, path = "/") { 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}`; -} \ No newline at end of file +} + +export function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index ecfa1c4b..ae97be13 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -1,15 +1,18 @@ import { useRTCStore } from "@/hooks/stores"; +import { sleep } from "@/utils"; // JSON-RPC utility for use outside of React components + export interface JsonRpcCallOptions { method: string; params?: unknown; - timeout?: number; + attemptTimeoutMs?: number; + maxAttempts?: number; } -export interface JsonRpcCallResponse { +export interface JsonRpcCallResponse { jsonrpc: string; - result?: unknown; + result?: T; error?: { code: number; message: string; @@ -20,16 +23,58 @@ export interface JsonRpcCallResponse { let rpcCallCounter = 0; -export function callJsonRpc(options: JsonRpcCallOptions): Promise { - return new Promise((resolve, reject) => { - // Access the RTC store directly outside of React context - const rpcDataChannel = useRTCStore.getState().rpcDataChannel; +// Helper: wait for RTC data channel to be ready +// This waits indefinitely for the channel to be ready, only aborting via the signal +// Throws if the channel instance changed while waiting (stale connection detected) +async function waitForRtcReady(signal: AbortSignal): Promise { + const pollInterval = 100; + let lastSeenChannel: RTCDataChannel | null = null; - if (!rpcDataChannel || rpcDataChannel.readyState !== "open") { - reject(new Error("RPC data channel not available")); - return; + while (!signal.aborted) { + const state = useRTCStore.getState(); + const currentChannel = state.rpcDataChannel; + + // Channel instance changed (new connection replaced old one) + if (lastSeenChannel && currentChannel && lastSeenChannel !== currentChannel) { + console.debug("[waitForRtcReady] Channel instance changed, aborting wait"); + throw new Error("RTC connection changed while waiting for readiness"); } + // Channel was removed from store (connection closed) + if (lastSeenChannel && !currentChannel) { + console.debug("[waitForRtcReady] Channel was removed from store, aborting wait"); + throw new Error("RTC connection was closed while waiting for readiness"); + } + + // No channel yet, keep waiting + if (!currentChannel) { + await sleep(pollInterval); + continue; + } + + // Track this channel instance + lastSeenChannel = currentChannel; + + // Channel is ready! + if (currentChannel.readyState === "open") { + return currentChannel; + } + + await sleep(pollInterval); + } + + // Signal was aborted for some reason + console.debug("[waitForRtcReady] Aborted via signal"); + throw new Error("RTC readiness check aborted"); +} + +// Helper: send RPC request and wait for response +async function sendRpcRequest( + rpcDataChannel: RTCDataChannel, + options: JsonRpcCallOptions, + signal: AbortSignal, +): Promise> { + return new Promise((resolve, reject) => { rpcCallCounter++; const requestId = `rpc_${Date.now()}_${rpcCallCounter}`; @@ -40,32 +85,96 @@ export function callJsonRpc(options: JsonRpcCallOptions): Promise { try { - const response = JSON.parse(event.data) as JsonRpcCallResponse; + const response = JSON.parse(event.data) as JsonRpcCallResponse; if (response.id === requestId) { - clearTimeout(timeoutId); - rpcDataChannel.removeEventListener("message", messageHandler); + cleanup(); resolve(response); } - } catch (error) { + } catch { // Ignore parse errors from other messages } }; - timeoutId = setTimeout(() => { - rpcDataChannel.removeEventListener("message", messageHandler); - reject(new Error(`JSON-RPC call timed out after ${timeout}ms`)); - }, timeout); + const abortHandler = () => { + cleanup(); + reject(new Error("Request aborted")); + }; + const cleanup = () => { + rpcDataChannel.removeEventListener("message", messageHandler); + signal.removeEventListener("abort", abortHandler); + }; + + signal.addEventListener("abort", abortHandler); rpcDataChannel.addEventListener("message", messageHandler); rpcDataChannel.send(JSON.stringify(request)); }); } +// Function overloads for better typing +export function callJsonRpc( + options: JsonRpcCallOptions, +): Promise & { result: T }>; +export function callJsonRpc( + options: JsonRpcCallOptions, +): Promise>; +export async function callJsonRpc( + options: JsonRpcCallOptions, +): Promise> { + const maxAttempts = options.maxAttempts ?? 1; + const timeout = options.attemptTimeoutMs || 5000; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // Exponential backoff for retries that starts at 500ms up to a maximum of 10 seconds + const backoffMs = Math.min(500 * Math.pow(2, attempt), 10000); + let timeoutId: ReturnType | null = null; + + try { + // Wait for RTC readiness without timeout - this allows time for WebRTC to connect + const readyAbortController = new AbortController(); + const rpcDataChannel = await waitForRtcReady(readyAbortController.signal); + + // Now apply timeout only to the actual RPC request/response + const rpcAbortController = new AbortController(); + timeoutId = setTimeout(() => rpcAbortController.abort(), timeout); + + // Send RPC request and wait for response + const response = await sendRpcRequest( + rpcDataChannel, + options, + rpcAbortController.signal, + ); + + // Retry on error if attempts remain + if (response.error && attempt < maxAttempts - 1) { + await sleep(backoffMs); + continue; + } + + return response; + } catch (error) { + // Retry on timeout/error if attempts remain + if (attempt < maxAttempts - 1) { + await sleep(backoffMs); + continue; + } + + throw error instanceof Error + ? error + : new Error(`JSON-RPC call failed after ${timeout}ms`); + } finally { + if (timeoutId !== null) { + clearTimeout(timeoutId); + } + } + } + + // Should never reach here due to loop logic, but TypeScript needs this + throw new Error("Unexpected error in callJsonRpc"); +} + // Specific network settings API calls export async function getNetworkSettings() { const response = await callJsonRpc({ method: "getNetworkSettings" }); @@ -101,3 +210,35 @@ export async function renewDHCPLease() { } return response.result; } + +export interface VersionInfo { + appVersion: string; + systemVersion: string; +} + +export interface SystemVersionInfo { + local: VersionInfo; + remote?: VersionInfo; + systemUpdateAvailable: boolean; + appUpdateAvailable: boolean; + error?: string; +} + +export async function getUpdateStatus() { + const response = await callJsonRpc({ + method: "getUpdateStatus", + // This function calls our api server to see if there are any updates available. + // It can be called on page load right after a restart, so we need to give it time to + // establish a connection to the api server. + maxAttempts: 6, + }); + + if (response.error) throw response.error; + return response.result; +} + +export async function getLocalVersion() { + const response = await callJsonRpc({ method: "getLocalVersion" }); + if (response.error) throw response.error; + return response.result; +}