refactor: OTA (#912)

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
This commit is contained in:
Aveline 2025-11-19 15:20:59 +01:00 committed by GitHub
parent d49e2680d0
commit 752fb55799
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 2684 additions and 748 deletions

10
.vscode/settings.json vendored
View File

@ -11,5 +11,13 @@
}, },
"git.ignoreLimitWarning": true, "git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo", "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
"cmake.ignoreCMakeListsMissing": true "cmake.ignoreCMakeListsMissing": true,
"json.schemas": [
{
"fileMatch": [
"/internal/ota/testdata/ota/*.json"
],
"url": "./internal/ota/testdata/ota.schema.json"
}
]
} }

View File

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

18
hw.go
View File

@ -8,6 +8,8 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/jetkvm/kvm/internal/ota"
) )
func extractSerialNumber() (string, error) { func extractSerialNumber() (string, error) {
@ -29,22 +31,16 @@ func extractSerialNumber() (string, error) {
return matches[1], nil return matches[1], nil
} }
func readOtpEntropy() ([]byte, error) { //nolint:unused func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem") logger.Info().Dur("delayMs", delay).Msg("reboot requested")
if err != nil {
return nil, err
}
return content[0x17:0x1C], nil
}
func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
writeJSONRPCEvent("willReboot", postRebootAction, currentSession) writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen") nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
time.Sleep(delay - (1 * time.Second)) // wait requested extra settle time if delay > 1*time.Second {
time.Sleep(delay - 1*time.Second) // wait requested extra settle time
}
args := []string{} args := []string{}
if force { if force {

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

@ -0,0 +1,45 @@
package ota
import (
"context"
"time"
)
const (
appUpdatePath = "/userdata/jetkvm/jetkvm_app.update"
)
// DO NOT call it directly, it's not thread safe
// Mutex is currently held by the caller, e.g. doUpdate
func (s *State) updateApp(ctx context.Context, appUpdate *componentUpdateStatus) error {
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
}

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

@ -0,0 +1,24 @@
package ota
import (
"errors"
"fmt"
"github.com/rs/zerolog"
)
var (
// ErrVersionNotFound is returned when the specified version is not found
ErrVersionNotFound = errors.New("specified version not found")
)
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)
s.updating = false
s.triggerStateUpdate()
return err
}

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

@ -0,0 +1,429 @@
package ota
import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptrace"
"net/url"
"time"
"github.com/rs/zerolog"
)
// HttpClient is the interface for the HTTP client
type HttpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// 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
query := updateURL.Query()
query.Set("deviceId", params.DeviceID)
query.Set("prerelease", fmt.Sprintf("%v", params.IncludePreRelease))
// set the custom versions if they are specified
for component, constraint := range params.Components {
if constraint == "" {
continue
}
query.Set(component+"Version", constraint)
isCustomVersion = true
}
updateURL.RawQuery = query.Encode()
return updateURL.String(), nil, isCustomVersion
}
// newHTTPRequestWithTrace creates a new HTTP request with a trace logger
// TODO: use OTEL instead of doing this manually
func (s *State) newHTTPRequestWithTrace(ctx context.Context, method, url string, body io.Reader, logger func() *zerolog.Event) (*http.Request, error) {
localCtx := ctx
if s.l.GetLevel() <= zerolog.TraceLevel {
if logger == nil {
logger = func() *zerolog.Event { return s.l.Trace() }
}
l := func() *zerolog.Event { return logger().Str("url", url).Str("method", method) }
localCtx = httptrace.WithClientTrace(localCtx, &httptrace.ClientTrace{
GetConn: func(hostPort string) { l().Str("hostPort", hostPort).Msg("[conn] starting to create conn") },
GotConn: func(info httptrace.GotConnInfo) { l().Interface("info", info).Msg("[conn] connection established") },
PutIdleConn: func(err error) { l().Err(err).Msg("[conn] connection returned to idle pool") },
GotFirstResponseByte: func() { l().Msg("[resp] first response byte received") },
Got100Continue: func() { l().Msg("[resp] 100 continue received") },
DNSStart: func(info httptrace.DNSStartInfo) { l().Interface("info", info).Msg("[dns] starting to look up dns") },
DNSDone: func(info httptrace.DNSDoneInfo) { l().Interface("info", info).Msg("[dns] done looking up dns") },
ConnectStart: func(network, addr string) {
l().Str("network", network).Str("addr", addr).Msg("[tcp] starting tcp connection")
},
ConnectDone: func(network, addr string, err error) {
l().Str("network", network).Str("addr", addr).Err(err).Msg("[tcp] tcp connection created")
},
TLSHandshakeStart: func() { l().Msg("[tls] handshake started") },
TLSHandshakeDone: func(state tls.ConnectionState, err error) {
l().
Str("tlsVersion", tls.VersionName(state.Version)).
Str("cipherSuite", tls.CipherSuiteName(state.CipherSuite)).
Str("negotiatedProtocol", state.NegotiatedProtocol).
Str("serverName", state.ServerName).
Err(err).Msg("[tls] handshake done")
},
})
}
return http.NewRequestWithContext(localCtx, method, url, body)
}
func (s *State) fetchUpdateMetadata(ctx context.Context, params UpdateParams) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
logger := s.l.With().Logger()
if params.RequestID != "" {
logger = logger.With().Str("requestID", params.RequestID).Logger()
}
t := time.Now()
traceLogger := func() *zerolog.Event {
return logger.Trace().Dur("duration", time.Since(t))
}
url, err, isCustomVersion := s.getUpdateURL(params)
traceLogger().Err(err).
Msg("fetchUpdateMetadata: getUpdateURL")
if err != nil {
return nil, fmt.Errorf("error getting update URL: %w", err)
}
traceLogger().
Str("url", url).
Msg("fetching update metadata")
req, err := s.newHTTPRequestWithTrace(ctx, "GET", url, nil, traceLogger)
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()
traceLogger().
Int("status", resp.StatusCode).
Msg("fetchUpdateMetadata: response")
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)
}
traceLogger().
Msg("fetchUpdateMetadata: completed")
return metadata, nil
}
func (s *State) triggerStateUpdate() {
s.onStateUpdate(s.ToRPCState())
}
func (s *State) triggerComponentUpdateState(component string, update *componentUpdateStatus) {
s.componentUpdateStatuses[component] = *update
s.triggerStateUpdate()
}
// TryUpdate tries to update the given components
// if the update is already in progress, it returns an error
func (s *State) TryUpdate(ctx context.Context, params UpdateParams) error {
locked := s.mu.TryLock()
if !locked {
return fmt.Errorf("update already in progress")
}
return s.doUpdate(ctx, params)
}
// before calling doUpdate, the caller must have locked the mutex
// otherwise a runtime error will occur
func (s *State) doUpdate(ctx context.Context, params UpdateParams) error {
defer s.mu.Unlock()
scopedLogger := s.l.With().
Interface("params", params).
Logger()
scopedLogger.Info().Msg("checking for updates")
if s.updating {
return fmt.Errorf("update already in progress")
}
s.updating = true
s.triggerStateUpdate()
if len(params.Components) == 0 {
params.Components = defaultComponents
}
_, shouldUpdateApp := params.Components["app"]
_, shouldUpdateSystem := params.Components["system"]
if !shouldUpdateApp && !shouldUpdateSystem {
return s.componentUpdateError(
"Update aborted: no components were specified to update. Requested components: ",
fmt.Errorf("%v", params.Components),
&scopedLogger,
)
}
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 shouldUpdateApp && appUpdate.available {
appUpdate.pending = true
s.updating = true
s.triggerComponentUpdateState("app", appUpdate)
}
if shouldUpdateSystem && systemUpdate.available {
systemUpdate.pending = true
s.updating = true
s.triggerComponentUpdateState("system", systemUpdate)
}
scopedLogger.Trace().Bool("pending", appUpdate.pending).Msg("Checking for app update")
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")
}
scopedLogger.Trace().Bool("pending", systemUpdate.pending).Msg("Checking for system update")
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 {
if appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate {
scopedLogger.Info().Msg("disabling auto-update due to custom version update")
// If they are explicitly updating a custom version, we assume they want to disable auto-update
if _, err := s.setAutoUpdate(false); err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to disable auto-update")
}
}
scopedLogger.Info().Msg("System Rebooting due to OTA update")
redirectUrl := "/settings/general/update"
if params.ResetConfig {
scopedLogger.Info().Msg("Resetting config")
if err := s.resetConfig(); err != nil {
return s.componentUpdateError("Error resetting config", err, &scopedLogger)
}
redirectUrl = "/welcome"
}
postRebootAction := &PostRebootAction{
HealthCheck: "/device/status",
RedirectTo: redirectUrl,
}
// REBOOT_REDIRECT_DELAY_MS is 7 seconds in the UI,
// it means that healthCheckUrl will be called after 7 seconds that we send willReboot JSONRPC event
// so we need to reboot it within 7 seconds to avoid it being called before the device is rebooted
if err := s.reboot(true, postRebootAction, 5*time.Second); err != nil {
return s.componentUpdateError("Error requesting reboot", err, &scopedLogger)
}
}
// We don't need set the updating flag to false here. Either it will;
// - set to false by the componentUpdateError function
// - device will reboot
return nil
}
// UpdateParams represents the parameters for the update
type UpdateParams struct {
DeviceID string `json:"deviceID"`
Components map[string]string `json:"components"`
IncludePreRelease bool `json:"includePreRelease"`
ResetConfig bool `json:"resetConfig"`
// RequestID is a unique identifier for the update request
// When it's set, detailed trace logs will be enabled (if the log level is Trace)
RequestID string
}
// 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 = &currentAppUpdate
}
if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
systemUpdate = &currentSystemUpdate
}
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 the 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()
logger := s.l.With().Logger()
if params.RequestID != "" {
logger = logger.With().Str("requestID", params.RequestID).Logger()
}
t := time.Now()
logger.Trace().
Str("appVersionLocal", appVersionLocal.String()).
Str("systemVersionLocal", systemVersionLocal.String()).
Dur("duration", time.Since(t)).
Msg("checkUpdateStatus: getLocalVersion")
// fetch the 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
}
logger.Trace().
Interface("remoteMetadata", remoteMetadata).
Dur("duration", time.Since(t)).
Msg("checkUpdateStatus: fetchUpdateMetadata")
// parse the remote metadata to the componentUpdateStatuses
if err := remoteMetadataToComponentStatus(
remoteMetadata,
"app",
appUpdateStatus,
params,
); err != nil {
return fmt.Errorf("error parsing remote app version: %w", err)
}
if err := remoteMetadataToComponentStatus(
remoteMetadata,
"system",
systemUpdateStatus,
params,
); err != nil {
return fmt.Errorf("error parsing remote system version: %w", err)
}
if s.l.GetLevel() <= zerolog.TraceLevel {
appUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [app]")
systemUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [system]")
}
logger.Trace().
Dur("duration", time.Since(t)).
Msg("checkUpdateStatus: completed")
return nil
}
// GetUpdateStatus returns the current update status (for backwards compatibility)
func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
// if no components are specified, use the default components
// we should remove this once app router feature is released
if len(params.Components) == 0 {
params.Components = defaultComponents
}
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
}

261
internal/ota/ota_test.go Normal file
View File

@ -0,0 +1,261 @@
package ota
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"embed"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"testing"
"time"
"github.com/Masterminds/semver/v3"
"github.com/gwatts/rootcerts"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
)
//go:embed testdata/ota
var testDataFS embed.FS
const pseudoDeviceID = "golang-test"
const releaseAPIEndpoint = "https://api.jetkvm.com/releases"
type testData struct {
Name string `json:"name"`
WithoutCerts bool `json:"withoutCerts"`
RemoteMetadata []struct {
Code int `json:"code"`
Params map[string]string `json:"params"`
Data UpdateMetadata `json:"data"`
} `json:"remoteMetadata"`
LocalMetadata struct {
SystemVersion string `json:"systemVersion"`
AppVersion string `json:"appVersion"`
} `json:"localMetadata"`
UpdateParams UpdateParams `json:"updateParams"`
Expected struct {
System bool `json:"system"`
App bool `json:"app"`
Error string `json:"error,omitempty"`
} `json:"expected"`
}
func (d *testData) ToFixtures(t *testing.T) map[string]mockData {
fixtures := make(map[string]mockData)
for _, resp := range d.RemoteMetadata {
url, err := url.Parse(releaseAPIEndpoint)
if err != nil {
t.Fatalf("failed to parse release API endpoint: %v", err)
}
query := url.Query()
query.Set("deviceId", pseudoDeviceID)
for key, value := range resp.Params {
query.Set(key, value)
}
url.RawQuery = query.Encode()
fixtures[url.String()] = mockData{
Metadata: &resp.Data,
StatusCode: resp.Code,
}
}
return fixtures
}
func (d *testData) ToUpdateParams() UpdateParams {
d.UpdateParams.DeviceID = pseudoDeviceID
return d.UpdateParams
}
func loadTestData(t *testing.T, filename string) *testData {
f, err := testDataFS.ReadFile(filepath.Join("testdata", "ota", filename))
if err != nil {
t.Fatalf("failed to read test data file %s: %v", filename, err)
}
var testData testData
if err := json.Unmarshal(f, &testData); err != nil {
t.Fatalf("failed to unmarshal test data file %s: %v", filename, err)
}
return &testData
}
type mockData struct {
Metadata *UpdateMetadata
StatusCode int
}
type mockHTTPClient struct {
DoFunc func(req *http.Request) (*http.Response, error)
Fixtures map[string]mockData
}
func compareURLs(a *url.URL, b *url.URL) bool {
if a.String() == b.String() {
return true
}
if a.Host != b.Host || a.Scheme != b.Scheme || a.Path != b.Path {
return false
}
// do a quick check to see if the query parameters are the same
queryA := a.Query()
queryB := b.Query()
if len(queryA) != len(queryB) {
return false
}
for key := range queryA {
if queryA.Get(key) != queryB.Get(key) {
return false
}
}
for key := range queryB {
if queryA.Get(key) != queryB.Get(key) {
return false
}
}
return true
}
func (m *mockHTTPClient) getFixture(expectedURL *url.URL) *mockData {
for u, fixture := range m.Fixtures {
fixtureURL, err := url.Parse(u)
if err != nil {
continue
}
if compareURLs(fixtureURL, expectedURL) {
return &fixture
}
}
return nil
}
func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) {
fixture := m.getFixture(req.URL)
if fixture == nil {
return &http.Response{
StatusCode: http.StatusNotFound,
Body: io.NopCloser(bytes.NewBufferString("")),
}, fmt.Errorf("no fixture found for URL: %s", req.URL.String())
}
resp := &http.Response{
StatusCode: fixture.StatusCode,
}
jsonData, err := json.Marshal(fixture.Metadata)
if err != nil {
return nil, fmt.Errorf("error marshalling metadata: %w", err)
}
resp.Body = io.NopCloser(bytes.NewBufferString(string(jsonData)))
return resp, nil
}
func newMockHTTPClient(fixtures map[string]mockData) *mockHTTPClient {
return &mockHTTPClient{
Fixtures: fixtures,
}
}
func newOtaState(d *testData, t *testing.T) *State {
pseudoGetLocalVersion := func() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion = semver.MustParse(d.LocalMetadata.AppVersion)
systemVersion = semver.MustParse(d.LocalMetadata.SystemVersion)
return systemVersion, appVersion, nil
}
traceLevel := zerolog.InfoLevel
if os.Getenv("TEST_LOG_TRACE") == "1" {
traceLevel = zerolog.TraceLevel
}
logger := zerolog.New(os.Stdout).Level(traceLevel)
otaState := NewState(Options{
SkipConfirmSystem: true,
Logger: &logger,
ReleaseAPIEndpoint: releaseAPIEndpoint,
GetHTTPClient: func() HttpClient {
if d.RemoteMetadata != nil {
return newMockHTTPClient(d.ToFixtures(t))
}
transport := http.DefaultTransport.(*http.Transport).Clone()
if !d.WithoutCerts {
transport.TLSClientConfig = &tls.Config{RootCAs: rootcerts.ServerCertPool()}
} else {
transport.TLSClientConfig = &tls.Config{RootCAs: x509.NewCertPool()}
}
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 testUsingJson(t *testing.T, filename string) {
td := loadTestData(t, filename)
otaState := newOtaState(td, t)
info, err := otaState.GetUpdateStatus(context.Background(), td.ToUpdateParams())
if err != nil {
if td.Expected.Error != "" {
assert.ErrorContains(t, err, td.Expected.Error)
} else {
t.Fatalf("failed to get update status: %v", err)
}
}
if td.Expected.System {
assert.True(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should available, but reason: %s", info.SystemUpdateAvailableReason))
} else {
assert.False(t, info.SystemUpdateAvailable, fmt.Sprintf("system update should not be available, but reason: %s", info.SystemUpdateAvailableReason))
}
if td.Expected.App {
assert.True(t, info.AppUpdateAvailable, fmt.Sprintf("app update should available, but reason: %s", info.AppUpdateAvailableReason))
} else {
assert.False(t, info.AppUpdateAvailable, fmt.Sprintf("app update should not be available, but reason: %s", info.AppUpdateAvailableReason))
}
}
func TestCheckUpdateComponentsSystemOnlyUpgrade(t *testing.T) {
testUsingJson(t, "system_only_upgrade.json")
}
func TestCheckUpdateComponentsSystemOnlyDowngrade(t *testing.T) {
testUsingJson(t, "system_only_downgrade.json")
}
func TestCheckUpdateComponentsAppOnlyUpgrade(t *testing.T) {
testUsingJson(t, "app_only_upgrade.json")
}
func TestCheckUpdateComponentsAppOnlyDowngrade(t *testing.T) {
testUsingJson(t, "app_only_downgrade.json")
}
func TestCheckUpdateComponentsSystemBothUpgrade(t *testing.T) {
testUsingJson(t, "both_upgrade.json")
}
func TestCheckUpdateComponentsSystemBothDowngrade(t *testing.T) {
testUsingJson(t, "both_downgrade.json")
}
func TestCheckUpdateComponentsNoComponents(t *testing.T) {
testUsingJson(t, "no_components.json")
}

167
internal/ota/rpc.go Normal file
View File

@ -0,0 +1,167 @@
package ota
import (
"fmt"
"reflect"
"strings"
"time"
"github.com/Masterminds/semver/v3"
)
// to make the field names consistent with the RPCState struct
var componentFieldMap = map[string]string{
"app": "App",
"system": "System",
}
// 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"`
}
func setTimeIfNotZero(rpcVal reflect.Value, i int, status time.Time) {
if !status.IsZero() {
rpcVal.Field(i).Set(reflect.ValueOf(&status))
}
}
func setFloat32IfNotZero(rpcVal reflect.Value, i int, status float32) {
if status != 0 {
rpcVal.Field(i).Set(reflect.ValueOf(&status))
}
}
// applyComponentStatusToRPCState uses reflection to map componentUpdateStatus fields to RPCState
func applyComponentStatusToRPCState(component string, status componentUpdateStatus, rpcState *RPCState) {
prefix := componentFieldMap[component]
if prefix == "" {
return
}
rpcVal := reflect.ValueOf(rpcState).Elem()
// it's really inefficient, but hey we do not need to use this often
// componentUpdateStatus is for internal use only, and all fields are unexported
for i := 0; i < rpcVal.NumField(); i++ {
rpcFieldName, hasPrefix := strings.CutPrefix(rpcVal.Type().Field(i).Name, prefix)
if !hasPrefix {
continue
}
switch rpcFieldName {
case "DownloadProgress":
setFloat32IfNotZero(rpcVal, i, status.downloadProgress)
case "DownloadFinishedAt":
setTimeIfNotZero(rpcVal, i, status.downloadFinishedAt)
case "VerificationProgress":
setFloat32IfNotZero(rpcVal, i, status.verificationProgress)
case "VerifiedAt":
setTimeIfNotZero(rpcVal, i, status.verifiedAt)
case "UpdateProgress":
setFloat32IfNotZero(rpcVal, i, status.updateProgress)
case "UpdatedAt":
setTimeIfNotZero(rpcVal, i, status.updatedAt)
case "UpdatePending":
rpcVal.Field(i).SetBool(status.pending)
default:
continue
}
}
}
// ToRPCState converts the State to the RPCState
func (s *State) ToRPCState() *RPCState {
r := &RPCState{
Updating: s.updating,
Error: s.error,
MetadataFetchedAt: &s.metadataFetchedAt,
}
for component, status := range s.componentUpdateStatuses {
applyComponentStatusToRPCState(component, status, r)
}
return r
}
func remoteMetadataToComponentStatus(
remoteMetadata *UpdateMetadata,
component string,
componentStatus *componentUpdateStatus,
params UpdateParams,
) error {
prefix := componentFieldMap[component]
if prefix == "" {
return fmt.Errorf("unknown component: %s", component)
}
remoteMetadataVal := reflect.ValueOf(remoteMetadata).Elem()
for i := 0; i < remoteMetadataVal.NumField(); i++ {
fieldName, hasPrefix := strings.CutPrefix(remoteMetadataVal.Type().Field(i).Name, prefix)
if !hasPrefix {
continue
}
switch fieldName {
case "URL":
componentStatus.url = remoteMetadataVal.Field(i).String()
case "Hash":
componentStatus.hash = remoteMetadataVal.Field(i).String()
case "Version":
componentStatus.version = remoteMetadataVal.Field(i).String()
default:
// fmt.Printf("unknown field %s", fieldName)
continue
}
}
localVersion, err := semver.NewVersion(componentStatus.localVersion)
if err != nil {
return fmt.Errorf("error parsing local version: %w", err)
}
remoteVersion, err := semver.NewVersion(componentStatus.version)
if err != nil {
return fmt.Errorf("error parsing remote version: %w", err)
}
componentStatus.available = remoteVersion.GreaterThan(localVersion)
componentStatus.availableReason = fmt.Sprintf("remote version %s is greater than local version %s", remoteVersion.String(), localVersion.String())
// Handle pre-release updates
if remoteVersion.Prerelease() != "" && params.IncludePreRelease && componentStatus.available {
componentStatus.availableReason += " (pre-release)"
}
// If a custom version is specified, use it to determine if the update is available
constraint, componentExists := params.Components[component]
// we don't need to check again if it's already available
if componentExists && constraint != "" {
componentStatus.available = componentStatus.version != componentStatus.localVersion
if componentStatus.available {
componentStatus.availableReason = fmt.Sprintf("custom version %s is not equal to local version %s", constraint, componentStatus.localVersion)
componentStatus.customVersionUpdate = true
}
} else if !componentExists {
componentStatus.available = false
componentStatus.availableReason = "component not specified in update parameters"
}
return nil
}

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

@ -0,0 +1,215 @@
package ota
import (
"sync"
"time"
"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog"
)
var (
availableComponents = []string{"app", "system"}
defaultComponents = map[string]string{
"app": "",
"system": "",
}
)
// 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"`
WillDisableAutoUpdate bool `json:"willDisableAutoUpdate"`
// only available for debugging and won't be exported
SystemUpdateAvailableReason string `json:"-"`
AppUpdateAvailableReason string `json:"-"`
// 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
availableReason string // why the component is available or not available
customVersionUpdate bool
version string
localVersion string
url string
hash string
downloadProgress float32
downloadFinishedAt time.Time
verificationProgress float32
verifiedAt time.Time
updateProgress float32
updatedAt time.Time
dependsOn []string
}
func (c *componentUpdateStatus) getZerologLogger(l *zerolog.Logger) *zerolog.Logger {
logger := l.With().
Bool("pending", c.pending).
Bool("available", c.available).
Str("availableReason", c.availableReason).
Str("version", c.version).
Str("localVersion", c.localVersion).
Str("url", c.url).
Str("hash", c.hash).
Float32("downloadProgress", c.downloadProgress).
Time("downloadFinishedAt", c.downloadFinishedAt).
Float32("verificationProgress", c.verificationProgress).
Time("verifiedAt", c.verifiedAt).
Float32("updateProgress", c.updateProgress).
Time("updatedAt", c.updatedAt).
Strs("dependsOn", c.dependsOn).
Logger()
return &logger
}
// 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
// SetAutoUpdateFunc is a function that sets the auto-update state
type SetAutoUpdateFunc func(enabled bool) (bool, error)
// GetHTTPClientFunc is a function that returns the HTTP client
type GetHTTPClientFunc func() HttpClient
// 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
setAutoUpdate SetAutoUpdateFunc
}
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,
SystemUpdateAvailableReason: systemUpdate.availableReason,
AppUpdateAvailable: appUpdate.available,
AppUpdateAvailableReason: appUpdate.availableReason,
WillDisableAutoUpdate: appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate,
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
SetAutoUpdate SetAutoUpdateFunc
}
// NewState creates a new OTA state
func NewState(opts Options) *State {
components := make(map[string]componentUpdateStatus)
for _, component := range availableComponents {
components[component] = 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,
setAutoUpdate: opts.SetAutoUpdate,
}
if !opts.SkipConfirmSystem {
go s.confirmCurrentSystem()
}
return s
}

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

@ -0,0 +1,101 @@
package ota
import (
"bytes"
"context"
"os/exec"
"time"
)
const (
systemUpdatePath = "/userdata/jetkvm/update_system.tar"
)
// DO NOT call it directly, it's not thread safe
// Mutex is currently held by the caller, e.g. doUpdate
func (s *State) updateSystem(ctx context.Context, systemUpdate *componentUpdateStatus) error {
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")
}

159
internal/ota/testdata/ota.schema.json vendored Normal file
View File

@ -0,0 +1,159 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "OTA Test Data Schema",
"description": "Schema for OTA update test data",
"type": "object",
"required": ["name", "remoteMetadata", "localMetadata", "updateParams"],
"properties": {
"name": {
"type": "string",
"description": "Name of the test case"
},
"withoutCerts": {
"type": "boolean",
"default": false,
"description": "Whether to run the test without Root CA certificates"
},
"remoteMetadata": {
"type": "array",
"description": "Remote metadata responses",
"items": {
"type": "object",
"required": ["params", "code", "data"],
"properties": {
"params": {
"type": "object",
"description": "Query parameters used for the request",
"required": ["prerelease"],
"properties": {
"prerelease": {
"type": "string",
"description": "Whether to include pre-release versions"
},
"appVersion": {
"type": "string",
"description": "Application version string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"systemVersion": {
"type": "string",
"description": "System version string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
}
},
"additionalProperties": false
},
"code": {
"type": "integer",
"description": "HTTP status code"
},
"data": {
"type": "object",
"required": ["appVersion", "appUrl", "appHash", "systemVersion", "systemUrl", "systemHash"],
"properties": {
"appVersion": {
"type": "string",
"description": "Application version string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"appUrl": {
"type": "string",
"description": "URL to download the application",
"format": "uri"
},
"appHash": {
"type": "string",
"description": "SHA-256 hash of the application",
"pattern": "^[a-f0-9]{64}$"
},
"systemVersion": {
"type": "string",
"description": "System version string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"systemUrl": {
"type": "string",
"description": "URL to download the system",
"format": "uri"
},
"systemHash": {
"type": "string",
"description": "SHA-256 hash of the system",
"pattern": "^[a-f0-9]{64}$"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"localMetadata": {
"type": "object",
"description": "Local metadata containing current installed versions",
"required": ["systemVersion", "appVersion"],
"properties": {
"systemVersion": {
"type": "string",
"description": "Currently installed system version",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
},
"appVersion": {
"type": "string",
"description": "Currently installed application version",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
}
},
"additionalProperties": false
},
"updateParams": {
"type": "object",
"description": "Parameters for the update operation",
"required": ["includePreRelease"],
"properties": {
"includePreRelease": {
"type": "boolean",
"description": "Whether to include pre-release versions"
},
"components": {
"type": "object",
"description": "Component update configuration",
"properties": {
"system": {
"type": "string",
"description": "System component update configuration (empty string to update)"
},
"app": {
"type": "string",
"description": "App component update configuration (version string to update to)"
}
},
"additionalProperties": true
}
},
"additionalProperties": false
},
"expected": {
"type": "object",
"description": "Expected update results",
"required": [],
"properties": {
"system": {
"type": "boolean",
"description": "Whether system update is expected"
},
"app": {
"type": "boolean",
"description": "Whether app update is expected"
},
"error": {
"type": "string",
"description": "Error message if the test case is expected to fail"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
}

View File

@ -0,0 +1,34 @@
{
"name": "Downgrade App Only",
"remoteMetadata": [
{
"params": {
"prerelease": "false",
"appVersion": "0.4.6"
},
"code": 200,
"data": {
"appVersion": "0.4.6",
"appUrl": "https://update.jetkvm.com/app/0.4.6/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.5",
"systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.2",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"app": "0.4.6"
}
},
"expected": {
"system": false,
"app": true
}
}

View File

@ -0,0 +1,33 @@
{
"name": "Upgrade App Only",
"remoteMetadata": [
{
"params": {
"prerelease": "false"
},
"code": 200,
"data": {
"appVersion": "0.4.7",
"appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.5",
"systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.2",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"app": ""
}
},
"expected": {
"system": false,
"app": true
}
}

View File

@ -0,0 +1,37 @@
{
"name": "Downgrade System & App",
"remoteMetadata": [
{
"params": {
"prerelease": "false",
"systemVersion": "0.2.2",
"appVersion": "0.4.6"
},
"code": 200,
"data": {
"appVersion": "0.4.6",
"appUrl": "https://update.jetkvm.com/app/0.4.6/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.2",
"systemUrl": "https://update.jetkvm.com/system/0.2.2/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.5",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"system": "0.2.2",
"app": "0.4.6"
}
},
"expected": {
"system": true,
"app": true
}
}

View File

@ -0,0 +1,34 @@
{
"name": "Upgrade System & App (components given)",
"remoteMetadata": [
{
"params": {
"prerelease": "false"
},
"code": 200,
"data": {
"appVersion": "0.4.7",
"appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.5",
"systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.2",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"system": "",
"app": ""
}
},
"expected": {
"system": true,
"app": true
}
}

View File

@ -0,0 +1,32 @@
{
"name": "Upgrade System & App (no components given)",
"remoteMetadata": [
{
"params": {
"prerelease": "false"
},
"code": 200,
"data": {
"appVersion": "0.4.7",
"appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.5",
"systemUrl": "https://update.jetkvm.com/system/0.2.5/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.2",
"appVersion": "0.4.2"
},
"updateParams": {
"includePreRelease": false,
"components": {}
},
"expected": {
"system": true,
"app": true
}
}

View File

@ -0,0 +1,34 @@
{
"name": "Downgrade System Only",
"remoteMetadata": [
{
"params": {
"prerelease": "false",
"systemVersion": "0.2.2"
},
"code": 200,
"data": {
"appVersion": "0.4.7",
"appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.2",
"systemUrl": "https://update.jetkvm.com/system/0.2.2/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.5",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"system": "0.2.2"
}
},
"expected": {
"system": true,
"app": false
}
}

View File

@ -0,0 +1,33 @@
{
"name": "Upgrade System Only",
"remoteMetadata": [
{
"params": {
"prerelease": "false"
},
"code": 200,
"data": {
"appVersion": "0.4.7",
"appUrl": "https://update.jetkvm.com/app/0.4.7/jetkvm_app",
"appHash": "714f33432f17035e38d238bf376e98f3073e6cc2845d269ff617503d12d92bdd",
"systemVersion": "0.2.6",
"systemUrl": "https://update.jetkvm.com/system/0.2.6/system.tar",
"systemHash": "2323463ea8652be767d94514e548f90dd61b1ebcc0fb1834d700fac5b3d88a35"
}
}
],
"localMetadata": {
"systemVersion": "0.2.5",
"appVersion": "0.4.5"
},
"updateParams": {
"includePreRelease": false,
"components": {
"system": ""
}
},
"expected": {
"system": true,
"app": false
}
}

View File

@ -0,0 +1,17 @@
{
"name": "Without Certs",
"localMetadata": {
"systemVersion": "0.2.5",
"appVersion": "0.4.7"
},
"updateParams": {
"includePreRelease": false,
"components": {}
},
"expected": {
"system": false,
"app": false,
"error": "certificate signed by unknown authority"
}
}

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

@ -0,0 +1,193 @@
package ota
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"time"
"github.com/rs/zerolog"
)
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 {
logger := s.l.With().
Str("path", path).
Str("url", url).
Str("downloadComponent", component).
Logger()
t := time.Now()
traceLogger := func() *zerolog.Event {
return logger.Trace().Dur("duration", time.Since(t))
}
traceLogger().Msg("downloading file")
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 {
traceLogger().Msg("removing existing file")
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 {
traceLogger().Msg("removing existing unverified file")
if err := os.Remove(unverifiedPath); err != nil {
return fmt.Errorf("error removing existing unverified file: %w", err)
}
}
traceLogger().Msg("creating unverified file")
file, err := os.Create(unverifiedPath)
if err != nil {
return fmt.Errorf("error creating file: %w", err)
}
defer file.Close()
traceLogger().Msg("creating request")
req, err := s.newHTTPRequestWithTrace(ctx, "GET", url, nil, traceLogger)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
client := s.client()
traceLogger().Msg("starting download")
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)
}
}
traceLogger().Msg("download finished")
file.Close()
traceLogger().Msg("syncing filesystem")
if err := syncFilesystem(); err != nil {
return fmt.Errorf("error syncing filesystem: %w", err)
}
return nil
}
func (s *State) verifyFile(path string, expectedHash string, verifyProgress *float32) error {
l := s.l.With().Str("path", path).Logger()
unverifiedPath := path + ".unverified"
fileToHash, err := os.Open(unverifiedPath)
if err != nil {
return fmt.Errorf("error opening file for hashing: %w", err)
}
defer fileToHash.Close()
hash := sha256.New()
fileInfo, err := fileToHash.Stat()
if err != nil {
return fmt.Errorf("error getting file info: %w", err)
}
totalSize := fileInfo.Size()
buf := make([]byte, 32*1024)
verified := int64(0)
for {
nr, er := fileToHash.Read(buf)
if nr > 0 {
nw, ew := hash.Write(buf[0:nr])
if nw < nr {
return fmt.Errorf("short write: %d < %d", nw, nr)
}
verified += int64(nw)
if ew != nil {
return fmt.Errorf("error writing to hash: %w", ew)
}
progress := float32(verified) / float32(totalSize)
if progress-*verifyProgress >= 0.01 {
*verifyProgress = progress
s.triggerStateUpdate()
}
}
if er != nil {
if er == io.EOF {
break
}
return fmt.Errorf("error reading file: %w", er)
}
}
hashSum := hash.Sum(nil)
l.Info().Str("hash", hex.EncodeToString(hashSum)).Msg("SHA256 hash of")
if hex.EncodeToString(hashSum) != expectedHash {
return fmt.Errorf("hash mismatch: %x != %s", hashSum, expectedHash)
}
if err := os.Rename(unverifiedPath, path); err != nil {
return fmt.Errorf("error renaming file: %w", err)
}
if err := os.Chmod(path, 0755); err != nil {
return fmt.Errorf("error making file executable: %w", err)
}
return nil
}

View File

@ -123,6 +123,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
Interface("id", request.ID).Logger() Interface("id", request.ID).Logger()
scopedLogger.Trace().Msg("Received RPC request") scopedLogger.Trace().Msg("Received RPC request")
t := time.Now()
handler, ok := rpcHandlers[request.Method] handler, ok := rpcHandlers[request.Method]
if !ok { if !ok {
@ -154,7 +155,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
return return
} }
scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned") scopedLogger.Trace().Dur("duration", time.Since(t)).Interface("result", result).Msg("RPC handler returned")
response := JSONRPCResponse{ response := JSONRPCResponse{
JSONRPC: "2.0", JSONRPC: "2.0",
@ -236,55 +237,6 @@ func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus() return nativeInstance.VideoLogStatus()
} }
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
func rpcSetDevChannelState(enabled bool) error {
config.IncludePreRelease = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
return updateStatus, nil
}
func rpcGetLocalVersion() (*LocalMetadata, error) {
systemVersion, appVersion, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
}
return &LocalMetadata{
AppVersion: appVersion.String(),
SystemVersion: systemVersion.String(),
}, nil
}
func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error { func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation currentRotation := config.DisplayRotation
if currentRotation == params.Rotation { if currentRotation == params.Rotation {
@ -654,7 +606,7 @@ func rpcGetMassStorageMode() (string, error) {
} }
func rpcIsUpdatePending() (bool, error) { func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil return otaState.IsUpdatePending(), nil
} }
func rpcGetUsbEmulationState() (bool, error) { func rpcGetUsbEmulationState() (bool, error) {
@ -1204,7 +1156,10 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}}, "setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion}, "getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus}, "getUpdateStatus": {Func: rpcGetUpdateStatus},
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
"tryUpdate": {Func: rpcTryUpdate}, "tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
"getDevModeState": {Func: rpcGetDevModeState}, "getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}}, "setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState}, "getSSHKeyState": {Func: rpcGetSSHKeyState},

19
main.go
View File

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

View File

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

672
ota.go
View File

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

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}", "advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}",
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
"advanced_error_version_update": "Kunne ikke starte versionsopdatering: {error}",
"advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)", "advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)",
"advanced_loopback_only_title": "Kun loopback-tilstand", "advanced_loopback_only_title": "Kun loopback-tilstand",
"advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:", "advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Opdater SSH-nøgle", "advanced_update_ssh_key_button": "Opdater SSH-nøgle",
"advanced_usb_emulation_description": "Styr USB-emuleringstilstanden", "advanced_usb_emulation_description": "Styr USB-emuleringstilstanden",
"advanced_usb_emulation_title": "USB-emulering", "advanced_usb_emulation_title": "USB-emulering",
"advanced_version_update_app_label": "App-version",
"advanced_version_update_button": "Opdatering til version",
"advanced_version_update_description": "Installer en specifik version fra GitHub-udgivelser",
"advanced_version_update_github_link": "JetKVM-udgivelsesside",
"advanced_version_update_helper": "Find tilgængelige versioner på",
"advanced_version_update_reset_config_description": "Nulstil konfigurationen efter opdateringen",
"advanced_version_update_reset_config_label": "Nulstil konfiguration",
"advanced_version_update_system_label": "Systemversion",
"advanced_version_update_target_app": "Kun i appen",
"advanced_version_update_target_both": "Både app og system",
"advanced_version_update_target_label": "Hvad skal opdateres",
"advanced_version_update_target_system": "Kun systemet",
"advanced_version_update_title": "Opdatering til specifik version",
"already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.", "already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.",
"already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.", "already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
"already_adopted_return_to_dashboard": "Tilbage til dashboardet", "already_adopted_return_to_dashboard": "Tilbage til dashboardet",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.", "connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
"connection_stats_playback_delay": "Afspilningsforsinkelse", "connection_stats_playback_delay": "Afspilningsforsinkelse",
"connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.", "connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.",
"connection_stats_remote_ip_address": "Fjern IP-adresse",
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse",
"connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder",
"connection_stats_remote_ip_address_description": "IP-adressen på den eksterne enhed.",
"connection_stats_round_trip_time": "Rundturstid", "connection_stats_round_trip_time": "Rundturstid",
"connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.", "connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
"connection_stats_sidebar": "Forbindelsesstatistik", "connection_stats_sidebar": "Forbindelsesstatistik",
@ -241,6 +259,7 @@
"general_auto_update_description": "Opdater automatisk enheden til den nyeste version", "general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
"general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}", "general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
"general_auto_update_title": "Automatisk opdatering", "general_auto_update_title": "Automatisk opdatering",
"general_check_for_stable_updates": "Nedgradering",
"general_check_for_updates": "Tjek for opdateringer", "general_check_for_updates": "Tjek for opdateringer",
"general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer", "general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
"general_reboot_description": "Vil du fortsætte med at genstarte systemet?", "general_reboot_description": "Vil du fortsætte med at genstarte systemet?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Søger efter opdateringer…", "general_update_checking_title": "Søger efter opdateringer…",
"general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!", "general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!",
"general_update_completed_title": "Opdatering gennemført", "general_update_completed_title": "Opdatering gennemført",
"general_update_downgrade_available_description": "En nedgradering er tilgængelig for at vende tilbage til en tidligere version.",
"general_update_downgrade_available_title": "Nedgradering tilgængelig",
"general_update_downgrade_button": "Nedgrader nu",
"general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.", "general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.",
"general_update_error_details": "Fejldetaljer: {errorMessage}", "general_update_error_details": "Fejldetaljer: {errorMessage}",
"general_update_error_title": "Opdateringsfejl", "general_update_error_title": "Opdateringsfejl",
"general_update_keep_current_button": "Behold den aktuelle version",
"general_update_later_button": "Opdater senere", "general_update_later_button": "Opdater senere",
"general_update_now_button": "Opdater nu", "general_update_now_button": "Opdater nu",
"general_update_rebooting": "Genstarter for at fuldføre opdateringen…", "general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Systemet er opdateret", "general_update_up_to_date_title": "Systemet er opdateret",
"general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.", "general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.",
"general_update_updating_title": "Opdatering af din enhed", "general_update_updating_title": "Opdatering af din enhed",
"general_update_will_disable_auto_update_description": "Du er ved at ændre din enhedsversion manuelt. Automatisk opdatering vil blive deaktiveret, når opdateringen er fuldført, for at forhindre utilsigtede opdateringer.",
"getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}", "getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}",
"hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}", "hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}",
"hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}", "hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Forbindelsen mislykkedes", "peer_connection_failed": "Forbindelsen mislykkedes",
"peer_connection_new": "Forbinder", "peer_connection_new": "Forbinder",
"previous": "Tidligere", "previous": "Tidligere",
"public_ip_card_header": "Offentlige IP-adresser",
"public_ip_card_refresh": "Opfriske",
"public_ip_card_refresh_error": "Kunne ikke opdatere offentlige IP-adresser: {error}",
"register_device_error": "Der opstod en fejl {error} under registrering af din enhed.", "register_device_error": "Der opstod en fejl {error} under registrering af din enhed.",
"register_device_finish_button": "Afslut opsætning", "register_device_finish_button": "Afslut opsætning",
"register_device_name_description": "Navngiv din enhed, så du nemt kan identificere den senere. Du kan til enhver tid ændre dette navn.", "register_device_name_description": "Navngiv din enhed, så du nemt kan identificere den senere. Du kan til enhver tid ændre dette navn.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}", "advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}",
"advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}", "advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}",
"advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}", "advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}",
"advanced_error_version_update": "Versionsaktualisierung konnte nicht initiiert werden: {error}",
"advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).", "advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).",
"advanced_loopback_only_title": "Nur-Loopback-Modus", "advanced_loopback_only_title": "Nur-Loopback-Modus",
"advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:", "advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren", "advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren",
"advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus", "advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus",
"advanced_usb_emulation_title": "USB-Emulation", "advanced_usb_emulation_title": "USB-Emulation",
"advanced_version_update_app_label": "App-Version",
"advanced_version_update_button": "Aktualisierung auf Version",
"advanced_version_update_description": "Installieren Sie eine bestimmte Version aus den GitHub-Releases.",
"advanced_version_update_github_link": "JetKVM-Releases-Seite",
"advanced_version_update_helper": "Finden Sie verfügbare Versionen auf der",
"advanced_version_update_reset_config_description": "Konfiguration nach dem Update zurücksetzen",
"advanced_version_update_reset_config_label": "Konfiguration zurücksetzen",
"advanced_version_update_system_label": "Systemversion",
"advanced_version_update_target_app": "Nur App",
"advanced_version_update_target_both": "Sowohl App als auch System",
"advanced_version_update_target_label": "Was sollte aktualisiert werden?",
"advanced_version_update_target_system": "System nur",
"advanced_version_update_title": "Aktualisierung auf eine bestimmte Version",
"already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.", "already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.",
"already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.", "already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.",
"already_adopted_return_to_dashboard": "Zurück zum Dashboard", "already_adopted_return_to_dashboard": "Zurück zum Dashboard",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.", "connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
"connection_stats_playback_delay": "Wiedergabeverzögerung", "connection_stats_playback_delay": "Wiedergabeverzögerung",
"connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.", "connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.",
"connection_stats_remote_ip_address": "Remote-IP-Adresse",
"connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse",
"connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert",
"connection_stats_remote_ip_address_description": "Die IP-Adresse des Remote-Geräts.",
"connection_stats_round_trip_time": "Round-Trip-Zeit", "connection_stats_round_trip_time": "Round-Trip-Zeit",
"connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.", "connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
"connection_stats_sidebar": "Verbindungsstatistiken", "connection_stats_sidebar": "Verbindungsstatistiken",
@ -241,6 +259,7 @@
"general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version", "general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version",
"general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}", "general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
"general_auto_update_title": "Automatische Aktualisierung", "general_auto_update_title": "Automatische Aktualisierung",
"general_check_for_stable_updates": "Herabstufung",
"general_check_for_updates": "Nach Updates suchen", "general_check_for_updates": "Nach Updates suchen",
"general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren", "general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
"general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?", "general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Suche nach Updates…", "general_update_checking_title": "Suche nach Updates…",
"general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!", "general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!",
"general_update_completed_title": "Update erfolgreich abgeschlossen", "general_update_completed_title": "Update erfolgreich abgeschlossen",
"general_update_downgrade_available_description": "Es besteht die Möglichkeit, auf eine frühere Version zurückzukehren.",
"general_update_downgrade_available_title": "Downgrade verfügbar",
"general_update_downgrade_button": "Jetzt downgraden",
"general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.", "general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
"general_update_error_details": "Fehlerdetails: {errorMessage}", "general_update_error_details": "Fehlerdetails: {errorMessage}",
"general_update_error_title": "Aktualisierungsfehler", "general_update_error_title": "Aktualisierungsfehler",
"general_update_keep_current_button": "Aktuelle Version beibehalten",
"general_update_later_button": "Später", "general_update_later_button": "Später",
"general_update_now_button": "Jetzt aktualisieren", "general_update_now_button": "Jetzt aktualisieren",
"general_update_rebooting": "Neustart zum Abschließen des Updates …", "general_update_rebooting": "Neustart zum Abschließen des Updates …",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Das System ist auf dem neuesten Stand", "general_update_up_to_date_title": "Das System ist auf dem neuesten Stand",
"general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.", "general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.",
"general_update_updating_title": "Aktualisieren Ihres Geräts", "general_update_updating_title": "Aktualisieren Ihres Geräts",
"general_update_will_disable_auto_update_description": "Sie sind im Begriff, die Version Ihres Geräts manuell zu ändern. Die automatische Aktualisierung wird nach Abschluss der Aktualisierung deaktiviert, um versehentliche Updates zu verhindern.",
"getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}", "getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}",
"hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}", "hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}",
"hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}", "hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Verbindung fehlgeschlagen", "peer_connection_failed": "Verbindung fehlgeschlagen",
"peer_connection_new": "Verbinden", "peer_connection_new": "Verbinden",
"previous": "Vorherige", "previous": "Vorherige",
"public_ip_card_header": "Öffentliche IP-Adressen",
"public_ip_card_refresh": "Aktualisieren",
"public_ip_card_refresh_error": "Aktualisierung der öffentlichen IP-Adressen fehlgeschlagen: {error}",
"register_device_error": "Beim Registrieren Ihres Geräts ist ein Fehler {error} aufgetreten.", "register_device_error": "Beim Registrieren Ihres Geräts ist ein Fehler {error} aufgetreten.",
"register_device_finish_button": "Einrichtung abschließen", "register_device_finish_button": "Einrichtung abschließen",
"register_device_name_description": "Geben Sie Ihrem Gerät einen Namen, damit Sie es später leicht identifizieren können. Sie können diesen Namen jederzeit ändern.", "register_device_name_description": "Geben Sie Ihrem Gerät einen Namen, damit Sie es später leicht identifizieren können. Sie können diesen Namen jederzeit ändern.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}", "advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}", "advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}", "advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
"advanced_error_version_update": "Failed to initiate version update: {error}",
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)", "advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-Only Mode", "advanced_loopback_only_title": "Loopback-Only Mode",
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:", "advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Update SSH Key", "advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state", "advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation", "advanced_usb_emulation_title": "USB Emulation",
"advanced_version_update_app_label": "App Version",
"advanced_version_update_button": "Update to Version",
"advanced_version_update_description": "Install a specific version from GitHub releases",
"advanced_version_update_github_link": "JetKVM releases page",
"advanced_version_update_helper": "Find available versions on the",
"advanced_version_update_reset_config_description": "Reset configuration after the update",
"advanced_version_update_reset_config_label": "Reset configuration",
"advanced_version_update_system_label": "System Version",
"advanced_version_update_target_app": "App only",
"advanced_version_update_target_both": "Both App and System",
"advanced_version_update_target_label": "What to update",
"advanced_version_update_target_system": "System only",
"advanced_version_update_title": "Update to Specific Version",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.", "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.", "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard", "already_adopted_return_to_dashboard": "Return to Dashboard",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.", "connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
"connection_stats_playback_delay": "Playback Delay", "connection_stats_playback_delay": "Playback Delay",
"connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.", "connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
"connection_stats_remote_ip_address": "Remote IP Address",
"connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
"connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
"connection_stats_round_trip_time": "Round-Trip Time", "connection_stats_round_trip_time": "Round-Trip Time",
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.", "connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
"connection_stats_sidebar": "Connection Stats", "connection_stats_sidebar": "Connection Stats",
@ -241,6 +259,7 @@
"general_auto_update_description": "Automatically update the device to the latest version", "general_auto_update_description": "Automatically update the device to the latest version",
"general_auto_update_error": "Failed to set auto-update: {error}", "general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update", "general_auto_update_title": "Auto Update",
"general_check_for_stable_updates": "Downgrade",
"general_check_for_updates": "Check for Updates", "general_check_for_updates": "Check for Updates",
"general_page_description": "Configure device settings and update preferences", "general_page_description": "Configure device settings and update preferences",
"general_reboot_description": "Do you want to proceed with rebooting the system?", "general_reboot_description": "Do you want to proceed with rebooting the system?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Checking for updates…", "general_update_checking_title": "Checking for updates…",
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!", "general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
"general_update_completed_title": "Update Completed Successfully", "general_update_completed_title": "Update Completed Successfully",
"general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.",
"general_update_downgrade_available_title": "Downgrade Available",
"general_update_downgrade_button": "Downgrade Now",
"general_update_error_description": "An error occurred while updating your device. Please try again later.", "general_update_error_description": "An error occurred while updating your device. Please try again later.",
"general_update_error_details": "Error details: {errorMessage}", "general_update_error_details": "Error details: {errorMessage}",
"general_update_error_title": "Update Error", "general_update_error_title": "Update Error",
"general_update_keep_current_button": "Keep Current Version",
"general_update_later_button": "Do it later", "general_update_later_button": "Do it later",
"general_update_now_button": "Update Now", "general_update_now_button": "Update Now",
"general_update_rebooting": "Rebooting to complete the update…", "general_update_rebooting": "Rebooting to complete the update…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "System is up to date", "general_update_up_to_date_title": "System is up to date",
"general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.", "general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
"general_update_updating_title": "Updating your device", "general_update_updating_title": "Updating your device",
"general_update_will_disable_auto_update_description": "You're about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.",
"getting_remote_session_description": "Getting remote session description attempt {attempt}", "getting_remote_session_description": "Getting remote session description attempt {attempt}",
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}", "hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}", "hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Connection failed", "peer_connection_failed": "Connection failed",
"peer_connection_new": "Connecting", "peer_connection_new": "Connecting",
"previous": "Previous", "previous": "Previous",
"public_ip_card_header": "Public IP addresses",
"public_ip_card_refresh": "Refresh",
"public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}",
"register_device_error": "There was an error {error} registering your device.", "register_device_error": "There was an error {error} registering your device.",
"register_device_finish_button": "Finish Setup", "register_device_finish_button": "Finish Setup",
"register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.", "register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.",
@ -897,11 +924,5 @@
"wake_on_lan_invalid_mac": "Invalid MAC address", "wake_on_lan_invalid_mac": "Invalid MAC address",
"wake_on_lan_magic_sent_success": "Magic Packet sent successfully", "wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
"welcome_to_jetkvm": "Welcome to JetKVM", "welcome_to_jetkvm": "Welcome to JetKVM",
"welcome_to_jetkvm_description": "Control any computer remotely","connection_stats_remote_ip_address": "Remote IP Address", "welcome_to_jetkvm_description": "Control any computer remotely"
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
"connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
"connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
"public_ip_card_header": "Public IP addresses",
"public_ip_card_refresh": "Refresh",
"public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}"
} }

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}", "advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}",
"advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}", "advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}",
"advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}", "advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}",
"advanced_error_version_update": "Error al iniciar la actualización de versión: {error}",
"advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)", "advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)",
"advanced_loopback_only_title": "Modo de solo bucle invertido", "advanced_loopback_only_title": "Modo de solo bucle invertido",
"advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:", "advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Actualizar clave SSH", "advanced_update_ssh_key_button": "Actualizar clave SSH",
"advanced_usb_emulation_description": "Controlar el estado de emulación USB", "advanced_usb_emulation_description": "Controlar el estado de emulación USB",
"advanced_usb_emulation_title": "Emulación USB", "advanced_usb_emulation_title": "Emulación USB",
"advanced_version_update_app_label": "Versión de la aplicación",
"advanced_version_update_button": "Actualización a la versión",
"advanced_version_update_description": "Instala una versión específica desde las versiones de GitHub.",
"advanced_version_update_github_link": "Página de lanzamientos de JetKVM",
"advanced_version_update_helper": "Encuentra las versiones disponibles en el",
"advanced_version_update_reset_config_description": "Restablecer la configuración después de la actualización",
"advanced_version_update_reset_config_label": "Restablecer configuración",
"advanced_version_update_system_label": "Versión del sistema",
"advanced_version_update_target_app": "Solo aplicación",
"advanced_version_update_target_both": "Tanto la aplicación como el sistema",
"advanced_version_update_target_label": "Qué actualizar",
"advanced_version_update_target_system": "Solo sistema",
"advanced_version_update_title": "Actualización a una versión específica",
"already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.", "already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.",
"already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.", "already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.",
"already_adopted_return_to_dashboard": "Regresar al panel de control", "already_adopted_return_to_dashboard": "Regresar al panel de control",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.", "connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
"connection_stats_playback_delay": "Retraso de reproducción", "connection_stats_playback_delay": "Retraso de reproducción",
"connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.", "connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.",
"connection_stats_remote_ip_address": "Dirección IP remota",
"connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota",
"connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles",
"connection_stats_remote_ip_address_description": "La dirección IP del dispositivo remoto.",
"connection_stats_round_trip_time": "Tiempo de ida y vuelta", "connection_stats_round_trip_time": "Tiempo de ida y vuelta",
"connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.", "connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
"connection_stats_sidebar": "Estadísticas de conexión", "connection_stats_sidebar": "Estadísticas de conexión",
@ -241,6 +259,7 @@
"general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión", "general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión",
"general_auto_update_error": "No se pudo configurar la actualización automática: {error}", "general_auto_update_error": "No se pudo configurar la actualización automática: {error}",
"general_auto_update_title": "Actualización automática", "general_auto_update_title": "Actualización automática",
"general_check_for_stable_updates": "Degradar",
"general_check_for_updates": "Buscar actualizaciones", "general_check_for_updates": "Buscar actualizaciones",
"general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias", "general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
"general_reboot_description": "¿Desea continuar con el reinicio del sistema?", "general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Buscando actualizaciones…", "general_update_checking_title": "Buscando actualizaciones…",
"general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!", "general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!",
"general_update_completed_title": "Actualización completada con éxito", "general_update_completed_title": "Actualización completada con éxito",
"general_update_downgrade_available_description": "Es posible realizar una reversión a una versión anterior.",
"general_update_downgrade_available_title": "Opción de cambio a una versión inferior disponible",
"general_update_downgrade_button": "Revierte ahora",
"general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.", "general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.",
"general_update_error_details": "Detalles del error: {errorMessage}", "general_update_error_details": "Detalles del error: {errorMessage}",
"general_update_error_title": "Error de actualización", "general_update_error_title": "Error de actualización",
"general_update_keep_current_button": "Mantener la versión actual",
"general_update_later_button": "Posponer", "general_update_later_button": "Posponer",
"general_update_now_button": "Actualizar ahora", "general_update_now_button": "Actualizar ahora",
"general_update_rebooting": "Reiniciando para completar la actualización…", "general_update_rebooting": "Reiniciando para completar la actualización…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "El sistema está actualizado", "general_update_up_to_date_title": "El sistema está actualizado",
"general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.", "general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
"general_update_updating_title": "Actualizar su dispositivo", "general_update_updating_title": "Actualizar su dispositivo",
"general_update_will_disable_auto_update_description": "Estás a punto de cambiar manualmente la versión de tu dispositivo. La actualización automática se desactivará una vez completada la actualización para evitar actualizaciones accidentales.",
"getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}", "getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}",
"hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}", "hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}",
"hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}", "hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "La conexión falló", "peer_connection_failed": "La conexión falló",
"peer_connection_new": "Conectando", "peer_connection_new": "Conectando",
"previous": "Anterior", "previous": "Anterior",
"public_ip_card_header": "Direcciones IP públicas",
"public_ip_card_refresh": "Refrescar",
"public_ip_card_refresh_error": "Error al actualizar las direcciones IP públicas: {error}",
"register_device_error": "Se produjo un error {error} al registrar su dispositivo.", "register_device_error": "Se produjo un error {error} al registrar su dispositivo.",
"register_device_finish_button": "Finalizar configuración", "register_device_finish_button": "Finalizar configuración",
"register_device_name_description": "Ponle un nombre a tu dispositivo para que puedas identificarlo fácilmente más tarde. Puedes cambiarlo en cualquier momento.", "register_device_name_description": "Ponle un nombre a tu dispositivo para que puedas identificarlo fácilmente más tarde. Puedes cambiarlo en cualquier momento.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}", "advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}",
"advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}", "advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}",
"advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}", "advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}",
"advanced_error_version_update": "Échec de la mise à jour de version : {error}",
"advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)", "advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)",
"advanced_loopback_only_title": "Mode de bouclage uniquement", "advanced_loopback_only_title": "Mode de bouclage uniquement",
"advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :", "advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Mettre à jour la clé SSH", "advanced_update_ssh_key_button": "Mettre à jour la clé SSH",
"advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB", "advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB",
"advanced_usb_emulation_title": "Émulation USB", "advanced_usb_emulation_title": "Émulation USB",
"advanced_version_update_app_label": "Version de l'application",
"advanced_version_update_button": "Mise à jour vers la version",
"advanced_version_update_description": "Installer une version spécifique à partir des versions GitHub",
"advanced_version_update_github_link": "page des versions de JetKVM",
"advanced_version_update_helper": "Trouvez les versions disponibles sur le",
"advanced_version_update_reset_config_description": "Réinitialiser la configuration après la mise à jour",
"advanced_version_update_reset_config_label": "Réinitialiser la configuration",
"advanced_version_update_system_label": "Version du système",
"advanced_version_update_target_app": "Application uniquement",
"advanced_version_update_target_both": "L'application et le système",
"advanced_version_update_target_label": "Que mettre à jour",
"advanced_version_update_target_system": "Système uniquement",
"advanced_version_update_title": "Mise à jour vers une version spécifique",
"already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.", "already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.",
"already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.", "already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.",
"already_adopted_return_to_dashboard": "Retour au tableau de bord", "already_adopted_return_to_dashboard": "Retour au tableau de bord",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.", "connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
"connection_stats_playback_delay": "Délai de lecture", "connection_stats_playback_delay": "Délai de lecture",
"connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.", "connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.",
"connection_stats_remote_ip_address": "Adresse IP distante",
"connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante",
"connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers",
"connection_stats_remote_ip_address_description": "L'adresse IP du périphérique distant.",
"connection_stats_round_trip_time": "Temps de trajet aller-retour", "connection_stats_round_trip_time": "Temps de trajet aller-retour",
"connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.", "connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
"connection_stats_sidebar": "Statistiques de connexion", "connection_stats_sidebar": "Statistiques de connexion",
@ -241,6 +259,7 @@
"general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version", "general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version",
"general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}", "general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}",
"general_auto_update_title": "Mise à jour automatique", "general_auto_update_title": "Mise à jour automatique",
"general_check_for_stable_updates": "Rétrograder",
"general_check_for_updates": "Vérifier les mises à jour", "general_check_for_updates": "Vérifier les mises à jour",
"general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences", "general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences",
"general_reboot_description": "Voulez-vous procéder au redémarrage du système ?", "general_reboot_description": "Voulez-vous procéder au redémarrage du système ?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Vérification des mises à jour…", "general_update_checking_title": "Vérification des mises à jour…",
"general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !", "general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !",
"general_update_completed_title": "Mise à jour terminée avec succès", "general_update_completed_title": "Mise à jour terminée avec succès",
"general_update_downgrade_available_description": "Il est possible de revenir à une version antérieure.",
"general_update_downgrade_available_title": "Rétrogradation possible",
"general_update_downgrade_button": "Rétrograder maintenant",
"general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.", "general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.",
"general_update_error_details": "Détails de l'erreur : {errorMessage}", "general_update_error_details": "Détails de l'erreur : {errorMessage}",
"general_update_error_title": "Erreur de mise à jour", "general_update_error_title": "Erreur de mise à jour",
"general_update_keep_current_button": "Conserver la version actuelle",
"general_update_later_button": "Faire plus tard", "general_update_later_button": "Faire plus tard",
"general_update_now_button": "Mettre à jour maintenant", "general_update_now_button": "Mettre à jour maintenant",
"general_update_rebooting": "Redémarrage pour terminer la mise à jour…", "general_update_rebooting": "Redémarrage pour terminer la mise à jour…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Le système est à jour", "general_update_up_to_date_title": "Le système est à jour",
"general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.", "general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.",
"general_update_updating_title": "Mise à jour de votre appareil", "general_update_updating_title": "Mise à jour de votre appareil",
"general_update_will_disable_auto_update_description": "Vous allez modifier manuellement la version de votre appareil. La mise à jour automatique sera désactivée une fois la mise à jour terminée afin d'éviter toute mise à jour accidentelle.",
"getting_remote_session_description": "Obtention d'{attempt} description de session à distance", "getting_remote_session_description": "Obtention d'{attempt} description de session à distance",
"hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}", "hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}",
"hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}", "hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "La connexion a échoué", "peer_connection_failed": "La connexion a échoué",
"peer_connection_new": "Nouveau", "peer_connection_new": "Nouveau",
"previous": "Précédent", "previous": "Précédent",
"public_ip_card_header": "Adresses IP publiques",
"public_ip_card_refresh": "Rafraîchir",
"public_ip_card_refresh_error": "Échec de l'actualisation des adresses IP publiques : {error}",
"register_device_error": "Une erreur {error} s'est produite lors de l'enregistrement de votre appareil.", "register_device_error": "Une erreur {error} s'est produite lors de l'enregistrement de votre appareil.",
"register_device_finish_button": "Terminer la configuration", "register_device_finish_button": "Terminer la configuration",
"register_device_name_description": "Nommez votre appareil pour pouvoir l'identifier facilement plus tard. Vous pouvez modifier ce nom à tout moment.", "register_device_name_description": "Nommez votre appareil pour pouvoir l'identifier facilement plus tard. Vous pouvez modifier ce nom à tout moment.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}", "advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}",
"advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}", "advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}",
"advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}", "advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}",
"advanced_error_version_update": "Impossibile avviare l'aggiornamento della versione: {error}",
"advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)", "advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)",
"advanced_loopback_only_title": "Modalità solo loopback", "advanced_loopback_only_title": "Modalità solo loopback",
"advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:", "advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Aggiorna la chiave SSH", "advanced_update_ssh_key_button": "Aggiorna la chiave SSH",
"advanced_usb_emulation_description": "Controlla lo stato di emulazione USB", "advanced_usb_emulation_description": "Controlla lo stato di emulazione USB",
"advanced_usb_emulation_title": "Emulazione USB", "advanced_usb_emulation_title": "Emulazione USB",
"advanced_version_update_app_label": "Versione dell'app",
"advanced_version_update_button": "Aggiorna alla versione",
"advanced_version_update_description": "Installa una versione specifica dalle versioni di GitHub",
"advanced_version_update_github_link": "Pagina delle versioni di JetKVM",
"advanced_version_update_helper": "Trova le versioni disponibili su",
"advanced_version_update_reset_config_description": "Ripristina la configurazione dopo l'aggiornamento",
"advanced_version_update_reset_config_label": "Reimposta configurazione",
"advanced_version_update_system_label": "Versione del sistema",
"advanced_version_update_target_app": "Solo app",
"advanced_version_update_target_both": "Sia l'app che il sistema",
"advanced_version_update_target_label": "Cosa aggiornare",
"advanced_version_update_target_system": "Solo sistema",
"advanced_version_update_title": "Aggiorna alla versione specifica",
"already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.", "already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.",
"already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.", "already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.",
"already_adopted_return_to_dashboard": "Torna alla dashboard", "already_adopted_return_to_dashboard": "Torna alla dashboard",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.", "connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.",
"connection_stats_playback_delay": "Ritardo di riproduzione", "connection_stats_playback_delay": "Ritardo di riproduzione",
"connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.", "connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.",
"connection_stats_remote_ip_address": "Indirizzo IP remoto",
"connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto",
"connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti",
"connection_stats_remote_ip_address_description": "L'indirizzo IP del dispositivo remoto.",
"connection_stats_round_trip_time": "Tempo di andata e ritorno", "connection_stats_round_trip_time": "Tempo di andata e ritorno",
"connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.", "connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
"connection_stats_sidebar": "Statistiche di connessione", "connection_stats_sidebar": "Statistiche di connessione",
@ -241,6 +259,7 @@
"general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione", "general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione",
"general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}", "general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}",
"general_auto_update_title": "Aggiornamento automatico", "general_auto_update_title": "Aggiornamento automatico",
"general_check_for_stable_updates": "Declassare",
"general_check_for_updates": "Verifica aggiornamenti", "general_check_for_updates": "Verifica aggiornamenti",
"general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze", "general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
"general_reboot_description": "Vuoi procedere con il riavvio del sistema?", "general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Controllo degli aggiornamenti…", "general_update_checking_title": "Controllo degli aggiornamenti…",
"general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!", "general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!",
"general_update_completed_title": "Aggiornamento completato con successo", "general_update_completed_title": "Aggiornamento completato con successo",
"general_update_downgrade_available_description": "È possibile effettuare il downgrade per tornare a una versione precedente.",
"general_update_downgrade_available_title": "Downgrade disponibile",
"general_update_downgrade_button": "Effettua il downgrade ora",
"general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.", "general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.",
"general_update_error_details": "Dettagli errore: {errorMessage}", "general_update_error_details": "Dettagli errore: {errorMessage}",
"general_update_error_title": "Errore di aggiornamento", "general_update_error_title": "Errore di aggiornamento",
"general_update_keep_current_button": "Mantieni la versione corrente",
"general_update_later_button": "Fallo più tardi", "general_update_later_button": "Fallo più tardi",
"general_update_now_button": "Aggiorna ora", "general_update_now_button": "Aggiorna ora",
"general_update_rebooting": "Riavvio per completare l'aggiornamento…", "general_update_rebooting": "Riavvio per completare l'aggiornamento…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Il sistema è aggiornato", "general_update_up_to_date_title": "Il sistema è aggiornato",
"general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.", "general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.",
"general_update_updating_title": "Aggiornamento del dispositivo", "general_update_updating_title": "Aggiornamento del dispositivo",
"general_update_will_disable_auto_update_description": "Stai per modificare manualmente la versione del tuo dispositivo. L'aggiornamento automatico verrà disattivato al termine dell'aggiornamento per evitare aggiornamenti accidentali.",
"getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}", "getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}",
"hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}", "hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}",
"hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}", "hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Connessione fallita", "peer_connection_failed": "Connessione fallita",
"peer_connection_new": "Collegamento", "peer_connection_new": "Collegamento",
"previous": "Precedente", "previous": "Precedente",
"public_ip_card_header": "Indirizzi IP pubblici",
"public_ip_card_refresh": "Aggiorna",
"public_ip_card_refresh_error": "Impossibile aggiornare gli indirizzi IP pubblici: {error}",
"register_device_error": "Si è verificato un errore {error} durante la registrazione del dispositivo.", "register_device_error": "Si è verificato un errore {error} durante la registrazione del dispositivo.",
"register_device_finish_button": "Completa l'installazione", "register_device_finish_button": "Completa l'installazione",
"register_device_name_description": "Assegna un nome al tuo dispositivo per poterlo identificare facilmente in seguito. Puoi cambiare questo nome in qualsiasi momento.", "register_device_name_description": "Assegna un nome al tuo dispositivo per poterlo identificare facilmente in seguito. Puoi cambiare questo nome in qualsiasi momento.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}", "advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}",
"advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}", "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
"advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
"advanced_error_version_update": "Kunne ikke starte versjonsoppdatering: {error}",
"advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)", "advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)",
"advanced_loopback_only_title": "Kun lokal tilgang", "advanced_loopback_only_title": "Kun lokal tilgang",
"advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:", "advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Oppdater SSH-nøkkel", "advanced_update_ssh_key_button": "Oppdater SSH-nøkkel",
"advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden", "advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden",
"advanced_usb_emulation_title": "USB-emulering", "advanced_usb_emulation_title": "USB-emulering",
"advanced_version_update_app_label": "Appversjon",
"advanced_version_update_button": "Oppdater til versjon",
"advanced_version_update_description": "Installer en spesifikk versjon fra GitHub-utgivelser",
"advanced_version_update_github_link": "JetKVM-utgivelsesside",
"advanced_version_update_helper": "Finn tilgjengelige versjoner på",
"advanced_version_update_reset_config_description": "Tilbakestill konfigurasjonen etter oppdateringen",
"advanced_version_update_reset_config_label": "Tilbakestill konfigurasjon",
"advanced_version_update_system_label": "Systemversjon",
"advanced_version_update_target_app": "Kun app",
"advanced_version_update_target_both": "Både app og system",
"advanced_version_update_target_label": "Hva som skal oppdateres",
"advanced_version_update_target_system": "Kun systemet",
"advanced_version_update_title": "Oppdatering til spesifikk versjon",
"already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.", "already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.",
"already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.", "already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.",
"already_adopted_return_to_dashboard": "Gå tilbake til dashbordet", "already_adopted_return_to_dashboard": "Gå tilbake til dashbordet",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.", "connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.",
"connection_stats_playback_delay": "Avspillingsforsinkelse", "connection_stats_playback_delay": "Avspillingsforsinkelse",
"connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.", "connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.",
"connection_stats_remote_ip_address": "Ekstern IP-adresse",
"connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen",
"connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen",
"connection_stats_remote_ip_address_description": "IP-adressen til den eksterne enheten.",
"connection_stats_round_trip_time": "Tur-retur-tid", "connection_stats_round_trip_time": "Tur-retur-tid",
"connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.", "connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
"connection_stats_sidebar": "Tilkoblingsstatistikk", "connection_stats_sidebar": "Tilkoblingsstatistikk",
@ -241,6 +259,7 @@
"general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen", "general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen",
"general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}", "general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}",
"general_auto_update_title": "Automatisk oppdatering", "general_auto_update_title": "Automatisk oppdatering",
"general_check_for_stable_updates": "Nedgrader",
"general_check_for_updates": "Se etter oppdateringer", "general_check_for_updates": "Se etter oppdateringer",
"general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser", "general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
"general_reboot_description": "Vil du fortsette med å starte systemet på nytt?", "general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Ser etter oppdateringer …", "general_update_checking_title": "Ser etter oppdateringer …",
"general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!", "general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!",
"general_update_completed_title": "Oppdatering fullført", "general_update_completed_title": "Oppdatering fullført",
"general_update_downgrade_available_description": "En nedgradering er tilgjengelig for å gå tilbake til en tidligere versjon.",
"general_update_downgrade_available_title": "Nedgradering tilgjengelig",
"general_update_downgrade_button": "Nedgrader nå",
"general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.", "general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.",
"general_update_error_details": "Feildetaljer: {errorMessage}", "general_update_error_details": "Feildetaljer: {errorMessage}",
"general_update_error_title": "Oppdateringsfeil", "general_update_error_title": "Oppdateringsfeil",
"general_update_keep_current_button": "Behold gjeldende versjon",
"general_update_later_button": "Oppdater senere", "general_update_later_button": "Oppdater senere",
"general_update_now_button": "Oppdater nå", "general_update_now_button": "Oppdater nå",
"general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …", "general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Alt er oppdatert", "general_update_up_to_date_title": "Alt er oppdatert",
"general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.", "general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.",
"general_update_updating_title": "Oppdaterer enheten din", "general_update_updating_title": "Oppdaterer enheten din",
"general_update_will_disable_auto_update_description": "Du er i ferd med å endre enhetsversjonen manuelt. Automatisk oppdatering vil bli deaktivert etter at oppdateringen er fullført for å forhindre utilsiktede oppdateringer.",
"getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}", "getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}",
"hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}", "hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}",
"hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}", "hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Tilkoblingen mislyktes", "peer_connection_failed": "Tilkoblingen mislyktes",
"peer_connection_new": "Tilkobling", "peer_connection_new": "Tilkobling",
"previous": "Tidligere", "previous": "Tidligere",
"public_ip_card_header": "Offentlige IP-adresser",
"public_ip_card_refresh": "Forfriske",
"public_ip_card_refresh_error": "Kunne ikke oppdatere offentlige IP-adresser: {error}",
"register_device_error": "Det oppsto en feil {error} under registrering av enheten din.", "register_device_error": "Det oppsto en feil {error} under registrering av enheten din.",
"register_device_finish_button": "Fullfør oppsettet", "register_device_finish_button": "Fullfør oppsettet",
"register_device_name_description": "Gi enheten din et navn slik at du enkelt kan identifisere den senere. Du kan endre dette navnet når som helst.", "register_device_name_description": "Gi enheten din et navn slik at du enkelt kan identifisere den senere. Du kan endre dette navnet når som helst.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}", "advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}",
"advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}", "advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}",
"advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}", "advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}",
"advanced_error_version_update": "Misslyckades med att initiera versionsuppdatering: {error}",
"advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)", "advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-läge", "advanced_loopback_only_title": "Loopback-läge",
"advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:", "advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "Uppdatera SSH-nyckel", "advanced_update_ssh_key_button": "Uppdatera SSH-nyckel",
"advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen", "advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen",
"advanced_usb_emulation_title": "USB-emulering", "advanced_usb_emulation_title": "USB-emulering",
"advanced_version_update_app_label": "Appversion",
"advanced_version_update_button": "Uppdatera till version",
"advanced_version_update_description": "Installera en specifik version från GitHub-utgåvor",
"advanced_version_update_github_link": "JetKVM-utgåvorsida",
"advanced_version_update_helper": "Hitta tillgängliga versioner på",
"advanced_version_update_reset_config_description": "Återställ konfigurationen efter uppdateringen",
"advanced_version_update_reset_config_label": "Återställ konfigurationen",
"advanced_version_update_system_label": "Systemversion",
"advanced_version_update_target_app": "Endast app",
"advanced_version_update_target_both": "Både app och system",
"advanced_version_update_target_label": "Vad som ska uppdateras",
"advanced_version_update_target_system": "Endast systemet",
"advanced_version_update_title": "Uppdatera till specifik version",
"already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.", "already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.",
"already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.", "already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.",
"already_adopted_return_to_dashboard": "Återgå till instrumentpanelen", "already_adopted_return_to_dashboard": "Återgå till instrumentpanelen",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.", "connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.",
"connection_stats_playback_delay": "Uppspelningsfördröjning", "connection_stats_playback_delay": "Uppspelningsfördröjning",
"connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.", "connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.",
"connection_stats_remote_ip_address": "Fjärr-IP-adress",
"connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen",
"connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp",
"connection_stats_remote_ip_address_description": "IP-adressen för den fjärranslutna enheten.",
"connection_stats_round_trip_time": "Tur- och returtid", "connection_stats_round_trip_time": "Tur- och returtid",
"connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.", "connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
"connection_stats_sidebar": "Anslutningsstatistik", "connection_stats_sidebar": "Anslutningsstatistik",
@ -241,6 +259,7 @@
"general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen", "general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen",
"general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}", "general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}",
"general_auto_update_title": "Automatisk uppdatering", "general_auto_update_title": "Automatisk uppdatering",
"general_check_for_stable_updates": "Nedvärdera",
"general_check_for_updates": "Kontrollera efter uppdateringar", "general_check_for_updates": "Kontrollera efter uppdateringar",
"general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar", "general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
"general_reboot_description": "Vill du fortsätta med att starta om systemet?", "general_reboot_description": "Vill du fortsätta med att starta om systemet?",
@ -261,9 +280,13 @@
"general_update_checking_title": "Söker efter uppdateringar…", "general_update_checking_title": "Söker efter uppdateringar…",
"general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!", "general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!",
"general_update_completed_title": "Uppdateringen är slutförd", "general_update_completed_title": "Uppdateringen är slutförd",
"general_update_downgrade_available_description": "En nedgradering är tillgänglig för att återgå till en tidigare version.",
"general_update_downgrade_available_title": "Nedgradering tillgänglig",
"general_update_downgrade_button": "Nedgradera nu",
"general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.", "general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.",
"general_update_error_details": "Felinformation: {errorMessage}", "general_update_error_details": "Felinformation: {errorMessage}",
"general_update_error_title": "Uppdateringsfel", "general_update_error_title": "Uppdateringsfel",
"general_update_keep_current_button": "Behåll aktuell version",
"general_update_later_button": "Gör det senare", "general_update_later_button": "Gör det senare",
"general_update_now_button": "Uppdatera nu", "general_update_now_button": "Uppdatera nu",
"general_update_rebooting": "Startar om för att slutföra uppdateringen…", "general_update_rebooting": "Startar om för att slutföra uppdateringen…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "Systemet är uppdaterat", "general_update_up_to_date_title": "Systemet är uppdaterat",
"general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.", "general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.",
"general_update_updating_title": "Uppdaterar din enhet", "general_update_updating_title": "Uppdaterar din enhet",
"general_update_will_disable_auto_update_description": "Du håller på att ändra din enhetsversion manuellt. Automatisk uppdatering inaktiveras efter att uppdateringen är klar för att förhindra oavsiktliga uppdateringar.",
"getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}", "getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}",
"hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}", "hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}",
"hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}", "hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "Anslutningen misslyckades", "peer_connection_failed": "Anslutningen misslyckades",
"peer_connection_new": "Ansluter", "peer_connection_new": "Ansluter",
"previous": "Föregående", "previous": "Föregående",
"public_ip_card_header": "Offentliga IP-adresser",
"public_ip_card_refresh": "Uppdatera",
"public_ip_card_refresh_error": "Misslyckades med att uppdatera offentliga IP-adresser: {error}",
"register_device_error": "Det uppstod ett fel {error} din enhet registrerades.", "register_device_error": "Det uppstod ett fel {error} din enhet registrerades.",
"register_device_finish_button": "Slutför installationen", "register_device_finish_button": "Slutför installationen",
"register_device_name_description": "Namnge din enhet så att du enkelt kan identifiera den senare. Du kan ändra namnet när som helst.", "register_device_name_description": "Namnge din enhet så att du enkelt kan identifiera den senare. Du kan ändra namnet när som helst.",

View File

@ -74,6 +74,7 @@
"advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}", "advanced_error_update_ssh_key": "无法更新 SSH 密钥: {error}",
"advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}", "advanced_error_usb_emulation_disable": "无法禁用 USB 仿真: {error}",
"advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}", "advanced_error_usb_emulation_enable": "无法启用 USB 仿真: {error}",
"advanced_error_version_update": "版本更新失败: {error}",
"advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机127.0.0.1", "advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机127.0.0.1",
"advanced_loopback_only_title": "仅环回模式", "advanced_loopback_only_title": "仅环回模式",
"advanced_loopback_warning_before": "在启用此功能之前,请确保您已:", "advanced_loopback_warning_before": "在启用此功能之前,请确保您已:",
@ -100,6 +101,19 @@
"advanced_update_ssh_key_button": "更新 SSH 密钥", "advanced_update_ssh_key_button": "更新 SSH 密钥",
"advanced_usb_emulation_description": "控制 USB 仿真状态", "advanced_usb_emulation_description": "控制 USB 仿真状态",
"advanced_usb_emulation_title": "USB 仿真", "advanced_usb_emulation_title": "USB 仿真",
"advanced_version_update_app_label": "应用版本",
"advanced_version_update_button": "更新至版本",
"advanced_version_update_description": "从 GitHub 发布页面安装特定版本",
"advanced_version_update_github_link": "JetKVM 发布页面",
"advanced_version_update_helper": "在以下平台查找可用版本",
"advanced_version_update_reset_config_description": "更新后重置配置",
"advanced_version_update_reset_config_label": "重置配置",
"advanced_version_update_system_label": "系统版本",
"advanced_version_update_target_app": "仅限应用内购买",
"advanced_version_update_target_both": "应用程序和系统",
"advanced_version_update_target_label": "需要更新什么",
"advanced_version_update_target_system": "仅系统",
"advanced_version_update_title": "更新至特定版本",
"already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。", "already_adopted_new_owner": "如果您是新用户,请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误,请联系我们的支持团队寻求帮助。",
"already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。", "already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。",
"already_adopted_return_to_dashboard": "返回仪表板", "already_adopted_return_to_dashboard": "返回仪表板",
@ -176,6 +190,10 @@
"connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。", "connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
"connection_stats_playback_delay": "播放延迟", "connection_stats_playback_delay": "播放延迟",
"connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。", "connection_stats_playback_delay_description": "当帧不均匀到达时,抖动缓冲区添加延迟以平滑播放。",
"connection_stats_remote_ip_address": "远程 IP 地址",
"connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败",
"connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板",
"connection_stats_remote_ip_address_description": "远程设备的IP地址。",
"connection_stats_round_trip_time": "往返时间", "connection_stats_round_trip_time": "往返时间",
"connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。", "connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
"connection_stats_sidebar": "连接统计", "connection_stats_sidebar": "连接统计",
@ -241,6 +259,7 @@
"general_auto_update_description": "自动将设备更新到最新版本", "general_auto_update_description": "自动将设备更新到最新版本",
"general_auto_update_error": "无法设置自动更新: {error}", "general_auto_update_error": "无法设置自动更新: {error}",
"general_auto_update_title": "自动更新", "general_auto_update_title": "自动更新",
"general_check_for_stable_updates": "降级",
"general_check_for_updates": "检查更新", "general_check_for_updates": "检查更新",
"general_page_description": "配置设备设置并更新首选项", "general_page_description": "配置设备设置并更新首选项",
"general_reboot_description": "您想继续重新启动系统吗?", "general_reboot_description": "您想继续重新启动系统吗?",
@ -261,9 +280,13 @@
"general_update_checking_title": "正在检查更新…", "general_update_checking_title": "正在检查更新…",
"general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!", "general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧!",
"general_update_completed_title": "更新已成功完成", "general_update_completed_title": "更新已成功完成",
"general_update_downgrade_available_description": "可以降级到以前的版本。",
"general_update_downgrade_available_title": "可降级",
"general_update_downgrade_button": "立即降级",
"general_update_error_description": "更新您的设备时出错。请稍后重试。", "general_update_error_description": "更新您的设备时出错。请稍后重试。",
"general_update_error_details": "错误详细信息: {errorMessage}", "general_update_error_details": "错误详细信息: {errorMessage}",
"general_update_error_title": "更新错误", "general_update_error_title": "更新错误",
"general_update_keep_current_button": "保持当前版本",
"general_update_later_button": "稍后再说", "general_update_later_button": "稍后再说",
"general_update_now_button": "立即更新", "general_update_now_button": "立即更新",
"general_update_rebooting": "重新启动以完成更新…", "general_update_rebooting": "重新启动以完成更新…",
@ -279,6 +302,7 @@
"general_update_up_to_date_title": "系统已更新", "general_update_up_to_date_title": "系统已更新",
"general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。", "general_update_updating_description": "正在更新,请勿关闭设备。该过程可能需要数分钟。",
"general_update_updating_title": "更新您的设备", "general_update_updating_title": "更新您的设备",
"general_update_will_disable_auto_update_description": "您即将手动更改设备版本。更新完成后,自动更新功能将被禁用,以防止意外更新。",
"getting_remote_session_description": "获取远程会话描述尝试 {attempt}", "getting_remote_session_description": "获取远程会话描述尝试 {attempt}",
"hardware_backlight_settings_error": "无法设置背光设置: {error}", "hardware_backlight_settings_error": "无法设置背光设置: {error}",
"hardware_backlight_settings_get_error": "无法获取背光设置: {error}", "hardware_backlight_settings_get_error": "无法获取背光设置: {error}",
@ -701,6 +725,9 @@
"peer_connection_failed": "连接失败", "peer_connection_failed": "连接失败",
"peer_connection_new": "正在连接", "peer_connection_new": "正在连接",
"previous": "上一步", "previous": "上一步",
"public_ip_card_header": "公共 IP 地址",
"public_ip_card_refresh": "刷新",
"public_ip_card_refresh_error": "刷新公网 IP 地址失败: {error}",
"register_device_error": "注册您的设备时出现错误{error} 。", "register_device_error": "注册您的设备时出现错误{error} 。",
"register_device_finish_button": "完成设置", "register_device_finish_button": "完成设置",
"register_device_name_description": "为您的设备命名,以便日后轻松识别。您可以随时更改此名称。", "register_device_name_description": "为您的设备命名,以便日后轻松识别。您可以随时更改此名称。",

View File

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

View File

@ -6,6 +6,19 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
export interface VersionInfo {
appVersion: string;
systemVersion: string;
}
export interface SystemVersionInfo {
local: VersionInfo;
remote?: VersionInfo;
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
error?: string;
}
export function useVersion() { export function useVersion() {
const { const {
appVersion, appVersion,

View File

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

View File

@ -1,21 +1,30 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useSettingsStore } from "@hooks/stores"; import { useSettingsStore } from "@hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc"; import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
import { Button } from "@components/Button"; import { Button } from "@components/Button";
import Checkbox from "@components/Checkbox"; import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
import { ConfirmDialog } from "@components/ConfirmDialog"; import { ConfirmDialog } from "@components/ConfirmDialog";
import { GridCard } from "@components/Card"; import { GridCard } from "@components/Card";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
import { TextAreaWithLabel } from "@components/TextArea"; import { TextAreaWithLabel } from "@components/TextArea";
import { InputFieldWithLabel } from "@components/InputField";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { isOnDevice } from "@/main"; import { isOnDevice } from "@/main";
import notifications from "@/notifications"; import notifications from "@/notifications";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils"; import { sleep } from "@/utils";
import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
import { SystemVersionInfo } from "@hooks/useVersion";
import { FeatureFlag } from "../components/FeatureFlag";
export default function SettingsAdvancedRoute() { export default function SettingsAdvancedRoute() {
const { send } = useJsonRpc(); const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const [sshKey, setSSHKey] = useState<string>(""); const [sshKey, setSSHKey] = useState<string>("");
const { setDeveloperMode } = useSettingsStore(); const { setDeveloperMode } = useSettingsStore();
@ -23,7 +32,12 @@ export default function SettingsAdvancedRoute() {
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false); const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false); const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false); const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
const [updateTarget, setUpdateTarget] = useState<string>("app");
const [appVersion, setAppVersion] = useState<string>("");
const [systemVersion, setSystemVersion] = useState<string>("");
const [resetConfig, setResetConfig] = useState(false);
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false);
const settings = useSettingsStore(); const settings = useSettingsStore();
useEffect(() => { useEffect(() => {
@ -173,6 +187,61 @@ export default function SettingsAdvancedRoute() {
setShowLoopbackWarning(false); setShowLoopbackWarning(false);
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]); }, [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
);
setCustomVersionUpdateLoading(false);
}, []);
const handleCustomVersionUpdate = useCallback(async () => {
const components: UpdateComponents = {};
if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion;
if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion;
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
setCustomVersionUpdateLoading(true);
versionInfo = await checkUpdateComponents({
components,
}, devChannel);
} catch (error: unknown) {
const jsonRpcError = error as JsonRpcError;
handleVersionUpdateError(jsonRpcError);
return;
}
let hasUpdate = false;
const pageParams = new URLSearchParams();
if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) {
hasUpdate = true;
pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
}
if (components.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,
setCustomVersionUpdateLoading
]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<SettingsPageHeader <SettingsPageHeader
@ -201,41 +270,151 @@ export default function SettingsAdvancedRoute() {
onChange={e => handleDevModeChange(e.target.checked)} onChange={e => handleDevModeChange(e.target.checked)}
/> />
</SettingsItem> </SettingsItem>
{settings.developerMode ? (
{settings.developerMode && ( <NestedSettingsGroup>
<GridCard> <GridCard>
<div className="flex items-start gap-x-4 p-4 select-none"> <div className="flex items-start gap-x-4 p-4 select-none">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500" className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
> >
<path <path
fillRule="evenodd" fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd" clipRule="evenodd"
/> />
</svg> </svg>
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white"> <h3 className="text-base font-bold text-slate-900 dark:text-white">
{m.advanced_developer_mode_enabled_title()} {m.advanced_developer_mode_enabled_title()}
</h3> </h3>
<div> <div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300"> <ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>{m.advanced_developer_mode_warning_security()}</li> <li>{m.advanced_developer_mode_warning_security()}</li>
<li>{m.advanced_developer_mode_warning_risks()}</li> <li>{m.advanced_developer_mode_warning_risks()}</li>
</ul> </ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
{m.advanced_developer_mode_warning_advanced()}
</div> </div>
</div> </div>
<div className="text-xs text-slate-700 dark:text-slate-300"> </div>
{m.advanced_developer_mode_warning_advanced()} </GridCard>
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div> </div>
</div> </div>
</div> )}
</GridCard>
)} <FeatureFlag minAppVersion="0.4.10" name="version-update">
<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 ||
customVersionUpdateLoading
}
loading={customVersionUpdateLoading}
onClick={handleCustomVersionUpdate}
/>
</div>
</FeatureFlag>
</NestedSettingsGroup>
) : null}
<SettingsItem <SettingsItem
title={m.advanced_loopback_only_title()} title={m.advanced_loopback_only_title()}
@ -247,34 +426,7 @@ export default function SettingsAdvancedRoute() {
/> />
</SettingsItem> </SettingsItem>
{isOnDevice && settings.developerMode && (
<div className="space-y-4">
<SettingsItem
title={m.advanced_ssh_access_title()}
description={m.advanced_ssh_access_description()}
/>
<div className="space-y-4">
<TextAreaWithLabel
label={m.advanced_ssh_public_key_label()}
value={sshKey || ""}
rows={3}
onChange={e => setSSHKey(e.target.value)}
placeholder={m.advanced_ssh_public_key_placeholder()}
/>
<p className="text-xs text-slate-600 dark:text-slate-400">
{m.advanced_ssh_default_user()}<strong>root</strong>.
</p>
<div className="flex items-center gap-x-2">
<Button
size="SM"
theme="primary"
text={m.advanced_update_ssh_key_button()}
onClick={handleUpdateSSHKey}
/>
</div>
</div>
</div>
)}
<SettingsItem <SettingsItem
title={m.advanced_troubleshooting_mode_title()} title={m.advanced_troubleshooting_mode_title()}
@ -289,7 +441,7 @@ export default function SettingsAdvancedRoute() {
</SettingsItem> </SettingsItem>
{settings.debugMode && ( {settings.debugMode && (
<> <NestedSettingsGroup>
<SettingsItem <SettingsItem
title={m.advanced_usb_emulation_title()} title={m.advanced_usb_emulation_title()}
description={m.advanced_usb_emulation_description()} description={m.advanced_usb_emulation_description()}
@ -320,7 +472,7 @@ export default function SettingsAdvancedRoute() {
}} }}
/> />
</SettingsItem> </SettingsItem>
</> </NestedSettingsGroup>
)} )}
</div> </div>

View File

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

View File

@ -11,7 +11,7 @@ import LoadingSpinner from "../components/LoadingSpinner";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation"; import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
// Time to wait after initiating reboot before redirecting to home // Time to wait after initiating reboot before redirecting to home
const REBOOT_REDIRECT_DELAY_MS = 5000; const REBOOT_REDIRECT_DELAY_MS = 7000;
export default function SettingsGeneralRebootRoute() { export default function SettingsGeneralRebootRoute() {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router"; import { useLocation, useNavigate, useSearchParams } from "react-router";
import { useJsonRpc } from "@hooks/useJsonRpc"; import { useJsonRpc } from "@hooks/useJsonRpc";
import { UpdateState, useUpdateStore } from "@hooks/stores"; import { UpdateState, useUpdateStore } from "@hooks/stores";
@ -11,16 +11,21 @@ import LoadingSpinner from "@components/LoadingSpinner";
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard"; import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
import { m } from "@localizations/messages.js"; import { m } from "@localizations/messages.js";
import { sleep } from "@/utils"; import { sleep } from "@/utils";
import { SystemVersionInfo } from "@/utils/jsonrpc"; import { checkUpdateComponents, SystemVersionInfo, UpdateComponents, updateParams } from "@/utils/jsonrpc";
export default function SettingsGeneralUpdateRoute() { export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
const { updateSuccess } = location.state || {}; const { updateSuccess } = location.state || {};
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore(); const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
const { send } = useJsonRpc(); 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 () => { const onClose = useCallback(async () => {
navigate(".."); // back to the devices.$id.settings page navigate(".."); // back to the devices.$id.settings page
@ -37,6 +42,21 @@ export default function SettingsGeneralUpdateRoute() {
setModalView("updating"); setModalView("updating");
}, [send, setModalView, setShouldReload]); }, [send, setModalView, setShouldReload]);
const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => {
const components: UpdateComponents = {};
if (appTargetVersion) components.app = appTargetVersion;
if (systemTargetVersion) components.system = systemTargetVersion;
setShouldReload(true);
setModalView("updating");
send("tryUpdateComponents", {
params: { components, },
includePreRelease: false,
resetConfig,
});
}, [resetConfig, send, setModalView, setShouldReload]);
useEffect(() => { useEffect(() => {
if (otaState.updating) { if (otaState.updating) {
setModalView("updating"); setModalView("updating");
@ -49,20 +69,39 @@ export default function SettingsGeneralUpdateRoute() {
} }
}, [otaState.error, otaState.updating, setModalView, updateSuccess]); }, [otaState.error, otaState.updating, setModalView, updateSuccess]);
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />; return <Dialog
onClose={onClose}
onConfirmUpdate={onConfirmUpdate}
onConfirmCustomUpdate={onConfirmCustomUpdate}
customAppVersion={customAppVersion}
customSystemVersion={customSystemVersion}
/>;
} }
export function Dialog({ export function Dialog({
onClose, onClose,
onConfirmUpdate, onConfirmUpdate,
onConfirmCustomUpdate: onConfirmCustomUpdateCallback,
customAppVersion,
customSystemVersion,
}: Readonly<{ }: Readonly<{
onClose: () => void; onClose: () => void;
onConfirmUpdate: () => void; onConfirmUpdate: () => void;
onConfirmCustomUpdate: (appVersion?: string, systemVersion?: string) => void;
customAppVersion?: string;
customSystemVersion?: string;
}>) { }>) {
const { navigateTo } = useDeviceUiNavigation(); const { navigateTo } = useDeviceUiNavigation();
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null); const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
const { modalView, setModalView, otaState } = useUpdateStore(); const { modalView, setModalView, otaState } = useUpdateStore();
const 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( const onFinishedLoading = useCallback(
(versionInfo: SystemVersionInfo) => { (versionInfo: SystemVersionInfo) => {
@ -71,13 +110,13 @@ export function Dialog({
setVersionInfo(versionInfo); setVersionInfo(versionInfo);
if (hasUpdate) { if (hasUpdate || forceCustomUpdate) {
setModalView("updateAvailable"); setModalView("updateAvailable");
} else { } else {
setModalView("upToDate"); setModalView("upToDate");
} }
}, },
[setModalView], [setModalView, forceCustomUpdate],
); );
return ( return (
@ -92,12 +131,18 @@ export function Dialog({
)} )}
{modalView === "loading" && ( {modalView === "loading" && (
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} /> <LoadingState
onFinished={onFinishedLoading}
onCancelCheck={onClose}
customAppVersion={customAppVersion}
customSystemVersion={customSystemVersion}
/>
)} )}
{modalView === "updateAvailable" && ( {modalView === "updateAvailable" && (
<UpdateAvailableState <UpdateAvailableState
onConfirmUpdate={onConfirmUpdate} forceCustomUpdate={forceCustomUpdate}
onConfirm={forceCustomUpdate ? onConfirmCustomUpdate : onConfirmUpdate}
onClose={onClose} onClose={onClose}
versionInfo={versionInfo!} versionInfo={versionInfo!}
/> />
@ -126,9 +171,13 @@ export function Dialog({
function LoadingState({ function LoadingState({
onFinished, onFinished,
onCancelCheck, onCancelCheck,
customAppVersion,
customSystemVersion,
}: { }: {
onFinished: (versionInfo: SystemVersionInfo) => void; onFinished: (versionInfo: SystemVersionInfo) => void;
onCancelCheck: () => void; onCancelCheck: () => void;
customAppVersion?: string;
customSystemVersion?: string;
}) { }) {
const [progressWidth, setProgressWidth] = useState("0%"); const [progressWidth, setProgressWidth] = useState("0%");
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@ -138,6 +187,17 @@ function LoadingState({
const progressBarRef = useRef<HTMLDivElement>(null); const progressBarRef = useRef<HTMLDivElement>(null);
const checkUpdate = useCallback(async () => {
if (!customAppVersion && !customSystemVersion) {
return await getVersionInfo();
}
const params: updateParams = { components: {} as UpdateComponents };
if (customAppVersion) params.components!.app = customAppVersion;
if (customSystemVersion) params.components!.system = customSystemVersion;
return await checkUpdateComponents(params, false);
}, [customAppVersion, customSystemVersion, getVersionInfo]);
useEffect(() => { useEffect(() => {
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const signal = abortControllerRef.current.signal; const signal = abortControllerRef.current.signal;
@ -147,7 +207,7 @@ function LoadingState({
setProgressWidth("100%"); setProgressWidth("100%");
}, 0); }, 0);
getVersionInfo() checkUpdate()
.then(async versionInfo => { .then(async versionInfo => {
// Add a small delay to ensure it's not just flickering // Add a small delay to ensure it's not just flickering
await sleep(600); await sleep(600);
@ -169,7 +229,7 @@ function LoadingState({
clearTimeout(animationTimer); clearTimeout(animationTimer);
abortControllerRef.current?.abort(); abortControllerRef.current?.abort();
}; };
}, [getVersionInfo, onFinished, setModalView]); }, [checkUpdate, onFinished, setModalView]);
return ( return (
<div className="flex flex-col items-start justify-start space-y-4 text-left"> <div className="flex flex-col items-start justify-start space-y-4 text-left">
@ -377,11 +437,12 @@ function SystemUpToDateState({
function UpdateAvailableState({ function UpdateAvailableState({
versionInfo, versionInfo,
onConfirmUpdate, onConfirm,
onClose, onClose,
}: { }: {
versionInfo: SystemVersionInfo; versionInfo: SystemVersionInfo;
onConfirmUpdate: () => void; forceCustomUpdate: boolean;
onConfirm: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
return ( return (
@ -396,18 +457,23 @@ function UpdateAvailableState({
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300"> <p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
{versionInfo?.systemUpdateAvailable ? ( {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 /> <br />
</> </>
) : null} ) : null}
{versionInfo?.appUpdateAvailable ? ( {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} ) : null}
{versionInfo?.willDisableAutoUpdate ? (
<p className="mb-4 text-sm text-red-600 dark:text-red-400">
{m.general_update_will_disable_auto_update_description()}
</p>
) : null}
</p> </p>
<div className="flex items-center justify-start gap-x-2"> <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} /> <Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
</div> </div>
</div> </div>

View File

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

View File

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

View File

@ -221,16 +221,21 @@ export interface SystemVersionInfo {
remote?: VersionInfo; remote?: VersionInfo;
systemUpdateAvailable: boolean; systemUpdateAvailable: boolean;
appUpdateAvailable: boolean; appUpdateAvailable: boolean;
willDisableAutoUpdate?: boolean;
error?: string; error?: string;
} }
const UPDATE_STATUS_RPC_TIMEOUT_MS = 10000;
const UPDATE_STATUS_RPC_MAX_ATTEMPTS = 6;
export async function getUpdateStatus() { export async function getUpdateStatus() {
const response = await callJsonRpc<SystemVersionInfo>({ const response = await callJsonRpc<SystemVersionInfo>({
method: "getUpdateStatus", method: "getUpdateStatus",
// This function calls our api server to see if there are any updates available. // This function calls our api server to see if there are any updates available.
// It can be called on page load right after a restart, so we need to give it time to // It can be called on page load right after a restart, so we need to give it time to
// establish a connection to the api server. // establish a connection to the api server.
maxAttempts: 6, maxAttempts: UPDATE_STATUS_RPC_MAX_ATTEMPTS,
attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
}); });
if (response.error) throw response.error; if (response.error) throw response.error;
@ -242,3 +247,27 @@ export async function getLocalVersion() {
if (response.error) throw response.error; if (response.error) throw response.error;
return response.result; return response.result;
} }
export type UpdateComponent = "app" | "system";
export type UpdateComponents = Partial<Record<UpdateComponent, string>>;
export interface updateParams {
components?: UpdateComponents;
}
export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
const response = await callJsonRpc<SystemVersionInfo>({
method: "checkUpdateComponents",
params: {
params,
includePreRelease,
},
// maxAttempts is set to 1,
// because it currently retry for all errors,
// and we don't want to retry if the error is not a network error
maxAttempts: 1,
attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
});
if (response.error) throw response.error;
return response.result;
}

View File

@ -288,7 +288,7 @@ func newSession(config SessionConfig) (*Session, error) {
}) })
// Wait for channel to be open before sending initial state // Wait for channel to be open before sending initial state
d.OnOpen(func() { d.OnOpen(func() {
triggerOTAStateUpdate() triggerOTAStateUpdate(otaState.ToRPCState())
triggerVideoStateUpdate() triggerVideoStateUpdate()
triggerUSBStateUpdate() triggerUSBStateUpdate()
notifyFailsafeMode(session) notifyFailsafeMode(session)