From 85f7f6061866ed2a754d923276653bb53bf44d30 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 28 Oct 2025 08:54:41 +0000 Subject: [PATCH 01/62] WIP: OTA refactor --- config.go | 13 + hw.go | 4 +- internal/ota/app.go | 58 ++ internal/ota/logger.go | 5 + internal/ota/ota.go | 211 +++++++ internal/ota/state.go | 209 +++++++ internal/ota/sys.go | 100 +++ internal/ota/utils.go | 166 +++++ jsonrpc.go | 32 +- main.go | 15 +- network.go | 7 +- ota.go | 589 ++---------------- ui/localization/messages/en.json | 1 + ui/src/hooks/useVersion.tsx | 3 +- .../devices.$id.settings.general._index.tsx | 23 +- .../devices.$id.settings.general.update.tsx | 2 +- webrtc.go | 2 +- 17 files changed, 861 insertions(+), 579 deletions(-) create mode 100644 internal/ota/app.go create mode 100644 internal/ota/logger.go create mode 100644 internal/ota/ota.go create mode 100644 internal/ota/state.go create mode 100644 internal/ota/sys.go create mode 100644 internal/ota/utils.go diff --git a/config.go b/config.go index 5a3e7dc8f..7dc3db307 100644 --- a/config.go +++ b/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "strconv" + "strings" "sync" "github.com/jetkvm/kvm/internal/confparser" @@ -80,6 +81,7 @@ func (m *KeyboardMacro) Validate() error { type Config struct { CloudURL string `json:"cloud_url"` + UpdateAPIURL string `json:"update_api_url"` CloudAppURL string `json:"cloud_app_url"` CloudToken string `json:"cloud_token"` GoogleIdentity string `json:"google_identity"` @@ -109,6 +111,15 @@ type Config struct { VideoQualityFactor float64 `json:"video_quality_factor"` } +// GetUpdateAPIURL returns the update API URL +func (c *Config) GetUpdateAPIURL() string { + if c.UpdateAPIURL == "" { + return "https://api.jetkvm.com" + } + return strings.TrimSuffix(c.UpdateAPIURL, "/") + "/releases" +} + +// GetDisplayRotation returns the display rotation func (c *Config) GetDisplayRotation() uint16 { rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16) if err != nil { @@ -118,6 +129,7 @@ func (c *Config) GetDisplayRotation() uint16 { return uint16(rotationInt) } +// SetDisplayRotation sets the display rotation func (c *Config) SetDisplayRotation(rotation string) error { _, err := strconv.ParseUint(rotation, 10, 16) if err != nil { @@ -157,6 +169,7 @@ var ( func getDefaultConfig() Config { return Config{ CloudURL: "https://api.jetkvm.com", + UpdateAPIURL: "https://api.jetkvm.com", CloudAppURL: "https://app.jetkvm.com", AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", diff --git a/hw.go b/hw.go index 7797adc1f..b6416e250 100644 --- a/hw.go +++ b/hw.go @@ -8,6 +8,8 @@ import ( "strings" "sync" "time" + + "github.com/jetkvm/kvm/internal/ota" ) func extractSerialNumber() (string, error) { @@ -37,7 +39,7 @@ func readOtpEntropy() ([]byte, error) { //nolint:unused return content[0x17:0x1C], nil } -func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error { +func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error { //nolint:unused logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay) writeJSONRPCEvent("willReboot", postRebootAction, currentSession) diff --git a/internal/ota/app.go b/internal/ota/app.go new file mode 100644 index 000000000..482a07de7 --- /dev/null +++ b/internal/ota/app.go @@ -0,0 +1,58 @@ +package ota + +import ( + "context" + "fmt" + "time" + + "github.com/rs/zerolog" +) + +const ( + appUpdatePath = "/userdata/jetkvm/jetkvm_app.update" +) + +func (s *State) componentUpdateError(prefix string, err error, l *zerolog.Logger) error { + if l == nil { + l = s.l + } + l.Error().Err(err).Msg(prefix) + s.error = fmt.Sprintf("%s: %v", prefix, err) + return err +} + +func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + + l := s.l.With().Str("path", appUpdatePath).Logger() + + if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, &appUpdate.downloadProgress); err != nil { + return s.componentUpdateError("Error downloading app update", err, &l) + } + + downloadFinished := time.Now() + appUpdate.downloadFinishedAt = downloadFinished + appUpdate.downloadProgress = 1 + s.onProgressUpdate() + + if err := s.verifyFile( + appUpdatePath, + appUpdate.hash, + &appUpdate.verificationProgress, + ); err != nil { + return s.componentUpdateError("Error verifying app update hash", err, &l) + } + verifyFinished := time.Now() + appUpdate.verifiedAt = verifyFinished + appUpdate.verificationProgress = 1 + appUpdate.updatedAt = verifyFinished + appUpdate.updateProgress = 1 + s.onProgressUpdate() + + l.Info().Msg("App update downloaded") + + s.rebootNeeded = true + + return nil +} diff --git a/internal/ota/logger.go b/internal/ota/logger.go new file mode 100644 index 000000000..a13036de9 --- /dev/null +++ b/internal/ota/logger.go @@ -0,0 +1,5 @@ +package ota + +import "github.com/jetkvm/kvm/internal/logging" + +var logger = logging.GetSubsystemLogger("ota") diff --git a/internal/ota/ota.go b/internal/ota/ota.go new file mode 100644 index 000000000..b45909ec7 --- /dev/null +++ b/internal/ota/ota.go @@ -0,0 +1,211 @@ +package ota + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/Masterminds/semver/v3" +) + +// UpdateReleaseAPIEndpoint updates the release API endpoint +func (s *State) UpdateReleaseAPIEndpoint(endpoint string) { + s.releaseAPIEndpoint = endpoint +} + +// GetReleaseAPIEndpoint returns the release API endpoint +func (s *State) GetReleaseAPIEndpoint() string { + return s.releaseAPIEndpoint +} + +func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) { + metadata := &UpdateMetadata{} + + updateURL, err := url.Parse(s.releaseAPIEndpoint) + if err != nil { + return nil, fmt.Errorf("error parsing update metadata URL: %w", err) + } + + query := updateURL.Query() + query.Set("deviceId", deviceID) + query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) + updateURL.RawQuery = query.Encode() + + logger.Info().Str("url", updateURL.String()).Msg("Checking for updates") + + req, err := http.NewRequestWithContext(ctx, "GET", updateURL.String(), nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + client := s.client() + + 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) + } + + err = json.NewDecoder(resp.Body).Decode(metadata) + if err != nil { + return nil, fmt.Errorf("error decoding response: %w", err) + } + + return metadata, nil +} + +func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreRelease bool) error { + scopedLogger := s.l.With(). + Str("deviceID", deviceID). + Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)). + Logger() + + scopedLogger.Info().Msg("Trying to update...") + if s.updating { + return fmt.Errorf("update already in progress") + } + + s.updating = true + s.onProgressUpdate() + + defer func() { + s.updating = false + s.onProgressUpdate() + }() + + appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) + if err != nil { + return s.componentUpdateError("Error checking for updates", err, &scopedLogger) + } + + s.metadataFetchedAt = time.Now() + s.onProgressUpdate() + + if appUpdate.available { + appUpdate.pending = true + } + + if systemUpdate.available { + systemUpdate.pending = true + } + + if appUpdate.pending { + scopedLogger.Info(). + Str("url", appUpdate.url). + Str("hash", appUpdate.hash). + Msg("App update available") + + if err := s.updateApp(ctx, appUpdate); err != nil { + return s.componentUpdateError("Error updating app", err, &scopedLogger) + } + } else { + scopedLogger.Info().Msg("App is up to date") + } + + if systemUpdate.pending { + if err := s.updateSystem(ctx, systemUpdate); err != nil { + return s.componentUpdateError("Error updating system", err, &scopedLogger) + } + } else { + scopedLogger.Info().Msg("System is up to date") + } + + if s.rebootNeeded { + scopedLogger.Info().Msg("System Rebooting due to OTA update") + + postRebootAction := &PostRebootAction{ + HealthCheck: "/device/status", + RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version), + } + + if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil { + return s.componentUpdateError("Error requesting reboot", err, &scopedLogger) + } + } + + return nil +} + +func (s *State) getUpdateStatus( + ctx context.Context, + deviceID string, + includePreRelease bool, +) ( + appUpdate *componentUpdateStatus, + systemUpdate *componentUpdateStatus, + err error, +) { + appUpdate = &componentUpdateStatus{} + systemUpdate = &componentUpdateStatus{} + err = nil + + // Get local versions + systemVersionLocal, appVersionLocal, err := s.getLocalVersion() + if err != nil { + return nil, nil, fmt.Errorf("error getting local version: %w", err) + } + appUpdate.localVersion = appVersionLocal.String() + systemUpdate.localVersion = systemVersionLocal.String() + + // Get remote metadata + remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease) + if err != nil { + err = fmt.Errorf("error checking for updates: %w", err) + return + } + appUpdate.url = remoteMetadata.AppURL + appUpdate.hash = remoteMetadata.AppHash + appUpdate.version = remoteMetadata.AppVersion + + systemUpdate.url = remoteMetadata.SystemURL + systemUpdate.hash = remoteMetadata.SystemHash + systemUpdate.version = remoteMetadata.SystemVersion + + // Get remote versions + systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) + if err != nil { + err = fmt.Errorf("error parsing remote system version: %w", err) + return + } + systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) + + appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) + if err != nil { + err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) + return + } + appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) + + // Handle pre-release updates + isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" + isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" + + if isRemoteSystemPreRelease && !includePreRelease { + systemUpdate.available = false + } + if isRemoteAppPreRelease && !includePreRelease { + appUpdate.available = false + } + + s.componentUpdateStatuses["app"] = *appUpdate + s.componentUpdateStatuses["system"] = *systemUpdate + + return +} + +// GetUpdateStatus returns the current update status (for backwards compatibility) +func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) { + _, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) + if err != nil { + return nil, fmt.Errorf("error getting update status: %w", err) + } + + return s.ToUpdateStatus(), nil +} diff --git a/internal/ota/state.go b/internal/ota/state.go new file mode 100644 index 000000000..4375a08f5 --- /dev/null +++ b/internal/ota/state.go @@ -0,0 +1,209 @@ +package ota + +import ( + "net/http" + "sync" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog" +) + +// UpdateMetadata represents the metadata of an update +type UpdateMetadata struct { + AppVersion string `json:"appVersion"` + AppURL string `json:"appUrl"` + AppHash string `json:"appHash"` + SystemVersion string `json:"systemVersion"` + SystemURL string `json:"systemUrl"` + SystemHash string `json:"systemHash"` +} + +// LocalMetadata represents the local metadata of the system +type LocalMetadata struct { + AppVersion string `json:"appVersion"` + SystemVersion string `json:"systemVersion"` +} + +// UpdateStatus represents the current update status +type UpdateStatus struct { + Local *LocalMetadata `json:"local"` + Remote *UpdateMetadata `json:"remote"` + SystemUpdateAvailable bool `json:"systemUpdateAvailable"` + AppUpdateAvailable bool `json:"appUpdateAvailable"` + + // for backwards compatibility + Error string `json:"error,omitempty"` +} + +// PostRebootAction represents the action to be taken after a reboot +// It is used to redirect the user to a specific page after a reboot +type PostRebootAction struct { + HealthCheck string `json:"healthCheck"` // The health check URL to call after the reboot + RedirectUrl string `json:"redirectUrl"` // The URL to redirect to after the reboot +} + +// componentUpdateStatus represents the status of a component update +type componentUpdateStatus struct { + pending bool + available bool + version string + localVersion string + url string + hash string + downloadProgress float32 + downloadFinishedAt time.Time + verificationProgress float32 + verifiedAt time.Time + updateProgress float32 + updatedAt time.Time + dependsOn []string +} + +// RPCState represents the current OTA state for the RPC API +type RPCState struct { + Updating bool `json:"updating"` + Error string `json:"error,omitempty"` + MetadataFetchedAt time.Time `json:"metadataFetchedAt,omitempty"` + AppUpdatePending bool `json:"appUpdatePending"` + SystemUpdatePending bool `json:"systemUpdatePending"` + AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar + AppDownloadFinishedAt time.Time `json:"appDownloadFinishedAt,omitempty"` + SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar + SystemDownloadFinishedAt time.Time `json:"systemDownloadFinishedAt,omitempty"` + AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` + AppVerifiedAt time.Time `json:"appVerifiedAt,omitempty"` + SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` + SystemVerifiedAt time.Time `json:"systemVerifiedAt,omitempty"` + AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar + AppUpdatedAt time.Time `json:"appUpdatedAt,omitempty"` + SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement + SystemUpdatedAt time.Time `json:"systemUpdatedAt,omitempty"` +} + +// HwRebootFunc is a function that reboots the hardware +type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error + +// GetHTTPClientFunc is a function that returns the HTTP client +type GetHTTPClientFunc func() *http.Client + +// OnStateUpdateFunc is a function that updates the state of the OTA +type OnStateUpdateFunc func(state *RPCState) + +// OnProgressUpdateFunc is a function that updates the progress of the OTA +type OnProgressUpdateFunc func(progress float32) + +// GetLocalVersionFunc is a function that returns the local version of the system and app +type GetLocalVersionFunc func() (systemVersion *semver.Version, appVersion *semver.Version, err error) + +// State represents the current OTA state for the UI +type State struct { + releaseAPIEndpoint string + l *zerolog.Logger + mu sync.Mutex + updating bool + error string + metadataFetchedAt time.Time + rebootNeeded bool + componentUpdateStatuses map[string]componentUpdateStatus + client GetHTTPClientFunc + reboot HwRebootFunc + getLocalVersion GetLocalVersionFunc +} + +// ToUpdateStatus converts the State to the UpdateStatus +func (s *State) ToUpdateStatus() *UpdateStatus { + appUpdate, ok := s.componentUpdateStatuses["app"] + if !ok { + return nil + } + + systemUpdate, ok := s.componentUpdateStatuses["system"] + if !ok { + return nil + } + + return &UpdateStatus{ + Local: &LocalMetadata{ + AppVersion: appUpdate.localVersion, + SystemVersion: systemUpdate.localVersion, + }, + Remote: &UpdateMetadata{ + AppVersion: appUpdate.version, + AppURL: appUpdate.url, + AppHash: appUpdate.hash, + SystemVersion: systemUpdate.version, + SystemURL: systemUpdate.url, + SystemHash: systemUpdate.hash, + }, + SystemUpdateAvailable: systemUpdate.available, + AppUpdateAvailable: appUpdate.available, + Error: s.error, + } +} + +// IsUpdatePending returns true if an update is pending +func (s *State) IsUpdatePending() bool { + return s.updating +} + +// Options represents the options for the OTA state +type Options struct { + Logger *zerolog.Logger + GetHTTPClient GetHTTPClientFunc + GetLocalVersion GetLocalVersionFunc + OnStateUpdate OnStateUpdateFunc + OnProgressUpdate OnProgressUpdateFunc + HwReboot HwRebootFunc + ReleaseAPIEndpoint string +} + +// NewState creates a new OTA state +func NewState(opts Options) *State { + s := &State{ + l: opts.Logger, + client: opts.GetHTTPClient, + reboot: opts.HwReboot, + getLocalVersion: opts.GetLocalVersion, + componentUpdateStatuses: make(map[string]componentUpdateStatus), + releaseAPIEndpoint: opts.ReleaseAPIEndpoint, + } + go s.confirmCurrentSystem() + return s +} + +// ToRPCState converts the State to the RPCState +func (s *State) ToRPCState() *RPCState { + r := &RPCState{ + Updating: s.updating, + Error: s.error, + MetadataFetchedAt: s.metadataFetchedAt, + } + + app, ok := s.componentUpdateStatuses["app"] + if ok { + r.AppUpdatePending = app.pending + r.AppDownloadProgress = app.downloadProgress + r.AppDownloadFinishedAt = app.downloadFinishedAt + r.AppVerificationProgress = app.verificationProgress + r.AppVerifiedAt = app.verifiedAt + r.AppUpdateProgress = app.updateProgress + r.AppUpdatedAt = app.updatedAt + } + + system, ok := s.componentUpdateStatuses["system"] + if ok { + r.SystemUpdatePending = system.pending + r.SystemDownloadProgress = system.downloadProgress + r.SystemDownloadFinishedAt = system.downloadFinishedAt + r.SystemVerificationProgress = system.verificationProgress + r.SystemVerifiedAt = system.verifiedAt + r.SystemUpdateProgress = system.updateProgress + r.SystemUpdatedAt = system.updatedAt + } + + return r +} + +func (s *State) onProgressUpdate() { +} diff --git a/internal/ota/sys.go b/internal/ota/sys.go new file mode 100644 index 000000000..2426c3532 --- /dev/null +++ b/internal/ota/sys.go @@ -0,0 +1,100 @@ +package ota + +import ( + "bytes" + "context" + "os/exec" + "time" +) + +const ( + systemUpdatePath = "/userdata/jetkvm/update_system.tar" +) + +func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + + l := s.l.With().Str("path", systemUpdatePath).Logger() + + if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, &systemUpdate.downloadProgress); err != nil { + return s.componentUpdateError("Error downloading system update", err, &l) + } + + downloadFinished := time.Now() + systemUpdate.downloadFinishedAt = downloadFinished + systemUpdate.downloadProgress = 1 + s.onProgressUpdate() + + if err := s.verifyFile( + systemUpdatePath, + systemUpdate.hash, + &systemUpdate.verificationProgress, + ); err != nil { + return s.componentUpdateError("Error verifying system update hash", err, &l) + } + verifyFinished := time.Now() + systemUpdate.verifiedAt = verifyFinished + systemUpdate.verificationProgress = 1 + systemUpdate.updatedAt = verifyFinished + systemUpdate.updateProgress = 1 + s.onProgressUpdate() + + l.Info().Msg("System update downloaded") + + l.Info().Msg("Starting rk_ota command") + + cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all") + var b bytes.Buffer + cmd.Stdout = &b + cmd.Stderr = &b + if err := cmd.Start(); err != nil { + return s.componentUpdateError("Error starting rk_ota command", err, &l) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + ticker := time.NewTicker(1800 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + if systemUpdate.updateProgress >= 0.99 { + return + } + systemUpdate.updateProgress += 0.01 + if systemUpdate.updateProgress > 0.99 { + systemUpdate.updateProgress = 0.99 + } + s.onProgressUpdate() + case <-ctx.Done(): + return + } + } + }() + + err := cmd.Wait() + cancel() + rkLogger := s.l.With(). + Str("output", b.String()). + Int("exitCode", cmd.ProcessState.ExitCode()).Logger() + if err != nil { + return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) + } + rkLogger.Info().Msg("rk_ota success") + systemUpdate.updateProgress = 1 + systemUpdate.updatedAt = verifyFinished + s.onProgressUpdate() + + return nil +} + +func (s *State) confirmCurrentSystem() { + output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput() + if err != nil { + s.l.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") + } + s.l.Trace().Str("output", string(output)).Msg("current partition in A/B setup set") +} diff --git a/internal/ota/utils.go b/internal/ota/utils.go new file mode 100644 index 000000000..caa033843 --- /dev/null +++ b/internal/ota/utils.go @@ -0,0 +1,166 @@ +package ota + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "net/http" + "os" + "os/exec" +) + +func syncFilesystem() error { + // Flush filesystem buffers to ensure all data is written to disk + if err := exec.Command("sync").Run(); err != nil { + return fmt.Errorf("error flushing filesystem buffers: %w", err) + } + + // Clear the filesystem caches to force a read from disk + if err := os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644); err != nil { + return fmt.Errorf("error clearing filesystem caches: %w", err) + } + + return nil +} + +func (s *State) downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error { + if _, err := os.Stat(path); err == nil { + if err := os.Remove(path); err != nil { + return fmt.Errorf("error removing existing file: %w", err) + } + } + + unverifiedPath := path + ".unverified" + if _, err := os.Stat(unverifiedPath); err == nil { + if err := os.Remove(unverifiedPath); err != nil { + return fmt.Errorf("error removing existing unverified file: %w", err) + } + } + + file, err := os.Create(unverifiedPath) + if err != nil { + return fmt.Errorf("error creating file: %w", err) + } + defer file.Close() + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return fmt.Errorf("error creating request: %w", err) + } + + client := s.client() + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("error downloading file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + totalSize := resp.ContentLength + if totalSize <= 0 { + return fmt.Errorf("invalid content length") + } + + var written int64 + buf := make([]byte, 32*1024) + for { + nr, er := resp.Body.Read(buf) + if nr > 0 { + nw, ew := file.Write(buf[0:nr]) + if nw < nr { + return fmt.Errorf("short write: %d < %d", nw, nr) + } + written += int64(nw) + if ew != nil { + return fmt.Errorf("error writing to file: %w", ew) + } + progress := float32(written) / float32(totalSize) + if progress-*downloadProgress >= 0.01 { + *downloadProgress = progress + s.onProgressUpdate() + } + } + if er != nil { + if er == io.EOF { + break + } + return fmt.Errorf("error reading response body: %w", er) + } + } + + file.Close() + + if err := syncFilesystem(); err != nil { + return fmt.Errorf("error syncing filesystem: %w", err) + } + + return nil +} + +func (s *State) verifyFile(path string, expectedHash string, verifyProgress *float32) error { + l := s.l.With().Str("path", path).Logger() + + unverifiedPath := path + ".unverified" + fileToHash, err := os.Open(unverifiedPath) + if err != nil { + return fmt.Errorf("error opening file for hashing: %w", err) + } + defer fileToHash.Close() + + hash := sha256.New() + fileInfo, err := fileToHash.Stat() + if err != nil { + return fmt.Errorf("error getting file info: %w", err) + } + totalSize := fileInfo.Size() + + buf := make([]byte, 32*1024) + verified := int64(0) + + for { + nr, er := fileToHash.Read(buf) + if nr > 0 { + nw, ew := hash.Write(buf[0:nr]) + if nw < nr { + return fmt.Errorf("short write: %d < %d", nw, nr) + } + verified += int64(nw) + if ew != nil { + return fmt.Errorf("error writing to hash: %w", ew) + } + progress := float32(verified) / float32(totalSize) + if progress-*verifyProgress >= 0.01 { + *verifyProgress = progress + s.onProgressUpdate() + } + } + if er != nil { + if er == io.EOF { + break + } + return fmt.Errorf("error reading file: %w", er) + } + } + + hashSum := hash.Sum(nil) + l.Info().Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of") + + if hex.EncodeToString(hashSum) != expectedHash { + return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash) + } + + if err := os.Rename(unverifiedPath, path); err != nil { + return fmt.Errorf("error renaming file: %w", err) + } + + if err := os.Chmod(path, 0755); err != nil { + return fmt.Errorf("error making file executable: %w", err) + } + + return nil +} diff --git a/jsonrpc.go b/jsonrpc.go index 5ed90a7a2..960e0bea5 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -19,6 +19,7 @@ import ( "go.bug.st/serial" "github.com/jetkvm/kvm/internal/hidrpc" + "github.com/jetkvm/kvm/internal/ota" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/utils" ) @@ -248,9 +249,8 @@ func rpcSetDevChannelState(enabled bool) error { return nil } -func rpcGetUpdateStatus() (*UpdateStatus, error) { - includePreRelease := config.IncludePreRelease - updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease) +func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { + updateStatus, err := otaState.GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease) // to ensure backwards compatibility, // if there's an error, we won't return an error, but we will set the error field if err != nil { @@ -260,15 +260,32 @@ func rpcGetUpdateStatus() (*UpdateStatus, error) { updateStatus.Error = err.Error() } + logger.Info().Interface("updateStatus", updateStatus).Msg("Update status") + return updateStatus, nil } -func rpcGetLocalVersion() (*LocalMetadata, error) { +func rpcGetUpdateStatus() (*ota.UpdateStatus, error) { + return getUpdateStatus(config.IncludePreRelease) +} + +func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) { + switch channel { + case "stable": + return getUpdateStatus(false) + case "dev": + return getUpdateStatus(true) + default: + return nil, fmt.Errorf("invalid channel: %s", channel) + } +} + +func rpcGetLocalVersion() (*ota.LocalMetadata, error) { systemVersion, appVersion, err := GetLocalVersion() if err != nil { return nil, fmt.Errorf("error getting local version: %w", err) } - return &LocalMetadata{ + return &ota.LocalMetadata{ AppVersion: appVersion.String(), SystemVersion: systemVersion.String(), }, nil @@ -277,7 +294,7 @@ func rpcGetLocalVersion() (*LocalMetadata, error) { func rpcTryUpdate() error { includePreRelease := config.IncludePreRelease go func() { - err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease) + err := otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease) if err != nil { logger.Warn().Err(err).Msg("failed to try update") } @@ -654,7 +671,7 @@ func rpcGetMassStorageMode() (string, error) { } func rpcIsUpdatePending() (bool, error) { - return IsUpdatePending(), nil + return otaState.IsUpdatePending(), nil } func rpcGetUsbEmulationState() (bool, error) { @@ -1200,6 +1217,7 @@ var rpcHandlers = map[string]RPCHandler{ "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "getLocalVersion": {Func: rpcGetLocalVersion}, "getUpdateStatus": {Func: rpcGetUpdateStatus}, + "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, diff --git a/main.go b/main.go index bcc2d73dc..b6fc469d6 100644 --- a/main.go +++ b/main.go @@ -32,12 +32,6 @@ func Main() { Msg("starting JetKVM") go runWatchdog() - go confirmCurrentSystem() - - initDisplay() - initNative(systemVersionLocal, appVersionLocal) - - http.DefaultClient.Timeout = 1 * time.Minute err = rootcerts.UpdateDefaultTransport() if err != nil { @@ -47,6 +41,13 @@ func Main() { Int("ca_certs_loaded", len(rootcerts.Certs())). Msg("loaded Root CA certificates") + initOta() + + initDisplay() + initNative(systemVersionLocal, appVersionLocal) + + http.DefaultClient.Timeout = 1 * time.Minute + // Initialize network if err := initNetwork(); err != nil { logger.Error().Err(err).Msg("failed to initialize network") @@ -106,7 +107,7 @@ func Main() { } includePreRelease := config.IncludePreRelease - err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease) + err = otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease) if err != nil { logger.Warn().Err(err).Msg("failed to auto update") } diff --git a/network.go b/network.go index 846f41f1c..25e562a06 100644 --- a/network.go +++ b/network.go @@ -8,6 +8,7 @@ import ( "github.com/jetkvm/kvm/internal/confparser" "github.com/jetkvm/kvm/internal/mdns" "github.com/jetkvm/kvm/internal/network/types" + "github.com/jetkvm/kvm/internal/ota" "github.com/jetkvm/kvm/pkg/nmlite" ) @@ -176,7 +177,7 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error { return nm.SetHostname(hostname, domain) } -func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) { +func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) { oldDhcpClient := oldConfig.DHCPClient.String l := networkLogger.With(). @@ -200,7 +201,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re l.Info().Msg("IPv4 mode changed with udhcpc, reboot required") if newIPv4Mode == "static" && oldIPv4Mode != "static" { - postRebootAction = &PostRebootAction{ + postRebootAction = &ota.PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } @@ -217,7 +218,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re // Handle IP change for redirect (only if both are not nil and IP changed) if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil && newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String { - postRebootAction = &PostRebootAction{ + postRebootAction = &ota.PostRebootAction{ HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String), RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String), } diff --git a/ota.go b/ota.go index 5371e4288..90bd8f28f 100644 --- a/ota.go +++ b/ota.go @@ -1,59 +1,61 @@ package kvm import ( - "bytes" - "context" - "crypto/sha256" - "crypto/tls" - "encoding/hex" - "encoding/json" "fmt" - "io" "net/http" - "net/url" "os" - "os/exec" "strings" - "time" "github.com/Masterminds/semver/v3" - "github.com/gwatts/rootcerts" - "github.com/rs/zerolog" + "github.com/jetkvm/kvm/internal/ota" ) -type UpdateMetadata struct { - AppVersion string `json:"appVersion"` - AppUrl string `json:"appUrl"` - AppHash string `json:"appHash"` - SystemVersion string `json:"systemVersion"` - SystemUrl string `json:"systemUrl"` - SystemHash string `json:"systemHash"` -} +var builtAppVersion = "0.1.0+dev" -type LocalMetadata struct { - AppVersion string `json:"appVersion"` - SystemVersion string `json:"systemVersion"` -} +var otaState *ota.State -// UpdateStatus represents the current update status -type UpdateStatus struct { - Local *LocalMetadata `json:"local"` - Remote *UpdateMetadata `json:"remote"` - SystemUpdateAvailable bool `json:"systemUpdateAvailable"` - AppUpdateAvailable bool `json:"appUpdateAvailable"` +func initOta() { + otaState = ota.NewState(ota.Options{ + Logger: otaLogger, + ReleaseAPIEndpoint: config.GetUpdateAPIURL(), + GetHTTPClient: func() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = config.NetworkConfig.GetTransportProxyFunc() - // for backwards compatibility - Error string `json:"error,omitempty"` + client := &http.Client{ + Transport: transport, + } + return client + }, + GetLocalVersion: GetLocalVersion, + HwReboot: hwReboot, + OnStateUpdate: func(state *ota.RPCState) { + triggerOTAStateUpdate(state) + }, + OnProgressUpdate: func(progress float32) { + writeJSONRPCEvent("otaProgress", progress, currentSession) + }, + }) } -const UpdateMetadataUrl = "https://api.jetkvm.com/releases" - -var builtAppVersion = "0.1.0+dev" +func triggerOTAStateUpdate(state *ota.RPCState) { + go func() { + if currentSession == nil || (otaState == nil && state == nil) { + return + } + if state == nil { + state = otaState.ToRPCState() + } + writeJSONRPCEvent("otaState", state, currentSession) + }() +} +// GetBuiltAppVersion returns the built-in app version func GetBuiltAppVersion() string { return builtAppVersion } +// GetLocalVersion returns the local version of the system and app func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { appVersion, err = semver.NewVersion(builtAppVersion) if err != nil { @@ -72,520 +74,3 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio return systemVersion, appVersion, nil } - -func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) { - metadata := &UpdateMetadata{} - - updateUrl, err := url.Parse(UpdateMetadataUrl) - if err != nil { - return nil, fmt.Errorf("error parsing update metadata URL: %w", err) - } - - query := updateUrl.Query() - query.Set("deviceId", deviceId) - query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) - updateUrl.RawQuery = query.Encode() - - logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates") - - req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = config.NetworkConfig.GetTransportProxyFunc() - - client := &http.Client{ - Transport: transport, - } - - 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) - } - - err = json.NewDecoder(resp.Body).Decode(metadata) - if err != nil { - return nil, fmt.Errorf("error decoding response: %w", err) - } - - return metadata, nil -} - -func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error { - if _, err := os.Stat(path); err == nil { - if err := os.Remove(path); err != nil { - return fmt.Errorf("error removing existing file: %w", err) - } - } - - unverifiedPath := path + ".unverified" - if _, err := os.Stat(unverifiedPath); err == nil { - if err := os.Remove(unverifiedPath); err != nil { - return fmt.Errorf("error removing existing unverified file: %w", err) - } - } - - file, err := os.Create(unverifiedPath) - if err != nil { - return fmt.Errorf("error creating file: %w", err) - } - defer file.Close() - - req, err := http.NewRequestWithContext(ctx, "GET", url, nil) - if err != nil { - return fmt.Errorf("error creating request: %w", err) - } - - client := http.Client{ - Timeout: 10 * time.Minute, - Transport: &http.Transport{ - Proxy: config.NetworkConfig.GetTransportProxyFunc(), - TLSHandshakeTimeout: 30 * time.Second, - TLSClientConfig: &tls.Config{ - RootCAs: rootcerts.ServerCertPool(), - }, - }, - } - - resp, err := client.Do(req) - if err != nil { - return fmt.Errorf("error downloading file: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - totalSize := resp.ContentLength - if totalSize <= 0 { - return fmt.Errorf("invalid content length") - } - - var written int64 - buf := make([]byte, 32*1024) - for { - nr, er := resp.Body.Read(buf) - if nr > 0 { - nw, ew := file.Write(buf[0:nr]) - if nw < nr { - return fmt.Errorf("short file write: %d < %d", nw, nr) - } - written += int64(nw) - if ew != nil { - return fmt.Errorf("error writing to file: %w", ew) - } - progress := float32(written) / float32(totalSize) - if progress-*downloadProgress >= 0.01 { - *downloadProgress = progress - triggerOTAStateUpdate() - } - } - if er != nil { - if er == io.EOF { - break - } - return fmt.Errorf("error reading response body: %w", er) - } - } - - file.Close() - - // Flush filesystem buffers to ensure all data is written to disk - err = exec.Command("sync").Run() - if err != nil { - return fmt.Errorf("error flushing filesystem buffers: %w", err) - } - - // Clear the filesystem caches to force a read from disk - err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644) - if err != nil { - return fmt.Errorf("error clearing filesystem caches: %w", err) - } - - return nil -} - -func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error { - if scopedLogger == nil { - scopedLogger = otaLogger - } - - unverifiedPath := path + ".unverified" - fileToHash, err := os.Open(unverifiedPath) - if err != nil { - return fmt.Errorf("error opening file for hashing: %w", err) - } - defer fileToHash.Close() - - hash := sha256.New() - fileInfo, err := fileToHash.Stat() - if err != nil { - return fmt.Errorf("error getting file info: %w", err) - } - totalSize := fileInfo.Size() - - buf := make([]byte, 32*1024) - verified := int64(0) - - for { - nr, er := fileToHash.Read(buf) - if nr > 0 { - nw, ew := hash.Write(buf[0:nr]) - if nw < nr { - return fmt.Errorf("short hash write: %d < %d", nw, nr) - } - verified += int64(nw) - if ew != nil { - return fmt.Errorf("error writing to hash: %w", ew) - } - progress := float32(verified) / float32(totalSize) - if progress-*verifyProgress >= 0.01 { - *verifyProgress = progress - triggerOTAStateUpdate() - } - } - if er != nil { - if er == io.EOF { - break - } - return fmt.Errorf("error reading file: %w", er) - } - } - - // close the file so we can rename below - if err := fileToHash.Close(); err != nil { - return fmt.Errorf("error closing file: %w", err) - } - - hashSum := hex.EncodeToString(hash.Sum(nil)) - scopedLogger.Info().Str("path", path).Str("hash", hashSum).Msg("SHA256 hash of") - - if hashSum != expectedHash { - return fmt.Errorf("hash mismatch: %s != %s", hashSum, expectedHash) - } - - if err := os.Rename(unverifiedPath, path); err != nil { - return fmt.Errorf("error renaming file: %w", err) - } - - if err := os.Chmod(path, 0755); err != nil { - return fmt.Errorf("error making file executable: %w", err) - } - - return nil -} - -type OTAState struct { - Updating bool `json:"updating"` - Error string `json:"error,omitempty"` - MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` - AppUpdatePending bool `json:"appUpdatePending"` - SystemUpdatePending bool `json:"systemUpdatePending"` - AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar - AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` - SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar - SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` - AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` - AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` - SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` - SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` - AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar - AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` - SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement - SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` -} - -var otaState = OTAState{} - -func triggerOTAStateUpdate() { - go func() { - if currentSession == nil { - logger.Info().Msg("No active RPC session, skipping update state update") - return - } - writeJSONRPCEvent("otaState", otaState, currentSession) - }() -} - -func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error { - scopedLogger := otaLogger.With(). - Str("deviceId", deviceId). - Bool("includePreRelease", includePreRelease). - Logger() - - scopedLogger.Info().Msg("Trying to update...") - if otaState.Updating { - return fmt.Errorf("update already in progress") - } - - otaState = OTAState{ - Updating: true, - } - triggerOTAStateUpdate() - - defer func() { - otaState.Updating = false - triggerOTAStateUpdate() - }() - - updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease) - if err != nil { - otaState.Error = fmt.Sprintf("Error checking for updates: %v", err) - scopedLogger.Error().Err(err).Msg("Error checking for updates") - return fmt.Errorf("error checking for updates: %w", err) - } - - now := time.Now() - otaState.MetadataFetchedAt = &now - otaState.AppUpdatePending = updateStatus.AppUpdateAvailable - otaState.SystemUpdatePending = updateStatus.SystemUpdateAvailable - triggerOTAStateUpdate() - - local := updateStatus.Local - remote := updateStatus.Remote - appUpdateAvailable := updateStatus.AppUpdateAvailable - systemUpdateAvailable := updateStatus.SystemUpdateAvailable - - rebootNeeded := false - - if appUpdateAvailable { - scopedLogger.Info(). - Str("local", local.AppVersion). - Str("remote", remote.AppVersion). - Msg("App update available") - - err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress) - if err != nil { - otaState.Error = fmt.Sprintf("Error downloading app update: %v", err) - scopedLogger.Error().Err(err).Msg("Error downloading app update") - triggerOTAStateUpdate() - return fmt.Errorf("error downloading app update: %w", err) - } - - downloadFinished := time.Now() - otaState.AppDownloadFinishedAt = &downloadFinished - otaState.AppDownloadProgress = 1 - triggerOTAStateUpdate() - - err = verifyFile( - "/userdata/jetkvm/jetkvm_app.update", - remote.AppHash, - &otaState.AppVerificationProgress, - &scopedLogger, - ) - if err != nil { - otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err) - scopedLogger.Error().Err(err).Msg("Error verifying app update hash") - triggerOTAStateUpdate() - return fmt.Errorf("error verifying app update: %w", err) - } - - verifyFinished := time.Now() - otaState.AppVerifiedAt = &verifyFinished - otaState.AppVerificationProgress = 1 - triggerOTAStateUpdate() - - otaState.AppUpdatedAt = &verifyFinished - otaState.AppUpdateProgress = 1 - triggerOTAStateUpdate() - - scopedLogger.Info().Msg("App update downloaded") - rebootNeeded = true - triggerOTAStateUpdate() - } else { - scopedLogger.Info().Msg("App is up to date") - } - - if systemUpdateAvailable { - scopedLogger.Info(). - Str("local", local.SystemVersion). - Str("remote", remote.SystemVersion). - Msg("System update available") - - err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress) - if err != nil { - otaState.Error = fmt.Sprintf("Error downloading system update: %v", err) - scopedLogger.Error().Err(err).Msg("Error downloading system update") - triggerOTAStateUpdate() - return fmt.Errorf("error downloading system update: %w", err) - } - - downloadFinished := time.Now() - otaState.SystemDownloadFinishedAt = &downloadFinished - otaState.SystemDownloadProgress = 1 - triggerOTAStateUpdate() - - err = verifyFile( - "/userdata/jetkvm/update_system.tar", - remote.SystemHash, - &otaState.SystemVerificationProgress, - &scopedLogger, - ) - if err != nil { - otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err) - scopedLogger.Error().Err(err).Msg("Error verifying system update hash") - triggerOTAStateUpdate() - return fmt.Errorf("error verifying system update: %w", err) - } - - scopedLogger.Info().Msg("System update downloaded") - verifyFinished := time.Now() - otaState.SystemVerifiedAt = &verifyFinished - otaState.SystemVerificationProgress = 1 - triggerOTAStateUpdate() - - scopedLogger.Info().Msg("Starting rk_ota command") - cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all") - var b bytes.Buffer - cmd.Stdout = &b - cmd.Stderr = &b - err = cmd.Start() - if err != nil { - otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err) - scopedLogger.Error().Err(err).Msg("Error starting rk_ota command") - triggerOTAStateUpdate() - return fmt.Errorf("error starting rk_ota command: %w", err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go func() { - ticker := time.NewTicker(1800 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - if otaState.SystemUpdateProgress >= 0.99 { - return - } - otaState.SystemUpdateProgress += 0.01 - if otaState.SystemUpdateProgress > 0.99 { - otaState.SystemUpdateProgress = 0.99 - } - triggerOTAStateUpdate() - case <-ctx.Done(): - return - } - } - }() - - err = cmd.Wait() - cancel() - output := b.String() - if err != nil { - otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output) - scopedLogger.Error(). - Err(err). - Str("output", output). - Int("exitCode", cmd.ProcessState.ExitCode()). - Msg("Error executing rk_ota command") - triggerOTAStateUpdate() - return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output) - } - - scopedLogger.Info().Str("output", output).Msg("rk_ota success") - otaState.SystemUpdateProgress = 1 - otaState.SystemUpdatedAt = &verifyFinished - rebootNeeded = true - triggerOTAStateUpdate() - } else { - scopedLogger.Info().Msg("System is up to date") - } - - if rebootNeeded { - scopedLogger.Info().Msg("System Rebooting due to OTA update") - - // Build redirect URL with conditional query parameters - redirectTo := "/settings/general/update" - queryParams := url.Values{} - if systemUpdateAvailable { - queryParams.Set("systemVersion", remote.SystemVersion) - } - if appUpdateAvailable { - queryParams.Set("appVersion", remote.AppVersion) - } - if len(queryParams) > 0 { - redirectTo += "?" + queryParams.Encode() - } - - postRebootAction := &PostRebootAction{ - HealthCheck: "/device/status", - RedirectTo: redirectTo, - } - - if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil { - return fmt.Errorf("error requesting reboot: %w", err) - } - } - - return nil -} - -func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) { - updateStatus := &UpdateStatus{} - - // Get local versions - systemVersionLocal, appVersionLocal, err := GetLocalVersion() - if err != nil { - return updateStatus, fmt.Errorf("error getting local version: %w", err) - } - updateStatus.Local = &LocalMetadata{ - AppVersion: appVersionLocal.String(), - SystemVersion: systemVersionLocal.String(), - } - - // Get remote metadata - remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease) - if err != nil { - return updateStatus, fmt.Errorf("error checking for updates: %w", err) - } - updateStatus.Remote = remoteMetadata - - // Get remote versions - systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) - if err != nil { - return updateStatus, fmt.Errorf("error parsing remote system version: %w", err) - } - appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) - if err != nil { - return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) - } - - updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal) - updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal) - - // Handle pre-release updates - isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" - isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" - - if isRemoteSystemPreRelease && !includePreRelease { - updateStatus.SystemUpdateAvailable = false - } - if isRemoteAppPreRelease && !includePreRelease { - updateStatus.AppUpdateAvailable = false - } - - return updateStatus, nil -} - -func IsUpdatePending() bool { - return otaState.Updating -} - -// make sure our current a/b partition is set as default -func confirmCurrentSystem() { - output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput() - if err != nil { - logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") - } -} diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 0356e8e5d..78b045384 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -242,6 +242,7 @@ "general_auto_update_error": "Failed to set auto-update: {error}", "general_auto_update_title": "Auto Update", "general_check_for_updates": "Check for Updates", + "general_check_for_stable_updates": "Downgrade", "general_page_description": "Configure device settings and update preferences", "general_reboot_description": "Do you want to proceed with rebooting the system?", "general_reboot_device": "Reboot Device", diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index 94c2f99d1..78bbc313a 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { useDeviceStore } from "@/hooks/stores"; import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc"; @@ -53,5 +53,6 @@ export function useVersion() { getLocalVersion, appVersion, systemVersion, + isOnDevVersion, }; } diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index 86e92bcd1..7f70f2aa7 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -12,12 +12,13 @@ import notifications from "@/notifications"; import { getLocale, setLocale, locales, baseLocale } from '@localizations/runtime.js'; import { m } from "@localizations/messages.js"; import { deleteCookie, map_locale_code_to_name } from "@/utils"; +import { useVersion } from "@hooks/useVersion"; export default function SettingsGeneralRoute() { const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const [autoUpdate, setAutoUpdate] = useState(true); - + const { isOnDevVersion } = useVersion(); const currentVersions = useDeviceStore(state => { const { appVersion, systemVersion } = state; if (!appVersion || !systemVersion) return null; @@ -48,10 +49,10 @@ export default function SettingsGeneralRoute() { const localeOptions = useMemo(() => { return ["", ...locales] .map((code) => { - const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code); - // don't repeat the name if it's the same in both locales (or blank) - const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName; - return { value: code, label: label } + const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code); + // don't repeat the name if it's the same in both locales (or blank) + const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName; + return { value: code, label: label } }); }, [currentLocale]); @@ -74,6 +75,10 @@ export default function SettingsGeneralRoute() { notifications.success(m.locale_change_success({ locale: validLocale || m.locale_auto() })); }; + const downgradeAvailable = useMemo(() => { + return isOnDevVersion; + }, [isOnDevVersion]); + return (
} /> -
+
+ {downgradeAvailable &&
-
+ )} {selectedProvider === "custom" && ( -
+
-
+ )} )} diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 4c3c9e948..9576dbefe 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from "@components/ConfirmDialog"; import { GridCard } from "@components/Card"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { NestedSettingsGroup } from "@components/NestedSettingsGroup"; import { TextAreaWithLabel } from "@components/TextArea"; import { isOnDevice } from "@/main"; import notifications from "@/notifications"; @@ -201,41 +202,69 @@ export default function SettingsAdvancedRoute() { onChange={e => handleDevModeChange(e.target.checked)} />
- - {settings.developerMode && ( - -
- - - -
-
-

- {m.advanced_developer_mode_enabled_title()} -

-
-
    -
  • {m.advanced_developer_mode_warning_security()}
  • -
  • {m.advanced_developer_mode_warning_risks()}
  • -
+ {settings.developerMode ? ( + + +
+ + + +
+
+

+ {m.advanced_developer_mode_enabled_title()} +

+
+
    +
  • {m.advanced_developer_mode_warning_security()}
  • +
  • {m.advanced_developer_mode_warning_risks()}
  • +
+
+
+
+ {m.advanced_developer_mode_warning_advanced()}
-
- {m.advanced_developer_mode_warning_advanced()} +
+ + + {isOnDevice && ( +
+ + setSSHKey(e.target.value)} + placeholder={m.advanced_ssh_public_key_placeholder()} + /> +

+ {m.advanced_ssh_default_user()}root. +

+
+
-
-
- )} + )} +
+ ) : null} - {isOnDevice && settings.developerMode && ( -
- -
- setSSHKey(e.target.value)} - placeholder={m.advanced_ssh_public_key_placeholder()} - /> -

- {m.advanced_ssh_default_user()}root. -

-
-
-
-
- )} + {settings.debugMode && ( - <> + - + )}
diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx index 7f70f2aa7..55b92c006 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -12,13 +12,11 @@ import notifications from "@/notifications"; import { getLocale, setLocale, locales, baseLocale } from '@localizations/runtime.js'; import { m } from "@localizations/messages.js"; import { deleteCookie, map_locale_code_to_name } from "@/utils"; -import { useVersion } from "@hooks/useVersion"; export default function SettingsGeneralRoute() { const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const [autoUpdate, setAutoUpdate] = useState(true); - const { isOnDevVersion } = useVersion(); const currentVersions = useDeviceStore(state => { const { appVersion, systemVersion } = state; if (!appVersion || !systemVersion) return null; @@ -75,10 +73,6 @@ export default function SettingsGeneralRoute() { notifications.success(m.locale_change_success({ locale: validLocale || m.locale_auto() })); }; - const downgradeAvailable = useMemo(() => { - return isOnDevVersion; - }, [isOnDevVersion]); - return (
- {downgradeAvailable &&
+
Date: Wed, 29 Oct 2025 12:05:40 +0100 Subject: [PATCH 03/62] feat: add version update functionality to advanced settings --- ui/localization/messages/en.json | 12 +++ .../routes/devices.$id.settings.advanced.tsx | 82 +++++++++++++++++++ 2 files changed, 94 insertions(+) diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 78b045384..9091334ec 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -100,6 +100,18 @@ "advanced_update_ssh_key_button": "Update SSH Key", "advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_title": "USB Emulation", + "advanced_version_update_app_label": "App Version", + "advanced_version_update_button": "Update to Version", + "advanced_version_update_description": "Install a specific version from GitHub releases", + "advanced_version_update_github_link": "JetKVM releases page", + "advanced_version_update_helper": "Find available versions on the", + "advanced_version_update_system_label": "System Version", + "advanced_version_update_target_app": "App only", + "advanced_version_update_target_both": "Both App and System", + "advanced_version_update_target_label": "What to update", + "advanced_version_update_target_system": "System only", + "advanced_version_update_title": "Update to Specific Version", + "advanced_error_version_update": "Failed to initiate version update: {error}", "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.", "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.", "already_adopted_return_to_dashboard": "Return to Dashboard", diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 9576dbefe..f6af2fc9f 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsStore } from "@hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { Button } from "@components/Button"; import Checkbox from "@components/Checkbox"; import { ConfirmDialog } from "@components/ConfirmDialog"; @@ -10,6 +11,8 @@ import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { NestedSettingsGroup } from "@components/NestedSettingsGroup"; import { TextAreaWithLabel } from "@components/TextArea"; +import { InputFieldWithLabel } from "@components/InputField"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { isOnDevice } from "@/main"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; @@ -17,6 +20,7 @@ import { sleep } from "@/utils"; export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); + const { navigateTo } = useDeviceUiNavigation(); const [sshKey, setSSHKey] = useState(""); const { setDeveloperMode } = useSettingsStore(); @@ -24,6 +28,9 @@ export default function SettingsAdvancedRoute() { const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false); + const [updateTarget, setUpdateTarget] = useState("app"); + const [appVersion, setAppVersion] = useState(""); + const [systemVersion, setSystemVersion] = useState(""); const settings = useSettingsStore(); @@ -174,6 +181,21 @@ export default function SettingsAdvancedRoute() { setShowLoopbackWarning(false); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); + const handleVersionUpdate = useCallback(() => { + // TODO: Add version params to tryUpdate + console.log("tryUpdate", updateTarget, appVersion, systemVersion); + send("tryUpdate", {}, (resp: JsonRpcResponse) => { + if ("error" in resp) { + notifications.error( + m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() }) + ); + return; + } + // Navigate to update page + navigateTo("/settings/general/update"); + }); + }, [updateTarget, appVersion, systemVersion, send, navigateTo]); + return (
)} + +
+ + + setUpdateTarget(e.target.value)} + /> + + {(updateTarget === "app" || updateTarget === "both") && ( + setAppVersion(e.target.value)} + /> + )} + + {(updateTarget === "system" || updateTarget === "both") && ( + setSystemVersion(e.target.value)} + /> + )} + +

+ {m.advanced_version_update_helper()}{" "} + + {m.advanced_version_update_github_link()} + +

+ +
) : null} From 0a98a732752f0cf56f9d5a1902d66422b722768c Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 31 Oct 2025 15:50:41 +0000 Subject: [PATCH 04/62] feat: downgrade --- .vscode/settings.json | 2 +- internal/ota/app.go | 4 +- internal/ota/ota.go | 103 ++++++++++++---- internal/ota/state.go | 69 +++++++++-- internal/ota/sys.go | 8 +- internal/ota/utils.go | 4 +- jsonrpc.go | 68 +---------- main.go | 6 +- ota.go | 112 ++++++++++++++++++ scripts/dev_deploy.sh | 2 +- ui/localization/messages/en.json | 30 +++-- ui/src/hooks/stores.ts | 1 + ui/src/hooks/useVersion.tsx | 16 +++ .../routes/devices.$id.settings.advanced.tsx | 14 ++- .../devices.$id.settings.general.update.tsx | 57 +++++++++ 15 files changed, 365 insertions(+), 131 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf7..2fc7b8b37 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,5 @@ ] }, "git.ignoreLimitWarning": true, - "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" + "cmake.sourceDirectory": "/workspaces/kvm-sleep-mode/internal/native/cgo" } \ No newline at end of file diff --git a/internal/ota/app.go b/internal/ota/app.go index 482a07de7..5554549cd 100644 --- a/internal/ota/app.go +++ b/internal/ota/app.go @@ -34,7 +34,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) downloadFinished := time.Now() appUpdate.downloadFinishedAt = downloadFinished appUpdate.downloadProgress = 1 - s.onProgressUpdate() + s.triggerStateUpdate() if err := s.verifyFile( appUpdatePath, @@ -48,7 +48,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) appUpdate.verificationProgress = 1 appUpdate.updatedAt = verifyFinished appUpdate.updateProgress = 1 - s.onProgressUpdate() + s.triggerStateUpdate() l.Info().Msg("App update downloaded") diff --git a/internal/ota/ota.go b/internal/ota/ota.go index b45909ec7..21b4705c9 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -21,22 +21,45 @@ func (s *State) GetReleaseAPIEndpoint() string { return s.releaseAPIEndpoint } -func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) { - metadata := &UpdateMetadata{} - +// getUpdateURL returns the update URL for the given parameters +func (s *State) getUpdateURL(params UpdateParams) (string, error) { updateURL, err := url.Parse(s.releaseAPIEndpoint) if err != nil { - return nil, fmt.Errorf("error parsing update metadata URL: %w", err) + return "", fmt.Errorf("error parsing update metadata URL: %w", err) + } + + appTargetVersion := s.GetTargetVersion("app") + if appTargetVersion != "" && params.AppTargetVersion == "" { + params.AppTargetVersion = appTargetVersion + } + systemTargetVersion := s.GetTargetVersion("system") + if systemTargetVersion != "" && params.SystemTargetVersion == "" { + params.SystemTargetVersion = systemTargetVersion } query := updateURL.Query() - query.Set("deviceId", deviceID) - query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) + query.Set("deviceId", params.DeviceID) + query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease)) + if params.AppTargetVersion != "" { + query.Set("appVersion", params.AppTargetVersion) + } + if params.SystemTargetVersion != "" { + query.Set("systemVersion", params.SystemTargetVersion) + } updateURL.RawQuery = query.Encode() - logger.Info().Str("url", updateURL.String()).Msg("Checking for updates") + return updateURL.String(), nil +} + +func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) { + metadata := &UpdateMetadata{} + + url, err := s.getUpdateURL(params) + if err != nil { + return nil, fmt.Errorf("error getting update URL: %w", err) + } - req, err := http.NewRequestWithContext(ctx, "GET", updateURL.String(), nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) } @@ -61,38 +84,49 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includ return metadata, nil } -func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreRelease bool) error { +func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error { + return s.doUpdate(ctx, params) +} + +func (s *State) triggerStateUpdate() { + s.onStateUpdate(s.ToRPCState()) +} + +func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { scopedLogger := s.l.With(). - Str("deviceID", deviceID). - Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)). + Interface("params", params). Logger() - scopedLogger.Info().Msg("Trying to update...") + scopedLogger.Info().Msg("checking for updates") if s.updating { return fmt.Errorf("update already in progress") } s.updating = true - s.onProgressUpdate() + s.triggerStateUpdate() defer func() { s.updating = false - s.onProgressUpdate() + s.triggerStateUpdate() }() - appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) + appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) if err != nil { return s.componentUpdateError("Error checking for updates", err, &scopedLogger) } + if params.CheckOnly { + return nil + } + s.metadataFetchedAt = time.Now() - s.onProgressUpdate() + s.triggerStateUpdate() - if appUpdate.available { + if appUpdate.available || appUpdate.downgradeAvailable { appUpdate.pending = true } - if systemUpdate.available { + if systemUpdate.available || systemUpdate.downgradeAvailable { systemUpdate.pending = true } @@ -133,10 +167,18 @@ func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreReleas return nil } +// UpdateParams represents the parameters for the update +type UpdateParams struct { + DeviceID string `json:"deviceID"` + AppTargetVersion string `json:"appTargetVersion"` + SystemTargetVersion string `json:"systemTargetVersion"` + IncludePreRelease bool `json:"includePreRelease"` + CheckOnly bool `json:"checkOnly"` +} + func (s *State) getUpdateStatus( ctx context.Context, - deviceID string, - includePreRelease bool, + params UpdateParams, ) ( appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, @@ -144,7 +186,14 @@ func (s *State) getUpdateStatus( ) { appUpdate = &componentUpdateStatus{} systemUpdate = &componentUpdateStatus{} - err = nil + + if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok { + appUpdate = ¤tAppUpdate + } + + if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok { + systemUpdate = ¤tSystemUpdate + } // Get local versions systemVersionLocal, appVersionLocal, err := s.getLocalVersion() @@ -155,7 +204,7 @@ func (s *State) getUpdateStatus( systemUpdate.localVersion = systemVersionLocal.String() // Get remote metadata - remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease) + remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) if err != nil { err = fmt.Errorf("error checking for updates: %w", err) return @@ -175,6 +224,7 @@ func (s *State) getUpdateStatus( return } systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) + systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) if err != nil { @@ -182,15 +232,16 @@ func (s *State) getUpdateStatus( return } appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) + appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) // Handle pre-release updates isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" - if isRemoteSystemPreRelease && !includePreRelease { + if isRemoteSystemPreRelease && !params.IncludePreRelease { systemUpdate.available = false } - if isRemoteAppPreRelease && !includePreRelease { + if isRemoteAppPreRelease && !params.IncludePreRelease { appUpdate.available = false } @@ -201,8 +252,8 @@ func (s *State) getUpdateStatus( } // GetUpdateStatus returns the current update status (for backwards compatibility) -func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) { - _, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) +func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { + _, _, err := s.getUpdateStatus(ctx, params) if err != nil { return nil, fmt.Errorf("error getting update status: %w", err) } diff --git a/internal/ota/state.go b/internal/ota/state.go index 4375a08f5..6374edc92 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -1,6 +1,7 @@ package ota import ( + "fmt" "net/http" "sync" "time" @@ -27,10 +28,12 @@ type LocalMetadata struct { // UpdateStatus represents the current update status type UpdateStatus struct { - Local *LocalMetadata `json:"local"` - Remote *UpdateMetadata `json:"remote"` - SystemUpdateAvailable bool `json:"systemUpdateAvailable"` - AppUpdateAvailable bool `json:"appUpdateAvailable"` + Local *LocalMetadata `json:"local"` + Remote *UpdateMetadata `json:"remote"` + SystemUpdateAvailable bool `json:"systemUpdateAvailable"` + SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"` + AppUpdateAvailable bool `json:"appUpdateAvailable"` + AppDowngradeAvailable bool `json:"appDowngradeAvailable"` // for backwards compatibility Error string `json:"error,omitempty"` @@ -47,8 +50,10 @@ type PostRebootAction struct { type componentUpdateStatus struct { pending bool available bool + downgradeAvailable bool version string localVersion string + targetVersion string url string hash string downloadProgress float32 @@ -79,6 +84,8 @@ type RPCState struct { AppUpdatedAt time.Time `json:"appUpdatedAt,omitempty"` SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement SystemUpdatedAt time.Time `json:"systemUpdatedAt,omitempty"` + SystemTargetVersion string `json:"systemTargetVersion,omitempty"` + AppTargetVersion string `json:"appTargetVersion,omitempty"` } // HwRebootFunc is a function that reboots the hardware @@ -109,6 +116,40 @@ type State struct { client GetHTTPClientFunc reboot HwRebootFunc getLocalVersion GetLocalVersionFunc + onStateUpdate OnStateUpdateFunc +} + +// SetTargetVersion sets the target version for a component +func (s *State) SetTargetVersion(component string, version string) error { + parsedVersion := version + if version != "" { + // validate if it's a valid semver string first + semverVersion, err := semver.NewVersion(version) + if err != nil { + return fmt.Errorf("not a valid semantic version: %w", err) + } + parsedVersion = semverVersion.String() + } + + // check if the component exists + componentUpdate, ok := s.componentUpdateStatuses[component] + if !ok { + return fmt.Errorf("component %s not found", component) + } + + componentUpdate.targetVersion = parsedVersion + s.componentUpdateStatuses[component] = componentUpdate + + return nil +} + +// GetTargetVersion returns the target version for a component +func (s *State) GetTargetVersion(component string) string { + componentUpdate, ok := s.componentUpdateStatuses[component] + if !ok { + return "" + } + return componentUpdate.targetVersion } // ToUpdateStatus converts the State to the UpdateStatus @@ -136,9 +177,11 @@ func (s *State) ToUpdateStatus() *UpdateStatus { SystemURL: systemUpdate.url, SystemHash: systemUpdate.hash, }, - SystemUpdateAvailable: systemUpdate.available, - AppUpdateAvailable: appUpdate.available, - Error: s.error, + SystemUpdateAvailable: systemUpdate.available, + SystemDowngradeAvailable: systemUpdate.downgradeAvailable, + AppUpdateAvailable: appUpdate.available, + AppDowngradeAvailable: appUpdate.downgradeAvailable, + Error: s.error, } } @@ -160,12 +203,17 @@ type Options struct { // NewState creates a new OTA state func NewState(opts Options) *State { + components := make(map[string]componentUpdateStatus) + components["app"] = componentUpdateStatus{} + components["system"] = componentUpdateStatus{} + s := &State{ l: opts.Logger, client: opts.GetHTTPClient, reboot: opts.HwReboot, + onStateUpdate: opts.OnStateUpdate, getLocalVersion: opts.GetLocalVersion, - componentUpdateStatuses: make(map[string]componentUpdateStatus), + componentUpdateStatuses: components, releaseAPIEndpoint: opts.ReleaseAPIEndpoint, } go s.confirmCurrentSystem() @@ -189,6 +237,7 @@ func (s *State) ToRPCState() *RPCState { r.AppVerifiedAt = app.verifiedAt r.AppUpdateProgress = app.updateProgress r.AppUpdatedAt = app.updatedAt + r.AppTargetVersion = app.targetVersion } system, ok := s.componentUpdateStatuses["system"] @@ -200,10 +249,8 @@ func (s *State) ToRPCState() *RPCState { r.SystemVerifiedAt = system.verifiedAt r.SystemUpdateProgress = system.updateProgress r.SystemUpdatedAt = system.updatedAt + r.SystemTargetVersion = system.targetVersion } return r } - -func (s *State) onProgressUpdate() { -} diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 2426c3532..334fa1eb5 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -24,7 +24,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS downloadFinished := time.Now() systemUpdate.downloadFinishedAt = downloadFinished systemUpdate.downloadProgress = 1 - s.onProgressUpdate() + s.triggerStateUpdate() if err := s.verifyFile( systemUpdatePath, @@ -38,7 +38,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS systemUpdate.verificationProgress = 1 systemUpdate.updatedAt = verifyFinished systemUpdate.updateProgress = 1 - s.onProgressUpdate() + s.triggerStateUpdate() l.Info().Msg("System update downloaded") @@ -68,7 +68,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS if systemUpdate.updateProgress > 0.99 { systemUpdate.updateProgress = 0.99 } - s.onProgressUpdate() + s.triggerStateUpdate() case <-ctx.Done(): return } @@ -86,7 +86,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS rkLogger.Info().Msg("rk_ota success") systemUpdate.updateProgress = 1 systemUpdate.updatedAt = verifyFinished - s.onProgressUpdate() + s.triggerStateUpdate() return nil } diff --git a/internal/ota/utils.go b/internal/ota/utils.go index caa033843..88d99e4d1 100644 --- a/internal/ota/utils.go +++ b/internal/ota/utils.go @@ -82,7 +82,7 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, downl progress := float32(written) / float32(totalSize) if progress-*downloadProgress >= 0.01 { *downloadProgress = progress - s.onProgressUpdate() + s.triggerStateUpdate() } } if er != nil { @@ -136,7 +136,7 @@ func (s *State) verifyFile(path string, expectedHash string, verifyProgress *flo progress := float32(verified) / float32(totalSize) if progress-*verifyProgress >= 0.01 { *verifyProgress = progress - s.onProgressUpdate() + s.triggerStateUpdate() } } if er != nil { diff --git a/jsonrpc.go b/jsonrpc.go index 960e0bea5..e206617a9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -19,7 +19,6 @@ import ( "go.bug.st/serial" "github.com/jetkvm/kvm/internal/hidrpc" - "github.com/jetkvm/kvm/internal/ota" "github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/utils" ) @@ -237,71 +236,6 @@ func rpcGetVideoLogStatus() (string, error) { return nativeInstance.VideoLogStatus() } -func rpcGetDevChannelState() (bool, error) { - return config.IncludePreRelease, nil -} - -func rpcSetDevChannelState(enabled bool) error { - config.IncludePreRelease = enabled - if err := SaveConfig(); err != nil { - return fmt.Errorf("failed to save config: %w", err) - } - return nil -} - -func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { - updateStatus, err := otaState.GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease) - // to ensure backwards compatibility, - // if there's an error, we won't return an error, but we will set the error field - if err != nil { - if updateStatus == nil { - return nil, fmt.Errorf("error checking for updates: %w", err) - } - updateStatus.Error = err.Error() - } - - logger.Info().Interface("updateStatus", updateStatus).Msg("Update status") - - return updateStatus, nil -} - -func rpcGetUpdateStatus() (*ota.UpdateStatus, error) { - return getUpdateStatus(config.IncludePreRelease) -} - -func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) { - switch channel { - case "stable": - return getUpdateStatus(false) - case "dev": - return getUpdateStatus(true) - default: - return nil, fmt.Errorf("invalid channel: %s", channel) - } -} - -func rpcGetLocalVersion() (*ota.LocalMetadata, error) { - systemVersion, appVersion, err := GetLocalVersion() - if err != nil { - return nil, fmt.Errorf("error getting local version: %w", err) - } - return &ota.LocalMetadata{ - AppVersion: appVersion.String(), - SystemVersion: systemVersion.String(), - }, nil -} - -func rpcTryUpdate() error { - includePreRelease := config.IncludePreRelease - go func() { - err := otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease) - if err != nil { - logger.Warn().Err(err).Msg("failed to try update") - } - }() - return nil -} - func rpcSetDisplayRotation(params DisplayRotationSettings) error { currentRotation := config.DisplayRotation if currentRotation == params.Rotation { @@ -1219,6 +1153,8 @@ var rpcHandlers = map[string]RPCHandler{ "getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, + "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly"}}, + "cancelDowngrade": {Func: rpcCancelDowngrade}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "getSSHKeyState": {Func: rpcGetSSHKeyState}, diff --git a/main.go b/main.go index b6fc469d6..8e9899194 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "time" "github.com/gwatts/rootcerts" + "github.com/jetkvm/kvm/internal/ota" ) var appCtx context.Context @@ -107,7 +108,10 @@ func Main() { } includePreRelease := config.IncludePreRelease - err = otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease) + err = otaState.TryUpdate(context.Background(), ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + }) if err != nil { logger.Warn().Err(err).Msg("failed to auto update") } diff --git a/ota.go b/ota.go index 90bd8f28f..921cd353e 100644 --- a/ota.go +++ b/ota.go @@ -1,6 +1,7 @@ package kvm import ( + "context" "fmt" "net/http" "os" @@ -74,3 +75,114 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio return systemVersion, appVersion, nil } + +func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { + updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + }) + // to ensure backwards compatibility, + // if there's an error, we won't return an error, but we will set the error field + if err != nil { + if updateStatus == nil { + return nil, fmt.Errorf("error checking for updates: %w", err) + } + updateStatus.Error = err.Error() + } + + logger.Info().Interface("updateStatus", updateStatus).Msg("Update status") + + return updateStatus, nil +} + +func rpcGetDevChannelState() (bool, error) { + return config.IncludePreRelease, nil +} + +func rpcSetDevChannelState(enabled bool) error { + config.IncludePreRelease = enabled + if err := SaveConfig(); err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func rpcGetUpdateStatus() (*ota.UpdateStatus, error) { + return getUpdateStatus(config.IncludePreRelease) +} + +func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) { + switch channel { + case "stable": + return getUpdateStatus(false) + case "dev": + return getUpdateStatus(true) + default: + return nil, fmt.Errorf("invalid channel: %s", channel) + } +} + +func rpcGetLocalVersion() (*ota.LocalMetadata, error) { + systemVersion, appVersion, err := GetLocalVersion() + if err != nil { + return nil, fmt.Errorf("error getting local version: %w", err) + } + return &ota.LocalMetadata{ + AppVersion: appVersion.String(), + SystemVersion: systemVersion.String(), + }, nil +} + +// ComponentName represents the name of a component +type tryUpdateComponents struct { + AppTargetVersion string `json:"app"` + SystemTargetVersion string `json:"system"` +} + +func rpcTryUpdate() error { + return rpcTryUpdateComponents(tryUpdateComponents{ + AppTargetVersion: "", + SystemTargetVersion: "", + }, config.IncludePreRelease, false) +} + +func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bool, checkOnly bool) error { + updateParams := ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + CheckOnly: checkOnly, + } + + logger.Info().Interface("components", components).Msg("components") + + if components.AppTargetVersion != "" { + updateParams.AppTargetVersion = components.AppTargetVersion + if err := otaState.SetTargetVersion("app", components.AppTargetVersion); err != nil { + return fmt.Errorf("failed to set app target version: %w", err) + } + } + if components.SystemTargetVersion != "" { + updateParams.SystemTargetVersion = components.SystemTargetVersion + if err := otaState.SetTargetVersion("system", components.SystemTargetVersion); err != nil { + return fmt.Errorf("failed to set system target version: %w", err) + } + } + + go func() { + err := otaState.TryUpdate(context.Background(), updateParams) + if err != nil { + logger.Warn().Err(err).Msg("failed to try update") + } + }() + return nil +} + +func rpcCancelDowngrade() error { + if err := otaState.SetTargetVersion("app", ""); err != nil { + return fmt.Errorf("failed to set app target version: %w", err) + } + if err := otaState.SetTargetVersion("system", ""); err != nil { + return fmt.Errorf("failed to set system target version: %w", err) + } + return nil +} diff --git a/scripts/dev_deploy.sh b/scripts/dev_deploy.sh index 6c8b204c0..c2afb5cfc 100755 --- a/scripts/dev_deploy.sh +++ b/scripts/dev_deploy.sh @@ -280,4 +280,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d EOF fi -echo "Deployment complete." +echo "Deployment complete." \ No newline at end of file diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 9091334ec..69a44fdae 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -100,18 +100,6 @@ "advanced_update_ssh_key_button": "Update SSH Key", "advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_title": "USB Emulation", - "advanced_version_update_app_label": "App Version", - "advanced_version_update_button": "Update to Version", - "advanced_version_update_description": "Install a specific version from GitHub releases", - "advanced_version_update_github_link": "JetKVM releases page", - "advanced_version_update_helper": "Find available versions on the", - "advanced_version_update_system_label": "System Version", - "advanced_version_update_target_app": "App only", - "advanced_version_update_target_both": "Both App and System", - "advanced_version_update_target_label": "What to update", - "advanced_version_update_target_system": "System only", - "advanced_version_update_title": "Update to Specific Version", - "advanced_error_version_update": "Failed to initiate version update: {error}", "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.", "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.", "already_adopted_return_to_dashboard": "Return to Dashboard", @@ -910,5 +898,21 @@ "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", + "advanced_version_update_app_label": "App Version", + "advanced_version_update_button": "Update to Version", + "advanced_version_update_description": "Install a specific version from GitHub releases", + "advanced_version_update_github_link": "JetKVM releases page", + "advanced_version_update_helper": "Find available versions on the", + "advanced_version_update_system_label": "System Version", + "advanced_version_update_target_app": "App only", + "advanced_version_update_target_both": "Both App and System", + "advanced_version_update_target_label": "What to update", + "advanced_version_update_target_system": "System only", + "advanced_version_update_title": "Update to Specific Version", + "advanced_error_version_update": "Failed to initiate version update: {error}", + "general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.", + "general_update_downgrade_available_title": "Downgrade Available", + "general_update_downgrade_button": "Downgrade Now", + "general_update_keep_current_button": "Keep Current Version" } diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index c56cb5f81..9f7fd226b 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -554,6 +554,7 @@ export type UpdateModalViews = | "updating" | "upToDate" | "updateAvailable" + | "updateDowngradeAvailable" | "updateCompleted" | "error"; diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index 78bbc313a..8e24116b4 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -5,6 +5,22 @@ import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc"; import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +import semver from "semver"; + +export interface VersionInfo { + appVersion: string; + systemVersion: string; +} + +export interface SystemVersionInfo { + local: VersionInfo; + remote?: VersionInfo; + systemUpdateAvailable: boolean; + systemDowngradeAvailable: boolean; + appUpdateAvailable: boolean; + appDowngradeAvailable: boolean; + error?: string; +} export function useVersion() { const { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index f6af2fc9f..84e28855f 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -182,9 +182,15 @@ export default function SettingsAdvancedRoute() { }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); const handleVersionUpdate = useCallback(() => { - // TODO: Add version params to tryUpdate - console.log("tryUpdate", updateTarget, appVersion, systemVersion); - send("tryUpdate", {}, (resp: JsonRpcResponse) => { + const params = { + components: { + app: appVersion, + system: systemVersion, + }, + includePreRelease: devChannel, + checkOnly: true, + }; + send("tryUpdateComponents", params, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() }) @@ -194,7 +200,7 @@ export default function SettingsAdvancedRoute() { // Navigate to update page navigateTo("/settings/general/update"); }); - }, [updateTarget, appVersion, systemVersion, send, navigateTo]); + }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo]); return (
diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 285ce940d..cfd1b06a6 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -59,16 +59,21 @@ export function Dialog({ const [versionInfo, setVersionInfo] = useState(null); const { modalView, setModalView, otaState } = useUpdateStore(); + const { send } = useJsonRpc(); const onFinishedLoading = useCallback( (versionInfo: SystemVersionInfo) => { const hasUpdate = versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; + const hasDowngrade = + versionInfo?.systemDowngradeAvailable || versionInfo?.appDowngradeAvailable; setVersionInfo(versionInfo); if (hasUpdate) { setModalView("updateAvailable"); + } else if (hasDowngrade) { + setModalView("updateDowngradeAvailable"); } else { setModalView("upToDate"); } @@ -76,6 +81,11 @@ export function Dialog({ [setModalView], ); + const onCancelDowngrade = useCallback(() => { + send("cancelDowngrade", {}); + onClose(); + }, [onClose, send]); + return (
@@ -98,6 +108,13 @@ export function Dialog({ versionInfo={versionInfo!} /> )} + {modalView === "updateDowngradeAvailable" && ( + + )} {modalView === "updating" && ( void; + onCancelDowngrade: () => void; +}) { + return ( +
+
+

+ {m.general_update_downgrade_available_title()} +

+

+ {m.general_update_downgrade_available_description()} +

+

+ {versionInfo?.systemDowngradeAvailable ? ( + <> + {m.general_update_system_type()}: {versionInfo?.remote?.systemVersion} +
+ + ) : null} + {versionInfo?.appDowngradeAvailable ? ( + <> + {m.general_update_application_type()}: {versionInfo?.remote?.appVersion} + + ) : null} +

+
+
+
+
+ ); +} + function UpdateCompletedState({ onClose }: { onClose: () => void }) { return (
From 2b3f392f0f4f64e1cf4365b3ed685f3fa66b594a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 31 Oct 2025 16:15:42 +0000 Subject: [PATCH 05/62] cleanup: ota state --- internal/ota/app.go | 6 +- internal/ota/ota.go | 26 +++--- internal/ota/state.go | 81 +++++++++++-------- internal/ota/sys.go | 10 +-- internal/ota/utils.go | 15 +++- .../routes/devices.$id.settings.advanced.tsx | 2 +- 6 files changed, 84 insertions(+), 56 deletions(-) diff --git a/internal/ota/app.go b/internal/ota/app.go index 5554549cd..301ea9536 100644 --- a/internal/ota/app.go +++ b/internal/ota/app.go @@ -27,14 +27,14 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) l := s.l.With().Str("path", appUpdatePath).Logger() - if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, &appUpdate.downloadProgress); err != nil { + if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, "app"); err != nil { return s.componentUpdateError("Error downloading app update", err, &l) } downloadFinished := time.Now() appUpdate.downloadFinishedAt = downloadFinished appUpdate.downloadProgress = 1 - s.triggerStateUpdate() + s.triggerComponentUpdateState("app", appUpdate) if err := s.verifyFile( appUpdatePath, @@ -48,7 +48,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) appUpdate.verificationProgress = 1 appUpdate.updatedAt = verifyFinished appUpdate.updateProgress = 1 - s.triggerStateUpdate() + s.triggerComponentUpdateState("app", appUpdate) l.Info().Msg("App update downloaded") diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 21b4705c9..366ea9221 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -92,6 +92,11 @@ func (s *State) triggerStateUpdate() { s.onStateUpdate(s.ToRPCState()) } +func (s *State) triggerComponentUpdateState(component string, update *componentUpdateStatus) { + s.componentUpdateStatuses[component] = *update + s.triggerStateUpdate() +} + func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { scopedLogger := s.l.With(). Interface("params", params). @@ -102,32 +107,35 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { return fmt.Errorf("update already in progress") } - s.updating = true - s.triggerStateUpdate() - - defer func() { - s.updating = false + if !params.CheckOnly { + s.updating = true s.triggerStateUpdate() - }() + defer func() { + s.updating = false + s.triggerStateUpdate() + }() + } appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) if err != nil { return s.componentUpdateError("Error checking for updates", err, &scopedLogger) } + s.metadataFetchedAt = time.Now() + s.triggerStateUpdate() + if params.CheckOnly { return nil } - s.metadataFetchedAt = time.Now() - s.triggerStateUpdate() - if appUpdate.available || appUpdate.downgradeAvailable { appUpdate.pending = true + s.triggerComponentUpdateState("app", appUpdate) } if systemUpdate.available || systemUpdate.downgradeAvailable { systemUpdate.pending = true + s.triggerComponentUpdateState("system", systemUpdate) } if appUpdate.pending { diff --git a/internal/ota/state.go b/internal/ota/state.go index 6374edc92..9d9b8c012 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -67,25 +67,25 @@ type componentUpdateStatus struct { // RPCState represents the current OTA state for the RPC API type RPCState struct { - Updating bool `json:"updating"` - Error string `json:"error,omitempty"` - MetadataFetchedAt time.Time `json:"metadataFetchedAt,omitempty"` - AppUpdatePending bool `json:"appUpdatePending"` - SystemUpdatePending bool `json:"systemUpdatePending"` - AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar - AppDownloadFinishedAt time.Time `json:"appDownloadFinishedAt,omitempty"` - SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar - SystemDownloadFinishedAt time.Time `json:"systemDownloadFinishedAt,omitempty"` - AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` - AppVerifiedAt time.Time `json:"appVerifiedAt,omitempty"` - SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` - SystemVerifiedAt time.Time `json:"systemVerifiedAt,omitempty"` - AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar - AppUpdatedAt time.Time `json:"appUpdatedAt,omitempty"` - SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement - SystemUpdatedAt time.Time `json:"systemUpdatedAt,omitempty"` - SystemTargetVersion string `json:"systemTargetVersion,omitempty"` - AppTargetVersion string `json:"appTargetVersion,omitempty"` + Updating bool `json:"updating"` + Error string `json:"error,omitempty"` + MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` + AppUpdatePending bool `json:"appUpdatePending"` + SystemUpdatePending bool `json:"systemUpdatePending"` + AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar + AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` + SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar + SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` + AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"` + AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` + SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"` + SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` + AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar + AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` + SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement + SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` + SystemTargetVersion *string `json:"systemTargetVersion,omitempty"` + AppTargetVersion *string `json:"appTargetVersion,omitempty"` } // HwRebootFunc is a function that reboots the hardware @@ -221,35 +221,48 @@ func NewState(opts Options) *State { } // ToRPCState converts the State to the RPCState +// probably we need a generator for this ... func (s *State) ToRPCState() *RPCState { r := &RPCState{ Updating: s.updating, Error: s.error, - MetadataFetchedAt: s.metadataFetchedAt, + MetadataFetchedAt: &s.metadataFetchedAt, } app, ok := s.componentUpdateStatuses["app"] if ok { r.AppUpdatePending = app.pending - r.AppDownloadProgress = app.downloadProgress - r.AppDownloadFinishedAt = app.downloadFinishedAt - r.AppVerificationProgress = app.verificationProgress - r.AppVerifiedAt = app.verifiedAt - r.AppUpdateProgress = app.updateProgress - r.AppUpdatedAt = app.updatedAt - r.AppTargetVersion = app.targetVersion + r.AppDownloadProgress = &app.downloadProgress + if !app.downloadFinishedAt.IsZero() { + r.AppDownloadFinishedAt = &app.downloadFinishedAt + } + r.AppVerificationProgress = &app.verificationProgress + if !app.verifiedAt.IsZero() { + r.AppVerifiedAt = &app.verifiedAt + } + r.AppUpdateProgress = &app.updateProgress + if !app.updatedAt.IsZero() { + r.AppUpdatedAt = &app.updatedAt + } + r.AppTargetVersion = &app.targetVersion } system, ok := s.componentUpdateStatuses["system"] if ok { r.SystemUpdatePending = system.pending - r.SystemDownloadProgress = system.downloadProgress - r.SystemDownloadFinishedAt = system.downloadFinishedAt - r.SystemVerificationProgress = system.verificationProgress - r.SystemVerifiedAt = system.verifiedAt - r.SystemUpdateProgress = system.updateProgress - r.SystemUpdatedAt = system.updatedAt - r.SystemTargetVersion = system.targetVersion + r.SystemDownloadProgress = &system.downloadProgress + if !system.downloadFinishedAt.IsZero() { + r.SystemDownloadFinishedAt = &system.downloadFinishedAt + } + r.SystemVerificationProgress = &system.verificationProgress + if !system.verifiedAt.IsZero() { + r.SystemVerifiedAt = &system.verifiedAt + } + r.SystemUpdateProgress = &system.updateProgress + if !system.updatedAt.IsZero() { + r.SystemUpdatedAt = &system.updatedAt + } + r.SystemTargetVersion = &system.targetVersion } return r diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 334fa1eb5..575cf634b 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -17,14 +17,14 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS l := s.l.With().Str("path", systemUpdatePath).Logger() - if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, &systemUpdate.downloadProgress); err != nil { + if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, "system"); err != nil { return s.componentUpdateError("Error downloading system update", err, &l) } downloadFinished := time.Now() systemUpdate.downloadFinishedAt = downloadFinished systemUpdate.downloadProgress = 1 - s.triggerStateUpdate() + s.triggerComponentUpdateState("system", systemUpdate) if err := s.verifyFile( systemUpdatePath, @@ -38,7 +38,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS systemUpdate.verificationProgress = 1 systemUpdate.updatedAt = verifyFinished systemUpdate.updateProgress = 1 - s.triggerStateUpdate() + s.triggerComponentUpdateState("system", systemUpdate) l.Info().Msg("System update downloaded") @@ -68,7 +68,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS if systemUpdate.updateProgress > 0.99 { systemUpdate.updateProgress = 0.99 } - s.triggerStateUpdate() + s.triggerComponentUpdateState("system", systemUpdate) case <-ctx.Done(): return } @@ -86,7 +86,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS rkLogger.Info().Msg("rk_ota success") systemUpdate.updateProgress = 1 systemUpdate.updatedAt = verifyFinished - s.triggerStateUpdate() + s.triggerComponentUpdateState("system", systemUpdate) return nil } diff --git a/internal/ota/utils.go b/internal/ota/utils.go index 88d99e4d1..6da310ef0 100644 --- a/internal/ota/utils.go +++ b/internal/ota/utils.go @@ -25,7 +25,14 @@ func syncFilesystem() error { return nil } -func (s *State) downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error { +func (s *State) downloadFile(ctx context.Context, path string, url string, component string) error { + componentUpdate, ok := s.componentUpdateStatuses[component] + if !ok { + return fmt.Errorf("component %s not found", component) + } + + downloadProgress := componentUpdate.downloadProgress + if _, err := os.Stat(path); err == nil { if err := os.Remove(path); err != nil { return fmt.Errorf("error removing existing file: %w", err) @@ -80,9 +87,9 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, downl return fmt.Errorf("error writing to file: %w", ew) } progress := float32(written) / float32(totalSize) - if progress-*downloadProgress >= 0.01 { - *downloadProgress = progress - s.triggerStateUpdate() + if progress-downloadProgress >= 0.01 { + componentUpdate.downloadProgress = progress + s.triggerComponentUpdateState(component, &componentUpdate) } } if er != nil { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 84e28855f..b90b874c5 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -200,7 +200,7 @@ export default function SettingsAdvancedRoute() { // Navigate to update page navigateTo("/settings/general/update"); }); - }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo]); + }, [appVersion, systemVersion, devChannel, send, navigateTo]); return (
From f6b0b7297d42b75a2995c20a7e64f58860324735 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 31 Oct 2025 16:47:15 +0000 Subject: [PATCH 06/62] fix: update components --- internal/ota/logger.go | 5 -- internal/ota/ota.go | 30 ++++++++--- internal/ota/state.go | 2 +- internal/ota/sys.go | 2 + ota.go | 22 ++++---- ui/src/hooks/useVersion.tsx | 2 +- .../routes/devices.$id.settings.advanced.tsx | 8 ++- .../devices.$id.settings.general.update.tsx | 52 +++++++++++++++---- 8 files changed, 87 insertions(+), 36 deletions(-) delete mode 100644 internal/ota/logger.go diff --git a/internal/ota/logger.go b/internal/ota/logger.go deleted file mode 100644 index a13036de9..000000000 --- a/internal/ota/logger.go +++ /dev/null @@ -1,5 +0,0 @@ -package ota - -import "github.com/jetkvm/kvm/internal/logging" - -var logger = logging.GetSubsystemLogger("ota") diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 366ea9221..9e40e840c 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "net/url" + "slices" "time" "github.com/Masterminds/semver/v3" @@ -59,6 +60,10 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (* return nil, fmt.Errorf("error getting update URL: %w", err) } + s.l.Trace(). + Str("url", url). + Msg("fetching update metadata") + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, fmt.Errorf("error creating request: %w", err) @@ -107,6 +112,16 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { return fmt.Errorf("update already in progress") } + if len(params.Components) == 0 { + params.Components = []string{"app", "system"} + } + shouldUpdateApp := slices.Contains(params.Components, "app") + shouldUpdateSystem := slices.Contains(params.Components, "system") + + if !shouldUpdateApp && !shouldUpdateSystem { + return fmt.Errorf("no components to update") + } + if !params.CheckOnly { s.updating = true s.triggerStateUpdate() @@ -128,12 +143,12 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { return nil } - if appUpdate.available || appUpdate.downgradeAvailable { + if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) { appUpdate.pending = true s.triggerComponentUpdateState("app", appUpdate) } - if systemUpdate.available || systemUpdate.downgradeAvailable { + if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) { systemUpdate.pending = true s.triggerComponentUpdateState("system", systemUpdate) } @@ -177,11 +192,12 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { // UpdateParams represents the parameters for the update type UpdateParams struct { - DeviceID string `json:"deviceID"` - AppTargetVersion string `json:"appTargetVersion"` - SystemTargetVersion string `json:"systemTargetVersion"` - IncludePreRelease bool `json:"includePreRelease"` - CheckOnly bool `json:"checkOnly"` + DeviceID string `json:"deviceID"` + AppTargetVersion string `json:"appTargetVersion"` + SystemTargetVersion string `json:"systemTargetVersion"` + Components []string `json:"components,omitempty"` + IncludePreRelease bool `json:"includePreRelease"` + CheckOnly bool `json:"checkOnly"` } func (s *State) getUpdateStatus( diff --git a/internal/ota/state.go b/internal/ota/state.go index 9d9b8c012..0406b9261 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -62,7 +62,7 @@ type componentUpdateStatus struct { verifiedAt time.Time updateProgress float32 updatedAt time.Time - dependsOn []string + dependsOn []string //nolint:unused } // RPCState represents the current OTA state for the RPC API diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 575cf634b..465b9a4d5 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -84,6 +84,8 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) } rkLogger.Info().Msg("rk_ota success") + + s.rebootNeeded = true systemUpdate.updateProgress = 1 systemUpdate.updatedAt = verifyFinished s.triggerComponentUpdateState("system", systemUpdate) diff --git a/ota.go b/ota.go index 921cd353e..dd761bdca 100644 --- a/ota.go +++ b/ota.go @@ -137,6 +137,7 @@ func rpcGetLocalVersion() (*ota.LocalMetadata, error) { type tryUpdateComponents struct { AppTargetVersion string `json:"app"` SystemTargetVersion string `json:"system"` + Components string `json:"components,omitempty"` // components is a comma-separated list of components to update } func rpcTryUpdate() error { @@ -155,17 +156,18 @@ func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bo logger.Info().Interface("components", components).Msg("components") - if components.AppTargetVersion != "" { - updateParams.AppTargetVersion = components.AppTargetVersion - if err := otaState.SetTargetVersion("app", components.AppTargetVersion); err != nil { - return fmt.Errorf("failed to set app target version: %w", err) - } + updateParams.AppTargetVersion = components.AppTargetVersion + if err := otaState.SetTargetVersion("app", components.AppTargetVersion); err != nil { + return fmt.Errorf("failed to set app target version: %w", err) } - if components.SystemTargetVersion != "" { - updateParams.SystemTargetVersion = components.SystemTargetVersion - if err := otaState.SetTargetVersion("system", components.SystemTargetVersion); err != nil { - return fmt.Errorf("failed to set system target version: %w", err) - } + + updateParams.SystemTargetVersion = components.SystemTargetVersion + if err := otaState.SetTargetVersion("system", components.SystemTargetVersion); err != nil { + return fmt.Errorf("failed to set system target version: %w", err) + } + + if components.Components != "" { + updateParams.Components = strings.Split(components.Components, ",") } go func() { diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index 8e24116b4..64b0c6174 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -1,11 +1,11 @@ import { useCallback, useMemo } from "react"; +import semver from "semver"; import { useDeviceStore } from "@/hooks/stores"; import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc"; import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; -import semver from "semver"; export interface VersionInfo { appVersion: string; diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index b90b874c5..39fe73d30 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -197,10 +197,14 @@ export default function SettingsAdvancedRoute() { ); return; } + const pageParams = new URLSearchParams(); + pageParams.set("downgrade", "true"); + pageParams.set("components", updateTarget == "both" ? "app,system" : updateTarget); + // Navigate to update page - navigateTo("/settings/general/update"); + navigateTo(`/settings/general/update?${pageParams.toString()}`); }); - }, [appVersion, systemVersion, devChannel, send, navigateTo]); + }, [updateTarget,appVersion, systemVersion, devChannel, send, navigateTo]); return (
diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index cfd1b06a6..2f38af3ff 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useLocation, useNavigate } from "react-router"; +import { useLocation, useNavigate, useSearchParams } from "react-router"; import { useJsonRpc } from "@hooks/useJsonRpc"; import { UpdateState, useUpdateStore } from "@hooks/stores"; @@ -16,11 +16,16 @@ import { SystemVersionInfo } from "@/utils/jsonrpc"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); const location = useLocation(); + //@ts-ignore + const [searchParams, setSearchParams] = useSearchParams(); const { updateSuccess } = location.state || {}; const { setModalView, otaState } = useUpdateStore(); const { send } = useJsonRpc(); + const downgrade = useMemo(() => searchParams.get("downgrade") === "true", [searchParams]); + const updateComponents = useMemo(() => searchParams.get("components") || "", [searchParams]); + 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. @@ -33,6 +38,18 @@ export default function SettingsGeneralUpdateRoute() { setModalView("updating"); }, [send, setModalView]); + const onConfirmDowngrade = useCallback((system?: string, app?: string) => { + send("tryUpdateComponents", { + components: { + system, app, + components: updateComponents + }, + includePreRelease: true, + checkOnly: false, + }); + setModalView("updating"); + }, [send, setModalView, updateComponents]); + useEffect(() => { if (otaState.updating) { setModalView("updating"); @@ -45,15 +62,24 @@ export default function SettingsGeneralUpdateRoute() { } }, [otaState.error, otaState.updating, setModalView, updateSuccess]); - return ; + return ; } export function Dialog({ onClose, onConfirmUpdate, + onConfirmDowngrade, + downgrade, }: Readonly<{ + downgrade: boolean; onClose: () => void; onConfirmUpdate: () => void; + onConfirmDowngrade: () => void; }>) { const { navigateTo } = useDeviceUiNavigation(); @@ -70,15 +96,15 @@ export function Dialog({ setVersionInfo(versionInfo); - if (hasUpdate) { - setModalView("updateAvailable"); - } else if (hasDowngrade) { + if (hasDowngrade && downgrade) { setModalView("updateDowngradeAvailable"); + } else if (hasUpdate) { + setModalView("updateAvailable"); } else { setModalView("upToDate"); } }, - [setModalView], + [setModalView, downgrade], ); const onCancelDowngrade = useCallback(() => { @@ -110,7 +136,7 @@ export function Dialog({ )} {modalView === "updateDowngradeAvailable" && ( @@ -429,13 +455,19 @@ function UpdateAvailableState({ function UpdateDowngradeAvailableState({ versionInfo, - onConfirmUpdate, + onConfirmDowngrade, onCancelDowngrade, }: { versionInfo: SystemVersionInfo; - onConfirmUpdate: () => void; + onConfirmDowngrade: (system?: string, app?: string) => void; onCancelDowngrade: () => void; }) { + const confirmDowngrade = useCallback(() => { + onConfirmDowngrade( + versionInfo?.remote?.systemVersion || undefined, + versionInfo?.remote?.appVersion || undefined, + ); + }, [versionInfo, onConfirmDowngrade]); return (
@@ -459,7 +491,7 @@ function UpdateDowngradeAvailableState({ ) : null}

-
From 1e9dcc19861e8de8977c9480389461636438e5dc Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 31 Oct 2025 17:37:25 +0000 Subject: [PATCH 07/62] feat: allow configuration to be reset during update --- internal/ota/ota.go | 8 ++++++++ internal/ota/state.go | 6 ++++++ jsonrpc.go | 2 +- ota.go | 6 ++++-- ui/src/routes/devices.$id.settings.advanced.tsx | 4 ++++ ui/src/routes/devices.$id.settings.general.update.tsx | 2 ++ 6 files changed, 25 insertions(+), 3 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 9e40e840c..194c89596 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -177,6 +177,13 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { if s.rebootNeeded { scopedLogger.Info().Msg("System Rebooting due to OTA update") + if params.ResetConfig { + scopedLogger.Info().Msg("Resetting config") + if err := s.resetConfig(); err != nil { + return s.componentUpdateError("Error resetting config", err, &scopedLogger) + } + } + postRebootAction := &PostRebootAction{ HealthCheck: "/device/status", RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version), @@ -198,6 +205,7 @@ type UpdateParams struct { Components []string `json:"components,omitempty"` IncludePreRelease bool `json:"includePreRelease"` CheckOnly bool `json:"checkOnly"` + ResetConfig bool `json:"resetConfig"` } func (s *State) getUpdateStatus( diff --git a/internal/ota/state.go b/internal/ota/state.go index 0406b9261..a11a50f96 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -91,6 +91,9 @@ type RPCState struct { // HwRebootFunc is a function that reboots the hardware type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error +// ResetConfigFunc is a function that resets the config +type ResetConfigFunc func() error + // GetHTTPClientFunc is a function that returns the HTTP client type GetHTTPClientFunc func() *http.Client @@ -117,6 +120,7 @@ type State struct { reboot HwRebootFunc getLocalVersion GetLocalVersionFunc onStateUpdate OnStateUpdateFunc + resetConfig ResetConfigFunc } // SetTargetVersion sets the target version for a component @@ -199,6 +203,7 @@ type Options struct { OnProgressUpdate OnProgressUpdateFunc HwReboot HwRebootFunc ReleaseAPIEndpoint string + ResetConfig ResetConfigFunc } // NewState creates a new OTA state @@ -215,6 +220,7 @@ func NewState(opts Options) *State { getLocalVersion: opts.GetLocalVersion, componentUpdateStatuses: components, releaseAPIEndpoint: opts.ReleaseAPIEndpoint, + resetConfig: opts.ResetConfig, } go s.confirmCurrentSystem() return s diff --git a/jsonrpc.go b/jsonrpc.go index e206617a9..7e2540ced 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1153,7 +1153,7 @@ var rpcHandlers = map[string]RPCHandler{ "getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, - "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly"}}, + "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly", "resetConfig"}}, "cancelDowngrade": {Func: rpcCancelDowngrade}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, diff --git a/ota.go b/ota.go index dd761bdca..d85ce84be 100644 --- a/ota.go +++ b/ota.go @@ -30,6 +30,7 @@ func initOta() { }, GetLocalVersion: GetLocalVersion, HwReboot: hwReboot, + ResetConfig: rpcResetConfig, OnStateUpdate: func(state *ota.RPCState) { triggerOTAStateUpdate(state) }, @@ -144,14 +145,15 @@ func rpcTryUpdate() error { return rpcTryUpdateComponents(tryUpdateComponents{ AppTargetVersion: "", SystemTargetVersion: "", - }, config.IncludePreRelease, false) + }, config.IncludePreRelease, false, false) } -func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bool, checkOnly bool) error { +func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bool, checkOnly bool, resetConfig bool) error { updateParams := ota.UpdateParams{ DeviceID: GetDeviceID(), IncludePreRelease: includePreRelease, CheckOnly: checkOnly, + ResetConfig: resetConfig, } logger.Info().Interface("components", components).Msg("components") diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 39fe73d30..6df14becf 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -189,6 +189,8 @@ export default function SettingsAdvancedRoute() { }, includePreRelease: devChannel, checkOnly: true, + // no need to reset config for a check only update + resetConfig: false, }; send("tryUpdateComponents", params, (resp: JsonRpcResponse) => { if ("error" in resp) { @@ -199,6 +201,8 @@ export default function SettingsAdvancedRoute() { } const pageParams = new URLSearchParams(); pageParams.set("downgrade", "true"); + // TODO: implement this + pageParams.set("resetConfig", "true"); pageParams.set("components", updateTarget == "both" ? "app,system" : updateTarget); // Navigate to update page diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 2f38af3ff..00e4ba053 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -46,6 +46,8 @@ export default function SettingsGeneralUpdateRoute() { }, includePreRelease: true, checkOnly: false, + // TODO: implement this + resetConfig: false, }); setModalView("updating"); }, [send, setModalView, updateComponents]); From aa7c6fe082cbfe8c2b039a1d51fe1dc1c6d7a749 Mon Sep 17 00:00:00 2001 From: Adam Shiervani Date: Fri, 31 Oct 2025 18:51:51 +0100 Subject: [PATCH 08/62] feat: enhance version update settings with reset configuration option --- ui/localization/messages/en.json | 38 ++++++++++--------- .../routes/devices.$id.settings.advanced.tsx | 18 +++++++-- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 69a44fdae..c8a6e9488 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Failed to update SSH key: {error}", "advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}", "advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}", + "advanced_error_version_update": "Failed to initiate version update: {error}", "advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)", "advanced_loopback_only_title": "Loopback-Only Mode", "advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Update SSH Key", "advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_title": "USB Emulation", + "advanced_version_update_app_label": "App Version", + "advanced_version_update_button": "Update to Version", + "advanced_version_update_description": "Install a specific version from GitHub releases", + "advanced_version_update_github_link": "JetKVM releases page", + "advanced_version_update_helper": "Find available versions on the", + "advanced_version_update_reset_config_description": "Reset configuration after the update", + "advanced_version_update_reset_config_label": "Reset configuration", + "advanced_version_update_system_label": "System Version", + "advanced_version_update_target_app": "App only", + "advanced_version_update_target_both": "Both App and System", + "advanced_version_update_target_label": "What to update", + "advanced_version_update_target_system": "System only", + "advanced_version_update_title": "Update to Specific Version", "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.", "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.", "already_adopted_return_to_dashboard": "Return to Dashboard", @@ -241,8 +255,8 @@ "general_auto_update_description": "Automatically update the device to the latest version", "general_auto_update_error": "Failed to set auto-update: {error}", "general_auto_update_title": "Auto Update", - "general_check_for_updates": "Check for Updates", "general_check_for_stable_updates": "Downgrade", + "general_check_for_updates": "Check for Updates", "general_page_description": "Configure device settings and update preferences", "general_reboot_description": "Do you want to proceed with rebooting the system?", "general_reboot_device": "Reboot Device", @@ -262,9 +276,13 @@ "general_update_checking_title": "Checking for updates…", "general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!", "general_update_completed_title": "Update Completed Successfully", + "general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.", + "general_update_downgrade_available_title": "Downgrade Available", + "general_update_downgrade_button": "Downgrade Now", "general_update_error_description": "An error occurred while updating your device. Please try again later.", "general_update_error_details": "Error details: {errorMessage}", "general_update_error_title": "Update Error", + "general_update_keep_current_button": "Keep Current Version", "general_update_later_button": "Do it later", "general_update_now_button": "Update Now", "general_update_rebooting": "Rebooting to complete the update…", @@ -898,21 +916,5 @@ "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", - "advanced_version_update_app_label": "App Version", - "advanced_version_update_button": "Update to Version", - "advanced_version_update_description": "Install a specific version from GitHub releases", - "advanced_version_update_github_link": "JetKVM releases page", - "advanced_version_update_helper": "Find available versions on the", - "advanced_version_update_system_label": "System Version", - "advanced_version_update_target_app": "App only", - "advanced_version_update_target_both": "Both App and System", - "advanced_version_update_target_label": "What to update", - "advanced_version_update_target_system": "System only", - "advanced_version_update_title": "Update to Specific Version", - "advanced_error_version_update": "Failed to initiate version update: {error}", - "general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.", - "general_update_downgrade_available_title": "Downgrade Available", - "general_update_downgrade_button": "Downgrade Now", - "general_update_keep_current_button": "Keep Current Version" + "welcome_to_jetkvm_description": "Control any computer remotely" } diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 6df14becf..e0facfafd 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -4,7 +4,7 @@ import { useSettingsStore } from "@hooks/stores"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { Button } from "@components/Button"; -import Checkbox from "@components/Checkbox"; +import Checkbox, { CheckboxWithLabel } from "@components/Checkbox"; import { ConfirmDialog } from "@components/ConfirmDialog"; import { GridCard } from "@components/Card"; import { SettingsItem } from "@components/SettingsItem"; @@ -31,6 +31,7 @@ export default function SettingsAdvancedRoute() { const [updateTarget, setUpdateTarget] = useState("app"); const [appVersion, setAppVersion] = useState(""); const [systemVersion, setSystemVersion] = useState(""); + const [resetConfig, setResetConfig] = useState(false); const settings = useSettingsStore(); @@ -192,6 +193,7 @@ export default function SettingsAdvancedRoute() { // no need to reset config for a check only update resetConfig: false, }; + send("tryUpdateComponents", params, (resp: JsonRpcResponse) => { if ("error" in resp) { notifications.error( @@ -201,14 +203,13 @@ export default function SettingsAdvancedRoute() { } const pageParams = new URLSearchParams(); pageParams.set("downgrade", "true"); - // TODO: implement this - pageParams.set("resetConfig", "true"); + pageParams.set("resetConfig", resetConfig.toString()); pageParams.set("components", updateTarget == "both" ? "app,system" : updateTarget); // Navigate to update page navigateTo(`/settings/general/update?${pageParams.toString()}`); }); - }, [updateTarget,appVersion, systemVersion, devChannel, send, navigateTo]); + }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]); return (
@@ -347,6 +348,15 @@ export default function SettingsAdvancedRoute() {

+
+ setResetConfig(e.target.checked)} + /> +
+
+
+ +
+ setVersionChangeAcknowledged(e.target.checked)} + /> +
From ba76d5bbc9de0a5102f69ea886d47374d2c99b4a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 16:01:09 +0000 Subject: [PATCH 19/62] refactor(ui): simplify update dialog --- internal/ota/ota.go | 49 +++--- ota.go | 4 +- .../routes/devices.$id.settings.advanced.tsx | 6 +- .../devices.$id.settings.general.update.tsx | 148 +++++++----------- ui/src/utils/jsonrpc.ts | 4 +- 5 files changed, 91 insertions(+), 120 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 0459c3f27..e23010f55 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -220,6 +220,8 @@ type UpdateParams struct { ResetConfig bool `json:"resetConfig"` } +// getUpdateStatus gets the update status for the given components +// and updates the componentUpdateStatuses map func (s *State) getUpdateStatus( ctx context.Context, params UpdateParams, @@ -239,7 +241,7 @@ func (s *State) getUpdateStatus( systemUpdate = ¤tSystemUpdate } - err = s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) + err = s.checkUpdateStatus(ctx, params, appUpdate, systemUpdate) if err != nil { return nil, nil, err } @@ -250,21 +252,20 @@ func (s *State) getUpdateStatus( return appUpdate, systemUpdate, nil } -// doGetUpdateStatus is the internal function that gets the update status -// it WON'T change the state of the OTA state -func (s *State) doGetUpdateStatus( +// checkUpdateStatus checks the update status for the given components +func (s *State) checkUpdateStatus( ctx context.Context, params UpdateParams, - appUpdate *componentUpdateStatus, - systemUpdate *componentUpdateStatus, + appUpdateStatus *componentUpdateStatus, + systemUpdateStatus *componentUpdateStatus, ) error { // Get local versions systemVersionLocal, appVersionLocal, err := s.getLocalVersion() if err != nil { return fmt.Errorf("error getting local version: %w", err) } - appUpdate.localVersion = appVersionLocal.String() - systemUpdate.localVersion = systemVersionLocal.String() + appUpdateStatus.localVersion = appVersionLocal.String() + systemUpdateStatus.localVersion = systemVersionLocal.String() // Get remote metadata remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) @@ -276,13 +277,13 @@ func (s *State) doGetUpdateStatus( } return err } - appUpdate.url = remoteMetadata.AppURL - appUpdate.hash = remoteMetadata.AppHash - appUpdate.version = remoteMetadata.AppVersion + appUpdateStatus.url = remoteMetadata.AppURL + appUpdateStatus.hash = remoteMetadata.AppHash + appUpdateStatus.version = remoteMetadata.AppVersion - systemUpdate.url = remoteMetadata.SystemURL - systemUpdate.hash = remoteMetadata.SystemHash - systemUpdate.version = remoteMetadata.SystemVersion + systemUpdateStatus.url = remoteMetadata.SystemURL + systemUpdateStatus.hash = remoteMetadata.SystemHash + systemUpdateStatus.version = remoteMetadata.SystemVersion // Get remote versions systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) @@ -290,26 +291,26 @@ func (s *State) doGetUpdateStatus( err = fmt.Errorf("error parsing remote system version: %w", err) return err } - systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) - systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) + systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) + systemUpdateStatus.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) if err != nil { err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) return err } - appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) - appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) + appUpdateStatus.available = appVersionRemote.GreaterThan(appVersionLocal) + appUpdateStatus.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) // Handle pre-release updates isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" if isRemoteSystemPreRelease && !params.IncludePreRelease { - systemUpdate.available = false + systemUpdateStatus.available = false } if isRemoteAppPreRelease && !params.IncludePreRelease { - appUpdate.available = false + appUpdateStatus.available = false } return nil @@ -317,12 +318,12 @@ func (s *State) doGetUpdateStatus( // GetUpdateStatus returns the current update status (for backwards compatibility) func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { - appUpdate := &componentUpdateStatus{} - systemUpdate := &componentUpdateStatus{} - err := s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) + appUpdateStatus := componentUpdateStatus{} + systemUpdateStatus := componentUpdateStatus{} + err := s.checkUpdateStatus(ctx, params, &appUpdateStatus, &systemUpdateStatus) if err != nil { return nil, fmt.Errorf("error getting update status: %w", err) } - return toUpdateStatus(appUpdate, systemUpdate, ""), nil + return toUpdateStatus(&appUpdateStatus, &systemUpdateStatus, ""), nil } diff --git a/ota.go b/ota.go index b9d454d64..1ebd54e34 100644 --- a/ota.go +++ b/ota.go @@ -135,8 +135,8 @@ func rpcGetLocalVersion() (*ota.LocalMetadata, error) { } type updateParams struct { - AppTargetVersion string `json:"app"` - SystemTargetVersion string `json:"system"` + AppTargetVersion string `json:"appTargetVersion"` + SystemTargetVersion string `json:"systemTargetVersion"` Components string `json:"components,omitempty"` // components is a comma-separated list of components to update } diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 45b9d7059..b84d8ed7d 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -204,9 +204,8 @@ export default function SettingsAdvancedRoute() { setVersionUpdateLoading(true); versionInfo = await checkUpdateComponents({ components: components.join(","), - // TODO: Rename to appTargetVersion and systemTargetVersion - app: appVersion, - system: systemVersion, + appTargetVersion: appVersion, + systemTargetVersion: systemVersion, }, devChannel); console.log("versionInfo", versionInfo); } catch (error: unknown) { @@ -216,7 +215,6 @@ export default function SettingsAdvancedRoute() { } const pageParams = new URLSearchParams(); - pageParams.set("downgrade", "true"); if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) { pageParams.set("app", versionInfo.remote?.appVersion); } diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 6376945b7..20a026cc5 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -11,7 +11,7 @@ import LoadingSpinner from "@components/LoadingSpinner"; import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard"; import { m } from "@localizations/messages.js"; import { sleep } from "@/utils"; -import { SystemVersionInfo } from "@/utils/jsonrpc"; +import { checkUpdateComponents, SystemVersionInfo, updateParams } from "@/utils/jsonrpc"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); @@ -38,20 +38,16 @@ export default function SettingsGeneralUpdateRoute() { setModalView("updating"); }, [send, setModalView]); - const onConfirmDowngrade = useCallback(() => { + const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => { const components = []; - if (customSystemVersion) { - components.push("system"); - } - if (customAppVersion) { - components.push("app"); - } + if (appTargetVersion) components.push("system"); + if (systemTargetVersion) components.push("app"); send("tryUpdateComponents", { params: { components: components.join(","), - app: customAppVersion, - system: customSystemVersion, + appTargetVersion, + systemTargetVersion, }, includePreRelease: false, resetConfig, @@ -59,7 +55,7 @@ export default function SettingsGeneralUpdateRoute() { if ("error" in resp) return; setModalView("updating"); }); - }, [send, setModalView, customAppVersion, customSystemVersion, resetConfig]); + }, [send, setModalView, resetConfig]); useEffect(() => { if (otaState.updating) { @@ -76,7 +72,7 @@ export default function SettingsGeneralUpdateRoute() { return ; @@ -85,13 +81,13 @@ export default function SettingsGeneralUpdateRoute() { export function Dialog({ onClose, onConfirmUpdate, - onConfirmDowngrade, + onConfirmCustomUpdate: onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, }: Readonly<{ onClose: () => void; onConfirmUpdate: () => void; - onConfirmDowngrade: () => void; + onConfirmCustomUpdate: (appVersion?: string, systemVersion?: string) => void; customAppVersion?: string; customSystemVersion?: string; }>) { @@ -99,32 +95,30 @@ export function Dialog({ const [versionInfo, setVersionInfo] = useState(null); const { modalView, setModalView, otaState } = useUpdateStore(); - const { send } = useJsonRpc(); + const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined; + const onConfirmCustomUpdate = useCallback(() => { + onConfirmCustomUpdateCallback( + customAppVersion !== undefined ? customAppVersion : versionInfo?.remote?.appVersion, + customSystemVersion !== undefined ? customSystemVersion : versionInfo?.remote?.systemVersion, + ); + }, [onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, versionInfo]); const onFinishedLoading = useCallback( (versionInfo: SystemVersionInfo) => { const hasUpdate = versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; - const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined; setVersionInfo(versionInfo); - if (forceCustomUpdate) { - setModalView("confirmCustomUpdate"); - } else if (hasUpdate) { + if (hasUpdate || forceCustomUpdate) { setModalView("updateAvailable"); } else { setModalView("upToDate"); } }, - [setModalView, customAppVersion, customSystemVersion], + [setModalView, forceCustomUpdate], ); - const onCancelDowngrade = useCallback(() => { - send("cancelDowngrade", {}); - onClose(); - }, [onClose, send]); - return (
@@ -137,24 +131,22 @@ export function Dialog({ )} {modalView === "loading" && ( - + )} {modalView === "updateAvailable" && ( )} - {modalView === "confirmCustomUpdate" && ( - - )} {modalView === "updating" && ( void; onCancelCheck: () => void; + customAppVersion?: string; + customSystemVersion?: string; }) { const [progressWidth, setProgressWidth] = useState("0%"); const abortControllerRef = useRef(null); @@ -190,6 +186,23 @@ function LoadingState({ const { setModalView } = useUpdateStore(); const progressBarRef = useRef(null); + + const checkUpdate = useCallback(async () => { + if (!customAppVersion && !customSystemVersion) { + return await getVersionInfo(); + } + const params : updateParams = { + components: "", + appTargetVersion: customAppVersion, + systemTargetVersion: customSystemVersion, + }; + if (customAppVersion) params.components += ",app"; + if (customSystemVersion) params.components += ",system"; + params.components = params.components?.replace(/^,+/, ""); + + return await checkUpdateComponents(params, false); + }, [customAppVersion, customSystemVersion, getVersionInfo]); + useEffect(() => { abortControllerRef.current = new AbortController(); const signal = abortControllerRef.current.signal; @@ -199,8 +212,7 @@ function LoadingState({ setProgressWidth("100%"); }, 0); - // TODO: CHECK FOR QUERY PARAMS - getVersionInfo() + checkUpdate() .then(async versionInfo => { // Add a small delay to ensure it's not just flickering await sleep(600); @@ -222,7 +234,7 @@ function LoadingState({ clearTimeout(animationTimer); abortControllerRef.current?.abort(); }; - }, [getVersionInfo, onFinished, setModalView]); + }, [checkUpdate, onFinished, setModalView]); return (
@@ -430,11 +442,13 @@ function SystemUpToDateState({ function UpdateAvailableState({ versionInfo, - onConfirmUpdate, + forceCustomUpdate, + onConfirm, onClose, }: { versionInfo: SystemVersionInfo; - onConfirmUpdate: () => void; + forceCustomUpdate: boolean; + onConfirm: () => void; onClose: () => void; }) { return ( @@ -444,23 +458,23 @@ function UpdateAvailableState({ {m.general_update_available_title()}

- {m.general_update_available_description()} + {forceCustomUpdate ? m.general_update_downgrade_available_description() : m.general_update_available_description()}

- {versionInfo?.systemUpdateAvailable ? ( + {(forceCustomUpdate ? versionInfo?.systemDowngradeAvailable : versionInfo?.systemUpdateAvailable) ? ( <> - {m.general_update_system_type()}: {versionInfo?.remote?.systemVersion} + {m.general_update_system_type()}: {versionInfo?.local?.systemVersion} {versionInfo?.remote?.systemVersion}
) : null} - {versionInfo?.appUpdateAvailable ? ( + {(forceCustomUpdate ? versionInfo?.appDowngradeAvailable : versionInfo?.appUpdateAvailable) ? ( <> - {m.general_update_application_type()}: {versionInfo?.remote?.appVersion} + {m.general_update_application_type()}: {versionInfo?.local?.appVersion} {versionInfo?.remote?.appVersion} ) : null}

-
@@ -468,48 +482,6 @@ function UpdateAvailableState({ ); } -function ConfirmCustomUpdate({ - appVersion, - systemVersion, - onConfirm, - onCancel, -}: { - appVersion?: string; - systemVersion?: string; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( -
-
-

- {m.general_update_downgrade_available_title()} -

-

- {m.general_update_downgrade_available_description()} -

-

- {systemVersion ? ( - <> - {m.general_update_system_type()}: {systemVersion} -
- - ) : null} - {appVersion ? ( - <> - {m.general_update_application_type()}: {appVersion} - - ) : null} -

-
-
-
-
- ); -} - function UpdateCompletedState({ onClose }: { onClose: () => void }) { return (
diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index 68ef0d473..b219a30ec 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -246,8 +246,8 @@ export async function getLocalVersion() { } export interface updateParams { - app?: string; - system?: string; + appTargetVersion?: string; + systemTargetVersion?: string; components?: string; } From 9372afed6be4f3265d82f7ca962192ec6dbf7f86 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 16:16:58 +0000 Subject: [PATCH 20/62] fix(ui): correct custom update components order --- ui/src/routes/devices.$id.settings.general.update.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 20a026cc5..7e75d578c 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -40,8 +40,8 @@ export default function SettingsGeneralUpdateRoute() { const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => { const components = []; - if (appTargetVersion) components.push("system"); - if (systemTargetVersion) components.push("app"); + if (appTargetVersion) components.push("app"); + if (systemTargetVersion) components.push("system"); send("tryUpdateComponents", { params: { @@ -97,6 +97,7 @@ export function Dialog({ const { modalView, setModalView, otaState } = useUpdateStore(); const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined; const onConfirmCustomUpdate = useCallback(() => { + console.debug("onConfirmCustomUpdate", customAppVersion, customSystemVersion, versionInfo); onConfirmCustomUpdateCallback( customAppVersion !== undefined ? customAppVersion : versionInfo?.remote?.appVersion, customSystemVersion !== undefined ? customSystemVersion : versionInfo?.remote?.systemVersion, From a246ef12135f923db9c11401c7b6c6aff6eb22b5 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 16:21:04 +0000 Subject: [PATCH 21/62] chore(ui): rename custom update query parameters --- ui/src/routes/devices.$id.settings.advanced.tsx | 6 +++--- ui/src/routes/devices.$id.settings.general.update.tsx | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index b84d8ed7d..2d251598d 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -216,12 +216,12 @@ export default function SettingsAdvancedRoute() { const pageParams = new URLSearchParams(); if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) { - pageParams.set("app", versionInfo.remote?.appVersion); + pageParams.set("custom_app_version", versionInfo.remote?.appVersion); } if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemDowngradeAvailable) { - pageParams.set("system", versionInfo.remote?.systemVersion); + pageParams.set("custom_system_version", versionInfo.remote?.systemVersion); } - pageParams.set("resetConfig", resetConfig.toString()); + pageParams.set("reset_config", resetConfig.toString()); // Navigate to update page navigateTo(`/settings/general/update?${pageParams.toString()}`); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 7e75d578c..82be6b9bc 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -22,9 +22,9 @@ export default function SettingsGeneralUpdateRoute() { const { setModalView, otaState } = useUpdateStore(); const { send } = useJsonRpc(); - const customAppVersion = useMemo(() => searchParams.get("app") || "", [searchParams]); - const customSystemVersion = useMemo(() => searchParams.get("system") || "", [searchParams]); - const resetConfig = useMemo(() => searchParams.get("resetConfig") === "true", [searchParams]); + const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || "", [searchParams]); + const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || "", [searchParams]); + const resetConfig = useMemo(() => searchParams.get("reset_config") === "true", [searchParams]); const onClose = useCallback(async () => { navigate(".."); // back to the devices.$id.settings page From 8bd3d4cfcf741942bb4c46d65048313ce3f40bd0 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 16:31:44 +0000 Subject: [PATCH 22/62] fix: undefined custom update versions --- ui/src/routes/devices.$id.settings.general.update.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 82be6b9bc..ea2dde495 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -22,8 +22,8 @@ export default function SettingsGeneralUpdateRoute() { const { setModalView, otaState } = useUpdateStore(); const { send } = useJsonRpc(); - const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || "", [searchParams]); - const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || "", [searchParams]); + const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || undefined, [searchParams]); + const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || undefined, [searchParams]); const resetConfig = useMemo(() => searchParams.get("reset_config") === "true", [searchParams]); const onClose = useCallback(async () => { @@ -459,7 +459,7 @@ function UpdateAvailableState({ {m.general_update_available_title()}

- {forceCustomUpdate ? m.general_update_downgrade_available_description() : m.general_update_available_description()} + {m.general_update_available_description()}

{(forceCustomUpdate ? versionInfo?.systemDowngradeAvailable : versionInfo?.systemUpdateAvailable) ? ( @@ -475,7 +475,7 @@ function UpdateAvailableState({ ) : null}

-
From 9832be29ef76f9c2a1b7272d371019d285b30fb4 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 7 Nov 2025 17:13:04 +0000 Subject: [PATCH 23/62] refactor: remove downgrade attributes from ota state and jsonrpc --- internal/ota/ota.go | 15 ++++++++---- internal/ota/state.go | 19 ++++++--------- jsonrpc.go | 1 - ota.go | 10 -------- ui/src/hooks/stores.ts | 1 - ui/src/hooks/useVersion.tsx | 2 -- .../routes/devices.$id.settings.advanced.tsx | 24 +++++++++++++++---- .../devices.$id.settings.general.update.tsx | 5 ++-- ui/src/utils/jsonrpc.ts | 2 -- 9 files changed, 40 insertions(+), 39 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index e23010f55..272a5414d 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -152,12 +152,12 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { return nil } - if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) { + if shouldUpdateApp && appUpdate.available { appUpdate.pending = true s.triggerComponentUpdateState("app", appUpdate) } - if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) { + if shouldUpdateSystem && systemUpdate.available { systemUpdate.pending = true s.triggerComponentUpdateState("system", systemUpdate) } @@ -292,7 +292,6 @@ func (s *State) checkUpdateStatus( return err } systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) - systemUpdateStatus.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) if err != nil { @@ -300,7 +299,6 @@ func (s *State) checkUpdateStatus( return err } appUpdateStatus.available = appVersionRemote.GreaterThan(appVersionLocal) - appUpdateStatus.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) // Handle pre-release updates isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" @@ -313,6 +311,15 @@ func (s *State) checkUpdateStatus( appUpdateStatus.available = false } + // Handle custom target versions + if slices.Contains(params.Components, "app") && params.AppTargetVersion != "" { + appUpdateStatus.available = appVersionRemote.String() != appUpdateStatus.localVersion + } + + if slices.Contains(params.Components, "system") && params.SystemTargetVersion != "" { + systemUpdateStatus.available = systemVersionRemote.String() != systemUpdateStatus.localVersion + } + return nil } diff --git a/internal/ota/state.go b/internal/ota/state.go index f6a35e40f..d8aa4399e 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -28,12 +28,10 @@ type LocalMetadata struct { // UpdateStatus represents the current update status type UpdateStatus struct { - Local *LocalMetadata `json:"local"` - Remote *UpdateMetadata `json:"remote"` - SystemUpdateAvailable bool `json:"systemUpdateAvailable"` - SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"` - AppUpdateAvailable bool `json:"appUpdateAvailable"` - AppDowngradeAvailable bool `json:"appDowngradeAvailable"` + Local *LocalMetadata `json:"local"` + Remote *UpdateMetadata `json:"remote"` + SystemUpdateAvailable bool `json:"systemUpdateAvailable"` + AppUpdateAvailable bool `json:"appUpdateAvailable"` // for backwards compatibility Error string `json:"error,omitempty"` @@ -50,7 +48,6 @@ type PostRebootAction struct { type componentUpdateStatus struct { pending bool available bool - downgradeAvailable bool version string localVersion string targetVersion string @@ -170,11 +167,9 @@ func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpd SystemURL: systemUpdate.url, SystemHash: systemUpdate.hash, }, - SystemUpdateAvailable: systemUpdate.available, - SystemDowngradeAvailable: systemUpdate.downgradeAvailable, - AppUpdateAvailable: appUpdate.available, - AppDowngradeAvailable: appUpdate.downgradeAvailable, - Error: error, + SystemUpdateAvailable: systemUpdate.available, + AppUpdateAvailable: appUpdate.available, + Error: error, } } diff --git a/jsonrpc.go b/jsonrpc.go index 2810e4f9a..5b6d04579 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1155,7 +1155,6 @@ var rpcHandlers = map[string]RPCHandler{ "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}}, - "cancelDowngrade": {Func: rpcCancelDowngrade}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "getSSHKeyState": {Func: rpcGetSSHKeyState}, diff --git a/ota.go b/ota.go index 1ebd54e34..44fe0cd38 100644 --- a/ota.go +++ b/ota.go @@ -194,13 +194,3 @@ func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetCo }() return nil } - -func rpcCancelDowngrade() error { - if err := otaState.SetTargetVersion("app", ""); err != nil { - return fmt.Errorf("failed to set app target version: %w", err) - } - if err := otaState.SetTargetVersion("system", ""); err != nil { - return fmt.Errorf("failed to set system target version: %w", err) - } - return nil -} diff --git a/ui/src/hooks/stores.ts b/ui/src/hooks/stores.ts index 9f7fd226b..c56cb5f81 100644 --- a/ui/src/hooks/stores.ts +++ b/ui/src/hooks/stores.ts @@ -554,7 +554,6 @@ export type UpdateModalViews = | "updating" | "upToDate" | "updateAvailable" - | "updateDowngradeAvailable" | "updateCompleted" | "error"; diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx index 48af1f46d..feb996178 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -15,9 +15,7 @@ export interface SystemVersionInfo { local: VersionInfo; remote?: VersionInfo; systemUpdateAvailable: boolean; - systemDowngradeAvailable: boolean; appUpdateAvailable: boolean; - appDowngradeAvailable: boolean; error?: string; } diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 2d251598d..260db3ca1 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -185,10 +185,10 @@ export default function SettingsAdvancedRoute() { setShowLoopbackWarning(false); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); - const handleVersionUpdateError = useCallback((error?: JsonRpcError) => { + const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => { notifications.error( m.advanced_error_version_update({ - error: error?.data ?? error?.message ?? m.unknown_error() + error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error()) }), { duration: 1000 * 15 } // 15 seconds ); @@ -214,15 +214,31 @@ export default function SettingsAdvancedRoute() { return; } + console.debug("versionInfo", versionInfo, components.includes("app") && versionInfo.remote?.appVersion && versionInfo?.appUpdateAvailable, components.includes("system") && versionInfo.remote?.systemVersion && versionInfo?.systemUpdateAvailable); + console.debug("components", components); + console.debug("versionInfo.remote?.appVersion", versionInfo.remote?.appVersion); + console.debug("versionInfo.appUpdateAvailable", versionInfo?.appUpdateAvailable); + console.debug("versionInfo.remote?.systemVersion", versionInfo.remote?.systemVersion); + console.debug("versionInfo.systemUpdateAvailable", versionInfo?.systemUpdateAvailable); + + let hasUpdate = false; + const pageParams = new URLSearchParams(); - if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) { + if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appUpdateAvailable) { + hasUpdate = true; pageParams.set("custom_app_version", versionInfo.remote?.appVersion); } - if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemDowngradeAvailable) { + if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemUpdateAvailable) { + hasUpdate = true; pageParams.set("custom_system_version", versionInfo.remote?.systemVersion); } pageParams.set("reset_config", resetConfig.toString()); + if (!hasUpdate) { + handleVersionUpdateError("No update available"); + return; + } + // Navigate to update page navigateTo(`/settings/general/update?${pageParams.toString()}`); }, [ diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index ea2dde495..31aac2a07 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -443,7 +443,6 @@ function SystemUpToDateState({ function UpdateAvailableState({ versionInfo, - forceCustomUpdate, onConfirm, onClose, }: { @@ -462,13 +461,13 @@ function UpdateAvailableState({ {m.general_update_available_description()}

- {(forceCustomUpdate ? versionInfo?.systemDowngradeAvailable : versionInfo?.systemUpdateAvailable) ? ( + {versionInfo?.systemUpdateAvailable ? ( <> {m.general_update_system_type()}: {versionInfo?.local?.systemVersion} {versionInfo?.remote?.systemVersion}
) : null} - {(forceCustomUpdate ? versionInfo?.appDowngradeAvailable : versionInfo?.appUpdateAvailable) ? ( + {versionInfo?.appUpdateAvailable ? ( <> {m.general_update_application_type()}: {versionInfo?.local?.appVersion} {versionInfo?.remote?.appVersion} diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index b219a30ec..51d12127b 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -220,9 +220,7 @@ export interface SystemVersionInfo { local: VersionInfo; remote?: VersionInfo; systemUpdateAvailable: boolean; - systemDowngradeAvailable: boolean; appUpdateAvailable: boolean; - appDowngradeAvailable: boolean; error?: string; } From 3fab951d43c1d9d54802c109e4d1d499d99217da Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 13:19:21 +0000 Subject: [PATCH 24/62] fix(ota): should only check update if target version is specified --- internal/ota/ota.go | 24 +++++++++++++---- internal/ota/ota_test.go | 58 ++++++++++++++++++++++++++++++++++++++++ internal/ota/state.go | 5 +++- ota.go | 4 +-- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 internal/ota/ota_test.go diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 272a5414d..137ce023a 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -311,13 +311,27 @@ func (s *State) checkUpdateStatus( appUpdateStatus.available = false } - // Handle custom target versions - if slices.Contains(params.Components, "app") && params.AppTargetVersion != "" { - appUpdateStatus.available = appVersionRemote.String() != appUpdateStatus.localVersion + components := params.Components + // skip check if no components are specified + if len(components) == 0 { + return nil } - if slices.Contains(params.Components, "system") && params.SystemTargetVersion != "" { - systemUpdateStatus.available = systemVersionRemote.String() != systemUpdateStatus.localVersion + // TODO: simplify this + if slices.Contains(components, "app") { + if params.AppTargetVersion != "" { + appUpdateStatus.available = appVersionRemote.String() != appVersionLocal.String() + } + } else { + appUpdateStatus.available = false + } + + if slices.Contains(components, "system") { + if params.SystemTargetVersion != "" { + systemUpdateStatus.available = systemVersionRemote.String() != systemVersionLocal.String() + } + } else { + systemUpdateStatus.available = false } return nil diff --git a/internal/ota/ota_test.go b/internal/ota/ota_test.go new file mode 100644 index 000000000..7a82b43e1 --- /dev/null +++ b/internal/ota/ota_test.go @@ -0,0 +1,58 @@ +package ota + +import ( + "context" + "net/http" + "os" + "testing" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func pseudoGetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { + systemVersion = semver.MustParse("0.2.5") + appVersion = semver.MustParse("0.4.7") + return systemVersion, appVersion, nil +} + +func newOtaState() *State { + logger := zerolog.New(os.Stdout).Level(zerolog.TraceLevel) + otaState := NewState(Options{ + SkipConfirmSystem: true, + Logger: &logger, + ReleaseAPIEndpoint: "https://api.jetkvm.com/releases", + GetHTTPClient: func() *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + client := &http.Client{ + Transport: transport, + } + return client + }, + GetLocalVersion: pseudoGetLocalVersion, + HwReboot: func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error { return nil }, + ResetConfig: func() error { return nil }, + OnStateUpdate: func(state *RPCState) {}, + OnProgressUpdate: func(progress float32) {}, + }) + return otaState +} + +func TestCheckUpdateComponents(t *testing.T) { + otaState := newOtaState() + updateParams := UpdateParams{ + DeviceID: "test", + IncludePreRelease: false, + SystemTargetVersion: "0.2.2", + Components: []string{"system"}, + } + info, err := otaState.GetUpdateStatus(context.Background(), updateParams) + t.Logf("update status: %+v", info) + if err != nil { + t.Fatalf("failed to check update: %v", err) + } + assert.True(t, info.SystemUpdateAvailable) + assert.False(t, info.AppUpdateAvailable) +} diff --git a/internal/ota/state.go b/internal/ota/state.go index d8aa4399e..58618902b 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -203,6 +203,7 @@ type Options struct { HwReboot HwRebootFunc ReleaseAPIEndpoint string ResetConfig ResetConfigFunc + SkipConfirmSystem bool } // NewState creates a new OTA state @@ -221,7 +222,9 @@ func NewState(opts Options) *State { releaseAPIEndpoint: opts.ReleaseAPIEndpoint, resetConfig: opts.ResetConfig, } - go s.confirmCurrentSystem() + if !opts.SkipConfirmSystem { + go s.confirmCurrentSystem() + } return s } diff --git a/ota.go b/ota.go index 44fe0cd38..56a67bcce 100644 --- a/ota.go +++ b/ota.go @@ -91,7 +91,7 @@ func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { updateStatus.Error = err.Error() } - logger.Info().Interface("updateStatus", updateStatus).Msg("Update status") + otaLogger.Info().Interface("updateStatus", updateStatus).Msg("Update status") return updateStatus, nil } @@ -189,7 +189,7 @@ func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetCo go func() { err := otaState.TryUpdate(context.Background(), updateParams) if err != nil { - logger.Warn().Err(err).Msg("failed to try update") + otaLogger.Warn().Err(err).Msg("failed to try update") } }() return nil From 0cc84f0c54e06947eb7883e18e968acecf84340a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 13:48:23 +0000 Subject: [PATCH 25/62] fix(ui): shouldn't pass custom version to onConfirmCustomUpdate if not available --- ui/src/routes/devices.$id.settings.general.update.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index effcf6d9f..fb5be0550 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -101,10 +101,9 @@ export function Dialog({ const { modalView, setModalView, otaState } = useUpdateStore(); const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined; const onConfirmCustomUpdate = useCallback(() => { - console.debug("onConfirmCustomUpdate", customAppVersion, customSystemVersion, versionInfo); onConfirmCustomUpdateCallback( - customAppVersion !== undefined ? customAppVersion : versionInfo?.remote?.appVersion, - customSystemVersion !== undefined ? customSystemVersion : versionInfo?.remote?.systemVersion, + customAppVersion !== undefined ? versionInfo?.remote?.appVersion : undefined, + customSystemVersion !== undefined ? versionInfo?.remote?.systemVersion : undefined, ); }, [onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, versionInfo]); From 005505a2daa14925c8254fbc7d1435c4c9e96424 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 10 Nov 2025 17:00:31 +0000 Subject: [PATCH 26/62] chore(ota): use []string instead of comma-separated string --- ota.go | 15 +++++---------- ui/src/routes/devices.$id.settings.advanced.tsx | 2 +- .../devices.$id.settings.general.update.tsx | 9 ++++----- ui/src/utils/jsonrpc.ts | 2 +- 4 files changed, 11 insertions(+), 17 deletions(-) diff --git a/ota.go b/ota.go index 56a67bcce..ebea18004 100644 --- a/ota.go +++ b/ota.go @@ -135,9 +135,9 @@ func rpcGetLocalVersion() (*ota.LocalMetadata, error) { } type updateParams struct { - AppTargetVersion string `json:"appTargetVersion"` - SystemTargetVersion string `json:"systemTargetVersion"` - Components string `json:"components,omitempty"` // components is a comma-separated list of components to update + AppTargetVersion string `json:"appTargetVersion"` + SystemTargetVersion string `json:"systemTargetVersion"` + Components []string `json:"components,omitempty"` } func rpcTryUpdate() error { @@ -154,9 +154,7 @@ func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota IncludePreRelease: includePreRelease, AppTargetVersion: params.AppTargetVersion, SystemTargetVersion: params.SystemTargetVersion, - } - if params.Components != "" { - updateParams.Components = strings.Split(params.Components, ",") + Components: params.Components, } info, err := otaState.GetUpdateStatus(context.Background(), updateParams) if err != nil { @@ -170,6 +168,7 @@ func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetCo DeviceID: GetDeviceID(), IncludePreRelease: includePreRelease, ResetConfig: resetConfig, + Components: params.Components, } updateParams.AppTargetVersion = params.AppTargetVersion @@ -182,10 +181,6 @@ func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetCo return fmt.Errorf("failed to set system target version: %w", err) } - if params.Components != "" { - updateParams.Components = strings.Split(params.Components, ",") - } - go func() { err := otaState.TryUpdate(context.Background(), updateParams) if err != nil { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 260db3ca1..5eccce314 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -203,7 +203,7 @@ export default function SettingsAdvancedRoute() { // because it will be redirected to the update page later setVersionUpdateLoading(true); versionInfo = await checkUpdateComponents({ - components: components.join(","), + components, appTargetVersion: appVersion, systemTargetVersion: systemVersion, }, devChannel); diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index fb5be0550..17f576cad 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -49,7 +49,7 @@ export default function SettingsGeneralUpdateRoute() { send("tryUpdateComponents", { params: { - components: components.join(","), + components, appTargetVersion, systemTargetVersion, }, @@ -196,13 +196,12 @@ function LoadingState({ return await getVersionInfo(); } const params : updateParams = { - components: "", + components: [], appTargetVersion: customAppVersion, systemTargetVersion: customSystemVersion, }; - if (customAppVersion) params.components += ",app"; - if (customSystemVersion) params.components += ",system"; - params.components = params.components?.replace(/^,+/, ""); + if (customAppVersion) params.components?.push("app"); + if (customSystemVersion) params.components?.push("system"); return await checkUpdateComponents(params, false); }, [customAppVersion, customSystemVersion, getVersionInfo]); diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index 51d12127b..f4cbc91f9 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -246,7 +246,7 @@ export async function getLocalVersion() { export interface updateParams { appTargetVersion?: string; systemTargetVersion?: string; - components?: string; + components?: string[]; } export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) { From 8d085a6071e6cceda68bf86d134abbb615c573a6 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 11:55:22 +0000 Subject: [PATCH 27/62] refactor(ota): improve OTA state management --- internal/ota/app.go | 3 - internal/ota/ota.go | 152 +++++++--------- internal/ota/ota_test.go | 7 +- internal/ota/rpc.go | 172 ++++++++++++++++++ internal/ota/state.go | 154 +++++----------- ota.go | 25 +-- .../routes/devices.$id.settings.advanced.tsx | 20 +- .../devices.$id.settings.general.update.tsx | 20 +- ui/src/utils/jsonrpc.ts | 7 +- 9 files changed, 311 insertions(+), 249 deletions(-) create mode 100644 internal/ota/rpc.go diff --git a/internal/ota/app.go b/internal/ota/app.go index 301ea9536..3ae206f9a 100644 --- a/internal/ota/app.go +++ b/internal/ota/app.go @@ -22,9 +22,6 @@ func (s *State) componentUpdateError(prefix string, err error, l *zerolog.Logger } func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) error { - s.mu.Lock() - defer s.mu.Unlock() - l := s.l.With().Str("path", appUpdatePath).Logger() if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, "app"); err != nil { diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 137ce023a..1ea6a3569 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -7,10 +7,9 @@ import ( "fmt" "net/http" "net/url" - "slices" "time" - "github.com/Masterminds/semver/v3" + "github.com/rs/zerolog" ) // UpdateReleaseAPIEndpoint updates the release API endpoint @@ -32,26 +31,18 @@ func (s *State) getUpdateURL(params UpdateParams) (string, error, bool) { isCustomVersion := false - appTargetVersion := s.GetTargetVersion("app") - if appTargetVersion != "" && params.AppTargetVersion == "" { - params.AppTargetVersion = appTargetVersion - } - systemTargetVersion := s.GetTargetVersion("system") - if systemTargetVersion != "" && params.SystemTargetVersion == "" { - params.SystemTargetVersion = systemTargetVersion - } - query := updateURL.Query() query.Set("deviceId", params.DeviceID) query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease)) - if params.AppTargetVersion != "" { - query.Set("appVersion", params.AppTargetVersion) - isCustomVersion = true - } - if params.SystemTargetVersion != "" { - query.Set("systemVersion", params.SystemTargetVersion) + + // set the custom versions if they are specified + for component, constraint := range params.Components { + if constraint != "" { + query.Set(component+"Version", constraint) + } isCustomVersion = true } + updateURL.RawQuery = query.Encode() return updateURL.String(), nil, isCustomVersion @@ -98,10 +89,6 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (* return metadata, nil } -func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error { - return s.doUpdate(ctx, params) -} - func (s *State) triggerStateUpdate() { s.onStateUpdate(s.ToRPCState()) } @@ -111,7 +98,22 @@ func (s *State) triggerComponentUpdateState(component string, update *componentU s.triggerStateUpdate() } +// TryUpdate tries to update the given components +// if the update is already in progress, it returns an error +func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error { + locked := s.mu.TryLock() + if !locked { + return fmt.Errorf("update already in progress") + } + + return s.doUpdate(ctx, params) +} + +// before calling doUpdate, the caller must have locked the mutex +// otherwise a runtime error will occur func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { + defer s.mu.Unlock() + scopedLogger := s.l.With(). Interface("params", params). Logger() @@ -122,10 +124,11 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { } if len(params.Components) == 0 { - params.Components = []string{"app", "system"} + params.Components = defaultComponents } - shouldUpdateApp := slices.Contains(params.Components, "app") - shouldUpdateSystem := slices.Contains(params.Components, "system") + + _, shouldUpdateApp := params.Components["app"] + _, shouldUpdateSystem := params.Components["system"] if !shouldUpdateApp && !shouldUpdateSystem { return fmt.Errorf("no components to update") @@ -211,13 +214,11 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { // UpdateParams represents the parameters for the update type UpdateParams struct { - DeviceID string `json:"deviceID"` - AppTargetVersion string `json:"appTargetVersion"` - SystemTargetVersion string `json:"systemTargetVersion"` - Components []string `json:"components,omitempty"` - IncludePreRelease bool `json:"includePreRelease"` - CheckOnly bool `json:"checkOnly"` - ResetConfig bool `json:"resetConfig"` + DeviceID string `json:"deviceID"` + Components map[string]string `json:"components,omitempty"` + IncludePreRelease bool `json:"includePreRelease"` + CheckOnly bool `json:"checkOnly"` + ResetConfig bool `json:"resetConfig"` } // getUpdateStatus gets the update status for the given components @@ -259,7 +260,7 @@ func (s *State) checkUpdateStatus( appUpdateStatus *componentUpdateStatus, systemUpdateStatus *componentUpdateStatus, ) error { - // Get local versions + // get the local versions systemVersionLocal, appVersionLocal, err := s.getLocalVersion() if err != nil { return fmt.Errorf("error getting local version: %w", err) @@ -267,7 +268,12 @@ func (s *State) checkUpdateStatus( appUpdateStatus.localVersion = appVersionLocal.String() systemUpdateStatus.localVersion = systemVersionLocal.String() - // Get remote metadata + s.l.Trace(). + Str("appVersionLocal", appVersionLocal.String()). + Str("systemVersionLocal", systemVersionLocal.String()). + Msg("checkUpdateStatus: getLocalVersion") + + // fetch the remote metadata remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) if err != nil { if err == ErrVersionNotFound || errors.Unwrap(err) == ErrVersionNotFound { @@ -277,61 +283,33 @@ func (s *State) checkUpdateStatus( } return err } - appUpdateStatus.url = remoteMetadata.AppURL - appUpdateStatus.hash = remoteMetadata.AppHash - appUpdateStatus.version = remoteMetadata.AppVersion - systemUpdateStatus.url = remoteMetadata.SystemURL - systemUpdateStatus.hash = remoteMetadata.SystemHash - systemUpdateStatus.version = remoteMetadata.SystemVersion - - // Get remote versions - systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) - if err != nil { - err = fmt.Errorf("error parsing remote system version: %w", err) - return err - } - systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) - - appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) - if err != nil { - err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) - return err - } - appUpdateStatus.available = appVersionRemote.GreaterThan(appVersionLocal) - - // Handle pre-release updates - isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" - isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" - - if isRemoteSystemPreRelease && !params.IncludePreRelease { - systemUpdateStatus.available = false - } - if isRemoteAppPreRelease && !params.IncludePreRelease { - appUpdateStatus.available = false - } - - components := params.Components - // skip check if no components are specified - if len(components) == 0 { - return nil - } - - // TODO: simplify this - if slices.Contains(components, "app") { - if params.AppTargetVersion != "" { - appUpdateStatus.available = appVersionRemote.String() != appVersionLocal.String() - } - } else { - appUpdateStatus.available = false - } - - if slices.Contains(components, "system") { - if params.SystemTargetVersion != "" { - systemUpdateStatus.available = systemVersionRemote.String() != systemVersionLocal.String() - } - } else { - systemUpdateStatus.available = false + s.l.Trace(). + Interface("remoteMetadata", remoteMetadata). + Msg("checkUpdateStatus: fetchUpdateMetadata") + + // parse the remote metadata to the componentUpdateStatuses + if err := remoteMetadataToComponentStatus( + remoteMetadata, + "app", + appUpdateStatus, + params, + ); err != nil { + return fmt.Errorf("error parsing remote app version: %w", err) + } + + if err := remoteMetadataToComponentStatus( + remoteMetadata, + "system", + systemUpdateStatus, + params, + ); err != nil { + return fmt.Errorf("error parsing remote system version: %w", err) + } + + if s.l.GetLevel() <= zerolog.TraceLevel { + appUpdateStatus.getZerologLogger(s.l).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [app]") + systemUpdateStatus.getZerologLogger(s.l).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [system]") } return nil diff --git a/internal/ota/ota_test.go b/internal/ota/ota_test.go index 7a82b43e1..d2b81cdbe 100644 --- a/internal/ota/ota_test.go +++ b/internal/ota/ota_test.go @@ -43,10 +43,9 @@ func newOtaState() *State { func TestCheckUpdateComponents(t *testing.T) { otaState := newOtaState() updateParams := UpdateParams{ - DeviceID: "test", - IncludePreRelease: false, - SystemTargetVersion: "0.2.2", - Components: []string{"system"}, + DeviceID: "test", + IncludePreRelease: false, + Components: map[string]string{"system": "0.2.2"}, } info, err := otaState.GetUpdateStatus(context.Background(), updateParams) t.Logf("update status: %+v", info) diff --git a/internal/ota/rpc.go b/internal/ota/rpc.go new file mode 100644 index 000000000..e89c93d1c --- /dev/null +++ b/internal/ota/rpc.go @@ -0,0 +1,172 @@ +package ota + +import ( + "fmt" + "reflect" + "strings" + "time" + + "github.com/Masterminds/semver/v3" +) + +// to make the field names consistent with the RPCState struct +var componentFieldMap = map[string]string{ + "app": "App", + "system": "System", +} + +// RPCState represents the current OTA state for the RPC API +type RPCState struct { + Updating bool `json:"updating"` + Error string `json:"error,omitempty"` + MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` + AppUpdatePending bool `json:"appUpdatePending"` + SystemUpdatePending bool `json:"systemUpdatePending"` + AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar + AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` + SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar + SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` + AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"` + AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` + SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"` + SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` + AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar + AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` + SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement + SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` +} + +// applyComponentStatusToRPCState uses reflection to map componentUpdateStatus fields to RPCState +func applyComponentStatusToRPCState(component string, status componentUpdateStatus, rpcState *RPCState) { + prefix := componentFieldMap[component] + if prefix == "" { + return + } + + rpcVal := reflect.ValueOf(rpcState).Elem() + + // it's really inefficient, but hey we do not need to use this often + // componentUpdateStatus is for internal use only, and all fields are unexported + for i := 0; i < rpcVal.NumField(); i++ { + rpcFieldName, hasPrefix := strings.CutPrefix(rpcVal.Type().Field(i).Name, prefix) + if !hasPrefix { + continue + } + + switch rpcFieldName { + case "DownloadProgress": + rpcVal.Field(i).Set(reflect.ValueOf(&status.downloadProgress)) + case "DownloadFinishedAt": + rpcVal.Field(i).Set(reflect.ValueOf(&status.downloadFinishedAt)) + case "VerificationProgress": + rpcVal.Field(i).Set(reflect.ValueOf(&status.verificationProgress)) + case "VerifiedAt": + rpcVal.Field(i).Set(reflect.ValueOf(&status.verifiedAt)) + case "UpdateProgress": + rpcVal.Field(i).Set(reflect.ValueOf(&status.updateProgress)) + case "UpdatedAt": + rpcVal.Field(i).Set(reflect.ValueOf(&status.updatedAt)) + case "UpdatePending": + rpcVal.Field(i).SetBool(status.pending) + default: + continue + } + } +} + +// ToRPCState converts the State to the RPCState +func (s *State) ToRPCState() *RPCState { + r := &RPCState{ + Updating: s.updating, + Error: s.error, + MetadataFetchedAt: &s.metadataFetchedAt, + } + + for component, status := range s.componentUpdateStatuses { + applyComponentStatusToRPCState(component, status, r) + } + + return r +} + +func remoteMetadataToComponentStatus( + remoteMetadata *UpdateMetadata, + component string, + componentStatus *componentUpdateStatus, + params UpdateParams, +) error { + prefix := componentFieldMap[component] + if prefix == "" { + return fmt.Errorf("unknown component: %s", component) + } + + remoteMetadataVal := reflect.ValueOf(remoteMetadata).Elem() + for i := 0; i < remoteMetadataVal.NumField(); i++ { + fieldName, hasPrefix := strings.CutPrefix(remoteMetadataVal.Type().Field(i).Name, prefix) + if !hasPrefix { + continue + } + + switch fieldName { + case "URL": + componentStatus.url = remoteMetadataVal.Field(i).String() + case "Hash": + componentStatus.hash = remoteMetadataVal.Field(i).String() + case "Version": + componentStatus.version = remoteMetadataVal.Field(i).String() + default: + // fmt.Printf("unknown field %s", fieldName) + continue + } + } + + localVersion, err := semver.NewVersion(componentStatus.localVersion) + if err != nil { + return fmt.Errorf("error parsing local version: %w", err) + } + + remoteVersion, err := semver.NewVersion(componentStatus.version) + if err != nil { + return fmt.Errorf("error parsing remote version: %w", err) + } + componentStatus.available = remoteVersion.GreaterThan(localVersion) + componentStatus.availableReason = fmt.Sprintf("remote version %s is greater than local version %s", remoteVersion.String(), localVersion.String()) + + // Handle pre-release updates + if remoteVersion.Prerelease() != "" && params.IncludePreRelease && componentStatus.available { + componentStatus.availableReason += " (pre-release)" + } + + // If a custom version is specified, use it to determine if the update is available + constraint, componentExists := params.Components[component] + // we don't need to check again if it's already available + if componentExists && constraint != "" { + componentStatus.available = componentStatus.version != componentStatus.localVersion + if componentStatus.available { + componentStatus.availableReason = fmt.Sprintf("custom version %s is not equal to local version %s", constraint, componentStatus.localVersion) + } + } else if !componentExists { + componentStatus.available = false + componentStatus.availableReason = "component not specified in update parameters" + } + + return nil +} + +// appUpdateStatus.url = remoteMetadata.AppURL +// appUpdateStatus.hash = remoteMetadata.AppHash +// appUpdateStatus.version = remoteMetadata.AppVersion + +// systemUpdateStatus.url = remoteMetadata.SystemURL +// systemUpdateStatus.hash = remoteMetadata.SystemHash +// systemUpdateStatus.version = remoteMetadata.SystemVersion + +// // Get remote versions +// systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) +// +// if err != nil { +// err = fmt.Errorf("error parsing remote system version: %w", err) +// return err +// } +// +// systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) diff --git a/internal/ota/state.go b/internal/ota/state.go index 58618902b..d0a75e62e 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -1,7 +1,6 @@ package ota import ( - "fmt" "net/http" "sync" "time" @@ -10,6 +9,14 @@ import ( "github.com/rs/zerolog" ) +var ( + availableComponents = []string{"app", "system"} + defaultComponents = map[string]string{ + "app": "", + "system": "", + } +) + // UpdateMetadata represents the metadata of an update type UpdateMetadata struct { AppVersion string `json:"appVersion"` @@ -48,9 +55,9 @@ type PostRebootAction struct { type componentUpdateStatus struct { pending bool available bool + availableReason string // why the component is available or not available version string localVersion string - targetVersion string url string hash string downloadProgress float32 @@ -59,30 +66,27 @@ type componentUpdateStatus struct { verifiedAt time.Time updateProgress float32 updatedAt time.Time - dependsOn []string //nolint:unused + dependsOn []string } -// RPCState represents the current OTA state for the RPC API -type RPCState struct { - Updating bool `json:"updating"` - Error string `json:"error,omitempty"` - MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"` - AppUpdatePending bool `json:"appUpdatePending"` - SystemUpdatePending bool `json:"systemUpdatePending"` - AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar - AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"` - SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar - SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"` - AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"` - AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"` - SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"` - SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"` - AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar - AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"` - SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement - SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` - SystemTargetVersion *string `json:"systemTargetVersion,omitempty"` - AppTargetVersion *string `json:"appTargetVersion,omitempty"` +func (c *componentUpdateStatus) getZerologLogger(l *zerolog.Logger) *zerolog.Logger { + logger := l.With(). + Bool("pending", c.pending). + Bool("available", c.available). + Str("availableReason", c.availableReason). + Str("version", c.version). + Str("localVersion", c.localVersion). + Str("url", c.url). + Str("hash", c.hash). + Float32("downloadProgress", c.downloadProgress). + Time("downloadFinishedAt", c.downloadFinishedAt). + Float32("verificationProgress", c.verificationProgress). + Time("verifiedAt", c.verifiedAt). + Float32("updateProgress", c.updateProgress). + Time("updatedAt", c.updatedAt). + Strs("dependsOn", c.dependsOn). + Logger() + return &logger } // HwRebootFunc is a function that reboots the hardware @@ -120,39 +124,6 @@ type State struct { resetConfig ResetConfigFunc } -// SetTargetVersion sets the target version for a component -func (s *State) SetTargetVersion(component string, version string) error { - parsedVersion := version - if version != "" { - // validate if it's a valid semver string first - semverVersion, err := semver.NewVersion(version) - if err != nil { - return fmt.Errorf("not a valid semantic version: %w", err) - } - parsedVersion = semverVersion.String() - } - - // check if the component exists - componentUpdate, ok := s.componentUpdateStatuses[component] - if !ok { - return fmt.Errorf("component %s not found", component) - } - - componentUpdate.targetVersion = parsedVersion - s.componentUpdateStatuses[component] = componentUpdate - - return nil -} - -// GetTargetVersion returns the target version for a component -func (s *State) GetTargetVersion(component string) string { - componentUpdate, ok := s.componentUpdateStatuses[component] - if !ok { - return "" - } - return componentUpdate.targetVersion -} - func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, error string) *UpdateStatus { return &UpdateStatus{ Local: &LocalMetadata{ @@ -209,8 +180,9 @@ type Options struct { // NewState creates a new OTA state func NewState(opts Options) *State { components := make(map[string]componentUpdateStatus) - components["app"] = componentUpdateStatus{} - components["system"] = componentUpdateStatus{} + for _, component := range availableComponents { + components[component] = componentUpdateStatus{} + } s := &State{ l: opts.Logger, @@ -228,50 +200,20 @@ func NewState(opts Options) *State { return s } -// ToRPCState converts the State to the RPCState -// probably we need a generator for this ... -func (s *State) ToRPCState() *RPCState { - r := &RPCState{ - Updating: s.updating, - Error: s.error, - MetadataFetchedAt: &s.metadataFetchedAt, - } - - app, ok := s.componentUpdateStatuses["app"] - if ok { - r.AppUpdatePending = app.pending - r.AppDownloadProgress = &app.downloadProgress - if !app.downloadFinishedAt.IsZero() { - r.AppDownloadFinishedAt = &app.downloadFinishedAt - } - r.AppVerificationProgress = &app.verificationProgress - if !app.verifiedAt.IsZero() { - r.AppVerifiedAt = &app.verifiedAt - } - r.AppUpdateProgress = &app.updateProgress - if !app.updatedAt.IsZero() { - r.AppUpdatedAt = &app.updatedAt - } - r.AppTargetVersion = &app.targetVersion - } - - system, ok := s.componentUpdateStatuses["system"] - if ok { - r.SystemUpdatePending = system.pending - r.SystemDownloadProgress = &system.downloadProgress - if !system.downloadFinishedAt.IsZero() { - r.SystemDownloadFinishedAt = &system.downloadFinishedAt - } - r.SystemVerificationProgress = &system.verificationProgress - if !system.verifiedAt.IsZero() { - r.SystemVerifiedAt = &system.verifiedAt - } - r.SystemUpdateProgress = &system.updateProgress - if !system.updatedAt.IsZero() { - r.SystemUpdatedAt = &system.updatedAt - } - r.SystemTargetVersion = &system.targetVersion - } - - return r -} +// appUpdateStatus.url = remoteMetadata.AppURL +// appUpdateStatus.hash = remoteMetadata.AppHash +// appUpdateStatus.version = remoteMetadata.AppVersion + +// systemUpdateStatus.url = remoteMetadata.SystemURL +// systemUpdateStatus.hash = remoteMetadata.SystemHash +// systemUpdateStatus.version = remoteMetadata.SystemVersion + +// // Get remote versions +// systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) +// +// if err != nil { +// err = fmt.Errorf("error parsing remote system version: %w", err) +// return err +// } +// +// systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) diff --git a/ota.go b/ota.go index ebea18004..b8ae47f12 100644 --- a/ota.go +++ b/ota.go @@ -135,26 +135,21 @@ func rpcGetLocalVersion() (*ota.LocalMetadata, error) { } type updateParams struct { - AppTargetVersion string `json:"appTargetVersion"` - SystemTargetVersion string `json:"systemTargetVersion"` - Components []string `json:"components,omitempty"` + Components map[string]string `json:"components,omitempty"` } func rpcTryUpdate() error { return rpcTryUpdateComponents(updateParams{ - AppTargetVersion: "", - SystemTargetVersion: "", + Components: make(map[string]string), }, config.IncludePreRelease, false) } // rpcCheckUpdateComponents checks the update status for the given components func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota.UpdateStatus, error) { updateParams := ota.UpdateParams{ - DeviceID: GetDeviceID(), - IncludePreRelease: includePreRelease, - AppTargetVersion: params.AppTargetVersion, - SystemTargetVersion: params.SystemTargetVersion, - Components: params.Components, + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + Components: params.Components, } info, err := otaState.GetUpdateStatus(context.Background(), updateParams) if err != nil { @@ -171,16 +166,6 @@ func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetCo Components: params.Components, } - updateParams.AppTargetVersion = params.AppTargetVersion - if err := otaState.SetTargetVersion("app", params.AppTargetVersion); err != nil { - return fmt.Errorf("failed to set app target version: %w", err) - } - - updateParams.SystemTargetVersion = params.SystemTargetVersion - if err := otaState.SetTargetVersion("system", params.SystemTargetVersion); err != nil { - return fmt.Errorf("failed to set system target version: %w", err) - } - go func() { err := otaState.TryUpdate(context.Background(), updateParams) if err != nil { diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 5eccce314..84f61a9d3 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -17,7 +17,7 @@ import { isOnDevice } from "@/main"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; import { sleep } from "@/utils"; -import { checkUpdateComponents } from "@/utils/jsonrpc"; +import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc"; import { SystemVersionInfo } from "@hooks/useVersion"; export default function SettingsAdvancedRoute() { @@ -196,16 +196,17 @@ export default function SettingsAdvancedRoute() { }, []); const handleVersionUpdate = useCallback(async () => { - const components = updateTarget === "both" ? ["app", "system"] : [updateTarget]; + const components: UpdateComponents = {}; + if (["app", "both"].includes(updateTarget)) components.app = appVersion; + if (["system", "both"].includes(updateTarget)) components.system = systemVersion; let versionInfo: SystemVersionInfo | undefined; + try { // we do not need to set it to false if check succeeds, // because it will be redirected to the update page later setVersionUpdateLoading(true); versionInfo = await checkUpdateComponents({ components, - appTargetVersion: appVersion, - systemTargetVersion: systemVersion, }, devChannel); console.log("versionInfo", versionInfo); } catch (error: unknown) { @@ -214,21 +215,14 @@ export default function SettingsAdvancedRoute() { return; } - console.debug("versionInfo", versionInfo, components.includes("app") && versionInfo.remote?.appVersion && versionInfo?.appUpdateAvailable, components.includes("system") && versionInfo.remote?.systemVersion && versionInfo?.systemUpdateAvailable); - console.debug("components", components); - console.debug("versionInfo.remote?.appVersion", versionInfo.remote?.appVersion); - console.debug("versionInfo.appUpdateAvailable", versionInfo?.appUpdateAvailable); - console.debug("versionInfo.remote?.systemVersion", versionInfo.remote?.systemVersion); - console.debug("versionInfo.systemUpdateAvailable", versionInfo?.systemUpdateAvailable); - let hasUpdate = false; const pageParams = new URLSearchParams(); - if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appUpdateAvailable) { + if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) { hasUpdate = true; pageParams.set("custom_app_version", versionInfo.remote?.appVersion); } - if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemUpdateAvailable) { + if (components.system && versionInfo?.remote?.systemVersion && versionInfo?.systemUpdateAvailable) { hasUpdate = true; pageParams.set("custom_system_version", versionInfo.remote?.systemVersion); } diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index 17f576cad..c69d2140c 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -11,7 +11,7 @@ import LoadingSpinner from "@components/LoadingSpinner"; import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard"; import { m } from "@localizations/messages.js"; import { sleep } from "@/utils"; -import { checkUpdateComponents, SystemVersionInfo, updateParams } from "@/utils/jsonrpc"; +import { checkUpdateComponents, SystemVersionInfo, UpdateComponents, updateParams } from "@/utils/jsonrpc"; export default function SettingsGeneralUpdateRoute() { const navigate = useNavigate(); @@ -43,15 +43,13 @@ export default function SettingsGeneralUpdateRoute() { }, [send, setModalView, setShouldReload]); const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => { - const components = []; - if (appTargetVersion) components.push("app"); - if (systemTargetVersion) components.push("system"); + const components: UpdateComponents = {}; + if (appTargetVersion) components.app = appTargetVersion; + if (systemTargetVersion) components.system = systemTargetVersion; send("tryUpdateComponents", { params: { components, - appTargetVersion, - systemTargetVersion, }, includePreRelease: false, resetConfig, @@ -195,13 +193,9 @@ function LoadingState({ if (!customAppVersion && !customSystemVersion) { return await getVersionInfo(); } - const params : updateParams = { - components: [], - appTargetVersion: customAppVersion, - systemTargetVersion: customSystemVersion, - }; - if (customAppVersion) params.components?.push("app"); - if (customSystemVersion) params.components?.push("system"); + const params: updateParams = { components: {} as UpdateComponents }; + if (customAppVersion) params.components!.app = customAppVersion; + if (customSystemVersion) params.components!.system = customSystemVersion; return await checkUpdateComponents(params, false); }, [customAppVersion, customSystemVersion, getVersionInfo]); diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts index f4cbc91f9..42fd29f0c 100644 --- a/ui/src/utils/jsonrpc.ts +++ b/ui/src/utils/jsonrpc.ts @@ -243,10 +243,11 @@ export async function getLocalVersion() { return response.result; } +export type UpdateComponent = "app" | "system"; +export type UpdateComponents = Partial>; + export interface updateParams { - appTargetVersion?: string; - systemTargetVersion?: string; - components?: string[]; + components?: UpdateComponents; } export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) { From 68bc4805379967fc339a538d66adf9142df4afad Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 11:58:20 +0000 Subject: [PATCH 28/62] fix: remove duplicate triggerOTAStateUpdate call --- webrtc.go | 1 - 1 file changed, 1 deletion(-) diff --git a/webrtc.go b/webrtc.go index 46fca2b8a..9a7463ea4 100644 --- a/webrtc.go +++ b/webrtc.go @@ -288,7 +288,6 @@ func newSession(config SessionConfig) (*Session, error) { }) // Wait for channel to be open before sending initial state d.OnOpen(func() { - triggerOTAStateUpdate() triggerVideoStateUpdate() triggerUSBStateUpdate() notifyFailsafeMode(session) From 4411c45cd5bed9bd3374fbbca09764ac012bff66 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 12:04:22 +0000 Subject: [PATCH 29/62] fix: defer updating state update to after update is complete --- internal/ota/ota.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 1ea6a3569..095fec405 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -137,10 +137,6 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { if !params.CheckOnly { s.updating = true s.triggerStateUpdate() - defer func() { - s.updating = false - s.triggerStateUpdate() - }() } appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) @@ -152,6 +148,8 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { s.triggerStateUpdate() if params.CheckOnly { + s.updating = false + s.triggerStateUpdate() return nil } From 0eff994878d8634ce8ecd6d143f58f7d692ea76d Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 12:07:01 +0000 Subject: [PATCH 30/62] fix: update custom version update logic --- internal/ota/ota.go | 6 ++++-- .../routes/devices.$id.settings.advanced.tsx | 20 +++++++++---------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 095fec405..5617a39b8 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -37,9 +37,11 @@ func (s *State) getUpdateURL(params UpdateParams) (string, error, bool) { // set the custom versions if they are specified for component, constraint := range params.Components { - if constraint != "" { - query.Set(component+"Version", constraint) + if constraint == "" { + continue } + + query.Set(component+"Version", constraint) isCustomVersion = true } diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 84f61a9d3..3f366ee7e 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -35,7 +35,7 @@ export default function SettingsAdvancedRoute() { const [systemVersion, setSystemVersion] = useState(""); const [resetConfig, setResetConfig] = useState(false); const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false); - const [versionUpdateLoading, setVersionUpdateLoading] = useState(false); + const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false); const settings = useSettingsStore(); useEffect(() => { @@ -192,19 +192,19 @@ export default function SettingsAdvancedRoute() { }), { duration: 1000 * 15 } // 15 seconds ); - setVersionUpdateLoading(false); + setCustomVersionUpdateLoading(false); }, []); - const handleVersionUpdate = useCallback(async () => { + const handleCustomVersionUpdate = useCallback(async () => { const components: UpdateComponents = {}; - if (["app", "both"].includes(updateTarget)) components.app = appVersion; - if (["system", "both"].includes(updateTarget)) components.system = systemVersion; + if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion; + if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion; let versionInfo: SystemVersionInfo | undefined; try { // we do not need to set it to false if check succeeds, // because it will be redirected to the update page later - setVersionUpdateLoading(true); + setCustomVersionUpdateLoading(true); versionInfo = await checkUpdateComponents({ components, }, devChannel); @@ -238,7 +238,7 @@ export default function SettingsAdvancedRoute() { }, [ updateTarget, appVersion, systemVersion, devChannel, navigateTo, resetConfig, handleVersionUpdateError, - setVersionUpdateLoading + setCustomVersionUpdateLoading ]); return ( @@ -404,10 +404,10 @@ export default function SettingsAdvancedRoute() { (updateTarget === "system" && !systemVersion) || (updateTarget === "both" && (!appVersion || !systemVersion)) || !versionChangeAcknowledged || - versionUpdateLoading + customVersionUpdateLoading } - loading={versionUpdateLoading} - onClick={handleVersionUpdate} + loading={customVersionUpdateLoading} + onClick={handleCustomVersionUpdate} />

From 1d7f8ddc29ec44519e0b8b7eb5f1c7e32a555acc Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 12:08:31 +0000 Subject: [PATCH 31/62] clean up comments --- internal/ota/rpc.go | 18 ------------------ internal/ota/state.go | 18 ------------------ 2 files changed, 36 deletions(-) diff --git a/internal/ota/rpc.go b/internal/ota/rpc.go index e89c93d1c..7ef007ffe 100644 --- a/internal/ota/rpc.go +++ b/internal/ota/rpc.go @@ -152,21 +152,3 @@ func remoteMetadataToComponentStatus( return nil } - -// appUpdateStatus.url = remoteMetadata.AppURL -// appUpdateStatus.hash = remoteMetadata.AppHash -// appUpdateStatus.version = remoteMetadata.AppVersion - -// systemUpdateStatus.url = remoteMetadata.SystemURL -// systemUpdateStatus.hash = remoteMetadata.SystemHash -// systemUpdateStatus.version = remoteMetadata.SystemVersion - -// // Get remote versions -// systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) -// -// if err != nil { -// err = fmt.Errorf("error parsing remote system version: %w", err) -// return err -// } -// -// systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) diff --git a/internal/ota/state.go b/internal/ota/state.go index d0a75e62e..2dc0d4537 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -199,21 +199,3 @@ func NewState(opts Options) *State { } return s } - -// appUpdateStatus.url = remoteMetadata.AppURL -// appUpdateStatus.hash = remoteMetadata.AppHash -// appUpdateStatus.version = remoteMetadata.AppVersion - -// systemUpdateStatus.url = remoteMetadata.SystemURL -// systemUpdateStatus.hash = remoteMetadata.SystemHash -// systemUpdateStatus.version = remoteMetadata.SystemVersion - -// // Get remote versions -// systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion) -// -// if err != nil { -// err = fmt.Errorf("error parsing remote system version: %w", err) -// return err -// } -// -// systemUpdateStatus.available = systemVersionRemote.GreaterThan(systemVersionLocal) From 1abf1f03b798fe1e1823d04c97e2c57b018786ca Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 12:11:50 +0000 Subject: [PATCH 32/62] clean up comments and add default API URL --- .vscode/settings.json | 3 ++- config.go | 10 +++++++--- main.go | 2 +- scripts/dev_deploy.sh | 2 +- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 91db117fb..41aeee583 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,6 @@ ] }, "git.ignoreLimitWarning": true, - "cmake.sourceDirectory": "internal/native/cgo" + "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo", + "cmake.ignoreCMakeListsMissing": true } \ No newline at end of file diff --git a/config.go b/config.go index 7dc3db307..471c7665e 100644 --- a/config.go +++ b/config.go @@ -16,6 +16,10 @@ import ( "github.com/prometheus/client_golang/prometheus/promauto" ) +const ( + DefaultAPIURL = "https://api.jetkvm.com" +) + type WakeOnLanDevice struct { Name string `json:"name"` MacAddress string `json:"macAddress"` @@ -114,7 +118,7 @@ type Config struct { // GetUpdateAPIURL returns the update API URL func (c *Config) GetUpdateAPIURL() string { if c.UpdateAPIURL == "" { - return "https://api.jetkvm.com" + return DefaultAPIURL } return strings.TrimSuffix(c.UpdateAPIURL, "/") + "/releases" } @@ -168,8 +172,8 @@ var ( func getDefaultConfig() Config { return Config{ - CloudURL: "https://api.jetkvm.com", - UpdateAPIURL: "https://api.jetkvm.com", + CloudURL: DefaultAPIURL, + UpdateAPIURL: DefaultAPIURL, CloudAppURL: "https://app.jetkvm.com", AutoUpdateEnabled: true, // Set a default value ActiveExtension: "", diff --git a/main.go b/main.go index 7ae5fca7a..da38d638d 100644 --- a/main.go +++ b/main.go @@ -50,8 +50,8 @@ func Main() { initOta() - initDisplay() initNative(systemVersionLocal, appVersionLocal) + initDisplay() http.DefaultClient.Timeout = 1 * time.Minute diff --git a/scripts/dev_deploy.sh b/scripts/dev_deploy.sh index c2afb5cfc..6c8b204c0 100755 --- a/scripts/dev_deploy.sh +++ b/scripts/dev_deploy.sh @@ -280,4 +280,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d EOF fi -echo "Deployment complete." \ No newline at end of file +echo "Deployment complete." From 8527c7cb4521fbf41ab7ea05e7f6cc3c0ba8021a Mon Sep 17 00:00:00 2001 From: Siyuan Date: Fri, 14 Nov 2025 12:14:00 +0000 Subject: [PATCH 33/62] update translations --- ui/localization/messages/da.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/de.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/en.json | 15 ++++++++------- ui/localization/messages/es.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/fr.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/it.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/nb.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/sv.json | 26 ++++++++++++++++++++++++++ ui/localization/messages/zh.json | 26 ++++++++++++++++++++++++++ 9 files changed, 216 insertions(+), 7 deletions(-) diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index dae6e9065..f2773deef 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", + "advanced_error_version_update": "Kunne ikke starte versionsopdatering: {error}", "advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)", "advanced_loopback_only_title": "Kun loopback-tilstand", "advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Opdater SSH-nøgle", "advanced_usb_emulation_description": "Styr USB-emuleringstilstanden", "advanced_usb_emulation_title": "USB-emulering", + "advanced_version_update_app_label": "App-version", + "advanced_version_update_button": "Opdatering til version", + "advanced_version_update_description": "Installer en specifik version fra GitHub-udgivelser", + "advanced_version_update_github_link": "JetKVM-udgivelsesside", + "advanced_version_update_helper": "Find tilgængelige versioner på", + "advanced_version_update_reset_config_description": "Nulstil konfigurationen efter opdateringen", + "advanced_version_update_reset_config_label": "Nulstil konfiguration", + "advanced_version_update_system_label": "Systemversion", + "advanced_version_update_target_app": "Kun i appen", + "advanced_version_update_target_both": "Både app og system", + "advanced_version_update_target_label": "Hvad skal opdateres", + "advanced_version_update_target_system": "Kun systemet", + "advanced_version_update_title": "Opdatering til specifik version", "already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.", "already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.", "already_adopted_return_to_dashboard": "Tilbage til dashboardet", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.", "connection_stats_playback_delay": "Afspilningsforsinkelse", "connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.", + "connection_stats_remote_ip_address": "Fjern IP-adresse", + "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse", + "connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder", + "connection_stats_remote_ip_address_description": "IP-adressen på den eksterne enhed.", "connection_stats_round_trip_time": "Rundturstid", "connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.", "connection_stats_sidebar": "Forbindelsesstatistik", @@ -241,6 +259,7 @@ "general_auto_update_description": "Opdater automatisk enheden til den nyeste version", "general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}", "general_auto_update_title": "Automatisk opdatering", + "general_check_for_stable_updates": "Nedgradering", "general_check_for_updates": "Tjek for opdateringer", "general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer", "general_reboot_description": "Vil du fortsætte med at genstarte systemet?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Søger efter opdateringer…", "general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!", "general_update_completed_title": "Opdatering gennemført", + "general_update_downgrade_available_description": "En nedgradering er tilgængelig for at vende tilbage til en tidligere version.", + "general_update_downgrade_available_title": "Nedgradering tilgængelig", + "general_update_downgrade_button": "Nedgrader nu", "general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.", "general_update_error_details": "Fejldetaljer: {errorMessage}", "general_update_error_title": "Opdateringsfejl", + "general_update_keep_current_button": "Behold den aktuelle version", "general_update_later_button": "Opdater senere", "general_update_now_button": "Opdater nu", "general_update_rebooting": "Genstarter for at fuldføre opdateringen…", @@ -701,6 +724,9 @@ "peer_connection_failed": "Forbindelsen mislykkedes", "peer_connection_new": "Forbinder", "previous": "Tidligere", + "public_ip_card_header": "Offentlige IP-adresser", + "public_ip_card_refresh": "Opfriske", + "public_ip_card_refresh_error": "Kunne ikke opdatere offentlige IP-adresser: {error}", "register_device_error": "Der opstod en fejl {error} under registrering af din enhed.", "register_device_finish_button": "Afslut opsætning", "register_device_name_description": "Navngiv din enhed, så du nemt kan identificere den senere. Du kan til enhver tid ændre dette navn.", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index 04e25844b..f855ab88e 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}", "advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}", "advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}", + "advanced_error_version_update": "Versionsaktualisierung konnte nicht initiiert werden: {error}", "advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).", "advanced_loopback_only_title": "Nur-Loopback-Modus", "advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren", "advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus", "advanced_usb_emulation_title": "USB-Emulation", + "advanced_version_update_app_label": "App-Version", + "advanced_version_update_button": "Aktualisierung auf Version", + "advanced_version_update_description": "Installieren Sie eine bestimmte Version aus den GitHub-Releases.", + "advanced_version_update_github_link": "JetKVM-Releases-Seite", + "advanced_version_update_helper": "Finden Sie verfügbare Versionen auf der", + "advanced_version_update_reset_config_description": "Konfiguration nach dem Update zurücksetzen", + "advanced_version_update_reset_config_label": "Konfiguration zurücksetzen", + "advanced_version_update_system_label": "Systemversion", + "advanced_version_update_target_app": "Nur App", + "advanced_version_update_target_both": "Sowohl App als auch System", + "advanced_version_update_target_label": "Was sollte aktualisiert werden?", + "advanced_version_update_target_system": "System nur", + "advanced_version_update_title": "Aktualisierung auf eine bestimmte Version", "already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.", "already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.", "already_adopted_return_to_dashboard": "Zurück zum Dashboard", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.", "connection_stats_playback_delay": "Wiedergabeverzögerung", "connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.", + "connection_stats_remote_ip_address": "Remote-IP-Adresse", + "connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse", + "connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert", + "connection_stats_remote_ip_address_description": "Die IP-Adresse des Remote-Geräts.", "connection_stats_round_trip_time": "Round-Trip-Zeit", "connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.", "connection_stats_sidebar": "Verbindungsstatistiken", @@ -241,6 +259,7 @@ "general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version", "general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}", "general_auto_update_title": "Automatische Aktualisierung", + "general_check_for_stable_updates": "Herabstufung", "general_check_for_updates": "Nach Updates suchen", "general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren", "general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Suche nach Updates…", "general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!", "general_update_completed_title": "Update erfolgreich abgeschlossen", + "general_update_downgrade_available_description": "Es besteht die Möglichkeit, auf eine frühere Version zurückzukehren.", + "general_update_downgrade_available_title": "Downgrade verfügbar", + "general_update_downgrade_button": "Jetzt downgraden", "general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.", "general_update_error_details": "Fehlerdetails: {errorMessage}", "general_update_error_title": "Aktualisierungsfehler", + "general_update_keep_current_button": "Aktuelle Version beibehalten", "general_update_later_button": "Später", "general_update_now_button": "Jetzt aktualisieren", "general_update_rebooting": "Neustart zum Abschließen des Updates …", @@ -701,6 +724,9 @@ "peer_connection_failed": "Verbindung fehlgeschlagen", "peer_connection_new": "Verbinden", "previous": "Vorherige", + "public_ip_card_header": "Öffentliche IP-Adressen", + "public_ip_card_refresh": "Aktualisieren", + "public_ip_card_refresh_error": "Aktualisierung der öffentlichen IP-Adressen fehlgeschlagen: {error}", "register_device_error": "Beim Registrieren Ihres Geräts ist ein Fehler {error} aufgetreten.", "register_device_finish_button": "Einrichtung abschließen", "register_device_name_description": "Geben Sie Ihrem Gerät einen Namen, damit Sie es später leicht identifizieren können. Sie können diesen Namen jederzeit ändern.", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index 3a283b78c..a52d16291 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -190,6 +190,10 @@ "connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.", "connection_stats_playback_delay": "Playback Delay", "connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.", + "connection_stats_remote_ip_address": "Remote IP Address", + "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", + "connection_stats_remote_ip_address_description": "The IP address of the remote device.", "connection_stats_round_trip_time": "Round-Trip Time", "connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.", "connection_stats_sidebar": "Connection Stats", @@ -720,6 +724,9 @@ "peer_connection_failed": "Connection failed", "peer_connection_new": "Connecting", "previous": "Previous", + "public_ip_card_header": "Public IP addresses", + "public_ip_card_refresh": "Refresh", + "public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}", "register_device_error": "There was an error {error} registering your device.", "register_device_finish_button": "Finish Setup", "register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.", @@ -916,11 +923,5 @@ "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","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}" + "welcome_to_jetkvm_description": "Control any computer remotely" } diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index a74e35ec7..bb3e5500d 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}", "advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}", "advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}", + "advanced_error_version_update": "Error al iniciar la actualización de versión: {error}", "advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)", "advanced_loopback_only_title": "Modo de solo bucle invertido", "advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Actualizar clave SSH", "advanced_usb_emulation_description": "Controlar el estado de emulación USB", "advanced_usb_emulation_title": "Emulación USB", + "advanced_version_update_app_label": "Versión de la aplicación", + "advanced_version_update_button": "Actualización a la versión", + "advanced_version_update_description": "Instala una versión específica desde las versiones de GitHub.", + "advanced_version_update_github_link": "Página de lanzamientos de JetKVM", + "advanced_version_update_helper": "Encuentra las versiones disponibles en el", + "advanced_version_update_reset_config_description": "Restablecer la configuración después de la actualización", + "advanced_version_update_reset_config_label": "Restablecer configuración", + "advanced_version_update_system_label": "Versión del sistema", + "advanced_version_update_target_app": "Solo aplicación", + "advanced_version_update_target_both": "Tanto la aplicación como el sistema", + "advanced_version_update_target_label": "Qué actualizar", + "advanced_version_update_target_system": "Solo sistema", + "advanced_version_update_title": "Actualización a una versión específica", "already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.", "already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.", "already_adopted_return_to_dashboard": "Regresar al panel de control", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.", "connection_stats_playback_delay": "Retraso de reproducción", "connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.", + "connection_stats_remote_ip_address": "Dirección IP remota", + "connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota", + "connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles", + "connection_stats_remote_ip_address_description": "La dirección IP del dispositivo remoto.", "connection_stats_round_trip_time": "Tiempo de ida y vuelta", "connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.", "connection_stats_sidebar": "Estadísticas de conexión", @@ -241,6 +259,7 @@ "general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión", "general_auto_update_error": "No se pudo configurar la actualización automática: {error}", "general_auto_update_title": "Actualización automática", + "general_check_for_stable_updates": "Degradar", "general_check_for_updates": "Buscar actualizaciones", "general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias", "general_reboot_description": "¿Desea continuar con el reinicio del sistema?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Buscando actualizaciones…", "general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!", "general_update_completed_title": "Actualización completada con éxito", + "general_update_downgrade_available_description": "Es posible realizar una reversión a una versión anterior.", + "general_update_downgrade_available_title": "Opción de cambio a una versión inferior disponible", + "general_update_downgrade_button": "Revierte ahora", "general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.", "general_update_error_details": "Detalles del error: {errorMessage}", "general_update_error_title": "Error de actualización", + "general_update_keep_current_button": "Mantener la versión actual", "general_update_later_button": "Posponer", "general_update_now_button": "Actualizar ahora", "general_update_rebooting": "Reiniciando para completar la actualización…", @@ -701,6 +724,9 @@ "peer_connection_failed": "La conexión falló", "peer_connection_new": "Conectando", "previous": "Anterior", + "public_ip_card_header": "Direcciones IP públicas", + "public_ip_card_refresh": "Refrescar", + "public_ip_card_refresh_error": "Error al actualizar las direcciones IP públicas: {error}", "register_device_error": "Se produjo un error {error} al registrar su dispositivo.", "register_device_finish_button": "Finalizar configuración", "register_device_name_description": "Ponle un nombre a tu dispositivo para que puedas identificarlo fácilmente más tarde. Puedes cambiarlo en cualquier momento.", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index eb7361d14..d44f21b06 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}", "advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}", "advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}", + "advanced_error_version_update": "Échec de la mise à jour de version : {error}", "advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)", "advanced_loopback_only_title": "Mode de bouclage uniquement", "advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Mettre à jour la clé SSH", "advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB", "advanced_usb_emulation_title": "Émulation USB", + "advanced_version_update_app_label": "Version de l'application", + "advanced_version_update_button": "Mise à jour vers la version", + "advanced_version_update_description": "Installer une version spécifique à partir des versions GitHub", + "advanced_version_update_github_link": "page des versions de JetKVM", + "advanced_version_update_helper": "Trouvez les versions disponibles sur le", + "advanced_version_update_reset_config_description": "Réinitialiser la configuration après la mise à jour", + "advanced_version_update_reset_config_label": "Réinitialiser la configuration", + "advanced_version_update_system_label": "Version du système", + "advanced_version_update_target_app": "Application uniquement", + "advanced_version_update_target_both": "L'application et le système", + "advanced_version_update_target_label": "Que mettre à jour", + "advanced_version_update_target_system": "Système uniquement", + "advanced_version_update_title": "Mise à jour vers une version spécifique", "already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.", "already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.", "already_adopted_return_to_dashboard": "Retour au tableau de bord", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.", "connection_stats_playback_delay": "Délai de lecture", "connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.", + "connection_stats_remote_ip_address": "Adresse IP distante", + "connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante", + "connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers", + "connection_stats_remote_ip_address_description": "L'adresse IP du périphérique distant.", "connection_stats_round_trip_time": "Temps de trajet aller-retour", "connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.", "connection_stats_sidebar": "Statistiques de connexion", @@ -241,6 +259,7 @@ "general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version", "general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}", "general_auto_update_title": "Mise à jour automatique", + "general_check_for_stable_updates": "Rétrograder", "general_check_for_updates": "Vérifier les mises à jour", "general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences", "general_reboot_description": "Voulez-vous procéder au redémarrage du système ?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Vérification des mises à jour…", "general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !", "general_update_completed_title": "Mise à jour terminée avec succès", + "general_update_downgrade_available_description": "Il est possible de revenir à une version antérieure.", + "general_update_downgrade_available_title": "Rétrogradation possible", + "general_update_downgrade_button": "Rétrograder maintenant", "general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.", "general_update_error_details": "Détails de l'erreur : {errorMessage}", "general_update_error_title": "Erreur de mise à jour", + "general_update_keep_current_button": "Conserver la version actuelle", "general_update_later_button": "Faire plus tard", "general_update_now_button": "Mettre à jour maintenant", "general_update_rebooting": "Redémarrage pour terminer la mise à jour…", @@ -701,6 +724,9 @@ "peer_connection_failed": "La connexion a échoué", "peer_connection_new": "Nouveau", "previous": "Précédent", + "public_ip_card_header": "Adresses IP publiques", + "public_ip_card_refresh": "Rafraîchir", + "public_ip_card_refresh_error": "Échec de l'actualisation des adresses IP publiques : {error}", "register_device_error": "Une erreur {error} s'est produite lors de l'enregistrement de votre appareil.", "register_device_finish_button": "Terminer la configuration", "register_device_name_description": "Nommez votre appareil pour pouvoir l'identifier facilement plus tard. Vous pouvez modifier ce nom à tout moment.", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index b2aa55294..30880f9c5 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}", "advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}", "advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}", + "advanced_error_version_update": "Impossibile avviare l'aggiornamento della versione: {error}", "advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)", "advanced_loopback_only_title": "Modalità solo loopback", "advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Aggiorna la chiave SSH", "advanced_usb_emulation_description": "Controlla lo stato di emulazione USB", "advanced_usb_emulation_title": "Emulazione USB", + "advanced_version_update_app_label": "Versione dell'app", + "advanced_version_update_button": "Aggiorna alla versione", + "advanced_version_update_description": "Installa una versione specifica dalle versioni di GitHub", + "advanced_version_update_github_link": "Pagina delle versioni di JetKVM", + "advanced_version_update_helper": "Trova le versioni disponibili su", + "advanced_version_update_reset_config_description": "Ripristina la configurazione dopo l'aggiornamento", + "advanced_version_update_reset_config_label": "Reimposta configurazione", + "advanced_version_update_system_label": "Versione del sistema", + "advanced_version_update_target_app": "Solo app", + "advanced_version_update_target_both": "Sia l'app che il sistema", + "advanced_version_update_target_label": "Cosa aggiornare", + "advanced_version_update_target_system": "Solo sistema", + "advanced_version_update_title": "Aggiorna alla versione specifica", "already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.", "already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.", "already_adopted_return_to_dashboard": "Torna alla dashboard", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.", "connection_stats_playback_delay": "Ritardo di riproduzione", "connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.", + "connection_stats_remote_ip_address": "Indirizzo IP remoto", + "connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto", + "connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti", + "connection_stats_remote_ip_address_description": "L'indirizzo IP del dispositivo remoto.", "connection_stats_round_trip_time": "Tempo di andata e ritorno", "connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.", "connection_stats_sidebar": "Statistiche di connessione", @@ -241,6 +259,7 @@ "general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione", "general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}", "general_auto_update_title": "Aggiornamento automatico", + "general_check_for_stable_updates": "Declassare", "general_check_for_updates": "Verifica aggiornamenti", "general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze", "general_reboot_description": "Vuoi procedere con il riavvio del sistema?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Controllo degli aggiornamenti…", "general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!", "general_update_completed_title": "Aggiornamento completato con successo", + "general_update_downgrade_available_description": "È possibile effettuare il downgrade per tornare a una versione precedente.", + "general_update_downgrade_available_title": "Downgrade disponibile", + "general_update_downgrade_button": "Effettua il downgrade ora", "general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.", "general_update_error_details": "Dettagli errore: {errorMessage}", "general_update_error_title": "Errore di aggiornamento", + "general_update_keep_current_button": "Mantieni la versione corrente", "general_update_later_button": "Fallo più tardi", "general_update_now_button": "Aggiorna ora", "general_update_rebooting": "Riavvio per completare l'aggiornamento…", @@ -701,6 +724,9 @@ "peer_connection_failed": "Connessione fallita", "peer_connection_new": "Collegamento", "previous": "Precedente", + "public_ip_card_header": "Indirizzi IP pubblici", + "public_ip_card_refresh": "Aggiorna", + "public_ip_card_refresh_error": "Impossibile aggiornare gli indirizzi IP pubblici: {error}", "register_device_error": "Si è verificato un errore {error} durante la registrazione del dispositivo.", "register_device_finish_button": "Completa l'installazione", "register_device_name_description": "Assegna un nome al tuo dispositivo per poterlo identificare facilmente in seguito. Puoi cambiare questo nome in qualsiasi momento.", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 26b8584eb..1e8e18319 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", + "advanced_error_version_update": "Kunne ikke starte versjonsoppdatering: {error}", "advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)", "advanced_loopback_only_title": "Kun lokal tilgang", "advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Oppdater SSH-nøkkel", "advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden", "advanced_usb_emulation_title": "USB-emulering", + "advanced_version_update_app_label": "Appversjon", + "advanced_version_update_button": "Oppdater til versjon", + "advanced_version_update_description": "Installer en spesifikk versjon fra GitHub-utgivelser", + "advanced_version_update_github_link": "JetKVM-utgivelsesside", + "advanced_version_update_helper": "Finn tilgjengelige versjoner på", + "advanced_version_update_reset_config_description": "Tilbakestill konfigurasjonen etter oppdateringen", + "advanced_version_update_reset_config_label": "Tilbakestill konfigurasjon", + "advanced_version_update_system_label": "Systemversjon", + "advanced_version_update_target_app": "Kun app", + "advanced_version_update_target_both": "Både app og system", + "advanced_version_update_target_label": "Hva som skal oppdateres", + "advanced_version_update_target_system": "Kun systemet", + "advanced_version_update_title": "Oppdatering til spesifikk versjon", "already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.", "already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.", "already_adopted_return_to_dashboard": "Gå tilbake til dashbordet", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.", "connection_stats_playback_delay": "Avspillingsforsinkelse", "connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.", + "connection_stats_remote_ip_address": "Ekstern IP-adresse", + "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen", + "connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen", + "connection_stats_remote_ip_address_description": "IP-adressen til den eksterne enheten.", "connection_stats_round_trip_time": "Tur-retur-tid", "connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.", "connection_stats_sidebar": "Tilkoblingsstatistikk", @@ -241,6 +259,7 @@ "general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen", "general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}", "general_auto_update_title": "Automatisk oppdatering", + "general_check_for_stable_updates": "Nedgrader", "general_check_for_updates": "Se etter oppdateringer", "general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser", "general_reboot_description": "Vil du fortsette med å starte systemet på nytt?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Ser etter oppdateringer …", "general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!", "general_update_completed_title": "Oppdatering fullført", + "general_update_downgrade_available_description": "En nedgradering er tilgjengelig for å gå tilbake til en tidligere versjon.", + "general_update_downgrade_available_title": "Nedgradering tilgjengelig", + "general_update_downgrade_button": "Nedgrader nå", "general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.", "general_update_error_details": "Feildetaljer: {errorMessage}", "general_update_error_title": "Oppdateringsfeil", + "general_update_keep_current_button": "Behold gjeldende versjon", "general_update_later_button": "Oppdater senere", "general_update_now_button": "Oppdater nå", "general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …", @@ -701,6 +724,9 @@ "peer_connection_failed": "Tilkoblingen mislyktes", "peer_connection_new": "Tilkobling", "previous": "Tidligere", + "public_ip_card_header": "Offentlige IP-adresser", + "public_ip_card_refresh": "Forfriske", + "public_ip_card_refresh_error": "Kunne ikke oppdatere offentlige IP-adresser: {error}", "register_device_error": "Det oppsto en feil {error} under registrering av enheten din.", "register_device_finish_button": "Fullfør oppsettet", "register_device_name_description": "Gi enheten din et navn slik at du enkelt kan identifisere den senere. Du kan endre dette navnet når som helst.", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index a4df3271f..1be9bb794 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}", "advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}", + "advanced_error_version_update": "Misslyckades med att initiera versionsuppdatering: {error}", "advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)", "advanced_loopback_only_title": "Loopback-läge", "advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "Uppdatera SSH-nyckel", "advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen", "advanced_usb_emulation_title": "USB-emulering", + "advanced_version_update_app_label": "Appversion", + "advanced_version_update_button": "Uppdatera till version", + "advanced_version_update_description": "Installera en specifik version från GitHub-utgåvor", + "advanced_version_update_github_link": "JetKVM-utgåvorsida", + "advanced_version_update_helper": "Hitta tillgängliga versioner på", + "advanced_version_update_reset_config_description": "Återställ konfigurationen efter uppdateringen", + "advanced_version_update_reset_config_label": "Återställ konfigurationen", + "advanced_version_update_system_label": "Systemversion", + "advanced_version_update_target_app": "Endast app", + "advanced_version_update_target_both": "Både app och system", + "advanced_version_update_target_label": "Vad som ska uppdateras", + "advanced_version_update_target_system": "Endast systemet", + "advanced_version_update_title": "Uppdatera till specifik version", "already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.", "already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.", "already_adopted_return_to_dashboard": "Återgå till instrumentpanelen", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.", "connection_stats_playback_delay": "Uppspelningsfördröjning", "connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.", + "connection_stats_remote_ip_address": "Fjärr-IP-adress", + "connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen", + "connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp", + "connection_stats_remote_ip_address_description": "IP-adressen för den fjärranslutna enheten.", "connection_stats_round_trip_time": "Tur- och returtid", "connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.", "connection_stats_sidebar": "Anslutningsstatistik", @@ -241,6 +259,7 @@ "general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen", "general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}", "general_auto_update_title": "Automatisk uppdatering", + "general_check_for_stable_updates": "Nedvärdera", "general_check_for_updates": "Kontrollera efter uppdateringar", "general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar", "general_reboot_description": "Vill du fortsätta med att starta om systemet?", @@ -261,9 +280,13 @@ "general_update_checking_title": "Söker efter uppdateringar…", "general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!", "general_update_completed_title": "Uppdateringen är slutförd", + "general_update_downgrade_available_description": "En nedgradering är tillgänglig för att återgå till en tidigare version.", + "general_update_downgrade_available_title": "Nedgradering tillgänglig", + "general_update_downgrade_button": "Nedgradera nu", "general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.", "general_update_error_details": "Felinformation: {errorMessage}", "general_update_error_title": "Uppdateringsfel", + "general_update_keep_current_button": "Behåll aktuell version", "general_update_later_button": "Gör det senare", "general_update_now_button": "Uppdatera nu", "general_update_rebooting": "Startar om för att slutföra uppdateringen…", @@ -701,6 +724,9 @@ "peer_connection_failed": "Anslutningen misslyckades", "peer_connection_new": "Ansluter", "previous": "Föregående", + "public_ip_card_header": "Offentliga IP-adresser", + "public_ip_card_refresh": "Uppdatera", + "public_ip_card_refresh_error": "Misslyckades med att uppdatera offentliga IP-adresser: {error}", "register_device_error": "Det uppstod ett fel {error} din enhet registrerades.", "register_device_finish_button": "Slutför installationen", "register_device_name_description": "Namnge din enhet så att du enkelt kan identifiera den senare. Du kan ändra namnet när som helst.", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 14a558832..46ee0b752 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -74,6 +74,7 @@ "advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}", "advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}", "advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}", + "advanced_error_version_update": "版本更新失败: {error}", "advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机(127.0.0.1)", "advanced_loopback_only_title": "仅环回模式", "advanced_loopback_warning_before": "在启用此功能之前,请确保您已:", @@ -100,6 +101,19 @@ "advanced_update_ssh_key_button": "更新 SSH 密钥", "advanced_usb_emulation_description": "控制 USB 仿真状态", "advanced_usb_emulation_title": "USB 仿真", + "advanced_version_update_app_label": "应用版本", + "advanced_version_update_button": "更新至版本", + "advanced_version_update_description": "从 GitHub 发布页面安装特定版本", + "advanced_version_update_github_link": "JetKVM 发布页面", + "advanced_version_update_helper": "在以下平台查找可用版本", + "advanced_version_update_reset_config_description": "更新后重置配置", + "advanced_version_update_reset_config_label": "重置配置", + "advanced_version_update_system_label": "系统版本", + "advanced_version_update_target_app": "仅限应用内购买", + "advanced_version_update_target_both": "应用程序和系统", + "advanced_version_update_target_label": "需要更新什么", + "advanced_version_update_target_system": "仅系统", + "advanced_version_update_title": "更新至特定版本", "already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。", "already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。", "already_adopted_return_to_dashboard": "返回仪表板", @@ -176,6 +190,10 @@ "connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。", "connection_stats_playback_delay": "播放延迟", "connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。", + "connection_stats_remote_ip_address": "远程 IP 地址", + "connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败", + "connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板", + "connection_stats_remote_ip_address_description": "远程设备的IP地址。", "connection_stats_round_trip_time": "往返时间", "connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。", "connection_stats_sidebar": "连接统计", @@ -241,6 +259,7 @@ "general_auto_update_description": "自动将设备更新到最新版本", "general_auto_update_error": "无法设置自动更新: {error}", "general_auto_update_title": "自动更新", + "general_check_for_stable_updates": "降级", "general_check_for_updates": "检查更新", "general_page_description": "配置设备设置并更新首选项", "general_reboot_description": "您想继续重新启动系统吗?", @@ -261,9 +280,13 @@ "general_update_checking_title": "正在检查更新…", "general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!", "general_update_completed_title": "更新已成功完成", + "general_update_downgrade_available_description": "可以降级到以前的版本。", + "general_update_downgrade_available_title": "可降级", + "general_update_downgrade_button": "立即降级", "general_update_error_description": "更新您的设备时出错。请稍后重试。", "general_update_error_details": "错误详细信息: {errorMessage}", "general_update_error_title": "更新错误", + "general_update_keep_current_button": "保持当前版本", "general_update_later_button": "稍后再说", "general_update_now_button": "立即更新", "general_update_rebooting": "重新启动以完成更新…", @@ -701,6 +724,9 @@ "peer_connection_failed": "连接失败", "peer_connection_new": "正在连接", "previous": "上一步", + "public_ip_card_header": "公共 IP 地址", + "public_ip_card_refresh": "刷新", + "public_ip_card_refresh_error": "刷新公网 IP 地址失败: {error}", "register_device_error": "注册您的设备时出现错误{error} 。", "register_device_finish_button": "完成设置", "register_device_name_description": "为您的设备命名,以便日后轻松识别。您可以随时更改此名称。", From 1880d5bfbc0ecfd9ee2bb46b6e821c6840dd2646 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 10:42:18 +0000 Subject: [PATCH 34/62] fix: set components to default components if not set --- internal/ota/ota.go | 6 ++ internal/ota/ota_test.go | 182 +++++++++++++++++++++++++++++++++++---- internal/ota/state.go | 12 ++- 3 files changed, 181 insertions(+), 19 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 5617a39b8..f5b9fd446 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -317,6 +317,12 @@ func (s *State) checkUpdateStatus( // GetUpdateStatus returns the current update status (for backwards compatibility) func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { + // if no components are specified, use the default components + // we should remove this once app router feature is released + if len(params.Components) == 0 { + params.Components = defaultComponents + } + appUpdateStatus := componentUpdateStatus{} systemUpdateStatus := componentUpdateStatus{} err := s.checkUpdateStatus(ctx, params, &appUpdateStatus, &systemUpdateStatus) diff --git a/internal/ota/ota_test.go b/internal/ota/ota_test.go index d2b81cdbe..4f175edcb 100644 --- a/internal/ota/ota_test.go +++ b/internal/ota/ota_test.go @@ -2,30 +2,52 @@ package ota import ( "context" + "crypto/tls" + "crypto/x509" + "fmt" "net/http" "os" "testing" "time" "github.com/Masterminds/semver/v3" + "github.com/gwatts/rootcerts" "github.com/rs/zerolog" "github.com/stretchr/testify/assert" ) -func pseudoGetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { - systemVersion = semver.MustParse("0.2.5") - appVersion = semver.MustParse("0.4.7") - return systemVersion, appVersion, nil +const pseudoDeviceID = "golang-test" + +type otaTestStateParams struct { + LocalSystemVersion string + LocalAppVersion string + WithoutCerts bool } -func newOtaState() *State { - logger := zerolog.New(os.Stdout).Level(zerolog.TraceLevel) +func newOtaState(p otaTestStateParams) *State { + pseudoGetLocalVersion := func() (systemVersion *semver.Version, appVersion *semver.Version, err error) { + appVersion = semver.MustParse(p.LocalAppVersion) + systemVersion = semver.MustParse(p.LocalSystemVersion) + return systemVersion, appVersion, nil + } + + traceLevel := zerolog.InfoLevel + + if os.Getenv("TEST_LOG_TRACE") == "true" { + traceLevel = zerolog.TraceLevel + } + logger := zerolog.New(os.Stdout).Level(traceLevel) otaState := NewState(Options{ SkipConfirmSystem: true, Logger: &logger, ReleaseAPIEndpoint: "https://api.jetkvm.com/releases", GetHTTPClient: func() *http.Client { transport := http.DefaultTransport.(*http.Transport).Clone() + if !p.WithoutCerts { + transport.TLSClientConfig = &tls.Config{RootCAs: rootcerts.ServerCertPool()} + } else { + transport.TLSClientConfig = &tls.Config{RootCAs: x509.NewCertPool()} + } client := &http.Client{ Transport: transport, } @@ -40,18 +62,146 @@ func newOtaState() *State { return otaState } -func TestCheckUpdateComponents(t *testing.T) { - otaState := newOtaState() - updateParams := UpdateParams{ - DeviceID: "test", - IncludePreRelease: false, - Components: map[string]string{"system": "0.2.2"}, - } - info, err := otaState.GetUpdateStatus(context.Background(), updateParams) +func TestCheckUpdateComponentsWithoutCerts(t *testing.T) { + otaState := newOtaState(otaTestStateParams{ + LocalSystemVersion: "0.2.5", + LocalAppVersion: "0.4.7", + WithoutCerts: true, + }) + _, err := otaState.GetUpdateStatus(context.Background(), UpdateParams{}) + assert.ErrorContains(t, err, "certificate signed by unknown authority") +} + +type updateStatusAsserts struct { + system bool + app bool + skip bool +} + +func testGetUpdateStatus(t *testing.T, p otaTestStateParams, u UpdateParams, asserts updateStatusAsserts) *UpdateStatus { + otaState := newOtaState(p) + info, err := otaState.GetUpdateStatus(context.Background(), u) t.Logf("update status: %+v", info) if err != nil { t.Fatalf("failed to check update: %v", err) } - assert.True(t, info.SystemUpdateAvailable) - assert.False(t, info.AppUpdateAvailable) + if asserts.skip { + return info + } + + if asserts.system { + assert.True(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should available, but reason: %s", info.SystemUpdateAvailableReason)) + } else { + assert.False(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should not be available, but reason: %s", info.SystemUpdateAvailableReason)) + } + if asserts.app { + assert.True(t, info.AppUpdateAvailable, fmt.Sprintf("app update should available, but reason: %s", info.AppUpdateAvailableReason)) + } else { + assert.False(t, info.AppUpdateAvailable, fmt.Sprintf("app update should not be available, but reason: %s", info.AppUpdateAvailableReason)) + } + return info +} + +func TestCheckUpdateComponentsSystemOnlyUpgrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.2", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + IncludePreRelease: false, + Components: map[string]string{"system": ""}, + }, updateStatusAsserts{ + system: true, + app: false, + }) +} + +func TestCheckUpdateComponentsSystemOnlyDowngrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.5", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + Components: map[string]string{"system": "0.2.2"}, + IncludePreRelease: false, + }, updateStatusAsserts{ + system: true, + app: false, + }) +} + +func TestCheckUpdateComponentsAppOnlyUpgrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.2", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + IncludePreRelease: false, + Components: map[string]string{"app": ""}, + }, updateStatusAsserts{ + system: false, + app: true, + }) +} + +func TestCheckUpdateComponentsAppOnlyDowngrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.2", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + Components: map[string]string{"app": "0.4.6"}, + IncludePreRelease: false, + }, updateStatusAsserts{ + system: false, + app: true, + }) +} + +func TestCheckUpdateComponentsSystemBothUpgrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.2", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + IncludePreRelease: false, + Components: map[string]string{"system": "", "app": ""}, + }, updateStatusAsserts{ + system: true, + app: true, + }) +} + +func TestCheckUpdateComponentsSystemBothDowngrade(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.5", // remote >= 0.2.5 + LocalAppVersion: "0.4.5", // remote >= 0.4.7 + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + Components: map[string]string{"system": "0.2.2", "app": "0.4.6"}, + IncludePreRelease: false, + }, updateStatusAsserts{ + system: true, + app: true, + }) +} + +func TestCheckUpdateComponentsNoComponents(t *testing.T) { + _ = testGetUpdateStatus(t, otaTestStateParams{ + LocalSystemVersion: "0.2.2", + LocalAppVersion: "0.4.2", + WithoutCerts: false, + }, UpdateParams{ + DeviceID: pseudoDeviceID, + IncludePreRelease: false, + }, updateStatusAsserts{ + system: true, + app: true, + }) } diff --git a/internal/ota/state.go b/internal/ota/state.go index 2dc0d4537..0ee572127 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -40,6 +40,10 @@ type UpdateStatus struct { SystemUpdateAvailable bool `json:"systemUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"` + // only available for debugging and won't be exported + SystemUpdateAvailableReason string `json:"-"` + AppUpdateAvailableReason string `json:"-"` + // for backwards compatibility Error string `json:"error,omitempty"` } @@ -138,9 +142,11 @@ func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpd SystemURL: systemUpdate.url, SystemHash: systemUpdate.hash, }, - SystemUpdateAvailable: systemUpdate.available, - AppUpdateAvailable: appUpdate.available, - Error: error, + SystemUpdateAvailable: systemUpdate.available, + SystemUpdateAvailableReason: systemUpdate.availableReason, + AppUpdateAvailable: appUpdate.available, + AppUpdateAvailableReason: appUpdate.availableReason, + Error: error, } } From 3b0efa7d2004564450edf7175ad7d782cdb049cd Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 12:22:57 +0000 Subject: [PATCH 35/62] chore: use json schema for ota test data --- .vscode/settings.json | 10 +- internal/ota/ota.go | 7 +- internal/ota/ota_test.go | 296 +++++++++++------- internal/ota/state.go | 3 +- internal/ota/testdata/ota.schema.json | 159 ++++++++++ .../ota/testdata/ota/app_only_downgrade.json | 34 ++ .../ota/testdata/ota/app_only_upgrade.json | 33 ++ internal/ota/testdata/ota/both_downgrade.json | 37 +++ internal/ota/testdata/ota/both_upgrade.json | 34 ++ internal/ota/testdata/ota/no_components.json | 32 ++ .../testdata/ota/system_only_downgrade.json | 34 ++ .../ota/testdata/ota/system_only_upgrade.json | 33 ++ internal/ota/testdata/ota/without_certs.json | 17 + ota.go | 2 +- 14 files changed, 605 insertions(+), 126 deletions(-) create mode 100644 internal/ota/testdata/ota.schema.json create mode 100644 internal/ota/testdata/ota/app_only_downgrade.json create mode 100644 internal/ota/testdata/ota/app_only_upgrade.json create mode 100644 internal/ota/testdata/ota/both_downgrade.json create mode 100644 internal/ota/testdata/ota/both_upgrade.json create mode 100644 internal/ota/testdata/ota/no_components.json create mode 100644 internal/ota/testdata/ota/system_only_downgrade.json create mode 100644 internal/ota/testdata/ota/system_only_upgrade.json create mode 100644 internal/ota/testdata/ota/without_certs.json diff --git a/.vscode/settings.json b/.vscode/settings.json index 41aeee583..5aeb206a9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,13 @@ }, "git.ignoreLimitWarning": true, "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo", - "cmake.ignoreCMakeListsMissing": true + "cmake.ignoreCMakeListsMissing": true, + "json.schemas": [ + { + "fileMatch": [ + "/internal/ota/testdata/ota/*.json" + ], + "url": "./internal/ota/testdata/ota.schema.json" + } + ] } \ No newline at end of file diff --git a/internal/ota/ota.go b/internal/ota/ota.go index f5b9fd446..d0e330fbc 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -12,6 +12,11 @@ import ( "github.com/rs/zerolog" ) +// HttpClient is the interface for the HTTP client +type HttpClient interface { + Do(req *http.Request) (*http.Response, error) +} + // UpdateReleaseAPIEndpoint updates the release API endpoint func (s *State) UpdateReleaseAPIEndpoint(endpoint string) { s.releaseAPIEndpoint = endpoint @@ -215,7 +220,7 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { // UpdateParams represents the parameters for the update type UpdateParams struct { DeviceID string `json:"deviceID"` - Components map[string]string `json:"components,omitempty"` + Components map[string]string `json:"components"` IncludePreRelease bool `json:"includePreRelease"` CheckOnly bool `json:"checkOnly"` ResetConfig bool `json:"resetConfig"` diff --git a/internal/ota/ota_test.go b/internal/ota/ota_test.go index 4f175edcb..2c8ce661d 100644 --- a/internal/ota/ota_test.go +++ b/internal/ota/ota_test.go @@ -1,12 +1,18 @@ package ota import ( + "bytes" "context" "crypto/tls" "crypto/x509" + "embed" + "encoding/json" "fmt" + "io" "net/http" + "net/url" "os" + "path/filepath" "testing" "time" @@ -16,34 +22,173 @@ import ( "github.com/stretchr/testify/assert" ) +//go:embed testdata/ota +var testDataFS embed.FS + const pseudoDeviceID = "golang-test" +const releaseAPIEndpoint = "https://api.jetkvm.com/releases" + +type testData struct { + Name string `json:"name"` + WithoutCerts bool `json:"withoutCerts"` + RemoteMetadata []struct { + Code int `json:"code"` + Params map[string]string `json:"params"` + Data UpdateMetadata `json:"data"` + } `json:"remoteMetadata"` + LocalMetadata struct { + SystemVersion string `json:"systemVersion"` + AppVersion string `json:"appVersion"` + } `json:"localMetadata"` + UpdateParams UpdateParams `json:"updateParams"` + Expected struct { + System bool `json:"system"` + App bool `json:"app"` + Error string `json:"error,omitempty"` + } `json:"expected"` +} + +func (d *testData) ToFixtures(t *testing.T) map[string]mockData { + fixtures := make(map[string]mockData) + for _, resp := range d.RemoteMetadata { + url, err := url.Parse(releaseAPIEndpoint) + if err != nil { + t.Fatalf("failed to parse release API endpoint: %v", err) + } + query := url.Query() + query.Set("deviceId", pseudoDeviceID) + for key, value := range resp.Params { + query.Set(key, value) + } + url.RawQuery = query.Encode() + fixtures[url.String()] = mockData{ + Metadata: &resp.Data, + StatusCode: resp.Code, + } + } + return fixtures +} + +func (d *testData) ToUpdateParams() UpdateParams { + d.UpdateParams.DeviceID = pseudoDeviceID + return d.UpdateParams +} + +func loadTestData(t *testing.T, filename string) *testData { + f, err := testDataFS.ReadFile(filepath.Join("testdata", "ota", filename)) + if err != nil { + t.Fatalf("failed to read test data file %s: %v", filename, err) + } + + var testData testData + if err := json.Unmarshal(f, &testData); err != nil { + t.Fatalf("failed to unmarshal test data file %s: %v", filename, err) + } + + return &testData +} + +type mockData struct { + Metadata *UpdateMetadata + StatusCode int +} + +type mockHTTPClient struct { + DoFunc func(req *http.Request) (*http.Response, error) + Fixtures map[string]mockData +} + +func compareURLs(a *url.URL, b *url.URL) bool { + if a.String() == b.String() { + return true + } + if a.Host != b.Host || a.Scheme != b.Scheme || a.Path != b.Path { + return false + } -type otaTestStateParams struct { - LocalSystemVersion string - LocalAppVersion string - WithoutCerts bool + // do a quick check to see if the query parameters are the same + queryA := a.Query() + queryB := b.Query() + if len(queryA) != len(queryB) { + return false + } + for key := range queryA { + if queryA.Get(key) != queryB.Get(key) { + return false + } + } + for key := range queryB { + if queryA.Get(key) != queryB.Get(key) { + return false + } + } + return true +} + +func (m *mockHTTPClient) getFixture(expectedURL *url.URL) *mockData { + for u, fixture := range m.Fixtures { + fixtureURL, err := url.Parse(u) + if err != nil { + continue + } + if compareURLs(fixtureURL, expectedURL) { + return &fixture + } + } + return nil } -func newOtaState(p otaTestStateParams) *State { +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + fixture := m.getFixture(req.URL) + if fixture == nil { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewBufferString("")), + }, fmt.Errorf("no fixture found for URL: %s", req.URL.String()) + } + + resp := &http.Response{ + StatusCode: fixture.StatusCode, + } + + jsonData, err := json.Marshal(fixture.Metadata) + if err != nil { + return nil, fmt.Errorf("error marshalling metadata: %w", err) + } + + resp.Body = io.NopCloser(bytes.NewBufferString(string(jsonData))) + return resp, nil +} + +func newMockHTTPClient(fixtures map[string]mockData) *mockHTTPClient { + return &mockHTTPClient{ + Fixtures: fixtures, + } +} + +func newOtaState(d *testData, t *testing.T) *State { pseudoGetLocalVersion := func() (systemVersion *semver.Version, appVersion *semver.Version, err error) { - appVersion = semver.MustParse(p.LocalAppVersion) - systemVersion = semver.MustParse(p.LocalSystemVersion) + appVersion = semver.MustParse(d.LocalMetadata.AppVersion) + systemVersion = semver.MustParse(d.LocalMetadata.SystemVersion) return systemVersion, appVersion, nil } traceLevel := zerolog.InfoLevel - if os.Getenv("TEST_LOG_TRACE") == "true" { + if os.Getenv("TEST_LOG_TRACE") == "1" { traceLevel = zerolog.TraceLevel } logger := zerolog.New(os.Stdout).Level(traceLevel) otaState := NewState(Options{ SkipConfirmSystem: true, Logger: &logger, - ReleaseAPIEndpoint: "https://api.jetkvm.com/releases", - GetHTTPClient: func() *http.Client { + ReleaseAPIEndpoint: releaseAPIEndpoint, + GetHTTPClient: func() HttpClient { + if d.RemoteMetadata != nil { + return newMockHTTPClient(d.ToFixtures(t)) + } transport := http.DefaultTransport.(*http.Transport).Clone() - if !p.WithoutCerts { + if !d.WithoutCerts { transport.TLSClientConfig = &tls.Config{RootCAs: rootcerts.ServerCertPool()} } else { transport.TLSClientConfig = &tls.Config{RootCAs: x509.NewCertPool()} @@ -62,146 +207,55 @@ func newOtaState(p otaTestStateParams) *State { return otaState } -func TestCheckUpdateComponentsWithoutCerts(t *testing.T) { - otaState := newOtaState(otaTestStateParams{ - LocalSystemVersion: "0.2.5", - LocalAppVersion: "0.4.7", - WithoutCerts: true, - }) - _, err := otaState.GetUpdateStatus(context.Background(), UpdateParams{}) - assert.ErrorContains(t, err, "certificate signed by unknown authority") -} - -type updateStatusAsserts struct { - system bool - app bool - skip bool -} - -func testGetUpdateStatus(t *testing.T, p otaTestStateParams, u UpdateParams, asserts updateStatusAsserts) *UpdateStatus { - otaState := newOtaState(p) - info, err := otaState.GetUpdateStatus(context.Background(), u) - t.Logf("update status: %+v", info) +func testUsingJson(t *testing.T, filename string) { + td := loadTestData(t, filename) + otaState := newOtaState(td, t) + info, err := otaState.GetUpdateStatus(context.Background(), td.ToUpdateParams()) if err != nil { - t.Fatalf("failed to check update: %v", err) - } - if asserts.skip { - return info + if td.Expected.Error != "" { + assert.ErrorContains(t, err, td.Expected.Error) + } else { + t.Fatalf("failed to get update status: %v", err) + } } - if asserts.system { + if td.Expected.System { assert.True(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should available, but reason: %s", info.SystemUpdateAvailableReason)) } else { assert.False(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should not be available, but reason: %s", info.SystemUpdateAvailableReason)) } - if asserts.app { + + if td.Expected.App { assert.True(t, info.AppUpdateAvailable, fmt.Sprintf("app update should available, but reason: %s", info.AppUpdateAvailableReason)) } else { assert.False(t, info.AppUpdateAvailable, fmt.Sprintf("app update should not be available, but reason: %s", info.AppUpdateAvailableReason)) } - return info } func TestCheckUpdateComponentsSystemOnlyUpgrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.2", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - IncludePreRelease: false, - Components: map[string]string{"system": ""}, - }, updateStatusAsserts{ - system: true, - app: false, - }) + testUsingJson(t, "system_only_upgrade.json") } func TestCheckUpdateComponentsSystemOnlyDowngrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.5", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - Components: map[string]string{"system": "0.2.2"}, - IncludePreRelease: false, - }, updateStatusAsserts{ - system: true, - app: false, - }) + testUsingJson(t, "system_only_downgrade.json") } func TestCheckUpdateComponentsAppOnlyUpgrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.2", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - IncludePreRelease: false, - Components: map[string]string{"app": ""}, - }, updateStatusAsserts{ - system: false, - app: true, - }) + testUsingJson(t, "app_only_upgrade.json") } func TestCheckUpdateComponentsAppOnlyDowngrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.2", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - Components: map[string]string{"app": "0.4.6"}, - IncludePreRelease: false, - }, updateStatusAsserts{ - system: false, - app: true, - }) + testUsingJson(t, "app_only_downgrade.json") } func TestCheckUpdateComponentsSystemBothUpgrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.2", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - IncludePreRelease: false, - Components: map[string]string{"system": "", "app": ""}, - }, updateStatusAsserts{ - system: true, - app: true, - }) + testUsingJson(t, "both_upgrade.json") } func TestCheckUpdateComponentsSystemBothDowngrade(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.5", // remote >= 0.2.5 - LocalAppVersion: "0.4.5", // remote >= 0.4.7 - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - Components: map[string]string{"system": "0.2.2", "app": "0.4.6"}, - IncludePreRelease: false, - }, updateStatusAsserts{ - system: true, - app: true, - }) + testUsingJson(t, "both_downgrade.json") } func TestCheckUpdateComponentsNoComponents(t *testing.T) { - _ = testGetUpdateStatus(t, otaTestStateParams{ - LocalSystemVersion: "0.2.2", - LocalAppVersion: "0.4.2", - WithoutCerts: false, - }, UpdateParams{ - DeviceID: pseudoDeviceID, - IncludePreRelease: false, - }, updateStatusAsserts{ - system: true, - app: true, - }) + testUsingJson(t, "no_components.json") } diff --git a/internal/ota/state.go b/internal/ota/state.go index 0ee572127..1bb12033a 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -1,7 +1,6 @@ package ota import ( - "net/http" "sync" "time" @@ -100,7 +99,7 @@ type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay tim type ResetConfigFunc func() error // GetHTTPClientFunc is a function that returns the HTTP client -type GetHTTPClientFunc func() *http.Client +type GetHTTPClientFunc func() HttpClient // OnStateUpdateFunc is a function that updates the state of the OTA type OnStateUpdateFunc func(state *RPCState) diff --git a/internal/ota/testdata/ota.schema.json b/internal/ota/testdata/ota.schema.json new file mode 100644 index 000000000..15965850e --- /dev/null +++ b/internal/ota/testdata/ota.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "OTA Test Data Schema", + "description": "Schema for OTA update test data", + "type": "object", + "required": ["name", "remoteMetadata", "localMetadata", "updateParams"], + "properties": { + "name": { + "type": "string", + "description": "Name of the test case" + }, + "withoutCerts": { + "type": "boolean", + "default": false, + "description": "Whether to run the test without Root CA certificates" + }, + "remoteMetadata": { + "type": "array", + "description": "Remote metadata responses", + "items": { + "type": "object", + "required": ["params", "code", "data"], + "properties": { + "params": { + "type": "object", + "description": "Query parameters used for the request", + "required": ["prerelease"], + "properties": { + "prerelease": { + "type": "string", + "description": "Whether to include pre-release versions" + }, + "appVersion": { + "type": "string", + "description": "Application version string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "systemVersion": { + "type": "string", + "description": "System version string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + }, + "additionalProperties": false + }, + "code": { + "type": "integer", + "description": "HTTP status code" + }, + "data": { + "type": "object", + "required": ["appVersion", "appUrl", "appHash", "systemVersion", "systemUrl", "systemHash"], + "properties": { + "appVersion": { + "type": "string", + "description": "Application version string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "appUrl": { + "type": "string", + "description": "URL to download the application", + "format": "uri" + }, + "appHash": { + "type": "string", + "description": "SHA-256 hash of the application", + "pattern": "^[a-f0-9]{64}$" + }, + "systemVersion": { + "type": "string", + "description": "System version string", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "systemUrl": { + "type": "string", + "description": "URL to download the system", + "format": "uri" + }, + "systemHash": { + "type": "string", + "description": "SHA-256 hash of the system", + "pattern": "^[a-f0-9]{64}$" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "localMetadata": { + "type": "object", + "description": "Local metadata containing current installed versions", + "required": ["systemVersion", "appVersion"], + "properties": { + "systemVersion": { + "type": "string", + "description": "Currently installed system version", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + }, + "appVersion": { + "type": "string", + "description": "Currently installed application version", + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" + } + }, + "additionalProperties": false + }, + "updateParams": { + "type": "object", + "description": "Parameters for the update operation", + "required": ["includePreRelease"], + "properties": { + "includePreRelease": { + "type": "boolean", + "description": "Whether to include pre-release versions" + }, + "components": { + "type": "object", + "description": "Component update configuration", + "properties": { + "system": { + "type": "string", + "description": "System component update configuration (empty string to update)" + }, + "app": { + "type": "string", + "description": "App component update configuration (version string to update to)" + } + }, + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "expected": { + "type": "object", + "description": "Expected update results", + "required": [], + "properties": { + "system": { + "type": "boolean", + "description": "Whether system update is expected" + }, + "app": { + "type": "boolean", + "description": "Whether app update is expected" + }, + "error": { + "type": "string", + "description": "Error message if the test case is expected to fail" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} + diff --git a/internal/ota/testdata/ota/app_only_downgrade.json b/internal/ota/testdata/ota/app_only_downgrade.json new file mode 100644 index 000000000..e8e2f7d14 --- /dev/null +++ b/internal/ota/testdata/ota/app_only_downgrade.json @@ -0,0 +1,34 @@ +{ + "name": "Downgrade App Only", + "remoteMetadata": [ + { + "params": { + "prerelease": "false", + "appVersion": "0.4.6" + }, + "code": 200, + "data": { + "appVersion": "0.4.6", + "appUrl": "https://update.jetkvm.com/app/0.4.6/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.5", + "systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.2", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "app": "0.4.6" + } + }, + "expected": { + "system": false, + "app": true + } +} \ No newline at end of file diff --git a/internal/ota/testdata/ota/app_only_upgrade.json b/internal/ota/testdata/ota/app_only_upgrade.json new file mode 100644 index 000000000..69aa7fb71 --- /dev/null +++ b/internal/ota/testdata/ota/app_only_upgrade.json @@ -0,0 +1,33 @@ +{ + "name": "Upgrade App Only", + "remoteMetadata": [ + { + "params": { + "prerelease": "false" + }, + "code": 200, + "data": { + "appVersion": "0.4.7", + "appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.5", + "systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.2", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "app": "" + } + }, + "expected": { + "system": false, + "app": true + } +} \ No newline at end of file diff --git a/internal/ota/testdata/ota/both_downgrade.json b/internal/ota/testdata/ota/both_downgrade.json new file mode 100644 index 000000000..3c57461c2 --- /dev/null +++ b/internal/ota/testdata/ota/both_downgrade.json @@ -0,0 +1,37 @@ +{ + "name": "Downgrade System & App", + "remoteMetadata": [ + { + "params": { + "prerelease": "false", + "systemVersion": "0.2.2", + "appVersion": "0.4.6" + }, + "code": 200, + "data": { + "appVersion": "0.4.6", + "appUrl": "https://update.jetkvm.com/app/0.4.6/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.2", + "systemUrl": "https://update.jetkvm.com/system/0.2.2/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.5", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "system": "0.2.2", + "app": "0.4.6" + } + }, + "expected": { + "system": true, + "app": true + } +} + diff --git a/internal/ota/testdata/ota/both_upgrade.json b/internal/ota/testdata/ota/both_upgrade.json new file mode 100644 index 000000000..c3d3daee8 --- /dev/null +++ b/internal/ota/testdata/ota/both_upgrade.json @@ -0,0 +1,34 @@ +{ + "name": "Upgrade System & App (components given)", + "remoteMetadata": [ + { + "params": { + "prerelease": "false" + }, + "code": 200, + "data": { + "appVersion": "0.4.7", + "appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.5", + "systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.2", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "system": "", + "app": "" + } + }, + "expected": { + "system": true, + "app": true + } +} \ No newline at end of file diff --git a/internal/ota/testdata/ota/no_components.json b/internal/ota/testdata/ota/no_components.json new file mode 100644 index 000000000..9fb8b253f --- /dev/null +++ b/internal/ota/testdata/ota/no_components.json @@ -0,0 +1,32 @@ +{ + "name": "Upgrade System & App (no components given)", + "remoteMetadata": [ + { + "params": { + "prerelease": "false" + }, + "code": 200, + "data": { + "appVersion": "0.4.7", + "appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.5", + "systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.2", + "appVersion": "0.4.2" + }, + "updateParams": { + "includePreRelease": false, + "components": {} + }, + "expected": { + "system": true, + "app": true + } +} + diff --git a/internal/ota/testdata/ota/system_only_downgrade.json b/internal/ota/testdata/ota/system_only_downgrade.json new file mode 100644 index 000000000..007f5279f --- /dev/null +++ b/internal/ota/testdata/ota/system_only_downgrade.json @@ -0,0 +1,34 @@ +{ + "name": "Downgrade System Only", + "remoteMetadata": [ + { + "params": { + "prerelease": "false", + "systemVersion": "0.2.2" + }, + "code": 200, + "data": { + "appVersion": "0.4.7", + "appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.2", + "systemUrl": "https://update.jetkvm.com/system/0.2.2/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.5", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "system": "0.2.2" + } + }, + "expected": { + "system": true, + "app": false + } +} \ No newline at end of file diff --git a/internal/ota/testdata/ota/system_only_upgrade.json b/internal/ota/testdata/ota/system_only_upgrade.json new file mode 100644 index 000000000..b32c9434d --- /dev/null +++ b/internal/ota/testdata/ota/system_only_upgrade.json @@ -0,0 +1,33 @@ +{ + "name": "Upgrade System Only", + "remoteMetadata": [ + { + "params": { + "prerelease": "false" + }, + "code": 200, + "data": { + "appVersion": "0.4.7", + "appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app", + "appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd", + "systemVersion": "0.2.6", + "systemUrl": "https://update.jetkvm.com/system/0.2.6/system.tar", + "systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35" + } + } + ], + "localMetadata": { + "systemVersion": "0.2.5", + "appVersion": "0.4.5" + }, + "updateParams": { + "includePreRelease": false, + "components": { + "system": "" + } + }, + "expected": { + "system": true, + "app": false + } +} \ No newline at end of file diff --git a/internal/ota/testdata/ota/without_certs.json b/internal/ota/testdata/ota/without_certs.json new file mode 100644 index 000000000..d51508960 --- /dev/null +++ b/internal/ota/testdata/ota/without_certs.json @@ -0,0 +1,17 @@ +{ + "name": "Without Certs", + "localMetadata": { + "systemVersion": "0.2.5", + "appVersion": "0.4.7" + }, + "updateParams": { + "includePreRelease": false, + "components": {} + }, + "expected": { + "system": false, + "app": false, + "error": "certificate signed by unknown authority" + } +} + diff --git a/ota.go b/ota.go index b8ae47f12..19ef20fdb 100644 --- a/ota.go +++ b/ota.go @@ -19,7 +19,7 @@ func initOta() { otaState = ota.NewState(ota.Options{ Logger: otaLogger, ReleaseAPIEndpoint: config.GetUpdateAPIURL(), - GetHTTPClient: func() *http.Client { + GetHTTPClient: func() ota.HttpClient { transport := http.DefaultTransport.(*http.Transport).Clone() transport.Proxy = config.NetworkConfig.GetTransportProxyFunc() From c19bd0d46b8f07921bb55781cedf531bbe72cfbd Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 12:27:07 +0000 Subject: [PATCH 36/62] fix: set updating to false when checking for updates failed --- internal/ota/ota.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index d0e330fbc..6802e316a 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -148,6 +148,7 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) if err != nil { + s.updating = false return s.componentUpdateError("Error checking for updates", err, &scopedLogger) } From e1943c89e826bcb0e937e4a685cebee71c00b1bc Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 13:16:54 +0000 Subject: [PATCH 37/62] fix: do not set zero values in RPCState --- internal/ota/rpc.go | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/internal/ota/rpc.go b/internal/ota/rpc.go index 7ef007ffe..849760255 100644 --- a/internal/ota/rpc.go +++ b/internal/ota/rpc.go @@ -36,6 +36,18 @@ type RPCState struct { SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"` } +func setTimeIfNotZero(rpcVal reflect.Value, i int, status time.Time) { + if !status.IsZero() { + rpcVal.Field(i).Set(reflect.ValueOf(&status)) + } +} + +func setFloat32IfNotZero(rpcVal reflect.Value, i int, status float32) { + if status != 0 { + rpcVal.Field(i).Set(reflect.ValueOf(&status)) + } +} + // applyComponentStatusToRPCState uses reflection to map componentUpdateStatus fields to RPCState func applyComponentStatusToRPCState(component string, status componentUpdateStatus, rpcState *RPCState) { prefix := componentFieldMap[component] @@ -55,17 +67,17 @@ func applyComponentStatusToRPCState(component string, status componentUpdateStat switch rpcFieldName { case "DownloadProgress": - rpcVal.Field(i).Set(reflect.ValueOf(&status.downloadProgress)) + setFloat32IfNotZero(rpcVal, i, status.downloadProgress) case "DownloadFinishedAt": - rpcVal.Field(i).Set(reflect.ValueOf(&status.downloadFinishedAt)) + setTimeIfNotZero(rpcVal, i, status.downloadFinishedAt) case "VerificationProgress": - rpcVal.Field(i).Set(reflect.ValueOf(&status.verificationProgress)) + setFloat32IfNotZero(rpcVal, i, status.verificationProgress) case "VerifiedAt": - rpcVal.Field(i).Set(reflect.ValueOf(&status.verifiedAt)) + setTimeIfNotZero(rpcVal, i, status.verifiedAt) case "UpdateProgress": - rpcVal.Field(i).Set(reflect.ValueOf(&status.updateProgress)) + setFloat32IfNotZero(rpcVal, i, status.updateProgress) case "UpdatedAt": - rpcVal.Field(i).Set(reflect.ValueOf(&status.updatedAt)) + setTimeIfNotZero(rpcVal, i, status.updatedAt) case "UpdatePending": rpcVal.Field(i).SetBool(status.pending) default: From eff4920a065431dbdd39406d3dfdd1524ce5b000 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 13:33:18 +0000 Subject: [PATCH 38/62] fix: remove mutex from updateApp & updateSystem --- internal/ota/app.go | 14 ++------------ internal/ota/errors.go | 16 +++++++++++++++- internal/ota/ota.go | 4 ++++ internal/ota/sys.go | 5 ++--- internal/ota/utils.go | 12 +++++++++++- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/internal/ota/app.go b/internal/ota/app.go index 3ae206f9a..55caa8e8a 100644 --- a/internal/ota/app.go +++ b/internal/ota/app.go @@ -2,25 +2,15 @@ package ota import ( "context" - "fmt" "time" - - "github.com/rs/zerolog" ) const ( appUpdatePath = "/userdata/jetkvm/jetkvm_app.update" ) -func (s *State) componentUpdateError(prefix string, err error, l *zerolog.Logger) error { - if l == nil { - l = s.l - } - l.Error().Err(err).Msg(prefix) - s.error = fmt.Sprintf("%s: %v", prefix, err) - return err -} - +// DO NOT call it directly, it's not thread safe +// Mutex is currently held by the caller, e.g. doUpdate func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) error { l := s.l.With().Str("path", appUpdatePath).Logger() diff --git a/internal/ota/errors.go b/internal/ota/errors.go index 498042820..45f22890b 100644 --- a/internal/ota/errors.go +++ b/internal/ota/errors.go @@ -1,8 +1,22 @@ package ota -import "errors" +import ( + "errors" + "fmt" + + "github.com/rs/zerolog" +) var ( // ErrVersionNotFound is returned when the specified version is not found ErrVersionNotFound = errors.New("specified version not found") ) + +func (s *State) componentUpdateError(prefix string, err error, l *zerolog.Logger) error { + if l == nil { + l = s.l + } + l.Error().Err(err).Msg(prefix) + s.error = fmt.Sprintf("%s: %v", prefix, err) + return err +} diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 6802e316a..bdf28f179 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -171,6 +171,8 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { s.triggerComponentUpdateState("system", systemUpdate) } + scopedLogger.Trace().Bool("pending", appUpdate.pending).Msg("Checking for app update") + if appUpdate.pending { scopedLogger.Info(). Str("url", appUpdate.url). @@ -184,6 +186,8 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { scopedLogger.Info().Msg("App is up to date") } + scopedLogger.Trace().Bool("pending", systemUpdate.pending).Msg("Checking for system update") + if systemUpdate.pending { if err := s.updateSystem(ctx, systemUpdate); err != nil { return s.componentUpdateError("Error updating system", err, &scopedLogger) diff --git a/internal/ota/sys.go b/internal/ota/sys.go index 465b9a4d5..6a5002f6f 100644 --- a/internal/ota/sys.go +++ b/internal/ota/sys.go @@ -11,10 +11,9 @@ const ( systemUpdatePath = "/userdata/jetkvm/update_system.tar" ) +// DO NOT call it directly, it's not thread safe +// Mutex is currently held by the caller, e.g. doUpdate func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateStatus) error { - s.mu.Lock() - defer s.mu.Unlock() - l := s.l.With().Str("path", systemUpdatePath).Logger() if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, "system"); err != nil { diff --git a/internal/ota/utils.go b/internal/ota/utils.go index 6da310ef0..45d6c92c8 100644 --- a/internal/ota/utils.go +++ b/internal/ota/utils.go @@ -26,6 +26,10 @@ func syncFilesystem() error { } func (s *State) downloadFile(ctx context.Context, path string, url string, component string) error { + logger := s.l.With().Str("path", path).Str("url", url).Str("component", component).Logger() + + logger.Trace().Msg("downloading file") + componentUpdate, ok := s.componentUpdateStatuses[component] if !ok { return fmt.Errorf("component %s not found", component) @@ -34,6 +38,7 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, compo downloadProgress := componentUpdate.downloadProgress if _, err := os.Stat(path); err == nil { + logger.Trace().Msg("removing existing file") if err := os.Remove(path); err != nil { return fmt.Errorf("error removing existing file: %w", err) } @@ -41,23 +46,27 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, compo unverifiedPath := path + ".unverified" if _, err := os.Stat(unverifiedPath); err == nil { + logger.Trace().Msg("removing existing unverified file") if err := os.Remove(unverifiedPath); err != nil { return fmt.Errorf("error removing existing unverified file: %w", err) } } + logger.Trace().Msg("creating unverified file") file, err := os.Create(unverifiedPath) if err != nil { return fmt.Errorf("error creating file: %w", err) } defer file.Close() + logger.Trace().Msg("creating request") req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return fmt.Errorf("error creating request: %w", err) } client := s.client() + logger.Trace().Msg("starting download") resp, err := client.Do(req) if err != nil { return fmt.Errorf("error downloading file: %w", err) @@ -100,15 +109,16 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, compo } } + logger.Trace().Msg("download finished") file.Close() + logger.Trace().Msg("syncing filesystem") if err := syncFilesystem(); err != nil { return fmt.Errorf("error syncing filesystem: %w", err) } return nil } - func (s *State) verifyFile(path string, expectedHash string, verifyProgress *float32) error { l := s.l.With().Str("path", path).Logger() From 176b7d2f0692455a00a7c4b523e93b529416f571 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 13:34:21 +0000 Subject: [PATCH 39/62] cleanup: remove CheckOnly from UpdateParams --- internal/ota/ota.go | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index bdf28f179..589aba2ee 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -141,11 +141,6 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { return fmt.Errorf("no components to update") } - if !params.CheckOnly { - s.updating = true - s.triggerStateUpdate() - } - appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params) if err != nil { s.updating = false @@ -155,12 +150,6 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { s.metadataFetchedAt = time.Now() s.triggerStateUpdate() - if params.CheckOnly { - s.updating = false - s.triggerStateUpdate() - return nil - } - if shouldUpdateApp && appUpdate.available { appUpdate.pending = true s.triggerComponentUpdateState("app", appUpdate) @@ -227,7 +216,6 @@ type UpdateParams struct { DeviceID string `json:"deviceID"` Components map[string]string `json:"components"` IncludePreRelease bool `json:"includePreRelease"` - CheckOnly bool `json:"checkOnly"` ResetConfig bool `json:"resetConfig"` } From d9f8054906c9674deca0b707a30d327008397fac Mon Sep 17 00:00:00 2001 From: Siyuan Date: Mon, 17 Nov 2025 14:53:47 +0000 Subject: [PATCH 40/62] feat: disable auto-update when custom version update is detected --- internal/ota/ota.go | 8 ++++++++ internal/ota/rpc.go | 1 + internal/ota/state.go | 9 +++++++++ ota.go | 7 +++++++ ui/localization/messages/da.json | 1 + ui/localization/messages/de.json | 1 + ui/localization/messages/en.json | 1 + ui/localization/messages/es.json | 1 + ui/localization/messages/fr.json | 1 + ui/localization/messages/it.json | 1 + ui/localization/messages/nb.json | 1 + ui/localization/messages/sv.json | 1 + ui/localization/messages/zh.json | 1 + ui/src/routes/devices.$id.settings.general.update.tsx | 5 +++++ ui/src/utils/jsonrpc.ts | 1 + 15 files changed, 40 insertions(+) diff --git a/internal/ota/ota.go b/internal/ota/ota.go index 589aba2ee..534c6976f 100644 --- a/internal/ota/ota.go +++ b/internal/ota/ota.go @@ -186,6 +186,14 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error { } if s.rebootNeeded { + if appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate { + scopedLogger.Info().Msg("disabling auto-update due to custom version update") + if _, err := s.setAutoUpdate(false); err != nil { + scopedLogger.Warn().Err(err).Msg("Failed to disable auto-update") + } + return nil + } + scopedLogger.Info().Msg("System Rebooting due to OTA update") redirectUrl := fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version) diff --git a/internal/ota/rpc.go b/internal/ota/rpc.go index 849760255..30f132ea8 100644 --- a/internal/ota/rpc.go +++ b/internal/ota/rpc.go @@ -156,6 +156,7 @@ func remoteMetadataToComponentStatus( componentStatus.available = componentStatus.version != componentStatus.localVersion if componentStatus.available { componentStatus.availableReason = fmt.Sprintf("custom version %s is not equal to local version %s", constraint, componentStatus.localVersion) + componentStatus.customVersionUpdate = true } } else if !componentExists { componentStatus.available = false diff --git a/internal/ota/state.go b/internal/ota/state.go index 1bb12033a..2bb7055ee 100644 --- a/internal/ota/state.go +++ b/internal/ota/state.go @@ -38,6 +38,7 @@ type UpdateStatus struct { Remote *UpdateMetadata `json:"remote"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"` + WillDisableAutoUpdate bool `json:"willDisableAutoUpdate"` // only available for debugging and won't be exported SystemUpdateAvailableReason string `json:"-"` @@ -59,6 +60,7 @@ type componentUpdateStatus struct { pending bool available bool availableReason string // why the component is available or not available + customVersionUpdate bool version string localVersion string url string @@ -98,6 +100,9 @@ type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay tim // ResetConfigFunc is a function that resets the config type ResetConfigFunc func() error +// SetAutoUpdateFunc is a function that sets the auto-update state +type SetAutoUpdateFunc func(enabled bool) (bool, error) + // GetHTTPClientFunc is a function that returns the HTTP client type GetHTTPClientFunc func() HttpClient @@ -125,6 +130,7 @@ type State struct { getLocalVersion GetLocalVersionFunc onStateUpdate OnStateUpdateFunc resetConfig ResetConfigFunc + setAutoUpdate SetAutoUpdateFunc } func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, error string) *UpdateStatus { @@ -145,6 +151,7 @@ func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpd SystemUpdateAvailableReason: systemUpdate.availableReason, AppUpdateAvailable: appUpdate.available, AppUpdateAvailableReason: appUpdate.availableReason, + WillDisableAutoUpdate: appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate, Error: error, } } @@ -180,6 +187,7 @@ type Options struct { ReleaseAPIEndpoint string ResetConfig ResetConfigFunc SkipConfirmSystem bool + SetAutoUpdate SetAutoUpdateFunc } // NewState creates a new OTA state @@ -198,6 +206,7 @@ func NewState(opts Options) *State { componentUpdateStatuses: components, releaseAPIEndpoint: opts.ReleaseAPIEndpoint, resetConfig: opts.ResetConfig, + setAutoUpdate: opts.SetAutoUpdate, } if !opts.SkipConfirmSystem { go s.confirmCurrentSystem() diff --git a/ota.go b/ota.go index 19ef20fdb..595933aa2 100644 --- a/ota.go +++ b/ota.go @@ -31,6 +31,7 @@ func initOta() { GetLocalVersion: GetLocalVersion, HwReboot: hwReboot, ResetConfig: rpcResetConfig, + SetAutoUpdate: rpcSetAutoUpdateState, OnStateUpdate: func(state *ota.RPCState) { triggerOTAStateUpdate(state) }, @@ -82,6 +83,7 @@ func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { DeviceID: GetDeviceID(), IncludePreRelease: includePreRelease, }) + // to ensure backwards compatibility, // if there's an error, we won't return an error, but we will set the error field if err != nil { @@ -91,6 +93,11 @@ func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) { updateStatus.Error = err.Error() } + // otaState doesn't have the current auto-update state, so we need to get it from the config + if updateStatus.WillDisableAutoUpdate { + updateStatus.WillDisableAutoUpdate = config.AutoUpdateEnabled + } + otaLogger.Info().Interface("updateStatus", updateStatus).Msg("Update status") return updateStatus, nil diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json index f2773deef..1a41e0cc4 100644 --- a/ui/localization/messages/da.json +++ b/ui/localization/messages/da.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Systemet er opdateret", "general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.", "general_update_updating_title": "Opdatering af din enhed", + "general_update_will_disable_auto_update_description": "Du nedgraderer i øjeblikket til en tidligere version. Automatisk opdatering vil blive deaktiveret, når opdateringen er fuldført, for at forhindre utilsigtede opdateringer.", "getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}", "hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}", "hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}", diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json index f855ab88e..e37b7be00 100644 --- a/ui/localization/messages/de.json +++ b/ui/localization/messages/de.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Das System ist auf dem neuesten Stand", "general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.", "general_update_updating_title": "Aktualisieren Ihres Geräts", + "general_update_will_disable_auto_update_description": "Sie führen derzeit ein Downgrade auf eine ältere Version durch. Die automatische Aktualisierung wird nach Abschluss des Updates deaktiviert, um versehentliche Aktualisierungen zu verhindern.", "getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}", "hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}", "hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}", diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json index a52d16291..26be6bcf1 100644 --- a/ui/localization/messages/en.json +++ b/ui/localization/messages/en.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "System is up to date", "general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.", "general_update_updating_title": "Updating your device", + "general_update_will_disable_auto_update_description": "You're currently downgrading to a previous version. Auto-update will be disabled after the update is completed to prevent accidental updates.", "getting_remote_session_description": "Getting remote session description attempt {attempt}", "hardware_backlight_settings_error": "Failed to set backlight settings: {error}", "hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}", diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json index bb3e5500d..d4987c9a2 100644 --- a/ui/localization/messages/es.json +++ b/ui/localization/messages/es.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "El sistema está actualizado", "general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.", "general_update_updating_title": "Actualizar su dispositivo", + "general_update_will_disable_auto_update_description": "Estás instalando una versión anterior. La actualización automática se desactivará una vez finalizada la actualización para evitar actualizaciones accidentales.", "getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}", "hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}", "hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}", diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json index d44f21b06..2d1f2fe42 100644 --- a/ui/localization/messages/fr.json +++ b/ui/localization/messages/fr.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Le système est à jour", "general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.", "general_update_updating_title": "Mise à jour de votre appareil", + "general_update_will_disable_auto_update_description": "Vous allez revenir à une version antérieure. La mise à jour automatique sera désactivée une fois l'opération terminée afin d'éviter toute mise à jour accidentelle.", "getting_remote_session_description": "Obtention d'{attempt} description de session à distance", "hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}", "hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}", diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json index 30880f9c5..b5d0007b1 100644 --- a/ui/localization/messages/it.json +++ b/ui/localization/messages/it.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Il sistema è aggiornato", "general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.", "general_update_updating_title": "Aggiornamento del dispositivo", + "general_update_will_disable_auto_update_description": "Stai eseguendo il downgrade a una versione precedente. L'aggiornamento automatico verrà disattivato al termine dell'aggiornamento per evitare aggiornamenti accidentali.", "getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}", "hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}", "hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}", diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json index 1e8e18319..6f8b1ba3b 100644 --- a/ui/localization/messages/nb.json +++ b/ui/localization/messages/nb.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Alt er oppdatert", "general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.", "general_update_updating_title": "Oppdaterer enheten din", + "general_update_will_disable_auto_update_description": "Du nedgraderer for øyeblikket til en tidligere versjon. Automatisk oppdatering vil bli deaktivert etter at oppdateringen er fullført for å forhindre utilsiktede oppdateringer.", "getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}", "hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}", "hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}", diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json index 1be9bb794..2af36228b 100644 --- a/ui/localization/messages/sv.json +++ b/ui/localization/messages/sv.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "Systemet är uppdaterat", "general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.", "general_update_updating_title": "Uppdaterar din enhet", + "general_update_will_disable_auto_update_description": "Du nedgraderar för närvarande till en tidigare version. Automatisk uppdatering inaktiveras efter att uppdateringen är klar för att förhindra oavsiktliga uppdateringar.", "getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}", "hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}", "hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}", diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json index 46ee0b752..cc1cbc351 100644 --- a/ui/localization/messages/zh.json +++ b/ui/localization/messages/zh.json @@ -302,6 +302,7 @@ "general_update_up_to_date_title": "系统已更新", "general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。", "general_update_updating_title": "更新您的设备", + "general_update_will_disable_auto_update_description": "您目前正在降级到之前的版本。更新完成后,自动更新功能将被禁用,以防止意外更新。", "getting_remote_session_description": "获取远程会话描述尝试 {attempt}", "hardware_backlight_settings_error": "无法设置背光设置: {error}", "hardware_backlight_settings_get_error": "无法获取背光设置: {error}", diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx index c69d2140c..cef41f2c7 100644 --- a/ui/src/routes/devices.$id.settings.general.update.tsx +++ b/ui/src/routes/devices.$id.settings.general.update.tsx @@ -468,6 +468,11 @@ function UpdateAvailableState({ {m.general_update_application_type()}: {versionInfo?.local?.appVersion} {versionInfo?.remote?.appVersion} ) : null} + {versionInfo?.willDisableAutoUpdate ? ( +

+ {m.general_update_will_disable_auto_update_description()} +

+ ) : null}

)} -
- - - setUpdateTarget(e.target.value)} - /> - - {(updateTarget === "app" || updateTarget === "both") && ( - setAppVersion(e.target.value)} - /> - )} - - {(updateTarget === "system" || updateTarget === "both") && ( - setSystemVersion(e.target.value)} + +
+ - )} - -

- {m.advanced_version_update_helper()}{" "} - - {m.advanced_version_update_github_link()} - -

- -
- setResetConfig(e.target.checked)} + + setUpdateTarget(e.target.value)} /> -
-
- setVersionChangeAcknowledged(e.target.checked)} + {(updateTarget === "app" || updateTarget === "both") && ( + setAppVersion(e.target.value)} + /> + )} + + {(updateTarget === "system" || updateTarget === "both") && ( + setSystemVersion(e.target.value)} + /> + )} + +

+ {m.advanced_version_update_helper()}{" "} + + {m.advanced_version_update_github_link()} + +

+ +
+ setResetConfig(e.target.checked)} + /> +
+ +
+ setVersionChangeAcknowledged(e.target.checked)} + /> +
+ +
- -
+
) : null}