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,
"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()
if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, &appUpdate.downloadProgress); err != nil {
if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, "app"); err != nil {
return s.componentUpdateError("Error downloading app update", err, &l)
}
downloadFinished := time.Now()
appUpdate.downloadFinishedAt = downloadFinished
appUpdate.downloadProgress = 1
s.onProgressUpdate()
s.triggerComponentUpdateState("app", appUpdate)
if err := s.verifyFile(
appUpdatePath,
@ -48,7 +48,7 @@ func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus)
appUpdate.verificationProgress = 1
appUpdate.updatedAt = verifyFinished
appUpdate.updateProgress = 1
s.onProgressUpdate()
s.triggerComponentUpdateState("app", appUpdate)
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"
"net/http"
"net/url"
"slices"
"time"
"github.com/Masterminds/semver/v3"
@ -21,22 +22,49 @@ func (s *State) GetReleaseAPIEndpoint() string {
return s.releaseAPIEndpoint
}
func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
// getUpdateURL returns the update URL for the given parameters
func (s *State) getUpdateURL(params UpdateParams) (string, error) {
updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil {
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
return "", fmt.Errorf("error parsing update metadata URL: %w", err)
}
appTargetVersion := s.GetTargetVersion("app")
if appTargetVersion != "" && params.AppTargetVersion == "" {
params.AppTargetVersion = appTargetVersion
}
systemTargetVersion := s.GetTargetVersion("system")
if systemTargetVersion != "" && params.SystemTargetVersion == "" {
params.SystemTargetVersion = systemTargetVersion
}
query := updateURL.Query()
query.Set("deviceId", deviceID)
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
query.Set("deviceId", params.DeviceID)
query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease))
if params.AppTargetVersion != "" {
query.Set("appVersion", params.AppTargetVersion)
}
if params.SystemTargetVersion != "" {
query.Set("systemVersion", params.SystemTargetVersion)
}
updateURL.RawQuery = query.Encode()
logger.Info().Str("url", updateURL.String()).Msg("Checking for updates")
return updateURL.String(), nil
}
req, err := http.NewRequestWithContext(ctx, "GET", updateURL.String(), nil)
func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
url, err := s.getUpdateURL(params)
if err != nil {
return nil, fmt.Errorf("error getting update URL: %w", err)
}
s.l.Trace().
Str("url", url).
Msg("fetching update metadata")
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
@ -61,39 +89,68 @@ func (s *State) fetchUpdateMetadata(ctx context.Context, deviceID string, includ
return metadata, nil
}
func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreRelease bool) error {
func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error {
return s.doUpdate(ctx, params)
}
func (s *State) triggerStateUpdate() {
s.onStateUpdate(s.ToRPCState())
}
func (s *State) 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().
Str("deviceID", deviceID).
Str("includePreRelease", fmt.Sprintf("%v", includePreRelease)).
Interface("params", params).
Logger()
scopedLogger.Info().Msg("Trying to update...")
scopedLogger.Info().Msg("checking for updates")
if s.updating {
return fmt.Errorf("update already in progress")
}
s.updating = true
s.onProgressUpdate()
if len(params.Components) == 0 {
params.Components = []string{"app", "system"}
}
shouldUpdateApp := slices.Contains(params.Components, "app")
shouldUpdateSystem := slices.Contains(params.Components, "system")
defer func() {
s.updating = false
s.onProgressUpdate()
}()
if !shouldUpdateApp && !shouldUpdateSystem {
return fmt.Errorf("no components to update")
}
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 {
return s.componentUpdateError("Error checking for updates", err, &scopedLogger)
}
s.metadataFetchedAt = time.Now()
s.onProgressUpdate()
s.triggerStateUpdate()
if appUpdate.available {
appUpdate.pending = true
if params.CheckOnly {
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
s.triggerComponentUpdateState("system", systemUpdate)
}
if appUpdate.pending {
@ -120,9 +177,19 @@ func (s *State) TryUpdate(ctx context.Context, deviceID string, includePreReleas
if s.rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update")
redirectUrl := fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version)
if params.ResetConfig {
scopedLogger.Info().Msg("Resetting config")
if err := s.resetConfig(); err != nil {
return s.componentUpdateError("Error resetting config", err, &scopedLogger)
}
redirectUrl = "/device/setup"
}
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectUrl: fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version),
RedirectUrl: redirectUrl,
}
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
}
// UpdateParams represents the parameters for the update
type UpdateParams struct {
DeviceID string `json:"deviceID"`
AppTargetVersion string `json:"appTargetVersion"`
SystemTargetVersion string `json:"systemTargetVersion"`
Components []string `json:"components,omitempty"`
IncludePreRelease bool `json:"includePreRelease"`
CheckOnly bool `json:"checkOnly"`
ResetConfig bool `json:"resetConfig"`
}
func (s *State) getUpdateStatus(
ctx context.Context,
deviceID string,
includePreRelease bool,
params UpdateParams,
) (
appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus,
@ -144,7 +221,14 @@ func (s *State) getUpdateStatus(
) {
appUpdate = &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
systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
@ -155,7 +239,7 @@ func (s *State) getUpdateStatus(
systemUpdate.localVersion = systemVersionLocal.String()
// Get remote metadata
remoteMetadata, err := s.fetchUpdateMetadata(ctx, deviceID, includePreRelease)
remoteMetadata, err := s.fetchUpdateMetadata(ctx, params)
if err != nil {
err = fmt.Errorf("error checking for updates: %w", err)
return
@ -175,6 +259,7 @@ func (s *State) getUpdateStatus(
return
}
systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal)
systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal)
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
@ -182,15 +267,16 @@ func (s *State) getUpdateStatus(
return
}
appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal)
appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease {
if isRemoteSystemPreRelease && !params.IncludePreRelease {
systemUpdate.available = false
}
if isRemoteAppPreRelease && !includePreRelease {
if isRemoteAppPreRelease && !params.IncludePreRelease {
appUpdate.available = false
}
@ -201,8 +287,8 @@ func (s *State) getUpdateStatus(
}
// GetUpdateStatus returns the current update status (for backwards compatibility)
func (s *State) GetUpdateStatus(ctx context.Context, deviceID string, includePreRelease bool) (*UpdateStatus, error) {
_, _, err := s.getUpdateStatus(ctx, deviceID, includePreRelease)
func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
_, _, err := s.getUpdateStatus(ctx, params)
if err != nil {
return nil, fmt.Errorf("error getting update status: %w", err)
}

View File

@ -1,6 +1,7 @@
package ota
import (
"fmt"
"net/http"
"sync"
"time"
@ -27,10 +28,12 @@ type LocalMetadata struct {
// UpdateStatus represents the current update status
type UpdateStatus struct {
Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppDowngradeAvailable bool `json:"appDowngradeAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
@ -47,8 +50,10 @@ type PostRebootAction struct {
type componentUpdateStatus struct {
pending bool
available bool
downgradeAvailable bool
version string
localVersion string
targetVersion string
url string
hash string
downloadProgress float32
@ -57,33 +62,38 @@ type componentUpdateStatus struct {
verifiedAt time.Time
updateProgress float32
updatedAt time.Time
dependsOn []string
dependsOn []string //nolint:unused
}
// RPCState represents the current OTA state for the RPC API
type RPCState struct {
Updating bool `json:"updating"`
Error string `json:"error,omitempty"`
MetadataFetchedAt time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadFinishedAt time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadFinishedAt time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppUpdatedAt time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt time.Time `json:"systemUpdatedAt,omitempty"`
Updating bool `json:"updating"`
Error string `json:"error,omitempty"`
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
SystemTargetVersion *string `json:"systemTargetVersion,omitempty"`
AppTargetVersion *string `json:"appTargetVersion,omitempty"`
}
// HwRebootFunc is a function that reboots the hardware
type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error
// ResetConfigFunc is a function that resets the config
type ResetConfigFunc func() error
// GetHTTPClientFunc is a function that returns the HTTP client
type GetHTTPClientFunc func() *http.Client
@ -109,6 +119,41 @@ type State struct {
client GetHTTPClientFunc
reboot HwRebootFunc
getLocalVersion GetLocalVersionFunc
onStateUpdate OnStateUpdateFunc
resetConfig ResetConfigFunc
}
// SetTargetVersion sets the target version for a component
func (s *State) SetTargetVersion(component string, version string) error {
parsedVersion := version
if version != "" {
// validate if it's a valid semver string first
semverVersion, err := semver.NewVersion(version)
if err != nil {
return fmt.Errorf("not a valid semantic version: %w", err)
}
parsedVersion = semverVersion.String()
}
// check if the component exists
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return fmt.Errorf("component %s not found", component)
}
componentUpdate.targetVersion = parsedVersion
s.componentUpdateStatuses[component] = componentUpdate
return nil
}
// GetTargetVersion returns the target version for a component
func (s *State) GetTargetVersion(component string) string {
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return ""
}
return componentUpdate.targetVersion
}
// ToUpdateStatus converts the State to the UpdateStatus
@ -136,9 +181,11 @@ func (s *State) ToUpdateStatus() *UpdateStatus {
SystemURL: systemUpdate.url,
SystemHash: systemUpdate.hash,
},
SystemUpdateAvailable: systemUpdate.available,
AppUpdateAvailable: appUpdate.available,
Error: s.error,
SystemUpdateAvailable: systemUpdate.available,
SystemDowngradeAvailable: systemUpdate.downgradeAvailable,
AppUpdateAvailable: appUpdate.available,
AppDowngradeAvailable: appUpdate.downgradeAvailable,
Error: s.error,
}
}
@ -156,54 +203,73 @@ type Options struct {
OnProgressUpdate OnProgressUpdateFunc
HwReboot HwRebootFunc
ReleaseAPIEndpoint string
ResetConfig ResetConfigFunc
}
// NewState creates a new OTA state
func NewState(opts Options) *State {
components := make(map[string]componentUpdateStatus)
components["app"] = componentUpdateStatus{}
components["system"] = componentUpdateStatus{}
s := &State{
l: opts.Logger,
client: opts.GetHTTPClient,
reboot: opts.HwReboot,
onStateUpdate: opts.OnStateUpdate,
getLocalVersion: opts.GetLocalVersion,
componentUpdateStatuses: make(map[string]componentUpdateStatus),
componentUpdateStatuses: components,
releaseAPIEndpoint: opts.ReleaseAPIEndpoint,
resetConfig: opts.ResetConfig,
}
go s.confirmCurrentSystem()
return s
}
// ToRPCState converts the State to the RPCState
// probably we need a generator for this ...
func (s *State) ToRPCState() *RPCState {
r := &RPCState{
Updating: s.updating,
Error: s.error,
MetadataFetchedAt: s.metadataFetchedAt,
MetadataFetchedAt: &s.metadataFetchedAt,
}
app, ok := s.componentUpdateStatuses["app"]
if ok {
r.AppUpdatePending = app.pending
r.AppDownloadProgress = app.downloadProgress
r.AppDownloadFinishedAt = app.downloadFinishedAt
r.AppVerificationProgress = app.verificationProgress
r.AppVerifiedAt = app.verifiedAt
r.AppUpdateProgress = app.updateProgress
r.AppUpdatedAt = app.updatedAt
r.AppDownloadProgress = &app.downloadProgress
if !app.downloadFinishedAt.IsZero() {
r.AppDownloadFinishedAt = &app.downloadFinishedAt
}
r.AppVerificationProgress = &app.verificationProgress
if !app.verifiedAt.IsZero() {
r.AppVerifiedAt = &app.verifiedAt
}
r.AppUpdateProgress = &app.updateProgress
if !app.updatedAt.IsZero() {
r.AppUpdatedAt = &app.updatedAt
}
r.AppTargetVersion = &app.targetVersion
}
system, ok := s.componentUpdateStatuses["system"]
if ok {
r.SystemUpdatePending = system.pending
r.SystemDownloadProgress = system.downloadProgress
r.SystemDownloadFinishedAt = system.downloadFinishedAt
r.SystemVerificationProgress = system.verificationProgress
r.SystemVerifiedAt = system.verifiedAt
r.SystemUpdateProgress = system.updateProgress
r.SystemUpdatedAt = system.updatedAt
r.SystemDownloadProgress = &system.downloadProgress
if !system.downloadFinishedAt.IsZero() {
r.SystemDownloadFinishedAt = &system.downloadFinishedAt
}
r.SystemVerificationProgress = &system.verificationProgress
if !system.verifiedAt.IsZero() {
r.SystemVerifiedAt = &system.verifiedAt
}
r.SystemUpdateProgress = &system.updateProgress
if !system.updatedAt.IsZero() {
r.SystemUpdatedAt = &system.updatedAt
}
r.SystemTargetVersion = &system.targetVersion
}
return r
}
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()
if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, &systemUpdate.downloadProgress); err != nil {
if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, "system"); err != nil {
return s.componentUpdateError("Error downloading system update", err, &l)
}
downloadFinished := time.Now()
systemUpdate.downloadFinishedAt = downloadFinished
systemUpdate.downloadProgress = 1
s.onProgressUpdate()
s.triggerComponentUpdateState("system", systemUpdate)
if err := s.verifyFile(
systemUpdatePath,
@ -38,7 +38,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
systemUpdate.verificationProgress = 1
systemUpdate.updatedAt = verifyFinished
systemUpdate.updateProgress = 1
s.onProgressUpdate()
s.triggerComponentUpdateState("system", systemUpdate)
l.Info().Msg("System update downloaded")
@ -68,7 +68,7 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
if systemUpdate.updateProgress > 0.99 {
systemUpdate.updateProgress = 0.99
}
s.onProgressUpdate()
s.triggerComponentUpdateState("system", systemUpdate)
case <-ctx.Done():
return
}
@ -84,9 +84,11 @@ func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateS
return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger)
}
rkLogger.Info().Msg("rk_ota success")
s.rebootNeeded = true
systemUpdate.updateProgress = 1
systemUpdate.updatedAt = verifyFinished
s.onProgressUpdate()
s.triggerComponentUpdateState("system", systemUpdate)
return nil
}

View File

@ -25,7 +25,14 @@ func syncFilesystem() error {
return nil
}
func (s *State) downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
func (s *State) downloadFile(ctx context.Context, path string, url string, component string) error {
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return fmt.Errorf("component %s not found", component)
}
downloadProgress := componentUpdate.downloadProgress
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return fmt.Errorf("error removing existing file: %w", err)
@ -80,9 +87,9 @@ func (s *State) downloadFile(ctx context.Context, path string, url string, downl
return fmt.Errorf("error writing to file: %w", ew)
}
progress := float32(written) / float32(totalSize)
if progress-*downloadProgress >= 0.01 {
*downloadProgress = progress
s.onProgressUpdate()
if progress-downloadProgress >= 0.01 {
componentUpdate.downloadProgress = progress
s.triggerComponentUpdateState(component, &componentUpdate)
}
}
if er != nil {
@ -136,7 +143,7 @@ func (s *State) verifyFile(path string, expectedHash string, verifyProgress *flo
progress := float32(verified) / float32(totalSize)
if progress-*verifyProgress >= 0.01 {
*verifyProgress = progress
s.onProgressUpdate()
s.triggerStateUpdate()
}
}
if er != nil {

View File

@ -19,7 +19,6 @@ import (
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/hidrpc"
"github.com/jetkvm/kvm/internal/ota"
"github.com/jetkvm/kvm/internal/usbgadget"
"github.com/jetkvm/kvm/internal/utils"
)
@ -236,71 +235,6 @@ func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus()
}
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
func rpcSetDevChannelState(enabled bool) error {
config.IncludePreRelease = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
updateStatus, err := otaState.GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
logger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
return updateStatus, nil
}
func rpcGetUpdateStatus() (*ota.UpdateStatus, error) {
return getUpdateStatus(config.IncludePreRelease)
}
func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) {
switch channel {
case "stable":
return getUpdateStatus(false)
case "dev":
return getUpdateStatus(true)
default:
return nil, fmt.Errorf("invalid channel: %s", channel)
}
}
func rpcGetLocalVersion() (*ota.LocalMetadata, error) {
systemVersion, appVersion, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
}
return &ota.LocalMetadata{
AppVersion: appVersion.String(),
SystemVersion: systemVersion.String(),
}, nil
}
func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease
go func() {
err := otaState.TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation
if currentRotation == params.Rotation {
@ -1218,6 +1152,8 @@ var rpcHandlers = map[string]RPCHandler{
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"components", "includePreRelease", "checkOnly", "resetConfig"}},
"cancelDowngrade": {Func: rpcCancelDowngrade},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},

View File

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

116
ota.go
View File

@ -1,6 +1,7 @@
package kvm
import (
"context"
"fmt"
"net/http"
"os"
@ -29,6 +30,7 @@ func initOta() {
},
GetLocalVersion: GetLocalVersion,
HwReboot: hwReboot,
ResetConfig: rpcResetConfig,
OnStateUpdate: func(state *ota.RPCState) {
triggerOTAStateUpdate(state)
},
@ -74,3 +76,117 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
return systemVersion, appVersion, nil
}
func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
})
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
logger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
return updateStatus, nil
}
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
func rpcSetDevChannelState(enabled bool) error {
config.IncludePreRelease = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetUpdateStatus() (*ota.UpdateStatus, error) {
return getUpdateStatus(config.IncludePreRelease)
}
func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) {
switch channel {
case "stable":
return getUpdateStatus(false)
case "dev":
return getUpdateStatus(true)
default:
return nil, fmt.Errorf("invalid channel: %s", channel)
}
}
func rpcGetLocalVersion() (*ota.LocalMetadata, error) {
systemVersion, appVersion, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
}
return &ota.LocalMetadata{
AppVersion: appVersion.String(),
SystemVersion: systemVersion.String(),
}, nil
}
// ComponentName represents the name of a component
type tryUpdateComponents struct {
AppTargetVersion string `json:"app"`
SystemTargetVersion string `json:"system"`
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"
}
# 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
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
REMOTE_USER="root"
@ -113,6 +138,10 @@ if [ -z "$REMOTE_HOST" ]; then
exit 1
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
if [ "$(uname -m)" != "x86_64" ]; then
msg_warn "Warning: This script is only supported on x86_64 architecture"
@ -131,7 +160,7 @@ if [[ "$SKIP_UI_BUILD" = true && ! -f "static/index.html" ]]; then
SKIP_UI_BUILD=false
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"
make frontend SKIP_UI_BUILD=0
SKIP_UI_BUILD_RELEASE=1
@ -144,13 +173,13 @@ fi
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
make build_dev_test
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"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
@ -191,35 +220,35 @@ then
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# 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.
ssh "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev \
SKIP_NATIVE_IF_EXISTS=${SKIP_NATIVE_BUILD} \
SKIP_UI_BUILD=${SKIP_UI_BUILD_RELEASE} \
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# 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
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
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"
# 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 "${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}" "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"
fi
# 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 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_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
cd "${REMOTE_PATH}"
@ -240,4 +280,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d
EOF
fi
echo "Deployment complete."
echo "Deployment complete."

View File

@ -100,18 +100,6 @@
"advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation",
"advanced_version_update_app_label": "App Version",
"advanced_version_update_button": "Update to Version",
"advanced_version_update_description": "Install a specific version from GitHub releases",
"advanced_version_update_github_link": "JetKVM releases page",
"advanced_version_update_helper": "Find available versions on the",
"advanced_version_update_system_label": "System Version",
"advanced_version_update_target_app": "App only",
"advanced_version_update_target_both": "Both App and System",
"advanced_version_update_target_label": "What to update",
"advanced_version_update_target_system": "System only",
"advanced_version_update_title": "Update to Specific Version",
"advanced_error_version_update": "Failed to initiate version update: {error}",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard",
@ -904,5 +892,23 @@
"wake_on_lan_invalid_mac": "Invalid MAC address",
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
"welcome_to_jetkvm": "Welcome to JetKVM",
"welcome_to_jetkvm_description": "Control any computer remotely"
"welcome_to_jetkvm_description": "Control any computer remotely",
"advanced_version_update_app_label": "App Version",
"advanced_version_update_button": "Update to Version",
"advanced_version_update_description": "Install a specific version from GitHub releases",
"advanced_version_update_github_link": "JetKVM releases page",
"advanced_version_update_helper": "Find available versions on the",
"advanced_version_update_system_label": "System Version",
"advanced_version_update_target_app": "App only",
"advanced_version_update_target_both": "Both App and System",
"advanced_version_update_target_label": "What to update",
"advanced_version_update_target_system": "System only",
"advanced_version_update_title": "Update to Specific Version",
"advanced_error_version_update": "Failed to initiate version update: {error}",
"general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.",
"general_update_downgrade_available_title": "Downgrade Available",
"general_update_downgrade_button": "Downgrade Now",
"general_update_keep_current_button": "Keep Current Version",
"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"
| "upToDate"
| "updateAvailable"
| "updateDowngradeAvailable"
| "updateCompleted"
| "error";

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router";
import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores";
@ -14,16 +14,35 @@ import { m } from "@localizations/messages.js";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
//@ts-ignore
const [searchParams, setSearchParams] = useSearchParams();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const downgrade = useMemo(() => searchParams.get("downgrade") === "true", [searchParams]);
const updateComponents = useMemo(() => searchParams.get("components") || "", [searchParams]);
const resetConfig = useMemo(() => searchParams.get("resetConfig") === "true", [searchParams]);
const onConfirmUpdate = useCallback(() => {
send("tryUpdate", {});
setModalView("updating");
}, [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(() => {
if (otaState.updating) {
setModalView("updating");
@ -36,37 +55,56 @@ export default function SettingsGeneralUpdateRoute() {
}
}, [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({
onClose,
onConfirmUpdate,
onConfirmDowngrade,
downgrade,
}: Readonly<{
downgrade: boolean;
onClose: () => void;
onConfirmUpdate: () => void;
onConfirmDowngrade: () => void;
}>) {
const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const onFinishedLoading = useCallback(
(versionInfo: SystemVersionInfo) => {
const hasUpdate =
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
const hasDowngrade =
versionInfo?.systemDowngradeAvailable || versionInfo?.appDowngradeAvailable;
setVersionInfo(versionInfo);
if (hasUpdate) {
if (hasDowngrade && downgrade) {
setModalView("updateDowngradeAvailable");
} else if (hasUpdate) {
setModalView("updateAvailable");
} else {
setModalView("upToDate");
}
},
[setModalView],
[setModalView, downgrade],
);
const onCancelDowngrade = useCallback(() => {
send("cancelDowngrade", {});
onClose();
}, [onClose, send]);
return (
<div className="pointer-events-auto relative mx-auto text-left">
<div>
@ -89,6 +127,13 @@ export function Dialog({
versionInfo={versionInfo!}
/>
)}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && (
<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 }) {
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">