mirror of https://github.com/jetkvm/kvm.git
Merge 0cc84f0c54 into 31ea366e51
This commit is contained in:
commit
0513328620
|
|
@ -10,5 +10,5 @@
|
|||
]
|
||||
},
|
||||
"git.ignoreLimitWarning": true,
|
||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo"
|
||||
"cmake.sourceDirectory": "internal/native/cgo"
|
||||
}
|
||||
13
config.go
13
config.go
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
|
|
@ -80,6 +81,7 @@ func (m *KeyboardMacro) Validate() error {
|
|||
|
||||
type Config struct {
|
||||
CloudURL string `json:"cloud_url"`
|
||||
UpdateAPIURL string `json:"update_api_url"`
|
||||
CloudAppURL string `json:"cloud_app_url"`
|
||||
CloudToken string `json:"cloud_token"`
|
||||
GoogleIdentity string `json:"google_identity"`
|
||||
|
|
@ -109,6 +111,15 @@ type Config struct {
|
|||
VideoQualityFactor float64 `json:"video_quality_factor"`
|
||||
}
|
||||
|
||||
// GetUpdateAPIURL returns the update API URL
|
||||
func (c *Config) GetUpdateAPIURL() string {
|
||||
if c.UpdateAPIURL == "" {
|
||||
return "https://api.jetkvm.com"
|
||||
}
|
||||
return strings.TrimSuffix(c.UpdateAPIURL, "/") + "/releases"
|
||||
}
|
||||
|
||||
// GetDisplayRotation returns the display rotation
|
||||
func (c *Config) GetDisplayRotation() uint16 {
|
||||
rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
|
||||
if err != nil {
|
||||
|
|
@ -118,6 +129,7 @@ func (c *Config) GetDisplayRotation() uint16 {
|
|||
return uint16(rotationInt)
|
||||
}
|
||||
|
||||
// SetDisplayRotation sets the display rotation
|
||||
func (c *Config) SetDisplayRotation(rotation string) error {
|
||||
_, err := strconv.ParseUint(rotation, 10, 16)
|
||||
if err != nil {
|
||||
|
|
@ -157,6 +169,7 @@ var (
|
|||
func getDefaultConfig() Config {
|
||||
return Config{
|
||||
CloudURL: "https://api.jetkvm.com",
|
||||
UpdateAPIURL: "https://api.jetkvm.com",
|
||||
CloudAppURL: "https://app.jetkvm.com",
|
||||
AutoUpdateEnabled: true, // Set a default value
|
||||
ActiveExtension: "",
|
||||
|
|
|
|||
4
hw.go
4
hw.go
|
|
@ -8,6 +8,8 @@ import (
|
|||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jetkvm/kvm/internal/ota"
|
||||
)
|
||||
|
||||
func extractSerialNumber() (string, error) {
|
||||
|
|
@ -37,7 +39,7 @@ func readOtpEntropy() ([]byte, error) { //nolint:unused
|
|||
return content[0x17:0x1C], nil
|
||||
}
|
||||
|
||||
func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
|
||||
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
|
||||
logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
|
||||
|
||||
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -0,0 +1,350 @@
|
|||
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.pending = true
|
||||
s.triggerComponentUpdateState("app", appUpdate)
|
||||
}
|
||||
|
||||
if shouldUpdateSystem && systemUpdate.available {
|
||||
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"`
|
||||
}
|
||||
|
||||
// getUpdateStatus gets the update status for the given components
|
||||
// and updates the componentUpdateStatuses map
|
||||
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 = ¤tAppUpdate
|
||||
}
|
||||
|
||||
if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
|
||||
systemUpdate = ¤tSystemUpdate
|
||||
}
|
||||
|
||||
err = s.checkUpdateStatus(ctx, params, appUpdate, systemUpdate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.componentUpdateStatuses["app"] = *appUpdate
|
||||
s.componentUpdateStatuses["system"] = *systemUpdate
|
||||
|
||||
return appUpdate, systemUpdate, nil
|
||||
}
|
||||
|
||||
// checkUpdateStatus checks the update status for the given components
|
||||
func (s *State) checkUpdateStatus(
|
||||
ctx context.Context,
|
||||
params UpdateParams,
|
||||
appUpdateStatus *componentUpdateStatus,
|
||||
systemUpdateStatus *componentUpdateStatus,
|
||||
) error {
|
||||
// Get local versions
|
||||
systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting local version: %w", err)
|
||||
}
|
||||
appUpdateStatus.localVersion = appVersionLocal.String()
|
||||
systemUpdateStatus.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
|
||||
}
|
||||
appUpdateStatus.url = remoteMetadata.AppURL
|
||||
appUpdateStatus.hash = remoteMetadata.AppHash
|
||||
appUpdateStatus.version = remoteMetadata.AppVersion
|
||||
|
||||
systemUpdateStatus.url = remoteMetadata.SystemURL
|
||||
systemUpdateStatus.hash = remoteMetadata.SystemHash
|
||||
systemUpdateStatus.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
|
||||
}
|
||||
systemUpdateStatus.available = systemVersionRemote.GreaterThan(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
|
||||
}
|
||||
appUpdateStatus.available = appVersionRemote.GreaterThan(appVersionLocal)
|
||||
|
||||
// Handle pre-release updates
|
||||
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
|
||||
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
|
||||
|
||||
if isRemoteSystemPreRelease && !params.IncludePreRelease {
|
||||
systemUpdateStatus.available = false
|
||||
}
|
||||
if isRemoteAppPreRelease && !params.IncludePreRelease {
|
||||
appUpdateStatus.available = false
|
||||
}
|
||||
|
||||
components := params.Components
|
||||
// skip check if no components are specified
|
||||
if len(components) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: simplify this
|
||||
if slices.Contains(components, "app") {
|
||||
if params.AppTargetVersion != "" {
|
||||
appUpdateStatus.available = appVersionRemote.String() != appVersionLocal.String()
|
||||
}
|
||||
} else {
|
||||
appUpdateStatus.available = false
|
||||
}
|
||||
|
||||
if slices.Contains(components, "system") {
|
||||
if params.SystemTargetVersion != "" {
|
||||
systemUpdateStatus.available = systemVersionRemote.String() != systemVersionLocal.String()
|
||||
}
|
||||
} else {
|
||||
systemUpdateStatus.available = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUpdateStatus returns the current update status (for backwards compatibility)
|
||||
func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
|
||||
appUpdateStatus := componentUpdateStatus{}
|
||||
systemUpdateStatus := componentUpdateStatus{}
|
||||
err := s.checkUpdateStatus(ctx, params, &appUpdateStatus, &systemUpdateStatus)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting update status: %w", err)
|
||||
}
|
||||
|
||||
return toUpdateStatus(&appUpdateStatus, &systemUpdateStatus, ""), nil
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package ota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func pseudoGetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
||||
systemVersion = semver.MustParse("0.2.5")
|
||||
appVersion = semver.MustParse("0.4.7")
|
||||
return systemVersion, appVersion, nil
|
||||
}
|
||||
|
||||
func newOtaState() *State {
|
||||
logger := zerolog.New(os.Stdout).Level(zerolog.TraceLevel)
|
||||
otaState := NewState(Options{
|
||||
SkipConfirmSystem: true,
|
||||
Logger: &logger,
|
||||
ReleaseAPIEndpoint: "https://api.jetkvm.com/releases",
|
||||
GetHTTPClient: func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
return client
|
||||
},
|
||||
GetLocalVersion: pseudoGetLocalVersion,
|
||||
HwReboot: func(force bool, postRebootAction *PostRebootAction, delay time.Duration) error { return nil },
|
||||
ResetConfig: func() error { return nil },
|
||||
OnStateUpdate: func(state *RPCState) {},
|
||||
OnProgressUpdate: func(progress float32) {},
|
||||
})
|
||||
return otaState
|
||||
}
|
||||
|
||||
func TestCheckUpdateComponents(t *testing.T) {
|
||||
otaState := newOtaState()
|
||||
updateParams := UpdateParams{
|
||||
DeviceID: "test",
|
||||
IncludePreRelease: false,
|
||||
SystemTargetVersion: "0.2.2",
|
||||
Components: []string{"system"},
|
||||
}
|
||||
info, err := otaState.GetUpdateStatus(context.Background(), updateParams)
|
||||
t.Logf("update status: %+v", info)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check update: %v", err)
|
||||
}
|
||||
assert.True(t, info.SystemUpdateAvailable)
|
||||
assert.False(t, info.AppUpdateAvailable)
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
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"`
|
||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||
|
||||
// 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
|
||||
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,
|
||||
AppUpdateAvailable: appUpdate.available,
|
||||
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
|
||||
SkipConfirmSystem bool
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
if !opts.SkipConfirmSystem {
|
||||
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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
54
jsonrpc.go
54
jsonrpc.go
|
|
@ -236,55 +236,6 @@ func rpcGetVideoLogStatus() (string, error) {
|
|||
return nativeInstance.VideoLogStatus()
|
||||
}
|
||||
|
||||
func rpcGetDevChannelState() (bool, error) {
|
||||
return config.IncludePreRelease, nil
|
||||
}
|
||||
|
||||
func rpcSetDevChannelState(enabled bool) error {
|
||||
config.IncludePreRelease = enabled
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
||||
includePreRelease := config.IncludePreRelease
|
||||
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
|
||||
// to ensure backwards compatibility,
|
||||
// if there's an error, we won't return an error, but we will set the error field
|
||||
if err != nil {
|
||||
if updateStatus == nil {
|
||||
return nil, fmt.Errorf("error checking for updates: %w", err)
|
||||
}
|
||||
updateStatus.Error = err.Error()
|
||||
}
|
||||
|
||||
return updateStatus, nil
|
||||
}
|
||||
|
||||
func rpcGetLocalVersion() (*LocalMetadata, error) {
|
||||
systemVersion, appVersion, err := GetLocalVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting local version: %w", err)
|
||||
}
|
||||
return &LocalMetadata{
|
||||
AppVersion: appVersion.String(),
|
||||
SystemVersion: systemVersion.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func rpcTryUpdate() error {
|
||||
includePreRelease := config.IncludePreRelease
|
||||
go func() {
|
||||
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to try update")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
||||
currentRotation := config.DisplayRotation
|
||||
if currentRotation == params.Rotation {
|
||||
|
|
@ -654,7 +605,7 @@ func rpcGetMassStorageMode() (string, error) {
|
|||
}
|
||||
|
||||
func rpcIsUpdatePending() (bool, error) {
|
||||
return IsUpdatePending(), nil
|
||||
return otaState.IsUpdatePending(), nil
|
||||
}
|
||||
|
||||
func rpcGetUsbEmulationState() (bool, error) {
|
||||
|
|
@ -1200,7 +1151,10 @@ var rpcHandlers = map[string]RPCHandler{
|
|||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
|
||||
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
|
||||
"tryUpdate": {Func: rpcTryUpdate},
|
||||
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
|
||||
"getDevModeState": {Func: rpcGetDevModeState},
|
||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||
|
|
|
|||
19
main.go
19
main.go
|
|
@ -9,6 +9,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/gwatts/rootcerts"
|
||||
"github.com/jetkvm/kvm/internal/ota"
|
||||
)
|
||||
|
||||
var appCtx context.Context
|
||||
|
|
@ -32,12 +33,6 @@ func Main() {
|
|||
Msg("starting JetKVM")
|
||||
|
||||
go runWatchdog()
|
||||
go confirmCurrentSystem()
|
||||
|
||||
initDisplay()
|
||||
initNative(systemVersionLocal, appVersionLocal)
|
||||
|
||||
http.DefaultClient.Timeout = 1 * time.Minute
|
||||
|
||||
err = rootcerts.UpdateDefaultTransport()
|
||||
if err != nil {
|
||||
|
|
@ -47,6 +42,13 @@ func Main() {
|
|||
Int("ca_certs_loaded", len(rootcerts.Certs())).
|
||||
Msg("loaded Root CA certificates")
|
||||
|
||||
initOta()
|
||||
|
||||
initDisplay()
|
||||
initNative(systemVersionLocal, appVersionLocal)
|
||||
|
||||
http.DefaultClient.Timeout = 1 * time.Minute
|
||||
|
||||
// Initialize network
|
||||
if err := initNetwork(); err != nil {
|
||||
logger.Error().Err(err).Msg("failed to initialize network")
|
||||
|
|
@ -106,7 +108,10 @@ func Main() {
|
|||
}
|
||||
|
||||
includePreRelease := config.IncludePreRelease
|
||||
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
||||
err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
|
||||
DeviceID: GetDeviceID(),
|
||||
IncludePreRelease: includePreRelease,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Warn().Err(err).Msg("failed to auto update")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/jetkvm/kvm/internal/confparser"
|
||||
"github.com/jetkvm/kvm/internal/mdns"
|
||||
"github.com/jetkvm/kvm/internal/network/types"
|
||||
"github.com/jetkvm/kvm/internal/ota"
|
||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||
)
|
||||
|
||||
|
|
@ -176,7 +177,7 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
|||
return nm.SetHostname(hostname, domain)
|
||||
}
|
||||
|
||||
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
|
||||
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) {
|
||||
oldDhcpClient := oldConfig.DHCPClient.String
|
||||
|
||||
l := networkLogger.With().
|
||||
|
|
@ -200,7 +201,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
|||
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
|
||||
|
||||
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
|
||||
postRebootAction = &PostRebootAction{
|
||||
postRebootAction = &ota.PostRebootAction{
|
||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||
}
|
||||
|
|
@ -217,7 +218,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
|||
// Handle IP change for redirect (only if both are not nil and IP changed)
|
||||
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
|
||||
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
|
||||
postRebootAction = &PostRebootAction{
|
||||
postRebootAction = &ota.PostRebootAction{
|
||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||
}
|
||||
|
|
|
|||
689
ota.go
689
ota.go
|
|
@ -1,59 +1,63 @@
|
|||
package kvm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Masterminds/semver/v3"
|
||||
"github.com/gwatts/rootcerts"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/jetkvm/kvm/internal/ota"
|
||||
)
|
||||
|
||||
type UpdateMetadata struct {
|
||||
AppVersion string `json:"appVersion"`
|
||||
AppUrl string `json:"appUrl"`
|
||||
AppHash string `json:"appHash"`
|
||||
SystemVersion string `json:"systemVersion"`
|
||||
SystemUrl string `json:"systemUrl"`
|
||||
SystemHash string `json:"systemHash"`
|
||||
}
|
||||
|
||||
type LocalMetadata struct {
|
||||
AppVersion string `json:"appVersion"`
|
||||
SystemVersion string `json:"systemVersion"`
|
||||
}
|
||||
|
||||
// UpdateStatus represents the current update status
|
||||
type UpdateStatus struct {
|
||||
Local *LocalMetadata `json:"local"`
|
||||
Remote *UpdateMetadata `json:"remote"`
|
||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
||||
|
||||
// for backwards compatibility
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
|
||||
|
||||
var builtAppVersion = "0.1.0+dev"
|
||||
|
||||
var otaState *ota.State
|
||||
|
||||
func initOta() {
|
||||
otaState = ota.NewState(ota.Options{
|
||||
Logger: otaLogger,
|
||||
ReleaseAPIEndpoint: config.GetUpdateAPIURL(),
|
||||
GetHTTPClient: func() *http.Client {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
return client
|
||||
},
|
||||
GetLocalVersion: GetLocalVersion,
|
||||
HwReboot: hwReboot,
|
||||
ResetConfig: rpcResetConfig,
|
||||
OnStateUpdate: func(state *ota.RPCState) {
|
||||
triggerOTAStateUpdate(state)
|
||||
},
|
||||
OnProgressUpdate: func(progress float32) {
|
||||
writeJSONRPCEvent("otaProgress", progress, currentSession)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func triggerOTAStateUpdate(state *ota.RPCState) {
|
||||
go func() {
|
||||
if currentSession == nil || (otaState == nil && state == nil) {
|
||||
return
|
||||
}
|
||||
if state == nil {
|
||||
state = otaState.ToRPCState()
|
||||
}
|
||||
writeJSONRPCEvent("otaState", state, currentSession)
|
||||
}()
|
||||
}
|
||||
|
||||
// GetBuiltAppVersion returns the built-in app version
|
||||
func GetBuiltAppVersion() string {
|
||||
return builtAppVersion
|
||||
}
|
||||
|
||||
// GetLocalVersion returns the local version of the system and app
|
||||
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
||||
appVersion, err = semver.NewVersion(builtAppVersion)
|
||||
if err != nil {
|
||||
|
|
@ -73,519 +77,120 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
|
|||
return systemVersion, appVersion, nil
|
||||
}
|
||||
|
||||
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
|
||||
metadata := &UpdateMetadata{}
|
||||
|
||||
updateUrl, err := url.Parse(UpdateMetadataUrl)
|
||||
func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
|
||||
updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
|
||||
DeviceID: GetDeviceID(),
|
||||
IncludePreRelease: includePreRelease,
|
||||
})
|
||||
// to ensure backwards compatibility,
|
||||
// if there's an error, we won't return an error, but we will set the error field
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
|
||||
}
|
||||
|
||||
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)
|
||||
if updateStatus == nil {
|
||||
return nil, fmt.Errorf("error checking for updates: %w", err)
|
||||
}
|
||||
updateStatus.Error = err.Error()
|
||||
}
|
||||
|
||||
unverifiedPath := path + ".unverified"
|
||||
if _, err := os.Stat(unverifiedPath); err == nil {
|
||||
if err := os.Remove(unverifiedPath); err != nil {
|
||||
return fmt.Errorf("error removing existing unverified file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(unverifiedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: rootcerts.ServerCertPool(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error downloading file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
if totalSize <= 0 {
|
||||
return fmt.Errorf("invalid content length")
|
||||
}
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := file.Write(buf[0:nr])
|
||||
if nw < nr {
|
||||
return fmt.Errorf("short file write: %d < %d", nw, nr)
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
return fmt.Errorf("error writing to file: %w", ew)
|
||||
}
|
||||
progress := float32(written) / float32(totalSize)
|
||||
if progress-*downloadProgress >= 0.01 {
|
||||
*downloadProgress = progress
|
||||
triggerOTAStateUpdate()
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("error reading response body: %w", er)
|
||||
}
|
||||
}
|
||||
|
||||
file.Close()
|
||||
|
||||
// Flush filesystem buffers to ensure all data is written to disk
|
||||
err = exec.Command("sync").Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error flushing filesystem buffers: %w", err)
|
||||
}
|
||||
|
||||
// Clear the filesystem caches to force a read from disk
|
||||
err = os.WriteFile("/proc/sys/vm/drop_caches", []byte("1"), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error clearing filesystem caches: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
|
||||
if scopedLogger == nil {
|
||||
scopedLogger = otaLogger
|
||||
}
|
||||
|
||||
unverifiedPath := path + ".unverified"
|
||||
fileToHash, err := os.Open(unverifiedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error opening file for hashing: %w", err)
|
||||
}
|
||||
defer fileToHash.Close()
|
||||
|
||||
hash := sha256.New()
|
||||
fileInfo, err := fileToHash.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting file info: %w", err)
|
||||
}
|
||||
totalSize := fileInfo.Size()
|
||||
|
||||
buf := make([]byte, 32*1024)
|
||||
verified := int64(0)
|
||||
|
||||
for {
|
||||
nr, er := fileToHash.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := hash.Write(buf[0:nr])
|
||||
if nw < nr {
|
||||
return fmt.Errorf("short hash write: %d < %d", nw, nr)
|
||||
}
|
||||
verified += int64(nw)
|
||||
if ew != nil {
|
||||
return fmt.Errorf("error writing to hash: %w", ew)
|
||||
}
|
||||
progress := float32(verified) / float32(totalSize)
|
||||
if progress-*verifyProgress >= 0.01 {
|
||||
*verifyProgress = progress
|
||||
triggerOTAStateUpdate()
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er == io.EOF {
|
||||
break
|
||||
}
|
||||
return fmt.Errorf("error reading file: %w", er)
|
||||
}
|
||||
}
|
||||
|
||||
// close the file so we can rename below
|
||||
if err := fileToHash.Close(); err != nil {
|
||||
return fmt.Errorf("error closing file: %w", err)
|
||||
}
|
||||
|
||||
hashSum := hex.EncodeToString(hash.Sum(nil))
|
||||
scopedLogger.Info().Str("path", path).Str("hash", hashSum).Msg("SHA256 hash of")
|
||||
|
||||
if hashSum != expectedHash {
|
||||
return fmt.Errorf("hash mismatch: %s != %s", hashSum, expectedHash)
|
||||
}
|
||||
|
||||
if err := os.Rename(unverifiedPath, path); err != nil {
|
||||
return fmt.Errorf("error renaming file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(path, 0755); err != nil {
|
||||
return fmt.Errorf("error making file executable: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
otaLogger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
|
||||
|
||||
return updateStatus, nil
|
||||
}
|
||||
|
||||
func IsUpdatePending() bool {
|
||||
return otaState.Updating
|
||||
func rpcGetDevChannelState() (bool, error) {
|
||||
return config.IncludePreRelease, nil
|
||||
}
|
||||
|
||||
// make sure our current a/b partition is set as default
|
||||
func confirmCurrentSystem() {
|
||||
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
|
||||
if err != nil {
|
||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
||||
func rpcSetDevChannelState(enabled bool) error {
|
||||
config.IncludePreRelease = enabled
|
||||
if err := SaveConfig(); err != nil {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rpcGetUpdateStatus() (*ota.UpdateStatus, error) {
|
||||
return getUpdateStatus(config.IncludePreRelease)
|
||||
}
|
||||
|
||||
func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) {
|
||||
switch channel {
|
||||
case "stable":
|
||||
return getUpdateStatus(false)
|
||||
case "dev":
|
||||
return getUpdateStatus(true)
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid channel: %s", channel)
|
||||
}
|
||||
}
|
||||
|
||||
func rpcGetLocalVersion() (*ota.LocalMetadata, error) {
|
||||
systemVersion, appVersion, err := GetLocalVersion()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting local version: %w", err)
|
||||
}
|
||||
return &ota.LocalMetadata{
|
||||
AppVersion: appVersion.String(),
|
||||
SystemVersion: systemVersion.String(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type updateParams struct {
|
||||
AppTargetVersion string `json:"appTargetVersion"`
|
||||
SystemTargetVersion string `json:"systemTargetVersion"`
|
||||
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 {
|
||||
otaLogger.Warn().Err(err).Msg("failed to try update")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,4 +280,4 @@ PION_LOG_TRACE=${LOG_TRACE_SCOPES} ./jetkvm_app_debug | tee -a /tmp/jetkvm_app_d
|
|||
EOF
|
||||
fi
|
||||
|
||||
echo "Deployment complete."
|
||||
echo "Deployment complete."
|
||||
|
|
@ -74,6 +74,7 @@
|
|||
"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_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_title": "Loopback-Only Mode",
|
||||
"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_usb_emulation_description": "Control the USB emulation state",
|
||||
"advanced_usb_emulation_title": "USB Emulation",
|
||||
"advanced_version_update_app_label": "App Version",
|
||||
"advanced_version_update_button": "Update to Version",
|
||||
"advanced_version_update_description": "Install a specific version from GitHub releases",
|
||||
"advanced_version_update_github_link": "JetKVM releases page",
|
||||
"advanced_version_update_helper": "Find available versions on the",
|
||||
"advanced_version_update_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_other_user": "This device is currently registered to another user in our cloud 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_error": "Failed to set auto-update: {error}",
|
||||
"general_auto_update_title": "Auto Update",
|
||||
"general_check_for_stable_updates": "Downgrade",
|
||||
"general_check_for_updates": "Check for Updates",
|
||||
"general_page_description": "Configure device settings and update preferences",
|
||||
"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_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_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_details": "Error details: {errorMessage}",
|
||||
"general_update_error_title": "Update Error",
|
||||
"general_update_keep_current_button": "Keep Current Version",
|
||||
"general_update_later_button": "Do it later",
|
||||
"general_update_now_button": "Update Now",
|
||||
"general_update_rebooting": "Rebooting to complete the update…",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -6,6 +6,19 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
|
|||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
export interface VersionInfo {
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
local: VersionInfo;
|
||||
remote?: VersionInfo;
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useVersion() {
|
||||
const {
|
||||
appVersion,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import api from "@/api";
|
||||
import notifications from "@/notifications";
|
||||
|
|
@ -237,39 +238,30 @@ export default function SettingsAccessIndexRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
{tlsMode === "custom" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={m.access_tls_certificate_title()}
|
||||
description={m.access_tls_certificate_description()}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={m.access_certificate_label()}
|
||||
rows={3}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
value={tlsCert}
|
||||
onChange={e => handleTlsCertChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={m.access_private_key_label()}
|
||||
description={m.access_private_key_description()}
|
||||
rows={3}
|
||||
placeholder={
|
||||
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
value={tlsKey}
|
||||
onChange={e => handleTlsKeyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NestedSettingsGroup className="mt-4">
|
||||
<SettingsItem
|
||||
title={m.access_tls_certificate_title()}
|
||||
description={m.access_tls_certificate_description()}
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label={m.access_certificate_label()}
|
||||
rows={3}
|
||||
placeholder={
|
||||
"-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
|
||||
}
|
||||
value={tlsCert}
|
||||
onChange={e => handleTlsCertChange(e.target.value)}
|
||||
/>
|
||||
<TextAreaWithLabel
|
||||
label={m.access_private_key_label()}
|
||||
description={m.access_private_key_description()}
|
||||
rows={3}
|
||||
placeholder={
|
||||
"-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||
}
|
||||
value={tlsKey}
|
||||
onChange={e => handleTlsKeyChange(e.target.value)}
|
||||
/>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
|
|
@ -278,7 +270,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
onClick={handleCustomTlsUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
|
|
@ -352,7 +344,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
{selectedProvider === "custom" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<NestedSettingsGroup className="mt-4">
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
|
|
@ -371,7 +363,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
placeholder="https://app.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,28 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
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 Checkbox from "@components/Checkbox";
|
||||
import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
|
||||
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||
import { GridCard } from "@components/Card";
|
||||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import { InputFieldWithLabel } from "@components/InputField";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { isOnDevice } from "@/main";
|
||||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { sleep } from "@/utils";
|
||||
import { checkUpdateComponents } from "@/utils/jsonrpc";
|
||||
import { SystemVersionInfo } from "@hooks/useVersion";
|
||||
|
||||
export default function SettingsAdvancedRoute() {
|
||||
const { send } = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [sshKey, setSSHKey] = useState<string>("");
|
||||
const { setDeveloperMode } = useSettingsStore();
|
||||
|
|
@ -23,7 +30,12 @@ export default function SettingsAdvancedRoute() {
|
|||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||
|
||||
const [updateTarget, setUpdateTarget] = useState<string>("app");
|
||||
const [appVersion, setAppVersion] = useState<string>("");
|
||||
const [systemVersion, setSystemVersion] = useState<string>("");
|
||||
const [resetConfig, setResetConfig] = useState(false);
|
||||
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
|
||||
const [versionUpdateLoading, setVersionUpdateLoading] = useState(false);
|
||||
const settings = useSettingsStore();
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -173,6 +185,68 @@ export default function SettingsAdvancedRoute() {
|
|||
setShowLoopbackWarning(false);
|
||||
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
||||
|
||||
const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
|
||||
notifications.error(
|
||||
m.advanced_error_version_update({
|
||||
error: typeof error === "string" ? 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(","),
|
||||
appTargetVersion: appVersion,
|
||||
systemTargetVersion: systemVersion,
|
||||
}, devChannel);
|
||||
console.log("versionInfo", versionInfo);
|
||||
} catch (error: unknown) {
|
||||
const jsonRpcError = error as JsonRpcError;
|
||||
handleVersionUpdateError(jsonRpcError);
|
||||
return;
|
||||
}
|
||||
|
||||
console.debug("versionInfo", versionInfo, components.includes("app") && versionInfo.remote?.appVersion && versionInfo?.appUpdateAvailable, components.includes("system") && versionInfo.remote?.systemVersion && versionInfo?.systemUpdateAvailable);
|
||||
console.debug("components", components);
|
||||
console.debug("versionInfo.remote?.appVersion", versionInfo.remote?.appVersion);
|
||||
console.debug("versionInfo.appUpdateAvailable", versionInfo?.appUpdateAvailable);
|
||||
console.debug("versionInfo.remote?.systemVersion", versionInfo.remote?.systemVersion);
|
||||
console.debug("versionInfo.systemUpdateAvailable", versionInfo?.systemUpdateAvailable);
|
||||
|
||||
let hasUpdate = false;
|
||||
|
||||
const pageParams = new URLSearchParams();
|
||||
if (components.includes("app") && versionInfo.remote?.appVersion && versionInfo.appUpdateAvailable) {
|
||||
hasUpdate = true;
|
||||
pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
|
||||
}
|
||||
if (components.includes("system") && versionInfo.remote?.systemVersion && versionInfo.systemUpdateAvailable) {
|
||||
hasUpdate = true;
|
||||
pageParams.set("custom_system_version", versionInfo.remote?.systemVersion);
|
||||
}
|
||||
pageParams.set("reset_config", resetConfig.toString());
|
||||
|
||||
if (!hasUpdate) {
|
||||
handleVersionUpdateError("No update available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Navigate to update page
|
||||
navigateTo(`/settings/general/update?${pageParams.toString()}`);
|
||||
}, [
|
||||
updateTarget, appVersion, systemVersion, devChannel,
|
||||
navigateTo, resetConfig, handleVersionUpdateError,
|
||||
setVersionUpdateLoading
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<SettingsPageHeader
|
||||
|
|
@ -201,41 +275,149 @@ export default function SettingsAdvancedRoute() {
|
|||
onChange={e => handleDevModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4 select-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{m.advanced_developer_mode_enabled_title()}
|
||||
</h3>
|
||||
<div>
|
||||
<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_risks()}</li>
|
||||
</ul>
|
||||
{settings.developerMode ? (
|
||||
<NestedSettingsGroup>
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4 select-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
|
||||
>
|
||||
<path
|
||||
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"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-base font-bold text-slate-900 dark:text-white">
|
||||
{m.advanced_developer_mode_enabled_title()}
|
||||
</h3>
|
||||
<div>
|
||||
<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_risks()}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
{m.advanced_developer_mode_warning_advanced()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-700 dark:text-slate-300">
|
||||
{m.advanced_developer_mode_warning_advanced()}
|
||||
</div>
|
||||
</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 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>
|
||||
</GridCard>
|
||||
)}
|
||||
</NestedSettingsGroup>
|
||||
) : null}
|
||||
|
||||
<SettingsItem
|
||||
title={m.advanced_loopback_only_title()}
|
||||
|
|
@ -247,34 +429,7 @@ export default function SettingsAdvancedRoute() {
|
|||
/>
|
||||
</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
|
||||
title={m.advanced_troubleshooting_mode_title()}
|
||||
|
|
@ -289,7 +444,7 @@ export default function SettingsAdvancedRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
{settings.debugMode && (
|
||||
<>
|
||||
<NestedSettingsGroup>
|
||||
<SettingsItem
|
||||
title={m.advanced_usb_emulation_title()}
|
||||
description={m.advanced_usb_emulation_description()}
|
||||
|
|
@ -320,7 +475,7 @@ export default function SettingsAdvancedRoute() {
|
|||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() {
|
|||
const { send } = useJsonRpc();
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||
|
||||
const currentVersions = useDeviceStore(state => {
|
||||
const { appVersion, systemVersion } = state;
|
||||
if (!appVersion || !systemVersion) return null;
|
||||
|
|
@ -48,10 +47,10 @@ export default function SettingsGeneralRoute() {
|
|||
const localeOptions = useMemo(() => {
|
||||
return ["", ...locales]
|
||||
.map((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)
|
||||
const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName;
|
||||
return { value: code, label: label }
|
||||
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)
|
||||
const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName;
|
||||
return { value: code, label: label }
|
||||
});
|
||||
}, [currentLocale]);
|
||||
|
||||
|
|
@ -108,7 +107,7 @@ export default function SettingsGeneralRoute() {
|
|||
</>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
theme="light"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||
import { UpdateState, useUpdateStore } from "@hooks/stores";
|
||||
|
|
@ -11,16 +11,21 @@ import LoadingSpinner from "@components/LoadingSpinner";
|
|||
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
|
||||
import { m } from "@localizations/messages.js";
|
||||
import { sleep } from "@/utils";
|
||||
import { SystemVersionInfo } from "@/utils/jsonrpc";
|
||||
import { checkUpdateComponents, SystemVersionInfo, updateParams } from "@/utils/jsonrpc";
|
||||
|
||||
export default function SettingsGeneralUpdateRoute() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { updateSuccess } = location.state || {};
|
||||
|
||||
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
|
||||
const { send } = useJsonRpc();
|
||||
|
||||
const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || undefined, [searchParams]);
|
||||
const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || undefined, [searchParams]);
|
||||
const resetConfig = useMemo(() => searchParams.get("reset_config") === "true", [searchParams]);
|
||||
|
||||
const onClose = useCallback(async () => {
|
||||
navigate(".."); // back to the devices.$id.settings page
|
||||
|
||||
|
|
@ -37,6 +42,25 @@ export default function SettingsGeneralUpdateRoute() {
|
|||
setModalView("updating");
|
||||
}, [send, setModalView, setShouldReload]);
|
||||
|
||||
const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => {
|
||||
const components = [];
|
||||
if (appTargetVersion) components.push("app");
|
||||
if (systemTargetVersion) components.push("system");
|
||||
|
||||
send("tryUpdateComponents", {
|
||||
params: {
|
||||
components: components.join(","),
|
||||
appTargetVersion,
|
||||
systemTargetVersion,
|
||||
},
|
||||
includePreRelease: false,
|
||||
resetConfig,
|
||||
}, (resp) => {
|
||||
if ("error" in resp) return;
|
||||
setModalView("updating");
|
||||
});
|
||||
}, [send, setModalView, resetConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (otaState.updating) {
|
||||
setModalView("updating");
|
||||
|
|
@ -49,20 +73,39 @@ export default function SettingsGeneralUpdateRoute() {
|
|||
}
|
||||
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
|
||||
|
||||
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
|
||||
return <Dialog
|
||||
onClose={onClose}
|
||||
onConfirmUpdate={onConfirmUpdate}
|
||||
onConfirmCustomUpdate={onConfirmCustomUpdate}
|
||||
customAppVersion={customAppVersion}
|
||||
customSystemVersion={customSystemVersion}
|
||||
/>;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
onClose,
|
||||
onConfirmUpdate,
|
||||
onConfirmCustomUpdate: onConfirmCustomUpdateCallback,
|
||||
customAppVersion,
|
||||
customSystemVersion,
|
||||
}: Readonly<{
|
||||
onClose: () => void;
|
||||
onConfirmUpdate: () => void;
|
||||
onConfirmCustomUpdate: (appVersion?: string, systemVersion?: string) => void;
|
||||
customAppVersion?: string;
|
||||
customSystemVersion?: string;
|
||||
}>) {
|
||||
const { navigateTo } = useDeviceUiNavigation();
|
||||
|
||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||
const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined;
|
||||
const onConfirmCustomUpdate = useCallback(() => {
|
||||
onConfirmCustomUpdateCallback(
|
||||
customAppVersion !== undefined ? versionInfo?.remote?.appVersion : undefined,
|
||||
customSystemVersion !== undefined ? versionInfo?.remote?.systemVersion : undefined,
|
||||
);
|
||||
}, [onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, versionInfo]);
|
||||
|
||||
const onFinishedLoading = useCallback(
|
||||
(versionInfo: SystemVersionInfo) => {
|
||||
|
|
@ -71,13 +114,13 @@ export function Dialog({
|
|||
|
||||
setVersionInfo(versionInfo);
|
||||
|
||||
if (hasUpdate) {
|
||||
if (hasUpdate || forceCustomUpdate) {
|
||||
setModalView("updateAvailable");
|
||||
} else {
|
||||
setModalView("upToDate");
|
||||
}
|
||||
},
|
||||
[setModalView],
|
||||
[setModalView, forceCustomUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -92,12 +135,18 @@ export function Dialog({
|
|||
)}
|
||||
|
||||
{modalView === "loading" && (
|
||||
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
|
||||
<LoadingState
|
||||
onFinished={onFinishedLoading}
|
||||
onCancelCheck={onClose}
|
||||
customAppVersion={customAppVersion}
|
||||
customSystemVersion={customSystemVersion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{modalView === "updateAvailable" && (
|
||||
<UpdateAvailableState
|
||||
onConfirmUpdate={onConfirmUpdate}
|
||||
forceCustomUpdate={forceCustomUpdate}
|
||||
onConfirm={forceCustomUpdate ? onConfirmCustomUpdate : onConfirmUpdate}
|
||||
onClose={onClose}
|
||||
versionInfo={versionInfo!}
|
||||
/>
|
||||
|
|
@ -126,9 +175,13 @@ export function Dialog({
|
|||
function LoadingState({
|
||||
onFinished,
|
||||
onCancelCheck,
|
||||
customAppVersion,
|
||||
customSystemVersion,
|
||||
}: {
|
||||
onFinished: (versionInfo: SystemVersionInfo) => void;
|
||||
onCancelCheck: () => void;
|
||||
customAppVersion?: string;
|
||||
customSystemVersion?: string;
|
||||
}) {
|
||||
const [progressWidth, setProgressWidth] = useState("0%");
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
|
@ -138,6 +191,22 @@ function LoadingState({
|
|||
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const checkUpdate = useCallback(async () => {
|
||||
if (!customAppVersion && !customSystemVersion) {
|
||||
return await getVersionInfo();
|
||||
}
|
||||
const params : updateParams = {
|
||||
components: "",
|
||||
appTargetVersion: customAppVersion,
|
||||
systemTargetVersion: customSystemVersion,
|
||||
};
|
||||
if (customAppVersion) params.components += ",app";
|
||||
if (customSystemVersion) params.components += ",system";
|
||||
params.components = params.components?.replace(/^,+/, "");
|
||||
|
||||
return await checkUpdateComponents(params, false);
|
||||
}, [customAppVersion, customSystemVersion, getVersionInfo]);
|
||||
|
||||
useEffect(() => {
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
|
@ -147,7 +216,7 @@ function LoadingState({
|
|||
setProgressWidth("100%");
|
||||
}, 0);
|
||||
|
||||
getVersionInfo()
|
||||
checkUpdate()
|
||||
.then(async versionInfo => {
|
||||
// Add a small delay to ensure it's not just flickering
|
||||
await sleep(600);
|
||||
|
|
@ -169,7 +238,7 @@ function LoadingState({
|
|||
clearTimeout(animationTimer);
|
||||
abortControllerRef.current?.abort();
|
||||
};
|
||||
}, [getVersionInfo, onFinished, setModalView]);
|
||||
}, [checkUpdate, onFinished, setModalView]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||
|
|
@ -377,11 +446,12 @@ function SystemUpToDateState({
|
|||
|
||||
function UpdateAvailableState({
|
||||
versionInfo,
|
||||
onConfirmUpdate,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
versionInfo: SystemVersionInfo;
|
||||
onConfirmUpdate: () => void;
|
||||
forceCustomUpdate: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
|
|
@ -396,18 +466,18 @@ function UpdateAvailableState({
|
|||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
|
||||
{versionInfo?.systemUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.remote?.systemVersion}
|
||||
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.systemVersion}
|
||||
<br />
|
||||
</>
|
||||
) : null}
|
||||
{versionInfo?.appUpdateAvailable ? (
|
||||
<>
|
||||
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.remote?.appVersion}
|
||||
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.appVersion}
|
||||
</>
|
||||
) : null}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-x-2">
|
||||
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirmUpdate} />
|
||||
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirm} />
|
||||
<Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||
import { UsbInfoSetting } from "@components/UsbInfoSetting";
|
||||
import notifications from "@/notifications";
|
||||
|
|
@ -156,7 +157,7 @@ export default function SettingsHardwareRoute() {
|
|||
/>
|
||||
</SettingsItem>
|
||||
{backlightSettings.max_brightness != 0 && (
|
||||
<>
|
||||
<NestedSettingsGroup>
|
||||
<SettingsItem
|
||||
title={m.hardware_dim_display_after_title()}
|
||||
description={m.hardware_dim_display_after_description()}
|
||||
|
|
@ -198,7 +199,7 @@ export default function SettingsHardwareRoute() {
|
|||
}}
|
||||
/>
|
||||
</SettingsItem>
|
||||
</>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{m.hardware_display_wake_up_note()}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
|||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import Fieldset from "@components/Fieldset";
|
||||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
|
@ -174,7 +175,7 @@ export default function SettingsVideoRoute() {
|
|||
description={m.video_enhancement_description()}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 pl-4">
|
||||
<NestedSettingsGroup>
|
||||
<SettingsItem
|
||||
title={m.video_saturation_title()}
|
||||
description={m.video_saturation_description({ value: videoSaturation.toFixed(1) })}
|
||||
|
|
@ -232,7 +233,7 @@ export default function SettingsVideoRoute() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
<Fieldset disabled={edidLoading} className="space-y-2">
|
||||
<SettingsItem
|
||||
title={m.video_edid_title()}
|
||||
|
|
|
|||
|
|
@ -242,3 +242,21 @@ export async function getLocalVersion() {
|
|||
if (response.error) throw response.error;
|
||||
return response.result;
|
||||
}
|
||||
|
||||
export interface updateParams {
|
||||
appTargetVersion?: string;
|
||||
systemTargetVersion?: 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;
|
||||
}
|
||||
Loading…
Reference in New Issue