From 85f7f6061866ed2a754d923276653bb53bf44d30 Mon Sep 17 00:00:00 2001 From: Siyuan Date: Tue, 28 Oct 2025 08:54:41 +0000 Subject: [PATCH 01/17] 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 | 597 ++---------------- 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, 865 insertions(+), 583 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 5a3e7dc8..7dc3db30 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 7797adc1..b6416e25 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 00000000..482a07de --- /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 00000000..a13036de --- /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 00000000..b45909ec --- /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 00000000..4375a08f --- /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 00000000..2426c353 --- /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 00000000..caa03384 --- /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 5ed90a7a..960e0bea 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 bcc2d73d..b6fc469d 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 846f41f1..25e562a0 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 5371e428..90bd8f28 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"` -} - -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"` -} - -const UpdateMetadataUrl = "https://api.jetkvm.com/releases" - var builtAppVersion = "0.1.0+dev" +var otaState *ota.State + +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() + + 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) + }, + }) +} + +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 0356e8e5..78b04538 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 94c2f99d..78bbc313 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 86e92bcd..7f70f2aa 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 4c3c9e94..9576dbef 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 7f70f2aa..55b92c00 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/17] 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 78b04538..9091334e 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 9576dbef..f6af2fc9 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/17] feat: downgrade --- .vscode/settings.json | 2 +- internal/ota/app.go | 4 +- internal/ota/ota.go | 105 +++++++++++----- 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, 366 insertions(+), 132 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf..2fc7b8b3 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 482a07de..5554549c 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 b45909ec..21b4705c 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 +} - req, err := http.NewRequestWithContext(ctx, "GET", 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", 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) } - s.metadataFetchedAt = time.Now() - s.onProgressUpdate() + if params.CheckOnly { + return nil + } - if appUpdate.available { + s.metadataFetchedAt = time.Now() + s.triggerStateUpdate() + + 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 4375a08f..6374edc9 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 2426c353..334fa1eb 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 caa03384..88d99e4d 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 960e0bea..e206617a 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 b6fc469d..8e989919 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 90bd8f28..921cd353 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 6c8b204c..c2afb5cf 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 9091334e..69a44fda 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 c56cb5f8..9f7fd226 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 78bbc313..8e24116b 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 f6af2fc9..84e28855 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 285ce940..cfd1b06a 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/17] 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 5554549c..301ea953 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 21b4705c..366ea922 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 6374edc9..9d9b8c01 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 334fa1eb..575cf634 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 88d99e4d..6da310ef 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 84e28855..b90b874c 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/17] 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 a13036de..00000000 --- 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 366ea922..9e40e840 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 9d9b8c01..0406b926 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 575cf634..465b9a4d 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 921cd353..dd761bdc 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 8e24116b..64b0c617 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 b90b874c..39fe73d3 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 cfd1b06a..2f38af3f 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/17] 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 9e40e840..194c8959 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 0406b926..a11a50f9 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 e206617a..7e2540ce 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 dd761bdc..d85ce84b 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 39fe73d3..6df14bec 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 2f38af3f..00e4ba05 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/17] 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 69a44fda..c8a6e948 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 6df14bec..e0facfaf 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)} + /> +