Merge branch 'dev' into fix/faster-paste

This commit is contained in:
Marc Brooks 2025-11-17 22:25:50 -06:00 committed by GitHub
commit 79e9f2f405
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 1602 additions and 302 deletions

View File

@ -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
}

View File

@ -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")

107
failsafe.go Normal file
View File

@ -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
}

View File

@ -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()

View File

@ -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()

View File

@ -932,6 +932,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)
}
@ -1311,4 +1315,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
View File

@ -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")

View File

@ -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
@ -50,6 +56,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)
}
@ -60,7 +67,6 @@ func Main() {
// Initialize mDNS
if err := initMdns(); err != nil {
logger.Error().Err(err).Msg("failed to initialize mDNS")
os.Exit(1)
}
initPrometheus()
@ -126,6 +132,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)

View File

@ -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(),

View File

@ -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()
}

160
pkg/myip/check.go Normal file
View File

@ -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)
}

196
pkg/myip/ip.go Normal file
View File

@ -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
}
}
}

View File

@ -896,5 +896,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}"
}

359
ui/package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "kvm-ui",
"version": "2025.11.05.2130",
"version": "2025.11.07.2130",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "kvm-ui",
"version": "2025.11.05.2130",
"version": "2025.11.07.2130",
"dependencies": {
"@headlessui/react": "^2.2.9",
"@headlessui/tailwindcss": "^0.2.2",
@ -52,9 +52,9 @@
"@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",
@ -74,7 +74,7 @@
"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.2.0",
"vite-tsconfig-paths": "^5.1.4"
@ -1437,9 +1437,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz",
"integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.53.1.tgz",
"integrity": "sha512-bxZtughE4VNVJlL1RdoSE545kc4JxL7op57KKoi59/gwuU5rV6jLWFXXc8jwgFoT6vtj+ZjO+Z2C5nrY0Cl6wA==",
"cpu": [
"arm"
],
@ -1450,9 +1450,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz",
"integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.53.1.tgz",
"integrity": "sha512-44a1hreb02cAAfAKmZfXVercPFaDjqXCK+iKeVOlJ9ltvnO6QqsBHgKVPTu+MJHSLLeMEUbeG2qiDYgbFPU48g==",
"cpu": [
"arm64"
],
@ -1463,9 +1463,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz",
"integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.53.1.tgz",
"integrity": "sha512-usmzIgD0rf1syoOZ2WZvy8YpXK5G1V3btm3QZddoGSa6mOgfXWkkv+642bfUUldomgrbiLQGrPryb7DXLovPWQ==",
"cpu": [
"arm64"
],
@ -1476,9 +1476,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz",
"integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.53.1.tgz",
"integrity": "sha512-is3r/k4vig2Gt8mKtTlzzyaSQ+hd87kDxiN3uDSDwggJLUV56Umli6OoL+/YZa/KvtdrdyNfMKHzL/P4siOOmg==",
"cpu": [
"x64"
],
@ -1489,9 +1489,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz",
"integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.53.1.tgz",
"integrity": "sha512-QJ1ksgp/bDJkZB4daldVmHaEQkG4r8PUXitCOC2WRmRaSaHx5RwPoI3DHVfXKwDkB+Sk6auFI/+JHacTekPRSw==",
"cpu": [
"arm64"
],
@ -1502,9 +1502,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz",
"integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.53.1.tgz",
"integrity": "sha512-J6ma5xgAzvqsnU6a0+jgGX/gvoGokqpkx6zY4cWizRrm0ffhHDpJKQgC8dtDb3+MqfZDIqs64REbfHDMzxLMqQ==",
"cpu": [
"x64"
],
@ -1515,9 +1515,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz",
"integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.53.1.tgz",
"integrity": "sha512-JzWRR41o2U3/KMNKRuZNsDUAcAVUYhsPuMlx5RUldw0E4lvSIXFUwejtYz1HJXohUmqs/M6BBJAUBzKXZVddbg==",
"cpu": [
"arm"
],
@ -1528,9 +1528,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz",
"integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.53.1.tgz",
"integrity": "sha512-L8kRIrnfMrEoHLHtHn+4uYA52fiLDEDyezgxZtGUTiII/yb04Krq+vk3P2Try+Vya9LeCE9ZHU8CXD6J9EhzHQ==",
"cpu": [
"arm"
],
@ -1541,9 +1541,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz",
"integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.53.1.tgz",
"integrity": "sha512-ysAc0MFRV+WtQ8li8hi3EoFi7us6d1UzaS/+Dp7FYZfg3NdDljGMoVyiIp6Ucz7uhlYDBZ/zt6XI0YEZbUO11Q==",
"cpu": [
"arm64"
],
@ -1554,9 +1554,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz",
"integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.53.1.tgz",
"integrity": "sha512-UV6l9MJpDbDZZ/fJvqNcvO1PcivGEf1AvKuTcHoLjVZVFeAMygnamCTDikCVMRnA+qJe+B3pSbgX2+lBMqgBhA==",
"cpu": [
"arm64"
],
@ -1567,9 +1567,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz",
"integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.53.1.tgz",
"integrity": "sha512-UDUtelEprkA85g95Q+nj3Xf0M4hHa4DiJ+3P3h4BuGliY4NReYYqwlc0Y8ICLjN4+uIgCEvaygYlpf0hUj90Yg==",
"cpu": [
"loong64"
],
@ -1580,9 +1580,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz",
"integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.53.1.tgz",
"integrity": "sha512-vrRn+BYhEtNOte/zbc2wAUQReJXxEx2URfTol6OEfY2zFEUK92pkFBSXRylDM7aHi+YqEPJt9/ABYzmcrS4SgQ==",
"cpu": [
"ppc64"
],
@ -1593,9 +1593,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz",
"integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.53.1.tgz",
"integrity": "sha512-gto/1CxHyi4A7YqZZNznQYrVlPSaodOBPKM+6xcDSCMVZN/Fzb4K+AIkNz/1yAYz9h3Ng+e2fY9H6bgawVq17w==",
"cpu": [
"riscv64"
],
@ -1606,9 +1606,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz",
"integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.53.1.tgz",
"integrity": "sha512-KZ6Vx7jAw3aLNjFR8eYVcQVdFa/cvBzDNRFM3z7XhNNunWjA03eUrEwJYPk0G8V7Gs08IThFKcAPS4WY/ybIrQ==",
"cpu": [
"riscv64"
],
@ -1619,9 +1619,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz",
"integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.53.1.tgz",
"integrity": "sha512-HvEixy2s/rWNgpwyKpXJcHmE7om1M89hxBTBi9Fs6zVuLU4gOrEMQNbNsN/tBVIMbLyysz/iwNiGtMOpLAOlvA==",
"cpu": [
"s390x"
],
@ -1632,9 +1632,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz",
"integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.1.tgz",
"integrity": "sha512-E/n8x2MSjAQgjj9IixO4UeEUeqXLtiA7pyoXCFYLuXpBA/t2hnbIdxHfA7kK9BFsYAoNU4st1rHYdldl8dTqGA==",
"cpu": [
"x64"
],
@ -1645,9 +1645,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz",
"integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.53.1.tgz",
"integrity": "sha512-IhJ087PbLOQXCN6Ui/3FUkI9pWNZe/Z7rEIVOzMsOs1/HSAECCvSZ7PkIbkNqL/AZn6WbZvnoVZw/qwqYMo4/w==",
"cpu": [
"x64"
],
@ -1658,9 +1658,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz",
"integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.53.1.tgz",
"integrity": "sha512-0++oPNgLJHBblreu0SFM7b3mAsBJBTY0Ksrmu9N6ZVrPiTkRgda52mWR7TKhHAsUb9noCjFvAw9l6ZO1yzaVbA==",
"cpu": [
"arm64"
],
@ -1671,9 +1671,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz",
"integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.53.1.tgz",
"integrity": "sha512-VJXivz61c5uVdbmitLkDlbcTk9Or43YC2QVLRkqp86QoeFSqI81bNgjhttqhKNMKnQMWnecOCm7lZz4s+WLGpQ==",
"cpu": [
"arm64"
],
@ -1684,9 +1684,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz",
"integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.53.1.tgz",
"integrity": "sha512-NmZPVTUOitCXUH6erJDzTQ/jotYw4CnkMDjCYRxNHVD9bNyfrGoIse684F9okwzKCV4AIHRbUkeTBc9F2OOH5Q==",
"cpu": [
"ia32"
],
@ -1697,9 +1697,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz",
"integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.53.1.tgz",
"integrity": "sha512-2SNj7COIdAf6yliSpLdLG8BEsp5lgzRehgfkP0Av8zKfQFKku6JcvbobvHASPJu4f3BFxej5g+HuQPvqPhHvpQ==",
"cpu": [
"x64"
],
@ -1710,9 +1710,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz",
"integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.53.1.tgz",
"integrity": "sha512-rLarc1Ofcs3DHtgSzFO31pZsCh8g05R2azN1q3fF+H423Co87My0R+tazOEvYVKXSLh8C4LerMK41/K7wlklcg==",
"cpu": [
"x64"
],
@ -2006,9 +2006,9 @@
}
},
"node_modules/@tailwindcss/node": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.17.tgz",
"integrity": "sha512-csIkHIgLb3JisEFQ0vxr2Y57GUNYh447C8xzwj89U/8fdW8LhProdxvnVH6U8M2Y73QKiTIH+LWbK3V2BBZsAg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2016,39 +2016,39 @@
"enhanced-resolve": "^5.18.3",
"jiti": "^2.6.1",
"lightningcss": "1.30.2",
"magic-string": "^0.30.19",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.17"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.17.tgz",
"integrity": "sha512-F0F7d01fmkQhsTjXezGBLdrl1KresJTcI3DB8EkScCldyKp3Msz4hub4uyYaVnk88BAS1g5DQjjF6F5qczheLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
"@tailwindcss/oxide-darwin-x64": "4.1.16",
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
"@tailwindcss/oxide-android-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-arm64": "4.1.17",
"@tailwindcss/oxide-darwin-x64": "4.1.17",
"@tailwindcss/oxide-freebsd-x64": "4.1.17",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.17",
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-arm64-musl": "4.1.17",
"@tailwindcss/oxide-linux-x64-gnu": "4.1.17",
"@tailwindcss/oxide-linux-x64-musl": "4.1.17",
"@tailwindcss/oxide-wasm32-wasi": "4.1.17",
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.17",
"@tailwindcss/oxide-win32-x64-msvc": "4.1.17"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.17.tgz",
"integrity": "sha512-BMqpkJHgOZ5z78qqiGE6ZIRExyaHyuxjgrJ6eBO5+hfrfGkuya0lYfw8fRHG77gdTjWkNWEEm+qeG2cDMxArLQ==",
"cpu": [
"arm64"
],
@ -2063,9 +2063,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.17.tgz",
"integrity": "sha512-EquyumkQweUBNk1zGEU/wfZo2qkp/nQKRZM8bUYO0J+Lums5+wl2CcG1f9BgAjn/u9pJzdYddHWBiFXJTcxmOg==",
"cpu": [
"arm64"
],
@ -2080,9 +2080,9 @@
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.17.tgz",
"integrity": "sha512-gdhEPLzke2Pog8s12oADwYu0IAw04Y2tlmgVzIN0+046ytcgx8uZmCzEg4VcQh+AHKiS7xaL8kGo/QTiNEGRog==",
"cpu": [
"x64"
],
@ -2097,9 +2097,9 @@
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.17.tgz",
"integrity": "sha512-hxGS81KskMxML9DXsaXT1H0DyA+ZBIbyG/sSAjWNe2EDl7TkPOBI42GBV3u38itzGUOmFfCzk1iAjDXds8Oh0g==",
"cpu": [
"x64"
],
@ -2114,9 +2114,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.17.tgz",
"integrity": "sha512-k7jWk5E3ldAdw0cNglhjSgv501u7yrMf8oeZ0cElhxU6Y2o7f8yqelOp3fhf7evjIS6ujTI3U8pKUXV2I4iXHQ==",
"cpu": [
"arm"
],
@ -2131,9 +2131,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.17.tgz",
"integrity": "sha512-HVDOm/mxK6+TbARwdW17WrgDYEGzmoYayrCgmLEw7FxTPLcp/glBisuyWkFz/jb7ZfiAXAXUACfyItn+nTgsdQ==",
"cpu": [
"arm64"
],
@ -2148,9 +2148,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.17.tgz",
"integrity": "sha512-HvZLfGr42i5anKtIeQzxdkw/wPqIbpeZqe7vd3V9vI3RQxe3xU1fLjss0TjyhxWcBaipk7NYwSrwTwK1hJARMg==",
"cpu": [
"arm64"
],
@ -2165,9 +2165,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.17.tgz",
"integrity": "sha512-M3XZuORCGB7VPOEDH+nzpJ21XPvK5PyjlkSFkFziNHGLc5d6g3di2McAAblmaSUNl8IOmzYwLx9NsE7bplNkwQ==",
"cpu": [
"x64"
],
@ -2182,9 +2182,9 @@
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.17.tgz",
"integrity": "sha512-k7f+pf9eXLEey4pBlw+8dgfJHY4PZ5qOUFDyNf7SI6lHjQ9Zt7+NcscjpwdCEbYi6FI5c2KDTDWyf2iHcCSyyQ==",
"cpu": [
"x64"
],
@ -2199,9 +2199,9 @@
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.17.tgz",
"integrity": "sha512-cEytGqSSoy7zK4JRWiTCx43FsKP/zGr0CsuMawhH67ONlH+T79VteQeJQRO/X7L0juEUA8ZyuYikcRBf0vsxhg==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
@ -2217,8 +2217,8 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@emnapi/core": "^1.6.0",
"@emnapi/runtime": "^1.6.0",
"@emnapi/wasi-threads": "^1.1.0",
"@napi-rs/wasm-runtime": "^1.0.7",
"@tybys/wasm-util": "^0.10.1",
@ -2229,9 +2229,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.17.tgz",
"integrity": "sha512-JU5AHr7gKbZlOGvMdb4722/0aYbU+tN6lv1kONx0JK2cGsh7g148zVWLM0IKR3NeKLv+L90chBVYcJ8uJWbC9A==",
"cpu": [
"arm64"
],
@ -2246,9 +2246,9 @@
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.17.tgz",
"integrity": "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw==",
"cpu": [
"x64"
],
@ -2263,17 +2263,17 @@
}
},
"node_modules/@tailwindcss/postcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
"integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.17.tgz",
"integrity": "sha512-+nKl9N9mN5uJ+M7dBOOCzINw94MPstNR/GtIhz1fpZysxL/4a+No64jCBD6CPN+bIHWFx3KWuu8XJRrj/572Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"@tailwindcss/node": "4.1.17",
"@tailwindcss/oxide": "4.1.17",
"postcss": "^8.4.41",
"tailwindcss": "4.1.16"
"tailwindcss": "4.1.17"
}
},
"node_modules/@tailwindcss/typography": {
@ -2290,15 +2290,15 @@
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz",
"integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==",
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.17.tgz",
"integrity": "sha512-4+9w8ZHOiGnpcGI6z1TVVfWaX/koK7fKeSYF3qlYg2xpBtbteP2ddBxiarL+HVgfSJGeK5RIxRQmKm4rTJJAwA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.1.16",
"@tailwindcss/oxide": "4.1.16",
"tailwindcss": "4.1.16"
"@tailwindcss/node": "4.1.17",
"@tailwindcss/oxide": "4.1.17",
"tailwindcss": "4.1.17"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7"
@ -3221,9 +3221,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001753",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz",
"integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==",
"version": "1.0.30001754",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz",
"integrity": "sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==",
"dev": true,
"funding": [
{
@ -3695,9 +3695,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.245",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.245.tgz",
"integrity": "sha512-rdmGfW47ZhL/oWEJAY4qxRtdly2B98ooTJ0pdEI4jhVLZ6tNf8fPtov2wS1IRKwFJT92le3x4Knxiwzl7cPPpQ==",
"version": "1.5.248",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.248.tgz",
"integrity": "sha512-zsur2yunphlyAO4gIubdJEXCK6KOVvtpiuDfCIqbM9FjcnMYiyn0ICa3hWfPr0nc41zcLWobgy1iL7VvoOyA2Q==",
"dev": true,
"license": "ISC"
},
@ -6743,9 +6743,9 @@
}
},
"node_modules/rollup": {
"version": "4.52.5",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz",
"integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==",
"version": "4.53.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.1.tgz",
"integrity": "sha512-n2I0V0lN3E9cxxMqBCT3opWOiQBzRN7UG60z/WDKqdX2zHUS/39lezBcsckZFsV6fUTSnfqI7kHf60jDAPGKug==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@ -6758,28 +6758,28 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.52.5",
"@rollup/rollup-android-arm64": "4.52.5",
"@rollup/rollup-darwin-arm64": "4.52.5",
"@rollup/rollup-darwin-x64": "4.52.5",
"@rollup/rollup-freebsd-arm64": "4.52.5",
"@rollup/rollup-freebsd-x64": "4.52.5",
"@rollup/rollup-linux-arm-gnueabihf": "4.52.5",
"@rollup/rollup-linux-arm-musleabihf": "4.52.5",
"@rollup/rollup-linux-arm64-gnu": "4.52.5",
"@rollup/rollup-linux-arm64-musl": "4.52.5",
"@rollup/rollup-linux-loong64-gnu": "4.52.5",
"@rollup/rollup-linux-ppc64-gnu": "4.52.5",
"@rollup/rollup-linux-riscv64-gnu": "4.52.5",
"@rollup/rollup-linux-riscv64-musl": "4.52.5",
"@rollup/rollup-linux-s390x-gnu": "4.52.5",
"@rollup/rollup-linux-x64-gnu": "4.52.5",
"@rollup/rollup-linux-x64-musl": "4.52.5",
"@rollup/rollup-openharmony-arm64": "4.52.5",
"@rollup/rollup-win32-arm64-msvc": "4.52.5",
"@rollup/rollup-win32-ia32-msvc": "4.52.5",
"@rollup/rollup-win32-x64-gnu": "4.52.5",
"@rollup/rollup-win32-x64-msvc": "4.52.5",
"@rollup/rollup-android-arm-eabi": "4.53.1",
"@rollup/rollup-android-arm64": "4.53.1",
"@rollup/rollup-darwin-arm64": "4.53.1",
"@rollup/rollup-darwin-x64": "4.53.1",
"@rollup/rollup-freebsd-arm64": "4.53.1",
"@rollup/rollup-freebsd-x64": "4.53.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.53.1",
"@rollup/rollup-linux-arm-musleabihf": "4.53.1",
"@rollup/rollup-linux-arm64-gnu": "4.53.1",
"@rollup/rollup-linux-arm64-musl": "4.53.1",
"@rollup/rollup-linux-loong64-gnu": "4.53.1",
"@rollup/rollup-linux-ppc64-gnu": "4.53.1",
"@rollup/rollup-linux-riscv64-gnu": "4.53.1",
"@rollup/rollup-linux-riscv64-musl": "4.53.1",
"@rollup/rollup-linux-s390x-gnu": "4.53.1",
"@rollup/rollup-linux-x64-gnu": "4.53.1",
"@rollup/rollup-linux-x64-musl": "4.53.1",
"@rollup/rollup-openharmony-arm64": "4.53.1",
"@rollup/rollup-win32-arm64-msvc": "4.53.1",
"@rollup/rollup-win32-ia32-msvc": "4.53.1",
"@rollup/rollup-win32-x64-gnu": "4.53.1",
"@rollup/rollup-win32-x64-msvc": "4.53.1",
"fsevents": "~2.3.2"
}
},
@ -7230,11 +7230,10 @@
}
},
"node_modules/tailwindcss": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz",
"integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==",
"license": "MIT",
"peer": true
"version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",
"integrity": "sha512-j9Ee2YjuQqYT9bbRTfTZht9W/ytp5H+jJpZKiYdP/bpnXARAuELt9ofP0lPnmHjbga7SNQIxdTAXCmtKVYjN+Q==",
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.0",
@ -7650,9 +7649,9 @@
}
},
"node_modules/vite": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.0.tgz",
"integrity": "sha512-C/Naxf8H0pBx1PA4BdpT+c/5wdqI9ILMdwjSMILw7tVIh3JsxzZqdeTLmmdaoh5MYUEOyBnM9K3o0DzoZ/fe+w==",
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {

View File

@ -1,7 +1,7 @@
{
"name": "kvm-ui",
"private": true,
"version": "2025.11.05.2130",
"version": "2025.11.07.2130",
"type": "module",
"engines": {
"node": "^22.20.0"
@ -71,9 +71,9 @@
"@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",
@ -93,7 +93,7 @@
"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.2.0",
"vite-tsconfig-paths": "^5.1.4"

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -26,6 +26,7 @@ import { m } from "@localizations/messages.js";
export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssues: boolean }) {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const fullscreenContainerRef = useRef<HTMLDivElement>(null);
const { mediaStream, peerConnectionState } = useRTCStore();
const [isPlaying, setIsPlaying] = useState(false);
const [isPointerLockActive, setIsPointerLockActive] = useState(false);
@ -150,7 +151,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
}, [checkNavigatorPermissions, setIsKeyboardLockActive]);
const releaseKeyboardLock = useCallback(async () => {
if (videoElm.current === null || document.fullscreenElement !== videoElm.current) return;
if (fullscreenContainerRef.current === null || document.fullscreenElement !== fullscreenContainerRef.current) return;
if (navigator && "keyboard" in navigator) {
try {
@ -187,7 +188,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
}, [isPointerLockPossible]);
const requestFullscreen = useCallback(async () => {
if (!isFullscreenEnabled || !videoElm.current) return;
if (!isFullscreenEnabled || !fullscreenContainerRef.current) return;
// per https://wicg.github.io/keyboard-lock/#system-key-press-handler
// If keyboard lock is activated after fullscreen is already in effect, then the user my
@ -196,7 +197,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
await requestKeyboardLock();
await requestPointerLock();
await videoElm.current.requestFullscreen({
await fullscreenContainerRef.current.requestFullscreen({
navigationUI: "show",
});
}, [isFullscreenEnabled, requestKeyboardLock, requestPointerLock]);
@ -512,7 +513,10 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
{/* In relative mouse mode and under https, we enable the pointer lock, and to do so we need a bar to show the user to click on the video to enable mouse control */}
<PointerLockBar show={showPointerLockBar} />
<div className="relative mx-4 my-2 flex items-center justify-center overflow-hidden">
<div className="relative flex h-full w-full items-center justify-center">
<div
ref={fullscreenContainerRef}
className="relative flex h-full w-full items-center justify-center"
>
<video
ref={videoElm}
autoPlay

View File

@ -70,7 +70,8 @@ export default function PasteModal() {
const macroSteps: MacroStep[] = [];
for (const char of text) {
const keyprops = selectedKeyboard.chars[char];
const normalizedChar = char.normalize('NFC');
const keyprops = selectedKeyboard.chars[normalizedChar];
if (!keyprops) continue;
const { key, shift, altRight, deadKey, accentKey } = keyprops;
@ -168,7 +169,7 @@ export default function PasteModal() {
...new Set(
// @ts-expect-error TS doesn't recognize Intl.Segmenter in some environments
[...new Intl.Segmenter().segment(value)]
.map(x => x.segment)
.map(x => x.segment.normalize('NFC'))
.filter(char => !selectedKeyboard.chars[char]),
),
];

View File

@ -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()}

View File

@ -116,7 +116,7 @@ export interface RTCState {
peerConnection: RTCPeerConnection | null;
setPeerConnection: (pc: RTCState["peerConnection"]) => void;
setRpcDataChannel: (channel: RTCDataChannel) => void;
setRpcDataChannel: (channel: RTCDataChannel | null) => void;
rpcDataChannel: RTCDataChannel | null;
hidRpcDisabled: boolean;
@ -178,41 +178,42 @@ export const useRTCStore = create<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 }),
peerConnectionState: null,
setPeerConnectionState: (state: RTCPeerConnectionState) => set({ peerConnectionState: state }),
setPeerConnectionState: state => set({ peerConnectionState: state }),
mediaStream: null,
setMediaStream: (stream: MediaStream) => set({ mediaStream: stream }),
setMediaStream: stream => set({ mediaStream: stream }),
videoStreamStats: null,
appendVideoStreamStats: (stats: RTCInboundRtpStreamStats) => set({ videoStreamStats: stats }),
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(),
isTurnServerInUse: false,
setTurnServerInUse: (inUse: boolean) => set({ isTurnServerInUse: inUse }),
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
inboundRtpStats: new Map(),
appendInboundRtpStats: (stats: RTCInboundRtpStreamStats) => {
appendInboundRtpStats: stats => {
set(prevState => ({
inboundRtpStats: appendStatToMap(stats, prevState.inboundRtpStats),
}));
@ -220,7 +221,7 @@ export const useRTCStore = create<RTCState>(set => ({
clearInboundRtpStats: () => set({ inboundRtpStats: new Map() }),
candidatePairStats: new Map(),
appendCandidatePairStats: (stats: RTCIceCandidatePairStats) => {
appendCandidatePairStats: stats => {
set(prevState => ({
candidatePairStats: appendStatToMap(stats, prevState.candidatePairStats),
}));
@ -228,21 +229,21 @@ export const useRTCStore = create<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),
}));
@ -250,7 +251,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 {
@ -270,12 +271,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;
@ -290,10 +299,7 @@ export interface VideoState {
setClientSize: (width: number, height: number) => void;
setSize: (width: number, height: number) => void;
hdmiState: HdmiStates;
setHdmiState: (state: {
ready: boolean;
error?: HdmiErrorStates;
}) => void;
setHdmiState: (state: { ready: boolean; error?: HdmiErrorStates }) => void;
}
export const useVideoStore = create<VideoState>(set => ({
@ -304,7 +310,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 }),
@ -451,13 +458,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 }) =>
@ -508,14 +517,22 @@ export interface HidState {
}
export const useHidStore = create<HidState>(set => ({
keyboardLedState: { num_lock: false, caps_lock: false, scroll_lock: false, compose: false, kana: false } as KeyboardLedState,
setKeyboardLedState: (ledState: KeyboardLedState): void => set({ keyboardLedState: ledState }),
keyboardLedState: {
num_lock: false,
caps_lock: false,
scroll_lock: false,
compose: false,
kana: 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 }),
@ -567,7 +584,7 @@ export interface OtaState {
systemUpdateProgress: number;
systemUpdatedAt: string | null;
};
}
export interface UpdateState {
isUpdatePending: boolean;
@ -579,11 +596,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 => ({
@ -619,12 +639,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;
@ -734,6 +756,11 @@ export interface IPv6Address {
flag_tentative?: boolean;
}
export interface PublicIP {
ip: string;
last_updated: Date;
}
export interface NetworkState {
interface_name?: string;
mac_address?: string;
@ -977,5 +1004,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 }),
}));

View File

@ -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(() => {

View File

@ -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
);
}

View File

@ -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();
}}
/>

View File

@ -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(() => {
const onConfirmUpdate = useCallback(async () => {
setIsRebooting(true);
send("reboot", { force: true });
}, [send]);
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>
{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>
);

View File

@ -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
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]);
}
}, [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;

View File

@ -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 ? (

View File

@ -16,10 +16,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. */
@ -30,6 +31,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 = () => {
@ -158,21 +161,28 @@ 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="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" />
@ -238,8 +248,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"

View File

@ -33,6 +33,7 @@ import {
useUpdateStore,
useVideoStore,
VideoState,
useFailsafeModeStore,
} from "@hooks/stores";
import { JsonRpcRequest, JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
@ -43,6 +44,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,
@ -101,6 +103,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
@ -557,8 +560,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(() => {
@ -618,7 +622,8 @@ export default function KvmIdRoute() {
keysDownState, setKeysDownState,
setUsbState,
} = useHidStore();
const { setHidRpcDisabled } = useRTCStore();
const setHidRpcDisabled = useRTCStore(state => state.setHidRpcDisabled);
const { setFailsafeMode } = useFailsafeModeStore();
const [hasUpdated, setHasUpdated] = useState(false);
const { navigateTo } = useDeviceUiNavigation();
@ -695,6 +700,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);
@ -793,6 +804,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;
@ -868,13 +881,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} />

View File

@ -1,4 +1,6 @@
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 = "";

View File

@ -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);
}
}
}

View File

@ -27,6 +27,7 @@ func triggerVideoStateUpdate() {
}
func rpcGetVideoState() (native.VideoState, error) {
notifyFailsafeMode(currentSession)
return lastVideoState, nil
}

24
web.go
View File

@ -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)
}

View File

@ -293,9 +293,13 @@ func newSession(config SessionConfig) (*Session, error) {
scopedLogger.Warn().Msg("RPC message received but rpcQueue is nil")
}
})
// Wait for channel to be open before sending initial state
d.OnOpen(func() {
triggerOTAStateUpdate()
triggerVideoStateUpdate()
triggerUSBStateUpdate()
notifyFailsafeMode(session)
})
case "terminal":
handleTerminalChannel(d)
case "serial":
@ -399,10 +403,12 @@ func newSession(config SessionConfig) (*Session, error) {
}
func onActiveSessionsChanged() {
notifyFailsafeMode(currentSession)
requestDisplayUpdate(true, "active_sessions_changed")
}
func onFirstSessionConnected() {
notifyFailsafeMode(currentSession)
_ = nativeInstance.VideoStart()
stopVideoSleepModeTicker()
}