diff --git a/.vscode/settings.json b/.vscode/settings.json index ba3550bf..91db117f 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": "internal/native/cgo" } \ No newline at end of file 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..0a62d606 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 { 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..301ea953 --- /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, "app"); err != nil { + return s.componentUpdateError("Error downloading app update", err, &l) + } + + downloadFinished := time.Now() + appUpdate.downloadFinishedAt = downloadFinished + appUpdate.downloadProgress = 1 + s.triggerComponentUpdateState("app", appUpdate) + + 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.triggerComponentUpdateState("app", appUpdate) + + l.Info().Msg("App update downloaded") + + s.rebootNeeded = true + + return nil +} diff --git a/internal/ota/errors.go b/internal/ota/errors.go new file mode 100644 index 00000000..49804282 --- /dev/null +++ b/internal/ota/errors.go @@ -0,0 +1,8 @@ +package ota + +import "errors" + +var ( + // ErrVersionNotFound is returned when the specified version is not found + ErrVersionNotFound = errors.New("specified version not found") +) diff --git a/internal/ota/ota.go b/internal/ota/ota.go new file mode 100644 index 00000000..0459c3f2 --- /dev/null +++ b/internal/ota/ota.go @@ -0,0 +1,328 @@ +package ota + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "slices" + "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 +} + +// getUpdateURL returns the update URL for the given parameters +func (s *State) getUpdateURL(params UpdateParams) (string, error, bool) { + updateURL, err := url.Parse(s.releaseAPIEndpoint) + if err != nil { + return "", fmt.Errorf("error parsing update metadata URL: %w", err), false + } + + isCustomVersion := false + + appTargetVersion := s.GetTargetVersion("app") + if appTargetVersion != "" && params.AppTargetVersion == "" { + params.AppTargetVersion = appTargetVersion + } + systemTargetVersion := s.GetTargetVersion("system") + if systemTargetVersion != "" && params.SystemTargetVersion == "" { + params.SystemTargetVersion = systemTargetVersion + } + + query := updateURL.Query() + query.Set("deviceId", params.DeviceID) + query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease)) + if params.AppTargetVersion != "" { + query.Set("appVersion", params.AppTargetVersion) + isCustomVersion = true + } + if params.SystemTargetVersion != "" { + query.Set("systemVersion", params.SystemTargetVersion) + isCustomVersion = true + } + updateURL.RawQuery = query.Encode() + + return updateURL.String(), nil, isCustomVersion +} + +func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) { + metadata := &UpdateMetadata{} + + url, err, isCustomVersion := s.getUpdateURL(params) + if err != nil { + 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) + } + + 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 isCustomVersion && resp.StatusCode == http.StatusNotFound { + return nil, ErrVersionNotFound + } + + 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, params UpdateParams) error { + return s.doUpdate(ctx, params) +} + +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). + Logger() + + scopedLogger.Info().Msg("checking for updates") + if s.updating { + 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() + 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 + } + + if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) { + appUpdate.pending = true + s.triggerComponentUpdateState("app", appUpdate) + } + + if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) { + systemUpdate.pending = true + s.triggerComponentUpdateState("system", systemUpdate) + } + + 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") + + redirectUrl := fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version) + + if params.ResetConfig { + scopedLogger.Info().Msg("Resetting config") + if err := s.resetConfig(); err != nil { + return s.componentUpdateError("Error resetting config", err, &scopedLogger) + } + redirectUrl = "/device/setup" + } + + postRebootAction := &PostRebootAction{ + HealthCheck: "/device/status", + RedirectTo: redirectUrl, + } + + if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil { + return s.componentUpdateError("Error requesting reboot", err, &scopedLogger) + } + } + + return nil +} + +// UpdateParams represents the parameters for the update +type UpdateParams struct { + DeviceID string `json:"deviceID"` + AppTargetVersion string `json:"appTargetVersion"` + SystemTargetVersion string `json:"systemTargetVersion"` + Components []string `json:"components,omitempty"` + IncludePreRelease bool `json:"includePreRelease"` + CheckOnly bool `json:"checkOnly"` + ResetConfig bool `json:"resetConfig"` +} + +func (s *State) getUpdateStatus( + ctx context.Context, + params UpdateParams, +) ( + appUpdate *componentUpdateStatus, + systemUpdate *componentUpdateStatus, + err error, +) { + appUpdate = &componentUpdateStatus{} + systemUpdate = &componentUpdateStatus{} + + if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok { + appUpdate = ¤tAppUpdate + } + + if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok { + systemUpdate = ¤tSystemUpdate + } + + err = s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) + if err != nil { + return nil, nil, err + } + + s.componentUpdateStatuses["app"] = *appUpdate + s.componentUpdateStatuses["system"] = *systemUpdate + + return appUpdate, systemUpdate, nil +} + +// doGetUpdateStatus is the internal function that gets the update status +// it WON'T change the state of the OTA state +func (s *State) doGetUpdateStatus( + ctx context.Context, + params UpdateParams, + appUpdate *componentUpdateStatus, + systemUpdate *componentUpdateStatus, +) error { + // Get local versions + systemVersionLocal, appVersionLocal, err := s.getLocalVersion() + if err != nil { + return fmt.Errorf("error getting local version: %w", err) + } + appUpdate.localVersion = appVersionLocal.String() + systemUpdate.localVersion = systemVersionLocal.String() + + // Get remote metadata + remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) + if err != nil { + if err == ErrVersionNotFound || errors.Unwrap(err) == ErrVersionNotFound { + err = ErrVersionNotFound + } else { + err = fmt.Errorf("error checking for updates: %w", err) + } + return err + } + 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 err + } + systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) + systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal) + + appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) + if err != nil { + err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion) + return err + } + appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) + appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal) + + // Handle pre-release updates + isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" + isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" + + if isRemoteSystemPreRelease && !params.IncludePreRelease { + systemUpdate.available = false + } + if isRemoteAppPreRelease && !params.IncludePreRelease { + appUpdate.available = false + } + + return nil +} + +// GetUpdateStatus returns the current update status (for backwards compatibility) +func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) { + appUpdate := &componentUpdateStatus{} + systemUpdate := &componentUpdateStatus{} + err := s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate) + if err != nil { + return nil, fmt.Errorf("error getting update status: %w", err) + } + + return toUpdateStatus(appUpdate, systemUpdate, ""), nil +} diff --git a/internal/ota/state.go b/internal/ota/state.go new file mode 100644 index 00000000..f6a35e40 --- /dev/null +++ b/internal/ota/state.go @@ -0,0 +1,279 @@ +package ota + +import ( + "fmt" + "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"` + SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"` + AppUpdateAvailable bool `json:"appUpdateAvailable"` + AppDowngradeAvailable bool `json:"appDowngradeAvailable"` + + // 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 + RedirectTo string `json:"redirectTo"` // The URL to redirect to after the reboot +} + +// componentUpdateStatus represents the status of a component update +type componentUpdateStatus struct { + pending bool + available bool + downgradeAvailable bool + version string + localVersion string + targetVersion string + url string + hash string + downloadProgress float32 + downloadFinishedAt time.Time + verificationProgress float32 + verifiedAt time.Time + updateProgress float32 + updatedAt time.Time + dependsOn []string //nolint:unused +} + +// 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"` +} + +// 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 + +// 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 + onStateUpdate OnStateUpdateFunc + resetConfig ResetConfigFunc +} + +// SetTargetVersion sets the target version for a component +func (s *State) SetTargetVersion(component string, version string) error { + parsedVersion := version + if version != "" { + // validate if it's a valid semver string first + semverVersion, err := semver.NewVersion(version) + if err != nil { + return fmt.Errorf("not a valid semantic version: %w", err) + } + parsedVersion = semverVersion.String() + } + + // check if the component exists + componentUpdate, ok := s.componentUpdateStatuses[component] + if !ok { + return fmt.Errorf("component %s not found", component) + } + + componentUpdate.targetVersion = parsedVersion + s.componentUpdateStatuses[component] = componentUpdate + + return nil +} + +// GetTargetVersion returns the target version for a component +func (s *State) GetTargetVersion(component string) string { + componentUpdate, ok := s.componentUpdateStatuses[component] + if !ok { + return "" + } + return componentUpdate.targetVersion +} + +func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, error string) *UpdateStatus { + return &UpdateStatus{ + Local: &LocalMetadata{ + 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, + SystemDowngradeAvailable: systemUpdate.downgradeAvailable, + AppUpdateAvailable: appUpdate.available, + AppDowngradeAvailable: appUpdate.downgradeAvailable, + Error: error, + } +} + +// 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 toUpdateStatus(&appUpdate, &systemUpdate, 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 + ResetConfig ResetConfigFunc +} + +// 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: components, + releaseAPIEndpoint: opts.ReleaseAPIEndpoint, + resetConfig: opts.ResetConfig, + } + go s.confirmCurrentSystem() + return s +} + +// ToRPCState converts the State to the RPCState +// probably we need a generator for this ... +func (s *State) ToRPCState() *RPCState { + r := &RPCState{ + Updating: s.updating, + Error: s.error, + MetadataFetchedAt: &s.metadataFetchedAt, + } + + app, ok := s.componentUpdateStatuses["app"] + if ok { + r.AppUpdatePending = app.pending + r.AppDownloadProgress = &app.downloadProgress + if !app.downloadFinishedAt.IsZero() { + r.AppDownloadFinishedAt = &app.downloadFinishedAt + } + r.AppVerificationProgress = &app.verificationProgress + if !app.verifiedAt.IsZero() { + r.AppVerifiedAt = &app.verifiedAt + } + r.AppUpdateProgress = &app.updateProgress + if !app.updatedAt.IsZero() { + r.AppUpdatedAt = &app.updatedAt + } + r.AppTargetVersion = &app.targetVersion + } + + system, ok := s.componentUpdateStatuses["system"] + if ok { + r.SystemUpdatePending = system.pending + r.SystemDownloadProgress = &system.downloadProgress + if !system.downloadFinishedAt.IsZero() { + r.SystemDownloadFinishedAt = &system.downloadFinishedAt + } + r.SystemVerificationProgress = &system.verificationProgress + if !system.verifiedAt.IsZero() { + r.SystemVerifiedAt = &system.verifiedAt + } + r.SystemUpdateProgress = &system.updateProgress + if !system.updatedAt.IsZero() { + r.SystemUpdatedAt = &system.updatedAt + } + r.SystemTargetVersion = &system.targetVersion + } + + return r +} diff --git a/internal/ota/sys.go b/internal/ota/sys.go new file mode 100644 index 00000000..465b9a4d --- /dev/null +++ b/internal/ota/sys.go @@ -0,0 +1,102 @@ +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, "system"); err != nil { + return s.componentUpdateError("Error downloading system update", err, &l) + } + + downloadFinished := time.Now() + systemUpdate.downloadFinishedAt = downloadFinished + systemUpdate.downloadProgress = 1 + s.triggerComponentUpdateState("system", systemUpdate) + + 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.triggerComponentUpdateState("system", systemUpdate) + + 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.triggerComponentUpdateState("system", systemUpdate) + 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") + + s.rebootNeeded = true + systemUpdate.updateProgress = 1 + systemUpdate.updatedAt = verifyFinished + s.triggerComponentUpdateState("system", systemUpdate) + + 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..6da310ef --- /dev/null +++ b/internal/ota/utils.go @@ -0,0 +1,173 @@ +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, 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) + } + } + + 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 { + componentUpdate.downloadProgress = progress + s.triggerComponentUpdateState(component, &componentUpdate) + } + } + 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.triggerStateUpdate() + } + } + 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..2810e4f9 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -236,55 +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 rpcGetUpdateStatus() (*UpdateStatus, error) { - includePreRelease := config.IncludePreRelease - updateStatus, err := 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() - } - - return updateStatus, nil -} - -func rpcGetLocalVersion() (*LocalMetadata, error) { - systemVersion, appVersion, err := GetLocalVersion() - if err != nil { - return nil, fmt.Errorf("error getting local version: %w", err) - } - return &LocalMetadata{ - AppVersion: appVersion.String(), - SystemVersion: systemVersion.String(), - }, nil -} - -func rpcTryUpdate() error { - includePreRelease := config.IncludePreRelease - go func() { - err := 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 { @@ -654,7 +605,7 @@ func rpcGetMassStorageMode() (string, error) { } func rpcIsUpdatePending() (bool, error) { - return IsUpdatePending(), nil + return otaState.IsUpdatePending(), nil } func rpcGetUsbEmulationState() (bool, error) { @@ -1200,7 +1151,11 @@ var rpcHandlers = map[string]RPCHandler{ "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "getLocalVersion": {Func: rpcGetLocalVersion}, "getUpdateStatus": {Func: rpcGetUpdateStatus}, + "checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}}, + "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "tryUpdate": {Func: rpcTryUpdate}, + "tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}}, + "cancelDowngrade": {Func: rpcCancelDowngrade}, "getDevModeState": {Func: rpcGetDevModeState}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "getSSHKeyState": {Func: rpcGetSSHKeyState}, diff --git a/main.go b/main.go index bcc2d73d..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 @@ -32,12 +33,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 +42,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 +108,10 @@ func Main() { } includePreRelease := config.IncludePreRelease - err = 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/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..b9d454d6 100644 --- a/ota.go +++ b/ota.go @@ -1,59 +1,63 @@ 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, + ResetConfig: rpcResetConfig, + 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 { @@ -73,519 +77,130 @@ 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) +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 { - 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) + if updateStatus == nil { + return nil, fmt.Errorf("error checking for updates: %w", err) } + updateStatus.Error = err.Error() } - 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 - } + logger.Info().Interface("updateStatus", updateStatus).Msg("Update status") return updateStatus, nil } -func IsUpdatePending() bool { - return otaState.Updating +func rpcGetDevChannelState() (bool, error) { + return config.IncludePreRelease, nil } -// 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") +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 +} + +type updateParams 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 { + return rpcTryUpdateComponents(updateParams{ + AppTargetVersion: "", + SystemTargetVersion: "", + }, config.IncludePreRelease, false) +} + +// rpcCheckUpdateComponents checks the update status for the given components +func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota.UpdateStatus, error) { + updateParams := ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + AppTargetVersion: params.AppTargetVersion, + SystemTargetVersion: params.SystemTargetVersion, + } + if params.Components != "" { + updateParams.Components = strings.Split(params.Components, ",") + } + info, err := otaState.GetUpdateStatus(context.Background(), updateParams) + if err != nil { + return nil, fmt.Errorf("failed to check update: %w", err) + } + return info, nil +} + +func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetConfig bool) error { + updateParams := ota.UpdateParams{ + DeviceID: GetDeviceID(), + IncludePreRelease: includePreRelease, + ResetConfig: resetConfig, + } + + updateParams.AppTargetVersion = params.AppTargetVersion + if err := otaState.SetTargetVersion("app", params.AppTargetVersion); err != nil { + return fmt.Errorf("failed to set app target version: %w", err) + } + + updateParams.SystemTargetVersion = params.SystemTargetVersion + if err := otaState.SetTargetVersion("system", params.SystemTargetVersion); err != nil { + return fmt.Errorf("failed to set system target version: %w", err) + } + + if params.Components != "" { + updateParams.Components = strings.Split(params.Components, ",") + } + + 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 0356e8e5..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,6 +255,7 @@ "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_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?", @@ -261,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…", diff --git a/ui/src/components/NestedSettingsGroup.tsx b/ui/src/components/NestedSettingsGroup.tsx new file mode 100644 index 00000000..3ee57b0f --- /dev/null +++ b/ui/src/components/NestedSettingsGroup.tsx @@ -0,0 +1,22 @@ +import { cx } from "@/cva.config"; + +interface NestedSettingsGroupProps { + readonly children: React.ReactNode; + readonly className?: string; +} + +export function NestedSettingsGroup(props: NestedSettingsGroupProps) { + const { children, className } = props; + + return ( +
+ {children} +
+ ); +} + 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 94c2f99d..48af1f46 100644 --- a/ui/src/hooks/useVersion.tsx +++ b/ui/src/hooks/useVersion.tsx @@ -6,6 +6,21 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/ import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; +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 { appVersion, diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx index 766b8c4a..9b2d3cd3 100644 --- a/ui/src/routes/devices.$id.settings.access._index.tsx +++ b/ui/src/routes/devices.$id.settings.access._index.tsx @@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; +import { NestedSettingsGroup } from "@components/NestedSettingsGroup"; import { TextAreaWithLabel } from "@components/TextArea"; import api from "@/api"; import notifications from "@/notifications"; @@ -237,39 +238,30 @@ export default function SettingsAccessIndexRoute() { {tlsMode === "custom" && ( -
-
- -
- handleTlsCertChange(e.target.value)} - /> -
- -
-
- handleTlsKeyChange(e.target.value)} - /> -
-
-
+ + + handleTlsCertChange(e.target.value)} + /> + handleTlsKeyChange(e.target.value)} + />
-
+ )} {selectedProvider === "custom" && ( -
+
-
+ )} )} diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx index 4c3c9e94..9b0b8fd1 100644 --- a/ui/src/routes/devices.$id.settings.advanced.tsx +++ b/ui/src/routes/devices.$id.settings.advanced.tsx @@ -1,21 +1,28 @@ import { useCallback, useEffect, useState } from "react"; import { useSettingsStore } from "@hooks/stores"; -import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; +import { JsonRpcError, 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"; 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"; import { sleep } from "@/utils"; +import { checkUpdateComponents } from "@/utils/jsonrpc"; +import { SystemVersionInfo } from "@hooks/useVersion"; export default function SettingsAdvancedRoute() { const { send } = useJsonRpc(); + const { navigateTo } = useDeviceUiNavigation(); const [sshKey, setSSHKey] = useState(""); const { setDeveloperMode } = useSettingsStore(); @@ -23,7 +30,12 @@ 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 [resetConfig, setResetConfig] = useState(false); + const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false); + const [versionUpdateLoading, setVersionUpdateLoading] = useState(false); const settings = useSettingsStore(); useEffect(() => { @@ -173,6 +185,58 @@ export default function SettingsAdvancedRoute() { setShowLoopbackWarning(false); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]); + const handleVersionUpdateError = useCallback((error?: JsonRpcError) => { + notifications.error( + m.advanced_error_version_update({ + error: error?.data ?? error?.message ?? m.unknown_error() + }), + { duration: 1000 * 15 } // 15 seconds + ); + setVersionUpdateLoading(false); + }, []); + + const handleVersionUpdate = useCallback(async () => { + const components = updateTarget === "both" ? ["app", "system"] : [updateTarget]; + let versionInfo: SystemVersionInfo | undefined; + try { + // we do not need to set it to false if check succeeds, + // because it will be redirected to the update page later + setVersionUpdateLoading(true); + versionInfo = await checkUpdateComponents({ + components: components.join(","), + app: appVersion, + system: systemVersion, + }, devChannel); + console.log("versionInfo", versionInfo); + } catch (error: unknown) { + const jsonRpcError = error as JsonRpcError; + handleVersionUpdateError(jsonRpcError); + return ; + } + + if (!versionInfo) { + handleVersionUpdateError(); + return; + } + + const pageParams = new URLSearchParams(); + pageParams.set("downgrade", "true"); + if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) { + pageParams.set("app", versionInfo.remote?.appVersion); + } + if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemDowngradeAvailable) { + pageParams.set("system", versionInfo.remote?.systemVersion); + } + pageParams.set("resetConfig", resetConfig.toString()); + + // Navigate to update page + navigateTo(`/settings/general/update?${pageParams.toString()}`); + }, [ + updateTarget, appVersion, systemVersion, devChannel, + navigateTo, resetConfig, handleVersionUpdateError, + setVersionUpdateLoading + ]); + return (
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. +

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

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

+ +
+ setResetConfig(e.target.checked)} + /> +
+ +
+ setVersionChangeAcknowledged(e.target.checked)} + /> +
+ +
- - )} + + ) : 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 86e92bcd..55b92c00 100644 --- a/ui/src/routes/devices.$id.settings.general._index.tsx +++ b/ui/src/routes/devices.$id.settings.general._index.tsx @@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() { const { send } = useJsonRpc(); const { navigateTo } = useDeviceUiNavigation(); const [autoUpdate, setAutoUpdate] = useState(true); - const currentVersions = useDeviceStore(state => { const { appVersion, systemVersion } = state; if (!appVersion || !systemVersion) return null; @@ -48,10 +47,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]); @@ -108,7 +107,7 @@ export default function SettingsGeneralRoute() { } /> -
+
+
+
+ ); +} + function UpdateCompletedState({ onClose }: { onClose: () => void }) { return (
diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx index 42776414..3527f269 100644 --- a/ui/src/routes/devices.$id.settings.hardware.tsx +++ b/ui/src/routes/devices.$id.settings.hardware.tsx @@ -8,6 +8,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; +import { NestedSettingsGroup } from "@components/NestedSettingsGroup"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { UsbInfoSetting } from "@components/UsbInfoSetting"; import notifications from "@/notifications"; @@ -156,7 +157,7 @@ export default function SettingsHardwareRoute() { /> {backlightSettings.max_brightness != 0 && ( - <> + - + )}

{m.hardware_display_wake_up_note()} diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx index d6eb32d8..f1e68a0d 100644 --- a/ui/src/routes/devices.$id.settings.video.tsx +++ b/ui/src/routes/devices.$id.settings.video.tsx @@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { SettingsItem } from "@components/SettingsItem"; import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SelectMenuBasic } from "@components/SelectMenuBasic"; +import { NestedSettingsGroup } from "@components/NestedSettingsGroup"; import Fieldset from "@components/Fieldset"; import notifications from "@/notifications"; import { m } from "@localizations/messages.js"; @@ -174,7 +175,7 @@ export default function SettingsVideoRoute() { description={m.video_enhancement_description()} /> -

+
-
+
({ + method: "checkUpdateComponents", + params: { + params, + includePreRelease, + }, + }); + if (response.error) throw response.error; + return response.result; +} \ No newline at end of file diff --git a/webrtc.go b/webrtc.go index 37488f77..d0241bf8 100644 --- a/webrtc.go +++ b/webrtc.go @@ -286,7 +286,7 @@ func newSession(config SessionConfig) (*Session, error) { // Enqueue to ensure ordered processing session.rpcQueue <- msg }) - triggerOTAStateUpdate() + triggerOTAStateUpdate(nil) triggerVideoStateUpdate() triggerUSBStateUpdate() case "terminal":