mirror of https://github.com/jetkvm/kvm.git
Merge branch 'dev' into feat/audio-support
This commit is contained in:
commit
dc6b2ba8ed
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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: |
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
]
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
|
||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
|
||||
"cmake.ignoreCMakeListsMissing": true
|
||||
}
|
||||
|
|
@ -74,7 +74,12 @@ func supervise() error {
|
|||
// run the child binary
|
||||
cmd := exec.Command(binPath)
|
||||
|
||||
cmd.Env = append(os.Environ(), []string{envChildID + "=" + kvm.GetBuiltAppVersion()}...)
|
||||
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
|
||||
|
||||
cmd.Env = append(os.Environ(), []string{
|
||||
fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
|
||||
fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
|
||||
}...)
|
||||
cmd.Args = os.Args
|
||||
|
||||
logFile, err := os.CreateTemp("", "jetkvm-stdout.log")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
failsafeDefaultLastCrashPath = "/userdata/jetkvm/crashdump/last-crash.log"
|
||||
failsafeFile = "/userdata/jetkvm/.enablefailsafe"
|
||||
failsafeLastCrashEnv = "JETKVM_LAST_ERROR_PATH"
|
||||
failsafeEnv = "JETKVM_FORCE_FAILSAFE"
|
||||
)
|
||||
|
||||
var (
|
||||
failsafeOnce sync.Once
|
||||
failsafeCrashLog = ""
|
||||
failsafeModeActive = false
|
||||
failsafeModeReason = ""
|
||||
)
|
||||
|
||||
type FailsafeModeNotification struct {
|
||||
Active bool `json:"active"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// this function has side effects and can be only executed once
|
||||
func checkFailsafeReason() {
|
||||
failsafeOnce.Do(func() {
|
||||
// check if the failsafe environment variable is set
|
||||
if os.Getenv(failsafeEnv) == "1" {
|
||||
failsafeModeActive = true
|
||||
failsafeModeReason = "failsafe_env_set"
|
||||
return
|
||||
}
|
||||
|
||||
// check if the failsafe file exists
|
||||
if _, err := os.Stat(failsafeFile); err == nil {
|
||||
failsafeModeActive = true
|
||||
failsafeModeReason = "failsafe_file_exists"
|
||||
_ = os.Remove(failsafeFile)
|
||||
return
|
||||
}
|
||||
|
||||
// get the last crash log path from the environment variable
|
||||
lastCrashPath := os.Getenv(failsafeLastCrashEnv)
|
||||
if lastCrashPath == "" {
|
||||
lastCrashPath = failsafeDefaultLastCrashPath
|
||||
}
|
||||
|
||||
// check if the last crash log file exists
|
||||
l := failsafeLogger.With().Str("path", lastCrashPath).Logger()
|
||||
fi, err := os.Lstat(lastCrashPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
l.Warn().Err(err).Msg("failed to stat last crash log")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if fi.Mode()&os.ModeSymlink != os.ModeSymlink {
|
||||
l.Warn().Msg("last crash log is not a symlink, ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
// open the last crash log file and find if it contains the string "panic"
|
||||
content, err := os.ReadFile(lastCrashPath)
|
||||
if err != nil {
|
||||
l.Warn().Err(err).Msg("failed to read last crash log")
|
||||
return
|
||||
}
|
||||
|
||||
// unlink the last crash log file
|
||||
failsafeCrashLog = string(content)
|
||||
_ = os.Remove(lastCrashPath)
|
||||
|
||||
// TODO: read the goroutine stack trace and check which goroutine is panicking
|
||||
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
|
||||
failsafeModeActive = true
|
||||
failsafeModeReason = "video"
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func notifyFailsafeMode(session *Session) {
|
||||
if !failsafeModeActive || session == nil {
|
||||
return
|
||||
}
|
||||
|
||||
jsonRpcLogger.Info().Str("reason", failsafeModeReason).Msg("sending failsafe mode notification")
|
||||
|
||||
writeJSONRPCEvent("failsafeMode", FailsafeModeNotification{
|
||||
Active: true,
|
||||
Reason: failsafeModeReason,
|
||||
}, session)
|
||||
}
|
||||
|
||||
func rpcGetFailsafeLogs() (string, error) {
|
||||
if !failsafeModeActive {
|
||||
return "", fmt.Errorf("failsafe mode is not active")
|
||||
}
|
||||
|
||||
return failsafeCrashLog, nil
|
||||
}
|
||||
40
go.mod
40
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
|
||||
)
|
||||
|
|
|
|||
80
go.sum
80
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=
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
//go:build linux
|
||||
|
||||
// TODO: use a generator to generate the cgo code for the native functions
|
||||
// there's too much boilerplate code to write manually
|
||||
|
||||
package native
|
||||
|
||||
import (
|
||||
|
|
@ -46,7 +49,17 @@ static inline void jetkvm_cgo_setup_rpc_handler() {
|
|||
*/
|
||||
import "C"
|
||||
|
||||
var cgoLock sync.Mutex
|
||||
var (
|
||||
cgoLock sync.Mutex
|
||||
cgoDisabled bool
|
||||
)
|
||||
|
||||
func setCgoDisabled(disabled bool) {
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
cgoDisabled = disabled
|
||||
}
|
||||
|
||||
//export jetkvm_go_video_state_handler
|
||||
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
|
||||
|
|
@ -91,6 +104,10 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
|
|||
var eventCodeToNameMap = map[int]string{}
|
||||
|
||||
func uiEventCodeToName(code int) string {
|
||||
if cgoDisabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
name, ok := eventCodeToNameMap[code]
|
||||
if !ok {
|
||||
cCode := C.int(code)
|
||||
|
|
@ -103,6 +120,10 @@ func uiEventCodeToName(code int) string {
|
|||
}
|
||||
|
||||
func setUpNativeHandlers() {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -114,6 +135,10 @@ func setUpNativeHandlers() {
|
|||
}
|
||||
|
||||
func uiInit(rotation uint16) {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -123,6 +148,10 @@ func uiInit(rotation uint16) {
|
|||
}
|
||||
|
||||
func uiTick() {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -130,6 +159,10 @@ func uiTick() {
|
|||
}
|
||||
|
||||
func videoInit(factor float64) error {
|
||||
if cgoDisabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -143,6 +176,10 @@ func videoInit(factor float64) error {
|
|||
}
|
||||
|
||||
func videoShutdown() {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -150,6 +187,10 @@ func videoShutdown() {
|
|||
}
|
||||
|
||||
func videoStart() {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -157,6 +198,10 @@ func videoStart() {
|
|||
}
|
||||
|
||||
func videoStop() {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -164,6 +209,10 @@ func videoStop() {
|
|||
}
|
||||
|
||||
func videoLogStatus() string {
|
||||
if cgoDisabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -174,6 +223,10 @@ func videoLogStatus() string {
|
|||
}
|
||||
|
||||
func uiSetVar(name string, value string) {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -187,6 +240,10 @@ func uiSetVar(name string, value string) {
|
|||
}
|
||||
|
||||
func uiGetVar(name string) string {
|
||||
if cgoDisabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -197,6 +254,10 @@ func uiGetVar(name string) string {
|
|||
}
|
||||
|
||||
func uiSwitchToScreen(screen string) {
|
||||
if cgoDisabled {
|
||||
return
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -206,6 +267,10 @@ func uiSwitchToScreen(screen string) {
|
|||
}
|
||||
|
||||
func uiGetCurrentScreen() string {
|
||||
if cgoDisabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -214,6 +279,10 @@ func uiGetCurrentScreen() string {
|
|||
}
|
||||
|
||||
func uiObjAddState(objName string, state string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -226,6 +295,10 @@ func uiObjAddState(objName string, state string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiObjClearState(objName string, state string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -238,6 +311,10 @@ func uiObjClearState(objName string, state string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiGetLVGLVersion() string {
|
||||
if cgoDisabled {
|
||||
return ""
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -246,6 +323,10 @@ func uiGetLVGLVersion() string {
|
|||
|
||||
// TODO: use Enum instead of string but it's not a hot path and performance is not a concern now
|
||||
func uiObjAddFlag(objName string, flag string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -258,6 +339,10 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiObjClearFlag(objName string, flag string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -278,6 +363,10 @@ func uiObjShow(objName string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiObjSetOpacity(objName string, opacity int) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -289,6 +378,10 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
|
|||
}
|
||||
|
||||
func uiObjFadeIn(objName string, duration uint32) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -301,6 +394,10 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
|
|||
}
|
||||
|
||||
func uiObjFadeOut(objName string, duration uint32) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -313,6 +410,10 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
|
|||
}
|
||||
|
||||
func uiLabelSetText(objName string, text string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -330,6 +431,10 @@ func uiLabelSetText(objName string, text string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiImgSetSrc(objName string, src string) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -345,6 +450,10 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
|
|||
}
|
||||
|
||||
func uiDispSetRotation(rotation uint16) (bool, error) {
|
||||
if cgoDisabled {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -357,6 +466,10 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
|
|||
}
|
||||
|
||||
func videoGetStreamQualityFactor() (float64, error) {
|
||||
if cgoDisabled {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -365,6 +478,10 @@ func videoGetStreamQualityFactor() (float64, error) {
|
|||
}
|
||||
|
||||
func videoSetStreamQualityFactor(factor float64) error {
|
||||
if cgoDisabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -373,6 +490,10 @@ func videoSetStreamQualityFactor(factor float64) error {
|
|||
}
|
||||
|
||||
func videoGetEDID() (string, error) {
|
||||
if cgoDisabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
@ -381,6 +502,10 @@ func videoGetEDID() (string, error) {
|
|||
}
|
||||
|
||||
func videoSetEDID(edid string) error {
|
||||
if cgoDisabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
cgoLock.Lock()
|
||||
defer cgoLock.Unlock()
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
)
|
||||
|
||||
type Native struct {
|
||||
disable bool
|
||||
ready chan struct{}
|
||||
l *zerolog.Logger
|
||||
lD *zerolog.Logger
|
||||
|
|
@ -27,6 +28,7 @@ type Native struct {
|
|||
}
|
||||
|
||||
type NativeOptions struct {
|
||||
Disable bool
|
||||
SystemVersion *semver.Version
|
||||
AppVersion *semver.Version
|
||||
DisplayRotation uint16
|
||||
|
|
@ -74,6 +76,7 @@ func NewNative(opts NativeOptions) *Native {
|
|||
}
|
||||
|
||||
return &Native{
|
||||
disable: opts.Disable,
|
||||
ready: make(chan struct{}),
|
||||
l: nativeLogger,
|
||||
lD: displayLogger,
|
||||
|
|
@ -92,6 +95,12 @@ func NewNative(opts NativeOptions) *Native {
|
|||
}
|
||||
|
||||
func (n *Native) Start() {
|
||||
if n.disable {
|
||||
nativeLogger.Warn().Msg("native is disabled, skipping initialization")
|
||||
setCgoDisabled(true)
|
||||
return
|
||||
}
|
||||
|
||||
// set up singleton
|
||||
setInstance(n)
|
||||
setUpNativeHandlers()
|
||||
|
|
|
|||
|
|
@ -997,6 +997,10 @@ func rpcSetCloudUrl(apiUrl string, appUrl string) error {
|
|||
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
|
||||
}
|
||||
|
||||
if publicIPState != nil {
|
||||
publicIPState.SetCloudflareEndpoint(apiUrl)
|
||||
}
|
||||
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
|
@ -1319,4 +1323,7 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
|
||||
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
|
||||
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
|
||||
"getFailSafeLogs": {Func: rpcGetFailsafeLogs},
|
||||
"getPublicIPAddresses": {Func: rpcGetPublicIPAddresses, Params: []string{"refresh"}},
|
||||
"checkPublicIPAddresses": {Func: rpcCheckPublicIPAddresses},
|
||||
}
|
||||
|
|
|
|||
1
log.go
1
log.go
|
|
@ -11,6 +11,7 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error {
|
|||
|
||||
var (
|
||||
logger = logging.GetSubsystemLogger("jetkvm")
|
||||
failsafeLogger = logging.GetSubsystemLogger("failsafe")
|
||||
networkLogger = logging.GetSubsystemLogger("network")
|
||||
cloudLogger = logging.GetSubsystemLogger("cloud")
|
||||
websocketLogger = logging.GetSubsystemLogger("websocket")
|
||||
|
|
|
|||
9
main.go
9
main.go
|
|
@ -15,6 +15,12 @@ var appCtx context.Context
|
|||
|
||||
func Main() {
|
||||
logger.Log().Msg("JetKVM Starting Up")
|
||||
|
||||
checkFailsafeReason()
|
||||
if failsafeModeActive {
|
||||
logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
|
||||
}
|
||||
|
||||
LoadConfig()
|
||||
|
||||
var cancel context.CancelFunc
|
||||
|
|
@ -51,6 +57,7 @@ func Main() {
|
|||
// Initialize network
|
||||
if err := initNetwork(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to initialize network")
|
||||
// TODO: reset config to default
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +68,6 @@ func Main() {
|
|||
// Initialize mDNS
|
||||
if err := initMdns(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to initialize mDNS")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
initPrometheus()
|
||||
|
|
@ -127,6 +133,7 @@ func Main() {
|
|||
|
||||
// As websocket client already checks if the cloud token is set, we can start it here.
|
||||
go RunWebsocketClient()
|
||||
initPublicIPState()
|
||||
|
||||
initSerialPort()
|
||||
sigs := make(chan os.Signal, 1)
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ var (
|
|||
|
||||
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
||||
nativeInstance = native.NewNative(native.NativeOptions{
|
||||
Disable: failsafeModeActive,
|
||||
SystemVersion: systemVersion,
|
||||
AppVersion: appVersion,
|
||||
DisplayRotation: config.GetDisplayRotation(),
|
||||
|
|
|
|||
71
network.go
71
network.go
|
|
@ -3,12 +3,17 @@ package kvm
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/pkg/myip"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -17,6 +22,7 @@ const (
|
|||
|
||||
var (
|
||||
networkManager *nmlite.NetworkManager
|
||||
publicIPState *myip.PublicIPState
|
||||
)
|
||||
|
||||
type RpcNetworkSettings struct {
|
||||
|
|
@ -104,6 +110,13 @@ func triggerTimeSyncOnNetworkStateChange() {
|
|||
}()
|
||||
}
|
||||
|
||||
func setPublicIPReadyState(ipv4Ready, ipv6Ready bool) {
|
||||
if publicIPState == nil {
|
||||
return
|
||||
}
|
||||
publicIPState.SetIPv4AndIPv6(ipv4Ready, ipv6Ready)
|
||||
}
|
||||
|
||||
func networkStateChanged(_ string, state types.InterfaceState) {
|
||||
// do not block the main thread
|
||||
go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed")
|
||||
|
|
@ -117,6 +130,8 @@ func networkStateChanged(_ string, state types.InterfaceState) {
|
|||
triggerTimeSyncOnNetworkStateChange()
|
||||
}
|
||||
|
||||
setPublicIPReadyState(state.IPv4Ready, state.IPv6Ready)
|
||||
|
||||
// always restart mDNS when the network state changes
|
||||
if mDNS != nil {
|
||||
restartMdns()
|
||||
|
|
@ -164,6 +179,40 @@ func initNetwork() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func initPublicIPState() {
|
||||
// the feature will be only enabled if the cloud has been adopted
|
||||
// due to privacy reasons
|
||||
|
||||
// but it will be initialized anyway to avoid nil pointer dereferences
|
||||
ps := myip.NewPublicIPState(&myip.PublicIPStateConfig{
|
||||
Logger: networkLogger,
|
||||
CloudflareEndpoint: config.CloudURL,
|
||||
APIEndpoint: "",
|
||||
IPv4: false,
|
||||
IPv6: false,
|
||||
HttpClientGetter: func(family int) *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
netType := network
|
||||
switch family {
|
||||
case link.AfInet:
|
||||
netType = "tcp4"
|
||||
case link.AfInet6:
|
||||
netType = "tcp6"
|
||||
}
|
||||
return (&net.Dialer{}).DialContext(ctx, netType, addr)
|
||||
}
|
||||
|
||||
return &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
},
|
||||
})
|
||||
publicIPState = ps
|
||||
}
|
||||
|
||||
func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
||||
if nm == nil {
|
||||
return nil
|
||||
|
|
@ -312,3 +361,25 @@ func rpcToggleDHCPClient() error {
|
|||
|
||||
return rpcReboot(true)
|
||||
}
|
||||
|
||||
func rpcGetPublicIPAddresses(refresh bool) ([]myip.PublicIP, error) {
|
||||
if publicIPState == nil {
|
||||
return nil, fmt.Errorf("public IP state not initialized")
|
||||
}
|
||||
|
||||
if refresh {
|
||||
if err := publicIPState.ForceUpdate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return publicIPState.GetAddresses(), nil
|
||||
}
|
||||
|
||||
func rpcCheckPublicIPAddresses() error {
|
||||
if publicIPState == nil {
|
||||
return fmt.Errorf("public IP state not initialized")
|
||||
}
|
||||
|
||||
return publicIPState.ForceUpdate()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
package myip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||
)
|
||||
|
||||
func (ps *PublicIPState) request(ctx context.Context, url string, family int) ([]byte, error) {
|
||||
client := ps.httpClient(family)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
|
||||
return body, err
|
||||
}
|
||||
|
||||
// checkCloudflare uses cdn-cgi/trace to get the public IP address
|
||||
func (ps *PublicIPState) checkCloudflare(ctx context.Context, family int) (*PublicIP, error) {
|
||||
u, err := url.JoinPath(ps.cloudflareEndpoint, "/cdn-cgi/trace")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error joining path: %w", err)
|
||||
}
|
||||
|
||||
body, err := ps.request(ctx, u, family)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
values := make(map[string]string)
|
||||
for line := range strings.SplitSeq(string(body), "\n") {
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
values[key] = value
|
||||
}
|
||||
|
||||
ps.lastUpdated = time.Now()
|
||||
if ts, ok := values["ts"]; ok {
|
||||
if ts, err := strconv.ParseFloat(ts, 64); err == nil {
|
||||
ps.lastUpdated = time.Unix(int64(ts), 0)
|
||||
}
|
||||
}
|
||||
|
||||
ipStr, ok := values["ip"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no IP address found")
|
||||
}
|
||||
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("invalid IP address: %s", ipStr)
|
||||
}
|
||||
|
||||
return &PublicIP{
|
||||
IPAddress: ip,
|
||||
LastUpdated: ps.lastUpdated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// checkAPI uses the API endpoint to get the public IP address
|
||||
func (ps *PublicIPState) checkAPI(_ context.Context, _ int) (*PublicIP, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
// checkIPs checks both IPv4 and IPv6 public IP addresses in parallel
|
||||
// and updates the IPAddresses slice with the results
|
||||
func (ps *PublicIPState) checkIPs(ctx context.Context, checkIPv4, checkIPv6 bool) error {
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
var ips []PublicIP
|
||||
var errors []error
|
||||
|
||||
checkFamily := func(family int, familyName string) {
|
||||
wg.Add(1)
|
||||
go func(f int, name string) {
|
||||
defer wg.Done()
|
||||
|
||||
ip, err := ps.checkIPForFamily(ctx, f)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Errorf("%s check failed: %w", name, err))
|
||||
return
|
||||
}
|
||||
if ip != nil {
|
||||
ips = append(ips, *ip)
|
||||
}
|
||||
}(family, familyName)
|
||||
}
|
||||
|
||||
if checkIPv4 {
|
||||
checkFamily(link.AfInet, "IPv4")
|
||||
}
|
||||
|
||||
if checkIPv6 {
|
||||
checkFamily(link.AfInet6, "IPv6")
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if len(ips) > 0 {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
ps.addresses = ips
|
||||
ps.lastUpdated = time.Now()
|
||||
}
|
||||
|
||||
if len(errors) > 0 && len(ips) == 0 {
|
||||
return errors[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ps *PublicIPState) checkIPForFamily(ctx context.Context, family int) (*PublicIP, error) {
|
||||
if ps.apiEndpoint != "" {
|
||||
ip, err := ps.checkAPI(ctx, family)
|
||||
if err == nil && ip != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
if ps.cloudflareEndpoint != "" {
|
||||
ip, err := ps.checkCloudflare(ctx, family)
|
||||
if err == nil && ip != nil {
|
||||
return ip, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("all IP check methods failed for family %d", family)
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
package myip
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/logging"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type PublicIP struct {
|
||||
IPAddress net.IP `json:"ip"`
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type HttpClientGetter func(family int) *http.Client
|
||||
|
||||
type PublicIPState struct {
|
||||
addresses []PublicIP
|
||||
lastUpdated time.Time
|
||||
|
||||
cloudflareEndpoint string // cdn-cgi/trace domain
|
||||
apiEndpoint string // api endpoint
|
||||
ipv4 bool
|
||||
ipv6 bool
|
||||
httpClient HttpClientGetter
|
||||
logger *zerolog.Logger
|
||||
|
||||
timer *time.Timer
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type PublicIPStateConfig struct {
|
||||
CloudflareEndpoint string
|
||||
APIEndpoint string
|
||||
IPv4 bool
|
||||
IPv6 bool
|
||||
HttpClientGetter HttpClientGetter
|
||||
Logger *zerolog.Logger
|
||||
}
|
||||
|
||||
func stripURLPath(s string) string {
|
||||
parsed, err := url.Parse(s)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
scheme := parsed.Scheme
|
||||
if scheme != "http" && scheme != "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, parsed.Host)
|
||||
}
|
||||
|
||||
// NewPublicIPState creates a new PublicIPState
|
||||
func NewPublicIPState(config *PublicIPStateConfig) *PublicIPState {
|
||||
if config.Logger == nil {
|
||||
config.Logger = logging.GetSubsystemLogger("publicip")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
ps := &PublicIPState{
|
||||
addresses: make([]PublicIP, 0),
|
||||
lastUpdated: time.Now(),
|
||||
cloudflareEndpoint: stripURLPath(config.CloudflareEndpoint),
|
||||
apiEndpoint: config.APIEndpoint,
|
||||
ipv4: config.IPv4,
|
||||
ipv6: config.IPv6,
|
||||
httpClient: config.HttpClientGetter,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
logger: config.Logger,
|
||||
}
|
||||
// Start the timer automatically
|
||||
ps.Start()
|
||||
return ps
|
||||
}
|
||||
|
||||
// SetFamily sets if we need to track IPv4 and IPv6 public IP addresses
|
||||
func (ps *PublicIPState) SetIPv4AndIPv6(ipv4, ipv6 bool) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
ps.ipv4 = ipv4
|
||||
ps.ipv6 = ipv6
|
||||
}
|
||||
|
||||
// SetCloudflareEndpoint sets the Cloudflare endpoint
|
||||
func (ps *PublicIPState) SetCloudflareEndpoint(endpoint string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
ps.cloudflareEndpoint = stripURLPath(endpoint)
|
||||
}
|
||||
|
||||
// SetAPIEndpoint sets the API endpoint
|
||||
func (ps *PublicIPState) SetAPIEndpoint(endpoint string) {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
ps.apiEndpoint = endpoint
|
||||
}
|
||||
|
||||
// GetAddresses returns the public IP addresses
|
||||
func (ps *PublicIPState) GetAddresses() []PublicIP {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
return ps.addresses
|
||||
}
|
||||
|
||||
// Start starts the timer loop to check public IP addresses periodically
|
||||
func (ps *PublicIPState) Start() {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
// Stop any existing timer
|
||||
if ps.timer != nil {
|
||||
ps.timer.Stop()
|
||||
}
|
||||
|
||||
if ps.cancel != nil {
|
||||
ps.cancel()
|
||||
}
|
||||
|
||||
// Create new context and cancel function
|
||||
ps.ctx, ps.cancel = context.WithCancel(context.Background())
|
||||
|
||||
// Start the timer loop in a goroutine
|
||||
go ps.timerLoop(ps.ctx)
|
||||
}
|
||||
|
||||
// Stop stops the timer loop
|
||||
func (ps *PublicIPState) Stop() {
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
|
||||
if ps.cancel != nil {
|
||||
ps.cancel()
|
||||
ps.cancel = nil
|
||||
}
|
||||
|
||||
if ps.timer != nil {
|
||||
ps.timer.Stop()
|
||||
ps.timer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ForceUpdate forces an update of the public IP addresses
|
||||
func (ps *PublicIPState) ForceUpdate() error {
|
||||
return ps.checkIPs(context.Background(), true, true)
|
||||
}
|
||||
|
||||
// timerLoop runs the periodic IP check loop
|
||||
func (ps *PublicIPState) timerLoop(ctx context.Context) {
|
||||
timer := time.NewTimer(5 * time.Minute)
|
||||
defer timer.Stop()
|
||||
|
||||
// Store timer reference for Stop() to access
|
||||
ps.mu.Lock()
|
||||
ps.timer = timer
|
||||
checkIPv4 := ps.ipv4
|
||||
checkIPv6 := ps.ipv6
|
||||
ps.mu.Unlock()
|
||||
|
||||
// Perform initial check immediately
|
||||
checkIPs := func() {
|
||||
if err := ps.checkIPs(ctx, checkIPv4, checkIPv6); err != nil {
|
||||
ps.logger.Error().Err(err).Msg("failed to check public IP addresses")
|
||||
}
|
||||
}
|
||||
|
||||
checkIPs()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
// Perform the check
|
||||
checkIPs()
|
||||
|
||||
// Reset the timer for the next check
|
||||
timer.Reset(5 * time.Minute)
|
||||
|
||||
case <-ctx.Done():
|
||||
// Timer was stopped
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -938,5 +938,11 @@
|
|||
"wake_on_lan_invalid_mac": "Invalid MAC address",
|
||||
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
|
||||
"welcome_to_jetkvm": "Welcome to JetKVM",
|
||||
"welcome_to_jetkvm_description": "Control any computer remotely"
|
||||
"welcome_to_jetkvm_description": "Control any computer remotely","connection_stats_remote_ip_address": "Remote IP Address",
|
||||
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
|
||||
"connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
|
||||
"connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
|
||||
"public_ip_card_header": "Public IP addresses",
|
||||
"public_ip_card_refresh": "Refresh",
|
||||
"public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}"
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "kvm-ui",
|
||||
"private": true,
|
||||
"version": "2025.10.24.2140",
|
||||
"version": "2025.11.07.2130",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^22.20.0"
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"cva": "^1.0.0-beta.4",
|
||||
"dayjs": "^1.11.18",
|
||||
"dayjs": "^1.11.19",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"framer-motion": "^12.23.24",
|
||||
|
|
@ -47,52 +47,52 @@
|
|||
"react": "^19.2.0",
|
||||
"react-animate-height": "^3.2.3",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-hook-form": "^7.65.0",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-router": "^7.9.4",
|
||||
"react-simple-keyboard": "^3.8.131",
|
||||
"react-router": "^7.9.5",
|
||||
"react-simple-keyboard": "^3.8.132",
|
||||
"react-use-websocket": "^4.13.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"recharts": "^3.3.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"validator": "^13.15.15",
|
||||
"validator": "^13.15.20",
|
||||
"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.1",
|
||||
"@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.16",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@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.2.0",
|
||||
"@types/validator": "^13.15.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.46.3",
|
||||
"@typescript-eslint/parser": "^8.46.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint": "^9.39.1",
|
||||
"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.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.4.0",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.1.12",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
|
||||
import Card from "@components/Card";
|
||||
|
||||
interface FailsafeModeBannerProps {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export function FailsafeModeBanner({ reason }: FailsafeModeBannerProps) {
|
||||
const getReasonMessage = () => {
|
||||
switch (reason) {
|
||||
case "video":
|
||||
return "Failsafe Mode Active: Video-related settings are currently unavailable";
|
||||
default:
|
||||
return "Failsafe Mode Active: Some settings may be unavailable";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="diagonal-stripes flex items-center gap-3 p-4 rounded">
|
||||
<LuTriangleAlert className="h-5 w-5 flex-shrink-0 text-red-600 dark:text-red-400" />
|
||||
<p className="text-sm font-medium text-red-800 dark:text-white">
|
||||
{getReasonMessage()}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
import { useState } from "react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { LuInfo } from "react-icons/lu";
|
||||
|
||||
import { Button } from "@/components/Button";
|
||||
import Card, { GridCard } from "@components/Card";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||
import { useVersion } from "@/hooks/useVersion";
|
||||
import { useDeviceStore } from "@/hooks/stores";
|
||||
import notifications from "@/notifications";
|
||||
import { DOWNGRADE_VERSION } from "@/ui.config";
|
||||
|
||||
import { GitHubIcon } from "./Icons";
|
||||
|
||||
|
||||
|
||||
interface FailSafeModeOverlayProps {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
interface OverlayContentProps {
|
||||
readonly children: React.ReactNode;
|
||||
}
|
||||
|
||||
function OverlayContent({ children }: OverlayContentProps) {
|
||||
return (
|
||||
<GridCard cardClassName="h-full pointer-events-auto outline-hidden!">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-md border border-slate-800/30 dark:border-slate-300/20">
|
||||
{children}
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
||||
interface TooltipProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly text: string;
|
||||
readonly show: boolean;
|
||||
}
|
||||
|
||||
function Tooltip({ children, text, show }: TooltipProps) {
|
||||
if (!show) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="group/tooltip relative">
|
||||
{children}
|
||||
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 opacity-0 transition-opacity group-hover/tooltip:block group-hover/tooltip:opacity-100">
|
||||
<Card>
|
||||
<div className="whitespace-nowrap px-2 py-1 text-xs flex items-center gap-1 justify-center">
|
||||
<LuInfo className="h-3 w-3 text-slate-700 dark:text-slate-300" />
|
||||
{text}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
|
||||
const { send } = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const { appVersion } = useVersion();
|
||||
const { systemVersion } = useDeviceStore();
|
||||
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
|
||||
const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false);
|
||||
|
||||
const getReasonCopy = () => {
|
||||
switch (reason) {
|
||||
case "video":
|
||||
return {
|
||||
message:
|
||||
"We've detected an issue with the video capture process. Your device is still running and accessible, but video streaming is temporarily unavailable.",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
message:
|
||||
"A critical process has encountered an issue. Your device is still accessible, but some functionality may be temporarily unavailable.",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { message } = getReasonCopy();
|
||||
|
||||
const handleReportAndDownloadLogs = () => {
|
||||
setIsDownloadingLogs(true);
|
||||
|
||||
send("getFailSafeLogs", {}, async (resp: JsonRpcResponse) => {
|
||||
setIsDownloadingLogs(false);
|
||||
|
||||
if ("error" in resp) {
|
||||
notifications.error(`Failed to get recovery logs: ${resp.error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Download logs
|
||||
const logContent = resp.result as string;
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||
const filename = `jetkvm-recovery-${reason}-${timestamp}.txt`;
|
||||
|
||||
const blob = new Blob([logContent], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
a.click();
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
notifications.success("Crash logs downloaded successfully");
|
||||
setHasDownloadedLogs(true);
|
||||
|
||||
// Open GitHub issue
|
||||
const issueBody = `## Issue Description
|
||||
The \`${reason}\` process encountered an error and failsafe mode was activated.
|
||||
|
||||
**Reason:** \`${reason}\`
|
||||
**Timestamp:** ${new Date().toISOString()}
|
||||
**App Version:** ${appVersion || "Unknown"}
|
||||
**System Version:** ${systemVersion || "Unknown"}
|
||||
|
||||
## Logs
|
||||
Please attach the recovery logs file that was downloaded to your computer:
|
||||
\`${filename}\`
|
||||
|
||||
> [!NOTE]
|
||||
> Please remove any sensitive information from the logs. The reports are public and can be viewed by anyone.
|
||||
|
||||
## Additional Context
|
||||
[Please describe what you were doing when this occurred]`;
|
||||
|
||||
const issueUrl =
|
||||
`https://github.com/jetkvm/kvm/issues/new?` +
|
||||
`title=${encodeURIComponent(`Recovery Mode: ${reason} process issue`)}&` +
|
||||
`body=${encodeURIComponent(issueBody)}`;
|
||||
|
||||
window.open(issueUrl, "_blank");
|
||||
});
|
||||
};
|
||||
|
||||
const handleDowngrade = () => {
|
||||
navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
className="aspect-video h-full w-full isolate"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, transition: { duration: 0 } }}
|
||||
transition={{
|
||||
duration: 0.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<OverlayContent>
|
||||
<div className="flex max-w-lg flex-col items-start gap-y-1">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-yellow-500" />
|
||||
<div className="text-left text-sm text-slate-700 dark:text-slate-300">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2 text-black dark:text-white">
|
||||
<h2 className="text-xl font-bold">Fail safe mode activated</h2>
|
||||
<p className="text-sm">{message}</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={handleReportAndDownloadLogs}
|
||||
theme="primary"
|
||||
size="SM"
|
||||
disabled={isDownloadingLogs}
|
||||
LeadingIcon={GitHubIcon}
|
||||
text={isDownloadingLogs ? "Downloading Logs..." : "Download Logs & Report Issue"}
|
||||
/>
|
||||
|
||||
<div className="h-8 w-px bg-slate-200 dark:bg-slate-700 block" />
|
||||
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
|
||||
<Button
|
||||
onClick={() => navigateTo("/settings/general/reboot")}
|
||||
theme="light"
|
||||
size="SM"
|
||||
text="Reboot Device"
|
||||
disabled={!hasDownloadedLogs}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
|
||||
<Button
|
||||
size="SM"
|
||||
onClick={handleDowngrade}
|
||||
theme="light"
|
||||
text={`Downgrade to v${DOWNGRADE_VERSION}`}
|
||||
disabled={!hasDownloadedLogs}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import { LuRefreshCcw } from "react-icons/lu";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@components/Button";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { PublicIP } from "@hooks/stores";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import notifications from "@/notifications";
|
||||
import { formatters } from "@/utils";
|
||||
|
||||
|
||||
const TimeAgoLabel = ({ date }: { date: Date }) => {
|
||||
const [timeAgo, setTimeAgo] = useState<string | undefined>(formatters.timeAgo(date));
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setTimeAgo(formatters.timeAgo(date));
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [date]);
|
||||
|
||||
return (
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400 select-none">
|
||||
{timeAgo}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export default function PublicIPCard() {
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const [publicIPs, setPublicIPs] = useState<PublicIP[]>([]);
|
||||
const refreshPublicIPs = useCallback(() => {
|
||||
send("getPublicIPAddresses", { refresh: true }, (resp: JsonRpcResponse) => {
|
||||
setPublicIPs([]);
|
||||
if ("error" in resp) {
|
||||
notifications.error(m.public_ip_card_refresh_error({ error: resp.error.data || m.unknown_error() }));
|
||||
return;
|
||||
}
|
||||
const publicIPs = resp.result as PublicIP[];
|
||||
// sort the public IPs by IP address
|
||||
// IPv6 addresses are sorted after IPv4 addresses
|
||||
setPublicIPs(publicIPs.sort(({ ip: aIp }, { ip: bIp }) => {
|
||||
const aIsIPv6 = aIp.includes(":");
|
||||
const bIsIPv6 = bIp.includes(":");
|
||||
if (aIsIPv6 && !bIsIPv6) return 1;
|
||||
if (!aIsIPv6 && bIsIPv6) return -1;
|
||||
return aIp.localeCompare(bIp);
|
||||
}));
|
||||
});
|
||||
}, [send, setPublicIPs]);
|
||||
|
||||
useEffect(() => {
|
||||
refreshPublicIPs();
|
||||
}, [refreshPublicIPs]);
|
||||
|
||||
return (
|
||||
<GridCard>
|
||||
<div className="animate-fadeIn p-4 text-black opacity-0 animation-duration-500 dark:text-white">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{m.public_ip_card_header()}
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
size="XS"
|
||||
theme="light"
|
||||
type="button"
|
||||
className="text-red-500"
|
||||
text={m.public_ip_card_refresh()}
|
||||
LeadingIcon={LuRefreshCcw}
|
||||
onClick={refreshPublicIPs}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{publicIPs.length === 0 ? (
|
||||
<div>
|
||||
<div className="space-y-4">
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 w-1/4 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/3 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="h-4 w-1/2 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-x-6 gap-y-2">
|
||||
<div className="flex-1 space-y-2">
|
||||
{publicIPs?.map(ip => (
|
||||
<div key={ip.ip} className="flex justify-between border-slate-800/10 pt-2 dark:border-slate-300/20">
|
||||
<span className="text-sm font-medium">
|
||||
{ip.ip}
|
||||
</span>
|
||||
{ip.last_updated && <TimeAgoLabel date={new Date(ip.last_updated)} />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
import { useInterval } from "usehooks-ts";
|
||||
import { LuCopy } from "react-icons/lu";
|
||||
import { useState } from "react";
|
||||
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { useRTCStore, useUiStore } from "@hooks/stores";
|
||||
|
|
@ -6,6 +8,10 @@ import { createChartArray, Metric } from "@components/Metric";
|
|||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||
import SidebarHeader from "@components/SidebarHeader";
|
||||
import { someIterable } from "@/utils";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { Button } from "@components/Button";
|
||||
import { useCopyToClipboard } from "@components/useCopyToClipBoard";
|
||||
import notifications from "@/notifications";
|
||||
|
||||
export default function ConnectionStatsSidebar() {
|
||||
const { sidebarView, setSidebarView } = useUiStore();
|
||||
|
|
@ -21,6 +27,8 @@ export default function ConnectionStatsSidebar() {
|
|||
appendDiskDataChannelStats,
|
||||
} = useRTCStore();
|
||||
|
||||
const [remoteIPAddress, setRemoteIPAddress] = useState<string | null>(null);
|
||||
|
||||
useInterval(function collectWebRTCStats() {
|
||||
(async () => {
|
||||
if (!mediaStream) return;
|
||||
|
|
@ -49,6 +57,7 @@ export default function ConnectionStatsSidebar() {
|
|||
} else if (report.type === "remote-candidate") {
|
||||
if (successfulRemoteCandidateId === report.id) {
|
||||
appendRemoteCandidateStats(report);
|
||||
setRemoteIPAddress(report.address);
|
||||
}
|
||||
} else if (report.type === "data-channel" && report.label === "disk") {
|
||||
appendDiskDataChannelStats(report);
|
||||
|
|
@ -93,6 +102,8 @@ export default function ConnectionStatsSidebar() {
|
|||
return { date: d.date, metric: valueMs };
|
||||
});
|
||||
|
||||
const { copy } = useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
|
||||
<SidebarHeader title={m.connection_stats_sidebar()} setSidebarView={setSidebarView} />
|
||||
|
|
@ -106,6 +117,27 @@ export default function ConnectionStatsSidebar() {
|
|||
title={m.connection_stats_connection()}
|
||||
description={m.connection_stats_connection_description()}
|
||||
/>
|
||||
{remoteIPAddress && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{m.connection_stats_remote_ip_address()}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<GridCard cardClassName="rounded-r-none">
|
||||
<div className="h-[34px] flex items-center text-xs select-all text-black font-mono dark:text-white px-3 ">
|
||||
{remoteIPAddress}
|
||||
</div>
|
||||
</GridCard>
|
||||
<Button className="rounded-l-none border-l-slate-800/30 dark:border-slate-300/20" size="SM" type="button" theme="light" LeadingIcon={LuCopy} onClick={async () => {
|
||||
if (await copy(remoteIPAddress)) {
|
||||
notifications.success((m.connection_stats_remote_ip_address_copy_success({ ip: remoteIPAddress })));
|
||||
} else {
|
||||
notifications.error(m.connection_stats_remote_ip_address_copy_error());
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Metric
|
||||
title={m.connection_stats_round_trip_time()}
|
||||
description={m.connection_stats_round_trip_time_description()}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -181,44 +181,45 @@ export const useRTCStore = create<RTCState>(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 }),
|
||||
|
||||
audioTransceiver: null,
|
||||
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => set({ audioTransceiver: 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),
|
||||
}));
|
||||
|
|
@ -226,7 +227,7 @@ export const useRTCStore = create<RTCState>(set => ({
|
|||
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
|
||||
|
||||
candidatePairStats: new Map(),
|
||||
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
|
||||
appendCandidatePairStats: stats => {
|
||||
set(prevState => ({
|
||||
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
|
||||
}));
|
||||
|
|
@ -234,21 +235,21 @@ export const useRTCStore = create<RTCState>(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),
|
||||
}));
|
||||
|
|
@ -256,7 +257,7 @@ export const useRTCStore = create<RTCState>(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 {
|
||||
|
|
@ -276,12 +277,20 @@ export interface MouseState {
|
|||
export const useMouseStore = create<MouseState>(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<VideoState["hdmiState"], "no_signal" | "no_lock" | "out_of_range">
|
||||
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;
|
||||
|
|
@ -296,10 +305,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<VideoState>(set => ({
|
||||
|
|
@ -310,7 +316,8 @@ export const useVideoStore = create<VideoState>(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 }),
|
||||
|
|
@ -476,13 +483,15 @@ export interface MountMediaState {
|
|||
|
||||
export const useMountMediaStore = create<MountMediaState>(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 }) =>
|
||||
|
|
@ -499,7 +508,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;
|
||||
|
|
@ -534,14 +543,23 @@ export interface HidState {
|
|||
}
|
||||
|
||||
export const useHidStore = create<HidState>(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 }),
|
||||
|
|
@ -593,7 +611,7 @@ export interface OtaState {
|
|||
|
||||
systemUpdateProgress: number;
|
||||
systemUpdatedAt: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UpdateState {
|
||||
isUpdatePending: boolean;
|
||||
|
|
@ -605,11 +623,14 @@ export interface UpdateState {
|
|||
otaState: OtaState;
|
||||
setOtaState: (state: OtaState) => void;
|
||||
|
||||
modalView: UpdateModalViews
|
||||
modalView: UpdateModalViews;
|
||||
setModalView: (view: UpdateModalViews) => void;
|
||||
|
||||
updateErrorMessage: string | null;
|
||||
setUpdateErrorMessage: (errorMessage: string) => void;
|
||||
|
||||
shouldReload: boolean;
|
||||
setShouldReload: (reloadRequired: boolean) => void;
|
||||
}
|
||||
|
||||
export const useUpdateStore = create<UpdateState>(set => ({
|
||||
|
|
@ -645,12 +666,14 @@ export const useUpdateStore = create<UpdateState>(set => ({
|
|||
setModalView: (view: UpdateModalViews) => set({ modalView: view }),
|
||||
|
||||
updateErrorMessage: null,
|
||||
setUpdateErrorMessage: (errorMessage: string) => set({ updateErrorMessage: errorMessage }),
|
||||
setUpdateErrorMessage: (errorMessage: string) =>
|
||||
set({ updateErrorMessage: errorMessage }),
|
||||
|
||||
shouldReload: false,
|
||||
setShouldReload: (reloadRequired: boolean) => set({ shouldReload: reloadRequired }),
|
||||
}));
|
||||
|
||||
export type UsbConfigModalViews =
|
||||
| "updateUsbConfig"
|
||||
| "updateUsbConfigSuccess";
|
||||
export type UsbConfigModalViews = "updateUsbConfig" | "updateUsbConfigSuccess";
|
||||
|
||||
export interface UsbConfigModalState {
|
||||
modalView: UsbConfigModalViews;
|
||||
|
|
@ -760,6 +783,11 @@ export interface IPv6Address {
|
|||
flag_tentative?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicIP {
|
||||
ip: string;
|
||||
last_updated: Date;
|
||||
}
|
||||
|
||||
export interface NetworkState {
|
||||
interface_name?: string;
|
||||
mac_address?: string;
|
||||
|
|
@ -1003,5 +1031,17 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
|
|||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export interface FailsafeModeState {
|
||||
isFailsafeMode: boolean;
|
||||
reason: string; // "video", "network", etc.
|
||||
setFailsafeMode: (active: boolean, reason: string) => void;
|
||||
}
|
||||
|
||||
export const useFailsafeModeStore = create<FailsafeModeState>(set => ({
|
||||
isFailsafeMode: false,
|
||||
reason: "",
|
||||
setFailsafeMode: (active, reason) => set({ isFailsafeMode: active, reason }),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect } from "react";
|
||||
|
||||
import { useRTCStore } from "@hooks/stores";
|
||||
import { useRTCStore, useFailsafeModeStore } from "@hooks/stores";
|
||||
|
||||
export interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
|
|
@ -34,12 +34,51 @@ export const RpcMethodNotFound = -32601;
|
|||
const callbackStore = new Map<number | string, (resp: JsonRpcResponse) => void>();
|
||||
let requestCounter = 0;
|
||||
|
||||
// Map of blocked RPC methods by failsafe reason
|
||||
const blockedMethodsByReason: Record<string, string[]> = {
|
||||
video: [
|
||||
'setStreamQualityFactor',
|
||||
'getEDID',
|
||||
'setEDID',
|
||||
'getVideoLogStatus',
|
||||
'setDisplayRotation',
|
||||
'getVideoSleepMode',
|
||||
'setVideoSleepMode',
|
||||
'getVideoState',
|
||||
],
|
||||
};
|
||||
|
||||
export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
||||
const { rpcDataChannel } = useRTCStore();
|
||||
const { isFailsafeMode, reason } = useFailsafeModeStore();
|
||||
|
||||
const send = useCallback(
|
||||
async (method: string, params: unknown, callback?: (resp: JsonRpcResponse) => void) => {
|
||||
if (rpcDataChannel?.readyState !== "open") return;
|
||||
|
||||
// Check if method is blocked in failsafe mode
|
||||
if (isFailsafeMode && reason) {
|
||||
const blockedMethods = blockedMethodsByReason[reason] || [];
|
||||
if (blockedMethods.includes(method)) {
|
||||
console.warn(`RPC method "${method}" is blocked in failsafe mode (reason: ${reason})`);
|
||||
|
||||
// Call callback with error if provided
|
||||
if (callback) {
|
||||
const errorResponse: JsonRpcErrorResponse = {
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32000,
|
||||
message: "Method unavailable in failsafe mode",
|
||||
data: `This feature is unavailable while in failsafe mode (${reason})`,
|
||||
},
|
||||
id: requestCounter + 1,
|
||||
};
|
||||
callback(errorResponse);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
requestCounter++;
|
||||
const payload = { jsonrpc: "2.0", method, params, id: requestCounter };
|
||||
// Store the callback if it exists
|
||||
|
|
@ -47,7 +86,7 @@ export function useJsonRpc(onRequest?: (payload: JsonRpcRequest) => void) {
|
|||
|
||||
rpcDataChannel.send(JSON.stringify(payload));
|
||||
},
|
||||
[rpcDataChannel]
|
||||
[rpcDataChannel, isFailsafeMode, reason]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -363,3 +363,11 @@ video::-webkit-media-controls {
|
|||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.diagonal-stripes {
|
||||
background: repeating-linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 0, 0, 0.1) 0 12px, /* red-50 with 20% opacity */
|
||||
transparent 12px 24px
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { TextAreaWithLabel } from "@components/TextArea";
|
|||
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();
|
||||
|
|
@ -311,8 +312,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();
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,51 @@
|
|||
import { useCallback } from "react";
|
||||
import { useCallback , useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { Button } from "@components/Button";
|
||||
import { useFailsafeModeStore } from "@/hooks/stores";
|
||||
import { sleep } from "@/utils";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
import LoadingSpinner from "../components/LoadingSpinner";
|
||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||
|
||||
// Time to wait after initiating reboot before redirecting to home
|
||||
const REBOOT_REDIRECT_DELAY_MS = 5000;
|
||||
|
||||
export default function SettingsGeneralRebootRoute() {
|
||||
const navigate = useNavigate();
|
||||
const { send } = useJsonRpc();
|
||||
const [isRebooting, setIsRebooting] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const { setFailsafeMode } = useFailsafeModeStore();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
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]);
|
||||
const onConfirmUpdate = useCallback(async () => {
|
||||
setIsRebooting(true);
|
||||
send("reboot", { force: true });
|
||||
|
||||
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
|
||||
await new Promise(resolve => setTimeout(resolve, REBOOT_REDIRECT_DELAY_MS));
|
||||
setFailsafeMode(false, "");
|
||||
navigateTo("/");
|
||||
}, [navigateTo, send, setFailsafeMode]);
|
||||
|
||||
return <Dialog isRebooting={isRebooting} onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
isRebooting,
|
||||
onClose,
|
||||
onConfirmUpdate,
|
||||
}: Readonly<{
|
||||
isRebooting: boolean;
|
||||
onClose: () => void;
|
||||
onConfirmUpdate: () => void;
|
||||
}>) {
|
||||
|
|
@ -34,6 +54,7 @@ export function Dialog({
|
|||
<div className="pointer-events-auto relative mx-auto text-left">
|
||||
<div>
|
||||
<ConfirmationBox
|
||||
isRebooting={isRebooting}
|
||||
onYes={onConfirmUpdate}
|
||||
onNo={onClose}
|
||||
/>
|
||||
|
|
@ -43,9 +64,11 @@ export function Dialog({
|
|||
}
|
||||
|
||||
function ConfirmationBox({
|
||||
isRebooting,
|
||||
onYes,
|
||||
onNo,
|
||||
}: {
|
||||
isRebooting: boolean;
|
||||
onYes: () => void;
|
||||
onNo: () => void;
|
||||
}) {
|
||||
|
|
@ -58,11 +81,16 @@ function ConfirmationBox({
|
|||
<p className="text-sm text-slate-600 dark:text-slate-300">
|
||||
{m.general_reboot_description()}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<Button size="SM" theme="light" text={m.general_reboot_yes_button()} onClick={onYes} />
|
||||
<Button size="SM" theme="blank" text={m.general_reboot_no_button()} onClick={onNo} />
|
||||
</div>
|
||||
{isRebooting ? (
|
||||
<div className="mt-4 flex items-center justify-center">
|
||||
<LoadingSpinner className="h-6 w-6 text-blue-700 dark:text-blue-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex gap-x-2">
|
||||
<Button size="SM" theme="light" text={m.general_reboot_yes_button()} onClick={onYes} />
|
||||
<Button size="SM" theme="blank" text={m.general_reboot_no_button()} onClick={onNo} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -18,18 +18,24 @@ export default function SettingsGeneralUpdateRoute() {
|
|||
const location = useLocation();
|
||||
const { updateSuccess } = location.state || {};
|
||||
|
||||
const { setModalView, otaState } = useUpdateStore();
|
||||
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
const onClose = useCallback(async () => {
|
||||
navigate(".."); // back to the devices.$id.settings page
|
||||
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
|
||||
}, [navigate]);
|
||||
|
||||
if (shouldReload) {
|
||||
setShouldReload(false);
|
||||
await sleep(1000); // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
|
||||
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
|
||||
}
|
||||
}, [navigate, setShouldReload, shouldReload]);
|
||||
|
||||
const onConfirmUpdate = useCallback(() => {
|
||||
setShouldReload(true);
|
||||
send("tryUpdate", {});
|
||||
setModalView("updating");
|
||||
}, [send, setModalView]);
|
||||
}, [send, setModalView, setShouldReload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otaState.updating) {
|
||||
|
|
@ -131,6 +137,7 @@ function LoadingState({
|
|||
const { setModalView } = useUpdateStore();
|
||||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import dayjs from "dayjs";
|
|||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import validator from "validator";
|
||||
|
||||
import PublicIPCard from "@components/PublicIPCard";
|
||||
import { NetworkSettings, NetworkState, useNetworkStateStore, useRTCStore } from "@hooks/stores";
|
||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import AutoHeight from "@components/AutoHeight";
|
||||
|
|
@ -460,6 +461,8 @@ export default function SettingsNetworkRoute() {
|
|||
/>
|
||||
</SettingsItem>
|
||||
|
||||
<PublicIPCard />
|
||||
|
||||
<div>
|
||||
<AutoHeight>
|
||||
{formState.isLoading ? (
|
||||
|
|
@ -540,25 +543,25 @@ export default function SettingsNetworkRoute() {
|
|||
</AutoHeight>
|
||||
</div>
|
||||
|
||||
{ isLLDPAvailable &&
|
||||
(
|
||||
<div className="hidden space-y-4">
|
||||
<SettingsItem
|
||||
title={m.network_ll_dp_title()}
|
||||
description={m.network_ll_dp_description()}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
options={[
|
||||
{ value: "disabled", label: m.network_ll_dp_disabled() },
|
||||
{ value: "basic", label: m.network_ll_dp_basic() },
|
||||
{ value: "all", label: m.network_ll_dp_all() },
|
||||
]}
|
||||
{...register("lldp_mode")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
)
|
||||
{isLLDPAvailable &&
|
||||
(
|
||||
<div className="hidden space-y-4">
|
||||
<SettingsItem
|
||||
title={m.network_ll_dp_title()}
|
||||
description={m.network_ll_dp_description()}
|
||||
>
|
||||
<SelectMenuBasic
|
||||
size="SM"
|
||||
options={[
|
||||
{ value: "disabled", label: m.network_ll_dp_disabled() },
|
||||
{ value: "basic", label: m.network_ll_dp_basic() },
|
||||
{ value: "all", label: m.network_ll_dp_all() },
|
||||
]}
|
||||
{...register("lldp_mode")}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<div className="animate-fadeInStill animation-duration-300">
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ import {
|
|||
} from "react-icons/lu";
|
||||
|
||||
import { cx } from "@/cva.config";
|
||||
import { useUiStore } from "@hooks/stores";
|
||||
import { useUiStore, useFailsafeModeStore } from "@hooks/stores";
|
||||
import Card from "@components/Card";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import { FailsafeModeBanner } from "@components/FailSafeModeBanner";
|
||||
import { FeatureFlag } from "@components/FeatureFlag";
|
||||
import { LinkButton } from "@components/Button";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
/* TODO: Migrate to using URLs instead of the global state. To simplify the refactoring, we'll keep the global state for now. */
|
||||
|
|
@ -31,6 +32,8 @@ export default function SettingsRoute() {
|
|||
const [showLeftGradient, setShowLeftGradient] = useState(false);
|
||||
const [showRightGradient, setShowRightGradient] = useState(false);
|
||||
const { width = 0 } = useResizeObserver({ ref: scrollContainerRef as React.RefObject<HTMLDivElement> });
|
||||
const { isFailsafeMode: isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore();
|
||||
const isVideoDisabled = isFailsafeMode && failsafeReason === "video";
|
||||
|
||||
// Handle scroll position to show/hide gradients
|
||||
const handleScroll = () => {
|
||||
|
|
@ -159,18 +162,23 @@ export default function SettingsRoute() {
|
|||
</NavLink>
|
||||
</div>
|
||||
</FeatureFlag>
|
||||
<div className="shrink-0">
|
||||
<div className={cx("shrink-0", {
|
||||
"opacity-50 cursor-not-allowed": isVideoDisabled
|
||||
})}>
|
||||
<NavLink
|
||||
to="video"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
>
|
||||
className={({ isActive }) => cx(isActive ? "active" : "", {
|
||||
"pointer-events-none": isVideoDisabled
|
||||
})} >
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
|
||||
<LuVideo className="h-4 w-4 shrink-0" />
|
||||
<h1>{m.settings_video()}</h1>
|
||||
</div>
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<div className={cx("shrink-0", {
|
||||
"opacity-50 cursor-not-allowed": isVideoDisabled
|
||||
})}>
|
||||
<NavLink
|
||||
to="audio"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
|
|
@ -184,7 +192,9 @@ export default function SettingsRoute() {
|
|||
<div className="shrink-0">
|
||||
<NavLink
|
||||
to="hardware"
|
||||
className={({ isActive }) => (isActive ? "active" : "")}
|
||||
className={({ isActive }) => cx(isActive ? "active" : "", {
|
||||
"pointer-events-none": isVideoDisabled
|
||||
})}
|
||||
>
|
||||
<div className="flex items-center gap-x-2 rounded-md px-2.5 py-2.5 text-sm transition-colors hover:bg-slate-100 dark:hover:bg-slate-700 in-[.active]:bg-blue-50 in-[.active]:text-blue-700! md:in-[.active]:bg-transparent dark:in-[.active]:bg-blue-900 dark:in-[.active]:text-blue-200! dark:md:in-[.active]:bg-transparent">
|
||||
<LuCpu className="h-4 w-4 shrink-0" />
|
||||
|
|
@ -250,8 +260,8 @@ export default function SettingsRoute() {
|
|||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="w-full md:col-span-6">
|
||||
{/* <AutoHeight> */}
|
||||
<div className="w-full md:col-span-6 space-y-4">
|
||||
{isFailsafeMode && failsafeReason && <FailsafeModeBanner reason={failsafeReason} />}
|
||||
<Card className="dark:bg-slate-800">
|
||||
<div
|
||||
className="space-y-4 px-8 py-6"
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
useUpdateStore,
|
||||
useVideoStore,
|
||||
VideoState,
|
||||
useFailsafeModeStore,
|
||||
} from "@hooks/stores";
|
||||
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
|
||||
|
|
@ -44,6 +45,7 @@ const ConnectionStatsSidebar = lazy(() => import('@components/sidebar/connection
|
|||
const Terminal = lazy(() => import('@components/Terminal'));
|
||||
const UpdateInProgressStatusCard = lazy(() => import("@components/UpdateInProgressStatusCard"));
|
||||
import Modal from "@components/Modal";
|
||||
import { FailSafeModeOverlay } from "@components/FailSafeModeOverlay";
|
||||
import {
|
||||
ConnectionFailedOverlay,
|
||||
LoadingConnectionOverlay,
|
||||
|
|
@ -103,6 +105,7 @@ const loader: LoaderFunction = ({ params }: LoaderFunctionArgs) => {
|
|||
return isOnDevice ? deviceLoader() : cloudLoader(params);
|
||||
};
|
||||
|
||||
|
||||
export default function KvmIdRoute() {
|
||||
const loaderResp = useLoaderData();
|
||||
// Depending on the mode, we set the appropriate variables
|
||||
|
|
@ -677,8 +680,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(() => {
|
||||
|
|
@ -731,14 +735,15 @@ export default function KvmIdRoute() {
|
|||
});
|
||||
}, 10000);
|
||||
|
||||
const { setNetworkState } = useNetworkStateStore();
|
||||
const { setNetworkState } = useNetworkStateStore();
|
||||
const { setHdmiState } = useVideoStore();
|
||||
const {
|
||||
keyboardLedState, setKeyboardLedState,
|
||||
keysDownState, setKeysDownState,
|
||||
setUsbState,
|
||||
} = useHidStore();
|
||||
const { setHidRpcDisabled } = useRTCStore();
|
||||
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
|
||||
const { setFailsafeMode } = useFailsafeModeStore();
|
||||
|
||||
const [hasUpdated, setHasUpdated] = useState(false);
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
|
@ -815,6 +820,12 @@ export default function KvmIdRoute() {
|
|||
setRebootState({ isRebooting: true, postRebootAction });
|
||||
navigateTo("/");
|
||||
}
|
||||
|
||||
if (resp.method === "failsafeMode") {
|
||||
const { active, reason } = resp.params as { active: boolean; reason: string };
|
||||
console.debug("Setting failsafe mode", { active, reason });
|
||||
setFailsafeMode(active, reason);
|
||||
}
|
||||
}
|
||||
|
||||
const { send } = useJsonRpc(onJsonRpcRequest);
|
||||
|
|
@ -954,6 +965,8 @@ export default function KvmIdRoute() {
|
|||
getLocalVersion();
|
||||
}, [appVersion, getLocalVersion]);
|
||||
|
||||
const { isFailsafeMode, reason: failsafeReason } = useFailsafeModeStore();
|
||||
|
||||
const ConnectionStatusElement = useMemo(() => {
|
||||
const isOtherSession = location.pathname.includes("other-session");
|
||||
if (isOtherSession) return null;
|
||||
|
|
@ -1029,13 +1042,15 @@ export default function KvmIdRoute() {
|
|||
/>
|
||||
|
||||
<div className="relative flex h-full w-full overflow-hidden">
|
||||
<WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />
|
||||
{(isFailsafeMode && failsafeReason === "video") ? null : <WebRTCVideo hasConnectionIssues={!!ConnectionStatusElement} />}
|
||||
<div
|
||||
style={{ animationDuration: "500ms" }}
|
||||
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||
{isFailsafeMode && failsafeReason ? (
|
||||
<FailSafeModeOverlay reason={failsafeReason} />
|
||||
) : !!ConnectionStatusElement && ConnectionStatusElement}
|
||||
</div>
|
||||
</div>
|
||||
<SidebarContainer sidebarView={sidebarView} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export const CLOUD_API = import.meta.env.VITE_CLOUD_API;
|
||||
|
||||
export const DOWNGRADE_VERSION = import.meta.env.VITE_DOWNGRADE_VERSION || "0.4.8";
|
||||
|
||||
// In device mode, an empty string uses the current hostname (the JetKVM device's IP) as the API endpoint
|
||||
export const DEVICE_API = "";
|
||||
|
||||
|
|
|
|||
|
|
@ -24,17 +24,47 @@ export interface JsonRpcCallResponse<T = unknown> {
|
|||
let rpcCallCounter = 0;
|
||||
|
||||
// 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<RTCDataChannel> {
|
||||
const pollInterval = 100;
|
||||
let lastSeenChannel: RTCDataChannel | null = null;
|
||||
|
||||
while (!signal.aborted) {
|
||||
const state = useRTCStore.getState();
|
||||
if (state.rpcDataChannel?.readyState === "open") {
|
||||
return state.rpcDataChannel;
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
@ -97,25 +127,26 @@ export async function callJsonRpc<T = unknown>(
|
|||
const timeout = options.attemptTimeoutMs || 5000;
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = setTimeout(() => abortController.abort(), timeout);
|
||||
|
||||
// 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<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
// Wait for RTC readiness
|
||||
const rpcDataChannel = await waitForRtcReady(abortController.signal);
|
||||
// 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<T>(
|
||||
rpcDataChannel,
|
||||
options,
|
||||
abortController.signal,
|
||||
rpcAbortController.signal,
|
||||
);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Retry on error if attempts remain
|
||||
if (response.error && attempt < maxAttempts - 1) {
|
||||
await sleep(backoffMs);
|
||||
|
|
@ -124,8 +155,6 @@ export async function callJsonRpc<T = unknown>(
|
|||
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Retry on timeout/error if attempts remain
|
||||
if (attempt < maxAttempts - 1) {
|
||||
await sleep(backoffMs);
|
||||
|
|
@ -135,6 +164,10 @@ export async function callJsonRpc<T = unknown>(
|
|||
throw error instanceof Error
|
||||
? error
|
||||
: new Error(`JSON-RPC call failed after ${timeout}ms`);
|
||||
} finally {
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
video.go
1
video.go
|
|
@ -27,6 +27,7 @@ func triggerVideoStateUpdate() {
|
|||
}
|
||||
|
||||
func rpcGetVideoState() (native.VideoState, error) {
|
||||
notifyFailsafeMode(currentSession)
|
||||
return lastVideoState, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
24
web.go
24
web.go
|
|
@ -8,6 +8,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/pprof"
|
||||
"path/filepath"
|
||||
|
|
@ -184,6 +185,8 @@ func setupRouter() *gin.Engine {
|
|||
protected.PUT("/auth/password-local", handleUpdatePassword)
|
||||
protected.DELETE("/auth/local-password", handleDeletePassword)
|
||||
protected.POST("/storage/upload", handleUploadHttp)
|
||||
|
||||
protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket)
|
||||
}
|
||||
|
||||
// Catch-all route for SPA
|
||||
|
|
@ -341,7 +344,6 @@ func handleWebRTCSignalWsMessages(
|
|||
|
||||
l.Trace().Msg("sending ping frame")
|
||||
err := wsCon.Ping(runCtx)
|
||||
|
||||
if err != nil {
|
||||
l.Warn().Str("error", err.Error()).Msg("websocket ping error")
|
||||
cancelRun()
|
||||
|
|
@ -807,3 +809,23 @@ func handleSetup(c *gin.Context) {
|
|||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
|
||||
}
|
||||
|
||||
func handleSendWOLMagicPacket(c *gin.Context) {
|
||||
inputMacAddr := c.Param("mac-addr")
|
||||
macAddr, err := net.ParseMAC(inputMacAddr)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("inputMacAddr", inputMacAddr).Msg("Invalid MAC address provided")
|
||||
c.String(http.StatusBadRequest, "Invalid mac address provided")
|
||||
return
|
||||
}
|
||||
|
||||
macAddrString := macAddr.String()
|
||||
err = rpcSendWOLMagicPacket(macAddrString)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Str("macAddrString", macAddrString).Msg("Failed to send WOL magic packet")
|
||||
c.String(http.StatusInternalServerError, "Failed to send WOL to %s: %v", macAddrString, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.String(http.StatusOK, "WOL sent to %s ", macAddr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
triggerOTAStateUpdate()
|
||||
triggerVideoStateUpdate()
|
||||
triggerUSBStateUpdate()
|
||||
notifyFailsafeMode(session)
|
||||
case "terminal":
|
||||
handleTerminalChannel(d)
|
||||
case "serial":
|
||||
|
|
@ -428,10 +429,12 @@ func newSession(config SessionConfig) (*Session, error) {
|
|||
}
|
||||
|
||||
func onActiveSessionsChanged() {
|
||||
notifyFailsafeMode(currentSession)
|
||||
requestDisplayUpdate(true, "active_sessions_changed")
|
||||
}
|
||||
|
||||
func onFirstSessionConnected() {
|
||||
notifyFailsafeMode(currentSession)
|
||||
_ = nativeInstance.VideoStart()
|
||||
onWebRTCConnect()
|
||||
stopVideoSleepModeTicker()
|
||||
|
|
|
|||
Loading…
Reference in New Issue