Compare commits

...

6 Commits

Author SHA1 Message Date
Siyuan bce968000c feat: redirect to setup page after config reset 2025-10-31 17:56:20 +00:00
Adam Shiervani 0a299bbe18 feat: enhance version update settings with reset configuration option 2025-10-31 18:51:51 +01:00
Siyuan ae197c530b feat: allow configuration to be reset during update 2025-10-31 17:39:23 +00:00
Siyuan e63c7fce0d fix: update components 2025-10-31 17:28:00 +00:00
Siyuan 9e8ada32b3 cleanup: ota state 2025-10-31 16:15:42 +00:00
Siyuan 2447e8fff2 feat: downgrade 2025-10-31 15:50:41 +00:00
16 changed files with 578 additions and 202 deletions

View File

@ -10,5 +10,5 @@
] ]
}, },
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo" "cmake.sourceDirectory": "/workspaces/kvm-sleep-mode/internal/native/cgo"
} }

View File

@ -27,14 +27,14 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus)
l := s.l.With().Str("path", appUpdatePath).Logger() 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) return s.componentUpdateError("Error downloading app update", err, &l)
} }
downloadFinished := time.Now() downloadFinished := time.Now()
appUpdate.downloadFinishedAt = downloadFinished appUpdate.downloadFinishedAt = downloadFinished
appUpdate.downloadProgress = 1 appUpdate.downloadProgress = 1
s.onProgressUpdate() s.triggerComponentUpdateState("app", appUpdate)
if err := s.verifyFile( if err := s.verifyFile(
appUpdatePath, appUpdatePath,
@ -48,7 +48,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus)
appUpdate.verificationProgress = 1 appUpdate.verificationProgress = 1
appUpdate.updatedAt = verifyFinished appUpdate.updatedAt = verifyFinished
appUpdate.updateProgress = 1 appUpdate.updateProgress = 1
s.onProgressUpdate() s.triggerComponentUpdateState("app", appUpdate)
l.Info().Msg("App update downloaded") l.Info().Msg("App update downloaded")

View File

@ -1,5 +0,0 @@
package ota
import "github.com/jetkvm/kvm/internal/logging"
var logger = logging.GetSubsystemLogger("ota")

View File

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"time" "time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
@ -21,22 +22,49 @@ func (s *State) GetReleaseAPIEndpoint() string {
return s.releaseAPIEndpoint return s.releaseAPIEndpoint
} }
func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) { // getUpdateURL returns the update URL for the given parameters
metadata := &UpdateMetadata{} func (s *State) getUpdateURL(params UpdateParams) (string, error) {
updateURL, err := url.Parse(s.releaseAPIEndpoint) updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil { 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 := updateURL.Query()
query.Set("deviceId", deviceID) query.Set("deviceId", params.DeviceID)
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease)) 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() 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)
}
s.l.Trace().
Str("url", url).
Msg("fetching update metadata")
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating request: %w", err) return nil, fmt.Errorf("error creating request: %w", err)
} }
@ -61,39 +89,68 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includ
return metadata, nil 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) 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(). scopedLogger := s.l.With().
Str("deviceID", deviceID). Interface("params", params).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger() Logger()
scopedLogger.Info().Msg("Trying to update...") scopedLogger.Info().Msg("checking for updates")
if s.updating { if s.updating {
return fmt.Errorf("update already in progress") return fmt.Errorf("update already in progress")
} }
s.updating = true if len(params.Components) == 0 {
s.onProgressUpdate() params.Components = []string{"app", "system"}
}
shouldUpdateApp := slices.Contains(params.Components, "app")
shouldUpdateSystem := slices.Contains(params.Components, "system")
defer func() { if !shouldUpdateApp && !shouldUpdateSystem {
s.updating = false return fmt.Errorf("no components to update")
s.onProgressUpdate() }
}()
appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) 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 { if err != nil {
return s.componentUpdateError("Error checking for updates", err, &scopedLogger) return s.componentUpdateError("Error checking for updates", err, &scopedLogger)
} }
s.metadataFetchedAt = time.Now() s.metadataFetchedAt = time.Now()
s.onProgressUpdate() s.triggerStateUpdate()
if appUpdate.available { if params.CheckOnly {
appUpdate.pending = true return nil
} }
if systemUpdate.available { if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) {
appUpdate.pending = true
s.triggerComponentUpdateState("app", appUpdate)
}
if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) {
systemUpdate.pending = true systemUpdate.pending = true
s.triggerComponentUpdateState("system", systemUpdate)
} }
if appUpdate.pending { if appUpdate.pending {
@ -120,9 +177,19 @@ func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreReleas
if s.rebootNeeded { if s.rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update") 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{ postRebootAction := &PostRebootAction{
HealthCheck: "/device/status", HealthCheck: "/device/status",
RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version), RedirectUrl: redirectUrl,
} }
if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil { if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil {
@ -133,10 +200,20 @@ func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreReleas
return nil 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( func (s *State) getUpdateStatus(
ctx context.Context, ctx context.Context,
deviceID string, params UpdateParams,
includePreRelease bool,
) ( ) (
appUpdate *componentUpdateStatus, appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus,
@ -144,7 +221,14 @@ func (s *State) getUpdateStatus(
) { ) {
appUpdate = &componentUpdateStatus{} appUpdate = &componentUpdateStatus{}
systemUpdate = &componentUpdateStatus{} systemUpdate = &componentUpdateStatus{}
err = nil
if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok {
appUpdate = &currentAppUpdate
}
if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
systemUpdate = &currentSystemUpdate
}
// Get local versions // Get local versions
systemVersionLocal, appVersionLocal, err := s.getLocalVersion() systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
@ -155,7 +239,7 @@ func (s *State) getUpdateStatus(
systemUpdate.localVersion = systemVersionLocal.String() systemUpdate.localVersion = systemVersionLocal.String()
// Get remote metadata // Get remote metadata
remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease) remoteMetadata, err := s.fetchUpdateMetadata(ctx, params)
if err != nil { if err != nil {
err = fmt.Errorf("error checking for updates: %w", err) err = fmt.Errorf("error checking for updates: %w", err)
return return
@ -175,6 +259,7 @@ func (s *State) getUpdateStatus(
return return
} }
systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal) systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal)
systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal)
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion) appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil { if err != nil {
@ -182,15 +267,16 @@ func (s *State) getUpdateStatus(
return return
} }
appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal) appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal)
appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal)
// Handle pre-release updates // Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != "" isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != "" isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease { if isRemoteSystemPreRelease && !params.IncludePreRelease {
systemUpdate.available = false systemUpdate.available = false
} }
if isRemoteAppPreRelease && !includePreRelease { if isRemoteAppPreRelease && !params.IncludePreRelease {
appUpdate.available = false appUpdate.available = false
} }
@ -201,8 +287,8 @@ func (s *State) getUpdateStatus(
} }
// GetUpdateStatus returns the current update status (for backwards compatibility) // GetUpdateStatus returns the current update status (for backwards compatibility)
func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) { func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
_, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) _, _, err := s.getUpdateStatus(ctx, params)
if err != nil { if err != nil {
return nil, fmt.Errorf("error getting update status: %w", err) return nil, fmt.Errorf("error getting update status: %w", err)
} }

View File

@ -1,6 +1,7 @@
package ota package ota
import ( import (
"fmt"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@ -27,10 +28,12 @@ type LocalMetadata struct {
// UpdateStatus represents the current update status // UpdateStatus represents the current update status
type UpdateStatus struct { type UpdateStatus struct {
Local *LocalMetadata `json:"local"` Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"` Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"` SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"` SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppDowngradeAvailable bool `json:"appDowngradeAvailable"`
// for backwards compatibility // for backwards compatibility
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@ -47,8 +50,10 @@ type PostRebootAction struct {
type componentUpdateStatus struct { type componentUpdateStatus struct {
pending bool pending bool
available bool available bool
downgradeAvailable bool
version string version string
localVersion string localVersion string
targetVersion string
url string url string
hash string hash string
downloadProgress float32 downloadProgress float32
@ -57,33 +62,38 @@ type componentUpdateStatus struct {
verifiedAt time.Time verifiedAt time.Time
updateProgress float32 updateProgress float32
updatedAt time.Time updatedAt time.Time
dependsOn []string dependsOn []string //nolint:unused
} }
// RPCState represents the current OTA state for the RPC API // RPCState represents the current OTA state for the RPC API
type RPCState struct { type RPCState struct {
Updating bool `json:"updating"` Updating bool `json:"updating"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
MetadataFetchedAt time.Time `json:"metadataFetchedAt,omitempty"` MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"` AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"` SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadFinishedAt time.Time `json:"appDownloadFinishedAt,omitempty"` AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadFinishedAt time.Time `json:"systemDownloadFinishedAt,omitempty"` SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"` AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt time.Time `json:"appVerifiedAt,omitempty"` AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"` SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt time.Time `json:"systemVerifiedAt,omitempty"` SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppUpdatedAt time.Time `json:"appUpdatedAt,omitempty"` AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt time.Time `json:"systemUpdatedAt,omitempty"` SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
SystemTargetVersion *string `json:"systemTargetVersion,omitempty"`
AppTargetVersion *string `json:"appTargetVersion,omitempty"`
} }
// HwRebootFunc is a function that reboots the hardware // HwRebootFunc is a function that reboots the hardware
type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error 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 // GetHTTPClientFunc is a function that returns the HTTP client
type GetHTTPClientFunc func() *http.Client type GetHTTPClientFunc func() *http.Client
@ -109,6 +119,41 @@ type State struct {
client GetHTTPClientFunc client GetHTTPClientFunc
reboot HwRebootFunc reboot HwRebootFunc
getLocalVersion GetLocalVersionFunc 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
} }
// ToUpdateStatus converts the State to the UpdateStatus // ToUpdateStatus converts the State to the UpdateStatus
@ -136,9 +181,11 @@ func (s *State) ToUpdateStatus() *UpdateStatus {
SystemURL: systemUpdate.url, SystemURL: systemUpdate.url,
SystemHash: systemUpdate.hash, SystemHash: systemUpdate.hash,
}, },
SystemUpdateAvailable: systemUpdate.available, SystemUpdateAvailable: systemUpdate.available,
AppUpdateAvailable: appUpdate.available, SystemDowngradeAvailable: systemUpdate.downgradeAvailable,
Error: s.error, AppUpdateAvailable: appUpdate.available,
AppDowngradeAvailable: appUpdate.downgradeAvailable,
Error: s.error,
} }
} }
@ -156,54 +203,73 @@ type Options struct {
OnProgressUpdate OnProgressUpdateFunc OnProgressUpdate OnProgressUpdateFunc
HwReboot HwRebootFunc HwReboot HwRebootFunc
ReleaseAPIEndpoint string ReleaseAPIEndpoint string
ResetConfig ResetConfigFunc
} }
// NewState creates a new OTA state // NewState creates a new OTA state
func NewState(opts Options) *State { func NewState(opts Options) *State {
components := make(map[string]componentUpdateStatus)
components["app"] = componentUpdateStatus{}
components["system"] = componentUpdateStatus{}
s := &State{ s := &State{
l: opts.Logger, l: opts.Logger,
client: opts.GetHTTPClient, client: opts.GetHTTPClient,
reboot: opts.HwReboot, reboot: opts.HwReboot,
onStateUpdate: opts.OnStateUpdate,
getLocalVersion: opts.GetLocalVersion, getLocalVersion: opts.GetLocalVersion,
componentUpdateStatuses: make(map[string]componentUpdateStatus), componentUpdateStatuses: components,
releaseAPIEndpoint: opts.ReleaseAPIEndpoint, releaseAPIEndpoint: opts.ReleaseAPIEndpoint,
resetConfig: opts.ResetConfig,
} }
go s.confirmCurrentSystem() go s.confirmCurrentSystem()
return s return s
} }
// ToRPCState converts the State to the RPCState // ToRPCState converts the State to the RPCState
// probably we need a generator for this ...
func (s *State) ToRPCState() *RPCState { func (s *State) ToRPCState() *RPCState {
r := &RPCState{ r := &RPCState{
Updating: s.updating, Updating: s.updating,
Error: s.error, Error: s.error,
MetadataFetchedAt: s.metadataFetchedAt, MetadataFetchedAt: &s.metadataFetchedAt,
} }
app, ok := s.componentUpdateStatuses["app"] app, ok := s.componentUpdateStatuses["app"]
if ok { if ok {
r.AppUpdatePending = app.pending r.AppUpdatePending = app.pending
r.AppDownloadProgress = app.downloadProgress r.AppDownloadProgress = &app.downloadProgress
r.AppDownloadFinishedAt = app.downloadFinishedAt if !app.downloadFinishedAt.IsZero() {
r.AppVerificationProgress = app.verificationProgress r.AppDownloadFinishedAt = &app.downloadFinishedAt
r.AppVerifiedAt = app.verifiedAt }
r.AppUpdateProgress = app.updateProgress r.AppVerificationProgress = &app.verificationProgress
r.AppUpdatedAt = app.updatedAt 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"] system, ok := s.componentUpdateStatuses["system"]
if ok { if ok {
r.SystemUpdatePending = system.pending r.SystemUpdatePending = system.pending
r.SystemDownloadProgress = system.downloadProgress r.SystemDownloadProgress = &system.downloadProgress
r.SystemDownloadFinishedAt = system.downloadFinishedAt if !system.downloadFinishedAt.IsZero() {
r.SystemVerificationProgress = system.verificationProgress r.SystemDownloadFinishedAt = &system.downloadFinishedAt
r.SystemVerifiedAt = system.verifiedAt }
r.SystemUpdateProgress = system.updateProgress r.SystemVerificationProgress = &system.verificationProgress
r.SystemUpdatedAt = system.updatedAt 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 return r
} }
func (s *State) onProgressUpdate() {
}

View File

@ -17,14 +17,14 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
l := s.l.With().Str("path", systemUpdatePath).Logger() 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) return s.componentUpdateError("Error downloading system update", err, &l)
} }
downloadFinished := time.Now() downloadFinished := time.Now()
systemUpdate.downloadFinishedAt = downloadFinished systemUpdate.downloadFinishedAt = downloadFinished
systemUpdate.downloadProgress = 1 systemUpdate.downloadProgress = 1
s.onProgressUpdate() s.triggerComponentUpdateState("system", systemUpdate)
if err := s.verifyFile( if err := s.verifyFile(
systemUpdatePath, systemUpdatePath,
@ -38,7 +38,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
systemUpdate.verificationProgress = 1 systemUpdate.verificationProgress = 1
systemUpdate.updatedAt = verifyFinished systemUpdate.updatedAt = verifyFinished
systemUpdate.updateProgress = 1 systemUpdate.updateProgress = 1
s.onProgressUpdate() s.triggerComponentUpdateState("system", systemUpdate)
l.Info().Msg("System update downloaded") l.Info().Msg("System update downloaded")
@ -68,7 +68,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
if systemUpdate.updateProgress > 0.99 { if systemUpdate.updateProgress > 0.99 {
systemUpdate.updateProgress = 0.99 systemUpdate.updateProgress = 0.99
} }
s.onProgressUpdate() s.triggerComponentUpdateState("system", systemUpdate)
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@ -84,9 +84,11 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger) return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger)
} }
rkLogger.Info().Msg("rk_ota success") rkLogger.Info().Msg("rk_ota success")
s.rebootNeeded = true
systemUpdate.updateProgress = 1 systemUpdate.updateProgress = 1
systemUpdate.updatedAt = verifyFinished systemUpdate.updatedAt = verifyFinished
s.onProgressUpdate() s.triggerComponentUpdateState("system", systemUpdate)
return nil return nil
} }

View File

@ -25,7 +25,14 @@ func syncFilesystem() error {
return nil 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.Stat(path); err == nil {
if err := os.Remove(path); err != nil { if err := os.Remove(path); err != nil {
return fmt.Errorf("error removing existing file: %w", err) 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) return fmt.Errorf("error writing to file: %w", ew)
} }
progress := float32(written) / float32(totalSize) progress := float32(written) / float32(totalSize)
if progress-*downloadProgress >= 0.01 { if progress-downloadProgress >= 0.01 {
*downloadProgress = progress componentUpdate.downloadProgress = progress
s.onProgressUpdate() s.triggerComponentUpdateState(component, &componentUpdate)
} }
} }
if er != nil { if er != nil {
@ -136,7 +143,7 @@ func (s *State) verifyFile(path string, expectedHash string, verifyProgress *flo
progress := float32(verified) / float32(totalSize) progress := float32(verified) / float32(totalSize)
if progress-*verifyProgress >= 0.01 { if progress-*verifyProgress >= 0.01 {
*verifyProgress = progress *verifyProgress = progress
s.onProgressUpdate() s.triggerStateUpdate()
} }
} }
if er != nil { if er != nil {

View File

@ -19,7 +19,6 @@ import (
"go.bug.st/serial" "go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc" "github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/ota"
"github.com/jetkvm/kvm/internal/usbgadget" "github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils" "github.com/jetkvm/kvm/internal/utils"
) )
@ -236,71 +235,6 @@ func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus() 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 { func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation currentRotation := config.DisplayRotation
if currentRotation == params.Rotation { if currentRotation == params.Rotation {
@ -1218,6 +1152,8 @@ var rpcHandlers = map[string]RPCHandler{
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel}, "getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly", "resetConfig"}},
"cancelDowngrade": {Func: rpcCancelDowngrade},
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},

View File

@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/gwatts/rootcerts" "github.com/gwatts/rootcerts"
"github.com/jetkvm/kvm/internal/ota"
) )
var appCtx context.Context var appCtx context.Context
@ -100,7 +101,10 @@ func Main() {
} }
includePreRelease := config.IncludePreRelease includePreRelease := config.IncludePreRelease
err = otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease) err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
})
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("failed to auto update") logger.Warn().Err(err).Msg("failed to auto update")
} }

116
ota.go
View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -29,6 +30,7 @@ func initOta() {
}, },
GetLocalVersion: GetLocalVersion, GetLocalVersion: GetLocalVersion,
HwReboot: hwReboot, HwReboot: hwReboot,
ResetConfig: rpcResetConfig,
OnStateUpdate: func(state *ota.RPCState) { OnStateUpdate: func(state *ota.RPCState) {
triggerOTAStateUpdate(state) triggerOTAStateUpdate(state)
}, },
@ -74,3 +76,117 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
return systemVersion, appVersion, nil 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"`
Components string `json:"components,omitempty"` // components is a comma-separated list of components to update
}
func rpcTryUpdate() error {
return rpcTryUpdateComponents(tryUpdateComponents{
AppTargetVersion: "",
SystemTargetVersion: "",
}, config.IncludePreRelease, false, false)
}
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")
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.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() {
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
}

View File

@ -26,6 +26,31 @@ show_help() {
echo " $0 -r 192.168.0.17 -u admin" echo " $0 -r 192.168.0.17 -u admin"
} }
# Function to check if device is pingable
check_ping() {
local host=$1
msg_info "▶ Checking if device is reachable at ${host}..."
if ! ping -c 3 -W 5 "${host}" > /dev/null 2>&1; then
msg_err "Error: Cannot reach device at ${host}"
msg_err "Please verify the IP address and network connectivity"
exit 1
fi
msg_info "✓ Device is reachable"
}
# Function to check if SSH is accessible
check_ssh() {
local user=$1
local host=$2
msg_info "▶ Checking SSH connectivity to ${user}@${host}..."
if ! ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=10 "${user}@${host}" "echo 'SSH connection successful'" > /dev/null 2>&1; then
msg_err "Error: Cannot establish SSH connection to ${user}@${host}"
msg_err "Please verify SSH access and credentials"
exit 1
fi
msg_info "✓ SSH connection successful"
}
# Default values # Default values
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))") SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
REMOTE_USER="root" REMOTE_USER="root"
@ -113,6 +138,10 @@ if [ -z "$REMOTE_HOST" ]; then
exit 1 exit 1
fi fi
# Check device connectivity before proceeding
check_ping "${REMOTE_HOST}"
check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
# check if the current CPU architecture is x86_64 # check if the current CPU architecture is x86_64
if [ "$(uname -m)" != "x86_64" ]; then if [ "$(uname -m)" != "x86_64" ]; then
msg_warn "Warning: This script is only supported on x86_64 architecture" msg_warn "Warning: This script is only supported on x86_64 architecture"
@ -147,10 +176,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
make build_dev_test make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host" msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests" msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF' ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e set -e
TMP_DIR=$(mktemp -d) TMP_DIR=$(mktemp -d)
cd ${TMP_DIR} cd ${TMP_DIR}
@ -193,10 +222,10 @@ then
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE} ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater. # Copy the binary to the remote host as if we were the OTA updater.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process. # Reboot the device, the new app will be deployed by the startup process.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else else
msg_info "▶ Building development binary" msg_info "▶ Building development binary"
do_make build_dev \ do_make build_dev \
@ -205,21 +234,21 @@ else
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE} ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application # Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device" msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed" msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration # Remove the old USB gadget configuration
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi fi
# Deploy and run the application on the remote host # Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
set -e set -e
# Set the library path to include the directory where librockit.so is located # Set the library path to include the directory where librockit.so is located
@ -229,6 +258,17 @@ export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
killall jetkvm_app || true killall jetkvm_app || true
killall jetkvm_app_debug || true killall jetkvm_app_debug || true
# Wait until both binaries are killed, max 10 seconds
i=1
while [ \$i -le 10 ]; do
echo "Waiting for jetkvm_app and jetkvm_app_debug to be killed, \$i/10 ..."
if ! pgrep -f "jetkvm_app" > /dev/null && ! pgrep -f "jetkvm_app_debug" > /dev/null; then
break
fi
sleep 1
i=\$((i + 1))
done
# Navigate to the directory where the binary will be stored # Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}" cd "${REMOTE_PATH}"

View File

@ -100,18 +100,6 @@
"advanced_update_ssh_key_button": "Update SSH Key", "advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation", "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_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_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard", "already_adopted_return_to_dashboard": "Return to Dashboard",
@ -904,5 +892,23 @@
"wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_invalid_mac": "Invalid MAC address",
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
"welcome_to_jetkvm": "Welcome to JetKVM", "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",
"advanced_version_update_reset_config_description": "Reset configuration after the update",
"advanced_version_update_reset_config_label": "Reset configuration"
} }

View File

@ -536,6 +536,7 @@ export type UpdateModalViews =
| "updating" | "updating"
| "upToDate" | "upToDate"
| "updateAvailable" | "updateAvailable"
| "updateDowngradeAvailable"
| "updateCompleted" | "updateCompleted"
| "error"; | "error";

View File

@ -1,10 +1,10 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import semver from "semver";
import { useDeviceStore } from "@/hooks/stores"; import { useDeviceStore } from "@/hooks/stores";
import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc"; import { type JsonRpcResponse, RpcMethodNotFound, useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import semver from "semver";
export interface VersionInfo { export interface VersionInfo {
appVersion: string; appVersion: string;
@ -15,7 +15,9 @@ export interface SystemVersionInfo {
local: VersionInfo; local: VersionInfo;
remote?: VersionInfo; remote?: VersionInfo;
systemUpdateAvailable: boolean; systemUpdateAvailable: boolean;
systemDowngradeAvailable: boolean;
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
appDowngradeAvailable: boolean;
error?: string; error?: string;
} }

View File

@ -4,7 +4,7 @@ import { useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation"; import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Checkbox from "@components/Checkbox"; import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog"; import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
@ -30,6 +30,7 @@ export default function SettingsAdvancedRoute() {
const [updateTarget, setUpdateTarget] = useState<string>("app"); const [updateTarget, setUpdateTarget] = useState<string>("app");
const [appVersion, setAppVersion] = useState<string>(""); const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>(""); const [systemVersion, setSystemVersion] = useState<string>("");
const [resetConfig, setResetConfig] = useState(false);
const settings = useSettingsStore(); const settings = useSettingsStore();
@ -181,19 +182,33 @@ export default function SettingsAdvancedRoute() {
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdate = useCallback(() => { const handleVersionUpdate = useCallback(() => {
// TODO: Add version params to tryUpdate const params = {
console.log("tryUpdate", updateTarget, appVersion, systemVersion); components: {
send("tryUpdate", {}, (resp: JsonRpcResponse) => { app: appVersion,
system: systemVersion,
},
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) { if ("error" in resp) {
notifications.error( notifications.error(
m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() }) m.advanced_error_version_update({ error: resp.error.data || m.unknown_error() })
); );
return; return;
} }
const pageParams = new URLSearchParams();
pageParams.set("downgrade", "true");
pageParams.set("resetConfig", resetConfig.toString());
pageParams.set("components", updateTarget == "both" ? "app,system" : updateTarget);
// Navigate to update page // Navigate to update page
navigateTo("/settings/general/update"); navigateTo(`/settings/general/update?${pageParams.toString()}`);
}); });
}, [updateTarget, appVersion, systemVersion, send, navigateTo]); }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -332,6 +347,15 @@ export default function SettingsAdvancedRoute() {
</a> </a>
</p> </p>
<div>
<CheckboxWithLabel
label={m.advanced_version_update_reset_config_label()}
description={m.advanced_version_update_reset_config_description()}
checked={resetConfig}
onChange={e => setResetConfig(e.target.checked)}
/>
</div>
<Button <Button
size="SM" size="SM"
theme="primary" theme="primary"

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores"; import { UpdateState, useUpdateStore } from "@hooks/stores";
@ -14,16 +14,35 @@ import { m } from "@localizations/messages.js";
export default function SettingsGeneralUpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
//@ts-ignore
const [searchParams, setSearchParams] = useSearchParams();
const { updateSuccess } = location.state || {}; const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore(); const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const downgrade = useMemo(() => searchParams.get("downgrade") === "true", [searchParams]);
const updateComponents = useMemo(() => searchParams.get("components") || "", [searchParams]);
const resetConfig = useMemo(() => searchParams.get("resetConfig") === "true", [searchParams]);
const onConfirmUpdate = useCallback(() => { const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {}); send("tryUpdate", {});
setModalView("updating"); setModalView("updating");
}, [send, setModalView]); }, [send, setModalView]);
const onConfirmDowngrade = useCallback((system?: string, app?: string) => {
send("tryUpdateComponents", {
components: {
system, app,
components: updateComponents
},
includePreRelease: true,
checkOnly: false,
resetConfig: resetConfig,
});
setModalView("updating");
}, [send, setModalView, updateComponents, resetConfig]);
useEffect(() => { useEffect(() => {
if (otaState.updating) { if (otaState.updating) {
setModalView("updating"); setModalView("updating");
@ -36,37 +55,56 @@ export default function SettingsGeneralUpdateRoute() {
} }
}, [otaState.updating, otaState.error, setModalView, updateSuccess]); }, [otaState.updating, otaState.error, setModalView, updateSuccess]);
return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />; return <Dialog
onClose={() => navigate("..")}
onConfirmUpdate={onConfirmUpdate}
onConfirmDowngrade={onConfirmDowngrade}
downgrade={downgrade}
/>;
} }
export function Dialog({ export function Dialog({
onClose, onClose,
onConfirmUpdate, onConfirmUpdate,
onConfirmDowngrade,
downgrade,
}: Readonly<{ }: Readonly<{
downgrade: boolean;
onClose: () => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
onConfirmDowngrade: () => void;
}>) { }>) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const onFinishedLoading = useCallback( const onFinishedLoading = useCallback(
(versionInfo: SystemVersionInfo) => { (versionInfo: SystemVersionInfo) => {
const hasUpdate = const hasUpdate =
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
const hasDowngrade =
versionInfo?.systemDowngradeAvailable || versionInfo?.appDowngradeAvailable;
setVersionInfo(versionInfo); setVersionInfo(versionInfo);
if (hasUpdate) { if (hasDowngrade && downgrade) {
setModalView("updateDowngradeAvailable");
} else if (hasUpdate) {
setModalView("updateAvailable"); setModalView("updateAvailable");
} else { } else {
setModalView("upToDate"); setModalView("upToDate");
} }
}, },
[setModalView], [setModalView, downgrade],
); );
const onCancelDowngrade = useCallback(() => {
send("cancelDowngrade", {});
onClose();
}, [onClose, send]);
return ( return (
<div className="pointer-events-auto relative mx-auto text-left"> <div className="pointer-events-auto relative mx-auto text-left">
<div> <div>
@ -89,6 +127,13 @@ export function Dialog({
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
@ -400,6 +445,52 @@ function UpdateAvailableState({
); );
} }
function UpdateDowngradeAvailableState({
versionInfo,
onConfirmDowngrade,
onCancelDowngrade,
}: {
versionInfo: SystemVersionInfo;
onConfirmDowngrade: (system?: string, app?: string) => void;
onCancelDowngrade: () => void;
}) {
const confirmDowngrade = useCallback(() => {
onConfirmDowngrade(
versionInfo?.remote?.systemVersion || undefined,
versionInfo?.remote?.appVersion || undefined,
);
}, [versionInfo, onConfirmDowngrade]);
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
<p className="text-base font-semibold text-black dark:text-white">
{m.general_update_downgrade_available_title()}
</p>
<p className="mb-2 text-sm text-slate-600 dark:text-slate-300">
{m.general_update_downgrade_available_description()}
</p>
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemDowngradeAvailable ? (
<>
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.remote?.systemVersion}
<br />
</>
) : null}
{versionInfo?.appDowngradeAvailable ? (
<>
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.remote?.appVersion}
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text={m.general_update_downgrade_button()} onClick={confirmDowngrade} />
<Button size="SM" theme="light" text={m.general_update_keep_current_button()} onClick={onCancelDowngrade} />
</div>
</div>
</div>
);
}
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">