mirror of https://github.com/jetkvm/kvm.git
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:
commit
fba4eabf3b
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}"
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -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
107
Makefile
|
|
@ -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
|
||||
|
|
|
|||
52
cmd/main.go
52
cmd/main.go
|
|
@ -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]")
|
||||
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)
|
||||
|
|
|
|||
20
config.go
20
config.go
|
|
@ -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: "",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
10
failsafe.go
10
failsafe.go
|
|
@ -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
|
||||
if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
|
||||
failsafeModeReason = "video"
|
||||
return
|
||||
}
|
||||
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
|
||||
failsafeModeReason = "video"
|
||||
return
|
||||
} else {
|
||||
failsafeModeReason = "unknown"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -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
10
go.sum
|
|
@ -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
18
hw.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
+}
|
||||
|
|
@ -51,16 +51,8 @@ import "C"
|
|||
|
||||
var (
|
||||
cgoLock sync.Mutex
|
||||
cgoDisabled bool
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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{})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 = ¤tAppUpdate
|
||||
}
|
||||
|
||||
if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
|
||||
systemUpdate = ¤tSystemUpdate
|
||||
}
|
||||
|
||||
err = s.checkUpdateStatus(ctx, params, appUpdate, systemUpdate)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
s.componentUpdateStatuses["app"] = *appUpdate
|
||||
s.componentUpdateStatuses["system"] = *systemUpdate
|
||||
|
||||
return appUpdate, systemUpdate, nil
|
||||
}
|
||||
|
||||
// checkUpdateStatus checks the update status for the given components
|
||||
func (s *State) checkUpdateStatus(
|
||||
ctx context.Context,
|
||||
params UpdateParams,
|
||||
appUpdateStatus *componentUpdateStatus,
|
||||
systemUpdateStatus *componentUpdateStatus,
|
||||
) error {
|
||||
// get 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
60
jsonrpc.go
60
jsonrpc.go
|
|
@ -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
36
main.go
|
|
@ -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
|
||||
|
|
|
|||
29
native.go
29
native.go
|
|
@ -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()
|
||||
|
|
|
|||
12
network.go
12
network.go
|
|
@ -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
672
ota.go
|
|
@ -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)
|
||||
if updateStatus == nil {
|
||||
return nil, fmt.Errorf("error checking for updates: %w", err)
|
||||
}
|
||||
|
||||
query := updateUrl.Query()
|
||||
query.Set("deviceId", deviceId)
|
||||
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
|
||||
updateUrl.RawQuery = query.Encode()
|
||||
|
||||
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error sending request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(metadata)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding response: %w", err)
|
||||
updateStatus.Error = err.Error()
|
||||
}
|
||||
|
||||
return metadata, nil
|
||||
}
|
||||
|
||||
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("error removing existing file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
unverifiedPath := path + ".unverified"
|
||||
if _, err := os.Stat(unverifiedPath); err == nil {
|
||||
if err := os.Remove(unverifiedPath); err != nil {
|
||||
return fmt.Errorf("error removing existing unverified file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
file, err := os.Create(unverifiedPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request: %w", err)
|
||||
}
|
||||
|
||||
client := http.Client{
|
||||
Timeout: 10 * time.Minute,
|
||||
Transport: &http.Transport{
|
||||
Proxy: config.NetworkConfig.GetTransportProxyFunc(),
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
TLSClientConfig: &tls.Config{
|
||||
RootCAs: rootcerts.ServerCertPool(),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error downloading file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
totalSize := resp.ContentLength
|
||||
if totalSize <= 0 {
|
||||
return fmt.Errorf("invalid content length")
|
||||
}
|
||||
|
||||
var written int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
nr, er := resp.Body.Read(buf)
|
||||
if nr > 0 {
|
||||
nw, ew := file.Write(buf[0:nr])
|
||||
if nw < nr {
|
||||
return fmt.Errorf("short file write: %d < %d", nw, nr)
|
||||
}
|
||||
written += int64(nw)
|
||||
if ew != nil {
|
||||
return fmt.Errorf("error writing to file: %w", ew)
|
||||
}
|
||||
progress := float32(written) / float32(totalSize)
|
||||
if progress-*downloadProgress >= 0.01 {
|
||||
*downloadProgress = progress
|
||||
triggerOTAStateUpdate()
|
||||
}
|
||||
}
|
||||
if er != nil {
|
||||
if er == io.EOF {
|
||||
break
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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!"
|
||||
|
||||
|
|
@ -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."
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 设备",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
|
||||
<Button
|
||||
size="SM"
|
||||
onClick={handleDowngrade}
|
||||
theme="light"
|
||||
text={`Downgrade to v${DOWNGRADE_VERSION}`}
|
||||
disabled={!hasDownloadedLogs}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -6,6 +6,19 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
|
|||
import notifications from "@/notifications";
|
||||
import { m } from "@localizations/messages.js";
|
||||
|
||||
export interface VersionInfo {
|
||||
appVersion: string;
|
||||
systemVersion: string;
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
local: VersionInfo;
|
||||
remote?: VersionInfo;
|
||||
systemUpdateAvailable: boolean;
|
||||
appUpdateAvailable: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function useVersion() {
|
||||
const {
|
||||
appVersion,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
|||
import { SettingsItem } from "@components/SettingsItem";
|
||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||
import { TextAreaWithLabel } from "@components/TextArea";
|
||||
import api from "@/api";
|
||||
import notifications from "@/notifications";
|
||||
|
|
@ -237,13 +238,11 @@ export default function SettingsAccessIndexRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
{tlsMode === "custom" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="space-y-4">
|
||||
<NestedSettingsGroup className="mt-4">
|
||||
<SettingsItem
|
||||
title={m.access_tls_certificate_title()}
|
||||
description={m.access_tls_certificate_description()}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={m.access_certificate_label()}
|
||||
rows={3}
|
||||
|
|
@ -253,10 +252,6 @@ export default function SettingsAccessIndexRoute() {
|
|||
value={tlsCert}
|
||||
onChange={e => handleTlsCertChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={m.access_private_key_label()}
|
||||
description={m.access_private_key_description()}
|
||||
|
|
@ -267,9 +262,6 @@ export default function SettingsAccessIndexRoute() {
|
|||
value={tlsKey}
|
||||
onChange={e => handleTlsKeyChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="SM"
|
||||
|
|
@ -278,7 +270,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
onClick={handleCustomTlsUpdate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
|
|
@ -352,7 +344,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
</SettingsItem>
|
||||
|
||||
{selectedProvider === "custom" && (
|
||||
<div className="mt-4 space-y-4">
|
||||
<NestedSettingsGroup className="mt-4">
|
||||
<div className="flex items-end gap-x-2">
|
||||
<InputFieldWithLabel
|
||||
size="SM"
|
||||
|
|
@ -371,7 +363,7 @@ export default function SettingsAccessIndexRoute() {
|
|||
placeholder="https://app.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NestedSettingsGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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,8 +270,8 @@ export default function SettingsAdvancedRoute() {
|
|||
onChange={e => handleDevModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{settings.developerMode && (
|
||||
{settings.developerMode ? (
|
||||
<NestedSettingsGroup>
|
||||
<GridCard>
|
||||
<div className="flex items-start gap-x-4 p-4 select-none">
|
||||
<svg
|
||||
|
|
@ -235,25 +304,13 @@ export default function SettingsAdvancedRoute() {
|
|||
</div>
|
||||
</div>
|
||||
</GridCard>
|
||||
)}
|
||||
|
||||
<SettingsItem
|
||||
title={m.advanced_loopback_only_title()}
|
||||
description={m.advanced_loopback_only_description()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={localLoopbackOnly}
|
||||
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
{isOnDevice && settings.developerMode && (
|
||||
{isOnDevice && (
|
||||
<div className="space-y-4">
|
||||
<SettingsItem
|
||||
title={m.advanced_ssh_access_title()}
|
||||
description={m.advanced_ssh_access_description()}
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<TextAreaWithLabel
|
||||
label={m.advanced_ssh_public_key_label()}
|
||||
value={sshKey || ""}
|
||||
|
|
@ -273,9 +330,104 @@ export default function SettingsAdvancedRoute() {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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()}
|
||||
description={m.advanced_loopback_only_description()}
|
||||
>
|
||||
<Checkbox
|
||||
checked={localLoopbackOnly}
|
||||
onChange={e => handleLoopbackOnlyModeChange(e.target.checked)}
|
||||
/>
|
||||
</SettingsItem>
|
||||
|
||||
|
||||
|
||||
<SettingsItem
|
||||
title={m.advanced_troubleshooting_mode_title()}
|
||||
description={m.advanced_troubleshooting_mode_description()}
|
||||
|
|
@ -289,7 +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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue