diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf..41aeee58 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index d9636088..9a1e1899 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -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") diff --git a/failsafe.go b/failsafe.go new file mode 100644 index 00000000..3c6b3d3a --- /dev/null +++ b/failsafe.go @@ -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 +} diff --git a/internal/native/cgo_linux.go b/internal/native/cgo_linux.go index 850da0e8..be1a5a36 100644 --- a/internal/native/cgo_linux.go +++ b/internal/native/cgo_linux.go @@ -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() diff --git a/internal/native/native.go b/internal/native/native.go index 3b1cc0b4..cb8761cf 100644 --- a/internal/native/native.go +++ b/internal/native/native.go @@ -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() diff --git a/jsonrpc.go b/jsonrpc.go index ebb6d672..1f29dc03 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -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}, } diff --git a/log.go b/log.go index 2047bbfa..9cd9188e 100644 --- a/log.go +++ b/log.go @@ -11,6 +11,7 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...any) error { var ( logger = logging.GetSubsystemLogger("jetkvm") + failsafeLogger = logging.GetSubsystemLogger("failsafe") networkLogger = logging.GetSubsystemLogger("network") cloudLogger = logging.GetSubsystemLogger("cloud") websocketLogger = logging.GetSubsystemLogger("websocket") diff --git a/main.go b/main.go index bcc2d73d..d7ecc3e1 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,12 @@ var appCtx context.Context func Main() { logger.Log().Msg("JetKVM Starting Up") + + checkFailsafeReason() + if failsafeModeActive { + logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated") + } + LoadConfig() var cancel context.CancelFunc @@ -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) diff --git a/native.go b/native.go index 4a523bce..81a0e50d 100644 --- a/native.go +++ b/native.go @@ -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(), diff --git a/network.go b/network.go index 846f41f1..3a2b24f9 100644 --- a/network.go +++ b/network.go @@ -3,12 +3,17 @@ package kvm import ( "context" "fmt" + "net" + "net/http" "reflect" + "time" "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/pkg/myip" "github.com/jetkvm/kvm/pkg/nmlite" + "github.com/jetkvm/kvm/pkg/nmlite/link" ) const ( @@ -17,6 +22,7 @@ const ( var ( networkManager *nmlite.NetworkManager + publicIPState *myip.PublicIPState ) type RpcNetworkSettings struct { @@ -104,6 +110,13 @@ func triggerTimeSyncOnNetworkStateChange() { }() } +func setPublicIPReadyState(ipv4Ready, ipv6Ready bool) { + if publicIPState == nil { + return + } + publicIPState.SetIPv4AndIPv6(ipv4Ready, ipv6Ready) +} + func networkStateChanged(_ string, state types.InterfaceState) { // do not block the main thread go waitCtrlAndRequestDisplayUpdate(true, "network_state_changed") @@ -117,6 +130,8 @@ func networkStateChanged(_ string, state types.InterfaceState) { triggerTimeSyncOnNetworkStateChange() } + setPublicIPReadyState(state.IPv4Ready, state.IPv6Ready) + // always restart mDNS when the network state changes if mDNS != nil { restartMdns() @@ -164,6 +179,40 @@ func initNetwork() error { return nil } +func initPublicIPState() { + // the feature will be only enabled if the cloud has been adopted + // due to privacy reasons + + // but it will be initialized anyway to avoid nil pointer dereferences + ps := myip.NewPublicIPState(&myip.PublicIPStateConfig{ + Logger: networkLogger, + CloudflareEndpoint: config.CloudURL, + APIEndpoint: "", + IPv4: false, + IPv6: false, + HttpClientGetter: func(family int) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = config.NetworkConfig.GetTransportProxyFunc() + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + netType := network + switch family { + case link.AfInet: + netType = "tcp4" + case link.AfInet6: + netType = "tcp6" + } + return (&net.Dialer{}).DialContext(ctx, netType, addr) + } + + return &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + }, + }) + publicIPState = ps +} + func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { if nm == nil { return nil @@ -312,3 +361,25 @@ func rpcToggleDHCPClient() error { return rpcReboot(true) } + +func rpcGetPublicIPAddresses(refresh bool) ([]myip.PublicIP, error) { + if publicIPState == nil { + return nil, fmt.Errorf("public IP state not initialized") + } + + if refresh { + if err := publicIPState.ForceUpdate(); err != nil { + return nil, err + } + } + + return publicIPState.GetAddresses(), nil +} + +func rpcCheckPublicIPAddresses() error { + if publicIPState == nil { + return fmt.Errorf("public IP state not initialized") + } + + return publicIPState.ForceUpdate() +} diff --git a/pkg/myip/check.go b/pkg/myip/check.go new file mode 100644 index 00000000..86d3ba50 --- /dev/null +++ b/pkg/myip/check.go @@ -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) +} diff --git a/pkg/myip/ip.go b/pkg/myip/ip.go new file mode 100644 index 00000000..15afc24e --- /dev/null +++ b/pkg/myip/ip.go @@ -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 + } + } +} diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 6a0c1ed9..dbbd6ff5 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -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}" } diff --git a/ui/package-lock.json b/ui/package-lock.json index acdb64a4..7e4938e5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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": { diff --git a/ui/package.json b/ui/package.json index 36495177..2ca7cbe1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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" diff --git a/ui/src/components/FailSafeModeBanner.tsx b/ui/src/components/FailSafeModeBanner.tsx new file mode 100644 index 00000000..04fddc67 --- /dev/null +++ b/ui/src/components/FailSafeModeBanner.tsx @@ -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 ( + +
+ +

+ {getReasonMessage()} +

+
+
+ ); +} + diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx new file mode 100644 index 00000000..eadc5d9d --- /dev/null +++ b/ui/src/components/FailSafeModeOverlay.tsx @@ -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 ( + +
+ {children} +
+
+ ); +} + +interface TooltipProps { + readonly children: React.ReactNode; + readonly text: string; + readonly show: boolean; +} + +function Tooltip({ children, text, show }: TooltipProps) { + if (!show) { + return <>{children}; + } + + + return ( +
+ {children} +
+ +
+ + {text} +
+
+
+
+ ); +} + +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 ( + + + +
+ +
+
+
+

Fail safe mode activated

+

{message}

+
+
+
+
+ + +
+
+
+
+
+
+
+ ); +} + diff --git a/ui/src/components/PublicIPCard.tsx b/ui/src/components/PublicIPCard.tsx new file mode 100644 index 00000000..55c1c407 --- /dev/null +++ b/ui/src/components/PublicIPCard.tsx @@ -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(formatters.timeAgo(date)); + useEffect(() => { + const interval = setInterval(() => { + setTimeAgo(formatters.timeAgo(date)); + }, 1000); + return () => clearInterval(interval); + }, [date]); + + return ( + + {timeAgo} + + ); +}; + +export default function PublicIPCard() { + const { send } = useJsonRpc(); + + const [publicIPs, setPublicIPs] = useState([]); + 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 ( + +
+
+
+

+ {m.public_ip_card_header()} +

+ +
+
+
+ {publicIPs.length === 0 ? ( +
+
+
+
+
+
+
+
+
+ ) : ( +
+
+ {publicIPs?.map(ip => ( +
+ + {ip.ip} + + {ip.last_updated && } +
+ ))} +
+
+ )} +
+
+ + ); +} diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx index ec392012..c54d2b8d 100644 --- a/ui/src/components/WebRTCVideo.tsx +++ b/ui/src/components/WebRTCVideo.tsx @@ -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(null); + const fullscreenContainerRef = useRef(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 */}
-
+