Compare commits

...

15 Commits

Author SHA1 Message Date
Aveline 8c032918d7
Merge e186d3b674 into 36f06a064a 2025-11-06 21:55:06 +01:00
Aveline e186d3b674
Merge branch 'dev' into r/ota 2025-11-06 16:56:50 +01:00
Adam Shiervani 8c88f72ba4 feat: add acknowledgment checkbox for version changes in advanced settings 2025-11-06 11:54:50 +01:00
Adam Shiervani 931413acd9
Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-06 11:18:54 +01:00
Adam Shiervani 550ff2e108
Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-06 11:18:31 +01:00
Adam Shiervani cc36d83154
Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-06 11:15:54 +01:00
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
Adam Shiervani a69f7c9c50 feat: add version update functionality to advanced settings 2025-10-29 12:05:40 +01:00
Adam Shiervani 019c73cd61 refactor: mprove UI settings structure with NestedSettingsGroup 2025-10-29 11:50:27 +01:00
Siyuan 325de4a33a WIP: OTA refactor 2025-10-29 09:07:30 +00:00
24 changed files with 1495 additions and 755 deletions

View File

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

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
@ -80,6 +81,7 @@ func (m *KeyboardMacro) Validate() error {
type Config struct {
CloudURL string `json:"cloud_url"`
UpdateAPIURL string `json:"update_api_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
@ -109,6 +111,15 @@ type Config struct {
VideoQualityFactor float64 `json:"video_quality_factor"`
}
// GetUpdateAPIURL returns the update API URL
func (c *Config) GetUpdateAPIURL() string {
if c.UpdateAPIURL == "" {
return "https://api.jetkvm.com"
}
return strings.TrimSuffix(c.UpdateAPIURL, "/") + "/releases"
}
// GetDisplayRotation returns the display rotation
func (c *Config) GetDisplayRotation() uint16 {
rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
if err != nil {
@ -118,6 +129,7 @@ func (c *Config) GetDisplayRotation() uint16 {
return uint16(rotationInt)
}
// SetDisplayRotation sets the display rotation
func (c *Config) SetDisplayRotation(rotation string) error {
_, err := strconv.ParseUint(rotation, 10, 16)
if err != nil {
@ -157,6 +169,7 @@ var (
func getDefaultConfig() Config {
return Config{
CloudURL: "https://api.jetkvm.com",
UpdateAPIURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",

4
hw.go
View File

@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"github.com/jetkvm/kvm/internal/ota"
)
func extractSerialNumber() (string, error) {
@ -37,7 +39,7 @@ func readOtpEntropy() ([]byte, error) { //nolint:unused
return content[0x17:0x1C], nil
}
func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error { //nolint:unused
logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)

58
internal/ota/app.go Normal file
View File

@ -0,0 +1,58 @@
package ota
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
)
const (
appUpdatePath = "/userdata/jetkvm/jetkvm_app.update"
)
func (s *State) componentUpdateError(prefix string, err error, l *zerolog.Logger) error {
if l == nil {
l = s.l
}
l.Error().Err(err).Msg(prefix)
s.error = fmt.Sprintf("%s: %v", prefix, err)
return err
}
func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.l.With().Str("path", appUpdatePath).Logger()
if err := s.downloadFile(ctx, appUpdatePath, appUpdate.url, "app"); err != nil {
return s.componentUpdateError("Error downloading app update", err, &l)
}
downloadFinished := time.Now()
appUpdate.downloadFinishedAt = downloadFinished
appUpdate.downloadProgress = 1
s.triggerComponentUpdateState("app", appUpdate)
if err := s.verifyFile(
appUpdatePath,
appUpdate.hash,
&appUpdate.verificationProgress,
); err != nil {
return s.componentUpdateError("Error verifying app update hash", err, &l)
}
verifyFinished := time.Now()
appUpdate.verifiedAt = verifyFinished
appUpdate.verificationProgress = 1
appUpdate.updatedAt = verifyFinished
appUpdate.updateProgress = 1
s.triggerComponentUpdateState("app", appUpdate)
l.Info().Msg("App update downloaded")
s.rebootNeeded = true
return nil
}

297
internal/ota/ota.go Normal file
View File

@ -0,0 +1,297 @@
package ota
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"slices"
"time"
"github.com/Masterminds/semver/v3"
)
// UpdateReleaseAPIEndpoint updates the release API endpoint
func (s *State) UpdateReleaseAPIEndpoint(endpoint string) {
s.releaseAPIEndpoint = endpoint
}
// GetReleaseAPIEndpoint returns the release API endpoint
func (s *State) GetReleaseAPIEndpoint() string {
return s.releaseAPIEndpoint
}
// getUpdateURL returns the update URL for the given parameters
func (s *State) getUpdateURL(params UpdateParams) (string, error) {
updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil {
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", 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()
return 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)
}
client := s.client()
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(metadata)
if err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return metadata, nil
}
func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error {
return s.doUpdate(ctx, params)
}
func (s *State) triggerStateUpdate() {
s.onStateUpdate(s.ToRPCState())
}
func (s *State) triggerComponentUpdateState(component string, update *componentUpdateStatus) {
s.componentUpdateStatuses[component] = *update
s.triggerStateUpdate()
}
func (s *State) doUpdate(ctx context.Context, params UpdateParams) error {
scopedLogger := s.l.With().
Interface("params", params).
Logger()
scopedLogger.Info().Msg("checking for updates")
if s.updating {
return fmt.Errorf("update already in progress")
}
if len(params.Components) == 0 {
params.Components = []string{"app", "system"}
}
shouldUpdateApp := slices.Contains(params.Components, "app")
shouldUpdateSystem := slices.Contains(params.Components, "system")
if !shouldUpdateApp && !shouldUpdateSystem {
return fmt.Errorf("no components to update")
}
if !params.CheckOnly {
s.updating = true
s.triggerStateUpdate()
defer func() {
s.updating = false
s.triggerStateUpdate()
}()
}
appUpdate, systemUpdate, err := s.getUpdateStatus(ctx, params)
if err != nil {
return s.componentUpdateError("Error checking for updates", err, &scopedLogger)
}
s.metadataFetchedAt = time.Now()
s.triggerStateUpdate()
if params.CheckOnly {
return nil
}
if shouldUpdateApp && (appUpdate.available || appUpdate.downgradeAvailable) {
appUpdate.pending = true
s.triggerComponentUpdateState("app", appUpdate)
}
if shouldUpdateSystem && (systemUpdate.available || systemUpdate.downgradeAvailable) {
systemUpdate.pending = true
s.triggerComponentUpdateState("system", systemUpdate)
}
if appUpdate.pending {
scopedLogger.Info().
Str("url", appUpdate.url).
Str("hash", appUpdate.hash).
Msg("App update available")
if err := s.updateApp(ctx, appUpdate); err != nil {
return s.componentUpdateError("Error updating app", err, &scopedLogger)
}
} else {
scopedLogger.Info().Msg("App is up to date")
}
if systemUpdate.pending {
if err := s.updateSystem(ctx, systemUpdate); err != nil {
return s.componentUpdateError("Error updating system", err, &scopedLogger)
}
} else {
scopedLogger.Info().Msg("System is up to date")
}
if s.rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update")
redirectUrl := fmt.Sprintf("/settings/general/update?version=%s", systemUpdate.version)
if params.ResetConfig {
scopedLogger.Info().Msg("Resetting config")
if err := s.resetConfig(); err != nil {
return s.componentUpdateError("Error resetting config", err, &scopedLogger)
}
redirectUrl = "/device/setup"
}
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectUrl: redirectUrl,
}
if err := s.reboot(true, postRebootAction, 10*time.Second); err != nil {
return s.componentUpdateError("Error requesting reboot", err, &scopedLogger)
}
}
return nil
}
// UpdateParams represents the parameters for the update
type UpdateParams struct {
DeviceID string `json:"deviceID"`
AppTargetVersion string `json:"appTargetVersion"`
SystemTargetVersion string `json:"systemTargetVersion"`
Components []string `json:"components,omitempty"`
IncludePreRelease bool `json:"includePreRelease"`
CheckOnly bool `json:"checkOnly"`
ResetConfig bool `json:"resetConfig"`
}
func (s *State) getUpdateStatus(
ctx context.Context,
params UpdateParams,
) (
appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus,
err error,
) {
appUpdate = &componentUpdateStatus{}
systemUpdate = &componentUpdateStatus{}
if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok {
appUpdate = &currentAppUpdate
}
if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
systemUpdate = &currentSystemUpdate
}
// Get local versions
systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
if err != nil {
return nil, nil, fmt.Errorf("error getting local version: %w", err)
}
appUpdate.localVersion = appVersionLocal.String()
systemUpdate.localVersion = systemVersionLocal.String()
// Get remote metadata
remoteMetadata, err := s.fetchUpdateMetadata(ctx, params)
if err != nil {
err = fmt.Errorf("error checking for updates: %w", err)
return
}
appUpdate.url = remoteMetadata.AppURL
appUpdate.hash = remoteMetadata.AppHash
appUpdate.version = remoteMetadata.AppVersion
systemUpdate.url = remoteMetadata.SystemURL
systemUpdate.hash = remoteMetadata.SystemHash
systemUpdate.version = remoteMetadata.SystemVersion
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil {
err = fmt.Errorf("error parsing remote system version: %w", err)
return
}
systemUpdate.available = systemVersionRemote.GreaterThan(systemVersionLocal)
systemUpdate.downgradeAvailable = systemVersionRemote.LessThan(systemVersionLocal)
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
err = fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
return
}
appUpdate.available = appVersionRemote.GreaterThan(appVersionLocal)
appUpdate.downgradeAvailable = appVersionRemote.LessThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !params.IncludePreRelease {
systemUpdate.available = false
}
if isRemoteAppPreRelease && !params.IncludePreRelease {
appUpdate.available = false
}
s.componentUpdateStatuses["app"] = *appUpdate
s.componentUpdateStatuses["system"] = *systemUpdate
return
}
// GetUpdateStatus returns the current update status (for backwards compatibility)
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)
}
return s.ToUpdateStatus(), nil
}

275
internal/ota/state.go Normal file
View File

@ -0,0 +1,275 @@
package ota
import (
"fmt"
"net/http"
"sync"
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
)
// UpdateMetadata represents the metadata of an update
type UpdateMetadata struct {
AppVersion string `json:"appVersion"`
AppURL string `json:"appUrl"`
AppHash string `json:"appHash"`
SystemVersion string `json:"systemVersion"`
SystemURL string `json:"systemUrl"`
SystemHash string `json:"systemHash"`
}
// LocalMetadata represents the local metadata of the system
type LocalMetadata struct {
AppVersion string `json:"appVersion"`
SystemVersion string `json:"systemVersion"`
}
// UpdateStatus represents the current update status
type UpdateStatus struct {
Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
SystemDowngradeAvailable bool `json:"systemDowngradeAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
AppDowngradeAvailable bool `json:"appDowngradeAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
}
// PostRebootAction represents the action to be taken after a reboot
// It is used to redirect the user to a specific page after a reboot
type PostRebootAction struct {
HealthCheck string `json:"healthCheck"` // The health check URL to call after the reboot
RedirectUrl string `json:"redirectUrl"` // The URL to redirect to after the reboot
}
// componentUpdateStatus represents the status of a component update
type componentUpdateStatus struct {
pending bool
available bool
downgradeAvailable bool
version string
localVersion string
targetVersion string
url string
hash string
downloadProgress float32
downloadFinishedAt time.Time
verificationProgress float32
verifiedAt time.Time
updateProgress float32
updatedAt time.Time
dependsOn []string //nolint:unused
}
// RPCState represents the current OTA state for the RPC API
type RPCState struct {
Updating bool `json:"updating"`
Error string `json:"error,omitempty"`
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress *float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress *float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress *float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress *float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress *float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress *float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
SystemTargetVersion *string `json:"systemTargetVersion,omitempty"`
AppTargetVersion *string `json:"appTargetVersion,omitempty"`
}
// HwRebootFunc is a function that reboots the hardware
type HwRebootFunc func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error
// ResetConfigFunc is a function that resets the config
type ResetConfigFunc func() error
// GetHTTPClientFunc is a function that returns the HTTP client
type GetHTTPClientFunc func() *http.Client
// OnStateUpdateFunc is a function that updates the state of the OTA
type OnStateUpdateFunc func(state *RPCState)
// OnProgressUpdateFunc is a function that updates the progress of the OTA
type OnProgressUpdateFunc func(progress float32)
// GetLocalVersionFunc is a function that returns the local version of the system and app
type GetLocalVersionFunc func() (systemVersion *semver.Version, appVersion *semver.Version, err error)
// State represents the current OTA state for the UI
type State struct {
releaseAPIEndpoint string
l *zerolog.Logger
mu sync.Mutex
updating bool
error string
metadataFetchedAt time.Time
rebootNeeded bool
componentUpdateStatuses map[string]componentUpdateStatus
client GetHTTPClientFunc
reboot HwRebootFunc
getLocalVersion GetLocalVersionFunc
onStateUpdate OnStateUpdateFunc
resetConfig ResetConfigFunc
}
// SetTargetVersion sets the target version for a component
func (s *State) SetTargetVersion(component string, version string) error {
parsedVersion := version
if version != "" {
// validate if it's a valid semver string first
semverVersion, err := semver.NewVersion(version)
if err != nil {
return fmt.Errorf("not a valid semantic version: %w", err)
}
parsedVersion = semverVersion.String()
}
// check if the component exists
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return fmt.Errorf("component %s not found", component)
}
componentUpdate.targetVersion = parsedVersion
s.componentUpdateStatuses[component] = componentUpdate
return nil
}
// GetTargetVersion returns the target version for a component
func (s *State) GetTargetVersion(component string) string {
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return ""
}
return componentUpdate.targetVersion
}
// ToUpdateStatus converts the State to the UpdateStatus
func (s *State) ToUpdateStatus() *UpdateStatus {
appUpdate, ok := s.componentUpdateStatuses["app"]
if !ok {
return nil
}
systemUpdate, ok := s.componentUpdateStatuses["system"]
if !ok {
return nil
}
return &UpdateStatus{
Local: &LocalMetadata{
AppVersion: appUpdate.localVersion,
SystemVersion: systemUpdate.localVersion,
},
Remote: &UpdateMetadata{
AppVersion: appUpdate.version,
AppURL: appUpdate.url,
AppHash: appUpdate.hash,
SystemVersion: systemUpdate.version,
SystemURL: systemUpdate.url,
SystemHash: systemUpdate.hash,
},
SystemUpdateAvailable: systemUpdate.available,
SystemDowngradeAvailable: systemUpdate.downgradeAvailable,
AppUpdateAvailable: appUpdate.available,
AppDowngradeAvailable: appUpdate.downgradeAvailable,
Error: s.error,
}
}
// IsUpdatePending returns true if an update is pending
func (s *State) IsUpdatePending() bool {
return s.updating
}
// Options represents the options for the OTA state
type Options struct {
Logger *zerolog.Logger
GetHTTPClient GetHTTPClientFunc
GetLocalVersion GetLocalVersionFunc
OnStateUpdate OnStateUpdateFunc
OnProgressUpdate OnProgressUpdateFunc
HwReboot HwRebootFunc
ReleaseAPIEndpoint string
ResetConfig ResetConfigFunc
}
// NewState creates a new OTA state
func NewState(opts Options) *State {
components := make(map[string]componentUpdateStatus)
components["app"] = componentUpdateStatus{}
components["system"] = componentUpdateStatus{}
s := &State{
l: opts.Logger,
client: opts.GetHTTPClient,
reboot: opts.HwReboot,
onStateUpdate: opts.OnStateUpdate,
getLocalVersion: opts.GetLocalVersion,
componentUpdateStatuses: components,
releaseAPIEndpoint: opts.ReleaseAPIEndpoint,
resetConfig: opts.ResetConfig,
}
go s.confirmCurrentSystem()
return s
}
// ToRPCState converts the State to the RPCState
// probably we need a generator for this ...
func (s *State) ToRPCState() *RPCState {
r := &RPCState{
Updating: s.updating,
Error: s.error,
MetadataFetchedAt: &s.metadataFetchedAt,
}
app, ok := s.componentUpdateStatuses["app"]
if ok {
r.AppUpdatePending = app.pending
r.AppDownloadProgress = &app.downloadProgress
if !app.downloadFinishedAt.IsZero() {
r.AppDownloadFinishedAt = &app.downloadFinishedAt
}
r.AppVerificationProgress = &app.verificationProgress
if !app.verifiedAt.IsZero() {
r.AppVerifiedAt = &app.verifiedAt
}
r.AppUpdateProgress = &app.updateProgress
if !app.updatedAt.IsZero() {
r.AppUpdatedAt = &app.updatedAt
}
r.AppTargetVersion = &app.targetVersion
}
system, ok := s.componentUpdateStatuses["system"]
if ok {
r.SystemUpdatePending = system.pending
r.SystemDownloadProgress = &system.downloadProgress
if !system.downloadFinishedAt.IsZero() {
r.SystemDownloadFinishedAt = &system.downloadFinishedAt
}
r.SystemVerificationProgress = &system.verificationProgress
if !system.verifiedAt.IsZero() {
r.SystemVerifiedAt = &system.verifiedAt
}
r.SystemUpdateProgress = &system.updateProgress
if !system.updatedAt.IsZero() {
r.SystemUpdatedAt = &system.updatedAt
}
r.SystemTargetVersion = &system.targetVersion
}
return r
}

102
internal/ota/sys.go Normal file
View File

@ -0,0 +1,102 @@
package ota
import (
"bytes"
"context"
"os/exec"
"time"
)
const (
systemUpdatePath = "/userdata/jetkvm/update_system.tar"
)
func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateStatus) error {
s.mu.Lock()
defer s.mu.Unlock()
l := s.l.With().Str("path", systemUpdatePath).Logger()
if err := s.downloadFile(ctx, systemUpdatePath, systemUpdate.url, "system"); err != nil {
return s.componentUpdateError("Error downloading system update", err, &l)
}
downloadFinished := time.Now()
systemUpdate.downloadFinishedAt = downloadFinished
systemUpdate.downloadProgress = 1
s.triggerComponentUpdateState("system", systemUpdate)
if err := s.verifyFile(
systemUpdatePath,
systemUpdate.hash,
&systemUpdate.verificationProgress,
); err != nil {
return s.componentUpdateError("Error verifying system update hash", err, &l)
}
verifyFinished := time.Now()
systemUpdate.verifiedAt = verifyFinished
systemUpdate.verificationProgress = 1
systemUpdate.updatedAt = verifyFinished
systemUpdate.updateProgress = 1
s.triggerComponentUpdateState("system", systemUpdate)
l.Info().Msg("System update downloaded")
l.Info().Msg("Starting rk_ota command")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b
if err := cmd.Start(); err != nil {
return s.componentUpdateError("Error starting rk_ota command", err, &l)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
ticker := time.NewTicker(1800 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if systemUpdate.updateProgress >= 0.99 {
return
}
systemUpdate.updateProgress += 0.01
if systemUpdate.updateProgress > 0.99 {
systemUpdate.updateProgress = 0.99
}
s.triggerComponentUpdateState("system", systemUpdate)
case <-ctx.Done():
return
}
}
}()
err := cmd.Wait()
cancel()
rkLogger := s.l.With().
Str("output", b.String()).
Int("exitCode", cmd.ProcessState.ExitCode()).Logger()
if err != nil {
return s.componentUpdateError("Error executing rk_ota command", err, &rkLogger)
}
rkLogger.Info().Msg("rk_ota success")
s.rebootNeeded = true
systemUpdate.updateProgress = 1
systemUpdate.updatedAt = verifyFinished
s.triggerComponentUpdateState("system", systemUpdate)
return nil
}
func (s *State) confirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
if err != nil {
s.l.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
}
s.l.Trace().Str("output", string(output)).Msg("current partition in A/B setup set")
}

173
internal/ota/utils.go Normal file
View File

@ -0,0 +1,173 @@
package ota
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
)
func syncFilesystem() error {
// Flush filesystem buffers to ensure all data is written to disk
if err := exec.Command("sync").Run(); err != nil {
return fmt.Errorf("error flushing filesystem buffers: %w", err)
}
// Clear the filesystem caches to force a read from disk
if err := os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644); err != nil {
return fmt.Errorf("error clearing filesystem caches: %w", err)
}
return nil
}
func (s *State) downloadFile(ctx context.Context, path string, url string, component string) error {
componentUpdate, ok := s.componentUpdateStatuses[component]
if !ok {
return fmt.Errorf("component %s not found", component)
}
downloadProgress := componentUpdate.downloadProgress
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return fmt.Errorf("error removing existing file: %w", err)
}
}
unverifiedPath := path + ".unverified"
if _, err := os.Stat(unverifiedPath); err == nil {
if err := os.Remove(unverifiedPath); err != nil {
return fmt.Errorf("error removing existing unverified file: %w", err)
}
}
file, err := os.Create(unverifiedPath)
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer file.Close()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
client := s.client()
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
totalSize := resp.ContentLength
if totalSize <= 0 {
return fmt.Errorf("invalid content length")
}
var written int64
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := file.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
}
written += int64(nw)
if ew != nil {
return fmt.Errorf("error writing to file: %w", ew)
}
progress := float32(written) / float32(totalSize)
if progress-downloadProgress >= 0.01 {
componentUpdate.downloadProgress = progress
s.triggerComponentUpdateState(component, &componentUpdate)
}
}
if er != nil {
if er == io.EOF {
break
}
return fmt.Errorf("error reading response body: %w", er)
}
}
file.Close()
if err := syncFilesystem(); err != nil {
return fmt.Errorf("error syncing filesystem: %w", err)
}
return nil
}
func (s *State) verifyFile(path string, expectedHash string, verifyProgress *float32) error {
l := s.l.With().Str("path", path).Logger()
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
return fmt.Errorf("error opening file for hashing: %w", err)
}
defer fileToHash.Close()
hash := sha256.New()
fileInfo, err := fileToHash.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
totalSize := fileInfo.Size()
buf := make([]byte, 32*1024)
verified := int64(0)
for {
nr, er := fileToHash.Read(buf)
if nr > 0 {
nw, ew := hash.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
}
verified += int64(nw)
if ew != nil {
return fmt.Errorf("error writing to hash: %w", ew)
}
progress := float32(verified) / float32(totalSize)
if progress-*verifyProgress >= 0.01 {
*verifyProgress = progress
s.triggerStateUpdate()
}
}
if er != nil {
if er == io.EOF {
break
}
return fmt.Errorf("error reading file: %w", er)
}
}
hashSum := hash.Sum(nil)
l.Info().Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
}
if err := os.Rename(unverifiedPath, path); err != nil {
return fmt.Errorf("error renaming file: %w", err)
}
if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("error making file executable: %w", err)
}
return nil
}

View File

@ -236,55 +236,6 @@ func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus()
}
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
func rpcSetDevChannelState(enabled bool) error {
config.IncludePreRelease = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
return updateStatus, nil
}
func rpcGetLocalVersion() (*LocalMetadata, error) {
systemVersion, appVersion, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
}
return &LocalMetadata{
AppVersion: appVersion.String(),
SystemVersion: systemVersion.String(),
}, nil
}
func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation
if currentRotation == params.Rotation {
@ -654,7 +605,7 @@ func rpcGetMassStorageMode() (string, error) {
}
func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil
return otaState.IsUpdatePending(), nil
}
func rpcGetUsbEmulationState() (bool, error) {
@ -1200,7 +1151,10 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion},
"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},

19
main.go
View File

@ -9,6 +9,7 @@ import (
"time"
"github.com/gwatts/rootcerts"
"github.com/jetkvm/kvm/internal/ota"
)
var appCtx context.Context
@ -32,12 +33,6 @@ func Main() {
Msg("starting JetKVM")
go runWatchdog()
go confirmCurrentSystem()
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
http.DefaultClient.Timeout = 1 * time.Minute
err = rootcerts.UpdateDefaultTransport()
if err != nil {
@ -47,6 +42,13 @@ func Main() {
Int("ca_certs_loaded", len(rootcerts.Certs())).
Msg("loaded Root CA certificates")
initOta()
initDisplay()
initNative(systemVersionLocal, appVersionLocal)
http.DefaultClient.Timeout = 1 * time.Minute
// Initialize network
if err := initNetwork(); err != nil {
logger.Error().Err(err).Msg("failed to initialize network")
@ -106,7 +108,10 @@ func Main() {
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
})
if err != nil {
logger.Warn().Err(err).Msg("failed to auto update")
}

View File

@ -8,6 +8,7 @@ import (
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/ota"
"github.com/jetkvm/kvm/pkg/nmlite"
)
@ -176,7 +177,7 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
return nm.SetHostname(hostname, domain)
}
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) {
oldDhcpClient := oldConfig.DHCPClient.String
l := networkLogger.With().
@ -200,7 +201,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
postRebootAction = &PostRebootAction{
postRebootAction = &ota.PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
@ -217,7 +218,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
// Handle IP change for redirect (only if both are not nil and IP changed)
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
postRebootAction = &PostRebootAction{
postRebootAction = &ota.PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}

683
ota.go
View File

@ -1,59 +1,63 @@
package kvm
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
"time"
"github.com/Masterminds/semver/v3"
"github.com/gwatts/rootcerts"
"github.com/rs/zerolog"
"github.com/jetkvm/kvm/internal/ota"
)
type UpdateMetadata struct {
AppVersion string `json:"appVersion"`
AppUrl string `json:"appUrl"`
AppHash string `json:"appHash"`
SystemVersion string `json:"systemVersion"`
SystemUrl string `json:"systemUrl"`
SystemHash string `json:"systemHash"`
}
type LocalMetadata struct {
AppVersion string `json:"appVersion"`
SystemVersion string `json:"systemVersion"`
}
// UpdateStatus represents the current update status
type UpdateStatus struct {
Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
// for backwards compatibility
Error string `json:"error,omitempty"`
}
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
var builtAppVersion = "0.1.0+dev"
var otaState *ota.State
func initOta() {
otaState = ota.NewState(ota.Options{
Logger: otaLogger,
ReleaseAPIEndpoint: config.GetUpdateAPIURL(),
GetHTTPClient: func() *http.Client {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
return client
},
GetLocalVersion: GetLocalVersion,
HwReboot: hwReboot,
ResetConfig: rpcResetConfig,
OnStateUpdate: func(state *ota.RPCState) {
triggerOTAStateUpdate(state)
},
OnProgressUpdate: func(progress float32) {
writeJSONRPCEvent("otaProgress", progress, currentSession)
},
})
}
func triggerOTAStateUpdate(state *ota.RPCState) {
go func() {
if currentSession == nil || (otaState == nil && state == nil) {
return
}
if state == nil {
state = otaState.ToRPCState()
}
writeJSONRPCEvent("otaState", state, currentSession)
}()
}
// GetBuiltAppVersion returns the built-in app version
func GetBuiltAppVersion() string {
return builtAppVersion
}
// GetLocalVersion returns the local version of the system and app
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil {
@ -73,519 +77,116 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
return systemVersion, appVersion, nil
}
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
updateUrl, err := url.Parse(UpdateMetadataUrl)
func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
})
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
query := updateUrl.Query()
query.Set("deviceId", deviceId)
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
updateUrl.RawQuery = query.Encode()
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(metadata)
if err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return metadata, nil
}
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return fmt.Errorf("error removing existing file: %w", err)
}
}
unverifiedPath := path + ".unverified"
if _, err := os.Stat(unverifiedPath); err == nil {
if err := os.Remove(unverifiedPath); err != nil {
return fmt.Errorf("error removing existing unverified file: %w", err)
}
}
file, err := os.Create(unverifiedPath)
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer file.Close()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
client := http.Client{
Timeout: 10 * time.Minute,
Transport: &http.Transport{
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
TLSHandshakeTimeout: 30 * time.Second,
TLSClientConfig: &tls.Config{
RootCAs: rootcerts.ServerCertPool(),
},
},
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error downloading file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
totalSize := resp.ContentLength
if totalSize <= 0 {
return fmt.Errorf("invalid content length")
}
var written int64
buf := make([]byte, 32*1024)
for {
nr, er := resp.Body.Read(buf)
if nr > 0 {
nw, ew := file.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short file write: %d < %d", nw, nr)
}
written += int64(nw)
if ew != nil {
return fmt.Errorf("error writing to file: %w", ew)
}
progress := float32(written) / float32(totalSize)
if progress-*downloadProgress >= 0.01 {
*downloadProgress = progress
triggerOTAStateUpdate()
}
}
if er != nil {
if er == io.EOF {
break
}
return fmt.Errorf("error reading response body: %w", er)
}
}
file.Close()
// Flush filesystem buffers to ensure all data is written to disk
err = exec.Command("sync").Run()
if err != nil {
return fmt.Errorf("error flushing filesystem buffers: %w", err)
}
// Clear the filesystem caches to force a read from disk
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
if err != nil {
return fmt.Errorf("error clearing filesystem caches: %w", err)
}
return nil
}
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
if scopedLogger == nil {
scopedLogger = otaLogger
}
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
return fmt.Errorf("error opening file for hashing: %w", err)
}
defer fileToHash.Close()
hash := sha256.New()
fileInfo, err := fileToHash.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
totalSize := fileInfo.Size()
buf := make([]byte, 32*1024)
verified := int64(0)
for {
nr, er := fileToHash.Read(buf)
if nr > 0 {
nw, ew := hash.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short hash write: %d < %d", nw, nr)
}
verified += int64(nw)
if ew != nil {
return fmt.Errorf("error writing to hash: %w", ew)
}
progress := float32(verified) / float32(totalSize)
if progress-*verifyProgress >= 0.01 {
*verifyProgress = progress
triggerOTAStateUpdate()
}
}
if er != nil {
if er == io.EOF {
break
}
return fmt.Errorf("error reading file: %w", er)
}
}
// close the file so we can rename below
if err := fileToHash.Close(); err != nil {
return fmt.Errorf("error closing file: %w", err)
}
hashSum := hex.EncodeToString(hash.Sum(nil))
scopedLogger.Info().Str("path", path).Str("hash", hashSum).Msg("SHA256 hash of")
if hashSum != expectedHash {
return fmt.Errorf("hash mismatch: %s != %s", hashSum, expectedHash)
}
if err := os.Rename(unverifiedPath, path); err != nil {
return fmt.Errorf("error renaming file: %w", err)
}
if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("error making file executable: %w", err)
}
return nil
updateStatus.Error = err.Error()
}
type OTAState struct {
Updating bool `json:"updating"`
Error string `json:"error,omitempty"`
MetadataFetchedAt *time.Time `json:"metadataFetchedAt,omitempty"`
AppUpdatePending bool `json:"appUpdatePending"`
SystemUpdatePending bool `json:"systemUpdatePending"`
AppDownloadProgress float32 `json:"appDownloadProgress,omitempty"` //TODO: implement for progress bar
AppDownloadFinishedAt *time.Time `json:"appDownloadFinishedAt,omitempty"`
SystemDownloadProgress float32 `json:"systemDownloadProgress,omitempty"` //TODO: implement for progress bar
SystemDownloadFinishedAt *time.Time `json:"systemDownloadFinishedAt,omitempty"`
AppVerificationProgress float32 `json:"appVerificationProgress,omitempty"`
AppVerifiedAt *time.Time `json:"appVerifiedAt,omitempty"`
SystemVerificationProgress float32 `json:"systemVerificationProgress,omitempty"`
SystemVerifiedAt *time.Time `json:"systemVerifiedAt,omitempty"`
AppUpdateProgress float32 `json:"appUpdateProgress,omitempty"` //TODO: implement for progress bar
AppUpdatedAt *time.Time `json:"appUpdatedAt,omitempty"`
SystemUpdateProgress float32 `json:"systemUpdateProgress,omitempty"` //TODO: port rk_ota, then implement
SystemUpdatedAt *time.Time `json:"systemUpdatedAt,omitempty"`
}
var otaState = OTAState{}
func triggerOTAStateUpdate() {
go func() {
if currentSession == nil {
logger.Info().Msg("No active RPC session, skipping update state update")
return
}
writeJSONRPCEvent("otaState", otaState, currentSession)
}()
}
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
scopedLogger := otaLogger.With().
Str("deviceId", deviceId).
Bool("includePreRelease", includePreRelease).
Logger()
scopedLogger.Info().Msg("Trying to update...")
if otaState.Updating {
return fmt.Errorf("update already in progress")
}
otaState = OTAState{
Updating: true,
}
triggerOTAStateUpdate()
defer func() {
otaState.Updating = false
triggerOTAStateUpdate()
}()
updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
if err != nil {
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
scopedLogger.Error().Err(err).Msg("Error checking for updates")
return fmt.Errorf("error checking for updates: %w", err)
}
now := time.Now()
otaState.MetadataFetchedAt = &now
otaState.AppUpdatePending = updateStatus.AppUpdateAvailable
otaState.SystemUpdatePending = updateStatus.SystemUpdateAvailable
triggerOTAStateUpdate()
local := updateStatus.Local
remote := updateStatus.Remote
appUpdateAvailable := updateStatus.AppUpdateAvailable
systemUpdateAvailable := updateStatus.SystemUpdateAvailable
rebootNeeded := false
if appUpdateAvailable {
scopedLogger.Info().
Str("local", local.AppVersion).
Str("remote", remote.AppVersion).
Msg("App update available")
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading app update")
triggerOTAStateUpdate()
return fmt.Errorf("error downloading app update: %w", err)
}
downloadFinished := time.Now()
otaState.AppDownloadFinishedAt = &downloadFinished
otaState.AppDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile(
"/userdata/jetkvm/jetkvm_app.update",
remote.AppHash,
&otaState.AppVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
triggerOTAStateUpdate()
return fmt.Errorf("error verifying app update: %w", err)
}
verifyFinished := time.Now()
otaState.AppVerifiedAt = &verifyFinished
otaState.AppVerificationProgress = 1
triggerOTAStateUpdate()
otaState.AppUpdatedAt = &verifyFinished
otaState.AppUpdateProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("App update downloaded")
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("App is up to date")
}
if systemUpdateAvailable {
scopedLogger.Info().
Str("local", local.SystemVersion).
Str("remote", remote.SystemVersion).
Msg("System update available")
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
if err != nil {
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
scopedLogger.Error().Err(err).Msg("Error downloading system update")
triggerOTAStateUpdate()
return fmt.Errorf("error downloading system update: %w", err)
}
downloadFinished := time.Now()
otaState.SystemDownloadFinishedAt = &downloadFinished
otaState.SystemDownloadProgress = 1
triggerOTAStateUpdate()
err = verifyFile(
"/userdata/jetkvm/update_system.tar",
remote.SystemHash,
&otaState.SystemVerificationProgress,
&scopedLogger,
)
if err != nil {
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
triggerOTAStateUpdate()
return fmt.Errorf("error verifying system update: %w", err)
}
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
otaState.SystemVerificationProgress = 1
triggerOTAStateUpdate()
scopedLogger.Info().Msg("Starting rk_ota command")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b
err = cmd.Start()
if err != nil {
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error starting rk_ota command: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
ticker := time.NewTicker(1800 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ticker.C:
if otaState.SystemUpdateProgress >= 0.99 {
return
}
otaState.SystemUpdateProgress += 0.01
if otaState.SystemUpdateProgress > 0.99 {
otaState.SystemUpdateProgress = 0.99
}
triggerOTAStateUpdate()
case <-ctx.Done():
return
}
}
}()
err = cmd.Wait()
cancel()
output := b.String()
if err != nil {
otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output)
scopedLogger.Error().
Err(err).
Str("output", output).
Int("exitCode", cmd.ProcessState.ExitCode()).
Msg("Error executing rk_ota command")
triggerOTAStateUpdate()
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
}
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
otaState.SystemUpdateProgress = 1
otaState.SystemUpdatedAt = &verifyFinished
rebootNeeded = true
triggerOTAStateUpdate()
} else {
scopedLogger.Info().Msg("System is up to date")
}
if rebootNeeded {
scopedLogger.Info().Msg("System Rebooting due to OTA update")
// Build redirect URL with conditional query parameters
redirectTo := "/settings/general/update"
queryParams := url.Values{}
if systemUpdateAvailable {
queryParams.Set("systemVersion", remote.SystemVersion)
}
if appUpdateAvailable {
queryParams.Set("appVersion", remote.AppVersion)
}
if len(queryParams) > 0 {
redirectTo += "?" + queryParams.Encode()
}
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectTo: redirectTo,
}
if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil {
return fmt.Errorf("error requesting reboot: %w", err)
}
}
return nil
}
func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) {
updateStatus := &UpdateStatus{}
// Get local versions
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil {
return updateStatus, fmt.Errorf("error getting local version: %w", err)
}
updateStatus.Local = &LocalMetadata{
AppVersion: appVersionLocal.String(),
SystemVersion: systemVersionLocal.String(),
}
// Get remote metadata
remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease)
if err != nil {
return updateStatus, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Remote = remoteMetadata
// Get remote versions
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
if err != nil {
return updateStatus, fmt.Errorf("error parsing remote system version: %w", err)
}
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
if err != nil {
return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
}
updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal)
updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal)
// Handle pre-release updates
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
if isRemoteSystemPreRelease && !includePreRelease {
updateStatus.SystemUpdateAvailable = false
}
if isRemoteAppPreRelease && !includePreRelease {
updateStatus.AppUpdateAvailable = false
}
logger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
return updateStatus, nil
}
func IsUpdatePending() bool {
return otaState.Updating
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
// make sure our current a/b partition is set as default
func confirmCurrentSystem() {
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
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 {
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
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

@ -242,6 +242,7 @@
"general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update",
"general_check_for_updates": "Check for Updates",
"general_check_for_stable_updates": "Downgrade",
"general_page_description": "Configure device settings and update preferences",
"general_reboot_description": "Do you want to proceed with rebooting the system?",
"general_reboot_device": "Reboot Device",
@ -897,5 +898,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

@ -0,0 +1,22 @@
import { cx } from "@/cva.config";
interface NestedSettingsGroupProps {
readonly children: React.ReactNode;
readonly className?: string;
}
export function NestedSettingsGroup(props: NestedSettingsGroupProps) {
const { children, className } = props;
return (
<div
className={cx(
"space-y-4 border-l-2 border-slate-200 ml-2 pl-4 dark:border-slate-700",
className,
)}
>
{children}
</div>
);
}

View File

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

View File

@ -1,4 +1,5 @@
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import semver from "semver";
import { useDeviceStore } from "@/hooks/stores";
import { JsonRpcError, RpcMethodNotFound } from "@/hooks/useJsonRpc";
@ -6,6 +7,21 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
export interface VersionInfo {
appVersion: string;
systemVersion: string;
}
export interface SystemVersionInfo {
local: VersionInfo;
remote?: VersionInfo;
systemUpdateAvailable: boolean;
systemDowngradeAvailable: boolean;
appUpdateAvailable: boolean;
appDowngradeAvailable: boolean;
error?: string;
}
export function useVersion() {
const {
appVersion,
@ -14,44 +30,51 @@ export function useVersion() {
setSystemVersion,
} = useDeviceStore();
const getVersionInfo = useCallback(async () => {
try {
const result = await getUpdateStatus();
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
return result;
} catch (error) {
const jsonRpcError = error as JsonRpcError;
notifications.error(m.updates_failed_check({ error: jsonRpcError.message }));
throw jsonRpcError;
if (result.error) {
notifications.error(m.updates_failed_check({ error: String(result.error) }));
reject(new Error("Failed to check for updates"));
} else {
resolve(result);
}
}, [setAppVersion, setSystemVersion]);
}
});
});
}, [send, setAppVersion, setSystemVersion]);
const isOnDevVersion = useMemo(() => {
if (appVersion && semver.prerelease(appVersion)) return true;
if (systemVersion && semver.prerelease(systemVersion)) return true;
return false;
}, [appVersion, systemVersion]);
const getLocalVersion = useCallback(() => {
return new Promise<VersionInfo>((resolve, reject) => {
send("getLocalVersion", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.log(resp.error)
if (resp.error.code === RpcMethodNotFound) {
console.warn("Failed to get device version, using legacy version");
return getVersionInfo().then(result => resolve(result.local)).catch(reject);
}
console.error("Failed to get device version N", resp.error);
notifications.error(m.updates_failed_get_device_version({ error: String(resp.error) }));
reject(new Error("Failed to get device version"));
} else {
const result = resp.result as VersionInfo;
const getLocalVersion = useCallback(async () => {
try {
const result = await getLocalVersionRpc();
setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion);
return result;
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
if (jsonRpcError.code === RpcMethodNotFound) {
console.error("Failed to get local version, using legacy remote version");
const result = await getVersionInfo();
return result.local;
resolve(result);
}
console.error("Failed to get device version", jsonRpcError);
notifications.error(m.updates_failed_get_device_version({ error: jsonRpcError.message }));
throw jsonRpcError;
}
}, [setAppVersion, setSystemVersion, getVersionInfo]);
});
});
}, [send, setAppVersion, setSystemVersion, getVersionInfo]);
return {
getVersionInfo,
getLocalVersion,
appVersion,
systemVersion,
isOnDevVersion,
};
}

View File

@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea";
import api from "@/api";
import notifications from "@/notifications";
@ -237,13 +238,11 @@ export default function SettingsAccessIndexRoute() {
</SettingsItem>
{tlsMode === "custom" && (
<div className="mt-4 space-y-4">
<div className="space-y-4">
<NestedSettingsGroup className="mt-4">
<SettingsItem
title={m.access_tls_certificate_title()}
description={m.access_tls_certificate_description()}
/>
<div className="space-y-4">
<TextAreaWithLabel
label={m.access_certificate_label()}
rows={3}
@ -253,10 +252,6 @@ export default function SettingsAccessIndexRoute() {
value={tlsCert}
onChange={e => handleTlsCertChange(e.target.value)}
/>
</div>
<div className="space-y-4">
<div className="space-y-4">
<TextAreaWithLabel
label={m.access_private_key_label()}
description={m.access_private_key_description()}
@ -267,9 +262,6 @@ export default function SettingsAccessIndexRoute() {
value={tlsKey}
onChange={e => handleTlsKeyChange(e.target.value)}
/>
</div>
</div>
</div>
<div className="flex items-center gap-x-2">
<Button
size="SM"
@ -278,7 +270,7 @@ export default function SettingsAccessIndexRoute() {
onClick={handleCustomTlsUpdate}
/>
</div>
</div>
</NestedSettingsGroup>
)}
<SettingsItem
@ -352,7 +344,7 @@ export default function SettingsAccessIndexRoute() {
</SettingsItem>
{selectedProvider === "custom" && (
<div className="mt-4 space-y-4">
<NestedSettingsGroup className="mt-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
@ -371,7 +363,7 @@ export default function SettingsAccessIndexRoute() {
placeholder="https://app.example.com"
/>
</div>
</div>
</NestedSettingsGroup>
)}
</>
)}

View File

@ -2,13 +2,17 @@ import { useCallback, useEffect, useState } from "react";
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";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { isOnDevice } from "@/main";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
@ -16,6 +20,7 @@ import { sleep } from "@/utils";
export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore();
@ -23,6 +28,11 @@ export default function SettingsAdvancedRoute() {
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const [updateTarget, setUpdateTarget] = useState<string>("app");
const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>("");
const [resetConfig, setResetConfig] = useState(false);
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
const settings = useSettingsStore();
@ -173,6 +183,35 @@ export default function SettingsAdvancedRoute() {
setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdate = useCallback(() => {
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?${pageParams.toString()}`);
});
}, [updateTarget, appVersion, systemVersion, devChannel, send, navigateTo, resetConfig]);
return (
<div className="space-y-4">
<SettingsPageHeader
@ -201,8 +240,8 @@ export default function SettingsAdvancedRoute() {
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
{settings.developerMode ? (
<NestedSettingsGroup>
<GridCard>
<div className="flex items-start gap-x-4 p-4 select-none">
<svg
@ -235,25 +274,13 @@ export default function SettingsAdvancedRoute() {
</div>
</div>
</GridCard>
)}
<SettingsItem
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
>
<Checkbox
checked={localLoopbackOnly}
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
{isOnDevice && settings.developerMode && (
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<div className="space-y-4">
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
@ -273,9 +300,100 @@ export default function SettingsAdvancedRoute() {
/>
</div>
</div>
</div>
)}
<div className="space-y-4">
<SettingsItem
title={m.advanced_version_update_title()}
description={m.advanced_version_update_description()}
/>
<SelectMenuBasic
label={m.advanced_version_update_target_label()}
options={[
{ value: "app", label: m.advanced_version_update_target_app() },
{ value: "system", label: m.advanced_version_update_target_system() },
{ value: "both", label: m.advanced_version_update_target_both() },
]}
value={updateTarget}
onChange={e => setUpdateTarget(e.target.value)}
/>
{(updateTarget === "app" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_app_label()}
placeholder="0.4.9"
value={appVersion}
onChange={e => setAppVersion(e.target.value)}
/>
)}
{(updateTarget === "system" || updateTarget === "both") && (
<InputFieldWithLabel
label={m.advanced_version_update_system_label()}
placeholder="0.4.9"
value={systemVersion}
onChange={e => setSystemVersion(e.target.value)}
/>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_version_update_helper()}{" "}
<a
href="https://github.com/jetkvm/kvm/releases"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-700 hover:underline dark:text-blue-500"
>
{m.advanced_version_update_github_link()}
</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>
<div>
<CheckboxWithLabel
label="I understand version changes may break my device and require factory reset"
checked={versionChangeAcknowledged}
onChange={e => setVersionChangeAcknowledged(e.target.checked)}
/>
</div>
<Button
size="SM"
theme="primary"
text={m.advanced_version_update_button()}
disabled={
(updateTarget === "app" && !appVersion) ||
(updateTarget === "system" && !systemVersion) ||
(updateTarget === "both" && (!appVersion || !systemVersion)) ||
!versionChangeAcknowledged
}
onClick={handleVersionUpdate}
/>
</div>
</NestedSettingsGroup>
) : null}
<SettingsItem
title={m.advanced_loopback_only_title()}
description={m.advanced_loopback_only_description()}
>
<Checkbox
checked={localLoopbackOnly}
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
/>
</SettingsItem>
<SettingsItem
title={m.advanced_troubleshooting_mode_title()}
description={m.advanced_troubleshooting_mode_description()}
@ -289,7 +407,7 @@ export default function SettingsAdvancedRoute() {
</SettingsItem>
{settings.debugMode && (
<>
<NestedSettingsGroup>
<SettingsItem
title={m.advanced_usb_emulation_title()}
description={m.advanced_usb_emulation_description()}
@ -320,7 +438,7 @@ export default function SettingsAdvancedRoute() {
}}
/>
</SettingsItem>
</>
</NestedSettingsGroup>
)}
</div>

View File

@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true);
const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state;
if (!appVersion || !systemVersion) return null;
@ -108,7 +107,7 @@ export default function SettingsGeneralRoute() {
</>
}
/>
<div>
<div className="flex items-center justify-start gap-x-2">
<Button
size="SM"
theme="light"

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";
@ -16,23 +16,34 @@ import { SystemVersionInfo } from "@/utils/jsonrpc";
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
await sleep(1000);
window.location.reload(); // force a full reload to ensure the current device/cloud UI version is loaded
}, [navigate]);
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");
@ -45,37 +56,56 @@ export default function SettingsGeneralUpdateRoute() {
}
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
return <Dialog onClose={onClose} 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>
@ -98,6 +128,13 @@ export function Dialog({
versionInfo={versionInfo!}
/>
)}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
versionInfo={versionInfo!}
/>
)}
{modalView === "updating" && (
<UpdatingDeviceState
@ -410,6 +447,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">

View File

@ -8,6 +8,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { UsbInfoSetting } from "@components/UsbInfoSetting";
import notifications from "@/notifications";
@ -156,7 +157,7 @@ export default function SettingsHardwareRoute() {
/>
</SettingsItem>
{backlightSettings.max_brightness != 0 && (
<>
<NestedSettingsGroup>
<SettingsItem
title={m.hardware_dim_display_after_title()}
description={m.hardware_dim_display_after_description()}
@ -198,7 +199,7 @@ export default function SettingsHardwareRoute() {
}}
/>
</SettingsItem>
</>
</NestedSettingsGroup>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.hardware_display_wake_up_note()}

View File

@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import Fieldset from "@components/Fieldset";
import notifications from "@/notifications";
import { m } from "@localizations/messages.js";
@ -174,7 +175,7 @@ export default function SettingsVideoRoute() {
description={m.video_enhancement_description()}
/>
<div className="space-y-4 pl-4">
<NestedSettingsGroup>
<SettingsItem
title={m.video_saturation_title()}
description={m.video_saturation_description({ value: videoSaturation.toFixed(1) })}
@ -232,7 +233,7 @@ export default function SettingsVideoRoute() {
}}
/>
</div>
</div>
</NestedSettingsGroup>
<Fieldset disabled={edidLoading} className="space-y-2">
<SettingsItem
title={m.video_edid_title()}

View File

@ -286,7 +286,7 @@ func newSession(config SessionConfig) (*Session, error) {
// Enqueue to ensure ordered processing
session.rpcQueue <- msg
})
triggerOTAStateUpdate()
triggerOTAStateUpdate(nil)
triggerVideoStateUpdate()
triggerUSBStateUpdate()
case "terminal":