Compare commits

..

No commits in common. "bce968000c0b74015c56ec162d712e22e27dcb39" and "a69f7c9c504a286acaba43d03be190aee064112d" have entirely different histories.

16 changed files with 201 additions and 577 deletions

View File

@ -10,5 +10,5 @@
] ]
}, },
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-sleep-mode/internal/native/cgo" "cmake.sourceDirectory": "/workspaces/kvm-static-ip/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, "app"); err != nil { if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, &appUpdate.downloadProgress); 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.triggerComponentUpdateState("app", appUpdate) s.onProgressUpdate()
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.triggerComponentUpdateState("app", appUpdate) s.onProgressUpdate()
l.Info().Msg("App update downloaded") l.Info().Msg("App update downloaded")

5
internal/ota/logger.go Normal file
View File

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

View File

@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"slices"
"time" "time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
@ -22,49 +21,22 @@ func (s *State) GetReleaseAPIEndpoint() string {
return s.releaseAPIEndpoint return s.releaseAPIEndpoint
} }
// getUpdateURL returns the update URL for the given parameters func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) {
func (s *State) getUpdateURL(params UpdateParams) (string, error) { metadata := &UpdateMetadata{}
updateURL, err := url.Parse(s.releaseAPIEndpoint) updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil { if err != nil {
return "", fmt.Errorf("error parsing update metadata URL: %w", err) return nil, 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", params.DeviceID) query.Set("deviceId", deviceID)
query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease)) query.Set("prerelease", fmt.Sprintf("%v", 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()
return updateURL.String(), nil logger.Info().Str("url", updateURL.String()).Msg("Checking for updates")
}
func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) { req, err := http.NewRequestWithContext(ctx, "GET", updateURL.String(), nil)
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)
} }
@ -89,68 +61,39 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*
return metadata, nil return metadata, nil
} }
func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error { func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreRelease bool) 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().
Interface("params", params). Str("deviceID", deviceID).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Logger() Logger()
scopedLogger.Info().Msg("checking for updates") scopedLogger.Info().Msg("Trying to update...")
if s.updating { if s.updating {
return fmt.Errorf("update already in progress") return fmt.Errorf("update already in progress")
} }
if len(params.Components) == 0 { s.updating = true
params.Components = []string{"app", "system"} s.onProgressUpdate()
}
shouldUpdateApp := slices.Contains(params.Components, "app")
shouldUpdateSystem := slices.Contains(params.Components, "system")
if !shouldUpdateApp && !shouldUpdateSystem { defer func() {
return fmt.Errorf("no components to update") s.updating = false
} s.onProgressUpdate()
}()
if !params.CheckOnly { appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, deviceID, includePreRelease)
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.triggerStateUpdate() s.onProgressUpdate()
if params.CheckOnly { if appUpdate.available {
return nil
}
if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) {
appUpdate.pending = true appUpdate.pending = true
s.triggerComponentUpdateState("app", appUpdate)
} }
if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) { if systemUpdate.available {
systemUpdate.pending = true systemUpdate.pending = true
s.triggerComponentUpdateState("system", systemUpdate)
} }
if appUpdate.pending { if appUpdate.pending {
@ -177,19 +120,9 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error {
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: redirectUrl, RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version),
} }
if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil { if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil {
@ -200,20 +133,10 @@ func (s *State) doUpdate(ctx context.Context, params UpdateParams) error {
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,
params UpdateParams, deviceID string,
includePreRelease bool,
) ( ) (
appUpdate *componentUpdateStatus, appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus,
@ -221,14 +144,7 @@ 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()
@ -239,7 +155,7 @@ func (s *State) getUpdateStatus(
systemUpdate.localVersion = systemVersionLocal.String() systemUpdate.localVersion = systemVersionLocal.String()
// Get remote metadata // Get remote metadata
remoteMetadata, err := s.fetchUpdateMetadata(ctx, params) remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease)
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
@ -259,7 +175,6 @@ 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 {
@ -267,16 +182,15 @@ 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 && !params.IncludePreRelease { if isRemoteSystemPreRelease && !includePreRelease {
systemUpdate.available = false systemUpdate.available = false
} }
if isRemoteAppPreRelease && !params.IncludePreRelease { if isRemoteAppPreRelease && !includePreRelease {
appUpdate.available = false appUpdate.available = false
} }
@ -287,8 +201,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, params UpdateParams) (*UpdateStatus, error) { func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) {
_, _, err := s.getUpdateStatus(ctx, params) _, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease)
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,7 +1,6 @@
package ota package ota
import ( import (
"fmt"
"net/http" "net/http"
"sync" "sync"
"time" "time"
@ -28,12 +27,10 @@ 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"`
SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"` AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppDowngradeAvailable bool `json:"appDowngradeAvailable"`
// for backwards compatibility // for backwards compatibility
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
@ -50,10 +47,8 @@ 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
@ -62,38 +57,33 @@ type componentUpdateStatus struct {
verifiedAt time.Time verifiedAt time.Time
updateProgress float32 updateProgress float32
updatedAt time.Time updatedAt time.Time
dependsOn []string //nolint:unused dependsOn []string
} }
// 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
@ -119,41 +109,6 @@ 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
@ -181,11 +136,9 @@ func (s *State) ToUpdateStatus() *UpdateStatus {
SystemURL: systemUpdate.url, SystemURL: systemUpdate.url,
SystemHash: systemUpdate.hash, SystemHash: systemUpdate.hash,
}, },
SystemUpdateAvailable: systemUpdate.available, SystemUpdateAvailable: systemUpdate.available,
SystemDowngradeAvailable: systemUpdate.downgradeAvailable, AppUpdateAvailable: appUpdate.available,
AppUpdateAvailable: appUpdate.available, Error: s.error,
AppDowngradeAvailable: appUpdate.downgradeAvailable,
Error: s.error,
} }
} }
@ -203,73 +156,54 @@ 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: components, componentUpdateStatuses: make(map[string]componentUpdateStatus),
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
if !app.downloadFinishedAt.IsZero() { r.AppDownloadFinishedAt = app.downloadFinishedAt
r.AppDownloadFinishedAt = &app.downloadFinishedAt r.AppVerificationProgress = app.verificationProgress
} r.AppVerifiedAt = app.verifiedAt
r.AppVerificationProgress = &app.verificationProgress r.AppUpdateProgress = app.updateProgress
if !app.verifiedAt.IsZero() { r.AppUpdatedAt = app.updatedAt
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
if !system.downloadFinishedAt.IsZero() { r.SystemDownloadFinishedAt = system.downloadFinishedAt
r.SystemDownloadFinishedAt = &system.downloadFinishedAt r.SystemVerificationProgress = system.verificationProgress
} r.SystemVerifiedAt = system.verifiedAt
r.SystemVerificationProgress = &system.verificationProgress r.SystemUpdateProgress = system.updateProgress
if !system.verifiedAt.IsZero() { r.SystemUpdatedAt = system.updatedAt
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, "system"); err != nil { if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, &systemUpdate.downloadProgress); 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.triggerComponentUpdateState("system", systemUpdate) s.onProgressUpdate()
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.triggerComponentUpdateState("system", systemUpdate) s.onProgressUpdate()
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.triggerComponentUpdateState("system", systemUpdate) s.onProgressUpdate()
case <-ctx.Done(): case <-ctx.Done():
return return
} }
@ -84,11 +84,9 @@ 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.triggerComponentUpdateState("system", systemUpdate) s.onProgressUpdate()
return nil return nil
} }

View File

@ -25,14 +25,7 @@ func syncFilesystem() error {
return nil return nil
} }
func (s *State) downloadFile(ctx context.Context, path string, url string, component string) error { func (s *State) downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) 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)
@ -87,9 +80,9 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, compo
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 {
componentUpdate.downloadProgress = progress *downloadProgress = progress
s.triggerComponentUpdateState(component, &componentUpdate) s.onProgressUpdate()
} }
} }
if er != nil { if er != nil {
@ -143,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.triggerStateUpdate() s.onProgressUpdate()
} }
} }
if er != nil { if er != nil {

View File

@ -19,6 +19,7 @@ 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"
) )
@ -235,6 +236,71 @@ 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 {
@ -1152,8 +1218,6 @@ 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,7 +9,6 @@ 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
@ -101,10 +100,7 @@ func Main() {
} }
includePreRelease := config.IncludePreRelease includePreRelease := config.IncludePreRelease
err = otaState.TryUpdate(context.Background(), ota.UpdateParams{ err = otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
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,7 +1,6 @@
package kvm package kvm
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@ -30,7 +29,6 @@ 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)
}, },
@ -76,117 +74,3 @@ 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,31 +26,6 @@ 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"
@ -138,10 +113,6 @@ 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"
@ -160,7 +131,7 @@ if [[ "$SKIP_UI_BUILD" = true && ! -f "static/index.html" ]]; then
SKIP_UI_BUILD=false SKIP_UI_BUILD=false
fi fi
if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then if [[ "$SKIP_UI_BUILD" = false && "$JETKVM_INSIDE_DOCKER" != 1 ]]; then
msg_info "▶ Building frontend" msg_info "▶ Building frontend"
make frontend SKIP_UI_BUILD=0 make frontend SKIP_UI_BUILD=0
SKIP_UI_BUILD_RELEASE=1 SKIP_UI_BUILD_RELEASE=1
@ -173,13 +144,13 @@ fi
if [ "$RUN_GO_TESTS" = true ]; then if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests" msg_info "▶ Building go tests"
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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz ssh "${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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF' ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e set -e
TMP_DIR=$(mktemp -d) TMP_DIR=$(mktemp -d)
cd ${TMP_DIR} cd ${TMP_DIR}
@ -220,35 +191,35 @@ then
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app ssh "${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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot" ssh "${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 \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \ SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \ SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true" ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host # Copy the binary to the remote host
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app ssh "${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 -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}" "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}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC" ssh "${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 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF ssh "${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
@ -258,17 +229,6 @@ 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}"
@ -280,4 +240,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d
EOF EOF
fi fi
echo "Deployment complete." echo "Deployment complete."

View File

@ -100,6 +100,18 @@
"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",
@ -892,23 +904,5 @@
"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,7 +536,6 @@ 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,9 +15,7 @@ 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, { CheckboxWithLabel } from "@components/Checkbox"; import Checkbox 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,7 +30,6 @@ 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();
@ -182,33 +181,19 @@ export default function SettingsAdvancedRoute() {
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdate = useCallback(() => { const handleVersionUpdate = useCallback(() => {
const params = { // TODO: Add version params to tryUpdate
components: { console.log("tryUpdate", updateTarget, appVersion, systemVersion);
app: appVersion, send("tryUpdate", {}, (resp: JsonRpcResponse) => {
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?${pageParams.toString()}`); navigateTo("/settings/general/update");
}); });
}, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]); }, [updateTarget, appVersion, systemVersion, send, navigateTo]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -347,15 +332,6 @@ 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, useSearchParams } from "react-router"; import { useLocation, useNavigate } 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,35 +14,16 @@ 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");
@ -55,56 +36,37 @@ export default function SettingsGeneralUpdateRoute() {
} }
}, [otaState.updating, otaState.error, setModalView, updateSuccess]); }, [otaState.updating, otaState.error, setModalView, updateSuccess]);
return <Dialog return <Dialog onClose={() => navigate("..")} onConfirmUpdate={onConfirmUpdate} />;
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 (hasDowngrade && downgrade) { if (hasUpdate) {
setModalView("updateDowngradeAvailable");
} else if (hasUpdate) {
setModalView("updateAvailable"); setModalView("updateAvailable");
} else { } else {
setModalView("upToDate"); setModalView("upToDate");
} }
}, },
[setModalView, downgrade], [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>
@ -127,13 +89,6 @@ export function Dialog({
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
@ -445,52 +400,6 @@ 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">