feat: downgrade

This commit is contained in:
Siyuan 2025-10-31 15:50:41 +00:00
parent ab06e376d0
commit 0a98a73275
15 changed files with 366 additions and 132 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

@ -34,7 +34,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus)
downloadFinished := time.Now() downloadFinished := time.Now()
appUpdate.downloadFinishedAt = downloadFinished appUpdate.downloadFinishedAt = downloadFinished
appUpdate.downloadProgress = 1 appUpdate.downloadProgress = 1
s.onProgressUpdate() s.triggerStateUpdate()
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.triggerStateUpdate()
l.Info().Msg("App update downloaded") l.Info().Msg("App update downloaded")

View File

@ -21,22 +21,45 @@ 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)
}
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,38 +84,49 @@ 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) 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 s.updating = true
s.onProgressUpdate() s.triggerStateUpdate()
defer func() { defer func() {
s.updating = false s.updating = false
s.onProgressUpdate() s.triggerStateUpdate()
}() }()
appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease) appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params)
if err != nil { 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() if params.CheckOnly {
s.onProgressUpdate() return nil
}
if appUpdate.available { s.metadataFetchedAt = time.Now()
s.triggerStateUpdate()
if appUpdate.available || appUpdate.downgradeAvailable {
appUpdate.pending = true appUpdate.pending = true
} }
if systemUpdate.available { if systemUpdate.available || systemUpdate.downgradeAvailable {
systemUpdate.pending = true systemUpdate.pending = true
} }
@ -133,10 +167,18 @@ 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"`
IncludePreRelease bool `json:"includePreRelease"`
CheckOnly bool `json:"checkOnly"`
}
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 +186,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 +204,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 +224,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 +232,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 +252,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
@ -79,6 +84,8 @@ type RPCState struct {
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
@ -109,6 +116,40 @@ type State struct {
client GetHTTPClientFunc client GetHTTPClientFunc
reboot HwRebootFunc reboot HwRebootFunc
getLocalVersion GetLocalVersionFunc getLocalVersion GetLocalVersionFunc
onStateUpdate OnStateUpdateFunc
}
// SetTargetVersion sets the target version for a component
func (s *State) SetTargetVersion(component string, version string) error {
parsedVersion := version
if version != "" {
// validate if it's a valid semver string first
semverVersion, err := semver.NewVersion(version)
if err != nil {
return fmt.Errorf("not a valid semantic version: %w", err)
}
parsedVersion = semverVersion.String()
}
// check if the component exists
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return fmt.Errorf("component %s not found", component)
}
componentUpdate.targetVersion = parsedVersion
s.componentUpdateStatuses[component] = componentUpdate
return nil
}
// GetTargetVersion returns the target version for a component
func (s *State) GetTargetVersion(component string) string {
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return ""
}
return componentUpdate.targetVersion
} }
// ToUpdateStatus converts the State to the UpdateStatus // ToUpdateStatus converts the State to the UpdateStatus
@ -136,9 +177,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,
} }
} }
@ -160,12 +203,17 @@ type Options struct {
// 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,
} }
go s.confirmCurrentSystem() go s.confirmCurrentSystem()
@ -189,6 +237,7 @@ func (s *State) ToRPCState() *RPCState {
r.AppVerifiedAt = app.verifiedAt r.AppVerifiedAt = app.verifiedAt
r.AppUpdateProgress = app.updateProgress r.AppUpdateProgress = app.updateProgress
r.AppUpdatedAt = app.updatedAt r.AppUpdatedAt = app.updatedAt
r.AppTargetVersion = app.targetVersion
} }
system, ok := s.componentUpdateStatuses["system"] system, ok := s.componentUpdateStatuses["system"]
@ -200,10 +249,8 @@ func (s *State) ToRPCState() *RPCState {
r.SystemVerifiedAt = system.verifiedAt r.SystemVerifiedAt = system.verifiedAt
r.SystemUpdateProgress = system.updateProgress r.SystemUpdateProgress = system.updateProgress
r.SystemUpdatedAt = system.updatedAt r.SystemUpdatedAt = system.updatedAt
r.SystemTargetVersion = system.targetVersion
} }
return r return r
} }
func (s *State) onProgressUpdate() {
}

View File

@ -24,7 +24,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
downloadFinished := time.Now() downloadFinished := time.Now()
systemUpdate.downloadFinishedAt = downloadFinished systemUpdate.downloadFinishedAt = downloadFinished
systemUpdate.downloadProgress = 1 systemUpdate.downloadProgress = 1
s.onProgressUpdate() s.triggerStateUpdate()
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.triggerStateUpdate()
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.triggerStateUpdate()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@ -86,7 +86,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
rkLogger.Info().Msg("rk_ota success") rkLogger.Info().Msg("rk_ota success")
systemUpdate.updateProgress = 1 systemUpdate.updateProgress = 1
systemUpdate.updatedAt = verifyFinished systemUpdate.updatedAt = verifyFinished
s.onProgressUpdate() s.triggerStateUpdate()
return nil return nil
} }

View File

@ -82,7 +82,7 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, downl
progress := float32(written) / float32(totalSize) progress := float32(written) / float32(totalSize)
if progress-*downloadProgress >= 0.01 { if progress-*downloadProgress >= 0.01 {
*downloadProgress = progress *downloadProgress = progress
s.onProgressUpdate() s.triggerStateUpdate()
} }
} }
if er != nil { if er != nil {
@ -136,7 +136,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"
) )
@ -237,71 +236,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 {
@ -1219,6 +1153,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"}},
"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
@ -107,7 +108,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")
} }

112
ota.go
View File

@ -1,6 +1,7 @@
package kvm package kvm
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -74,3 +75,114 @@ 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"`
}
func rpcTryUpdate() error {
return rpcTryUpdateComponents(tryUpdateComponents{
AppTargetVersion: "",
SystemTargetVersion: "",
}, config.IncludePreRelease, false)
}
func rpcTryUpdateComponents(components tryUpdateComponents, includePreRelease bool, checkOnly bool) error {
updateParams := ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
CheckOnly: checkOnly,
}
logger.Info().Interface("components", components).Msg("components")
if components.AppTargetVersion != "" {
updateParams.AppTargetVersion = components.AppTargetVersion
if err := otaState.SetTargetVersion("app", components.AppTargetVersion); err != nil {
return fmt.Errorf("failed to set app target version: %w", err)
}
}
if components.SystemTargetVersion != "" {
updateParams.SystemTargetVersion = components.SystemTargetVersion
if err := otaState.SetTargetVersion("system", components.SystemTargetVersion); err != nil {
return fmt.Errorf("failed to set system target version: %w", err)
}
}
go func() {
err := otaState.TryUpdate(context.Background(), updateParams)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcCancelDowngrade() error {
if err := otaState.SetTargetVersion("app", ""); err != nil {
return fmt.Errorf("failed to set app target version: %w", err)
}
if err := otaState.SetTargetVersion("system", ""); err != nil {
return fmt.Errorf("failed to set system target version: %w", err)
}
return nil
}

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",
@ -910,5 +898,21 @@
"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"
} }

View File

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

View File

@ -5,6 +5,22 @@ import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc";
import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc"; import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/jsonrpc";
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 {
appVersion: string;
systemVersion: string;
}
export interface SystemVersionInfo {
local: VersionInfo;
remote?: VersionInfo;
systemUpdateAvailable: boolean;
systemDowngradeAvailable: boolean;
appUpdateAvailable: boolean;
appDowngradeAvailable: boolean;
error?: string;
}
export function useVersion() { export function useVersion() {
const { const {

View File

@ -182,9 +182,15 @@ 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,
};
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() })
@ -194,7 +200,7 @@ export default function SettingsAdvancedRoute() {
// Navigate to update page // Navigate to update page
navigateTo("/settings/general/update"); navigateTo("/settings/general/update");
}); });
}, [updateTarget, appVersion, systemVersion, send, navigateTo]); }, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">

View File

@ -59,16 +59,21 @@ export function Dialog({
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 (hasUpdate) {
setModalView("updateAvailable"); setModalView("updateAvailable");
} else if (hasDowngrade) {
setModalView("updateDowngradeAvailable");
} else { } else {
setModalView("upToDate"); setModalView("upToDate");
} }
@ -76,6 +81,11 @@ export function Dialog({
[setModalView], [setModalView],
); );
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>
@ -98,6 +108,13 @@ export function Dialog({
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
onConfirmUpdate={onConfirmUpdate}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
@ -410,6 +427,46 @@ function UpdateAvailableState({
); );
} }
function UpdateDowngradeAvailableState({
versionInfo,
onConfirmUpdate,
onCancelDowngrade,
}: {
versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void;
onCancelDowngrade: () => void;
}) {
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={onConfirmUpdate} />
<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">