Merge branch 'dev' into feat/audio-support

Integrated latest dev branch changes including:
- Native process refactoring with gRPC architecture
- OTA update system refactor with new component-based updates
- Updated build system and dependencies
- UI improvements and bug fixes

Post-merge fixes applied:
- Remove duplicate OTA RPC function declarations (now in ota.go)
- Fix GetDefaultEDID reference to use native.DefaultEDID constant
- Fix IsUpdatePending to use otaState.IsUpdatePending() method
- Add missing OTA RPC handler registrations for new update system

All audio functionality from feat/audio-support preserved.
All dev branch functionality preserved.
This commit is contained in:
Alex P 2025-11-20 22:49:33 +02:00
commit fba4eabf3b
89 changed files with 9990 additions and 1521 deletions

View File

@ -5,7 +5,7 @@ function sudo() {
if [ "$UID" -eq 0 ]; then
"$@"
else
${SUDO_PATH} -E "$@"
${SUDO_PATH} "$@"
fi
}
@ -17,7 +17,8 @@ sudo apt-get install -y --no-install-recommends \
iputils-ping \
build-essential \
device-tree-compiler \
gperf \
gperf g++-multilib gcc-multilib \
gdb-multiarch \
libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
wget zstd \
@ -31,35 +32,7 @@ pushd "${BUILDKIT_TMPDIR}" > /dev/null
wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSION}/buildkit.tar.zst && \
sudo mkdir -p /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="zstd -d --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
sudo tar --use-compress-program="unzstd --long=31" -xvf buildkit.tar.zst -C /opt/jetkvm-native-buildkit && \
rm buildkit.tar.zst
popd
# Install audio dependencies (ALSA and Opus) for JetKVM
echo "Installing JetKVM audio dependencies..."
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
PROJECT_ROOT="$(dirname "${SCRIPT_DIR}")"
AUDIO_DEPS_SCRIPT="${PROJECT_ROOT}/install_audio_deps.sh"
if [ -f "${AUDIO_DEPS_SCRIPT}" ]; then
echo "Running audio dependencies installation..."
# Pre-create audio libs directory with proper permissions
sudo mkdir -p /opt/jetkvm-audio-libs
sudo chmod 777 /opt/jetkvm-audio-libs
# Run installation script (now it can write without sudo)
bash "${AUDIO_DEPS_SCRIPT}"
echo "Audio dependencies installation completed."
if [ -d "/opt/jetkvm-audio-libs" ]; then
echo "Audio libraries installed in /opt/jetkvm-audio-libs"
# Set recursive permissions for all subdirectories and files
sudo chmod -R 777 /opt/jetkvm-audio-libs
echo "Permissions set to allow all users access to audio libraries"
else
echo "Error: /opt/jetkvm-audio-libs directory not found after installation."
exit 1
fi
else
echo "Warning: Audio dependencies script not found at ${AUDIO_DEPS_SCRIPT}"
echo "Skipping audio dependencies installation."
fi
rm -rf "${BUILDKIT_TMPDIR}"

17
.vscode/c_cpp_properties.json vendored Normal file
View File

@ -0,0 +1,17 @@
{
"configurations": [
{
"name": "Linux",
"includePath": [
"${workspaceFolder}/**"
],
"defines": [],
"compilerPath": "/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc",
"cStandard": "c17",
"cppStandard": "gnu++17",
"intelliSenseMode": "linux-gcc-arm",
"configurationProvider": "ms-vscode.cmake-tools"
}
],
"version": 4
}

28
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "GDB Debug - Native (binary)",
"type": "cppdbg",
"request": "launch",
"program": "internal/native/cgo/build/jknative-bin",
"args": [],
"stopAtEntry": true,
"cwd": "${workspaceFolder}",
"environment": [],
"MIMode": "gdb",
"miDebuggerPath": "/usr/bin/gdb-multiarch",
"miDebuggerServerAddress": "${config:TARGET_IP}:${config:DEBUG_PORT}",
"targetArchitecture": "arm",
"preLaunchTask": "deploy",
"setupCommands": [
{
"description": "Pretty-printing for gdb",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"externalConsole": true
}
]
}

17
.vscode/settings.json vendored
View File

@ -10,6 +10,19 @@
]
},
"git.ignoreLimitWarning": true,
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
"cmake.ignoreCMakeListsMissing": true
"cmake.sourceDirectory": "${workspaceFolder}/internal/native/cgo",
"cmake.ignoreCMakeListsMissing": true,
"C_Cpp.inlayHints.autoDeclarationTypes.enabled": true,
"C_Cpp.inlayHints.parameterNames.enabled": true,
"C_Cpp.inlayHints.referenceOperator.enabled": true,
"TARGET_IP": "192.168.0.199",
"DEBUG_PORT": "2345",
"json.schemas": [
{
"fileMatch": [
"/internal/ota/testdata/ota/*.json"
],
"url": "./internal/ota/testdata/ota.schema.json"
}
]
}

30
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,30 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "deploy",
"isBackground": true,
"type": "shell",
"command": "bash",
"args": [
"dev_deploy.sh",
"-r",
"${config:TARGET_IP}",
"--gdb-port",
"${config:DEBUG_PORT}",
"--native-binary",
"--disable-docker"
],
"problemMatcher": {
"base": "$gcc",
"background": {
"activeOnStart": true,
"beginsPattern": "${config:BINARY}",
"endsPattern": "Listening on port [0-9]{4}"
}
}
},
]
}

View File

@ -208,6 +208,12 @@ rm /userdata/kvm_config.json
systemctl restart jetkvm
```
### Debug native code with gdbserver
Change the `TARGET_IP` in `.vscode/settings.json` to your JetKVM device IP, then set breakpoints in your native code and start the `Debug Native` configuration in VSCode.
The code and GDB server will be deployed automatically.
---
## Testing Your Changes

107
Makefile
View File

@ -1,51 +1,9 @@
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
build_audio_deps:
bash .devcontainer/install_audio_deps.sh $(ALSA_VERSION) $(OPUS_VERSION)
# Prepare everything needed for local development (toolchain + audio deps + Go tools)
dev_env: build_audio_deps
$(CLEAN_GO_CACHE)
@echo "Installing Go development tools..."
go install golang.org/x/tools/cmd/goimports@latest
@echo "Development environment ready."
JETKVM_HOME ?= $(HOME)/.jetkvm
BUILDKIT_PATH ?= /opt/jetkvm-native-buildkit
BUILDKIT_FLAVOR ?= arm-rockchip830-linux-uclibcgnueabihf
AUDIO_LIBS_DIR ?= /opt/jetkvm-audio-libs
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date -u +%FT%T%z)
BUILDTS ?= $(shell date -u +%s)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8
# Audio library versions
ALSA_VERSION ?= 1.2.14
OPUS_VERSION ?= 1.5.2
# Set PKG_CONFIG_PATH globally for all targets that use CGO with audio libraries
export PKG_CONFIG_PATH := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/utils:$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)
# Common command to clean Go cache with verbose output for all Go builds
CLEAN_GO_CACHE := @echo "Cleaning Go cache..."; go clean -cache -v
# Optimization flags for ARM Cortex-A7 with NEON SIMD
OPTIM_CFLAGS := -O3 -mfpu=neon -mtune=cortex-a7 -mfloat-abi=hard -ftree-vectorize -ffast-math -funroll-loops -mvectorize-with-neon-quad -marm -D__ARM_NEON
# Cross-compilation environment for ARM - exported globally
export GOOS := linux
export GOARCH := arm
export GOARM := 7
export CC := $(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc
export CGO_ENABLED := 1
export CGO_CFLAGS := $(OPTIM_CFLAGS) -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/include -I$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/include
export CGO_LDFLAGS := -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/lib -L$(BUILDKIT_PATH)/$(BUILDKIT_FLAVOR)/sysroot/usr/lib -lrockit -lrockchip_mpp -lrga -lpthread -lm -ldl
# Audio-specific flags (only used for audio C binaries, NOT for main Go app)
AUDIO_CFLAGS := $(CGO_CFLAGS) -I$(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/include -I$(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/celt
AUDIO_LDFLAGS := $(AUDIO_LIBS_DIR)/alsa-lib-$(ALSA_VERSION)/src/.libs/libasound.a $(AUDIO_LIBS_DIR)/opus-$(OPUS_VERSION)/.libs/libopus.a -lm -ldl -lpthread
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD)
VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.9
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
@ -56,6 +14,8 @@ SKIP_NATIVE_IF_EXISTS ?= 0
SKIP_UI_BUILD ?= 0
ENABLE_SYNC_TRACE ?= 0
CMAKE_BUILD_TYPE ?= Release
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
ifeq ($(ENABLE_SYNC_TRACE), 1)
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
@ -94,29 +54,26 @@ build_native:
echo "Building native..."; \
CC="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc" \
LD="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-ld" \
CMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
./scripts/build_cgo.sh; \
fi
build_dev: build_native build_audio_deps
$(CLEAN_GO_CACHE)
build_dev: build_native
@echo "Building..."
go build \
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app -v cmd/main.go
build_test2json:
$(CLEAN_GO_CACHE)
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
$(CLEAN_GO_CACHE)
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_audio_deps build_test2json build_gotestsum
$(CLEAN_GO_CACHE)
build_dev_test: build_test2json build_gotestsum
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@ -126,7 +83,7 @@ build_dev_test: build_audio_deps build_test2json build_gotestsum
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
go test -v \
$(GO_CMD) test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
@ -163,10 +120,9 @@ dev_release: frontend build_dev
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend build_native build_audio_deps
$(CLEAN_GO_CACHE)
build_release: frontend build_native
@echo "Building release..."
go build \
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
@ -181,36 +137,3 @@ release:
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
# Run both Go and UI linting
lint: lint-go lint-ui
@echo "All linting completed successfully!"
# Run golangci-lint locally with the same configuration as CI
lint-go: build_audio_deps
@echo "Running golangci-lint..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --verbose
# Run both Go and UI linting with auto-fix
lint-fix: lint-go-fix lint-ui-fix
@echo "All linting with auto-fix completed successfully!"
# Run golangci-lint with auto-fix
lint-go-fix: build_audio_deps
@echo "Running golangci-lint with auto-fix..."
@mkdir -p static && touch static/.gitkeep
golangci-lint run --fix --verbose
# Run UI linting locally (mirrors GitHub workflow ui-lint.yml)
lint-ui:
@echo "Running UI lint..."
@cd ui && npm ci && npm run lint
# Run UI linting with auto-fix
lint-ui-fix:
@echo "Running UI lint with auto-fix..."
@cd ui && npm ci && npm run lint:fix
# Legacy alias for UI linting (for backward compatibility)
ui-lint: lint-ui

View File

@ -13,23 +13,39 @@ import (
"github.com/erikdubbelboer/gspt"
"github.com/jetkvm/kvm"
"github.com/jetkvm/kvm/internal/native"
"github.com/jetkvm/kvm/internal/supervisor"
)
const (
envChildID = "JETKVM_CHILD_ID"
errorDumpDir = "/userdata/jetkvm/crashdump"
errorDumpLastFile = "last-crash.log"
errorDumpTemplate = "jetkvm-%s.log"
var (
subcomponent string
)
func program() {
gspt.SetProcTitle(os.Args[0] + " [app]")
kvm.Main()
subcomponentOverride := os.Getenv(supervisor.EnvSubcomponent)
if subcomponentOverride != "" {
subcomponent = subcomponentOverride
}
switch subcomponent {
case "native":
native.RunNativeProcess(os.Args[0])
default:
kvm.Main()
}
}
func setProcTitle(status string) {
if status != "" {
status = " " + status
}
title := fmt.Sprintf("jetkvm: [supervisor]%s", status)
gspt.SetProcTitle(title)
}
func main() {
versionPtr := flag.Bool("version", false, "print version and exit")
versionJSONPtr := flag.Bool("version-json", false, "print version as json and exit")
flag.StringVar(&subcomponent, "subcomponent", "", "subcomponent to run")
flag.Parse()
if *versionPtr || *versionJSONPtr {
@ -42,7 +58,7 @@ func main() {
return
}
childID := os.Getenv(envChildID)
childID := os.Getenv(supervisor.EnvChildID)
switch childID {
case "":
doSupervise()
@ -55,6 +71,8 @@ func main() {
}
func supervise() error {
setProcTitle("")
// check binary path
binPath, err := os.Executable()
if err != nil {
@ -74,11 +92,11 @@ func supervise() error {
// run the child binary
cmd := exec.Command(binPath)
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
lastFilePath := filepath.Join(supervisor.ErrorDumpDir, supervisor.ErrorDumpLastFile)
cmd.Env = append(os.Environ(), []string{
fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
fmt.Sprintf("%s=%s", supervisor.EnvChildID, kvm.GetBuiltAppVersion()),
fmt.Sprintf("%s=%s", supervisor.ErrorDumpLastFile, lastFilePath),
}...)
cmd.Args = os.Args
@ -99,6 +117,8 @@ func supervise() error {
return fmt.Errorf("failed to start command: %w", startErr)
}
setProcTitle(fmt.Sprintf("started (pid=%d)", cmd.Process.Pid))
go func() {
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM)
@ -107,8 +127,6 @@ func supervise() error {
_ = cmd.Process.Signal(sig)
}()
gspt.SetProcTitle(os.Args[0] + " [sup]")
cmdErr := cmd.Wait()
if cmdErr == nil {
return nil
@ -186,11 +204,11 @@ func renameFile(f *os.File, newName string) error {
func ensureErrorDumpDir() error {
// TODO: check if the directory is writable
f, err := os.Stat(errorDumpDir)
f, err := os.Stat(supervisor.ErrorDumpDir)
if err == nil && f.IsDir() {
return nil
}
if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
if err := os.MkdirAll(supervisor.ErrorDumpDir, 0755); err != nil {
return fmt.Errorf("failed to create error dump directory: %w", err)
}
return nil
@ -200,7 +218,7 @@ func createErrorDump(logFile *os.File) {
fmt.Println()
fileName := fmt.Sprintf(
errorDumpTemplate,
supervisor.ErrorDumpTemplate,
time.Now().Format("20060102-150405"),
)
@ -210,7 +228,7 @@ func createErrorDump(logFile *os.File) {
return
}
filePath := filepath.Join(errorDumpDir, fileName)
filePath := filepath.Join(supervisor.ErrorDumpDir, fileName)
if err := renameFile(logFile, filePath); err != nil {
fmt.Printf("failed to rename file: %v\n", err)
return
@ -218,7 +236,7 @@ func createErrorDump(logFile *os.File) {
fmt.Printf("error dump copied: %s\n", filePath)
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
lastFilePath := filepath.Join(supervisor.ErrorDumpDir, supervisor.ErrorDumpLastFile)
if err := ensureSymlink(filePath, lastFilePath); err != nil {
fmt.Printf("failed to create symlink: %v\n", err)

View File

@ -5,6 +5,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
@ -16,6 +17,10 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto"
)
const (
DefaultAPIURL = "https://api.jetkvm.com"
)
type WakeOnLanDevice struct {
Name string `json:"name"`
MacAddress string `json:"macAddress"`
@ -81,6 +86,7 @@ func (m *KeyboardMacro) Validate() error {
type Config struct {
CloudURL string `json:"cloud_url"`
UpdateAPIURL string `json:"update_api_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
@ -118,8 +124,18 @@ type Config struct {
AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24
AudioSampleRate int `json:"audio_sample_rate"` // Hz (32000, 44100, 48000)
AudioPacketLossPerc int `json:"audio_packet_loss_perc"` // 0-100
NativeMaxRestart uint `json:"native_max_restart_attempts"`
}
// 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 {
rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
if err != nil {
@ -129,6 +145,7 @@ func (c *Config) GetDisplayRotation() uint16 {
return uint16(rotationInt)
}
// SetDisplayRotation sets the display rotation
func (c *Config) SetDisplayRotation(rotation string) error {
_, err := strconv.ParseUint(rotation, 10, 16)
if err != nil {
@ -168,7 +185,8 @@ var (
func getDefaultConfig() Config {
return Config{
CloudURL: "https://api.jetkvm.com",
CloudURL: DefaultAPIURL,
UpdateAPIURL: DefaultAPIURL,
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
ActiveExtension: "",

View File

@ -232,6 +232,14 @@ func updateStaticContents() {
// nativeInstance.UpdateLabelAndChangeVisibility("boot_screen_device_id", GetDeviceID())
}
// configureDisplayOnNativeRestart is called when the native process restarts
// it ensures the display is configured correctly after the restart
func configureDisplayOnNativeRestart() {
displayLogger.Info().Msg("native restarted, configuring display")
updateStaticContents()
requestDisplayUpdate(true, "native_restart")
}
// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
// the backlight brightness of the JetKVM hardware's display.
func setDisplayBrightness(brightness int, reason string) error {

View File

@ -5,6 +5,8 @@ import (
"os"
"strings"
"sync"
"github.com/jetkvm/kvm/internal/supervisor"
)
const (
@ -77,11 +79,17 @@ func checkFailsafeReason() {
_ = os.Remove(lastCrashPath)
// TODO: read the goroutine stack trace and check which goroutine is panicking
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
failsafeModeActive = true
failsafeModeActive = true
if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
failsafeModeReason = "video"
return
}
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
failsafeModeReason = "video"
return
} else {
failsafeModeReason = "unknown"
}
})
}

5
go.mod
View File

@ -44,6 +44,7 @@ require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/caarlos0/env/v11 v11.3.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/creack/goselect v0.1.2 // indirect
@ -87,6 +88,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
@ -97,6 +99,9 @@ require (
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

10
go.sum
View File

@ -12,6 +12,8 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
@ -173,6 +175,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -226,6 +230,12 @@ golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

18
hw.go
View File

@ -8,6 +8,8 @@ import (
"strings"
"sync"
"time"
"github.com/jetkvm/kvm/internal/ota"
)
func extractSerialNumber() (string, error) {
@ -29,22 +31,16 @@ func extractSerialNumber() (string, error) {
return matches[1], nil
}
func readOtpEntropy() ([]byte, error) { //nolint:unused
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
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)
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
logger.Info().Dur("delayMs", delay).Msg("reboot requested")
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
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{}
if force {

20
internal/native/README.md Normal file
View File

@ -0,0 +1,20 @@
# jetkvm-native
This component (`internal/native/`) acts as a bridge between Golang and native (C/C++) code.
It manages spawning and communicating with a native process via sockets (gRPC and Unix stream).
For performance-critical operations such as video frame, **a dedicated Unix socket should be used** to avoid the overhead of gRPC and ensure low-latency communication.
## Debugging
To enable debug mode, create a file called `.native-debug-mode` in the `/userdata/jetkvm` directory.
```bash
touch /userdata/jetkvm/.native-debug-mode
```
This will cause the native process to listen for SIGHUP signal and crash the process.
```bash
pgrep native | xargs kill -SIGHUP
```

View File

@ -42,6 +42,8 @@ FetchContent_MakeAvailable(lvgl)
# Get source files, excluding CMake generated files
file(GLOB_RECURSE sources CONFIGURE_DEPENDS "*.c" "ui/*.c")
list(FILTER sources EXCLUDE REGEX "CMakeFiles.*CompilerId.*\\.c$")
# Exclude main.c from library sources (it's used for the binary target)
list(FILTER sources EXCLUDE REGEX "main\\.c$")
add_library(jknative STATIC ${sources} ${CMAKE_CURRENT_SOURCE_DIR}/ctrl.h)
@ -68,4 +70,14 @@ target_link_libraries(jknative PRIVATE
# libgpiod
)
# Binary target using main.c as entry point
add_executable(jknative-bin ${CMAKE_CURRENT_SOURCE_DIR}/main.c)
# Link the binary to the library (if needed in the future)
target_link_libraries(jknative-bin PRIVATE
jknative
pthread
)
install(TARGETS jknative DESTINATION lib)
install(TARGETS jknative-bin DESTINATION bin)

227
internal/native/cgo/main.c Normal file
View File

@ -0,0 +1,227 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/select.h>
#include <fcntl.h>
#include <errno.h>
#include <pthread.h>
#include "ctrl.h"
#include "main.h"
#define SOCKET_PATH "/tmp/video.sock"
#define BUFFER_SIZE 4096
// Global state
static int client_fd = -1;
static pthread_mutex_t client_fd_mutex = PTHREAD_MUTEX_INITIALIZER;
void jetkvm_c_log_handler(int level, const char *filename, const char *funcname, int line, const char *message) {
// printf("[%s] %s:%d %s: %s\n", filename ? filename : "unknown", funcname ? funcname : "unknown", line, message ? message : "");
fprintf(stderr, "[%s] %s:%d %s: %s\n", filename ? filename : "unknown", funcname ? funcname : "unknown", line, message ? message : "");
}
// Video handler that pipes frames to the Unix socket
// This will be called by the video subsystem via video_send_frame -> jetkvm_set_video_handler's handler
void jetkvm_video_handler(const uint8_t *frame, ssize_t len) {
// pthread_mutex_lock(&client_fd_mutex);
// if (client_fd >= 0 && frame != NULL && len > 0) {
// ssize_t bytes_written = 0;
// while (bytes_written < len) {
// ssize_t n = write(client_fd, frame + bytes_written, len - bytes_written);
// if (n < 0) {
// if (errno == EPIPE || errno == ECONNRESET) {
// // Client disconnected
// close(client_fd);
// client_fd = -1;
// break;
// }
// perror("write");
// break;
// }
// bytes_written += n;
// }
// }
// pthread_mutex_unlock(&client_fd_mutex);
}
void jetkvm_video_state_handler(jetkvm_video_state_t *state) {
fprintf(stderr, "Video state: {\n"
"\"ready\": %d,\n"
"\"error\": \"%s\",\n"
"\"width\": %d,\n"
"\"height\": %d,\n"
"\"frame_per_second\": %f\n"
"}\n", state->ready, state->error, state->width, state->height, state->frame_per_second);
}
void jetkvm_indev_handler(int code) {
fprintf(stderr, "Video indev: %d\n", code);
}
void jetkvm_rpc_handler(const char *method, const char *params) {
fprintf(stderr, "Video rpc: %s %s\n", method, params);
}
// Note: jetkvm_set_video_handler, jetkvm_set_indev_handler, jetkvm_set_rpc_handler,
// jetkvm_call_rpc_handler, and jetkvm_set_video_state_handler are implemented in
// the library (ctrl.c) and will be used from there when linking.
int main(int argc, char *argv[]) {
const char *socket_path = SOCKET_PATH;
// Allow custom socket path via command line argument
if (argc > 1) {
socket_path = argv[1];
}
// Remove existing socket file if it exists
unlink(socket_path);
// Set handlers
jetkvm_set_log_handler(&jetkvm_c_log_handler);
jetkvm_set_video_handler(&jetkvm_video_handler);
jetkvm_set_video_state_handler(&jetkvm_video_state_handler);
jetkvm_set_indev_handler(&jetkvm_indev_handler);
jetkvm_set_rpc_handler(&jetkvm_rpc_handler);
// Initialize video first (before accepting connections)
fprintf(stderr, "Initializing video...\n");
if (jetkvm_video_init(1.0) != 0) {
fprintf(stderr, "Failed to initialize video\n");
return 1;
}
// Start video streaming - frames will be sent via video_send_frame
// which calls the video handler we set up
jetkvm_video_start();
fprintf(stderr, "Video streaming started.\n");
// Create Unix domain socket
int server_fd = socket(AF_UNIX, SOCK_STREAM, 0);
if (server_fd < 0) {
perror("socket");
jetkvm_video_stop();
jetkvm_video_shutdown();
return 1;
}
// Make socket non-blocking
int flags = fcntl(server_fd, F_GETFL, 0);
if (flags < 0 || fcntl(server_fd, F_SETFL, flags | O_NONBLOCK) < 0) {
perror("fcntl");
close(server_fd);
jetkvm_video_stop();
jetkvm_video_shutdown();
return 1;
}
// Bind socket to path
struct sockaddr_un addr;
memset(&addr, 0, sizeof(addr));
addr.sun_family = AF_UNIX;
strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1);
if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("bind");
close(server_fd);
jetkvm_video_stop();
jetkvm_video_shutdown();
return 1;
}
// Listen for connections
if (listen(server_fd, 1) < 0) {
perror("listen");
close(server_fd);
jetkvm_video_stop();
jetkvm_video_shutdown();
return 1;
}
fprintf(stderr, "Listening on Unix socket: %s (non-blocking)\n", socket_path);
fprintf(stderr, "Video frames will be sent to connected clients...\n");
// Main loop: check for new connections and handle client disconnections
fd_set read_fds;
struct timeval timeout;
while (1) {
FD_ZERO(&read_fds);
FD_SET(server_fd, &read_fds);
pthread_mutex_lock(&client_fd_mutex);
int current_client_fd = client_fd;
if (current_client_fd >= 0) {
FD_SET(current_client_fd, &read_fds);
}
int max_fd = (current_client_fd > server_fd) ? current_client_fd : server_fd;
pthread_mutex_unlock(&client_fd_mutex);
timeout.tv_sec = 1;
timeout.tv_usec = 0;
int result = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (result < 0) {
if (errno == EINTR) {
continue;
}
perror("select");
break;
}
// Check for new connection
if (FD_ISSET(server_fd, &read_fds)) {
int accepted_fd = accept(server_fd, NULL, NULL);
if (accepted_fd >= 0) {
fprintf(stderr, "Client connected\n");
pthread_mutex_lock(&client_fd_mutex);
if (client_fd >= 0) {
// Close previous client if any
close(client_fd);
}
client_fd = accepted_fd;
pthread_mutex_unlock(&client_fd_mutex);
} else if (errno != EAGAIN && errno != EWOULDBLOCK) {
perror("accept");
}
}
// Check if client disconnected
pthread_mutex_lock(&client_fd_mutex);
current_client_fd = client_fd;
pthread_mutex_unlock(&client_fd_mutex);
if (current_client_fd >= 0 && FD_ISSET(current_client_fd, &read_fds)) {
// Client sent data or closed connection
char buffer[1];
if (read(current_client_fd, buffer, 1) <= 0) {
fprintf(stderr, "Client disconnected\n");
pthread_mutex_lock(&client_fd_mutex);
close(client_fd);
client_fd = -1;
pthread_mutex_unlock(&client_fd_mutex);
}
}
}
// Stop video streaming
jetkvm_video_stop();
jetkvm_video_shutdown();
// Cleanup
pthread_mutex_lock(&client_fd_mutex);
if (client_fd >= 0) {
close(client_fd);
client_fd = -1;
}
pthread_mutex_unlock(&client_fd_mutex);
close(server_fd);
unlink(socket_path);
return 0;
}

View File

@ -0,0 +1,26 @@
#ifndef JETKVM_NATIVE_MAIN_H
#define JETKVM_NATIVE_MAIN_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>
#include "ctrl.h"
void jetkvm_c_log_handler(int level, const char *filename, const char *funcname, int line, const char *message);
void jetkvm_video_handler(const uint8_t *frame, ssize_t len);
void jetkvm_video_state_handler(jetkvm_video_state_t *state);
void jetkvm_indev_handler(int code);
void jetkvm_rpc_handler(const char *method, const char *params);
// typedef void (jetkvm_video_state_handler_t)(jetkvm_video_state_t *state);
// typedef void (jetkvm_log_handler_t)(int level, const char *filename, const char *funcname, int line, const char *message);
// typedef void (jetkvm_rpc_handler_t)(const char *method, const char *params);
// typedef void (jetkvm_video_handler_t)(const uint8_t *frame, ssize_t len);
// typedef void (jetkvm_indev_handler_t)(int code);
#endif

View File

@ -0,0 +1,210 @@
diff --git a/internal/native/cgo/video.c b/internal/native/cgo/video.c
index 2a4a034..760621a 100644
--- a/internal/native/cgo/video.c
+++ b/internal/native/cgo/video.c
@@ -354,6 +354,10 @@ bool detected_signal = false, streaming_flag = false, streaming_stopped = true;
pthread_t *streaming_thread = NULL;
pthread_mutex_t streaming_mutex = PTHREAD_MUTEX_INITIALIZER;
+// Diagnostic tracking for validation
+static uint64_t last_close_time = 0;
+static int consecutive_failures = 0;
+
bool get_streaming_flag()
{
log_info("getting streaming flag");
@@ -395,6 +399,12 @@ void *run_video_stream(void *arg)
continue;
}
+ // Log attempt to open with timing info
+ RK_U64 time_since_close = last_close_time > 0 ? (get_us() - last_close_time) : 0;
+ log_info("[DIAG] Attempting to open %s (time_since_last_close=%llu us)",
+ VIDEO_DEV, time_since_close);
+
+ RK_U64 open_start_time = get_us();
int video_dev_fd = open(VIDEO_DEV, O_RDWR);
if (video_dev_fd < 0)
{
@@ -402,7 +412,9 @@ void *run_video_stream(void *arg)
usleep(1000000);
continue;
}
- log_info("opened video capture device %s", VIDEO_DEV);
+ RK_U64 open_end_time = get_us();
+ log_info("[DIAG] opened video capture device %s in %llu us",
+ VIDEO_DEV, open_end_time - open_start_time);
uint32_t width = detected_width;
uint32_t height = detected_height;
@@ -414,14 +426,45 @@ void *run_video_stream(void *arg)
fmt.fmt.pix_mp.pixelformat = V4L2_PIX_FMT_YUYV;
fmt.fmt.pix_mp.field = V4L2_FIELD_ANY;
+ // Probe device state before attempting format set
+ struct v4l2_format query_fmt;
+ memset(&query_fmt, 0, sizeof(query_fmt));
+ query_fmt.type = type;
+ int query_ret = ioctl(video_dev_fd, VIDIOC_G_FMT, &query_fmt);
+ log_info("[DIAG] VIDIOC_G_FMT probe: ret=%d, errno=%d (%s)",
+ query_ret, query_ret < 0 ? errno : 0,
+ query_ret < 0 ? strerror(errno) : "OK");
+
+ RK_U64 set_fmt_start_time = get_us();
+ log_info("[DIAG] Attempting VIDIOC_S_FMT: %ux%u, time_since_open=%llu us",
+ width, height, set_fmt_start_time - open_end_time);
+
if (ioctl(video_dev_fd, VIDIOC_S_FMT, &fmt) < 0)
{
- log_error("Set format fail: %s", strerror(errno));
+ RK_U64 failure_time = get_us();
+ int saved_errno = errno;
+ consecutive_failures++;
+
+ log_error("[DIAG] Set format fail: errno=%d (%s)", saved_errno, strerror(saved_errno));
+ log_error("[DIAG] Failure context: consecutive_failures=%d, time_since_open=%llu us, "
+ "time_since_last_close=%llu us, resolution=%ux%u, streaming_flag=%d",
+ consecutive_failures,
+ failure_time - open_end_time,
+ last_close_time > 0 ? (open_start_time - last_close_time) : 0,
+ width, height,
+ streaming_flag);
+
usleep(100000); // Sleep for 100 milliseconds
close(video_dev_fd);
+ last_close_time = get_us();
+ log_info("[DIAG] Closed device after format failure at %llu us", last_close_time);
continue;
}
+ // Success - reset failure counter
+ log_info("[DIAG] VIDIOC_S_FMT succeeded (previous consecutive failures: %d)", consecutive_failures);
+ consecutive_failures = 0;
+
struct v4l2_buffer buf;
struct v4l2_requestbuffers req;
@@ -601,9 +644,46 @@ void *run_video_stream(void *arg)
}
cleanup:
log_info("cleaning up video capture device %s", VIDEO_DEV);
- if (ioctl(video_dev_fd, VIDIOC_STREAMOFF, &type) < 0)
+
+ RK_U64 streamoff_start = get_us();
+ log_info("[DIAG] Attempting VIDIOC_STREAMOFF");
+
+ int streamoff_ret = ioctl(video_dev_fd, VIDIOC_STREAMOFF, &type);
+ RK_U64 streamoff_end = get_us();
+
+ if (streamoff_ret < 0)
+ {
+ log_error("[DIAG] VIDIOC_STREAMOFF failed: errno=%d (%s), duration=%llu us",
+ errno, strerror(errno), streamoff_end - streamoff_start);
+ }
+ else
+ {
+ log_info("[DIAG] VIDIOC_STREAMOFF succeeded in %llu us",
+ streamoff_end - streamoff_start);
+ }
+
+ // VALIDATION TEST: Explicitly free V4L2 buffer queue
+ struct v4l2_requestbuffers req_free;
+ memset(&req_free, 0, sizeof(req_free));
+ req_free.count = 0; // Tell driver to free all buffers
+ req_free.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
+ req_free.memory = V4L2_MEMORY_DMABUF;
+
+ RK_U64 reqbufs_start = get_us();
+ log_info("[DIAG] VALIDATION: Calling VIDIOC_REQBUFS(count=0) to free buffer queue");
+
+ int reqbufs_ret = ioctl(video_dev_fd, VIDIOC_REQBUFS, &req_free);
+ RK_U64 reqbufs_end = get_us();
+
+ if (reqbufs_ret < 0)
+ {
+ log_error("[DIAG] VALIDATION: REQBUFS(0) FAILED - errno=%d (%s), duration=%llu us",
+ errno, strerror(errno), reqbufs_end - reqbufs_start);
+ }
+ else
{
- log_error("VIDIOC_STREAMOFF failed: %s", strerror(errno));
+ log_info("[DIAG] VALIDATION: REQBUFS(0) SUCCEEDED - freed buffers in %llu us",
+ reqbufs_end - reqbufs_start);
}
venc_stop();
@@ -617,9 +697,13 @@ void *run_video_stream(void *arg)
}
log_info("closing video capture device %s", VIDEO_DEV);
+ RK_U64 close_start = get_us();
close(video_dev_fd);
+ last_close_time = get_us();
+ log_info("[DIAG] Device closed, took %llu us, timestamp=%llu",
+ last_close_time - close_start, last_close_time);
}
-
+
log_info("video stream thread exiting");
streaming_stopped = true;
@@ -648,7 +732,7 @@ void video_shutdown()
RK_MPI_MB_DestroyPool(memPool);
}
log_info("Destroyed memory pool");
-
+
pthread_mutex_destroy(&streaming_mutex);
log_info("Destroyed streaming mutex");
}
@@ -665,14 +749,14 @@ void video_start_streaming()
log_warn("video streaming already started");
return;
}
-
+
pthread_t *new_thread = malloc(sizeof(pthread_t));
if (new_thread == NULL)
{
log_error("Failed to allocate memory for streaming thread");
return;
}
-
+
set_streaming_flag(true);
int result = pthread_create(new_thread, NULL, run_video_stream, NULL);
if (result != 0)
@@ -682,7 +766,7 @@ void video_start_streaming()
free(new_thread);
return;
}
-
+
// Only set streaming_thread after successful creation
streaming_thread = new_thread;
}
@@ -693,7 +777,7 @@ void video_stop_streaming()
log_info("video streaming already stopped");
return;
}
-
+
log_info("stopping video streaming");
set_streaming_flag(false);
@@ -711,7 +795,7 @@ void video_stop_streaming()
free(streaming_thread);
streaming_thread = NULL;
- log_info("video streaming stopped");
+ log_info("video streaming stopped");
}
void video_restart_streaming()
@@ -818,4 +902,4 @@ void video_set_quality_factor(float factor)
float video_get_quality_factor() {
return quality_factor;
-}
\ No newline at end of file
+}

View File

@ -50,17 +50,9 @@ static inline void jetkvm_cgo_setup_rpc_handler() {
import "C"
var (
cgoLock sync.Mutex
cgoDisabled bool
cgoLock sync.Mutex
)
func setCgoDisabled(disabled bool) {
cgoLock.Lock()
defer cgoLock.Unlock()
cgoDisabled = disabled
}
//export jetkvm_go_video_state_handler
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
videoState := VideoState{
@ -104,10 +96,6 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
var eventCodeToNameMap = map[int]string{}
func uiEventCodeToName(code int) string {
if cgoDisabled {
return ""
}
name, ok := eventCodeToNameMap[code]
if !ok {
cCode := C.int(code)
@ -120,10 +108,6 @@ func uiEventCodeToName(code int) string {
}
func setUpNativeHandlers() {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -135,10 +119,6 @@ func setUpNativeHandlers() {
}
func uiInit(rotation uint16) {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -148,10 +128,6 @@ func uiInit(rotation uint16) {
}
func uiTick() {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -159,10 +135,6 @@ func uiTick() {
}
func videoInit(factor float64) error {
if cgoDisabled {
return nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -176,10 +148,6 @@ func videoInit(factor float64) error {
}
func videoShutdown() {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -187,10 +155,6 @@ func videoShutdown() {
}
func videoStart() {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -198,10 +162,6 @@ func videoStart() {
}
func videoStop() {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -209,10 +169,6 @@ func videoStop() {
}
func videoLogStatus() string {
if cgoDisabled {
return ""
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -223,10 +179,6 @@ func videoLogStatus() string {
}
func uiSetVar(name string, value string) {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -240,10 +192,6 @@ func uiSetVar(name string, value string) {
}
func uiGetVar(name string) string {
if cgoDisabled {
return ""
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -254,10 +202,6 @@ func uiGetVar(name string) string {
}
func uiSwitchToScreen(screen string) {
if cgoDisabled {
return
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -267,10 +211,6 @@ func uiSwitchToScreen(screen string) {
}
func uiGetCurrentScreen() string {
if cgoDisabled {
return ""
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -279,10 +219,6 @@ func uiGetCurrentScreen() string {
}
func uiObjAddState(objName string, state string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -295,10 +231,6 @@ func uiObjAddState(objName string, state string) (bool, error) {
}
func uiObjClearState(objName string, state string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -311,10 +243,6 @@ func uiObjClearState(objName string, state string) (bool, error) {
}
func uiGetLVGLVersion() string {
if cgoDisabled {
return ""
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -323,10 +251,6 @@ func uiGetLVGLVersion() string {
// TODO: use Enum instead of string but it's not a hot path and performance is not a concern now
func uiObjAddFlag(objName string, flag string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -339,10 +263,6 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
}
func uiObjClearFlag(objName string, flag string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -363,10 +283,6 @@ func uiObjShow(objName string) (bool, error) {
}
func uiObjSetOpacity(objName string, opacity int) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -378,10 +294,6 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
}
func uiObjFadeIn(objName string, duration uint32) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -394,10 +306,6 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
}
func uiObjFadeOut(objName string, duration uint32) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -410,10 +318,6 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
}
func uiLabelSetText(objName string, text string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -431,10 +335,6 @@ func uiLabelSetText(objName string, text string) (bool, error) {
}
func uiImgSetSrc(objName string, src string) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -450,10 +350,6 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
}
func uiDispSetRotation(rotation uint16) (bool, error) {
if cgoDisabled {
return false, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -466,10 +362,6 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
}
func videoGetStreamQualityFactor() (float64, error) {
if cgoDisabled {
return 0, nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -478,10 +370,6 @@ func videoGetStreamQualityFactor() (float64, error) {
}
func videoSetStreamQualityFactor(factor float64) error {
if cgoDisabled {
return nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -490,10 +378,6 @@ func videoSetStreamQualityFactor(factor float64) error {
}
func videoGetEDID() (string, error) {
if cgoDisabled {
return "", nil
}
cgoLock.Lock()
defer cgoLock.Unlock()
@ -502,10 +386,6 @@ func videoGetEDID() (string, error) {
}
func videoSetEDID(edid string) error {
if cgoDisabled {
return nil
}
cgoLock.Lock()
defer cgoLock.Unlock()

111
internal/native/empty.go Normal file
View File

@ -0,0 +1,111 @@
package native
type EmptyNativeInterface struct {
}
func (e *EmptyNativeInterface) Start() error { return nil }
func (e *EmptyNativeInterface) VideoSetSleepMode(enabled bool) error { return nil }
func (e *EmptyNativeInterface) VideoGetSleepMode() (bool, error) { return false, nil }
func (e *EmptyNativeInterface) VideoSleepModeSupported() bool {
return false
}
func (e *EmptyNativeInterface) VideoSetQualityFactor(factor float64) error {
return nil
}
func (e *EmptyNativeInterface) VideoGetQualityFactor() (float64, error) {
return 0, nil
}
func (e *EmptyNativeInterface) VideoSetEDID(edid string) error {
return nil
}
func (e *EmptyNativeInterface) VideoGetEDID() (string, error) {
return "", nil
}
func (e *EmptyNativeInterface) VideoLogStatus() (string, error) {
return "", nil
}
func (e *EmptyNativeInterface) VideoStop() error {
return nil
}
func (e *EmptyNativeInterface) VideoStart() error {
return nil
}
func (e *EmptyNativeInterface) GetLVGLVersion() (string, error) {
return "", nil
}
func (e *EmptyNativeInterface) UIObjHide(objName string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjShow(objName string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UISetVar(name string, value string) {
}
func (e *EmptyNativeInterface) UIGetVar(name string) string {
return ""
}
func (e *EmptyNativeInterface) UIObjAddState(objName string, state string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjClearState(objName string, state string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjAddFlag(objName string, flag string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjClearFlag(objName string, flag string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjSetOpacity(objName string, opacity int) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjFadeIn(objName string, duration uint32) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjFadeOut(objName string, duration uint32) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjSetLabelText(objName string, text string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UIObjSetImageSrc(objName string, image string) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) DisplaySetRotation(rotation uint16) (bool, error) {
return false, nil
}
func (e *EmptyNativeInterface) UpdateLabelIfChanged(objName string, newText string) {}
func (e *EmptyNativeInterface) UpdateLabelAndChangeVisibility(objName string, newText string) {}
func (e *EmptyNativeInterface) SwitchToScreenIf(screenName string, shouldSwitch []string) {}
func (e *EmptyNativeInterface) SwitchToScreenIfDifferent(screenName string) {}
func (e *EmptyNativeInterface) DoNotUseThisIsForCrashTestingOnly() {}

View File

@ -0,0 +1,272 @@
package native
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
"github.com/rs/zerolog"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/connectivity"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
pb "github.com/jetkvm/kvm/internal/native/proto"
)
// GRPCClient wraps the gRPC client for the native service
type GRPCClient struct {
ctx context.Context
cancel context.CancelFunc
conn *grpc.ClientConn
client pb.NativeServiceClient
logger *zerolog.Logger
eventStream pb.NativeService_StreamEventsClient
eventM sync.RWMutex
eventCh chan *pb.Event
eventDone chan struct{}
onVideoStateChange func(state VideoState)
onIndevEvent func(event string)
onRpcEvent func(event string)
closed bool
closeM sync.Mutex
}
type grpcClientOptions struct {
SocketPath string
Logger *zerolog.Logger
OnVideoStateChange func(state VideoState)
OnIndevEvent func(event string)
OnRpcEvent func(event string)
}
// NewGRPCClient creates a new gRPC client connected to the native service
func NewGRPCClient(opts grpcClientOptions) (*GRPCClient, error) {
// Connect to the Unix domain socket
conn, err := grpc.NewClient(
fmt.Sprintf("unix-abstract:%v", opts.SocketPath),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
return nil, fmt.Errorf("failed to connect to gRPC server: %w", err)
}
client := pb.NewNativeServiceClient(conn)
ctx, cancel := context.WithCancel(context.Background())
grpcClient := &GRPCClient{
ctx: ctx,
cancel: cancel,
conn: conn,
client: client,
logger: opts.Logger,
eventCh: make(chan *pb.Event, 100),
eventDone: make(chan struct{}),
onVideoStateChange: opts.OnVideoStateChange,
onIndevEvent: opts.OnIndevEvent,
onRpcEvent: opts.OnRpcEvent,
}
// Start event stream
go grpcClient.startEventStream()
// Start event handler to process events from the channel
go func() {
for {
select {
case event := <-grpcClient.eventCh:
grpcClient.handleEvent(event)
case <-grpcClient.eventDone:
return
}
}
}()
return grpcClient, nil
}
func (c *GRPCClient) handleEventStream(stream pb.NativeService_StreamEventsClient) {
c.eventM.Lock()
c.eventStream = stream
defer func() {
c.eventStream = nil
c.eventM.Unlock()
}()
for {
logger := c.logger.With().Interface("stream", stream).Logger()
if stream == nil {
logger.Error().Msg("event stream is nil")
break
}
event, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
logger.Debug().Msg("event stream closed")
} else {
logger.Warn().Err(err).Msg("event stream error")
}
break
}
// enrich the logger with the event type and data, if debug mode is enabled
if c.logger.GetLevel() <= zerolog.DebugLevel {
logger = logger.With().
Str("type", event.Type).
Interface("data", event.Data).
Logger()
}
logger.Trace().Msg("received event")
select {
case c.eventCh <- event:
default:
logger.Warn().Msg("event channel full, dropping event")
}
}
}
func (c *GRPCClient) startEventStream() {
for {
// check if the client is closed
c.closeM.Lock()
if c.closed {
c.closeM.Unlock()
return
}
c.closeM.Unlock()
// check if the context is done
select {
case <-c.ctx.Done():
c.logger.Info().Msg("event stream context done, closing")
return
default:
}
stream, err := c.client.StreamEvents(c.ctx, &pb.Empty{})
if err != nil {
c.logger.Warn().Err(err).Msg("failed to start event stream, retrying ...")
time.Sleep(5 * time.Second)
continue
}
c.handleEventStream(stream)
// Wait before retrying
time.Sleep(1 * time.Second)
}
}
func (c *GRPCClient) checkIsReady(ctx context.Context) error {
c.logger.Trace().Msg("connection is idle, connecting ...")
resp, err := c.client.IsReady(ctx, &pb.IsReadyRequest{})
if err != nil {
if errors.Is(err, status.Error(codes.Unavailable, "")) {
return fmt.Errorf("timeout waiting for ready: %w", err)
}
return fmt.Errorf("failed to check if ready: %w", err)
}
if resp.Ready {
return nil
}
return nil
}
// WaitReady waits for the gRPC connection to be ready
func (c *GRPCClient) WaitReady() error {
ctx, cancel := context.WithTimeout(c.ctx, 60*time.Second)
defer cancel()
prevState := connectivity.Idle
for {
state := c.conn.GetState()
c.logger.
With().
Str("state", state.String()).
Int("prev_state", int(prevState)).
Logger()
prevState = state
if state == connectivity.Idle || state == connectivity.Ready {
if err := c.checkIsReady(ctx); err != nil {
time.Sleep(1 * time.Second)
continue
}
}
c.logger.Info().Msg("waiting for connection to be ready")
if state == connectivity.Ready {
return nil
}
if state == connectivity.Shutdown {
return fmt.Errorf("connection failed: %v", state)
}
if !c.conn.WaitForStateChange(ctx, state) {
return ctx.Err()
}
}
}
func (c *GRPCClient) handleEvent(event *pb.Event) {
switch event.Type {
case "video_state_change":
state := event.GetVideoState()
if state == nil {
c.logger.Warn().Msg("video state event is nil")
return
}
c.onVideoStateChange(VideoState{
Ready: state.Ready,
Error: state.Error,
Width: int(state.Width),
Height: int(state.Height),
FramePerSecond: state.FramePerSecond,
})
case "indev_event":
c.onIndevEvent(event.GetIndevEvent())
case "rpc_event":
c.onRpcEvent(event.GetRpcEvent())
default:
c.logger.Warn().Str("type", event.Type).Msg("unknown event type")
}
}
// Close closes the gRPC client
func (c *GRPCClient) Close() error {
c.closeM.Lock()
defer c.closeM.Unlock()
if c.closed {
return nil
}
c.closed = true
// cancel all ongoing operations
c.cancel()
close(c.eventDone)
c.eventM.Lock()
if c.eventStream != nil {
if err := c.eventStream.CloseSend(); err != nil {
c.logger.Warn().Err(err).Msg("failed to close event stream")
}
}
c.eventM.Unlock()
return c.conn.Close()
}

View File

@ -0,0 +1,212 @@
package native
import (
"context"
pb "github.com/jetkvm/kvm/internal/native/proto"
)
// Below are generated methods, do not edit manually
// Video methods
func (c *GRPCClient) VideoSetSleepMode(enabled bool) error {
_, err := c.client.VideoSetSleepMode(context.Background(), &pb.VideoSetSleepModeRequest{Enabled: enabled})
return err
}
func (c *GRPCClient) VideoGetSleepMode() (bool, error) {
resp, err := c.client.VideoGetSleepMode(context.Background(), &pb.Empty{})
if err != nil {
return false, err
}
return resp.Enabled, nil
}
func (c *GRPCClient) VideoSleepModeSupported() bool {
resp, err := c.client.VideoSleepModeSupported(context.Background(), &pb.Empty{})
if err != nil {
return false
}
return resp.Supported
}
func (c *GRPCClient) VideoSetQualityFactor(factor float64) error {
_, err := c.client.VideoSetQualityFactor(context.Background(), &pb.VideoSetQualityFactorRequest{Factor: factor})
return err
}
func (c *GRPCClient) VideoGetQualityFactor() (float64, error) {
resp, err := c.client.VideoGetQualityFactor(context.Background(), &pb.Empty{})
if err != nil {
return 0, err
}
return resp.Factor, nil
}
func (c *GRPCClient) VideoSetEDID(edid string) error {
_, err := c.client.VideoSetEDID(context.Background(), &pb.VideoSetEDIDRequest{Edid: edid})
return err
}
func (c *GRPCClient) VideoGetEDID() (string, error) {
resp, err := c.client.VideoGetEDID(context.Background(), &pb.Empty{})
if err != nil {
return "", err
}
return resp.Edid, nil
}
func (c *GRPCClient) VideoLogStatus() (string, error) {
resp, err := c.client.VideoLogStatus(context.Background(), &pb.Empty{})
if err != nil {
return "", err
}
return resp.Status, nil
}
func (c *GRPCClient) VideoStop() error {
_, err := c.client.VideoStop(context.Background(), &pb.Empty{})
return err
}
func (c *GRPCClient) VideoStart() error {
_, err := c.client.VideoStart(context.Background(), &pb.Empty{})
return err
}
// UI methods
func (c *GRPCClient) GetLVGLVersion() (string, error) {
resp, err := c.client.GetLVGLVersion(context.Background(), &pb.Empty{})
if err != nil {
return "", err
}
return resp.Version, nil
}
func (c *GRPCClient) UIObjHide(objName string) (bool, error) {
resp, err := c.client.UIObjHide(context.Background(), &pb.UIObjHideRequest{ObjName: objName})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjShow(objName string) (bool, error) {
resp, err := c.client.UIObjShow(context.Background(), &pb.UIObjShowRequest{ObjName: objName})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UISetVar(name string, value string) {
_, _ = c.client.UISetVar(context.Background(), &pb.UISetVarRequest{Name: name, Value: value})
}
func (c *GRPCClient) UIGetVar(name string) string {
resp, err := c.client.UIGetVar(context.Background(), &pb.UIGetVarRequest{Name: name})
if err != nil {
return ""
}
return resp.Value
}
func (c *GRPCClient) UIObjAddState(objName string, state string) (bool, error) {
resp, err := c.client.UIObjAddState(context.Background(), &pb.UIObjAddStateRequest{ObjName: objName, State: state})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjClearState(objName string, state string) (bool, error) {
resp, err := c.client.UIObjClearState(context.Background(), &pb.UIObjClearStateRequest{ObjName: objName, State: state})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjAddFlag(objName string, flag string) (bool, error) {
resp, err := c.client.UIObjAddFlag(context.Background(), &pb.UIObjAddFlagRequest{ObjName: objName, Flag: flag})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjClearFlag(objName string, flag string) (bool, error) {
resp, err := c.client.UIObjClearFlag(context.Background(), &pb.UIObjClearFlagRequest{ObjName: objName, Flag: flag})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjSetOpacity(objName string, opacity int) (bool, error) {
resp, err := c.client.UIObjSetOpacity(context.Background(), &pb.UIObjSetOpacityRequest{ObjName: objName, Opacity: int32(opacity)})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjFadeIn(objName string, duration uint32) (bool, error) {
resp, err := c.client.UIObjFadeIn(context.Background(), &pb.UIObjFadeInRequest{ObjName: objName, Duration: duration})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjFadeOut(objName string, duration uint32) (bool, error) {
resp, err := c.client.UIObjFadeOut(context.Background(), &pb.UIObjFadeOutRequest{ObjName: objName, Duration: duration})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjSetLabelText(objName string, text string) (bool, error) {
resp, err := c.client.UIObjSetLabelText(context.Background(), &pb.UIObjSetLabelTextRequest{ObjName: objName, Text: text})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UIObjSetImageSrc(objName string, image string) (bool, error) {
resp, err := c.client.UIObjSetImageSrc(context.Background(), &pb.UIObjSetImageSrcRequest{ObjName: objName, Image: image})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) DisplaySetRotation(rotation uint16) (bool, error) {
resp, err := c.client.DisplaySetRotation(context.Background(), &pb.DisplaySetRotationRequest{Rotation: uint32(rotation)})
if err != nil {
return false, err
}
return resp.Success, nil
}
func (c *GRPCClient) UpdateLabelIfChanged(objName string, newText string) {
_, _ = c.client.UpdateLabelIfChanged(context.Background(), &pb.UpdateLabelIfChangedRequest{ObjName: objName, NewText: newText})
}
func (c *GRPCClient) UpdateLabelAndChangeVisibility(objName string, newText string) {
_, _ = c.client.UpdateLabelAndChangeVisibility(context.Background(), &pb.UpdateLabelAndChangeVisibilityRequest{ObjName: objName, NewText: newText})
}
func (c *GRPCClient) SwitchToScreenIf(screenName string, shouldSwitch []string) {
_, _ = c.client.SwitchToScreenIf(context.Background(), &pb.SwitchToScreenIfRequest{ScreenName: screenName, ShouldSwitch: shouldSwitch})
}
func (c *GRPCClient) SwitchToScreenIfDifferent(screenName string) {
_, _ = c.client.SwitchToScreenIfDifferent(context.Background(), &pb.SwitchToScreenIfDifferentRequest{ScreenName: screenName})
}
func (c *GRPCClient) DoNotUseThisIsForCrashTestingOnly() {
_, _ = c.client.DoNotUseThisIsForCrashTestingOnly(context.Background(), &pb.Empty{})
}

View File

@ -0,0 +1,164 @@
package native
import (
"context"
"fmt"
"net"
"sync"
"github.com/rs/zerolog"
"google.golang.org/grpc"
pb "github.com/jetkvm/kvm/internal/native/proto"
)
// grpcServer wraps the Native instance and implements the gRPC service
type grpcServer struct {
pb.UnimplementedNativeServiceServer
native *Native
logger *zerolog.Logger
eventStreamChan chan *pb.Event
eventStreamMu sync.Mutex
eventStreamCtx context.Context
eventStreamCancel context.CancelFunc
}
// NewGRPCServer creates a new gRPC server for the native service
func NewGRPCServer(n *Native, logger *zerolog.Logger) *grpcServer {
s := &grpcServer{
native: n,
logger: logger,
eventStreamChan: make(chan *pb.Event, 100),
}
// Store original callbacks and wrap them to also broadcast events
originalVideoStateChange := n.onVideoStateChange
originalIndevEvent := n.onIndevEvent
originalRpcEvent := n.onRpcEvent
// Wrap callbacks to both call original and broadcast events
n.onVideoStateChange = func(state VideoState) {
if originalVideoStateChange != nil {
originalVideoStateChange(state)
}
event := &pb.Event{
Type: "video_state_change",
Data: &pb.Event_VideoState{
VideoState: &pb.VideoState{
Ready: state.Ready,
Error: state.Error,
Width: int32(state.Width),
Height: int32(state.Height),
FramePerSecond: state.FramePerSecond,
},
},
}
s.broadcastEvent(event)
}
n.onIndevEvent = func(event string) {
if originalIndevEvent != nil {
originalIndevEvent(event)
}
s.broadcastEvent(&pb.Event{
Type: "indev_event",
Data: &pb.Event_IndevEvent{
IndevEvent: event,
},
})
}
n.onRpcEvent = func(event string) {
if originalRpcEvent != nil {
originalRpcEvent(event)
}
s.broadcastEvent(&pb.Event{
Type: "rpc_event",
Data: &pb.Event_RpcEvent{
RpcEvent: event,
},
})
}
return s
}
func (s *grpcServer) broadcastEvent(event *pb.Event) {
s.eventStreamChan <- event
}
func (s *grpcServer) IsReady(ctx context.Context, req *pb.IsReadyRequest) (*pb.IsReadyResponse, error) {
return &pb.IsReadyResponse{Ready: true, VideoReady: true}, nil
}
// StreamEvents streams events from the native process
func (s *grpcServer) StreamEvents(req *pb.Empty, stream pb.NativeService_StreamEventsServer) error {
setProcTitle("connected")
defer setProcTitle("waiting")
// Cancel previous stream if exists
s.eventStreamMu.Lock()
if s.eventStreamCancel != nil {
s.logger.Debug().Msg("cancelling previous StreamEvents call")
s.eventStreamCancel()
}
// Create a cancellable context for this stream
ctx, cancel := context.WithCancel(stream.Context())
s.eventStreamCtx = ctx
s.eventStreamCancel = cancel
s.eventStreamMu.Unlock()
// Clean up when this stream ends
defer func() {
s.eventStreamMu.Lock()
defer s.eventStreamMu.Unlock()
if s.eventStreamCtx == ctx {
s.eventStreamCancel = nil
s.eventStreamCtx = nil
}
cancel()
}()
// Stream events
for {
select {
case event := <-s.eventStreamChan:
// Check if this stream is still the active one
s.eventStreamMu.Lock()
isActive := s.eventStreamCtx == ctx
s.eventStreamMu.Unlock()
if !isActive {
s.logger.Debug().Msg("stream replaced by new call, exiting")
return context.Canceled
}
if err := stream.Send(event); err != nil {
return err
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// StartGRPCServer starts the gRPC server on a Unix domain socket
func StartGRPCServer(server *grpcServer, socketPath string, logger *zerolog.Logger) (*grpc.Server, net.Listener, error) {
lis, err := net.Listen("unix", socketPath)
if err != nil {
return nil, nil, fmt.Errorf("failed to listen on socket: %w", err)
}
s := grpc.NewServer()
pb.RegisterNativeServiceServer(s, server)
go func() {
if err := s.Serve(lis); err != nil {
logger.Error().Err(err).Msg("gRPC server error")
}
}()
logger.Info().Str("socket", socketPath).Msg("gRPC server started")
return s, lis, nil
}

View File

@ -0,0 +1,230 @@
package native
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "github.com/jetkvm/kvm/internal/native/proto"
)
// Below are generated methods, do not edit manually
// Video methods
func (s *grpcServer) VideoSetSleepMode(ctx context.Context, req *pb.VideoSetSleepModeRequest) (*pb.Empty, error) {
if err := s.native.VideoSetSleepMode(req.Enabled); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.Empty{}, nil
}
func (s *grpcServer) VideoGetSleepMode(ctx context.Context, req *pb.Empty) (*pb.VideoGetSleepModeResponse, error) {
enabled, err := s.native.VideoGetSleepMode()
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.VideoGetSleepModeResponse{Enabled: enabled}, nil
}
func (s *grpcServer) VideoSleepModeSupported(ctx context.Context, req *pb.Empty) (*pb.VideoSleepModeSupportedResponse, error) {
return &pb.VideoSleepModeSupportedResponse{Supported: s.native.VideoSleepModeSupported()}, nil
}
func (s *grpcServer) VideoSetQualityFactor(ctx context.Context, req *pb.VideoSetQualityFactorRequest) (*pb.Empty, error) {
if err := s.native.VideoSetQualityFactor(req.Factor); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.Empty{}, nil
}
func (s *grpcServer) VideoGetQualityFactor(ctx context.Context, req *pb.Empty) (*pb.VideoGetQualityFactorResponse, error) {
factor, err := s.native.VideoGetQualityFactor()
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.VideoGetQualityFactorResponse{Factor: factor}, nil
}
func (s *grpcServer) VideoSetEDID(ctx context.Context, req *pb.VideoSetEDIDRequest) (*pb.Empty, error) {
if err := s.native.VideoSetEDID(req.Edid); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.Empty{}, nil
}
func (s *grpcServer) VideoGetEDID(ctx context.Context, req *pb.Empty) (*pb.VideoGetEDIDResponse, error) {
edid, err := s.native.VideoGetEDID()
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.VideoGetEDIDResponse{Edid: edid}, nil
}
func (s *grpcServer) VideoLogStatus(ctx context.Context, req *pb.Empty) (*pb.VideoLogStatusResponse, error) {
logStatus, err := s.native.VideoLogStatus()
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.VideoLogStatusResponse{Status: logStatus}, nil
}
func (s *grpcServer) VideoStop(ctx context.Context, req *pb.Empty) (*pb.Empty, error) {
procPrefix = "jetkvm: [native]"
setProcTitle(lastProcTitle)
if err := s.native.VideoStop(); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.Empty{}, nil
}
func (s *grpcServer) VideoStart(ctx context.Context, req *pb.Empty) (*pb.Empty, error) {
procPrefix = "jetkvm: [native+video]"
setProcTitle(lastProcTitle)
if err := s.native.VideoStart(); err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.Empty{}, nil
}
// UI methods
func (s *grpcServer) GetLVGLVersion(ctx context.Context, req *pb.Empty) (*pb.GetLVGLVersionResponse, error) {
version, err := s.native.GetLVGLVersion()
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.GetLVGLVersionResponse{Version: version}, nil
}
func (s *grpcServer) UIObjHide(ctx context.Context, req *pb.UIObjHideRequest) (*pb.UIObjHideResponse, error) {
success, err := s.native.UIObjHide(req.ObjName)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjHideResponse{Success: success}, nil
}
func (s *grpcServer) UIObjShow(ctx context.Context, req *pb.UIObjShowRequest) (*pb.UIObjShowResponse, error) {
success, err := s.native.UIObjShow(req.ObjName)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjShowResponse{Success: success}, nil
}
func (s *grpcServer) UISetVar(ctx context.Context, req *pb.UISetVarRequest) (*pb.Empty, error) {
s.native.UISetVar(req.Name, req.Value)
return &pb.Empty{}, nil
}
func (s *grpcServer) UIGetVar(ctx context.Context, req *pb.UIGetVarRequest) (*pb.UIGetVarResponse, error) {
value := s.native.UIGetVar(req.Name)
return &pb.UIGetVarResponse{Value: value}, nil
}
func (s *grpcServer) UIObjAddState(ctx context.Context, req *pb.UIObjAddStateRequest) (*pb.UIObjAddStateResponse, error) {
success, err := s.native.UIObjAddState(req.ObjName, req.State)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjAddStateResponse{Success: success}, nil
}
func (s *grpcServer) UIObjClearState(ctx context.Context, req *pb.UIObjClearStateRequest) (*pb.UIObjClearStateResponse, error) {
success, err := s.native.UIObjClearState(req.ObjName, req.State)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjClearStateResponse{Success: success}, nil
}
func (s *grpcServer) UIObjAddFlag(ctx context.Context, req *pb.UIObjAddFlagRequest) (*pb.UIObjAddFlagResponse, error) {
success, err := s.native.UIObjAddFlag(req.ObjName, req.Flag)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjAddFlagResponse{Success: success}, nil
}
func (s *grpcServer) UIObjClearFlag(ctx context.Context, req *pb.UIObjClearFlagRequest) (*pb.UIObjClearFlagResponse, error) {
success, err := s.native.UIObjClearFlag(req.ObjName, req.Flag)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjClearFlagResponse{Success: success}, nil
}
func (s *grpcServer) UIObjSetOpacity(ctx context.Context, req *pb.UIObjSetOpacityRequest) (*pb.UIObjSetOpacityResponse, error) {
success, err := s.native.UIObjSetOpacity(req.ObjName, int(req.Opacity))
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjSetOpacityResponse{Success: success}, nil
}
func (s *grpcServer) UIObjFadeIn(ctx context.Context, req *pb.UIObjFadeInRequest) (*pb.UIObjFadeInResponse, error) {
success, err := s.native.UIObjFadeIn(req.ObjName, req.Duration)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjFadeInResponse{Success: success}, nil
}
func (s *grpcServer) UIObjFadeOut(ctx context.Context, req *pb.UIObjFadeOutRequest) (*pb.UIObjFadeOutResponse, error) {
success, err := s.native.UIObjFadeOut(req.ObjName, req.Duration)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjFadeOutResponse{Success: success}, nil
}
func (s *grpcServer) UIObjSetLabelText(ctx context.Context, req *pb.UIObjSetLabelTextRequest) (*pb.UIObjSetLabelTextResponse, error) {
success, err := s.native.UIObjSetLabelText(req.ObjName, req.Text)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjSetLabelTextResponse{Success: success}, nil
}
func (s *grpcServer) UIObjSetImageSrc(ctx context.Context, req *pb.UIObjSetImageSrcRequest) (*pb.UIObjSetImageSrcResponse, error) {
success, err := s.native.UIObjSetImageSrc(req.ObjName, req.Image)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.UIObjSetImageSrcResponse{Success: success}, nil
}
func (s *grpcServer) DisplaySetRotation(ctx context.Context, req *pb.DisplaySetRotationRequest) (*pb.DisplaySetRotationResponse, error) {
success, err := s.native.DisplaySetRotation(uint16(req.Rotation))
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
}
return &pb.DisplaySetRotationResponse{Success: success}, nil
}
func (s *grpcServer) UpdateLabelIfChanged(ctx context.Context, req *pb.UpdateLabelIfChangedRequest) (*pb.Empty, error) {
s.native.UpdateLabelIfChanged(req.ObjName, req.NewText)
return &pb.Empty{}, nil
}
func (s *grpcServer) UpdateLabelAndChangeVisibility(ctx context.Context, req *pb.UpdateLabelAndChangeVisibilityRequest) (*pb.Empty, error) {
s.native.UpdateLabelAndChangeVisibility(req.ObjName, req.NewText)
return &pb.Empty{}, nil
}
func (s *grpcServer) SwitchToScreenIf(ctx context.Context, req *pb.SwitchToScreenIfRequest) (*pb.Empty, error) {
s.native.SwitchToScreenIf(req.ScreenName, req.ShouldSwitch)
return &pb.Empty{}, nil
}
func (s *grpcServer) SwitchToScreenIfDifferent(ctx context.Context, req *pb.SwitchToScreenIfDifferentRequest) (*pb.Empty, error) {
s.native.SwitchToScreenIfDifferent(req.ScreenName)
return &pb.Empty{}, nil
}
func (s *grpcServer) DoNotUseThisIsForCrashTestingOnly(ctx context.Context, req *pb.Empty) (*pb.Empty, error) {
s.native.DoNotUseThisIsForCrashTestingOnly()
return &pb.Empty{}, nil
}

View File

@ -0,0 +1,36 @@
package native
// NativeInterface defines the interface that both Native and NativeProxy implement
type NativeInterface interface {
Start() error
VideoSetSleepMode(enabled bool) error
VideoGetSleepMode() (bool, error)
VideoSleepModeSupported() bool
VideoSetQualityFactor(factor float64) error
VideoGetQualityFactor() (float64, error)
VideoSetEDID(edid string) error
VideoGetEDID() (string, error)
VideoLogStatus() (string, error)
VideoStop() error
VideoStart() error
GetLVGLVersion() (string, error)
UIObjHide(objName string) (bool, error)
UIObjShow(objName string) (bool, error)
UISetVar(name string, value string)
UIGetVar(name string) string
UIObjAddState(objName string, state string) (bool, error)
UIObjClearState(objName string, state string) (bool, error)
UIObjAddFlag(objName string, flag string) (bool, error)
UIObjClearFlag(objName string, flag string) (bool, error)
UIObjSetOpacity(objName string, opacity int) (bool, error)
UIObjFadeIn(objName string, duration uint32) (bool, error)
UIObjFadeOut(objName string, duration uint32) (bool, error)
UIObjSetLabelText(objName string, text string) (bool, error)
UIObjSetImageSrc(objName string, image string) (bool, error)
DisplaySetRotation(rotation uint16) (bool, error)
UpdateLabelIfChanged(objName string, newText string)
UpdateLabelAndChangeVisibility(objName string, newText string)
SwitchToScreenIf(screenName string, shouldSwitch []string)
SwitchToScreenIfDifferent(screenName string)
DoNotUseThisIsForCrashTestingOnly()
}

View File

@ -1,6 +1,7 @@
package native
import (
"os"
"sync"
"time"
@ -9,7 +10,6 @@ import (
)
type Native struct {
disable bool
ready chan struct{}
l *zerolog.Logger
lD *zerolog.Logger
@ -28,18 +28,23 @@ type Native struct {
}
type NativeOptions struct {
Disable bool
SystemVersion *semver.Version
AppVersion *semver.Version
DisplayRotation uint16
DefaultQualityFactor float64
MaxRestartAttempts uint
OnVideoStateChange func(state VideoState)
OnVideoFrameReceived func(frame []byte, duration time.Duration)
OnIndevEvent func(event string)
OnRpcEvent func(event string)
OnNativeRestart func()
}
func NewNative(opts NativeOptions) *Native {
pid := os.Getpid()
nativeSubLogger := nativeLogger.With().Int("pid", pid).Str("scope", "native").Logger()
displaySubLogger := displayLogger.With().Int("pid", pid).Str("scope", "native").Logger()
onVideoStateChange := opts.OnVideoStateChange
if onVideoStateChange == nil {
onVideoStateChange = func(state VideoState) {
@ -50,7 +55,7 @@ func NewNative(opts NativeOptions) *Native {
onVideoFrameReceived := opts.OnVideoFrameReceived
if onVideoFrameReceived == nil {
onVideoFrameReceived = func(frame []byte, duration time.Duration) {
nativeLogger.Info().Interface("frame", frame).Dur("duration", duration).Msg("video frame received")
nativeLogger.Trace().Interface("frame", frame).Dur("duration", duration).Msg("video frame received")
}
}
@ -76,10 +81,9 @@ func NewNative(opts NativeOptions) *Native {
}
return &Native{
disable: opts.Disable,
ready: make(chan struct{}),
l: nativeLogger,
lD: displayLogger,
l: &nativeSubLogger,
lD: &displaySubLogger,
systemVersion: opts.SystemVersion,
appVersion: opts.AppVersion,
displayRotation: opts.DisplayRotation,
@ -94,21 +98,11 @@ func NewNative(opts NativeOptions) *Native {
}
}
func (n *Native) Start(initialEDID string) {
if n.disable {
nativeLogger.Warn().Msg("native is disabled, skipping initialization")
setCgoDisabled(true)
return
}
func (n *Native) Start() error {
// set up singleton
setInstance(n)
setUpNativeHandlers()
if err := videoSetEDID(initialEDID); err != nil {
n.l.Warn().Err(err).Msg("failed to set EDID before video init")
}
// start the native video
go n.handleLogChan()
go n.handleVideoStateChan()
@ -121,9 +115,11 @@ func (n *Native) Start(initialEDID string) {
if err := videoInit(n.defaultQualityFactor); err != nil {
n.l.Error().Err(err).Msg("failed to initialize video")
return err
}
close(n.ready)
return nil
}
// DoNotUseThisIsForCrashTestingOnly

View File

@ -0,0 +1,33 @@
# Proto Files
This directory contains the Protocol Buffer definitions for the native service.
## Generating Code
To generate the Go code from the proto files, run:
```bash
./scripts/generate_proto.sh
```
Or manually:
```bash
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
internal/native/proto/native.proto
```
## Prerequisites
- `protoc` - Protocol Buffer compiler
- `protoc-gen-go` - Go plugin for protoc (install with: `go install google.golang.org/protobuf/cmd/protoc-gen-go@latest`)
- `protoc-gen-go-grpc` - gRPC Go plugin for protoc (install with: `go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest`)
## Note
The current `native.pb.go` and `native_grpc.pb.go` files are placeholder/stub files. They should be regenerated from `native.proto` using the commands above.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,258 @@
syntax = "proto3";
package native;
option go_package = "github.com/jetkvm/kvm/internal/native/proto";
// NativeService provides methods to interact with the native layer
service NativeService {
// Ready check
rpc IsReady(IsReadyRequest) returns (IsReadyResponse);
// Video methods
rpc VideoSetSleepMode(VideoSetSleepModeRequest) returns (Empty);
rpc VideoGetSleepMode(Empty) returns (VideoGetSleepModeResponse);
rpc VideoSleepModeSupported(Empty) returns (VideoSleepModeSupportedResponse);
rpc VideoSetQualityFactor(VideoSetQualityFactorRequest) returns (Empty);
rpc VideoGetQualityFactor(Empty) returns (VideoGetQualityFactorResponse);
rpc VideoSetEDID(VideoSetEDIDRequest) returns (Empty);
rpc VideoGetEDID(Empty) returns (VideoGetEDIDResponse);
rpc VideoLogStatus(Empty) returns (VideoLogStatusResponse);
rpc VideoStop(Empty) returns (Empty);
rpc VideoStart(Empty) returns (Empty);
// UI methods
rpc GetLVGLVersion(Empty) returns (GetLVGLVersionResponse);
rpc UIObjHide(UIObjHideRequest) returns (UIObjHideResponse);
rpc UIObjShow(UIObjShowRequest) returns (UIObjShowResponse);
rpc UISetVar(UISetVarRequest) returns (Empty);
rpc UIGetVar(UIGetVarRequest) returns (UIGetVarResponse);
rpc UIObjAddState(UIObjAddStateRequest) returns (UIObjAddStateResponse);
rpc UIObjClearState(UIObjClearStateRequest) returns (UIObjClearStateResponse);
rpc UIObjAddFlag(UIObjAddFlagRequest) returns (UIObjAddFlagResponse);
rpc UIObjClearFlag(UIObjClearFlagRequest) returns (UIObjClearFlagResponse);
rpc UIObjSetOpacity(UIObjSetOpacityRequest) returns (UIObjSetOpacityResponse);
rpc UIObjFadeIn(UIObjFadeInRequest) returns (UIObjFadeInResponse);
rpc UIObjFadeOut(UIObjFadeOutRequest) returns (UIObjFadeOutResponse);
rpc UIObjSetLabelText(UIObjSetLabelTextRequest) returns (UIObjSetLabelTextResponse);
rpc UIObjSetImageSrc(UIObjSetImageSrcRequest) returns (UIObjSetImageSrcResponse);
rpc DisplaySetRotation(DisplaySetRotationRequest) returns (DisplaySetRotationResponse);
rpc UpdateLabelIfChanged(UpdateLabelIfChangedRequest) returns (Empty);
rpc UpdateLabelAndChangeVisibility(UpdateLabelAndChangeVisibilityRequest) returns (Empty);
rpc SwitchToScreenIf(SwitchToScreenIfRequest) returns (Empty);
rpc SwitchToScreenIfDifferent(SwitchToScreenIfDifferentRequest) returns (Empty);
// Testing
rpc DoNotUseThisIsForCrashTestingOnly(Empty) returns (Empty);
// Events stream
rpc StreamEvents(Empty) returns (stream Event);
}
// Messages
message Empty {}
message IsReadyRequest {}
message IsReadyResponse {
bool ready = 1;
string error = 2;
bool video_ready = 3;
}
message VideoState {
bool ready = 1;
string error = 2;
int32 width = 3;
int32 height = 4;
double frame_per_second = 5;
}
message VideoSetSleepModeRequest {
bool enabled = 1;
}
message VideoGetSleepModeResponse {
bool enabled = 1;
}
message VideoSleepModeSupportedResponse {
bool supported = 1;
}
message VideoSetQualityFactorRequest {
double factor = 1;
}
message VideoGetQualityFactorResponse {
double factor = 1;
}
message VideoSetEDIDRequest {
string edid = 1;
}
message VideoGetEDIDResponse {
string edid = 1;
}
message VideoLogStatusResponse {
string status = 1;
}
message GetLVGLVersionResponse {
string version = 1;
}
message UIObjHideRequest {
string obj_name = 1;
}
message UIObjHideResponse {
bool success = 1;
}
message UIObjShowRequest {
string obj_name = 1;
}
message UIObjShowResponse {
bool success = 1;
}
message UISetVarRequest {
string name = 1;
string value = 2;
}
message UIGetVarRequest {
string name = 1;
}
message UIGetVarResponse {
string value = 1;
}
message UIObjAddStateRequest {
string obj_name = 1;
string state = 2;
}
message UIObjAddStateResponse {
bool success = 1;
}
message UIObjClearStateRequest {
string obj_name = 1;
string state = 2;
}
message UIObjClearStateResponse {
bool success = 1;
}
message UIObjAddFlagRequest {
string obj_name = 1;
string flag = 2;
}
message UIObjAddFlagResponse {
bool success = 1;
}
message UIObjClearFlagRequest {
string obj_name = 1;
string flag = 2;
}
message UIObjClearFlagResponse {
bool success = 1;
}
message UIObjSetOpacityRequest {
string obj_name = 1;
int32 opacity = 2;
}
message UIObjSetOpacityResponse {
bool success = 1;
}
message UIObjFadeInRequest {
string obj_name = 1;
uint32 duration = 2;
}
message UIObjFadeInResponse {
bool success = 1;
}
message UIObjFadeOutRequest {
string obj_name = 1;
uint32 duration = 2;
}
message UIObjFadeOutResponse {
bool success = 1;
}
message UIObjSetLabelTextRequest {
string obj_name = 1;
string text = 2;
}
message UIObjSetLabelTextResponse {
bool success = 1;
}
message UIObjSetImageSrcRequest {
string obj_name = 1;
string image = 2;
}
message UIObjSetImageSrcResponse {
bool success = 1;
}
message DisplaySetRotationRequest {
uint32 rotation = 1;
}
message DisplaySetRotationResponse {
bool success = 1;
}
message UpdateLabelIfChangedRequest {
string obj_name = 1;
string new_text = 2;
}
message UpdateLabelAndChangeVisibilityRequest {
string obj_name = 1;
string new_text = 2;
}
message SwitchToScreenIfRequest {
string screen_name = 1;
repeated string should_switch = 2;
}
message SwitchToScreenIfDifferentRequest {
string screen_name = 1;
}
message Event {
string type = 1;
oneof data {
VideoState video_state = 2;
string indev_event = 3;
string rpc_event = 4;
VideoFrame video_frame = 5;
}
}
message VideoFrame {
bytes frame = 1;
int64 duration_ns = 2;
}

File diff suppressed because it is too large Load Diff

688
internal/native/proxy.go Normal file
View File

@ -0,0 +1,688 @@
package native
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"net"
"os"
"os/exec"
"runtime"
"strings"
"sync"
"syscall"
"time"
"github.com/Masterminds/semver/v3"
"github.com/jetkvm/kvm/internal/supervisor"
"github.com/jetkvm/kvm/internal/utils"
"github.com/rs/zerolog"
)
const (
maxFrameSize = 1920 * 1080 / 2
defaultMaxRestartAttempts uint = 5
)
type nativeProxyOptions struct {
Disable bool `env:"JETKVM_NATIVE_DISABLE"`
SystemVersion *semver.Version `env:"JETKVM_NATIVE_SYSTEM_VERSION"`
AppVersion *semver.Version `env:"JETKVM_NATIVE_APP_VERSION"`
DisplayRotation uint16 `env:"JETKVM_NATIVE_DISPLAY_ROTATION"`
DefaultQualityFactor float64 `env:"JETKVM_NATIVE_DEFAULT_QUALITY_FACTOR"`
CtrlUnixSocket string `env:"JETKVM_NATIVE_CTRL_UNIX_SOCKET"`
VideoStreamUnixSocket string `env:"JETKVM_NATIVE_VIDEO_STREAM_UNIX_SOCKET"`
BinaryPath string `env:"JETKVM_NATIVE_BINARY_PATH"`
LoggerLevel zerolog.Level `env:"JETKVM_NATIVE_LOGGER_LEVEL"`
HandshakeMessage string `env:"JETKVM_NATIVE_HANDSHAKE_MESSAGE"`
MaxRestartAttempts uint
OnVideoFrameReceived func(frame []byte, duration time.Duration)
OnIndevEvent func(event string)
OnRpcEvent func(event string)
OnVideoStateChange func(state VideoState)
OnNativeRestart func()
}
func randomId(binaryLength int) string {
s := make([]byte, binaryLength)
_, err := rand.Read(s)
if err != nil {
nativeLogger.Error().Err(err).Msg("failed to generate random ID")
return strings.Repeat("0", binaryLength*2) // return all zeros if error
}
return hex.EncodeToString(s)
}
func (n *NativeOptions) toProxyOptions() *nativeProxyOptions {
// random 16 bytes hex string
handshakeMessage := randomId(16)
maxRestartAttempts := defaultMaxRestartAttempts
if n.MaxRestartAttempts > 0 {
maxRestartAttempts = n.MaxRestartAttempts
}
return &nativeProxyOptions{
SystemVersion: n.SystemVersion,
AppVersion: n.AppVersion,
DisplayRotation: n.DisplayRotation,
DefaultQualityFactor: n.DefaultQualityFactor,
OnVideoFrameReceived: n.OnVideoFrameReceived,
OnIndevEvent: n.OnIndevEvent,
OnRpcEvent: n.OnRpcEvent,
OnVideoStateChange: n.OnVideoStateChange,
OnNativeRestart: n.OnNativeRestart,
HandshakeMessage: handshakeMessage,
MaxRestartAttempts: maxRestartAttempts,
}
}
func (p *nativeProxyOptions) toNativeOptions() *NativeOptions {
return &NativeOptions{
SystemVersion: p.SystemVersion,
AppVersion: p.AppVersion,
DisplayRotation: p.DisplayRotation,
DefaultQualityFactor: p.DefaultQualityFactor,
}
}
// cmdWrapper wraps exec.Cmd to implement processCmd interface
type cmdWrapper struct {
*exec.Cmd
stdoutHandler *nativeProxyStdoutHandler
}
func (c *cmdWrapper) GetProcess() interface {
Kill() error
Signal(sig interface{}) error
} {
return &processWrapper{Process: c.Process}
}
type processWrapper struct {
*os.Process
}
func (p *processWrapper) Signal(sig interface{}) error {
if sig == nil {
// Check if process is alive by sending signal 0
return p.Process.Signal(os.Signal(syscall.Signal(0)))
}
if s, ok := sig.(os.Signal); ok {
return p.Process.Signal(s)
}
return fmt.Errorf("invalid signal type")
}
// NativeProxy is a proxy that communicates with a separate native process
type NativeProxy struct {
nativeUnixSocket string
videoStreamUnixSocket string
videoStreamListener net.Listener
binaryPath string
startMu sync.Mutex // mutex for the start process (context and isStopped)
ctx context.Context
cancel context.CancelFunc
client *GRPCClient
clientMu sync.RWMutex // mutex for the client
cmd *cmdWrapper
cmdMu sync.Mutex // mutex for the cmd
logger *zerolog.Logger
options *nativeProxyOptions
restarts uint
stopped bool
}
// NewNativeProxy creates a new NativeProxy that spawns a separate process
func NewNativeProxy(opts NativeOptions) (*NativeProxy, error) {
proxyOptions := opts.toProxyOptions()
proxyOptions.VideoStreamUnixSocket = fmt.Sprintf("@jetkvm/native/video-stream/%s", randomId(4))
// Get the current executable path to spawn itself
exePath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
proxy := &NativeProxy{
nativeUnixSocket: proxyOptions.CtrlUnixSocket,
videoStreamUnixSocket: proxyOptions.VideoStreamUnixSocket,
binaryPath: exePath,
logger: nativeLogger,
options: proxyOptions,
restarts: 0,
}
return proxy, nil
}
func (p *NativeProxy) startVideoStreamListener() error {
if p.videoStreamListener != nil {
return nil
}
logger := p.logger.With().Str("socketPath", p.videoStreamUnixSocket).Logger()
listener, err := net.Listen("unixpacket", p.videoStreamUnixSocket)
if err != nil {
logger.Warn().Err(err).Msg("failed to start video stream listener")
return fmt.Errorf("failed to start video stream listener: %w", err)
}
logger.Info().Msg("video stream listener started")
p.videoStreamListener = listener
go func() {
for {
conn, err := listener.Accept()
if err != nil {
logger.Warn().Err(err).Msg("failed to accept socket")
continue
}
logger.Info().Msg("video stream socket accepted")
go p.handleVideoFrame(conn)
}
}()
return nil
}
type nativeProxyStdoutHandler struct {
mu *sync.Mutex
handshakeCh chan bool
handshakeMessage string
handshakeDone bool
}
func (w *nativeProxyStdoutHandler) Write(p []byte) (n int, err error) {
w.mu.Lock()
defer w.mu.Unlock()
if !w.handshakeDone && strings.Contains(string(p), w.handshakeMessage) {
w.handshakeDone = true
w.handshakeCh <- true
return len(p), nil
}
os.Stdout.Write(p)
return len(p), nil
}
func (p *NativeProxy) toProcessCommand() (*cmdWrapper, error) {
// generate a new random ID for the gRPC socket on each restart
// sometimes the socket is not closed properly when the process exits
// this is a workaround to avoid the issue
p.nativeUnixSocket = fmt.Sprintf("jetkvm/native/grpc/%s", randomId(4))
p.options.CtrlUnixSocket = p.nativeUnixSocket
envArgs, err := utils.MarshalEnv(p.options)
if err != nil {
return nil, fmt.Errorf("failed to marshal environment variables: %w", err)
}
cmd := &cmdWrapper{
Cmd: exec.Command(
p.binaryPath,
"-subcomponent=native",
),
stdoutHandler: &nativeProxyStdoutHandler{
mu: &sync.Mutex{},
handshakeCh: make(chan bool),
handshakeMessage: p.options.HandshakeMessage,
},
}
cmd.Stdout = cmd.stdoutHandler
cmd.Stderr = os.Stderr
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGTERM,
}
// Set environment variable to indicate native process mode
cmd.Env = append(
os.Environ(),
envArgs...,
)
return cmd, nil
}
func (p *NativeProxy) handleVideoFrame(conn net.Conn) {
defer conn.Close()
inboundPacket := make([]byte, maxFrameSize)
lastFrame := time.Now()
for {
n, err := conn.Read(inboundPacket)
if err != nil {
p.logger.Warn().Err(err).Msg("failed to read video frame from socket")
break
}
now := time.Now()
sinceLastFrame := now.Sub(lastFrame)
lastFrame = now
p.options.OnVideoFrameReceived(inboundPacket[:n], sinceLastFrame)
}
}
// it should be only called by start() method, as it isn't thread-safe
func (p *NativeProxy) setUpGRPCClient() error {
// wait until handshake completed
select {
case <-p.cmd.stdoutHandler.handshakeCh:
p.logger.Info().Msg("handshake completed")
case <-time.After(10 * time.Second):
return fmt.Errorf("handshake not completed within 10 seconds")
}
logger := p.logger.With().Str("socketPath", "@"+p.nativeUnixSocket).Logger()
client, err := NewGRPCClient(grpcClientOptions{
SocketPath: p.nativeUnixSocket,
Logger: &logger,
OnIndevEvent: p.options.OnIndevEvent,
OnRpcEvent: p.options.OnRpcEvent,
OnVideoStateChange: p.options.OnVideoStateChange,
})
logger.Info().Msg("created gRPC client")
if err != nil {
return fmt.Errorf("failed to create gRPC client: %w", err)
}
p.client = client
// Wait for ready signal from the native process
if err := p.client.WaitReady(); err != nil {
// Clean up if ready failed
if p.cmd.Process != nil {
_ = p.cmd.Process.Kill()
_ = p.cmd.Wait()
}
return fmt.Errorf("failed to wait for ready: %w", err)
}
// Call on native restart callback if it exists and restarts are greater than 0
if p.options.OnNativeRestart != nil && p.restarts > 0 {
go p.options.OnNativeRestart()
}
return nil
}
func (p *NativeProxy) doStart() error {
p.cmdMu.Lock()
defer p.cmdMu.Unlock()
// lock OS thread to prevent the process from being moved to a different thread
// see also https://go.dev/issue/27505
runtime.LockOSThread()
defer runtime.UnlockOSThread()
cmd, err := p.toProcessCommand()
if err != nil {
return fmt.Errorf("failed to create process: %w", err)
}
p.cmd = cmd
if err := p.cmd.Start(); err != nil {
return fmt.Errorf("failed to start native process: %w", err)
}
// here we'll replace the logger with a new one that includes the process ID
// there's no need to lock the mutex here as the side effect is acceptable
newLogger := p.logger.With().Int("pid", p.cmd.Process.Pid).Logger()
p.logger = &newLogger
p.logger.Info().Msg("native process started")
if err := p.setUpGRPCClient(); err != nil {
return fmt.Errorf("failed to set up gRPC client: %w", err)
}
return nil
}
// Start starts the native process
func (p *NativeProxy) Start() error {
p.startMu.Lock()
defer p.startMu.Unlock()
p.ctx, p.cancel = context.WithCancel(context.Background())
if p.stopped {
return fmt.Errorf("proxy is stopped")
}
if err := p.startVideoStreamListener(); err != nil {
return fmt.Errorf("failed to start video stream listener: %w", err)
}
if err := p.doStart(); err != nil {
return fmt.Errorf("failed to start native process: %w", err)
}
go p.monitorProcess()
return nil
}
// monitorProcess monitors the native process and restarts it if it crashes
func (p *NativeProxy) monitorProcess() {
for {
if p.stopped {
return
}
select {
case <-p.ctx.Done():
p.logger.Trace().Msg("context done, stopping monitor process [before wait]")
return
default:
}
p.cmdMu.Lock()
err := fmt.Errorf("native process not started")
if p.cmd != nil {
err = p.cmd.Wait()
}
p.cmdMu.Unlock()
if p.stopped {
return
}
select {
case <-p.ctx.Done():
p.logger.Trace().Msg("context done, stopping monitor process [after wait]")
return
default:
}
p.logger.Warn().Err(err).Msg("native process exited, restarting ...")
// Wait a bit before restarting
time.Sleep(1 * time.Second)
// Restart the process
if err := p.restartProcess(); err != nil {
p.logger.Error().Err(err).Msg("failed to restart native process")
// Wait longer before retrying
time.Sleep(5 * time.Second)
continue
}
}
}
// restartProcess restarts the native process
func (p *NativeProxy) restartProcess() error {
p.restarts++
logger := p.logger.With().Uint("attempt", p.restarts).Uint("maxAttempts", p.options.MaxRestartAttempts).Logger()
if p.restarts >= p.options.MaxRestartAttempts {
logger.Fatal().Msgf("max restart attempts reached, exiting: %s", supervisor.FailsafeReasonVideoMaxRestartAttemptsReached)
return fmt.Errorf("max restart attempts reached")
}
if p.stopped {
return fmt.Errorf("proxy is stopped")
}
// Close old client
p.clientMu.Lock()
if p.client != nil {
if err := p.client.Close(); err != nil {
logger.Warn().Err(err).Msg("failed to close gRPC client")
}
p.client = nil // set to nil to avoid closing it again
}
p.clientMu.Unlock()
logger.Info().Msg("gRPC client closed")
logger.Info().Msg("attempting to restart native process")
if err := p.doStart(); err != nil {
logger.Error().Err(err).Msg("failed to start native process")
return fmt.Errorf("failed to start native process: %w", err)
}
logger.Info().Msg("native process restarted successfully")
return nil
}
// Stop stops the native process
func (p *NativeProxy) Stop() error {
p.startMu.Lock()
defer p.startMu.Unlock()
p.stopped = true
if p.cmd.Process != nil {
if err := p.cmd.Process.Kill(); err != nil {
return fmt.Errorf("failed to kill native process: %w", err)
}
_ = p.cmd.Wait()
}
return nil
}
func zeroValue[V string | bool | float64]() V {
var v V
return v
}
func nativeProxyClientExec[K comparable, V string | bool | float64](p *NativeProxy, fn func(*GRPCClient) (V, error)) (V, error) {
p.clientMu.RLock()
defer p.clientMu.RUnlock()
if p.client == nil {
return zeroValue[V](), fmt.Errorf("gRPC client not initialized")
}
return fn(p.client)
}
func nativeProxyClientExecWithoutArgument(p *NativeProxy, fn func(*GRPCClient) error) error {
p.clientMu.RLock()
defer p.clientMu.RUnlock()
if p.client == nil {
return fmt.Errorf("gRPC client not initialized")
}
return fn(p.client)
}
// Implement all Native methods by forwarding to gRPC client
func (p *NativeProxy) VideoSetSleepMode(enabled bool) error {
return nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
return client.VideoSetSleepMode(enabled)
})
}
func (p *NativeProxy) VideoGetSleepMode() (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.VideoGetSleepMode()
})
}
func (p *NativeProxy) VideoSleepModeSupported() bool {
result, _ := nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.VideoSleepModeSupported(), nil
})
return result
}
func (p *NativeProxy) VideoSetQualityFactor(factor float64) error {
return nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
return client.VideoSetQualityFactor(factor)
})
}
func (p *NativeProxy) VideoGetQualityFactor() (float64, error) {
return nativeProxyClientExec[float64](p, func(client *GRPCClient) (float64, error) {
return client.VideoGetQualityFactor()
})
}
func (p *NativeProxy) VideoSetEDID(edid string) error {
return nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
return client.VideoSetEDID(edid)
})
}
func (p *NativeProxy) VideoGetEDID() (string, error) {
return nativeProxyClientExec[string](p, func(client *GRPCClient) (string, error) {
return client.VideoGetEDID()
})
}
func (p *NativeProxy) VideoLogStatus() (string, error) {
return nativeProxyClientExec[string](p, func(client *GRPCClient) (string, error) {
return client.VideoLogStatus()
})
}
func (p *NativeProxy) VideoStop() error {
return nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
return client.VideoStop()
})
}
func (p *NativeProxy) VideoStart() error {
return nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
return client.VideoStart()
})
}
func (p *NativeProxy) GetLVGLVersion() (string, error) {
return nativeProxyClientExec[string](p, func(client *GRPCClient) (string, error) {
return client.GetLVGLVersion()
})
}
func (p *NativeProxy) UIObjHide(objName string) (bool, error) {
result, err := nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjHide(objName)
})
return result, err
}
func (p *NativeProxy) UIObjShow(objName string) (bool, error) {
result, err := nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjShow(objName)
})
return result, err
}
func (p *NativeProxy) UISetVar(name string, value string) {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.UISetVar(name, value)
return nil
})
}
func (p *NativeProxy) UIGetVar(name string) string {
result, _ := nativeProxyClientExec[string](p, func(client *GRPCClient) (string, error) {
return client.UIGetVar(name), nil
})
return result
}
func (p *NativeProxy) UIObjAddState(objName string, state string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjAddState(objName, state)
})
}
func (p *NativeProxy) UIObjClearState(objName string, state string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjClearState(objName, state)
})
}
func (p *NativeProxy) UIObjAddFlag(objName string, flag string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjAddFlag(objName, flag)
})
}
func (p *NativeProxy) UIObjClearFlag(objName string, flag string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjClearFlag(objName, flag)
})
}
func (p *NativeProxy) UIObjFadeIn(objName string, duration uint32) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjFadeIn(objName, duration)
})
}
func (p *NativeProxy) UIObjFadeOut(objName string, duration uint32) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjFadeOut(objName, duration)
})
}
func (p *NativeProxy) UIObjSetLabelText(objName string, text string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjSetLabelText(objName, text)
})
}
func (p *NativeProxy) UIObjSetImageSrc(objName string, image string) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjSetImageSrc(objName, image)
})
}
func (p *NativeProxy) UIObjSetOpacity(objName string, opacity int) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.UIObjSetOpacity(objName, opacity)
})
}
func (p *NativeProxy) DisplaySetRotation(rotation uint16) (bool, error) {
return nativeProxyClientExec[bool](p, func(client *GRPCClient) (bool, error) {
return client.DisplaySetRotation(rotation)
})
}
func (p *NativeProxy) UpdateLabelIfChanged(objName string, newText string) {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.UpdateLabelIfChanged(objName, newText)
return nil
})
}
func (p *NativeProxy) UpdateLabelAndChangeVisibility(objName string, newText string) {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.UpdateLabelAndChangeVisibility(objName, newText)
return nil
})
}
func (p *NativeProxy) SwitchToScreenIf(screenName string, shouldSwitch []string) {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.SwitchToScreenIf(screenName, shouldSwitch)
return nil
})
}
func (p *NativeProxy) SwitchToScreenIfDifferent(screenName string) {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.SwitchToScreenIfDifferent(screenName)
return nil
})
}
func (p *NativeProxy) DoNotUseThisIsForCrashTestingOnly() {
_ = nativeProxyClientExecWithoutArgument(p, func(client *GRPCClient) error {
client.DoNotUseThisIsForCrashTestingOnly()
return nil
})
}

137
internal/native/server.go Normal file
View File

@ -0,0 +1,137 @@
package native
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"time"
"github.com/caarlos0/env/v11"
"github.com/erikdubbelboer/gspt"
"github.com/rs/zerolog"
)
// Native Process
// stdout - exchange messages with the parent process
// stderr - logging and error messages
var (
procPrefix string = "jetkvm: [native]"
lastProcTitle string
)
const (
DebugModeFile = "/userdata/jetkvm/.native-debug-mode"
)
func setProcTitle(status string) {
lastProcTitle = status
if status != "" {
status = " " + status
}
title := fmt.Sprintf("%s%s", procPrefix, status)
gspt.SetProcTitle(title)
}
func monitorCrashSignal(ctx context.Context, logger *zerolog.Logger, nativeInstance NativeInterface) {
logger.Info().Msg("DEBUG mode: will crash the process on SIGHUP signal")
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGHUP)
for {
select {
case sig := <-sigChan:
logger.Info().Str("signal", sig.String()).Msg("received termination signal")
nativeInstance.DoNotUseThisIsForCrashTestingOnly()
case <-ctx.Done():
logger.Info().Msg("context done, stopping monitor process")
return
}
}
}
// RunNativeProcess runs the native process mode
func RunNativeProcess(binaryName string) {
appCtx, appCtxCancel := context.WithCancel(context.Background())
defer appCtxCancel()
logger := nativeLogger.With().Int("pid", os.Getpid()).Logger()
setProcTitle("starting")
// Parse native options
var proxyOptions nativeProxyOptions
if err := env.Parse(&proxyOptions); err != nil {
logger.Fatal().Err(err).Msg("failed to parse native proxy options")
}
// Connect to video stream socket
conn, err := net.Dial("unixpacket", proxyOptions.VideoStreamUnixSocket)
if err != nil {
logger.Fatal().Err(err).Msg("failed to connect to video stream socket")
}
logger.Info().Str("videoStreamSocketPath", proxyOptions.VideoStreamUnixSocket).Msg("connected to video stream socket")
nativeOptions := proxyOptions.toNativeOptions()
nativeOptions.OnVideoFrameReceived = func(frame []byte, duration time.Duration) {
_, err := conn.Write(frame)
if err != nil {
logger.Fatal().Err(err).Msg("failed to write frame to video stream socket")
}
}
// Create native instance
nativeInstance := NewNative(*nativeOptions)
gspt.SetProcTitle("jetkvm: [native] initializing")
// Start native instance
if err := nativeInstance.Start(); err != nil {
logger.Fatal().Err(err).Msg("failed to start native instance")
}
grpcLogger := logger.With().Str("socketPath", fmt.Sprintf("@%v", proxyOptions.CtrlUnixSocket)).Logger()
setProcTitle("starting gRPC server")
// Create gRPC server
grpcServer := NewGRPCServer(nativeInstance, &grpcLogger)
logger.Info().Msg("starting gRPC server")
// Start gRPC server
server, lis, err := StartGRPCServer(grpcServer, fmt.Sprintf("@%v", proxyOptions.CtrlUnixSocket), &logger)
if err != nil {
logger.Fatal().Err(err).Msg("failed to start gRPC server")
}
setProcTitle("ready")
if _, err := os.Stat(DebugModeFile); err == nil {
logger.Info().Msg("DEBUG mode: enabled")
go monitorCrashSignal(appCtx, &logger, nativeInstance)
}
// Signal that we're ready by writing handshake message to stdout (for parent to read)
// Stdout.Write is used to avoid buffering the message
_, err = os.Stdout.Write([]byte(proxyOptions.HandshakeMessage + "\n"))
if err != nil {
logger.Fatal().Err(err).Msg("failed to write handshake message to stdout")
}
// Set up signal handling
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
// Wait for signal
sig := <-sigChan
logger.Info().
Str("signal", sig.String()).
Msg("received termination signal")
// Graceful shutdown might stuck forever,
// we will use Stop() instead to force quit the gRPC server,
// we can implement a graceful shutdown with a timeout in the future if needed
server.Stop()
lis.Close()
logger.Info().Msg("native process exiting")
}

View File

@ -30,7 +30,7 @@ type MDNSListenOptions struct {
// NetworkConfig represents the complete network configuration for an interface
type NetworkConfig struct {
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"udhcpc"`
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`

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

@ -0,0 +1,11 @@
package supervisor
const (
EnvChildID = "JETKVM_CHILD_ID" // The child ID is the version of the app that is running
EnvSubcomponent = "JETKVM_SUBCOMPONENT" // The subcomponent is the component that is running
ErrorDumpDir = "/userdata/jetkvm/crashdump" // The error dump directory is the directory where the error dumps are stored
ErrorDumpLastFile = "last-crash.log" // The error dump last file is the last error dump file
ErrorDumpTemplate = "jetkvm-%s.log" // The error dump template is the template for the error dump file
FailsafeReasonVideoMaxRestartAttemptsReached = "failsafe::video.max_restart_attempts_reached"
)

92
internal/utils/env.go Normal file
View File

@ -0,0 +1,92 @@
package utils
import (
"fmt"
"reflect"
"strconv"
)
func MarshalEnv(instance interface{}) ([]string, error) {
v := reflect.ValueOf(instance)
if v.Kind() == reflect.Ptr {
if v.IsNil() {
return nil, fmt.Errorf("instance is nil")
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return nil, fmt.Errorf("instance must be a struct or pointer to struct")
}
t := v.Type()
var result []string
for i := 0; i < v.NumField(); i++ {
field := t.Field(i)
fieldValue := v.Field(i)
// Get the env tag
envTag := field.Tag.Get("env")
if envTag == "" || envTag == "-" {
continue
}
// Skip unexported fields
if !fieldValue.CanInterface() {
continue
}
var valueStr string
// Handle different types
switch fieldValue.Kind() {
case reflect.Bool:
valueStr = strconv.FormatBool(fieldValue.Bool())
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
valueStr = strconv.FormatUint(fieldValue.Uint(), 10)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
valueStr = strconv.FormatInt(fieldValue.Int(), 10)
case reflect.Float32, reflect.Float64:
valueStr = strconv.FormatFloat(fieldValue.Float(), 'f', -1, 64)
case reflect.String:
valueStr = fieldValue.String()
case reflect.Ptr:
if fieldValue.IsNil() {
continue // Skip nil pointers
}
elem := fieldValue.Elem()
// Handle *semver.Version and other pointer types
if elem.CanInterface() {
if stringer, ok := elem.Interface().(fmt.Stringer); ok {
valueStr = stringer.String()
} else {
valueStr = fmt.Sprintf("%v", elem.Interface())
}
} else {
valueStr = fmt.Sprintf("%v", elem.Interface())
}
default:
// For other types, try to convert to string
if fieldValue.CanInterface() {
if stringer, ok := fieldValue.Interface().(fmt.Stringer); ok {
valueStr = stringer.String()
} else {
valueStr = fmt.Sprintf("%v", fieldValue.Interface())
}
} else {
valueStr = fmt.Sprintf("%v", fieldValue.Interface())
}
}
result = append(result, fmt.Sprintf("%s=%s", envTag, valueStr))
}
return result, nil
}

View File

@ -0,0 +1,57 @@
package utils
import (
"reflect"
"testing"
"github.com/Masterminds/semver/v3"
)
type nativeOptions struct {
Disable bool `env:"JETKVM_NATIVE_DISABLE"`
SystemVersion *semver.Version `env:"JETKVM_NATIVE_SYSTEM_VERSION"`
AppVersion *semver.Version `env:"JETKVM_NATIVE_APP_VERSION"`
DisplayRotation uint16 `env:"JETKVM_NATIVE_DISPLAY_ROTATION"`
DefaultQualityFactor float64 `env:"JETKVM_NATIVE_DEFAULT_QUALITY_FACTOR"`
}
func TestMarshalEnv(t *testing.T) {
tests := []struct {
name string
instance interface{}
want []string
wantErr bool
}{
{
name: "basic struct",
instance: nativeOptions{
Disable: false,
SystemVersion: semver.MustParse("1.1.0"),
AppVersion: semver.MustParse("1111.0.0"),
DisplayRotation: 1,
DefaultQualityFactor: 1.0,
},
want: []string{
"JETKVM_NATIVE_DISABLE=false",
"JETKVM_NATIVE_SYSTEM_VERSION=1.1.0",
"JETKVM_NATIVE_APP_VERSION=1111.0.0",
"JETKVM_NATIVE_DISPLAY_ROTATION=1",
"JETKVM_NATIVE_DEFAULT_QUALITY_FACTOR=1",
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := MarshalEnv(tt.instance)
if (err != nil) != tt.wantErr {
t.Errorf("MarshalEnv() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("MarshalEnv() = %v, want %v", got, tt.want)
}
})
}
}

View File

@ -242,7 +242,8 @@ func rpcRefreshHdmiConnection() error {
return err
}
if currentEDID == "" {
currentEDID = nativeInstance.GetDefaultEDID()
// Use the default EDID from the native package
currentEDID = native.DefaultEDID
}
return nativeInstance.VideoSetEDID(currentEDID)
}
@ -251,55 +252,6 @@ func rpcGetVideoLogStatus() (string, error) {
return nativeInstance.VideoLogStatus()
}
func rpcGetDevChannelState() (bool, error) {
return config.IncludePreRelease, nil
}
func rpcSetDevChannelState(enabled bool) error {
config.IncludePreRelease = enabled
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
// to ensure backwards compatibility,
// if there's an error, we won't return an error, but we will set the error field
if err != nil {
if updateStatus == nil {
return nil, fmt.Errorf("error checking for updates: %w", err)
}
updateStatus.Error = err.Error()
}
return updateStatus, nil
}
func rpcGetLocalVersion() (*LocalMetadata, error) {
systemVersion, appVersion, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("error getting local version: %w", err)
}
return &LocalMetadata{
AppVersion: appVersion.String(),
SystemVersion: systemVersion.String(),
}, nil
}
func rpcTryUpdate() error {
includePreRelease := config.IncludePreRelease
go func() {
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to try update")
}
}()
return nil
}
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
currentRotation := config.DisplayRotation
if currentRotation == params.Rotation {
@ -669,7 +621,10 @@ func rpcGetMassStorageMode() (string, error) {
}
func rpcIsUpdatePending() (bool, error) {
return IsUpdatePending(), nil
if otaState == nil {
return false, nil
}
return otaState.IsUpdatePending(), nil
}
func rpcGetUsbEmulationState() (bool, error) {
@ -1368,7 +1323,10 @@ var rpcHandlers = map[string]RPCHandler{
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalVersion": {Func: rpcGetLocalVersion},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel, Params: []string{"channel"}},
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
"tryUpdate": {Func: rpcTryUpdate},
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},

36
main.go
View File

@ -2,22 +2,37 @@ package kvm
import (
"context"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/erikdubbelboer/gspt"
"github.com/gwatts/rootcerts"
"github.com/jetkvm/kvm/internal/ota"
)
var appCtx context.Context
var procPrefix string = "jetkvm: [app]"
func setProcTitle(status string) {
if status != "" {
status = " " + status
}
title := fmt.Sprintf("%s%s", procPrefix, status)
gspt.SetProcTitle(title)
}
func Main() {
setProcTitle("starting")
logger.Log().Msg("JetKVM Starting Up")
checkFailsafeReason()
if failsafeModeActive {
procPrefix = "jetkvm: [app+failsafe]"
logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
}
@ -38,10 +53,10 @@ func Main() {
Msg("starting JetKVM")
go runWatchdog()
go confirmCurrentSystem()
initDisplay()
setProcTitle("initNative")
initNative(systemVersionLocal, appVersionLocal)
initDisplay()
initAudio()
defer stopAudio()
@ -56,7 +71,12 @@ func Main() {
Int("ca_certs_loaded", len(rootcerts.Certs())).
Msg("loaded Root CA certificates")
initOta()
http.DefaultClient.Timeout = 1 * time.Minute
// Initialize network
setProcTitle("initNetwork")
if err := initNetwork(); err != nil {
logger.Error().Err(err).Msg("failed to initialize network")
// TODO: reset config to default
@ -64,17 +84,21 @@ func Main() {
}
// Initialize time sync
setProcTitle("initTimeSync")
initTimeSync()
timeSync.Start()
// Initialize mDNS
setProcTitle("initMdns")
if err := initMdns(); err != nil {
logger.Error().Err(err).Msg("failed to initialize mDNS")
}
setProcTitle("initPrometheus")
initPrometheus()
// initialize usb gadget
setProcTitle("initUsbGadget")
initUsbGadget()
if err := setInitialVirtualMediaState(); err != nil {
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
@ -116,7 +140,10 @@ func Main() {
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
DeviceID: GetDeviceID(),
IncludePreRelease: includePreRelease,
})
if err != nil {
logger.Warn().Err(err).Msg("failed to auto update")
}
@ -139,6 +166,9 @@ func Main() {
initPublicIPState()
initSerialPort()
setProcTitle("ready")
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs

View File

@ -11,17 +11,28 @@ import (
)
var (
nativeInstance *native.Native
nativeInstance native.NativeInterface
nativeCmdLock = sync.Mutex{}
)
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{
Disable: failsafeModeActive,
if failsafeModeActive {
nativeInstance = &native.EmptyNativeInterface{}
nativeLogger.Warn().Msg("failsafe mode active, using empty native interface")
return
}
nativeLogger.Info().Msg("initializing native proxy")
var err error
nativeInstance, err = native.NewNativeProxy(native.NativeOptions{
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
DefaultQualityFactor: config.VideoQualityFactor,
MaxRestartAttempts: config.NativeMaxRestart,
OnNativeRestart: func() {
configureDisplayOnNativeRestart()
},
OnVideoStateChange: func(state native.VideoState) {
lastVideoState = state
triggerVideoStateUpdate()
@ -63,8 +74,18 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
}
},
})
if err != nil {
nativeLogger.Fatal().Err(err).Msg("failed to create native proxy")
}
nativeInstance.Start(config.EdidString)
if err := nativeInstance.Start(); err != nil {
nativeLogger.Fatal().Err(err).Msg("failed to start native proxy")
}
go func() {
if err := nativeInstance.VideoSetEDID(config.EdidString); err != nil {
nativeLogger.Warn().Err(err).Msg("error setting EDID")
}
}()
if os.Getenv("JETKVM_CRASH_TESTING") == "1" {
nativeInstance.DoNotUseThisIsForCrashTestingOnly()

View File

@ -11,6 +11,7 @@ import (
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/mdns"
"github.com/jetkvm/kvm/internal/network/types"
"github.com/jetkvm/kvm/internal/ota"
"github.com/jetkvm/kvm/pkg/myip"
"github.com/jetkvm/kvm/pkg/nmlite"
"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)
}
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) {
oldDhcpClient := oldConfig.DHCPClient.String
l := networkLogger.With().
@ -249,7 +250,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
postRebootAction = &PostRebootAction{
postRebootAction = &ota.PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
@ -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)
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
postRebootAction = &PostRebootAction{
postRebootAction = &ota.PostRebootAction{
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
}
@ -283,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
}
if newConfig.Hostname.String != oldConfig.Hostname.String {
rebootRequired = true
l.Info().Msg("Hostname changed, reboot required")
}
return rebootRequired, postRebootAction
}

672
ota.go
View File

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

@ -4,6 +4,8 @@ set -e
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
source ${SCRIPT_PATH}/build_utils.sh
CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE:-Release}
CGO_PATH=$(realpath "${SCRIPT_PATH}/../internal/native/cgo")
BUILD_DIR=${CGO_PATH}/build
@ -31,7 +33,7 @@ VERBOSE=1 cmake -B "${BUILD_DIR}" \
-DCONFIG_LV_BUILD_EXAMPLES=OFF \
-DCONFIG_LV_BUILD_DEMOS=OFF \
-DSKIP_GLIBC_NAMES=ON \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \
-DCMAKE_INSTALL_PREFIX="${TMP_DIR}"
msg_info "▶ Copying built library and header files"

43
scripts/configure_vscode.py Executable file
View File

@ -0,0 +1,43 @@
#!/usr/bin/env python3
import json
import os
DEFAULT_C_INTELLISENSE_SETTINGS = {
"configurations": [
{
"name": "Linux",
"includePath": [
"${workspaceFolder}/**"
],
"defines": [],
# "compilerPath": "/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc",
"cStandard": "c17",
"cppStandard": "gnu++17",
"intelliSenseMode": "linux-gcc-arm",
"configurationProvider": "ms-vscode.cmake-tools"
}
],
"version": 4
}
def configure_c_intellisense():
settings_path = os.path.join('.vscode', 'c_cpp_properties.json')
settings = DEFAULT_C_INTELLISENSE_SETTINGS.copy()
# open existing settings if they exist
if os.path.exists(settings_path):
with open(settings_path, 'r') as f:
settings = json.load(f)
# update compiler path
settings['configurations'][0]['compilerPath'] = "/opt/jetkvm-native-buildkit/bin/arm-rockchip830-linux-uclibcgnueabihf-gcc"
settings['configurations'][0]['configurationProvider'] = "ms-vscode.cmake-tools"
with open(settings_path, 'w') as f:
json.dump(settings, f, indent=4)
print("C/C++ IntelliSense configuration updated.")
if __name__ == "__main__":
configure_c_intellisense()

View File

@ -12,12 +12,14 @@ show_help() {
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --gdb-port <port> GDB debug port (default: 2345)"
echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build"
echo " --skip-native-build Skip native build"
echo " --disable-docker Disable docker build"
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
echo " --native-binary Build and deploy the native binary (FOR DEBUGGING ONLY)"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
@ -58,6 +60,8 @@ REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
SKIP_UI_BUILD_RELEASE=0
SKIP_NATIVE_BUILD=0
GDB_DEBUG_PORT=2345
BUILD_NATIVE_BINARY=false
ENABLE_SYNC_TRACE=0
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
@ -79,6 +83,10 @@ while [[ $# -gt 0 ]]; do
REMOTE_USER="$2"
shift 2
;;
--gdb-port)
GDB_DEBUG_PORT="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
@ -113,6 +121,10 @@ while [[ $# -gt 0 ]]; do
RUN_GO_TESTS=true
shift
;;
--native-binary)
BUILD_NATIVE_BINARY=true
shift
;;
-i|--install)
INSTALL_APP=true
shift
@ -141,6 +153,10 @@ fi
# Check device connectivity before proceeding
check_ping "${REMOTE_HOST}"
check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
function sshdev() {
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "$@"
return $?
}
# check if the current CPU architecture is x86_64
if [ "$(uname -m)" != "x86_64" ]; then
@ -152,6 +168,34 @@ if [ "$BUILD_IN_DOCKER" = true ]; then
build_docker_image
fi
if [ "$BUILD_NATIVE_BINARY" = true ]; then
msg_info "▶ Building native binary"
CMAKE_BUILD_TYPE=Debug make build_native
msg_info "▶ Checking if GDB is available on remote host"
if ! sshdev "command -v gdbserver > /dev/null 2>&1"; then
msg_warn "Error: gdbserver is not installed on the remote host"
tar -czf - -C /opt/jetkvm-native-buildkit/gdb/ . | sshdev "tar -xzf - -C /usr/bin"
msg_info "✓ gdbserver installed on remote host"
fi
msg_info "▶ Stopping any existing instances of jetkvm_native_debug on remote host"
sshdev "killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug gdbserver || true >> /dev/null 2>&1"
sshdev "cat > ${REMOTE_PATH}/jetkvm_native_debug" < internal/native/cgo/build/jknative-bin
sshdev -t ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located
export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
cd ${REMOTE_PATH}
killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug || true
sleep 5
echo 'V' > /dev/watchdog
chmod +x jetkvm_native_debug
gdbserver localhost:${GDB_DEBUG_PORT} ./jetkvm_native_debug
EOF
exit 0
fi
# Build the development version on the host
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
# check if static/index.html exists
@ -176,10 +220,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
sshdev "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
sshdev ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
@ -222,10 +266,10 @@ then
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Copy the binary to the remote host as if we were the OTA updater.
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
# Reboot the device, the new app will be deployed by the startup process.
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
sshdev "reboot"
else
msg_info "▶ Building development binary"
do_make build_dev \
@ -234,21 +278,21 @@ else
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
# Kill any existing instances of the application
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
sshdev "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
sshdev "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
if [ "$RESET_USB_HID_DEVICE" = true ]; then
msg_info "▶ Resetting USB HID device"
msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
# Remove the old USB gadget configuration
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
sshdev "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
sshdev "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
sshdev ash << EOF
set -e
# Set the library path to include the directory where librockit.so is located

44
scripts/generate_proto.sh Executable file
View File

@ -0,0 +1,44 @@
#!/bin/bash
# Generate gRPC code from proto files
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
# Check if protoc is installed
if ! command -v protoc &> /dev/null; then
echo "Error: protoc is not installed"
echo "Install it with:"
echo " apt-get install protobuf-compiler # Debian/Ubuntu"
echo " brew install protobuf # macOS"
exit 1
fi
# Check if protoc-gen-go is installed
if ! command -v protoc-gen-go &> /dev/null; then
echo "Error: protoc-gen-go is not installed"
echo "Install it with: go install google.golang.org/protobuf/cmd/protoc-gen-go@latest"
exit 1
fi
# Check if protoc-gen-go-grpc is installed
if ! command -v protoc-gen-go-grpc &> /dev/null; then
echo "Error: protoc-gen-go-grpc is not installed"
echo "Install it with: go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"
exit 1
fi
# Generate code
echo "Generating gRPC code from proto files..."
protoc \
--go_out=. \
--go_opt=paths=source_relative \
--go-grpc_out=. \
--go-grpc_opt=paths=source_relative \
internal/native/proto/native.proto
echo "Done!"

134
scripts/release.sh Normal file
View File

@ -0,0 +1,134 @@
#!/bin/bash
set -eE
set -o pipefail
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
source ${SCRIPT_PATH}/build_utils.sh
# Function to display help message
show_help() {
echo "Usage: $0 [options] -v <version>"
echo
echo "Required:"
echo " --app-version <version> App version to release"
echo " --system-version <version> System version to release"
echo
echo "Optional:"
echo " -u, --user <remote_user> Remote username (default: root)"
echo " --run-go-tests Run go tests"
echo " --run-go-tests-only Run go tests and exit"
echo " --skip-ui-build Skip frontend/UI build"
echo " --skip-native-build Skip native build"
echo " --disable-docker Disable docker build"
echo " -i, --install Build for release and install the app"
echo " --help Display this help message"
echo
echo "Example:"
echo " $0 --system-version 0.2.6"
}
BUILD_VERSION=$1
R2_PATH="r2://jetkvm-update/system"
PACK_BIN_PATH="./tools/linux/Linux_Pack_Firmware"
UNPACK_BIN="${PACK_BIN_PATH}/mk-update_unpack.sh"
# Create temporary directory for downloads
TEMP_DIR=$(mktemp -d)
msg_ok "Created temporary directory: $TEMP_DIR"
# Cleanup function
cleanup() {
if [ -d "$TEMP_DIR" ]; then
msg_info "Cleaning up temporary directory: $TEMP_DIR"
rm -rf "$TEMP_DIR"
fi
}
# Set trap to cleanup on exit
# trap cleanup EXIT
mkdir -p ${TEMP_DIR}/extracted-update
${UNPACK_BIN} -i update.img -o ${TEMP_DIR}/extracted-update
exit 0
# Check if the version already exists
if rclone lsf $R2_PATH/$BUILD_VERSION/ | grep -q .; then
msg_err "Error: Version $BUILD_VERSION already exists in the remote storage."
exit 1
fi
# Check if the version exists in the github
RELEASE_URL="https://api.github.com/repos/jetkvm/rv1106-system/releases/tags/v$BUILD_VERSION"
# Download the release JSON
RELEASE_JSON=$(curl -s $RELEASE_URL)
# Check if the release has assets we need
if echo $RELEASE_JSON | jq -e '.assets | length == 0' > /dev/null; then
msg_err "Error: Version $BUILD_VERSION does not have assets we need."
exit 1
fi
function get_file_by_name() {
local file_name=$1
local file_url=$(echo $RELEASE_JSON | jq -r ".assets[] | select(.name == \"$file_name\") | .browser_download_url")
if [ -z "$file_url" ]; then
msg_err "Error: File $file_name not found in the release."
exit 1
fi
local digest=$(echo $RELEASE_JSON | jq -r ".assets[] | select(.name == \"$file_name\") | .digest")
local temp_file_path="$TEMP_DIR/$file_name"
msg_info "Downloading $file_name: $file_url"
# Download the file to temporary directory
curl -L -o "$temp_file_path" "$file_url"
# Verify digest if available
if [ "$digest" != "null" ] && [ -n "$digest" ]; then
msg_info "Verifying digest for $file_name ..."
local calculated_digest=$(sha256sum "$temp_file_path" | cut -d' ' -f1)
# Strip "sha256:" prefix if present
local expected_digest=$(echo "$digest" | sed 's/^sha256://')
if [ "$calculated_digest" != "$expected_digest" ]; then
msg_err "🙅 Digest verification failed for $file_name"
msg_info "Expected: $expected_digest"
msg_info "Calculated: $calculated_digest"
exit 1
fi
else
msg_warn "Warning: No digest available for $file_name, skipping verification"
fi
msg_ok "$file_name downloaded and verified."
}
get_file_by_name "update_ota.tar"
get_file_by_name "update.img"
strings -d bin/jetkvm_app | grep -x '0.4.8'
# Ask for confirmation
msg_info "Do you want to continue with the release? (y/n)"
read -n 1 -s -r -p "Press y to continue, any other key to exit"
echo -ne "\n"
if [ "$REPLY" != "y" ]; then
msg_err "🙅 Release cancelled."
exit 1
fi
msg_info "Releasing $BUILD_VERSION..."
sha256sum $TEMP_DIR/update_ota.tar | awk '{print $1}' > $TEMP_DIR/update_ota.tar.sha256
sha256sum $TEMP_DIR/update.img | awk '{print $1}' > $TEMP_DIR/update.img.sha256
# Check if the version already exists
msg_info "Copying to $R2_PATH/$BUILD_VERSION/"
rclone copyto --progress $TEMP_DIR/update_ota.tar $R2_PATH/$BUILD_VERSION/system.tar
rclone copyto --progress $TEMP_DIR/update_ota.tar.sha256 $R2_PATH/$BUILD_VERSION/system.tar.sha256
rclone copyto --progress $TEMP_DIR/update.img $R2_PATH/$BUILD_VERSION/update.img
rclone copyto --progress $TEMP_DIR/update.img.sha256 $R2_PATH/$BUILD_VERSION/update.img.sha256
msg_ok "$BUILD_VERSION released."

View File

@ -47,52 +47,8 @@
"access_tls_self_signed": "Selvsigneret",
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
"access_update_tls_settings": "Opdater TLS-indstillinger",
"action_bar_audio": "Lyd",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Forbindelsesstatistik",
"audio_input_failed_disable": "Kunne ikke deaktivere lydindgang: {error}",
"audio_input_failed_enable": "Kunne ikke aktivere lydindgang: {error}",
"audio_input_auto_enable_disabled": "Automatisk aktivering af mikrofon deaktiveret",
"audio_input_auto_enable_enabled": "Automatisk aktivering af mikrofon aktiveret",
"audio_output_disabled": "Lydudgang deaktiveret",
"audio_output_enabled": "Lydudgang aktiveret",
"audio_output_failed_disable": "Kunne ikke deaktivere lydudgang: {error}",
"audio_output_failed_enable": "Kunne ikke aktivere lydudgang: {error}",
"audio_popover_title": "Lyd",
"audio_popover_description": "Hurtige lydkontroller til højttalere og mikrofon",
"audio_speakers_title": "Højttalere",
"audio_speakers_description": "Lyd fra mål til højttalere",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofonindgang til mål",
"audio_https_only": "Kun HTTPS",
"audio_settings_description": "Konfigurer lydindgangs- og lydudgangsindstillinger for din JetKVM-enhed",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra fjerncomputeren",
"audio_settings_output_source_description": "Vælg lydoptagelsesenheden (HDMI eller USB)",
"audio_settings_output_source_failed": "Kunne ikke indstille lydudgangskilde: {error}",
"audio_settings_output_source_success": "Lydudgangskilde opdateret. Lyd starter om 30-60 sekunder.",
"audio_settings_output_source_title": "Lydudgangskilde",
"audio_settings_output_title": "Lydudgang",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk browsermikrofon ved tilslutning (ellers skal du aktivere det manuelt ved hver session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Lydkodningsbitrate (højere = bedre kvalitet, mere båndbredde)",
"audio_settings_complexity_title": "Opus Kompleksitet",
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, højere = bedre kvalitet, mere CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Transmission)",
"audio_settings_dtx_description": "Spar båndbredde under stilhed",
"audio_settings_fec_title": "FEC (Fremadrettet Fejlkorrektion)",
"audio_settings_fec_description": "Forbedre lydkvaliteten på tabende forbindelser",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstørrelse (højere = mere stabil, mere latens)",
"audio_settings_sample_rate_title": "Samplingsrate",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Pakketabskompensation",
"audio_settings_packet_loss_description": "FEC overhead-procent (højere = bedre gendannelse, mere båndbredde)",
"audio_settings_config_updated": "Lydkonfiguration opdateret",
"audio_settings_apply_button": "Anvend indstillinger",
"audio_settings_applied": "Lydindstillinger anvendt",
"action_bar_extension": "Udvidelse",
"action_bar_fullscreen": "Fuldskærm",
"action_bar_settings": "Indstillinger",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Lydudgangskilde opdateret. Lyd starter om 30-60 sekunder.",
"audio_settings_output_source_title": "Lydudgangskilde",
"audio_settings_output_title": "Lydudgang",
"audio_settings_packet_loss_description": "FEC overhead-procent (højere = bedre gendannelse, mere båndbredde)",
"audio_settings_packet_loss_title": "Pakketabskompensation",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Samplingsrate",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Lyd fra mål til højttalere",
@ -881,8 +841,8 @@
"usb_device_description": "USB-enheder, der skal emuleres på målcomputeren",
"usb_device_enable_absolute_mouse_description": "Aktivér absolut mus (markør)",
"usb_device_enable_absolute_mouse_title": "Aktivér absolut mus (markør)",
"usb_device_enable_audio_description": "Aktiver tovejs lyd",
"usb_device_enable_audio_title": "Aktiver USB-lyd",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Aktivér tastatur",
"usb_device_enable_keyboard_title": "Aktivér tastatur",
"usb_device_enable_mass_storage_description": "Nogle gange skal det muligvis deaktiveres for at forhindre problemer med bestemte enheder.",
@ -892,7 +852,7 @@
"usb_device_failed_load": "Kunne ikke indlæse USB-enheder: {error}",
"usb_device_failed_set": "Kunne ikke indstille USB-enheder: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, mus, masselager og lyd",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Kun tastatur",
"usb_device_restore_default": "Gendan til standard",
"usb_device_title": "USB-enhed",

View File

@ -49,50 +49,6 @@
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Verbindungsstatistiken",
"audio_input_failed_disable": "Fehler beim Deaktivieren des Audioeingangs: {error}",
"audio_input_failed_enable": "Fehler beim Aktivieren des Audioeingangs: {error}",
"audio_input_auto_enable_disabled": "Automatische Mikrofonaktivierung deaktiviert",
"audio_input_auto_enable_enabled": "Automatische Mikrofonaktivierung aktiviert",
"audio_output_disabled": "Audioausgang deaktiviert",
"audio_output_enabled": "Audioausgang aktiviert",
"audio_output_failed_disable": "Fehler beim Deaktivieren des Audioausgangs: {error}",
"audio_output_failed_enable": "Fehler beim Aktivieren des Audioausgangs: {error}",
"audio_popover_title": "Audio",
"audio_popover_description": "Schnelle Audiosteuerung für Lautsprecher und Mikrofon",
"audio_speakers_title": "Lautsprecher",
"audio_speakers_description": "Audio vom Ziel zu Lautsprechern",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoneingang zum Ziel",
"audio_https_only": "Nur HTTPS",
"audio_settings_description": "Konfigurieren Sie Audio-Eingangs- und Ausgangseinstellungen für Ihr JetKVM-Gerät",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Audio vom entfernten Computer aktivieren oder deaktivieren",
"audio_settings_output_source_description": "Wählen Sie das Audioaufnahmegerät (HDMI oder USB)",
"audio_settings_output_source_failed": "Fehler beim Festlegen der Audioausgabequelle: {error}",
"audio_settings_output_source_success": "Audioausgabequelle aktualisiert. Audio startet in 30-60 Sekunden.",
"audio_settings_output_source_title": "Audioausgabequelle",
"audio_settings_output_title": "Audioausgang",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Mikrofon automatisch aktivieren",
"audio_settings_auto_enable_microphone_description": "Browser-Mikrofon beim Verbinden automatisch aktivieren (andernfalls müssen Sie es in jeder Sitzung manuell aktivieren)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Audio-Codierungsbitrate (höher = bessere Qualität, mehr Bandbreite)",
"audio_settings_complexity_title": "Opus Komplexität",
"audio_settings_complexity_description": "Encoder-Komplexität (0-10, höher = bessere Qualität, mehr CPU)",
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
"audio_settings_dtx_description": "Bandbreite während Stille sparen",
"audio_settings_fec_title": "FEC (Forward Error Correction)",
"audio_settings_fec_description": "Audioqualität bei verlustbehafteten Verbindungen verbessern",
"audio_settings_buffer_title": "Pufferperioden",
"audio_settings_buffer_description": "ALSA-Puffergröße (höher = stabiler, mehr Latenz)",
"audio_settings_sample_rate_title": "Abtastrate",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Paketverlust-Kompensation",
"audio_settings_packet_loss_description": "FEC-Overhead-Prozentsatz (höher = bessere Wiederherstellung, mehr Bandbreite)",
"audio_settings_config_updated": "Audiokonfiguration aktualisiert",
"audio_settings_apply_button": "Einstellungen anwenden",
"audio_settings_applied": "Audioeinstellungen angewendet",
"action_bar_extension": "Erweiterung",
"action_bar_fullscreen": "Vollbild",
"action_bar_settings": "Einstellungen",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Audioausgabequelle aktualisiert. Audio startet in 30-60 Sekunden.",
"audio_settings_output_source_title": "Audioausgabequelle",
"audio_settings_output_title": "Audioausgang",
"audio_settings_packet_loss_description": "FEC-Overhead-Prozentsatz (höher = bessere Wiederherstellung, mehr Bandbreite)",
"audio_settings_packet_loss_title": "Paketverlust-Kompensation",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Abtastrate",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Audio vom Ziel zu Lautsprechern",
@ -881,8 +841,8 @@
"usb_device_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_absolute_mouse_title": "Absolute Maus (Zeiger) aktivieren",
"usb_device_enable_audio_description": "Bidirektionales Audio aktivieren",
"usb_device_enable_audio_title": "USB-Audio aktivieren",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
"usb_device_enable_keyboard_title": "Tastatur aktivieren",
"usb_device_enable_mass_storage_description": "Manchmal muss es möglicherweise deaktiviert werden, um Probleme mit bestimmten Geräten zu vermeiden",
@ -892,7 +852,7 @@
"usb_device_failed_load": "USB-Geräte konnten nicht geladen werden: {error}",
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, Maus und Massenspeicher",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, Maus, Massenspeicher und Audio",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Nur Tastatur",
"usb_device_restore_default": "Auf Standard zurücksetzen",
"usb_device_title": "USB-Gerät",

View File

@ -49,53 +49,6 @@
"access_update_tls_settings": "Update TLS Settings",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Connection Stats",
"audio_input_failed_disable": "Failed to disable audio input: {error}",
"audio_input_failed_enable": "Failed to enable audio input: {error}",
"audio_input_auto_enable_disabled": "Auto-enable microphone disabled",
"audio_input_auto_enable_enabled": "Auto-enable microphone enabled",
"audio_output_disabled": "Audio output disabled",
"audio_output_enabled": "Audio output enabled",
"audio_output_failed_disable": "Failed to disable audio output: {error}",
"audio_output_failed_enable": "Failed to enable audio output: {error}",
"audio_popover_title": "Audio",
"audio_popover_description": "Quick audio controls for speakers and microphone",
"audio_speakers_title": "Speakers",
"audio_speakers_description": "Audio from target to speakers",
"audio_microphone_title": "Microphone",
"audio_microphone_description": "Microphone input to target",
"audio_https_only": "HTTPS only",
"audio_settings_description": "Configure audio input and output settings for your JetKVM device",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Enable or disable audio from the remote computer",
"audio_settings_output_source_description": "Select the audio capture device (HDMI or USB)",
"audio_settings_output_source_failed": "Failed to set audio output source: {error}",
"audio_settings_output_source_success": "Audio output source updated. Audio will start in 30-60 seconds.",
"audio_settings_output_source_title": "Audio Output Source",
"audio_settings_output_title": "Audio Output",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Auto-enable Microphone",
"audio_settings_auto_enable_microphone_description": "Automatically enable browser microphone when connecting (otherwise you must manually enable each session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Audio encoding bitrate (higher = better quality, more bandwidth)",
"audio_settings_complexity_title": "Opus Complexity",
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
"audio_settings_dtx_description": "Save bandwidth during silence",
"audio_settings_fec_title": "FEC (Forward Error Correction)",
"audio_settings_fec_description": "Improve audio quality on lossy connections",
"audio_settings_buffer_title": "Buffer Periods",
"audio_settings_buffer_description": "ALSA buffer size (higher = more stable, more latency)",
"audio_settings_sample_rate_title": "Sample Rate",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Packet Loss Compensation",
"audio_settings_packet_loss_description": "FEC overhead percentage (higher = better recovery, more bandwidth)",
"audio_settings_config_updated": "Audio configuration updated",
"audio_settings_apply_button": "Apply Settings",
"audio_settings_applied": "Audio settings applied",
"audio_settings_default_suffix": " (default)",
"audio_settings_default_lan_suffix": " (default - LAN)",
"audio_settings_no_compensation_suffix": " (no compensation)",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Fullscreen",
"action_bar_settings": "Settings",
@ -122,6 +75,7 @@
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
"advanced_error_version_update": "Failed to initiate version update: {error}",
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
"advanced_loopback_only_title": "Loopback-Only Mode",
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
@ -148,6 +102,19 @@
"advanced_update_ssh_key_button": "Update SSH Key",
"advanced_usb_emulation_description": "Control the USB emulation state",
"advanced_usb_emulation_title": "USB Emulation",
"advanced_version_update_app_label": "App Version",
"advanced_version_update_button": "Update to Version",
"advanced_version_update_description": "Install a specific version from GitHub releases",
"advanced_version_update_github_link": "JetKVM releases page",
"advanced_version_update_helper": "Find available versions on the",
"advanced_version_update_reset_config_description": "Reset configuration after the update",
"advanced_version_update_reset_config_label": "Reset configuration",
"advanced_version_update_system_label": "System Version",
"advanced_version_update_target_app": "App only",
"advanced_version_update_target_both": "Both App and System",
"advanced_version_update_target_label": "What to update",
"advanced_version_update_target_system": "System only",
"advanced_version_update_title": "Update to Specific Version",
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
"already_adopted_return_to_dashboard": "Return to Dashboard",
@ -192,18 +159,25 @@
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
"audio_settings_complexity_title": "Opus Complexity",
"audio_settings_config_updated": "Audio configuration updated",
"audio_settings_default_lan_suffix": " (default - LAN)",
"audio_settings_default_suffix": " (default)",
"audio_settings_description": "Configure audio input and output settings for your JetKVM device",
"audio_settings_dtx_description": "Save bandwidth during silence",
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
"audio_settings_fec_description": "Improve audio quality on lossy connections",
"audio_settings_fec_title": "FEC (Forward Error Correction)",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_no_compensation_suffix": " (no compensation)",
"audio_settings_output_description": "Enable or disable audio from the remote computer",
"audio_settings_output_source_description": "Select the audio capture device (HDMI or USB)",
"audio_settings_output_source_failed": "Failed to set audio output source: {error}",
"audio_settings_output_source_success": "Audio output source updated. Audio will start in 30-60 seconds.",
"audio_settings_output_source_title": "Audio Output Source",
"audio_settings_output_title": "Audio Output",
"audio_settings_packet_loss_description": "FEC overhead percentage (higher = better recovery, more bandwidth)",
"audio_settings_packet_loss_title": "Packet Loss Compensation",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Sample Rate",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Audio from target to speakers",
@ -267,6 +241,7 @@
"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_description": "Round-trip time for the active ICE candidate pair between peers.",
"connection_stats_sidebar": "Connection Stats",
@ -332,6 +307,7 @@
"general_auto_update_description": "Automatically update the device to the latest version",
"general_auto_update_error": "Failed to set auto-update: {error}",
"general_auto_update_title": "Auto Update",
"general_check_for_stable_updates": "Downgrade",
"general_check_for_updates": "Check for Updates",
"general_page_description": "Configure device settings and update preferences",
"general_reboot_description": "Do you want to proceed with rebooting the system?",
@ -352,9 +328,13 @@
"general_update_checking_title": "Checking for updates…",
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
"general_update_completed_title": "Update Completed Successfully",
"general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.",
"general_update_downgrade_available_title": "Downgrade Available",
"general_update_downgrade_button": "Downgrade Now",
"general_update_error_description": "An error occurred while updating your device. Please try again later.",
"general_update_error_details": "Error details: {errorMessage}",
"general_update_error_title": "Update Error",
"general_update_keep_current_button": "Keep Current Version",
"general_update_later_button": "Do it later",
"general_update_now_button": "Update Now",
"general_update_rebooting": "Rebooting to complete the update…",
@ -370,6 +350,7 @@
"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_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}",
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",

View File

@ -49,50 +49,6 @@
"access_update_tls_settings": "Actualizar la configuración de TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Estadísticas de conexión",
"audio_input_failed_disable": "Error al desactivar la entrada de audio: {error}",
"audio_input_failed_enable": "Error al activar la entrada de audio: {error}",
"audio_input_auto_enable_disabled": "Habilitación automática de micrófono desactivada",
"audio_input_auto_enable_enabled": "Habilitación automática de micrófono activada",
"audio_output_disabled": "Salida de audio desactivada",
"audio_output_enabled": "Salida de audio activada",
"audio_output_failed_disable": "Error al desactivar la salida de audio: {error}",
"audio_output_failed_enable": "Error al activar la salida de audio: {error}",
"audio_popover_title": "Audio",
"audio_popover_description": "Controles de audio rápidos para altavoces y micrófono",
"audio_speakers_title": "Altavoces",
"audio_speakers_description": "Audio del objetivo a los altavoces",
"audio_microphone_title": "Micrófono",
"audio_microphone_description": "Entrada de micrófono al objetivo",
"audio_https_only": "Solo HTTPS",
"audio_settings_description": "Configure los ajustes de entrada y salida de audio para su dispositivo JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Habilitar o deshabilitar el audio de la computadora remota",
"audio_settings_output_source_description": "Seleccione el dispositivo de captura de audio (HDMI o USB)",
"audio_settings_output_source_failed": "Error al configurar la fuente de salida de audio: {error}",
"audio_settings_output_source_success": "Fuente de salida de audio actualizada. El audio comenzará en 30-60 segundos.",
"audio_settings_output_source_title": "Fuente de salida de audio",
"audio_settings_output_title": "Salida de audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Habilitar micrófono automáticamente",
"audio_settings_auto_enable_microphone_description": "Habilitar automáticamente el micrófono del navegador al conectar (de lo contrario, debe habilitarlo manualmente en cada sesión)",
"audio_settings_bitrate_title": "Bitrate Opus",
"audio_settings_bitrate_description": "Tasa de bits de codificación de audio (mayor = mejor calidad, más ancho de banda)",
"audio_settings_complexity_title": "Complejidad Opus",
"audio_settings_complexity_description": "Complejidad del codificador (0-10, mayor = mejor calidad, más CPU)",
"audio_settings_dtx_title": "DTX (Transmisión Discontinua)",
"audio_settings_dtx_description": "Ahorrar ancho de banda durante el silencio",
"audio_settings_fec_title": "FEC (Corrección de Errores)",
"audio_settings_fec_description": "Mejorar la calidad de audio en conexiones con pérdida",
"audio_settings_buffer_title": "Períodos de Buffer",
"audio_settings_buffer_description": "Tamaño del buffer ALSA (mayor = más estable, más latencia)",
"audio_settings_sample_rate_title": "Tasa de Muestreo",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Compensación de Pérdida de Paquetes",
"audio_settings_packet_loss_description": "Porcentaje de sobrecarga FEC (mayor = mejor recuperación, más ancho de banda)",
"audio_settings_config_updated": "Configuración de audio actualizada",
"audio_settings_apply_button": "Aplicar configuración",
"audio_settings_applied": "Configuración de audio aplicada",
"action_bar_extension": "Extensión",
"action_bar_fullscreen": "Pantalla completa",
"action_bar_settings": "Ajustes",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Fuente de salida de audio actualizada. El audio comenzará en 30-60 segundos.",
"audio_settings_output_source_title": "Fuente de salida de audio",
"audio_settings_output_title": "Salida de audio",
"audio_settings_packet_loss_description": "Porcentaje de sobrecarga FEC (mayor = mejor recuperación, más ancho de banda)",
"audio_settings_packet_loss_title": "Compensación de Pérdida de Paquetes",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Tasa de Muestreo",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Audio del objetivo a los altavoces",
@ -881,8 +841,8 @@
"usb_device_description": "Dispositivos USB para emular en la computadora de destino",
"usb_device_enable_absolute_mouse_description": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_absolute_mouse_title": "Habilitar el puntero absoluto del ratón",
"usb_device_enable_audio_description": "Habilitar audio bidireccional",
"usb_device_enable_audio_title": "Habilitar audio USB",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Habilitar el teclado",
"usb_device_enable_keyboard_title": "Habilitar el teclado",
"usb_device_enable_mass_storage_description": "A veces puede ser necesario desactivarlo para evitar problemas con ciertos dispositivos.",
@ -892,7 +852,7 @@
"usb_device_failed_load": "No se pudieron cargar los dispositivos USB: {error}",
"usb_device_failed_set": "No se pudieron configurar los dispositivos USB: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Teclado, ratón y almacenamiento masivo",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Teclado, ratón, almacenamiento masivo y audio",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Sólo teclado",
"usb_device_restore_default": "Restaurar a valores predeterminados",
"usb_device_title": "Dispositivo USB",

View File

@ -49,50 +49,6 @@
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiques de connexion",
"audio_input_failed_disable": "Échec de la désactivation de l'entrée audio : {error}",
"audio_input_failed_enable": "Échec de l'activation de l'entrée audio : {error}",
"audio_input_auto_enable_disabled": "Activation automatique du microphone désactivée",
"audio_input_auto_enable_enabled": "Activation automatique du microphone activée",
"audio_output_disabled": "Sortie audio désactivée",
"audio_output_enabled": "Sortie audio activée",
"audio_output_failed_disable": "Échec de la désactivation de la sortie audio : {error}",
"audio_output_failed_enable": "Échec de l'activation de la sortie audio : {error}",
"audio_popover_title": "Audio",
"audio_popover_description": "Contrôles audio rapides pour haut-parleurs et microphone",
"audio_speakers_title": "Haut-parleurs",
"audio_speakers_description": "Audio de la cible vers les haut-parleurs",
"audio_microphone_title": "Microphone",
"audio_microphone_description": "Entrée microphone vers la cible",
"audio_https_only": "HTTPS uniquement",
"audio_settings_description": "Configurez les paramètres d'entrée et de sortie audio pour votre appareil JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Activer ou désactiver l'audio de l'ordinateur distant",
"audio_settings_output_source_description": "Sélectionnez le périphérique de capture audio (HDMI ou USB)",
"audio_settings_output_source_failed": "Échec de la configuration de la source de sortie audio : {error}",
"audio_settings_output_source_success": "Source de sortie audio mise à jour. L'audio démarrera dans 30 à 60 secondes.",
"audio_settings_output_source_title": "Source de sortie audio",
"audio_settings_output_title": "Sortie audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Activer automatiquement le microphone",
"audio_settings_auto_enable_microphone_description": "Activer automatiquement le microphone du navigateur lors de la connexion (sinon vous devez l'activer manuellement à chaque session)",
"audio_settings_bitrate_title": "Débit Opus",
"audio_settings_bitrate_description": "Débit d'encodage audio (plus élevé = meilleure qualité, plus de bande passante)",
"audio_settings_complexity_title": "Complexité Opus",
"audio_settings_complexity_description": "Complexité de l'encodeur (0-10, plus élevé = meilleure qualité, plus de CPU)",
"audio_settings_dtx_title": "DTX (Transmission Discontinue)",
"audio_settings_dtx_description": "Économiser la bande passante pendant le silence",
"audio_settings_fec_title": "FEC (Correction d'Erreur)",
"audio_settings_fec_description": "Améliorer la qualité audio sur les connexions avec perte",
"audio_settings_buffer_title": "Périodes de Tampon",
"audio_settings_buffer_description": "Taille du tampon ALSA (plus élevé = plus stable, plus de latence)",
"audio_settings_sample_rate_title": "Fréquence d'Échantillonnage",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Compensation de Perte de Paquets",
"audio_settings_packet_loss_description": "Pourcentage de surcharge FEC (plus élevé = meilleure récupération, plus de bande passante)",
"audio_settings_config_updated": "Configuration audio mise à jour",
"audio_settings_apply_button": "Appliquer les paramètres",
"audio_settings_applied": "Paramètres audio appliqués",
"action_bar_extension": "Extension",
"action_bar_fullscreen": "Plein écran",
"action_bar_settings": "Paramètres",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Source de sortie audio mise à jour. L'audio démarrera dans 30 à 60 secondes.",
"audio_settings_output_source_title": "Source de sortie audio",
"audio_settings_output_title": "Sortie audio",
"audio_settings_packet_loss_description": "Pourcentage de surcharge FEC (plus élevé = meilleure récupération, plus de bande passante)",
"audio_settings_packet_loss_title": "Compensation de Perte de Paquets",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Fréquence d'Échantillonnage",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Audio de la cible vers les haut-parleurs",
@ -881,8 +841,8 @@
"usb_device_description": "Périphériques USB à émuler sur l'ordinateur cible",
"usb_device_enable_absolute_mouse_description": "Activer la souris absolue (pointeur)",
"usb_device_enable_absolute_mouse_title": "Activer la souris absolue (pointeur)",
"usb_device_enable_audio_description": "Activer l'audio bidirectionnel",
"usb_device_enable_audio_title": "Activer l'audio USB",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Activer le clavier",
"usb_device_enable_keyboard_title": "Activer le clavier",
"usb_device_enable_mass_storage_description": "Parfois, il peut être nécessaire de le désactiver pour éviter des problèmes avec certains appareils",
@ -892,7 +852,7 @@
"usb_device_failed_load": "Échec du chargement des périphériques USB : {error}",
"usb_device_failed_set": "Échec de la configuration des périphériques USB : {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Clavier, souris et stockage de masse",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Clavier, souris, stockage de masse et audio",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Clavier uniquement",
"usb_device_restore_default": "Restaurer les paramètres par défaut",
"usb_device_title": "périphérique USB",

View File

@ -49,50 +49,6 @@
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Statistiche di connessione",
"audio_input_failed_disable": "Impossibile disabilitare l'ingresso audio: {error}",
"audio_input_failed_enable": "Impossibile abilitare l'ingresso audio: {error}",
"audio_input_auto_enable_disabled": "Abilitazione automatica microfono disabilitata",
"audio_input_auto_enable_enabled": "Abilitazione automatica microfono abilitata",
"audio_output_disabled": "Uscita audio disabilitata",
"audio_output_enabled": "Uscita audio abilitata",
"audio_output_failed_disable": "Impossibile disabilitare l'uscita audio: {error}",
"audio_output_failed_enable": "Impossibile abilitare l'uscita audio: {error}",
"audio_popover_title": "Audio",
"audio_popover_description": "Controlli audio rapidi per altoparlanti e microfono",
"audio_speakers_title": "Altoparlanti",
"audio_speakers_description": "Audio dal target agli altoparlanti",
"audio_microphone_title": "Microfono",
"audio_microphone_description": "Ingresso microfono al target",
"audio_https_only": "Solo HTTPS",
"audio_settings_description": "Configura le impostazioni di ingresso e uscita audio per il tuo dispositivo JetKVM",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Abilita o disabilita l'audio dal computer remoto",
"audio_settings_output_source_description": "Seleziona il dispositivo di acquisizione audio (HDMI o USB)",
"audio_settings_output_source_failed": "Impossibile impostare la sorgente di uscita audio: {error}",
"audio_settings_output_source_success": "Sorgente di uscita audio aggiornata. L'audio inizierà tra 30-60 secondi.",
"audio_settings_output_source_title": "Sorgente di uscita audio",
"audio_settings_output_title": "Uscita audio",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Abilita automaticamente il microfono",
"audio_settings_auto_enable_microphone_description": "Abilita automaticamente il microfono del browser durante la connessione (altrimenti devi abilitarlo manualmente ad ogni sessione)",
"audio_settings_bitrate_title": "Bitrate Opus",
"audio_settings_bitrate_description": "Bitrate di codifica audio (più alto = migliore qualità, più banda)",
"audio_settings_complexity_title": "Complessità Opus",
"audio_settings_complexity_description": "Complessità dell'encoder (0-10, più alto = migliore qualità, più CPU)",
"audio_settings_dtx_title": "DTX (Trasmissione Discontinua)",
"audio_settings_dtx_description": "Risparmia banda durante il silenzio",
"audio_settings_fec_title": "FEC (Correzione Errori)",
"audio_settings_fec_description": "Migliora la qualità audio su connessioni con perdita",
"audio_settings_buffer_title": "Periodi Buffer",
"audio_settings_buffer_description": "Dimensione buffer ALSA (più alto = più stabile, più latenza)",
"audio_settings_sample_rate_title": "Frequenza di Campionamento",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Compensazione Perdita Pacchetti",
"audio_settings_packet_loss_description": "Percentuale overhead FEC (più alto = migliore recupero, più banda)",
"audio_settings_config_updated": "Configurazione audio aggiornata",
"audio_settings_apply_button": "Applica impostazioni",
"audio_settings_applied": "Impostazioni audio applicate",
"action_bar_extension": "Estensione",
"action_bar_fullscreen": "A schermo intero",
"action_bar_settings": "Impostazioni",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Sorgente di uscita audio aggiornata. L'audio inizierà tra 30-60 secondi.",
"audio_settings_output_source_title": "Sorgente di uscita audio",
"audio_settings_output_title": "Uscita audio",
"audio_settings_packet_loss_description": "Percentuale overhead FEC (più alto = migliore recupero, più banda)",
"audio_settings_packet_loss_title": "Compensazione Perdita Pacchetti",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Frequenza di Campionamento",
"audio_settings_title": "Audio",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Audio dal target agli altoparlanti",
@ -881,8 +841,8 @@
"usb_device_description": "Dispositivi USB da emulare sul computer di destinazione",
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
"usb_device_enable_absolute_mouse_title": "Abilita mouse assoluto (puntatore)",
"usb_device_enable_audio_description": "Abilita audio bidirezionale",
"usb_device_enable_audio_title": "Abilita audio USB",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Abilita tastiera",
"usb_device_enable_keyboard_title": "Abilita tastiera",
"usb_device_enable_mass_storage_description": "A volte potrebbe essere necessario disattivarlo per evitare problemi con determinati dispositivi",
@ -892,7 +852,7 @@
"usb_device_failed_load": "Impossibile caricare i dispositivi USB: {error}",
"usb_device_failed_set": "Impossibile impostare i dispositivi USB: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastiera, mouse e memoria di massa",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastiera, mouse, archiviazione di massa e audio",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Solo tastiera",
"usb_device_restore_default": "Ripristina impostazioni predefinite",
"usb_device_title": "Dispositivo USB",

View File

@ -47,52 +47,8 @@
"access_tls_self_signed": "Selvsignert",
"access_tls_updated": "TLS-innstillingene er oppdatert",
"access_update_tls_settings": "Oppdater TLS-innstillinger",
"action_bar_audio": "Lyd",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "Tilkoblingsstatistikk",
"audio_input_failed_disable": "Kunne ikke deaktivere lydinngang: {error}",
"audio_input_failed_enable": "Kunne ikke aktivere lydinngang: {error}",
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon deaktivert",
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktivert",
"audio_output_disabled": "Lydutgang deaktivert",
"audio_output_enabled": "Lydutgang aktivert",
"audio_output_failed_disable": "Kunne ikke deaktivere lydutgang: {error}",
"audio_output_failed_enable": "Kunne ikke aktivere lydutgang: {error}",
"audio_popover_title": "Lyd",
"audio_popover_description": "Raske lydkontroller for høyttalere og mikrofon",
"audio_speakers_title": "Høyttalere",
"audio_speakers_description": "Lyd fra mål til høyttalere",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoninngang til mål",
"audio_https_only": "Kun HTTPS",
"audio_settings_description": "Konfigurer lydinngangs- og lydutgangsinnstillinger for JetKVM-enheten din",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Aktiver eller deaktiver lyd fra den eksterne datamaskinen",
"audio_settings_output_source_description": "Velg lydopptaksenhet (HDMI eller USB)",
"audio_settings_output_source_failed": "Kunne ikke angi lydutgangskilde: {error}",
"audio_settings_output_source_success": "Lydutgangskilde oppdatert. Lyd starter om 30-60 sekunder.",
"audio_settings_output_source_title": "Lydutgangskilde",
"audio_settings_output_title": "Lydutgang",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktiver mikrofon automatisk",
"audio_settings_auto_enable_microphone_description": "Aktiver automatisk nettlesermikrofon ved tilkobling (ellers må du aktivere det manuelt hver økt)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Lydkodingsbitrate (høyere = bedre kvalitet, mer båndbredde)",
"audio_settings_complexity_title": "Opus Kompleksitet",
"audio_settings_complexity_description": "Encoder-kompleksitet (0-10, høyere = bedre kvalitet, mer CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Overføring)",
"audio_settings_dtx_description": "Spar båndbredde under stillhet",
"audio_settings_fec_title": "FEC (Fremadrettet Feilkorreksjon)",
"audio_settings_fec_description": "Forbedre lydkvaliteten på tapende tilkoblinger",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstørrelse (høyere = mer stabil, mer latens)",
"audio_settings_sample_rate_title": "Samplingsrate",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Pakketapskompensasjon",
"audio_settings_packet_loss_description": "FEC overhead-prosent (høyere = bedre gjenoppretting, mer båndbredde)",
"audio_settings_config_updated": "Lydkonfigurasjon oppdatert",
"audio_settings_apply_button": "Bruk innstillinger",
"audio_settings_applied": "Lydinnstillinger brukt",
"action_bar_extension": "Forlengelse",
"action_bar_fullscreen": "Fullskjerm",
"action_bar_settings": "Innstillinger",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "Lydutgangskilde oppdatert. Lyd starter om 30-60 sekunder.",
"audio_settings_output_source_title": "Lydutgangskilde",
"audio_settings_output_title": "Lydutgang",
"audio_settings_packet_loss_description": "FEC overhead-prosent (høyere = bedre gjenoppretting, mer båndbredde)",
"audio_settings_packet_loss_title": "Pakketapskompensasjon",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Samplingsrate",
"audio_settings_title": "Lyd",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Lyd fra mål til høyttalere",
@ -881,8 +841,8 @@
"usb_device_description": "USB-enheter som skal emuleres på måldatamaskinen",
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
"usb_device_enable_absolute_mouse_title": "Aktiver absolutt mus (peker)",
"usb_device_enable_audio_description": "Aktiver toveis lyd",
"usb_device_enable_audio_title": "Aktiver USB-lyd",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Aktiver tastatur",
"usb_device_enable_keyboard_title": "Aktiver tastatur",
"usb_device_enable_mass_storage_description": "Noen ganger må det kanskje deaktiveres for å forhindre problemer med visse enheter.",
@ -892,7 +852,7 @@
"usb_device_failed_load": "Klarte ikke å laste inn USB-enheter: {error}",
"usb_device_failed_set": "Kunne ikke angi USB-enheter: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tastatur, mus og masselagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tastatur, mus, masselagring og lyd",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Kun tastatur",
"usb_device_restore_default": "Gjenopprett til standard",
"usb_device_title": "USB-enhet",

View File

@ -47,51 +47,7 @@
"access_tls_self_signed": "Självsignerad",
"access_tls_updated": "TLS-inställningarna har uppdaterats",
"access_update_tls_settings": "Uppdatera TLS-inställningar",
"action_bar_audio": "Ljud",
"audio_input_failed_disable": "Det gick inte att inaktivera ljudingången: {error}",
"audio_input_failed_enable": "Det gick inte att aktivera ljudingången: {error}",
"audio_input_auto_enable_disabled": "Automatisk aktivering av mikrofon inaktiverad",
"audio_input_auto_enable_enabled": "Automatisk aktivering av mikrofon aktiverad",
"audio_output_disabled": "Ljudutgång inaktiverad",
"audio_output_enabled": "Ljudutgång aktiverad",
"audio_output_failed_disable": "Det gick inte att inaktivera ljudutgången: {error}",
"audio_output_failed_enable": "Det gick inte att aktivera ljudutgången: {error}",
"audio_popover_title": "Ljud",
"audio_popover_description": "Snabba ljudkontroller för högtalare och mikrofon",
"audio_speakers_title": "Högtalare",
"audio_speakers_description": "Ljud från mål till högtalare",
"audio_microphone_title": "Mikrofon",
"audio_microphone_description": "Mikrofoningång till mål",
"audio_https_only": "Endast HTTPS",
"audio_settings_description": "Konfigurera ljudinmatnings- och ljudutgångsinställningar för din JetKVM-enhet",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "Aktivera eller inaktivera ljud från fjärrdatorn",
"audio_settings_output_source_description": "Välj ljudinspelningsenhet (HDMI eller USB)",
"audio_settings_output_source_failed": "Det gick inte att ställa in ljudutgångskälla: {error}",
"audio_settings_output_source_success": "Ljudutgångskälla uppdaterad. Ljud startar om 30-60 sekunder.",
"audio_settings_output_source_title": "Ljudutgångskälla",
"audio_settings_output_title": "Ljudutgång",
"audio_settings_title": "Ljud",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "Aktivera mikrofon automatiskt",
"audio_settings_auto_enable_microphone_description": "Aktivera automatiskt webbläsarmikrofon vid anslutning (annars måste du aktivera den manuellt varje session)",
"audio_settings_bitrate_title": "Opus Bitrate",
"audio_settings_bitrate_description": "Ljudkodningsbitrate (högre = bättre kvalitet, mer bandbredd)",
"audio_settings_complexity_title": "Opus Komplexitet",
"audio_settings_complexity_description": "Encoder-komplexitet (0-10, högre = bättre kvalitet, mer CPU)",
"audio_settings_dtx_title": "DTX (Diskontinuerlig Överföring)",
"audio_settings_dtx_description": "Spara bandbredd under tystnad",
"audio_settings_fec_title": "FEC (Framåtriktad Felkorrigering)",
"audio_settings_fec_description": "Förbättra ljudkvaliteten på förlustdrabbade anslutningar",
"audio_settings_buffer_title": "Bufferperioder",
"audio_settings_buffer_description": "ALSA bufferstorlek (högre = mer stabil, mer latens)",
"audio_settings_sample_rate_title": "Samplingsfrekvens",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "Paketförlustkompensation",
"audio_settings_packet_loss_description": "FEC overhead-procent (högre = bättre återställning, mer bandbredd)",
"audio_settings_config_updated": "Ljudkonfiguration uppdaterad",
"audio_settings_apply_button": "Tillämpa inställningar",
"audio_settings_applied": "Ljudinställningar tillämpade",
"action_bar_audio": "Audio",
"action_bar_extension": "Förlängning",
"action_bar_fullscreen": "Helskärm",
"action_bar_settings": "Inställningar",
@ -200,6 +156,10 @@
"audio_settings_output_source_success": "Ljudutgångskälla uppdaterad. Ljud startar om 30-60 sekunder.",
"audio_settings_output_source_title": "Ljudutgångskälla",
"audio_settings_output_title": "Ljudutgång",
"audio_settings_packet_loss_description": "FEC overhead-procent (högre = bättre återställning, mer bandbredd)",
"audio_settings_packet_loss_title": "Paketförlustkompensation",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "Samplingsfrekvens",
"audio_settings_title": "Ljud",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "Ljud från mål till högtalare",
@ -880,8 +840,8 @@
"usb_device_description": "USB-enheter att emulera på måldatorn",
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
"usb_device_enable_absolute_mouse_title": "Aktivera absolut mus (pekare)",
"usb_device_enable_audio_description": "Aktivera dubbelriktad ljud",
"usb_device_enable_audio_title": "Aktivera USB-ljud",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
"usb_device_enable_keyboard_title": "Aktivera tangentbord",
"usb_device_enable_mass_storage_description": "Ibland kan det behöva inaktiveras för att förhindra problem med vissa enheter.",
@ -891,7 +851,7 @@
"usb_device_failed_load": "Misslyckades med att ladda USB-enheter: {error}",
"usb_device_failed_set": "Misslyckades med att ställa in USB-enheter: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "Tangentbord, mus och masslagring",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Tangentbord, mus, masslagring och ljud",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "Endast tangentbord",
"usb_device_restore_default": "Återställ till standard",
"usb_device_title": "USB-enhet",

View File

@ -47,52 +47,8 @@
"access_tls_self_signed": "自签名",
"access_tls_updated": "TLS 设置更新成功",
"access_update_tls_settings": "更新 TLS 设置",
"action_bar_audio": "音频",
"action_bar_audio": "Audio",
"action_bar_connection_stats": "连接统计",
"audio_input_failed_disable": "禁用音频输入失败:{error}",
"audio_input_failed_enable": "启用音频输入失败:{error}",
"audio_input_auto_enable_disabled": "自动启用麦克风已禁用",
"audio_input_auto_enable_enabled": "自动启用麦克风已启用",
"audio_output_disabled": "音频输出已禁用",
"audio_output_enabled": "音频输出已启用",
"audio_output_failed_disable": "禁用音频输出失败:{error}",
"audio_output_failed_enable": "启用音频输出失败:{error}",
"audio_popover_title": "音频",
"audio_popover_description": "扬声器和麦克风的快速音频控制",
"audio_speakers_title": "扬声器",
"audio_speakers_description": "从目标设备到扬声器的音频",
"audio_microphone_title": "麦克风",
"audio_microphone_description": "麦克风输入到目标设备",
"audio_https_only": "仅限 HTTPS",
"audio_settings_description": "配置 JetKVM 设备的音频输入和输出设置",
"audio_settings_hdmi_label": "HDMI",
"audio_settings_output_description": "启用或禁用来自远程计算机的音频",
"audio_settings_output_source_description": "选择音频捕获设备HDMI 或 USB",
"audio_settings_output_source_failed": "设置音频输出源失败:{error}",
"audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。",
"audio_settings_output_source_title": "音频输出源",
"audio_settings_output_title": "音频输出",
"audio_settings_title": "音频",
"audio_settings_usb_label": "USB",
"audio_settings_auto_enable_microphone_title": "自动启用麦克风",
"audio_settings_auto_enable_microphone_description": "连接时自动启用浏览器麦克风(否则您必须在每次会话中手动启用)",
"audio_settings_bitrate_title": "Opus 比特率",
"audio_settings_bitrate_description": "音频编码比特率(越高 = 质量越好,带宽越大)",
"audio_settings_complexity_title": "Opus 复杂度",
"audio_settings_complexity_description": "编码器复杂度0-10越高 = 质量越好CPU 使用越多)",
"audio_settings_dtx_title": "DTX不连续传输",
"audio_settings_dtx_description": "在静音时节省带宽",
"audio_settings_fec_title": "FEC前向纠错",
"audio_settings_fec_description": "改善有损连接上的音频质量",
"audio_settings_buffer_title": "缓冲周期",
"audio_settings_buffer_description": "ALSA 缓冲大小(越高 = 越稳定,延迟越高)",
"audio_settings_sample_rate_title": "采样率",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_packet_loss_title": "丢包补偿",
"audio_settings_packet_loss_description": "FEC 开销百分比(越高 = 恢复越好,带宽越大)",
"audio_settings_config_updated": "音频配置已更新",
"audio_settings_apply_button": "应用设置",
"audio_settings_applied": "音频设置已应用",
"action_bar_extension": "扩展",
"action_bar_fullscreen": "全屏",
"action_bar_settings": "设置",
@ -201,6 +157,10 @@
"audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。",
"audio_settings_output_source_title": "音频输出源",
"audio_settings_output_title": "音频输出",
"audio_settings_packet_loss_description": "FEC 开销百分比(越高 = 恢复越好,带宽越大)",
"audio_settings_packet_loss_title": "丢包补偿",
"audio_settings_sample_rate_description": "Audio sampling frequency (automatically detected from source)",
"audio_settings_sample_rate_title": "采样率",
"audio_settings_title": "音频",
"audio_settings_usb_label": "USB",
"audio_speakers_description": "从目标设备到扬声器的音频",
@ -881,8 +841,8 @@
"usb_device_description": "在目标计算机上仿真的 USB 设备",
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
"usb_device_enable_audio_description": "启用双向音频",
"usb_device_enable_audio_title": "启用 USB 音频",
"usb_device_enable_audio_description": "Enable bidirectional audio",
"usb_device_enable_audio_title": "Enable USB Audio",
"usb_device_enable_keyboard_description": "启用键盘",
"usb_device_enable_keyboard_title": "启用键盘",
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
@ -892,7 +852,7 @@
"usb_device_failed_load": "无法加载 USB 设备: {error}",
"usb_device_failed_set": "无法设置 USB 设备: {error}",
"usb_device_keyboard_mouse_and_mass_storage": "键盘、鼠标和大容量存储器",
"usb_device_keyboard_mouse_mass_storage_and_audio": "键盘、鼠标、大容量存储和音频",
"usb_device_keyboard_mouse_mass_storage_and_audio": "Keyboard, Mouse, Mass Storage and Audio",
"usb_device_keyboard_only": "仅限键盘",
"usb_device_restore_default": "恢复默认设置",
"usb_device_title": "USB 设备",

View File

@ -1,10 +1,9 @@
import { useState } from "react";
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
import { motion, AnimatePresence } from "framer-motion";
import { LuInfo } from "react-icons/lu";
import { Button } from "@/components/Button";
import Card, { GridCard } from "@components/Card";
import { GridCard } from "@components/Card";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { useVersion } from "@/hooks/useVersion";
@ -34,40 +33,12 @@ function OverlayContent({ children }: OverlayContentProps) {
);
}
interface TooltipProps {
readonly children: React.ReactNode;
readonly text: string;
readonly show: boolean;
}
function Tooltip({ children, text, show }: TooltipProps) {
if (!show) {
return <>{children}</>;
}
return (
<div className="group/tooltip relative">
{children}
<div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 opacity-0 transition-opacity group-hover/tooltip:block group-hover/tooltip:opacity-100">
<Card>
<div className="whitespace-nowrap px-2 py-1 text-xs flex items-center gap-1 justify-center">
<LuInfo className="h-3 w-3 text-slate-700 dark:text-slate-300" />
{text}
</div>
</Card>
</div>
</div>
);
}
export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
const { send } = useJsonRpc();
const { navigateTo } = useDeviceUiNavigation();
const { appVersion } = useVersion();
const { systemVersion } = useDeviceStore();
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false);
const getReasonCopy = () => {
switch (reason) {
@ -115,7 +86,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
URL.revokeObjectURL(url);
notifications.success("Crash logs downloaded successfully");
setHasDownloadedLogs(true);
// Open GitHub issue
const issueBody = `## Issue Description
@ -146,7 +116,7 @@ Please attach the recovery logs file that was downloaded to your computer:
};
const handleDowngrade = () => {
navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`);
navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`);
};
return (
@ -182,25 +152,19 @@ Please attach the recovery logs file that was downloaded to your computer:
/>
<div className="h-8 w-px bg-slate-200 dark:bg-slate-700 block" />
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
<Button
onClick={() => navigateTo("/settings/general/reboot")}
theme="light"
size="SM"
text="Reboot Device"
disabled={!hasDownloadedLogs}
/>
</Tooltip>
<Button
onClick={() => navigateTo("/settings/general/reboot")}
theme="light"
size="SM"
text="Reboot Device"
/>
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
<Button
size="SM"
onClick={handleDowngrade}
theme="light"
text={`Downgrade to v${DOWNGRADE_VERSION}`}
disabled={!hasDownloadedLogs}
/>
</Tooltip>
<Button
size="SM"
onClick={handleDowngrade}
theme="light"
text={`Downgrade to v${DOWNGRADE_VERSION}`}
/>
</div>

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

@ -553,7 +553,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
controlsList="nofullscreen"
style={videoStyle}
className={cx(
"max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
"max-h-full max-w-full sm:min-h-[384px] sm:min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
{
"cursor-none": settings.isCursorHidden,
"!opacity-0":

View File

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

View File

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

View File

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

View File

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

View File

@ -11,7 +11,7 @@ import LoadingSpinner from "../components/LoadingSpinner";
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
// 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() {
const navigate = useNavigate();

View File

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

View File

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

View File

@ -278,6 +278,14 @@ export default function SettingsNetworkRoute() {
});
}
if (dirty.hostname) {
changes.push({
label: m.network_hostname_title(),
from: initialSettingsRef.current?.hostname?.toString() ?? "",
to: data.hostname?.toString() ?? "",
});
}
// If no critical fields are changed, save immediately
if (changes.length === 0) return onSubmit(settings);

View File

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

View File

@ -735,7 +735,7 @@ export default function KvmIdRoute() {
});
}, 10000);
const { setNetworkState } = useNetworkStateStore();
const { setNetworkState } = useNetworkStateStore();
const { setHdmiState } = useVideoStore();
const {
keyboardLedState, setKeyboardLedState,
@ -817,7 +817,14 @@ export default function KvmIdRoute() {
if (resp.method === "willReboot") {
const postRebootAction = resp.params as unknown as PostRebootAction;
console.debug("Setting reboot state", postRebootAction);
setRebootState({ isRebooting: true, postRebootAction });
setRebootState({
isRebooting: true,
postRebootAction: {
healthCheck: postRebootAction?.healthCheck || `${window.location.origin}/device/status`,
redirectTo: postRebootAction?.redirectTo || window.location.href,
}
});
navigateTo("/");
}
@ -976,6 +983,10 @@ export default function KvmIdRoute() {
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
}
if (isFailsafeMode && failsafeReason) {
return <FailSafeModeOverlay reason={failsafeReason} />;
}
const hasConnectionFailed =
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
@ -1000,7 +1011,7 @@ export default function KvmIdRoute() {
}
return null;
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
}, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
return (
<FeatureFlagProvider appVersion={appVersion}>
@ -1048,9 +1059,7 @@ export default function KvmIdRoute() {
className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
>
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
{isFailsafeMode && failsafeReason ? (
<FailSafeModeOverlay reason={failsafeReason} />
) : !!ConnectionStatusElement && ConnectionStatusElement}
{!!ConnectionStatusElement && ConnectionStatusElement}
</div>
</div>
<SidebarContainer sidebarView={sidebarView} />

View File

@ -221,16 +221,21 @@ export interface SystemVersionInfo {
remote?: VersionInfo;
systemUpdateAvailable: boolean;
appUpdateAvailable: boolean;
willDisableAutoUpdate?: boolean;
error?: string;
}
const UPDATE_STATUS_RPC_TIMEOUT_MS = 10000;
const UPDATE_STATUS_RPC_MAX_ATTEMPTS = 6;
export async function getUpdateStatus() {
const response = await callJsonRpc<SystemVersionInfo>({
method: "getUpdateStatus",
// 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
// 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;
@ -242,3 +247,27 @@ export async function getLocalVersion() {
if (response.error) throw response.error;
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

@ -33,7 +33,7 @@ export default defineConfig(({ mode, command }) => {
outdir: "./localization/paraglide",
outputStructure: 'message-modules',
cookieName: 'JETKVM_LOCALE',
strategy: ['cookie', 'preferredLanguage', 'baseLocale'],
strategy: ['cookie', 'baseLocale'],
}))
return {

View File

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