Compare commits

...

18 Commits

Author SHA1 Message Date
Siyuan 1bca0c5e26 refactor: simplify version check and downgrade 2025-11-07 12:20:57 +00:00
Siyuan 329ad025bf fix: should return error if version is not available 2025-11-07 09:01:55 +00:00
Siyuan 252dcba7a1 fix: rename redirectUrl to redirectTo 2025-11-07 08:06:42 +00:00
Siyuan 7c5dfd9e50 chore: update messages 2025-11-07 08:04:23 +00:00
Adam Shiervani e0ff6716f1 feat: add acknowledgment checkbox for version changes in advanced settings 2025-11-07 08:04:23 +00:00
Adam Shiervani 32c66a3897 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 08:04:23 +00:00
Adam Shiervani daeb9e3599 Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 08:04:23 +00:00
Adam Shiervani ef7e662fdf Apply suggestion from @Copilot
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-07 08:04:23 +00:00
Siyuan 95e5e15226 feat: redirect to setup page after config reset 2025-11-07 08:04:23 +00:00
Adam Shiervani aa7c6fe082 feat: enhance version update settings with reset configuration option 2025-11-07 08:04:23 +00:00
Siyuan 1e9dcc1986 feat: allow configuration to be reset during update 2025-11-07 08:04:23 +00:00
Siyuan f6b0b7297d fix: update components 2025-11-07 08:04:23 +00:00
Siyuan 2b3f392f0f cleanup: ota state 2025-11-07 08:03:43 +00:00
Siyuan 0a98a73275 feat: downgrade 2025-11-07 08:03:43 +00:00
Adam Shiervani ab06e376d0 feat: add version update functionality to advanced settings 2025-11-07 08:03:18 +00:00
Adam Shiervani b0e659b76e refactor: mprove UI settings structure with NestedSettingsGroup 2025-11-07 08:03:18 +00:00
Siyuan 85f7f60618 WIP: OTA refactor 2025-11-07 08:03:18 +00:00
tadic-luka 36f06a064a
feat: add web route for sending WOL package to given mac addr (#945)
* feat: add web route for sending WOL package to given mac addr

```

adds a new route /device/send-wol/:mac-addr to send the magic WOL package
to the specified mac-addr.
Method is POST and is protected.

Useful for custom wake up scripts: example is sending HTTP request through iOS shortcut

Test plan:
calling the API with curl
```
$ curl -X POST   http://<jetkvm-ip>/device/send-wol/xx:xx:xx:xx:xx:xx
WOL sent to xx:xx:xx:xx:xx:xx
```

and observing the magic packet on my laptop/PC:
```
$ ncat -u -l 9 -k | xxd
00000000: ffff ffff ffff d050 9978 a620 d050 9978  .......P.x. .P.x
00000010: a620 d050 9978 a620 d050 9978 a620 d050  . .P.x. .P.x. .P
00000020: 9978 a620 d050 9978 a620 d050 9978 a620  .x. .P.x. .P.x.
00000030: d050 9978 a620 d050 9978 a620 d050 9978  .P.x. .P.x. .P.x
00000040: a620 d050 9978 a620 d050 9978 a620 d050  . .P.x. .P.x. .P
00000050: 9978 a620 d050 9978 a620 d050 9978 a620  .x. .P.x. .P.x.
```

calling the api with invalid mac addr returns HTTP 400 error
```
$ curl -X POST -v http://<jetkvm-ip>/device/send-wol/abcd
...
* Request completely sent off
< HTTP/1.1 400 Bad Request
...
...
Invalid mac address provided

* Resolve golint complaint

---------

Co-authored-by: Marc Brooks <IDisposable@gmail.com>
2025-11-06 21:39:22 +01:00
27 changed files with 1602 additions and 720 deletions

View File

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

4
hw.go
View File

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

8
internal/ota/errors.go Normal file
View File

@ -0,0 +1,8 @@
package ota
import "errors"
var (
// ErrVersionNotFound is returned when the specified version is not found
ErrVersionNotFound = errors.New("specified version not found")
)

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

@ -0,0 +1,328 @@
package ota
import (
"context"
"encoding/json"
"errors"
"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, bool) {
updateURL, err := url.Parse(s.releaseAPIEndpoint)
if err != nil {
return "", fmt.Errorf("error parsing update metadata URL: %w", err), false
}
isCustomVersion := false
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)
isCustomVersion = true
}
if params.SystemTargetVersion != "" {
query.Set("systemVersion", params.SystemTargetVersion)
isCustomVersion = true
}
updateURL.RawQuery = query.Encode()
return updateURL.String(), nil, isCustomVersion
}
func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
url, err, isCustomVersion := 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 isCustomVersion && resp.StatusCode == http.StatusNotFound {
return nil, ErrVersionNotFound
}
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",
RedirectTo: 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
}
err = s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate)
if err != nil {
return nil, nil, err
}
s.componentUpdateStatuses["app"] = *appUpdate
s.componentUpdateStatuses["system"] = *systemUpdate
return appUpdate, systemUpdate, nil
}
// doGetUpdateStatus is the internal function that gets the update status
// it WON'T change the state of the OTA state
func (s *State) doGetUpdateStatus(
ctx context.Context,
params UpdateParams,
appUpdate *componentUpdateStatus,
systemUpdate *componentUpdateStatus,
) error {
// Get local versions
systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
if err != nil {
return 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 {
if err == ErrVersionNotFound || errors.Unwrap(err) == ErrVersionNotFound {
err = ErrVersionNotFound
} else {
err = fmt.Errorf("error checking for updates: %w", err)
}
return err
}
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 err
}
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 err
}
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
}
return nil
}
// GetUpdateStatus returns the current update status (for backwards compatibility)
func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
appUpdate := &componentUpdateStatus{}
systemUpdate := &componentUpdateStatus{}
err := s.doGetUpdateStatus(ctx, params, appUpdate, systemUpdate)
if err != nil {
return nil, fmt.Errorf("error getting update status: %w", err)
}
return toUpdateStatus(appUpdate, systemUpdate, ""), nil
}

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

@ -0,0 +1,279 @@
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
RedirectTo string `json:"redirectTo"` // 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
}
func toUpdateStatus(appUpdate *componentUpdateStatus, systemUpdate *componentUpdateStatus, error string) *UpdateStatus {
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: error,
}
}
// 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 toUpdateStatus(&appUpdate, &systemUpdate, 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() 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 { func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation currentRotation := config.DisplayRotation
if currentRotation == params.Rotation { if currentRotation == params.Rotation {
@ -654,7 +605,7 @@ func rpcGetMassStorageMode() (string, error) {
} }
func rpcIsUpdatePending() (bool, error) { func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil return otaState.IsUpdatePending(), nil
} }
func rpcGetUsbEmulationState() (bool, error) { func rpcGetUsbEmulationState() (bool, error) {
@ -1200,7 +1151,11 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion}, "getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
"cancelDowngrade": {Func: rpcCancelDowngrade},
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},

19
main.go
View File

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

View File

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

699
ota.go
View File

@ -1,59 +1,63 @@
package kvm package kvm
import ( import (
"bytes"
"context" "context"
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/url"
"os" "os"
"os/exec"
"strings" "strings"
"time"
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/gwatts/rootcerts" "github.com/jetkvm/kvm/internal/ota"
"github.com/rs/zerolog"
) )
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 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 { func GetBuiltAppVersion() string {
return builtAppVersion return builtAppVersion
} }
// GetLocalVersion returns the local version of the system and app
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) { func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion) appVersion, err = semver.NewVersion(builtAppVersion)
if err != nil { if err != nil {
@ -73,519 +77,130 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
return systemVersion, appVersion, nil return systemVersion, appVersion, nil
} }
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) { func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
metadata := &UpdateMetadata{} updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
updateUrl, err := url.Parse(UpdateMetadataUrl) 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 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)
} }
updateStatus.Error = err.Error()
} }
unverifiedPath := path + ".unverified" logger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
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
}
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
}
return updateStatus, nil return updateStatus, nil
} }
func IsUpdatePending() bool { func rpcGetDevChannelState() (bool, error) {
return otaState.Updating return config.IncludePreRelease, nil
} }
// make sure our current a/b partition is set as default func rpcSetDevChannelState(enabled bool) error {
func confirmCurrentSystem() { config.IncludePreRelease = enabled
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput() if err := SaveConfig(); err != nil {
if err != nil { return fmt.Errorf("failed to save config: %w", err)
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup") }
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
}
type updateParams 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(updateParams{
AppTargetVersion: "",
SystemTargetVersion: "",
}, config.IncludePreRelease, false)
}
// rpcCheckUpdateComponents checks the update status for the given components
func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota.UpdateStatus, error) {
updateParams := ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
AppTargetVersion: params.AppTargetVersion,
SystemTargetVersion: params.SystemTargetVersion,
}
if params.Components != "" {
updateParams.Components = strings.Split(params.Components, ",")
}
info, err := otaState.GetUpdateStatus(context.Background(), updateParams)
if err != nil {
return nil, fmt.Errorf("failed to check update: %w", err)
}
return info, nil
}
func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetConfig bool) error {
updateParams := ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
ResetConfig: resetConfig,
}
updateParams.AppTargetVersion = params.AppTargetVersion
if err := otaState.SetTargetVersion("app", params.AppTargetVersion); err != nil {
return fmt.Errorf("failed to set app target version: %w", err)
}
updateParams.SystemTargetVersion = params.SystemTargetVersion
if err := otaState.SetTargetVersion("system", params.SystemTargetVersion); err != nil {
return fmt.Errorf("failed to set system target version: %w", err)
}
if params.Components != "" {
updateParams.Components = strings.Split(params.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

@ -280,4 +280,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d
EOF EOF
fi fi
echo "Deployment complete." echo "Deployment complete."

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}", "advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}", "advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}", "advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
"advanced_error_version_update": "Failed to initiate version update: {error}",
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)", "advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-Only Mode", "advanced_loopback_only_title": "Loopback-Only Mode",
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:", "advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Update SSH Key", "advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation", "advanced_usb_emulation_title": "USB Emulation",
"advanced_version_update_app_label": "App Version",
"advanced_version_update_button": "Update to Version",
"advanced_version_update_description": "Install a specific version from GitHub releases",
"advanced_version_update_github_link": "JetKVM releases page",
"advanced_version_update_helper": "Find available versions on the",
"advanced_version_update_reset_config_description": "Reset configuration after the update",
"advanced_version_update_reset_config_label": "Reset configuration",
"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",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.", "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.", "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard", "already_adopted_return_to_dashboard": "Return to Dashboard",
@ -241,6 +255,7 @@
"general_auto_update_description": "Automatically update the device to the latest version", "general_auto_update_description": "Automatically update the device to the latest version",
"general_auto_update_error": "Failed to set auto-update: {error}", "general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update", "general_auto_update_title": "Auto Update",
"general_check_for_stable_updates": "Downgrade",
"general_check_for_updates": "Check for Updates", "general_check_for_updates": "Check for Updates",
"general_page_description": "Configure device settings and update preferences", "general_page_description": "Configure device settings and update preferences",
"general_reboot_description": "Do you want to proceed with rebooting the system?", "general_reboot_description": "Do you want to proceed with rebooting the system?",
@ -261,9 +276,13 @@
"general_update_checking_title": "Checking for updates…", "general_update_checking_title": "Checking for updates…",
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!", "general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
"general_update_completed_title": "Update Completed Successfully", "general_update_completed_title": "Update Completed Successfully",
"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_error_description": "An error occurred while updating your device. Please try again later.", "general_update_error_description": "An error occurred while updating your device. Please try again later.",
"general_update_error_details": "Error details: {errorMessage}", "general_update_error_details": "Error details: {errorMessage}",
"general_update_error_title": "Update Error", "general_update_error_title": "Update Error",
"general_update_keep_current_button": "Keep Current Version",
"general_update_later_button": "Do it later", "general_update_later_button": "Do it later",
"general_update_now_button": "Update Now", "general_update_now_button": "Update Now",
"general_update_rebooting": "Rebooting to complete the update…", "general_update_rebooting": "Rebooting to complete the update…",

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" | "updating"
| "upToDate" | "upToDate"
| "updateAvailable" | "updateAvailable"
| "updateDowngradeAvailable"
| "updateCompleted" | "updateCompleted"
| "error"; | "error";

View File

@ -6,6 +6,21 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; 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() { export function useVersion() {
const { const {
appVersion, appVersion,

View File

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

View File

@ -1,21 +1,28 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores"; import { useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Checkbox from "@components/Checkbox"; import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog"; import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { isOnDevice } from "@/main"; import { isOnDevice } from "@/main";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils"; import { sleep } from "@/utils";
import { checkUpdateComponents } from "@/utils/jsonrpc";
import { SystemVersionInfo } from "@hooks/useVersion";
export default function SettingsAdvancedRoute() { export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [sshKey, setSSHKey] = useState<string>(""); const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore(); const { setDeveloperMode } = useSettingsStore();
@ -23,7 +30,12 @@ export default function SettingsAdvancedRoute() {
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = 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 [versionUpdateLoading, setVersionUpdateLoading] = useState(false);
const settings = useSettingsStore(); const settings = useSettingsStore();
useEffect(() => { useEffect(() => {
@ -173,6 +185,58 @@ export default function SettingsAdvancedRoute() {
setShowLoopbackWarning(false); setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]); }, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
const handleVersionUpdateError = useCallback((error?: JsonRpcError) => {
notifications.error(
m.advanced_error_version_update({
error: error?.data ?? error?.message ?? m.unknown_error()
}),
{ duration: 1000 * 15 } // 15 seconds
);
setVersionUpdateLoading(false);
}, []);
const handleVersionUpdate = useCallback(async () => {
const components = updateTarget === "both" ? ["app", "system"] : [updateTarget];
let versionInfo: SystemVersionInfo | undefined;
try {
// we do not need to set it to false if check succeeds,
// because it will be redirected to the update page later
setVersionUpdateLoading(true);
versionInfo = await checkUpdateComponents({
components: components.join(","),
app: appVersion,
system: systemVersion,
}, devChannel);
console.log("versionInfo", versionInfo);
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
handleVersionUpdateError(jsonRpcError);
return ;
}
if (!versionInfo) {
handleVersionUpdateError();
return;
}
const pageParams = new URLSearchParams();
pageParams.set("downgrade", "true");
if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appDowngradeAvailable) {
pageParams.set("app", versionInfo.remote?.appVersion);
}
if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemDowngradeAvailable) {
pageParams.set("system", versionInfo.remote?.systemVersion);
}
pageParams.set("resetConfig", resetConfig.toString());
// Navigate to update page
navigateTo(`/settings/general/update?${pageParams.toString()}`);
}, [
updateTarget, appVersion, systemVersion, devChannel,
navigateTo, resetConfig, handleVersionUpdateError,
setVersionUpdateLoading
]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -201,41 +265,149 @@ export default function SettingsAdvancedRoute() {
onChange={e => handleDevModeChange(e.target.checked)} onChange={e => handleDevModeChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
{settings.developerMode ? (
{settings.developerMode && ( <NestedSettingsGroup>
<GridCard> <GridCard>
<div className="flex items-start gap-x-4 p-4 select-none"> <div className="flex items-start gap-x-4 p-4 select-none">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500" className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.advanced_developer_mode_enabled_title()} {m.advanced_developer_mode_enabled_title()}
</h3> </h3>
<div> <div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300"> <ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_developer_mode_warning_security()}</li> <li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li> <li>{m.advanced_developer_mode_warning_risks()}</li>
</ul> </ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
{m.advanced_developer_mode_warning_advanced()}
</div> </div>
</div> </div>
<div className="text-xs text-slate-700 dark:text-slate-300"> </div>
{m.advanced_developer_mode_warning_advanced()} </GridCard>
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div> </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 ||
versionUpdateLoading
}
loading={versionUpdateLoading}
onClick={handleVersionUpdate}
/>
</div> </div>
</GridCard> </NestedSettingsGroup>
)} ) : null}
<SettingsItem <SettingsItem
title={m.advanced_loopback_only_title()} title={m.advanced_loopback_only_title()}
@ -247,34 +419,7 @@ export default function SettingsAdvancedRoute() {
/> />
</SettingsItem> </SettingsItem>
{isOnDevice && settings.developerMode && (
<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 || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div>
</div>
</div>
)}
<SettingsItem <SettingsItem
title={m.advanced_troubleshooting_mode_title()} title={m.advanced_troubleshooting_mode_title()}
@ -289,7 +434,7 @@ export default function SettingsAdvancedRoute() {
</SettingsItem> </SettingsItem>
{settings.debugMode && ( {settings.debugMode && (
<> <NestedSettingsGroup>
<SettingsItem <SettingsItem
title={m.advanced_usb_emulation_title()} title={m.advanced_usb_emulation_title()}
description={m.advanced_usb_emulation_description()} description={m.advanced_usb_emulation_description()}
@ -320,7 +465,7 @@ export default function SettingsAdvancedRoute() {
}} }}
/> />
</SettingsItem> </SettingsItem>
</> </NestedSettingsGroup>
)} )}
</div> </div>

View File

@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const [autoUpdate, setAutoUpdate] = useState(true); const [autoUpdate, setAutoUpdate] = useState(true);
const currentVersions = useDeviceStore(state => { const currentVersions = useDeviceStore(state => {
const { appVersion, systemVersion } = state; const { appVersion, systemVersion } = state;
if (!appVersion || !systemVersion) return null; if (!appVersion || !systemVersion) return null;
@ -48,10 +47,10 @@ export default function SettingsGeneralRoute() {
const localeOptions = useMemo(() => { const localeOptions = useMemo(() => {
return ["", ...locales] return ["", ...locales]
.map((code) => { .map((code) => {
const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code); const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code);
// don't repeat the name if it's the same in both locales (or blank) // don't repeat the name if it's the same in both locales (or blank)
const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName; const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName;
return { value: code, label: label } return { value: code, label: label }
}); });
}, [currentLocale]); }, [currentLocale]);
@ -108,7 +107,7 @@ export default function SettingsGeneralRoute() {
</> </>
} }
/> />
<div> <div className="flex items-center justify-start gap-x-2">
<Button <Button
size="SM" size="SM"
theme="light" theme="light"

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores"; import { UpdateState, useUpdateStore } from "@hooks/stores";
@ -16,11 +16,17 @@ import { SystemVersionInfo } from "@/utils/jsonrpc";
export default function SettingsGeneralUpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
const { updateSuccess } = location.state || {}; const { updateSuccess } = location.state || {};
const { setModalView, otaState } = useUpdateStore(); const { setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const downgrade = useMemo(() => searchParams.get("downgrade") === "true", [searchParams]);
const customAppVersion = useMemo(() => searchParams.get("app") || "", [searchParams]);
const customSystemVersion = useMemo(() => searchParams.get("system") || "", [searchParams]);
const resetConfig = useMemo(() => searchParams.get("resetConfig") === "true", [searchParams]);
const onClose = useCallback(async () => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
// Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation. // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
@ -33,6 +39,29 @@ export default function SettingsGeneralUpdateRoute() {
setModalView("updating"); setModalView("updating");
}, [send, setModalView]); }, [send, setModalView]);
const onConfirmDowngrade = useCallback(() => {
const components = [];
if (customSystemVersion) {
components.push("system");
}
if (customAppVersion) {
components.push("app");
}
send("tryUpdateComponents", {
params: {
components: components.join(","),
app: customAppVersion,
system: customSystemVersion,
},
includePreRelease: false,
resetConfig,
}, (resp) => {
if ("error" in resp) return;
setModalView("updating");
});
}, [send, setModalView, customAppVersion, customSystemVersion, resetConfig]);
useEffect(() => { useEffect(() => {
if (otaState.updating) { if (otaState.updating) {
setModalView("updating"); setModalView("updating");
@ -45,37 +74,61 @@ export default function SettingsGeneralUpdateRoute() {
} }
}, [otaState.error, otaState.updating, setModalView, updateSuccess]); }, [otaState.error, otaState.updating, setModalView, updateSuccess]);
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />; return <Dialog
onClose={onClose}
onConfirmUpdate={onConfirmUpdate}
onConfirmDowngrade={onConfirmDowngrade}
downgrade={downgrade}
customAppVersion={customAppVersion}
customSystemVersion={customSystemVersion}
/>;
} }
export function Dialog({ export function Dialog({
onClose, onClose,
onConfirmUpdate, onConfirmUpdate,
onConfirmDowngrade,
downgrade,
customAppVersion,
customSystemVersion,
}: Readonly<{ }: Readonly<{
downgrade: boolean;
onClose: () => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
onConfirmDowngrade: () => void;
customAppVersion?: string;
customSystemVersion?: string;
}>) { }>) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
const { send } = useJsonRpc();
const onFinishedLoading = useCallback( const onFinishedLoading = useCallback(
(versionInfo: SystemVersionInfo) => { (versionInfo: SystemVersionInfo) => {
const hasUpdate = const hasUpdate =
versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable; versionInfo?.systemUpdateAvailable || versionInfo?.appUpdateAvailable;
const hasDowngrade = customSystemVersion !== undefined || customAppVersion !== undefined;
setVersionInfo(versionInfo); setVersionInfo(versionInfo);
if (hasUpdate) { if (hasDowngrade && downgrade) {
setModalView("updateDowngradeAvailable");
} else if (hasUpdate) {
setModalView("updateAvailable"); setModalView("updateAvailable");
} else { } else {
setModalView("upToDate"); setModalView("upToDate");
} }
}, },
[setModalView], [setModalView, downgrade, customAppVersion, customSystemVersion],
); );
const onCancelDowngrade = useCallback(() => {
send("cancelDowngrade", {});
onClose();
}, [onClose, send]);
return ( return (
<div className="pointer-events-auto relative mx-auto text-left"> <div className="pointer-events-auto relative mx-auto text-left">
<div> <div>
@ -98,6 +151,14 @@ export function Dialog({
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
)} )}
{modalView === "updateDowngradeAvailable" && (
<UpdateDowngradeAvailableState
appVersion={customAppVersion}
systemVersion={customSystemVersion}
onConfirmDowngrade={onConfirmDowngrade}
onCancelDowngrade={onCancelDowngrade}
/>
)}
{modalView === "updating" && ( {modalView === "updating" && (
<UpdatingDeviceState <UpdatingDeviceState
@ -410,6 +471,51 @@ function UpdateAvailableState({
); );
} }
function UpdateDowngradeAvailableState({
appVersion,
systemVersion,
onConfirmDowngrade,
onCancelDowngrade,
}: {
appVersion?: string;
systemVersion?: string;
onConfirmDowngrade: () => void;
onCancelDowngrade: () => void;
}) {
const confirmDowngrade = useCallback(() => {
onConfirmDowngrade();
}, [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">
{systemVersion ? (
<>
<span className="font-semibold">{m.general_update_system_type()}</span>: {systemVersion}
<br />
</>
) : null}
{appVersion ? (
<>
<span className="font-semibold">{m.general_update_application_type()}</span>: {appVersion}
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text={m.general_update_downgrade_button()} onClick={confirmDowngrade} />
<Button size="SM" theme="light" text={m.general_update_keep_current_button()} onClick={onCancelDowngrade} />
</div>
</div>
</div>
);
}
function UpdateCompletedState({ onClose }: { onClose: () => void }) { function UpdateCompletedState({ onClose }: { onClose: () => void }) {
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">

View File

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

View File

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

View File

@ -220,7 +220,9 @@ export interface SystemVersionInfo {
local: VersionInfo; local: VersionInfo;
remote?: VersionInfo; remote?: VersionInfo;
systemUpdateAvailable: boolean; systemUpdateAvailable: boolean;
systemDowngradeAvailable: boolean;
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
appDowngradeAvailable: boolean;
error?: string; error?: string;
} }
@ -242,3 +244,21 @@ export async function getLocalVersion() {
if (response.error) throw response.error; if (response.error) throw response.error;
return response.result; return response.result;
} }
export interface updateParams {
app?: string;
system?: string;
components?: string;
}
export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
const response = await callJsonRpc<SystemVersionInfo>({
method: "checkUpdateComponents",
params: {
params,
includePreRelease,
},
});
if (response.error) throw response.error;
return response.result;
}

24
web.go
View File

@ -8,6 +8,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io/fs" "io/fs"
"net"
"net/http" "net/http"
"net/http/pprof" "net/http/pprof"
"path/filepath" "path/filepath"
@ -184,6 +185,8 @@ func setupRouter() *gin.Engine {
protected.PUT("/auth/password-local", handleUpdatePassword) protected.PUT("/auth/password-local", handleUpdatePassword)
protected.DELETE("/auth/local-password", handleDeletePassword) protected.DELETE("/auth/local-password", handleDeletePassword)
protected.POST("/storage/upload", handleUploadHttp) protected.POST("/storage/upload", handleUploadHttp)
protected.POST("/device/send-wol/:mac-addr", handleSendWOLMagicPacket)
} }
// Catch-all route for SPA // Catch-all route for SPA
@ -341,7 +344,6 @@ func handleWebRTCSignalWsMessages(
l.Trace().Msg("sending ping frame") l.Trace().Msg("sending ping frame")
err := wsCon.Ping(runCtx) err := wsCon.Ping(runCtx)
if err != nil { if err != nil {
l.Warn().Str("error", err.Error()).Msg("websocket ping error") l.Warn().Str("error", err.Error()).Msg("websocket ping error")
cancelRun() cancelRun()
@ -807,3 +809,23 @@ func handleSetup(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Device setup completed successfully"})
} }
func handleSendWOLMagicPacket(c *gin.Context) {
inputMacAddr := c.Param("mac-addr")
macAddr, err := net.ParseMAC(inputMacAddr)
if err != nil {
logger.Warn().Err(err).Str("sendWol", inputMacAddr).Msg("Invalid mac address provided")
c.String(http.StatusBadRequest, "Invalid mac address provided")
return
}
macAddrString := macAddr.String()
err = rpcSendWOLMagicPacket(macAddrString)
if err != nil {
logger.Warn().Err(err).Str("sendWOL", macAddrString).Msg("Failed to send WOL magic packet")
c.String(http.StatusInternalServerError, "Failed to send WOL to %s: %v", macAddrString, err)
return
}
c.String(http.StatusOK, "WOL sent to %s ", macAddr)
}

View File

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