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
|
if [ "$UID" -eq 0 ]; then
|
||||||
"$@"
|
"$@"
|
||||||
else
|
else
|
||||||
${SUDO_PATH} -E "$@"
|
${SUDO_PATH} "$@"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -17,7 +17,8 @@ sudo apt-get install -y --no-install-recommends \
|
||||||
iputils-ping \
|
iputils-ping \
|
||||||
build-essential \
|
build-essential \
|
||||||
device-tree-compiler \
|
device-tree-compiler \
|
||||||
gperf \
|
gperf g++-multilib gcc-multilib \
|
||||||
|
gdb-multiarch \
|
||||||
libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
|
libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
|
||||||
bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
|
bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
|
||||||
wget zstd \
|
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 && \
|
wget https://github.com/jetkvm/rv1106-system/releases/download/${BUILDKIT_VERSION}/buildkit.tar.zst && \
|
||||||
sudo mkdir -p /opt/jetkvm-native-buildkit && \
|
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
|
rm buildkit.tar.zst
|
||||||
popd
|
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}"
|
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,
|
"git.ignoreLimitWarning": true,
|
||||||
"cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
|
"cmake.sourceDirectory": "${workspaceFolder}/internal/native/cgo",
|
||||||
"cmake.ignoreCMakeListsMissing": true
|
"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
|
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
|
## Testing Your Changes
|
||||||
|
|
|
||||||
107
Makefile
107
Makefile
|
|
@ -1,51 +1,9 @@
|
||||||
# Build ALSA and Opus static libs for ARM in /opt/jetkvm-audio-libs
|
BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
|
||||||
build_audio_deps:
|
BUILDDATE := $(shell date -u +%FT%T%z)
|
||||||
bash .devcontainer/install_audio_deps.sh $(ALSA_VERSION) $(OPUS_VERSION)
|
BUILDTS := $(shell date -u +%s)
|
||||||
|
REVISION := $(shell git rev-parse HEAD)
|
||||||
# Prepare everything needed for local development (toolchain + audio deps + Go tools)
|
VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M)
|
||||||
dev_env: build_audio_deps
|
VERSION := 0.4.9
|
||||||
$(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
|
|
||||||
|
|
||||||
PROMETHEUS_TAG := github.com/prometheus/common/version
|
PROMETHEUS_TAG := github.com/prometheus/common/version
|
||||||
KVM_PKG_NAME := github.com/jetkvm/kvm
|
KVM_PKG_NAME := github.com/jetkvm/kvm
|
||||||
|
|
@ -56,6 +14,8 @@ SKIP_NATIVE_IF_EXISTS ?= 0
|
||||||
SKIP_UI_BUILD ?= 0
|
SKIP_UI_BUILD ?= 0
|
||||||
ENABLE_SYNC_TRACE ?= 0
|
ENABLE_SYNC_TRACE ?= 0
|
||||||
|
|
||||||
|
CMAKE_BUILD_TYPE ?= Release
|
||||||
|
|
||||||
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
|
||||||
ifeq ($(ENABLE_SYNC_TRACE), 1)
|
ifeq ($(ENABLE_SYNC_TRACE), 1)
|
||||||
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
|
GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
|
||||||
|
|
@ -94,29 +54,26 @@ build_native:
|
||||||
echo "Building native..."; \
|
echo "Building native..."; \
|
||||||
CC="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc" \
|
CC="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc" \
|
||||||
LD="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-ld" \
|
LD="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-ld" \
|
||||||
|
CMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
|
||||||
./scripts/build_cgo.sh; \
|
./scripts/build_cgo.sh; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
build_dev: build_native build_audio_deps
|
build_dev: build_native
|
||||||
$(CLEAN_GO_CACHE)
|
|
||||||
@echo "Building..."
|
@echo "Building..."
|
||||||
go build \
|
$(GO_CMD) build \
|
||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o $(BIN_DIR)/jetkvm_app -v cmd/main.go
|
-o $(BIN_DIR)/jetkvm_app -v cmd/main.go
|
||||||
|
|
||||||
build_test2json:
|
build_test2json:
|
||||||
$(CLEAN_GO_CACHE)
|
|
||||||
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
|
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
|
||||||
|
|
||||||
build_gotestsum:
|
build_gotestsum:
|
||||||
$(CLEAN_GO_CACHE)
|
|
||||||
@echo "Building gotestsum..."
|
@echo "Building gotestsum..."
|
||||||
$(GO_CMD) install gotest.tools/gotestsum@latest
|
$(GO_CMD) install gotest.tools/gotestsum@latest
|
||||||
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
|
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
|
||||||
|
|
||||||
build_dev_test: build_audio_deps build_test2json build_gotestsum
|
build_dev_test: build_test2json build_gotestsum
|
||||||
$(CLEAN_GO_CACHE)
|
|
||||||
# collect all directories that contain tests
|
# collect all directories that contain tests
|
||||||
@echo "Building tests for devices ..."
|
@echo "Building tests for devices ..."
|
||||||
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
|
@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_name=$$(echo $$test | sed 's/^.\///g'); \
|
||||||
test_pkg_full_name=$(KVM_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; \
|
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)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
|
||||||
$(GO_BUILD_ARGS) \
|
$(GO_BUILD_ARGS) \
|
||||||
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
|
-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 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
|
||||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
|
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
|
||||||
|
|
||||||
build_release: frontend build_native build_audio_deps
|
build_release: frontend build_native
|
||||||
$(CLEAN_GO_CACHE)
|
|
||||||
@echo "Building release..."
|
@echo "Building release..."
|
||||||
go build \
|
$(GO_CMD) build \
|
||||||
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
|
||||||
$(GO_RELEASE_BUILD_ARGS) \
|
$(GO_RELEASE_BUILD_ARGS) \
|
||||||
-o bin/jetkvm_app cmd/main.go
|
-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
|
@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 r2://jetkvm-update/app/$(VERSION)/jetkvm_app
|
||||||
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
|
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/erikdubbelboer/gspt"
|
||||||
"github.com/jetkvm/kvm"
|
"github.com/jetkvm/kvm"
|
||||||
|
"github.com/jetkvm/kvm/internal/native"
|
||||||
|
"github.com/jetkvm/kvm/internal/supervisor"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var (
|
||||||
envChildID = "JETKVM_CHILD_ID"
|
subcomponent string
|
||||||
errorDumpDir = "/userdata/jetkvm/crashdump"
|
|
||||||
errorDumpLastFile = "last-crash.log"
|
|
||||||
errorDumpTemplate = "jetkvm-%s.log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func program() {
|
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()
|
kvm.Main()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setProcTitle(status string) {
|
||||||
|
if status != "" {
|
||||||
|
status = " " + status
|
||||||
|
}
|
||||||
|
title := fmt.Sprintf("jetkvm: [supervisor]%s", status)
|
||||||
|
gspt.SetProcTitle(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
versionPtr := flag.Bool("version", false, "print version and exit")
|
versionPtr := flag.Bool("version", false, "print version and exit")
|
||||||
versionJSONPtr := flag.Bool("version-json", false, "print version as json and exit")
|
versionJSONPtr := flag.Bool("version-json", false, "print version as json and exit")
|
||||||
|
flag.StringVar(&subcomponent, "subcomponent", "", "subcomponent to run")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
if *versionPtr || *versionJSONPtr {
|
if *versionPtr || *versionJSONPtr {
|
||||||
|
|
@ -42,7 +58,7 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
childID := os.Getenv(envChildID)
|
childID := os.Getenv(supervisor.EnvChildID)
|
||||||
switch childID {
|
switch childID {
|
||||||
case "":
|
case "":
|
||||||
doSupervise()
|
doSupervise()
|
||||||
|
|
@ -55,6 +71,8 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func supervise() error {
|
func supervise() error {
|
||||||
|
setProcTitle("")
|
||||||
|
|
||||||
// check binary path
|
// check binary path
|
||||||
binPath, err := os.Executable()
|
binPath, err := os.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -74,11 +92,11 @@ func supervise() error {
|
||||||
// run the child binary
|
// run the child binary
|
||||||
cmd := exec.Command(binPath)
|
cmd := exec.Command(binPath)
|
||||||
|
|
||||||
lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
|
lastFilePath := filepath.Join(supervisor.ErrorDumpDir, supervisor.ErrorDumpLastFile)
|
||||||
|
|
||||||
cmd.Env = append(os.Environ(), []string{
|
cmd.Env = append(os.Environ(), []string{
|
||||||
fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
|
fmt.Sprintf("%s=%s", supervisor.EnvChildID, kvm.GetBuiltAppVersion()),
|
||||||
fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
|
fmt.Sprintf("%s=%s", supervisor.ErrorDumpLastFile, lastFilePath),
|
||||||
}...)
|
}...)
|
||||||
cmd.Args = os.Args
|
cmd.Args = os.Args
|
||||||
|
|
||||||
|
|
@ -99,6 +117,8 @@ func supervise() error {
|
||||||
return fmt.Errorf("failed to start command: %w", startErr)
|
return fmt.Errorf("failed to start command: %w", startErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProcTitle(fmt.Sprintf("started (pid=%d)", cmd.Process.Pid))
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigChan, syscall.SIGTERM)
|
signal.Notify(sigChan, syscall.SIGTERM)
|
||||||
|
|
@ -107,8 +127,6 @@ func supervise() error {
|
||||||
_ = cmd.Process.Signal(sig)
|
_ = cmd.Process.Signal(sig)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
gspt.SetProcTitle(os.Args[0] + " [sup]")
|
|
||||||
|
|
||||||
cmdErr := cmd.Wait()
|
cmdErr := cmd.Wait()
|
||||||
if cmdErr == nil {
|
if cmdErr == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -186,11 +204,11 @@ func renameFile(f *os.File, newName string) error {
|
||||||
|
|
||||||
func ensureErrorDumpDir() error {
|
func ensureErrorDumpDir() error {
|
||||||
// TODO: check if the directory is writable
|
// TODO: check if the directory is writable
|
||||||
f, err := os.Stat(errorDumpDir)
|
f, err := os.Stat(supervisor.ErrorDumpDir)
|
||||||
if err == nil && f.IsDir() {
|
if err == nil && f.IsDir() {
|
||||||
return nil
|
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 fmt.Errorf("failed to create error dump directory: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|
@ -200,7 +218,7 @@ func createErrorDump(logFile *os.File) {
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
fileName := fmt.Sprintf(
|
fileName := fmt.Sprintf(
|
||||||
errorDumpTemplate,
|
supervisor.ErrorDumpTemplate,
|
||||||
time.Now().Format("20060102-150405"),
|
time.Now().Format("20060102-150405"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -210,7 +228,7 @@ func createErrorDump(logFile *os.File) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
filePath := filepath.Join(errorDumpDir, fileName)
|
filePath := filepath.Join(supervisor.ErrorDumpDir, fileName)
|
||||||
if err := renameFile(logFile, filePath); err != nil {
|
if err := renameFile(logFile, filePath); err != nil {
|
||||||
fmt.Printf("failed to rename file: %v\n", err)
|
fmt.Printf("failed to rename file: %v\n", err)
|
||||||
return
|
return
|
||||||
|
|
@ -218,7 +236,7 @@ func createErrorDump(logFile *os.File) {
|
||||||
|
|
||||||
fmt.Printf("error dump copied: %s\n", filePath)
|
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 {
|
if err := ensureSymlink(filePath, lastFilePath); err != nil {
|
||||||
fmt.Printf("failed to create symlink: %v\n", err)
|
fmt.Printf("failed to create symlink: %v\n", err)
|
||||||
|
|
|
||||||
20
config.go
20
config.go
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/jetkvm/kvm/internal/confparser"
|
"github.com/jetkvm/kvm/internal/confparser"
|
||||||
|
|
@ -16,6 +17,10 @@ import (
|
||||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultAPIURL = "https://api.jetkvm.com"
|
||||||
|
)
|
||||||
|
|
||||||
type WakeOnLanDevice struct {
|
type WakeOnLanDevice struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
MacAddress string `json:"macAddress"`
|
MacAddress string `json:"macAddress"`
|
||||||
|
|
@ -81,6 +86,7 @@ func (m *KeyboardMacro) Validate() error {
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CloudURL string `json:"cloud_url"`
|
CloudURL string `json:"cloud_url"`
|
||||||
|
UpdateAPIURL string `json:"update_api_url"`
|
||||||
CloudAppURL string `json:"cloud_app_url"`
|
CloudAppURL string `json:"cloud_app_url"`
|
||||||
CloudToken string `json:"cloud_token"`
|
CloudToken string `json:"cloud_token"`
|
||||||
GoogleIdentity string `json:"google_identity"`
|
GoogleIdentity string `json:"google_identity"`
|
||||||
|
|
@ -118,8 +124,18 @@ type Config struct {
|
||||||
AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24
|
AudioBufferPeriods int `json:"audio_buffer_periods"` // 2-24
|
||||||
AudioSampleRate int `json:"audio_sample_rate"` // Hz (32000, 44100, 48000)
|
AudioSampleRate int `json:"audio_sample_rate"` // Hz (32000, 44100, 48000)
|
||||||
AudioPacketLossPerc int `json:"audio_packet_loss_perc"` // 0-100
|
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 {
|
func (c *Config) GetDisplayRotation() uint16 {
|
||||||
rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
|
rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -129,6 +145,7 @@ func (c *Config) GetDisplayRotation() uint16 {
|
||||||
return uint16(rotationInt)
|
return uint16(rotationInt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDisplayRotation sets the display rotation
|
||||||
func (c *Config) SetDisplayRotation(rotation string) error {
|
func (c *Config) SetDisplayRotation(rotation string) error {
|
||||||
_, err := strconv.ParseUint(rotation, 10, 16)
|
_, err := strconv.ParseUint(rotation, 10, 16)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -168,7 +185,8 @@ var (
|
||||||
|
|
||||||
func getDefaultConfig() Config {
|
func getDefaultConfig() Config {
|
||||||
return Config{
|
return Config{
|
||||||
CloudURL: "https://api.jetkvm.com",
|
CloudURL: DefaultAPIURL,
|
||||||
|
UpdateAPIURL: DefaultAPIURL,
|
||||||
CloudAppURL: "https://app.jetkvm.com",
|
CloudAppURL: "https://app.jetkvm.com",
|
||||||
AutoUpdateEnabled: true, // Set a default value
|
AutoUpdateEnabled: true, // Set a default value
|
||||||
ActiveExtension: "",
|
ActiveExtension: "",
|
||||||
|
|
|
||||||
|
|
@ -232,6 +232,14 @@ func updateStaticContents() {
|
||||||
// nativeInstance.UpdateLabelAndChangeVisibility("boot_screen_device_id", GetDeviceID())
|
// 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
|
// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
|
||||||
// the backlight brightness of the JetKVM hardware's display.
|
// the backlight brightness of the JetKVM hardware's display.
|
||||||
func setDisplayBrightness(brightness int, reason string) error {
|
func setDisplayBrightness(brightness int, reason string) error {
|
||||||
|
|
|
||||||
10
failsafe.go
10
failsafe.go
|
|
@ -5,6 +5,8 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/supervisor"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -77,11 +79,17 @@ func checkFailsafeReason() {
|
||||||
_ = os.Remove(lastCrashPath)
|
_ = os.Remove(lastCrashPath)
|
||||||
|
|
||||||
// TODO: read the goroutine stack trace and check which goroutine is panicking
|
// TODO: read the goroutine stack trace and check which goroutine is panicking
|
||||||
if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
|
|
||||||
failsafeModeActive = true
|
failsafeModeActive = true
|
||||||
|
if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
|
||||||
failsafeModeReason = "video"
|
failsafeModeReason = "video"
|
||||||
return
|
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/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.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/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/creack/goselect v0.1.2 // 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/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // 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/oauth2 v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
golang.org/x/text v0.30.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
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // 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 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 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
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=
|
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/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 h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
|
||||||
github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
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"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jetkvm/kvm/internal/ota"
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractSerialNumber() (string, error) {
|
func extractSerialNumber() (string, error) {
|
||||||
|
|
@ -29,22 +31,16 @@ func extractSerialNumber() (string, error) {
|
||||||
return matches[1], nil
|
return matches[1], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readOtpEntropy() ([]byte, error) { //nolint:unused
|
func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
|
||||||
content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
|
logger.Info().Dur("delayMs", delay).Msg("reboot requested")
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return content[0x17:0x1C], nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
|
|
||||||
logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
|
|
||||||
|
|
||||||
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
|
||||||
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
|
time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
|
||||||
|
|
||||||
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
|
nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
|
||||||
time.Sleep(delay - (1 * time.Second)) // wait requested extra settle time
|
if delay > 1*time.Second {
|
||||||
|
time.Sleep(delay - 1*time.Second) // wait requested extra settle time
|
||||||
|
}
|
||||||
|
|
||||||
args := []string{}
|
args := []string{}
|
||||||
if force {
|
if force {
|
||||||
|
|
|
||||||
|
|
@ -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
|
# Get source files, excluding CMake generated files
|
||||||
file(GLOB_RECURSE sources CONFIGURE_DEPENDS "*.c" "ui/*.c")
|
file(GLOB_RECURSE sources CONFIGURE_DEPENDS "*.c" "ui/*.c")
|
||||||
list(FILTER sources EXCLUDE REGEX "CMakeFiles.*CompilerId.*\\.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)
|
add_library(jknative STATIC ${sources} ${CMAKE_CURRENT_SOURCE_DIR}/ctrl.h)
|
||||||
|
|
||||||
|
|
@ -68,4 +70,14 @@ target_link_libraries(jknative PRIVATE
|
||||||
# libgpiod
|
# 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 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 (
|
var (
|
||||||
cgoLock sync.Mutex
|
cgoLock sync.Mutex
|
||||||
cgoDisabled bool
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func setCgoDisabled(disabled bool) {
|
|
||||||
cgoLock.Lock()
|
|
||||||
defer cgoLock.Unlock()
|
|
||||||
|
|
||||||
cgoDisabled = disabled
|
|
||||||
}
|
|
||||||
|
|
||||||
//export jetkvm_go_video_state_handler
|
//export jetkvm_go_video_state_handler
|
||||||
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
|
func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
|
||||||
videoState := VideoState{
|
videoState := VideoState{
|
||||||
|
|
@ -104,10 +96,6 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
|
||||||
var eventCodeToNameMap = map[int]string{}
|
var eventCodeToNameMap = map[int]string{}
|
||||||
|
|
||||||
func uiEventCodeToName(code int) string {
|
func uiEventCodeToName(code int) string {
|
||||||
if cgoDisabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
name, ok := eventCodeToNameMap[code]
|
name, ok := eventCodeToNameMap[code]
|
||||||
if !ok {
|
if !ok {
|
||||||
cCode := C.int(code)
|
cCode := C.int(code)
|
||||||
|
|
@ -120,10 +108,6 @@ func uiEventCodeToName(code int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func setUpNativeHandlers() {
|
func setUpNativeHandlers() {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -135,10 +119,6 @@ func setUpNativeHandlers() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiInit(rotation uint16) {
|
func uiInit(rotation uint16) {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -148,10 +128,6 @@ func uiInit(rotation uint16) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiTick() {
|
func uiTick() {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -159,10 +135,6 @@ func uiTick() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoInit(factor float64) error {
|
func videoInit(factor float64) error {
|
||||||
if cgoDisabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -176,10 +148,6 @@ func videoInit(factor float64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoShutdown() {
|
func videoShutdown() {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -187,10 +155,6 @@ func videoShutdown() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoStart() {
|
func videoStart() {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -198,10 +162,6 @@ func videoStart() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoStop() {
|
func videoStop() {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -209,10 +169,6 @@ func videoStop() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoLogStatus() string {
|
func videoLogStatus() string {
|
||||||
if cgoDisabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -223,10 +179,6 @@ func videoLogStatus() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiSetVar(name string, value string) {
|
func uiSetVar(name string, value string) {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -240,10 +192,6 @@ func uiSetVar(name string, value string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiGetVar(name string) string {
|
func uiGetVar(name string) string {
|
||||||
if cgoDisabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -254,10 +202,6 @@ func uiGetVar(name string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiSwitchToScreen(screen string) {
|
func uiSwitchToScreen(screen string) {
|
||||||
if cgoDisabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -267,10 +211,6 @@ func uiSwitchToScreen(screen string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiGetCurrentScreen() string {
|
func uiGetCurrentScreen() string {
|
||||||
if cgoDisabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -279,10 +219,6 @@ func uiGetCurrentScreen() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjAddState(objName string, state string) (bool, error) {
|
func uiObjAddState(objName string, state string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -295,10 +231,6 @@ func uiObjAddState(objName string, state string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjClearState(objName string, state string) (bool, error) {
|
func uiObjClearState(objName string, state string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -311,10 +243,6 @@ func uiObjClearState(objName string, state string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiGetLVGLVersion() string {
|
func uiGetLVGLVersion() string {
|
||||||
if cgoDisabled {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
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
|
// 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) {
|
func uiObjAddFlag(objName string, flag string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -339,10 +263,6 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjClearFlag(objName string, flag string) (bool, error) {
|
func uiObjClearFlag(objName string, flag string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -363,10 +283,6 @@ func uiObjShow(objName string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjSetOpacity(objName string, opacity int) (bool, error) {
|
func uiObjSetOpacity(objName string, opacity int) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -378,10 +294,6 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjFadeIn(objName string, duration uint32) (bool, error) {
|
func uiObjFadeIn(objName string, duration uint32) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -394,10 +306,6 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiObjFadeOut(objName string, duration uint32) (bool, error) {
|
func uiObjFadeOut(objName string, duration uint32) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -410,10 +318,6 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiLabelSetText(objName string, text string) (bool, error) {
|
func uiLabelSetText(objName string, text string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -431,10 +335,6 @@ func uiLabelSetText(objName string, text string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiImgSetSrc(objName string, src string) (bool, error) {
|
func uiImgSetSrc(objName string, src string) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -450,10 +350,6 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func uiDispSetRotation(rotation uint16) (bool, error) {
|
func uiDispSetRotation(rotation uint16) (bool, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -466,10 +362,6 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoGetStreamQualityFactor() (float64, error) {
|
func videoGetStreamQualityFactor() (float64, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -478,10 +370,6 @@ func videoGetStreamQualityFactor() (float64, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoSetStreamQualityFactor(factor float64) error {
|
func videoSetStreamQualityFactor(factor float64) error {
|
||||||
if cgoDisabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -490,10 +378,6 @@ func videoSetStreamQualityFactor(factor float64) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoGetEDID() (string, error) {
|
func videoGetEDID() (string, error) {
|
||||||
if cgoDisabled {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
defer cgoLock.Unlock()
|
||||||
|
|
||||||
|
|
@ -502,10 +386,6 @@ func videoGetEDID() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func videoSetEDID(edid string) error {
|
func videoSetEDID(edid string) error {
|
||||||
if cgoDisabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
cgoLock.Lock()
|
cgoLock.Lock()
|
||||||
defer cgoLock.Unlock()
|
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
|
package native
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -9,7 +10,6 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type Native struct {
|
type Native struct {
|
||||||
disable bool
|
|
||||||
ready chan struct{}
|
ready chan struct{}
|
||||||
l *zerolog.Logger
|
l *zerolog.Logger
|
||||||
lD *zerolog.Logger
|
lD *zerolog.Logger
|
||||||
|
|
@ -28,18 +28,23 @@ type Native struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type NativeOptions struct {
|
type NativeOptions struct {
|
||||||
Disable bool
|
|
||||||
SystemVersion *semver.Version
|
SystemVersion *semver.Version
|
||||||
AppVersion *semver.Version
|
AppVersion *semver.Version
|
||||||
DisplayRotation uint16
|
DisplayRotation uint16
|
||||||
DefaultQualityFactor float64
|
DefaultQualityFactor float64
|
||||||
|
MaxRestartAttempts uint
|
||||||
OnVideoStateChange func(state VideoState)
|
OnVideoStateChange func(state VideoState)
|
||||||
OnVideoFrameReceived func(frame []byte, duration time.Duration)
|
OnVideoFrameReceived func(frame []byte, duration time.Duration)
|
||||||
OnIndevEvent func(event string)
|
OnIndevEvent func(event string)
|
||||||
OnRpcEvent func(event string)
|
OnRpcEvent func(event string)
|
||||||
|
OnNativeRestart func()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNative(opts NativeOptions) *Native {
|
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
|
onVideoStateChange := opts.OnVideoStateChange
|
||||||
if onVideoStateChange == nil {
|
if onVideoStateChange == nil {
|
||||||
onVideoStateChange = func(state VideoState) {
|
onVideoStateChange = func(state VideoState) {
|
||||||
|
|
@ -50,7 +55,7 @@ func NewNative(opts NativeOptions) *Native {
|
||||||
onVideoFrameReceived := opts.OnVideoFrameReceived
|
onVideoFrameReceived := opts.OnVideoFrameReceived
|
||||||
if onVideoFrameReceived == nil {
|
if onVideoFrameReceived == nil {
|
||||||
onVideoFrameReceived = func(frame []byte, duration time.Duration) {
|
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{
|
return &Native{
|
||||||
disable: opts.Disable,
|
|
||||||
ready: make(chan struct{}),
|
ready: make(chan struct{}),
|
||||||
l: nativeLogger,
|
l: &nativeSubLogger,
|
||||||
lD: displayLogger,
|
lD: &displaySubLogger,
|
||||||
systemVersion: opts.SystemVersion,
|
systemVersion: opts.SystemVersion,
|
||||||
appVersion: opts.AppVersion,
|
appVersion: opts.AppVersion,
|
||||||
displayRotation: opts.DisplayRotation,
|
displayRotation: opts.DisplayRotation,
|
||||||
|
|
@ -94,21 +98,11 @@ func NewNative(opts NativeOptions) *Native {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Native) Start(initialEDID string) {
|
func (n *Native) Start() error {
|
||||||
if n.disable {
|
|
||||||
nativeLogger.Warn().Msg("native is disabled, skipping initialization")
|
|
||||||
setCgoDisabled(true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// set up singleton
|
// set up singleton
|
||||||
setInstance(n)
|
setInstance(n)
|
||||||
setUpNativeHandlers()
|
setUpNativeHandlers()
|
||||||
|
|
||||||
if err := videoSetEDID(initialEDID); err != nil {
|
|
||||||
n.l.Warn().Err(err).Msg("failed to set EDID before video init")
|
|
||||||
}
|
|
||||||
|
|
||||||
// start the native video
|
// start the native video
|
||||||
go n.handleLogChan()
|
go n.handleLogChan()
|
||||||
go n.handleVideoStateChan()
|
go n.handleVideoStateChan()
|
||||||
|
|
@ -121,9 +115,11 @@ func (n *Native) Start(initialEDID string) {
|
||||||
|
|
||||||
if err := videoInit(n.defaultQualityFactor); err != nil {
|
if err := videoInit(n.defaultQualityFactor); err != nil {
|
||||||
n.l.Error().Err(err).Msg("failed to initialize video")
|
n.l.Error().Err(err).Msg("failed to initialize video")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
close(n.ready)
|
close(n.ready)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoNotUseThisIsForCrashTestingOnly
|
// 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
|
// NetworkConfig represents the complete network configuration for an interface
|
||||||
type NetworkConfig struct {
|
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"`
|
Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"`
|
||||||
HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
|
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
|
return err
|
||||||
}
|
}
|
||||||
if currentEDID == "" {
|
if currentEDID == "" {
|
||||||
currentEDID = nativeInstance.GetDefaultEDID()
|
// Use the default EDID from the native package
|
||||||
|
currentEDID = native.DefaultEDID
|
||||||
}
|
}
|
||||||
return nativeInstance.VideoSetEDID(currentEDID)
|
return nativeInstance.VideoSetEDID(currentEDID)
|
||||||
}
|
}
|
||||||
|
|
@ -251,55 +252,6 @@ func rpcGetVideoLogStatus() (string, error) {
|
||||||
return nativeInstance.VideoLogStatus()
|
return nativeInstance.VideoLogStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetDevChannelState() (bool, error) {
|
|
||||||
return config.IncludePreRelease, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetDevChannelState(enabled bool) error {
|
|
||||||
config.IncludePreRelease = enabled
|
|
||||||
if err := SaveConfig(); err != nil {
|
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetUpdateStatus() (*UpdateStatus, error) {
|
|
||||||
includePreRelease := config.IncludePreRelease
|
|
||||||
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
|
|
||||||
// to ensure backwards compatibility,
|
|
||||||
// if there's an error, we won't return an error, but we will set the error field
|
|
||||||
if err != nil {
|
|
||||||
if updateStatus == nil {
|
|
||||||
return nil, fmt.Errorf("error checking for updates: %w", err)
|
|
||||||
}
|
|
||||||
updateStatus.Error = err.Error()
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateStatus, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcGetLocalVersion() (*LocalMetadata, error) {
|
|
||||||
systemVersion, appVersion, err := GetLocalVersion()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error getting local version: %w", err)
|
|
||||||
}
|
|
||||||
return &LocalMetadata{
|
|
||||||
AppVersion: appVersion.String(),
|
|
||||||
SystemVersion: systemVersion.String(),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcTryUpdate() error {
|
|
||||||
includePreRelease := config.IncludePreRelease
|
|
||||||
go func() {
|
|
||||||
err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warn().Err(err).Msg("failed to try update")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
func rpcSetDisplayRotation(params DisplayRotationSettings) error {
|
||||||
currentRotation := config.DisplayRotation
|
currentRotation := config.DisplayRotation
|
||||||
if currentRotation == params.Rotation {
|
if currentRotation == params.Rotation {
|
||||||
|
|
@ -669,7 +621,10 @@ func rpcGetMassStorageMode() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcIsUpdatePending() (bool, error) {
|
func rpcIsUpdatePending() (bool, error) {
|
||||||
return IsUpdatePending(), nil
|
if otaState == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return otaState.IsUpdatePending(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func rpcGetUsbEmulationState() (bool, error) {
|
func rpcGetUsbEmulationState() (bool, error) {
|
||||||
|
|
@ -1368,7 +1323,10 @@ var rpcHandlers = map[string]RPCHandler{
|
||||||
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
|
||||||
"getLocalVersion": {Func: rpcGetLocalVersion},
|
"getLocalVersion": {Func: rpcGetLocalVersion},
|
||||||
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
"getUpdateStatus": {Func: rpcGetUpdateStatus},
|
||||||
|
"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel, Params: []string{"channel"}},
|
||||||
|
"checkUpdateComponents": {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
|
||||||
"tryUpdate": {Func: rpcTryUpdate},
|
"tryUpdate": {Func: rpcTryUpdate},
|
||||||
|
"tryUpdateComponents": {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
|
||||||
"getDevModeState": {Func: rpcGetDevModeState},
|
"getDevModeState": {Func: rpcGetDevModeState},
|
||||||
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
|
||||||
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
"getSSHKeyState": {Func: rpcGetSSHKeyState},
|
||||||
|
|
|
||||||
36
main.go
36
main.go
|
|
@ -2,22 +2,37 @@ package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/erikdubbelboer/gspt"
|
||||||
"github.com/gwatts/rootcerts"
|
"github.com/gwatts/rootcerts"
|
||||||
|
"github.com/jetkvm/kvm/internal/ota"
|
||||||
)
|
)
|
||||||
|
|
||||||
var appCtx context.Context
|
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() {
|
func Main() {
|
||||||
|
setProcTitle("starting")
|
||||||
|
|
||||||
logger.Log().Msg("JetKVM Starting Up")
|
logger.Log().Msg("JetKVM Starting Up")
|
||||||
|
|
||||||
checkFailsafeReason()
|
checkFailsafeReason()
|
||||||
if failsafeModeActive {
|
if failsafeModeActive {
|
||||||
|
procPrefix = "jetkvm: [app+failsafe]"
|
||||||
logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
|
logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -38,10 +53,10 @@ func Main() {
|
||||||
Msg("starting JetKVM")
|
Msg("starting JetKVM")
|
||||||
|
|
||||||
go runWatchdog()
|
go runWatchdog()
|
||||||
go confirmCurrentSystem()
|
|
||||||
|
|
||||||
initDisplay()
|
setProcTitle("initNative")
|
||||||
initNative(systemVersionLocal, appVersionLocal)
|
initNative(systemVersionLocal, appVersionLocal)
|
||||||
|
initDisplay()
|
||||||
|
|
||||||
initAudio()
|
initAudio()
|
||||||
defer stopAudio()
|
defer stopAudio()
|
||||||
|
|
@ -56,7 +71,12 @@ func Main() {
|
||||||
Int("ca_certs_loaded", len(rootcerts.Certs())).
|
Int("ca_certs_loaded", len(rootcerts.Certs())).
|
||||||
Msg("loaded Root CA certificates")
|
Msg("loaded Root CA certificates")
|
||||||
|
|
||||||
|
initOta()
|
||||||
|
|
||||||
|
http.DefaultClient.Timeout = 1 * time.Minute
|
||||||
|
|
||||||
// Initialize network
|
// Initialize network
|
||||||
|
setProcTitle("initNetwork")
|
||||||
if err := initNetwork(); err != nil {
|
if err := initNetwork(); err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to initialize network")
|
logger.Error().Err(err).Msg("failed to initialize network")
|
||||||
// TODO: reset config to default
|
// TODO: reset config to default
|
||||||
|
|
@ -64,17 +84,21 @@ func Main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize time sync
|
// Initialize time sync
|
||||||
|
setProcTitle("initTimeSync")
|
||||||
initTimeSync()
|
initTimeSync()
|
||||||
timeSync.Start()
|
timeSync.Start()
|
||||||
|
|
||||||
// Initialize mDNS
|
// Initialize mDNS
|
||||||
|
setProcTitle("initMdns")
|
||||||
if err := initMdns(); err != nil {
|
if err := initMdns(); err != nil {
|
||||||
logger.Error().Err(err).Msg("failed to initialize mDNS")
|
logger.Error().Err(err).Msg("failed to initialize mDNS")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setProcTitle("initPrometheus")
|
||||||
initPrometheus()
|
initPrometheus()
|
||||||
|
|
||||||
// initialize usb gadget
|
// initialize usb gadget
|
||||||
|
setProcTitle("initUsbGadget")
|
||||||
initUsbGadget()
|
initUsbGadget()
|
||||||
if err := setInitialVirtualMediaState(); err != nil {
|
if err := setInitialVirtualMediaState(); err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
|
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
|
||||||
|
|
@ -116,7 +140,10 @@ func Main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
includePreRelease := config.IncludePreRelease
|
includePreRelease := config.IncludePreRelease
|
||||||
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
|
err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
|
||||||
|
DeviceID: GetDeviceID(),
|
||||||
|
IncludePreRelease: includePreRelease,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warn().Err(err).Msg("failed to auto update")
|
logger.Warn().Err(err).Msg("failed to auto update")
|
||||||
}
|
}
|
||||||
|
|
@ -139,6 +166,9 @@ func Main() {
|
||||||
initPublicIPState()
|
initPublicIPState()
|
||||||
|
|
||||||
initSerialPort()
|
initSerialPort()
|
||||||
|
|
||||||
|
setProcTitle("ready")
|
||||||
|
|
||||||
sigs := make(chan os.Signal, 1)
|
sigs := make(chan os.Signal, 1)
|
||||||
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sigs
|
<-sigs
|
||||||
|
|
|
||||||
29
native.go
29
native.go
|
|
@ -11,17 +11,28 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
nativeInstance *native.Native
|
nativeInstance native.NativeInterface
|
||||||
nativeCmdLock = sync.Mutex{}
|
nativeCmdLock = sync.Mutex{}
|
||||||
)
|
)
|
||||||
|
|
||||||
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
|
||||||
nativeInstance = native.NewNative(native.NativeOptions{
|
if failsafeModeActive {
|
||||||
Disable: 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,
|
SystemVersion: systemVersion,
|
||||||
AppVersion: appVersion,
|
AppVersion: appVersion,
|
||||||
DisplayRotation: config.GetDisplayRotation(),
|
DisplayRotation: config.GetDisplayRotation(),
|
||||||
DefaultQualityFactor: config.VideoQualityFactor,
|
DefaultQualityFactor: config.VideoQualityFactor,
|
||||||
|
MaxRestartAttempts: config.NativeMaxRestart,
|
||||||
|
OnNativeRestart: func() {
|
||||||
|
configureDisplayOnNativeRestart()
|
||||||
|
},
|
||||||
OnVideoStateChange: func(state native.VideoState) {
|
OnVideoStateChange: func(state native.VideoState) {
|
||||||
lastVideoState = state
|
lastVideoState = state
|
||||||
triggerVideoStateUpdate()
|
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" {
|
if os.Getenv("JETKVM_CRASH_TESTING") == "1" {
|
||||||
nativeInstance.DoNotUseThisIsForCrashTestingOnly()
|
nativeInstance.DoNotUseThisIsForCrashTestingOnly()
|
||||||
|
|
|
||||||
12
network.go
12
network.go
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"github.com/jetkvm/kvm/internal/confparser"
|
"github.com/jetkvm/kvm/internal/confparser"
|
||||||
"github.com/jetkvm/kvm/internal/mdns"
|
"github.com/jetkvm/kvm/internal/mdns"
|
||||||
"github.com/jetkvm/kvm/internal/network/types"
|
"github.com/jetkvm/kvm/internal/network/types"
|
||||||
|
"github.com/jetkvm/kvm/internal/ota"
|
||||||
"github.com/jetkvm/kvm/pkg/myip"
|
"github.com/jetkvm/kvm/pkg/myip"
|
||||||
"github.com/jetkvm/kvm/pkg/nmlite"
|
"github.com/jetkvm/kvm/pkg/nmlite"
|
||||||
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
"github.com/jetkvm/kvm/pkg/nmlite/link"
|
||||||
|
|
@ -225,7 +226,7 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
|
||||||
return nm.SetHostname(hostname, domain)
|
return nm.SetHostname(hostname, domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
|
func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) {
|
||||||
oldDhcpClient := oldConfig.DHCPClient.String
|
oldDhcpClient := oldConfig.DHCPClient.String
|
||||||
|
|
||||||
l := networkLogger.With().
|
l := networkLogger.With().
|
||||||
|
|
@ -249,7 +250,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
||||||
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
|
l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
|
||||||
|
|
||||||
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
|
if newIPv4Mode == "static" && oldIPv4Mode != "static" {
|
||||||
postRebootAction = &PostRebootAction{
|
postRebootAction = &ota.PostRebootAction{
|
||||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||||
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +267,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
||||||
// Handle IP change for redirect (only if both are not nil and IP changed)
|
// Handle IP change for redirect (only if both are not nil and IP changed)
|
||||||
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
|
if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
|
||||||
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
|
newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
|
||||||
postRebootAction = &PostRebootAction{
|
postRebootAction = &ota.PostRebootAction{
|
||||||
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
|
||||||
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
RedirectTo: fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
|
||||||
}
|
}
|
||||||
|
|
@ -283,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
|
||||||
l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
|
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
|
return rebootRequired, postRebootAction
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
672
ota.go
672
ota.go
|
|
@ -1,59 +1,65 @@
|
||||||
package kvm
|
package kvm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"crypto/tls"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/Masterminds/semver/v3"
|
"github.com/Masterminds/semver/v3"
|
||||||
"github.com/gwatts/rootcerts"
|
"github.com/google/uuid"
|
||||||
"github.com/rs/zerolog"
|
"github.com/jetkvm/kvm/internal/ota"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UpdateMetadata struct {
|
|
||||||
AppVersion string `json:"appVersion"`
|
|
||||||
AppUrl string `json:"appUrl"`
|
|
||||||
AppHash string `json:"appHash"`
|
|
||||||
SystemVersion string `json:"systemVersion"`
|
|
||||||
SystemUrl string `json:"systemUrl"`
|
|
||||||
SystemHash string `json:"systemHash"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type LocalMetadata struct {
|
|
||||||
AppVersion string `json:"appVersion"`
|
|
||||||
SystemVersion string `json:"systemVersion"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateStatus represents the current update status
|
|
||||||
type UpdateStatus struct {
|
|
||||||
Local *LocalMetadata `json:"local"`
|
|
||||||
Remote *UpdateMetadata `json:"remote"`
|
|
||||||
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
|
|
||||||
AppUpdateAvailable bool `json:"appUpdateAvailable"`
|
|
||||||
|
|
||||||
// for backwards compatibility
|
|
||||||
Error string `json:"error,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
|
|
||||||
|
|
||||||
var builtAppVersion = "0.1.0+dev"
|
var builtAppVersion = "0.1.0+dev"
|
||||||
|
|
||||||
|
var otaState *ota.State
|
||||||
|
|
||||||
|
func initOta() {
|
||||||
|
otaState = ota.NewState(ota.Options{
|
||||||
|
Logger: otaLogger,
|
||||||
|
ReleaseAPIEndpoint: config.GetUpdateAPIURL(),
|
||||||
|
GetHTTPClient: func() ota.HttpClient {
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
return client
|
||||||
|
},
|
||||||
|
GetLocalVersion: GetLocalVersion,
|
||||||
|
HwReboot: hwReboot,
|
||||||
|
ResetConfig: rpcResetConfig,
|
||||||
|
SetAutoUpdate: rpcSetAutoUpdateState,
|
||||||
|
OnStateUpdate: func(state *ota.RPCState) {
|
||||||
|
triggerOTAStateUpdate(state)
|
||||||
|
},
|
||||||
|
OnProgressUpdate: func(progress float32) {
|
||||||
|
writeJSONRPCEvent("otaProgress", progress, currentSession)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerOTAStateUpdate(state *ota.RPCState) {
|
||||||
|
go func() {
|
||||||
|
if currentSession == nil || (otaState == nil && state == nil) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state == nil {
|
||||||
|
state = otaState.ToRPCState()
|
||||||
|
}
|
||||||
|
writeJSONRPCEvent("otaState", state, currentSession)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuiltAppVersion returns the built-in app version
|
||||||
func GetBuiltAppVersion() string {
|
func GetBuiltAppVersion() string {
|
||||||
return builtAppVersion
|
return builtAppVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalVersion returns the local version of the system and app
|
||||||
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
|
||||||
appVersion, err = semver.NewVersion(builtAppVersion)
|
appVersion, err = semver.NewVersion(builtAppVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -73,519 +79,107 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
|
||||||
return systemVersion, appVersion, nil
|
return systemVersion, appVersion, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
|
func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
|
||||||
metadata := &UpdateMetadata{}
|
updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
|
||||||
|
DeviceID: GetDeviceID(),
|
||||||
|
IncludePreRelease: includePreRelease,
|
||||||
|
RequestID: uuid.New().String(),
|
||||||
|
})
|
||||||
|
|
||||||
updateUrl, err := url.Parse(UpdateMetadataUrl)
|
// to ensure backwards compatibility,
|
||||||
|
// if there's an error, we won't return an error, but we will set the error field
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
|
if updateStatus == nil {
|
||||||
|
return nil, fmt.Errorf("error checking for updates: %w", err)
|
||||||
}
|
}
|
||||||
|
updateStatus.Error = err.Error()
|
||||||
query := updateUrl.Query()
|
|
||||||
query.Set("deviceId", deviceId)
|
|
||||||
query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
|
|
||||||
updateUrl.RawQuery = query.Encode()
|
|
||||||
|
|
||||||
logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error creating request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
||||||
transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
|
|
||||||
|
|
||||||
client := &http.Client{
|
|
||||||
Transport: transport,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error sending request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = json.NewDecoder(resp.Body).Decode(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error decoding response: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return metadata, nil
|
// 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
|
||||||
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
|
|
||||||
}
|
}
|
||||||
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{}
|
otaLogger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
|
||||||
|
|
||||||
func triggerOTAStateUpdate() {
|
|
||||||
go func() {
|
|
||||||
if currentSession == nil {
|
|
||||||
logger.Info().Msg("No active RPC session, skipping update state update")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeJSONRPCEvent("otaState", otaState, currentSession)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) error {
|
|
||||||
scopedLogger := otaLogger.With().
|
|
||||||
Str("deviceId", deviceId).
|
|
||||||
Bool("includePreRelease", includePreRelease).
|
|
||||||
Logger()
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("Trying to update...")
|
|
||||||
if otaState.Updating {
|
|
||||||
return fmt.Errorf("update already in progress")
|
|
||||||
}
|
|
||||||
|
|
||||||
otaState = OTAState{
|
|
||||||
Updating: true,
|
|
||||||
}
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
otaState.Updating = false
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
}()
|
|
||||||
|
|
||||||
updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error checking for updates: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error checking for updates")
|
|
||||||
return fmt.Errorf("error checking for updates: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
otaState.MetadataFetchedAt = &now
|
|
||||||
otaState.AppUpdatePending = updateStatus.AppUpdateAvailable
|
|
||||||
otaState.SystemUpdatePending = updateStatus.SystemUpdateAvailable
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
local := updateStatus.Local
|
|
||||||
remote := updateStatus.Remote
|
|
||||||
appUpdateAvailable := updateStatus.AppUpdateAvailable
|
|
||||||
systemUpdateAvailable := updateStatus.SystemUpdateAvailable
|
|
||||||
|
|
||||||
rebootNeeded := false
|
|
||||||
|
|
||||||
if appUpdateAvailable {
|
|
||||||
scopedLogger.Info().
|
|
||||||
Str("local", local.AppVersion).
|
|
||||||
Str("remote", remote.AppVersion).
|
|
||||||
Msg("App update available")
|
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error downloading app update: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading app update")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error downloading app update: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFinished := time.Now()
|
|
||||||
otaState.AppDownloadFinishedAt = &downloadFinished
|
|
||||||
otaState.AppDownloadProgress = 1
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
err = verifyFile(
|
|
||||||
"/userdata/jetkvm/jetkvm_app.update",
|
|
||||||
remote.AppHash,
|
|
||||||
&otaState.AppVerificationProgress,
|
|
||||||
&scopedLogger,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error verifying app update hash: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error verifying app update hash")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error verifying app update: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
verifyFinished := time.Now()
|
|
||||||
otaState.AppVerifiedAt = &verifyFinished
|
|
||||||
otaState.AppVerificationProgress = 1
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
otaState.AppUpdatedAt = &verifyFinished
|
|
||||||
otaState.AppUpdateProgress = 1
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("App update downloaded")
|
|
||||||
rebootNeeded = true
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
} else {
|
|
||||||
scopedLogger.Info().Msg("App is up to date")
|
|
||||||
}
|
|
||||||
|
|
||||||
if systemUpdateAvailable {
|
|
||||||
scopedLogger.Info().
|
|
||||||
Str("local", local.SystemVersion).
|
|
||||||
Str("remote", remote.SystemVersion).
|
|
||||||
Msg("System update available")
|
|
||||||
|
|
||||||
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error downloading system update: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error downloading system update")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error downloading system update: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadFinished := time.Now()
|
|
||||||
otaState.SystemDownloadFinishedAt = &downloadFinished
|
|
||||||
otaState.SystemDownloadProgress = 1
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
err = verifyFile(
|
|
||||||
"/userdata/jetkvm/update_system.tar",
|
|
||||||
remote.SystemHash,
|
|
||||||
&otaState.SystemVerificationProgress,
|
|
||||||
&scopedLogger,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error verifying system update hash: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error verifying system update hash")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error verifying system update: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("System update downloaded")
|
|
||||||
verifyFinished := time.Now()
|
|
||||||
otaState.SystemVerifiedAt = &verifyFinished
|
|
||||||
otaState.SystemVerificationProgress = 1
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
|
|
||||||
scopedLogger.Info().Msg("Starting rk_ota command")
|
|
||||||
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/jetkvm/update_system.tar", "--save_dir=/userdata/jetkvm/ota_save", "--partition=all")
|
|
||||||
var b bytes.Buffer
|
|
||||||
cmd.Stdout = &b
|
|
||||||
cmd.Stderr = &b
|
|
||||||
err = cmd.Start()
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error starting rk_ota command: %v", err)
|
|
||||||
scopedLogger.Error().Err(err).Msg("Error starting rk_ota command")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error starting rk_ota command: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(1800 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
if otaState.SystemUpdateProgress >= 0.99 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
otaState.SystemUpdateProgress += 0.01
|
|
||||||
if otaState.SystemUpdateProgress > 0.99 {
|
|
||||||
otaState.SystemUpdateProgress = 0.99
|
|
||||||
}
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
err = cmd.Wait()
|
|
||||||
cancel()
|
|
||||||
output := b.String()
|
|
||||||
if err != nil {
|
|
||||||
otaState.Error = fmt.Sprintf("Error executing rk_ota command: %v\nOutput: %s", err, output)
|
|
||||||
scopedLogger.Error().
|
|
||||||
Err(err).
|
|
||||||
Str("output", output).
|
|
||||||
Int("exitCode", cmd.ProcessState.ExitCode()).
|
|
||||||
Msg("Error executing rk_ota command")
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
return fmt.Errorf("error executing rk_ota command: %w\nOutput: %s", err, output)
|
|
||||||
}
|
|
||||||
|
|
||||||
scopedLogger.Info().Str("output", output).Msg("rk_ota success")
|
|
||||||
otaState.SystemUpdateProgress = 1
|
|
||||||
otaState.SystemUpdatedAt = &verifyFinished
|
|
||||||
rebootNeeded = true
|
|
||||||
triggerOTAStateUpdate()
|
|
||||||
} else {
|
|
||||||
scopedLogger.Info().Msg("System is up to date")
|
|
||||||
}
|
|
||||||
|
|
||||||
if rebootNeeded {
|
|
||||||
scopedLogger.Info().Msg("System Rebooting due to OTA update")
|
|
||||||
|
|
||||||
// Build redirect URL with conditional query parameters
|
|
||||||
redirectTo := "/settings/general/update"
|
|
||||||
queryParams := url.Values{}
|
|
||||||
if systemUpdateAvailable {
|
|
||||||
queryParams.Set("systemVersion", remote.SystemVersion)
|
|
||||||
}
|
|
||||||
if appUpdateAvailable {
|
|
||||||
queryParams.Set("appVersion", remote.AppVersion)
|
|
||||||
}
|
|
||||||
if len(queryParams) > 0 {
|
|
||||||
redirectTo += "?" + queryParams.Encode()
|
|
||||||
}
|
|
||||||
|
|
||||||
postRebootAction := &PostRebootAction{
|
|
||||||
HealthCheck: "/device/status",
|
|
||||||
RedirectTo: redirectTo,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := hwReboot(true, postRebootAction, 10*time.Second); err != nil {
|
|
||||||
return fmt.Errorf("error requesting reboot: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetUpdateStatus(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateStatus, error) {
|
|
||||||
updateStatus := &UpdateStatus{}
|
|
||||||
|
|
||||||
// Get local versions
|
|
||||||
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
|
|
||||||
if err != nil {
|
|
||||||
return updateStatus, fmt.Errorf("error getting local version: %w", err)
|
|
||||||
}
|
|
||||||
updateStatus.Local = &LocalMetadata{
|
|
||||||
AppVersion: appVersionLocal.String(),
|
|
||||||
SystemVersion: systemVersionLocal.String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get remote metadata
|
|
||||||
remoteMetadata, err := fetchUpdateMetadata(ctx, deviceId, includePreRelease)
|
|
||||||
if err != nil {
|
|
||||||
return updateStatus, fmt.Errorf("error checking for updates: %w", err)
|
|
||||||
}
|
|
||||||
updateStatus.Remote = remoteMetadata
|
|
||||||
|
|
||||||
// Get remote versions
|
|
||||||
systemVersionRemote, err := semver.NewVersion(remoteMetadata.SystemVersion)
|
|
||||||
if err != nil {
|
|
||||||
return updateStatus, fmt.Errorf("error parsing remote system version: %w", err)
|
|
||||||
}
|
|
||||||
appVersionRemote, err := semver.NewVersion(remoteMetadata.AppVersion)
|
|
||||||
if err != nil {
|
|
||||||
return updateStatus, fmt.Errorf("error parsing remote app version: %w, %s", err, remoteMetadata.AppVersion)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStatus.SystemUpdateAvailable = systemVersionRemote.GreaterThan(systemVersionLocal)
|
|
||||||
updateStatus.AppUpdateAvailable = appVersionRemote.GreaterThan(appVersionLocal)
|
|
||||||
|
|
||||||
// Handle pre-release updates
|
|
||||||
isRemoteSystemPreRelease := systemVersionRemote.Prerelease() != ""
|
|
||||||
isRemoteAppPreRelease := appVersionRemote.Prerelease() != ""
|
|
||||||
|
|
||||||
if isRemoteSystemPreRelease && !includePreRelease {
|
|
||||||
updateStatus.SystemUpdateAvailable = false
|
|
||||||
}
|
|
||||||
if isRemoteAppPreRelease && !includePreRelease {
|
|
||||||
updateStatus.AppUpdateAvailable = false
|
|
||||||
}
|
|
||||||
|
|
||||||
return updateStatus, nil
|
return updateStatus, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IsUpdatePending() bool {
|
func rpcGetDevChannelState() (bool, error) {
|
||||||
return otaState.Updating
|
return config.IncludePreRelease, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// make sure our current a/b partition is set as default
|
func rpcSetDevChannelState(enabled bool) error {
|
||||||
func confirmCurrentSystem() {
|
config.IncludePreRelease = enabled
|
||||||
output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
|
if err := SaveConfig(); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetUpdateStatus() (*ota.UpdateStatus, error) {
|
||||||
|
return getUpdateStatus(config.IncludePreRelease)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcGetUpdateStatusChannel(channel string) (*ota.UpdateStatus, error) {
|
||||||
|
switch channel {
|
||||||
|
case "stable":
|
||||||
|
return getUpdateStatus(false)
|
||||||
|
case "dev":
|
||||||
|
return getUpdateStatus(true)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("invalid channel: %s", channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcGetLocalVersion() (*ota.LocalMetadata, error) {
|
||||||
|
systemVersion, appVersion, err := GetLocalVersion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error getting local version: %w", err)
|
||||||
|
}
|
||||||
|
return &ota.LocalMetadata{
|
||||||
|
AppVersion: appVersion.String(),
|
||||||
|
SystemVersion: systemVersion.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateParams struct {
|
||||||
|
Components map[string]string `json:"components,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcTryUpdate() error {
|
||||||
|
return rpcTryUpdateComponents(updateParams{
|
||||||
|
Components: make(map[string]string),
|
||||||
|
}, config.IncludePreRelease, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rpcCheckUpdateComponents checks the update status for the given components
|
||||||
|
func rpcCheckUpdateComponents(params updateParams, includePreRelease bool) (*ota.UpdateStatus, error) {
|
||||||
|
updateParams := ota.UpdateParams{
|
||||||
|
DeviceID: GetDeviceID(),
|
||||||
|
IncludePreRelease: includePreRelease,
|
||||||
|
Components: params.Components,
|
||||||
|
}
|
||||||
|
info, err := otaState.GetUpdateStatus(context.Background(), updateParams)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to check update: %w", err)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetConfig bool) error {
|
||||||
|
updateParams := ota.UpdateParams{
|
||||||
|
DeviceID: GetDeviceID(),
|
||||||
|
IncludePreRelease: includePreRelease,
|
||||||
|
ResetConfig: resetConfig,
|
||||||
|
Components: params.Components,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := otaState.TryUpdate(context.Background(), updateParams)
|
||||||
|
if err != nil {
|
||||||
|
otaLogger.Warn().Err(err).Msg("failed to try update")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ set -e
|
||||||
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
|
SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
|
||||||
source ${SCRIPT_PATH}/build_utils.sh
|
source ${SCRIPT_PATH}/build_utils.sh
|
||||||
|
|
||||||
|
CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE:-Release}
|
||||||
|
|
||||||
CGO_PATH=$(realpath "${SCRIPT_PATH}/../internal/native/cgo")
|
CGO_PATH=$(realpath "${SCRIPT_PATH}/../internal/native/cgo")
|
||||||
BUILD_DIR=${CGO_PATH}/build
|
BUILD_DIR=${CGO_PATH}/build
|
||||||
|
|
||||||
|
|
@ -31,7 +33,7 @@ VERBOSE=1 cmake -B "${BUILD_DIR}" \
|
||||||
-DCONFIG_LV_BUILD_EXAMPLES=OFF \
|
-DCONFIG_LV_BUILD_EXAMPLES=OFF \
|
||||||
-DCONFIG_LV_BUILD_DEMOS=OFF \
|
-DCONFIG_LV_BUILD_DEMOS=OFF \
|
||||||
-DSKIP_GLIBC_NAMES=ON \
|
-DSKIP_GLIBC_NAMES=ON \
|
||||||
-DCMAKE_BUILD_TYPE=Release \
|
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \
|
||||||
-DCMAKE_INSTALL_PREFIX="${TMP_DIR}"
|
-DCMAKE_INSTALL_PREFIX="${TMP_DIR}"
|
||||||
|
|
||||||
msg_info "▶ Copying built library and header files"
|
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
|
||||||
echo "Optional:"
|
echo "Optional:"
|
||||||
echo " -u, --user <remote_user> Remote username (default: root)"
|
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 Run go tests"
|
||||||
echo " --run-go-tests-only Run go tests and exit"
|
echo " --run-go-tests-only Run go tests and exit"
|
||||||
echo " --skip-ui-build Skip frontend/UI build"
|
echo " --skip-ui-build Skip frontend/UI build"
|
||||||
echo " --skip-native-build Skip native build"
|
echo " --skip-native-build Skip native build"
|
||||||
echo " --disable-docker Disable docker build"
|
echo " --disable-docker Disable docker build"
|
||||||
echo " --enable-sync-trace Enable sync trace (do not use in release builds)"
|
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 " -i, --install Build for release and install the app"
|
||||||
echo " --help Display this help message"
|
echo " --help Display this help message"
|
||||||
echo
|
echo
|
||||||
|
|
@ -58,6 +60,8 @@ REMOTE_PATH="/userdata/jetkvm/bin"
|
||||||
SKIP_UI_BUILD=false
|
SKIP_UI_BUILD=false
|
||||||
SKIP_UI_BUILD_RELEASE=0
|
SKIP_UI_BUILD_RELEASE=0
|
||||||
SKIP_NATIVE_BUILD=0
|
SKIP_NATIVE_BUILD=0
|
||||||
|
GDB_DEBUG_PORT=2345
|
||||||
|
BUILD_NATIVE_BINARY=false
|
||||||
ENABLE_SYNC_TRACE=0
|
ENABLE_SYNC_TRACE=0
|
||||||
RESET_USB_HID_DEVICE=false
|
RESET_USB_HID_DEVICE=false
|
||||||
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
|
||||||
|
|
@ -79,6 +83,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
REMOTE_USER="$2"
|
REMOTE_USER="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--gdb-port)
|
||||||
|
GDB_DEBUG_PORT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--skip-ui-build)
|
--skip-ui-build)
|
||||||
SKIP_UI_BUILD=true
|
SKIP_UI_BUILD=true
|
||||||
shift
|
shift
|
||||||
|
|
@ -113,6 +121,10 @@ while [[ $# -gt 0 ]]; do
|
||||||
RUN_GO_TESTS=true
|
RUN_GO_TESTS=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--native-binary)
|
||||||
|
BUILD_NATIVE_BINARY=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
-i|--install)
|
-i|--install)
|
||||||
INSTALL_APP=true
|
INSTALL_APP=true
|
||||||
shift
|
shift
|
||||||
|
|
@ -141,6 +153,10 @@ fi
|
||||||
# Check device connectivity before proceeding
|
# Check device connectivity before proceeding
|
||||||
check_ping "${REMOTE_HOST}"
|
check_ping "${REMOTE_HOST}"
|
||||||
check_ssh "${REMOTE_USER}" "${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
|
# check if the current CPU architecture is x86_64
|
||||||
if [ "$(uname -m)" != "x86_64" ]; then
|
if [ "$(uname -m)" != "x86_64" ]; then
|
||||||
|
|
@ -152,6 +168,34 @@ if [ "$BUILD_IN_DOCKER" = true ]; then
|
||||||
build_docker_image
|
build_docker_image
|
||||||
fi
|
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
|
# Build the development version on the host
|
||||||
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
|
# When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
|
||||||
# check if static/index.html exists
|
# check if static/index.html exists
|
||||||
|
|
@ -176,10 +220,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
|
||||||
make build_dev_test
|
make build_dev_test
|
||||||
|
|
||||||
msg_info "▶ Copying device-tests.tar.gz to remote host"
|
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"
|
msg_info "▶ Running go tests"
|
||||||
ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
|
sshdev ash << 'EOF'
|
||||||
set -e
|
set -e
|
||||||
TMP_DIR=$(mktemp -d)
|
TMP_DIR=$(mktemp -d)
|
||||||
cd ${TMP_DIR}
|
cd ${TMP_DIR}
|
||||||
|
|
@ -222,10 +266,10 @@ then
|
||||||
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
||||||
|
|
||||||
# Copy the binary to the remote host as if we were the OTA updater.
|
# 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.
|
# 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
|
else
|
||||||
msg_info "▶ Building development binary"
|
msg_info "▶ Building development binary"
|
||||||
do_make build_dev \
|
do_make build_dev \
|
||||||
|
|
@ -234,21 +278,21 @@ else
|
||||||
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
|
||||||
|
|
||||||
# Kill any existing instances of the application
|
# 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
|
# 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
|
if [ "$RESET_USB_HID_DEVICE" = true ]; then
|
||||||
msg_info "▶ Resetting USB HID device"
|
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"
|
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
|
# 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*"
|
sshdev "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 "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Deploy and run the application on the remote host
|
# 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 -e
|
||||||
|
|
||||||
# Set the library path to include the directory where librockit.so is located
|
# 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_self_signed": "Selvsigneret",
|
||||||
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
|
"access_tls_updated": "TLS-indstillingerne er blevet opdateret",
|
||||||
"access_update_tls_settings": "Opdater TLS-indstillinger",
|
"access_update_tls_settings": "Opdater TLS-indstillinger",
|
||||||
"action_bar_audio": "Lyd",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Forbindelsesstatistik",
|
"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_extension": "Udvidelse",
|
||||||
"action_bar_fullscreen": "Fuldskærm",
|
"action_bar_fullscreen": "Fuldskærm",
|
||||||
"action_bar_settings": "Indstillinger",
|
"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_success": "Lydudgangskilde opdateret. Lyd starter om 30-60 sekunder.",
|
||||||
"audio_settings_output_source_title": "Lydudgangskilde",
|
"audio_settings_output_source_title": "Lydudgangskilde",
|
||||||
"audio_settings_output_title": "Lydudgang",
|
"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_title": "Lyd",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Lyd fra mål til højttalere",
|
"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_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_description": "Aktivér absolut mus (markør)",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Aktiver USB-lyd",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktivér tastatur",
|
"usb_device_enable_keyboard_description": "Aktivér tastatur",
|
||||||
"usb_device_enable_keyboard_title": "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.",
|
"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_load": "Kunne ikke indlæse USB-enheder: {error}",
|
||||||
"usb_device_failed_set": "Kunne ikke indstille 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_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_keyboard_only": "Kun tastatur",
|
||||||
"usb_device_restore_default": "Gendan til standard",
|
"usb_device_restore_default": "Gendan til standard",
|
||||||
"usb_device_title": "USB-enhed",
|
"usb_device_title": "USB-enhed",
|
||||||
|
|
|
||||||
|
|
@ -49,50 +49,6 @@
|
||||||
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
|
"access_update_tls_settings": "TLS-Einstellungen aktualisieren",
|
||||||
"action_bar_audio": "Audio",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Verbindungsstatistiken",
|
"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_extension": "Erweiterung",
|
||||||
"action_bar_fullscreen": "Vollbild",
|
"action_bar_fullscreen": "Vollbild",
|
||||||
"action_bar_settings": "Einstellungen",
|
"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_success": "Audioausgabequelle aktualisiert. Audio startet in 30-60 Sekunden.",
|
||||||
"audio_settings_output_source_title": "Audioausgabequelle",
|
"audio_settings_output_source_title": "Audioausgabequelle",
|
||||||
"audio_settings_output_title": "Audioausgang",
|
"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_title": "Audio",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Audio vom Ziel zu Lautsprechern",
|
"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_description": "USB-Geräte zum Emulieren auf dem Zielcomputer",
|
||||||
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
|
"usb_device_enable_absolute_mouse_description": "Absolute Maus (Zeiger) aktivieren",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "USB-Audio aktivieren",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
|
"usb_device_enable_keyboard_description": "Tastatur aktivieren",
|
||||||
"usb_device_enable_keyboard_title": "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",
|
"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_load": "USB-Geräte konnten nicht geladen werden: {error}",
|
||||||
"usb_device_failed_set": "Fehler beim Festlegen der USB-Geräte: {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_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_keyboard_only": "Nur Tastatur",
|
||||||
"usb_device_restore_default": "Auf Standard zurücksetzen",
|
"usb_device_restore_default": "Auf Standard zurücksetzen",
|
||||||
"usb_device_title": "USB-Gerät",
|
"usb_device_title": "USB-Gerät",
|
||||||
|
|
|
||||||
|
|
@ -49,53 +49,6 @@
|
||||||
"access_update_tls_settings": "Update TLS Settings",
|
"access_update_tls_settings": "Update TLS Settings",
|
||||||
"action_bar_audio": "Audio",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Connection Stats",
|
"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_extension": "Extension",
|
||||||
"action_bar_fullscreen": "Fullscreen",
|
"action_bar_fullscreen": "Fullscreen",
|
||||||
"action_bar_settings": "Settings",
|
"action_bar_settings": "Settings",
|
||||||
|
|
@ -122,6 +75,7 @@
|
||||||
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
|
"advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
|
||||||
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
|
"advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
|
||||||
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
|
"advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
|
||||||
|
"advanced_error_version_update": "Failed to initiate version update: {error}",
|
||||||
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
|
"advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
|
||||||
"advanced_loopback_only_title": "Loopback-Only Mode",
|
"advanced_loopback_only_title": "Loopback-Only Mode",
|
||||||
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
|
"advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
|
||||||
|
|
@ -148,6 +102,19 @@
|
||||||
"advanced_update_ssh_key_button": "Update SSH Key",
|
"advanced_update_ssh_key_button": "Update SSH Key",
|
||||||
"advanced_usb_emulation_description": "Control the USB emulation state",
|
"advanced_usb_emulation_description": "Control the USB emulation state",
|
||||||
"advanced_usb_emulation_title": "USB Emulation",
|
"advanced_usb_emulation_title": "USB Emulation",
|
||||||
|
"advanced_version_update_app_label": "App Version",
|
||||||
|
"advanced_version_update_button": "Update to Version",
|
||||||
|
"advanced_version_update_description": "Install a specific version from GitHub releases",
|
||||||
|
"advanced_version_update_github_link": "JetKVM releases page",
|
||||||
|
"advanced_version_update_helper": "Find available versions on the",
|
||||||
|
"advanced_version_update_reset_config_description": "Reset configuration after the update",
|
||||||
|
"advanced_version_update_reset_config_label": "Reset configuration",
|
||||||
|
"advanced_version_update_system_label": "System Version",
|
||||||
|
"advanced_version_update_target_app": "App only",
|
||||||
|
"advanced_version_update_target_both": "Both App and System",
|
||||||
|
"advanced_version_update_target_label": "What to update",
|
||||||
|
"advanced_version_update_target_system": "System only",
|
||||||
|
"advanced_version_update_title": "Update to Specific Version",
|
||||||
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
|
"already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
|
||||||
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
|
"already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
|
||||||
"already_adopted_return_to_dashboard": "Return to Dashboard",
|
"already_adopted_return_to_dashboard": "Return to Dashboard",
|
||||||
|
|
@ -192,18 +159,25 @@
|
||||||
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
|
"audio_settings_complexity_description": "Encoder complexity (0-10, higher = better quality, more CPU)",
|
||||||
"audio_settings_complexity_title": "Opus Complexity",
|
"audio_settings_complexity_title": "Opus Complexity",
|
||||||
"audio_settings_config_updated": "Audio configuration updated",
|
"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_description": "Configure audio input and output settings for your JetKVM device",
|
||||||
"audio_settings_dtx_description": "Save bandwidth during silence",
|
"audio_settings_dtx_description": "Save bandwidth during silence",
|
||||||
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
|
"audio_settings_dtx_title": "DTX (Discontinuous Transmission)",
|
||||||
"audio_settings_fec_description": "Improve audio quality on lossy connections",
|
"audio_settings_fec_description": "Improve audio quality on lossy connections",
|
||||||
"audio_settings_fec_title": "FEC (Forward Error Correction)",
|
"audio_settings_fec_title": "FEC (Forward Error Correction)",
|
||||||
"audio_settings_hdmi_label": "HDMI",
|
"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_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_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_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_success": "Audio output source updated. Audio will start in 30-60 seconds.",
|
||||||
"audio_settings_output_source_title": "Audio Output Source",
|
"audio_settings_output_source_title": "Audio Output Source",
|
||||||
"audio_settings_output_title": "Audio Output",
|
"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_title": "Audio",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Audio from target to speakers",
|
"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": "Remote IP Address",
|
||||||
"connection_stats_remote_ip_address_copy_error": "Failed to copy 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_copy_success": "Remote IP address { ip } copied to clipboard",
|
||||||
|
"connection_stats_remote_ip_address_description": "The IP address of the remote device.",
|
||||||
"connection_stats_round_trip_time": "Round-Trip Time",
|
"connection_stats_round_trip_time": "Round-Trip Time",
|
||||||
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
|
"connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
|
||||||
"connection_stats_sidebar": "Connection Stats",
|
"connection_stats_sidebar": "Connection Stats",
|
||||||
|
|
@ -332,6 +307,7 @@
|
||||||
"general_auto_update_description": "Automatically update the device to the latest version",
|
"general_auto_update_description": "Automatically update the device to the latest version",
|
||||||
"general_auto_update_error": "Failed to set auto-update: {error}",
|
"general_auto_update_error": "Failed to set auto-update: {error}",
|
||||||
"general_auto_update_title": "Auto Update",
|
"general_auto_update_title": "Auto Update",
|
||||||
|
"general_check_for_stable_updates": "Downgrade",
|
||||||
"general_check_for_updates": "Check for Updates",
|
"general_check_for_updates": "Check for Updates",
|
||||||
"general_page_description": "Configure device settings and update preferences",
|
"general_page_description": "Configure device settings and update preferences",
|
||||||
"general_reboot_description": "Do you want to proceed with rebooting the system?",
|
"general_reboot_description": "Do you want to proceed with rebooting the system?",
|
||||||
|
|
@ -352,9 +328,13 @@
|
||||||
"general_update_checking_title": "Checking for updates…",
|
"general_update_checking_title": "Checking for updates…",
|
||||||
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
|
"general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
|
||||||
"general_update_completed_title": "Update Completed Successfully",
|
"general_update_completed_title": "Update Completed Successfully",
|
||||||
|
"general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.",
|
||||||
|
"general_update_downgrade_available_title": "Downgrade Available",
|
||||||
|
"general_update_downgrade_button": "Downgrade Now",
|
||||||
"general_update_error_description": "An error occurred while updating your device. Please try again later.",
|
"general_update_error_description": "An error occurred while updating your device. Please try again later.",
|
||||||
"general_update_error_details": "Error details: {errorMessage}",
|
"general_update_error_details": "Error details: {errorMessage}",
|
||||||
"general_update_error_title": "Update Error",
|
"general_update_error_title": "Update Error",
|
||||||
|
"general_update_keep_current_button": "Keep Current Version",
|
||||||
"general_update_later_button": "Do it later",
|
"general_update_later_button": "Do it later",
|
||||||
"general_update_now_button": "Update Now",
|
"general_update_now_button": "Update Now",
|
||||||
"general_update_rebooting": "Rebooting to complete the update…",
|
"general_update_rebooting": "Rebooting to complete the update…",
|
||||||
|
|
@ -370,6 +350,7 @@
|
||||||
"general_update_up_to_date_title": "System is up to date",
|
"general_update_up_to_date_title": "System is up to date",
|
||||||
"general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
|
"general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
|
||||||
"general_update_updating_title": "Updating your device",
|
"general_update_updating_title": "Updating your device",
|
||||||
|
"general_update_will_disable_auto_update_description": "You're about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.",
|
||||||
"getting_remote_session_description": "Getting remote session description attempt {attempt}",
|
"getting_remote_session_description": "Getting remote session description attempt {attempt}",
|
||||||
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
|
"hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
|
||||||
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
|
"hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
|
||||||
|
|
|
||||||
|
|
@ -49,50 +49,6 @@
|
||||||
"access_update_tls_settings": "Actualizar la configuración de TLS",
|
"access_update_tls_settings": "Actualizar la configuración de TLS",
|
||||||
"action_bar_audio": "Audio",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Estadísticas de conexión",
|
"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_extension": "Extensión",
|
||||||
"action_bar_fullscreen": "Pantalla completa",
|
"action_bar_fullscreen": "Pantalla completa",
|
||||||
"action_bar_settings": "Ajustes",
|
"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_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_source_title": "Fuente de salida de audio",
|
||||||
"audio_settings_output_title": "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_title": "Audio",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Audio del objetivo a los altavoces",
|
"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_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_description": "Habilitar el puntero absoluto del ratón",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Habilitar audio USB",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Habilitar el teclado",
|
"usb_device_enable_keyboard_description": "Habilitar el teclado",
|
||||||
"usb_device_enable_keyboard_title": "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.",
|
"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_load": "No se pudieron cargar los dispositivos USB: {error}",
|
||||||
"usb_device_failed_set": "No se pudieron configurar 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_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_keyboard_only": "Sólo teclado",
|
||||||
"usb_device_restore_default": "Restaurar a valores predeterminados",
|
"usb_device_restore_default": "Restaurar a valores predeterminados",
|
||||||
"usb_device_title": "Dispositivo USB",
|
"usb_device_title": "Dispositivo USB",
|
||||||
|
|
|
||||||
|
|
@ -49,50 +49,6 @@
|
||||||
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
|
"access_update_tls_settings": "Mettre à jour les paramètres TLS",
|
||||||
"action_bar_audio": "Audio",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Statistiques de connexion",
|
"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_extension": "Extension",
|
||||||
"action_bar_fullscreen": "Plein écran",
|
"action_bar_fullscreen": "Plein écran",
|
||||||
"action_bar_settings": "Paramètres",
|
"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_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_source_title": "Source de sortie audio",
|
||||||
"audio_settings_output_title": "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_title": "Audio",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Audio de la cible vers les haut-parleurs",
|
"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_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_description": "Activer la souris absolue (pointeur)",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Activer l'audio USB",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Activer le clavier",
|
"usb_device_enable_keyboard_description": "Activer le clavier",
|
||||||
"usb_device_enable_keyboard_title": "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",
|
"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_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_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_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_keyboard_only": "Clavier uniquement",
|
||||||
"usb_device_restore_default": "Restaurer les paramètres par défaut",
|
"usb_device_restore_default": "Restaurer les paramètres par défaut",
|
||||||
"usb_device_title": "périphérique USB",
|
"usb_device_title": "périphérique USB",
|
||||||
|
|
|
||||||
|
|
@ -49,50 +49,6 @@
|
||||||
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
|
"access_update_tls_settings": "Aggiorna le impostazioni TLS",
|
||||||
"action_bar_audio": "Audio",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Statistiche di connessione",
|
"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_extension": "Estensione",
|
||||||
"action_bar_fullscreen": "A schermo intero",
|
"action_bar_fullscreen": "A schermo intero",
|
||||||
"action_bar_settings": "Impostazioni",
|
"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_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_source_title": "Sorgente di uscita audio",
|
||||||
"audio_settings_output_title": "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_title": "Audio",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Audio dal target agli altoparlanti",
|
"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_description": "Dispositivi USB da emulare sul computer di destinazione",
|
||||||
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
|
"usb_device_enable_absolute_mouse_description": "Abilita mouse assoluto (puntatore)",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Abilita audio USB",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Abilita tastiera",
|
"usb_device_enable_keyboard_description": "Abilita tastiera",
|
||||||
"usb_device_enable_keyboard_title": "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",
|
"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_load": "Impossibile caricare i dispositivi USB: {error}",
|
||||||
"usb_device_failed_set": "Impossibile impostare 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_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_keyboard_only": "Solo tastiera",
|
||||||
"usb_device_restore_default": "Ripristina impostazioni predefinite",
|
"usb_device_restore_default": "Ripristina impostazioni predefinite",
|
||||||
"usb_device_title": "Dispositivo USB",
|
"usb_device_title": "Dispositivo USB",
|
||||||
|
|
|
||||||
|
|
@ -47,52 +47,8 @@
|
||||||
"access_tls_self_signed": "Selvsignert",
|
"access_tls_self_signed": "Selvsignert",
|
||||||
"access_tls_updated": "TLS-innstillingene er oppdatert",
|
"access_tls_updated": "TLS-innstillingene er oppdatert",
|
||||||
"access_update_tls_settings": "Oppdater TLS-innstillinger",
|
"access_update_tls_settings": "Oppdater TLS-innstillinger",
|
||||||
"action_bar_audio": "Lyd",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "Tilkoblingsstatistikk",
|
"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_extension": "Forlengelse",
|
||||||
"action_bar_fullscreen": "Fullskjerm",
|
"action_bar_fullscreen": "Fullskjerm",
|
||||||
"action_bar_settings": "Innstillinger",
|
"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_success": "Lydutgangskilde oppdatert. Lyd starter om 30-60 sekunder.",
|
||||||
"audio_settings_output_source_title": "Lydutgangskilde",
|
"audio_settings_output_source_title": "Lydutgangskilde",
|
||||||
"audio_settings_output_title": "Lydutgang",
|
"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_title": "Lyd",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Lyd fra mål til høyttalere",
|
"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_description": "USB-enheter som skal emuleres på måldatamaskinen",
|
||||||
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
|
"usb_device_enable_absolute_mouse_description": "Aktiver absolutt mus (peker)",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Aktiver USB-lyd",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktiver tastatur",
|
"usb_device_enable_keyboard_description": "Aktiver tastatur",
|
||||||
"usb_device_enable_keyboard_title": "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.",
|
"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_load": "Klarte ikke å laste inn USB-enheter: {error}",
|
||||||
"usb_device_failed_set": "Kunne ikke angi 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_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_keyboard_only": "Kun tastatur",
|
||||||
"usb_device_restore_default": "Gjenopprett til standard",
|
"usb_device_restore_default": "Gjenopprett til standard",
|
||||||
"usb_device_title": "USB-enhet",
|
"usb_device_title": "USB-enhet",
|
||||||
|
|
|
||||||
|
|
@ -47,51 +47,7 @@
|
||||||
"access_tls_self_signed": "Självsignerad",
|
"access_tls_self_signed": "Självsignerad",
|
||||||
"access_tls_updated": "TLS-inställningarna har uppdaterats",
|
"access_tls_updated": "TLS-inställningarna har uppdaterats",
|
||||||
"access_update_tls_settings": "Uppdatera TLS-inställningar",
|
"access_update_tls_settings": "Uppdatera TLS-inställningar",
|
||||||
"action_bar_audio": "Ljud",
|
"action_bar_audio": "Audio",
|
||||||
"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_extension": "Förlängning",
|
"action_bar_extension": "Förlängning",
|
||||||
"action_bar_fullscreen": "Helskärm",
|
"action_bar_fullscreen": "Helskärm",
|
||||||
"action_bar_settings": "Inställningar",
|
"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_success": "Ljudutgångskälla uppdaterad. Ljud startar om 30-60 sekunder.",
|
||||||
"audio_settings_output_source_title": "Ljudutgångskälla",
|
"audio_settings_output_source_title": "Ljudutgångskälla",
|
||||||
"audio_settings_output_title": "Ljudutgång",
|
"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_title": "Ljud",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "Ljud från mål till högtalare",
|
"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_description": "USB-enheter att emulera på måldatorn",
|
||||||
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
|
"usb_device_enable_absolute_mouse_description": "Aktivera absolut mus (pekare)",
|
||||||
"usb_device_enable_absolute_mouse_title": "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_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "Aktivera USB-ljud",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
|
"usb_device_enable_keyboard_description": "Aktivera tangentbord",
|
||||||
"usb_device_enable_keyboard_title": "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.",
|
"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_load": "Misslyckades med att ladda USB-enheter: {error}",
|
||||||
"usb_device_failed_set": "Misslyckades med att ställa in 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_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_keyboard_only": "Endast tangentbord",
|
||||||
"usb_device_restore_default": "Återställ till standard",
|
"usb_device_restore_default": "Återställ till standard",
|
||||||
"usb_device_title": "USB-enhet",
|
"usb_device_title": "USB-enhet",
|
||||||
|
|
|
||||||
|
|
@ -47,52 +47,8 @@
|
||||||
"access_tls_self_signed": "自签名",
|
"access_tls_self_signed": "自签名",
|
||||||
"access_tls_updated": "TLS 设置更新成功",
|
"access_tls_updated": "TLS 设置更新成功",
|
||||||
"access_update_tls_settings": "更新 TLS 设置",
|
"access_update_tls_settings": "更新 TLS 设置",
|
||||||
"action_bar_audio": "音频",
|
"action_bar_audio": "Audio",
|
||||||
"action_bar_connection_stats": "连接统计",
|
"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_extension": "扩展",
|
||||||
"action_bar_fullscreen": "全屏",
|
"action_bar_fullscreen": "全屏",
|
||||||
"action_bar_settings": "设置",
|
"action_bar_settings": "设置",
|
||||||
|
|
@ -201,6 +157,10 @@
|
||||||
"audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。",
|
"audio_settings_output_source_success": "音频输出源已更新。音频将在30-60秒内启动。",
|
||||||
"audio_settings_output_source_title": "音频输出源",
|
"audio_settings_output_source_title": "音频输出源",
|
||||||
"audio_settings_output_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_title": "音频",
|
||||||
"audio_settings_usb_label": "USB",
|
"audio_settings_usb_label": "USB",
|
||||||
"audio_speakers_description": "从目标设备到扬声器的音频",
|
"audio_speakers_description": "从目标设备到扬声器的音频",
|
||||||
|
|
@ -881,8 +841,8 @@
|
||||||
"usb_device_description": "在目标计算机上仿真的 USB 设备",
|
"usb_device_description": "在目标计算机上仿真的 USB 设备",
|
||||||
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
|
"usb_device_enable_absolute_mouse_description": "启用绝对鼠标(指针)",
|
||||||
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
|
"usb_device_enable_absolute_mouse_title": "启用绝对鼠标(指针)",
|
||||||
"usb_device_enable_audio_description": "启用双向音频",
|
"usb_device_enable_audio_description": "Enable bidirectional audio",
|
||||||
"usb_device_enable_audio_title": "启用 USB 音频",
|
"usb_device_enable_audio_title": "Enable USB Audio",
|
||||||
"usb_device_enable_keyboard_description": "启用键盘",
|
"usb_device_enable_keyboard_description": "启用键盘",
|
||||||
"usb_device_enable_keyboard_title": "启用键盘",
|
"usb_device_enable_keyboard_title": "启用键盘",
|
||||||
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
|
"usb_device_enable_mass_storage_description": "有时可能需要禁用它以防止某些设备出现问题",
|
||||||
|
|
@ -892,7 +852,7 @@
|
||||||
"usb_device_failed_load": "无法加载 USB 设备: {error}",
|
"usb_device_failed_load": "无法加载 USB 设备: {error}",
|
||||||
"usb_device_failed_set": "无法设置 USB 设备: {error}",
|
"usb_device_failed_set": "无法设置 USB 设备: {error}",
|
||||||
"usb_device_keyboard_mouse_and_mass_storage": "键盘、鼠标和大容量存储器",
|
"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_keyboard_only": "仅限键盘",
|
||||||
"usb_device_restore_default": "恢复默认设置",
|
"usb_device_restore_default": "恢复默认设置",
|
||||||
"usb_device_title": "USB 设备",
|
"usb_device_title": "USB 设备",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
import { LuInfo } from "react-icons/lu";
|
|
||||||
|
|
||||||
import { Button } from "@/components/Button";
|
import { Button } from "@/components/Button";
|
||||||
import Card, { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
|
||||||
import { useVersion } from "@/hooks/useVersion";
|
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) {
|
export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const { appVersion } = useVersion();
|
const { appVersion } = useVersion();
|
||||||
const { systemVersion } = useDeviceStore();
|
const { systemVersion } = useDeviceStore();
|
||||||
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
|
const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
|
||||||
const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false);
|
|
||||||
|
|
||||||
const getReasonCopy = () => {
|
const getReasonCopy = () => {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
|
|
@ -115,7 +86,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
notifications.success("Crash logs downloaded successfully");
|
notifications.success("Crash logs downloaded successfully");
|
||||||
setHasDownloadedLogs(true);
|
|
||||||
|
|
||||||
// Open GitHub issue
|
// Open GitHub issue
|
||||||
const issueBody = `## Issue Description
|
const issueBody = `## Issue Description
|
||||||
|
|
@ -146,7 +116,7 @@ Please attach the recovery logs file that was downloaded to your computer:
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDowngrade = () => {
|
const handleDowngrade = () => {
|
||||||
navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`);
|
navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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" />
|
<div className="h-8 w-px bg-slate-200 dark:bg-slate-700 block" />
|
||||||
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigateTo("/settings/general/reboot")}
|
onClick={() => navigateTo("/settings/general/reboot")}
|
||||||
theme="light"
|
theme="light"
|
||||||
size="SM"
|
size="SM"
|
||||||
text="Reboot Device"
|
text="Reboot Device"
|
||||||
disabled={!hasDownloadedLogs}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Tooltip text="Download logs first" show={!hasDownloadedLogs}>
|
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
onClick={handleDowngrade}
|
onClick={handleDowngrade}
|
||||||
theme="light"
|
theme="light"
|
||||||
text={`Downgrade to v${DOWNGRADE_VERSION}`}
|
text={`Downgrade to v${DOWNGRADE_VERSION}`}
|
||||||
disabled={!hasDownloadedLogs}
|
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
|
||||||
</div>
|
</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"
|
controlsList="nofullscreen"
|
||||||
style={videoStyle}
|
style={videoStyle}
|
||||||
className={cx(
|
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,
|
"cursor-none": settings.isCursorHidden,
|
||||||
"!opacity-0":
|
"!opacity-0":
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
|
||||||
|
export interface VersionInfo {
|
||||||
|
appVersion: string;
|
||||||
|
systemVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemVersionInfo {
|
||||||
|
local: VersionInfo;
|
||||||
|
remote?: VersionInfo;
|
||||||
|
systemUpdateAvailable: boolean;
|
||||||
|
appUpdateAvailable: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function useVersion() {
|
export function useVersion() {
|
||||||
const {
|
const {
|
||||||
appVersion,
|
appVersion,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { SettingsItem } from "@components/SettingsItem";
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||||
|
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
import api from "@/api";
|
import api from "@/api";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
@ -237,13 +238,11 @@ export default function SettingsAccessIndexRoute() {
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
{tlsMode === "custom" && (
|
{tlsMode === "custom" && (
|
||||||
<div className="mt-4 space-y-4">
|
<NestedSettingsGroup className="mt-4">
|
||||||
<div className="space-y-4">
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.access_tls_certificate_title()}
|
title={m.access_tls_certificate_title()}
|
||||||
description={m.access_tls_certificate_description()}
|
description={m.access_tls_certificate_description()}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
label={m.access_certificate_label()}
|
label={m.access_certificate_label()}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
|
@ -253,10 +252,6 @@ export default function SettingsAccessIndexRoute() {
|
||||||
value={tlsCert}
|
value={tlsCert}
|
||||||
onChange={e => handleTlsCertChange(e.target.value)}
|
onChange={e => handleTlsCertChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-4">
|
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
label={m.access_private_key_label()}
|
label={m.access_private_key_label()}
|
||||||
description={m.access_private_key_description()}
|
description={m.access_private_key_description()}
|
||||||
|
|
@ -267,9 +262,6 @@ export default function SettingsAccessIndexRoute() {
|
||||||
value={tlsKey}
|
value={tlsKey}
|
||||||
onChange={e => handleTlsKeyChange(e.target.value)}
|
onChange={e => handleTlsKeyChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<Button
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
|
|
@ -278,7 +270,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
onClick={handleCustomTlsUpdate}
|
onClick={handleCustomTlsUpdate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NestedSettingsGroup>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
|
|
@ -352,7 +344,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
{selectedProvider === "custom" && (
|
{selectedProvider === "custom" && (
|
||||||
<div className="mt-4 space-y-4">
|
<NestedSettingsGroup className="mt-4">
|
||||||
<div className="flex items-end gap-x-2">
|
<div className="flex items-end gap-x-2">
|
||||||
<InputFieldWithLabel
|
<InputFieldWithLabel
|
||||||
size="SM"
|
size="SM"
|
||||||
|
|
@ -371,7 +363,7 @@ export default function SettingsAccessIndexRoute() {
|
||||||
placeholder="https://app.example.com"
|
placeholder="https://app.example.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NestedSettingsGroup>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,30 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
import { useSettingsStore } from "@hooks/stores";
|
import { useSettingsStore } from "@hooks/stores";
|
||||||
import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
|
||||||
|
import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
|
||||||
import { Button } from "@components/Button";
|
import { Button } from "@components/Button";
|
||||||
import Checkbox from "@components/Checkbox";
|
import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
|
||||||
import { ConfirmDialog } from "@components/ConfirmDialog";
|
import { ConfirmDialog } from "@components/ConfirmDialog";
|
||||||
import { GridCard } from "@components/Card";
|
import { GridCard } from "@components/Card";
|
||||||
import { SettingsItem } from "@components/SettingsItem";
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
|
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||||
import { TextAreaWithLabel } from "@components/TextArea";
|
import { TextAreaWithLabel } from "@components/TextArea";
|
||||||
|
import { InputFieldWithLabel } from "@components/InputField";
|
||||||
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { isOnDevice } from "@/main";
|
import { isOnDevice } from "@/main";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
import { sleep } from "@/utils";
|
import { sleep } from "@/utils";
|
||||||
|
import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
|
||||||
|
import { SystemVersionInfo } from "@hooks/useVersion";
|
||||||
|
|
||||||
|
import { FeatureFlag } from "../components/FeatureFlag";
|
||||||
|
|
||||||
export default function SettingsAdvancedRoute() {
|
export default function SettingsAdvancedRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
||||||
const [sshKey, setSSHKey] = useState<string>("");
|
const [sshKey, setSSHKey] = useState<string>("");
|
||||||
const { setDeveloperMode } = useSettingsStore();
|
const { setDeveloperMode } = useSettingsStore();
|
||||||
|
|
@ -23,7 +32,12 @@ export default function SettingsAdvancedRoute() {
|
||||||
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
|
||||||
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
|
||||||
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
|
||||||
|
const [updateTarget, setUpdateTarget] = useState<string>("app");
|
||||||
|
const [appVersion, setAppVersion] = useState<string>("");
|
||||||
|
const [systemVersion, setSystemVersion] = useState<string>("");
|
||||||
|
const [resetConfig, setResetConfig] = useState(false);
|
||||||
|
const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
|
||||||
|
const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false);
|
||||||
const settings = useSettingsStore();
|
const settings = useSettingsStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -173,6 +187,61 @@ export default function SettingsAdvancedRoute() {
|
||||||
setShowLoopbackWarning(false);
|
setShowLoopbackWarning(false);
|
||||||
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
}, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
|
||||||
|
|
||||||
|
const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
|
||||||
|
notifications.error(
|
||||||
|
m.advanced_error_version_update({
|
||||||
|
error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error())
|
||||||
|
}),
|
||||||
|
{ duration: 1000 * 15 } // 15 seconds
|
||||||
|
);
|
||||||
|
setCustomVersionUpdateLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCustomVersionUpdate = useCallback(async () => {
|
||||||
|
const components: UpdateComponents = {};
|
||||||
|
if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion;
|
||||||
|
if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion;
|
||||||
|
let versionInfo: SystemVersionInfo | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// we do not need to set it to false if check succeeds,
|
||||||
|
// because it will be redirected to the update page later
|
||||||
|
setCustomVersionUpdateLoading(true);
|
||||||
|
versionInfo = await checkUpdateComponents({
|
||||||
|
components,
|
||||||
|
}, devChannel);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const jsonRpcError = error as JsonRpcError;
|
||||||
|
handleVersionUpdateError(jsonRpcError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasUpdate = false;
|
||||||
|
|
||||||
|
const pageParams = new URLSearchParams();
|
||||||
|
if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) {
|
||||||
|
hasUpdate = true;
|
||||||
|
pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
|
||||||
|
}
|
||||||
|
if (components.system && versionInfo?.remote?.systemVersion && versionInfo?.systemUpdateAvailable) {
|
||||||
|
hasUpdate = true;
|
||||||
|
pageParams.set("custom_system_version", versionInfo.remote?.systemVersion);
|
||||||
|
}
|
||||||
|
pageParams.set("reset_config", resetConfig.toString());
|
||||||
|
|
||||||
|
if (!hasUpdate) {
|
||||||
|
handleVersionUpdateError("No update available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to update page
|
||||||
|
navigateTo(`/settings/general/update?${pageParams.toString()}`);
|
||||||
|
}, [
|
||||||
|
updateTarget, appVersion, systemVersion, devChannel,
|
||||||
|
navigateTo, resetConfig, handleVersionUpdateError,
|
||||||
|
setCustomVersionUpdateLoading
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsPageHeader
|
<SettingsPageHeader
|
||||||
|
|
@ -201,8 +270,8 @@ export default function SettingsAdvancedRoute() {
|
||||||
onChange={e => handleDevModeChange(e.target.checked)}
|
onChange={e => handleDevModeChange(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
{settings.developerMode ? (
|
||||||
{settings.developerMode && (
|
<NestedSettingsGroup>
|
||||||
<GridCard>
|
<GridCard>
|
||||||
<div className="flex items-start gap-x-4 p-4 select-none">
|
<div className="flex items-start gap-x-4 p-4 select-none">
|
||||||
<svg
|
<svg
|
||||||
|
|
@ -235,25 +304,13 @@ export default function SettingsAdvancedRoute() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GridCard>
|
</GridCard>
|
||||||
)}
|
|
||||||
|
|
||||||
<SettingsItem
|
{isOnDevice && (
|
||||||
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 && (
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.advanced_ssh_access_title()}
|
title={m.advanced_ssh_access_title()}
|
||||||
description={m.advanced_ssh_access_description()}
|
description={m.advanced_ssh_access_description()}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4">
|
|
||||||
<TextAreaWithLabel
|
<TextAreaWithLabel
|
||||||
label={m.advanced_ssh_public_key_label()}
|
label={m.advanced_ssh_public_key_label()}
|
||||||
value={sshKey || ""}
|
value={sshKey || ""}
|
||||||
|
|
@ -273,9 +330,104 @@ export default function SettingsAdvancedRoute() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<SettingsItem
|
||||||
title={m.advanced_troubleshooting_mode_title()}
|
title={m.advanced_troubleshooting_mode_title()}
|
||||||
description={m.advanced_troubleshooting_mode_description()}
|
description={m.advanced_troubleshooting_mode_description()}
|
||||||
|
|
@ -289,7 +441,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
|
|
||||||
{settings.debugMode && (
|
{settings.debugMode && (
|
||||||
<>
|
<NestedSettingsGroup>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.advanced_usb_emulation_title()}
|
title={m.advanced_usb_emulation_title()}
|
||||||
description={m.advanced_usb_emulation_description()}
|
description={m.advanced_usb_emulation_description()}
|
||||||
|
|
@ -320,7 +472,7 @@ export default function SettingsAdvancedRoute() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</>
|
</NestedSettingsGroup>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() {
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
const [autoUpdate, setAutoUpdate] = useState(true);
|
const [autoUpdate, setAutoUpdate] = useState(true);
|
||||||
|
|
||||||
const currentVersions = useDeviceStore(state => {
|
const currentVersions = useDeviceStore(state => {
|
||||||
const { appVersion, systemVersion } = state;
|
const { appVersion, systemVersion } = state;
|
||||||
if (!appVersion || !systemVersion) return null;
|
if (!appVersion || !systemVersion) return null;
|
||||||
|
|
@ -85,6 +84,8 @@ export default function SettingsGeneralRoute() {
|
||||||
<div className="space-y-4 pb-2">
|
<div className="space-y-4 pb-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
|
badge="Beta"
|
||||||
|
badgeVariant="info"
|
||||||
title={m.user_interface_language_title()}
|
title={m.user_interface_language_title()}
|
||||||
description={m.user_interface_language_description()}
|
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
|
<Button
|
||||||
size="SM"
|
size="SM"
|
||||||
theme="light"
|
theme="light"
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import LoadingSpinner from "../components/LoadingSpinner";
|
||||||
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
|
||||||
|
|
||||||
// Time to wait after initiating reboot before redirecting to home
|
// Time to wait after initiating reboot before redirecting to home
|
||||||
const REBOOT_REDIRECT_DELAY_MS = 5000;
|
const REBOOT_REDIRECT_DELAY_MS = 7000;
|
||||||
|
|
||||||
export default function SettingsGeneralRebootRoute() {
|
export default function SettingsGeneralRebootRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useLocation, useNavigate } from "react-router";
|
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||||
|
|
||||||
import { useJsonRpc } from "@hooks/useJsonRpc";
|
import { useJsonRpc } from "@hooks/useJsonRpc";
|
||||||
import { UpdateState, useUpdateStore } from "@hooks/stores";
|
import { UpdateState, useUpdateStore } from "@hooks/stores";
|
||||||
|
|
@ -11,16 +11,21 @@ import LoadingSpinner from "@components/LoadingSpinner";
|
||||||
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
|
import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
import { sleep } from "@/utils";
|
import { sleep } from "@/utils";
|
||||||
import { SystemVersionInfo } from "@/utils/jsonrpc";
|
import { checkUpdateComponents, SystemVersionInfo, UpdateComponents, updateParams } from "@/utils/jsonrpc";
|
||||||
|
|
||||||
export default function SettingsGeneralUpdateRoute() {
|
export default function SettingsGeneralUpdateRoute() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const { updateSuccess } = location.state || {};
|
const { updateSuccess } = location.state || {};
|
||||||
|
|
||||||
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
|
const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
|
||||||
const { send } = useJsonRpc();
|
const { send } = useJsonRpc();
|
||||||
|
|
||||||
|
const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || undefined, [searchParams]);
|
||||||
|
const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || undefined, [searchParams]);
|
||||||
|
const resetConfig = useMemo(() => searchParams.get("reset_config") === "true", [searchParams]);
|
||||||
|
|
||||||
const onClose = useCallback(async () => {
|
const onClose = useCallback(async () => {
|
||||||
navigate(".."); // back to the devices.$id.settings page
|
navigate(".."); // back to the devices.$id.settings page
|
||||||
|
|
||||||
|
|
@ -37,6 +42,21 @@ export default function SettingsGeneralUpdateRoute() {
|
||||||
setModalView("updating");
|
setModalView("updating");
|
||||||
}, [send, setModalView, setShouldReload]);
|
}, [send, setModalView, setShouldReload]);
|
||||||
|
|
||||||
|
const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => {
|
||||||
|
const components: UpdateComponents = {};
|
||||||
|
if (appTargetVersion) components.app = appTargetVersion;
|
||||||
|
if (systemTargetVersion) components.system = systemTargetVersion;
|
||||||
|
|
||||||
|
setShouldReload(true);
|
||||||
|
setModalView("updating");
|
||||||
|
|
||||||
|
send("tryUpdateComponents", {
|
||||||
|
params: { components, },
|
||||||
|
includePreRelease: false,
|
||||||
|
resetConfig,
|
||||||
|
});
|
||||||
|
}, [resetConfig, send, setModalView, setShouldReload]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (otaState.updating) {
|
if (otaState.updating) {
|
||||||
setModalView("updating");
|
setModalView("updating");
|
||||||
|
|
@ -49,20 +69,39 @@ export default function SettingsGeneralUpdateRoute() {
|
||||||
}
|
}
|
||||||
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
|
}, [otaState.error, otaState.updating, setModalView, updateSuccess]);
|
||||||
|
|
||||||
return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
|
return <Dialog
|
||||||
|
onClose={onClose}
|
||||||
|
onConfirmUpdate={onConfirmUpdate}
|
||||||
|
onConfirmCustomUpdate={onConfirmCustomUpdate}
|
||||||
|
customAppVersion={customAppVersion}
|
||||||
|
customSystemVersion={customSystemVersion}
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Dialog({
|
export function Dialog({
|
||||||
onClose,
|
onClose,
|
||||||
onConfirmUpdate,
|
onConfirmUpdate,
|
||||||
|
onConfirmCustomUpdate: onConfirmCustomUpdateCallback,
|
||||||
|
customAppVersion,
|
||||||
|
customSystemVersion,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmUpdate: () => void;
|
onConfirmUpdate: () => void;
|
||||||
|
onConfirmCustomUpdate: (appVersion?: string, systemVersion?: string) => void;
|
||||||
|
customAppVersion?: string;
|
||||||
|
customSystemVersion?: string;
|
||||||
}>) {
|
}>) {
|
||||||
const { navigateTo } = useDeviceUiNavigation();
|
const { navigateTo } = useDeviceUiNavigation();
|
||||||
|
|
||||||
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
|
||||||
const { modalView, setModalView, otaState } = useUpdateStore();
|
const { modalView, setModalView, otaState } = useUpdateStore();
|
||||||
|
const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined;
|
||||||
|
const onConfirmCustomUpdate = useCallback(() => {
|
||||||
|
onConfirmCustomUpdateCallback(
|
||||||
|
customAppVersion !== undefined ? versionInfo?.remote?.appVersion : undefined,
|
||||||
|
customSystemVersion !== undefined ? versionInfo?.remote?.systemVersion : undefined,
|
||||||
|
);
|
||||||
|
}, [onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, versionInfo]);
|
||||||
|
|
||||||
const onFinishedLoading = useCallback(
|
const onFinishedLoading = useCallback(
|
||||||
(versionInfo: SystemVersionInfo) => {
|
(versionInfo: SystemVersionInfo) => {
|
||||||
|
|
@ -71,13 +110,13 @@ export function Dialog({
|
||||||
|
|
||||||
setVersionInfo(versionInfo);
|
setVersionInfo(versionInfo);
|
||||||
|
|
||||||
if (hasUpdate) {
|
if (hasUpdate || forceCustomUpdate) {
|
||||||
setModalView("updateAvailable");
|
setModalView("updateAvailable");
|
||||||
} else {
|
} else {
|
||||||
setModalView("upToDate");
|
setModalView("upToDate");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setModalView],
|
[setModalView, forceCustomUpdate],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -92,12 +131,18 @@ export function Dialog({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modalView === "loading" && (
|
{modalView === "loading" && (
|
||||||
<LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
|
<LoadingState
|
||||||
|
onFinished={onFinishedLoading}
|
||||||
|
onCancelCheck={onClose}
|
||||||
|
customAppVersion={customAppVersion}
|
||||||
|
customSystemVersion={customSystemVersion}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modalView === "updateAvailable" && (
|
{modalView === "updateAvailable" && (
|
||||||
<UpdateAvailableState
|
<UpdateAvailableState
|
||||||
onConfirmUpdate={onConfirmUpdate}
|
forceCustomUpdate={forceCustomUpdate}
|
||||||
|
onConfirm={forceCustomUpdate ? onConfirmCustomUpdate : onConfirmUpdate}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
versionInfo={versionInfo!}
|
versionInfo={versionInfo!}
|
||||||
/>
|
/>
|
||||||
|
|
@ -126,9 +171,13 @@ export function Dialog({
|
||||||
function LoadingState({
|
function LoadingState({
|
||||||
onFinished,
|
onFinished,
|
||||||
onCancelCheck,
|
onCancelCheck,
|
||||||
|
customAppVersion,
|
||||||
|
customSystemVersion,
|
||||||
}: {
|
}: {
|
||||||
onFinished: (versionInfo: SystemVersionInfo) => void;
|
onFinished: (versionInfo: SystemVersionInfo) => void;
|
||||||
onCancelCheck: () => void;
|
onCancelCheck: () => void;
|
||||||
|
customAppVersion?: string;
|
||||||
|
customSystemVersion?: string;
|
||||||
}) {
|
}) {
|
||||||
const [progressWidth, setProgressWidth] = useState("0%");
|
const [progressWidth, setProgressWidth] = useState("0%");
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
@ -138,6 +187,17 @@ function LoadingState({
|
||||||
|
|
||||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const checkUpdate = useCallback(async () => {
|
||||||
|
if (!customAppVersion && !customSystemVersion) {
|
||||||
|
return await getVersionInfo();
|
||||||
|
}
|
||||||
|
const params: updateParams = { components: {} as UpdateComponents };
|
||||||
|
if (customAppVersion) params.components!.app = customAppVersion;
|
||||||
|
if (customSystemVersion) params.components!.system = customSystemVersion;
|
||||||
|
|
||||||
|
return await checkUpdateComponents(params, false);
|
||||||
|
}, [customAppVersion, customSystemVersion, getVersionInfo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
abortControllerRef.current = new AbortController();
|
abortControllerRef.current = new AbortController();
|
||||||
const signal = abortControllerRef.current.signal;
|
const signal = abortControllerRef.current.signal;
|
||||||
|
|
@ -147,7 +207,7 @@ function LoadingState({
|
||||||
setProgressWidth("100%");
|
setProgressWidth("100%");
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
getVersionInfo()
|
checkUpdate()
|
||||||
.then(async versionInfo => {
|
.then(async versionInfo => {
|
||||||
// Add a small delay to ensure it's not just flickering
|
// Add a small delay to ensure it's not just flickering
|
||||||
await sleep(600);
|
await sleep(600);
|
||||||
|
|
@ -169,7 +229,7 @@ function LoadingState({
|
||||||
clearTimeout(animationTimer);
|
clearTimeout(animationTimer);
|
||||||
abortControllerRef.current?.abort();
|
abortControllerRef.current?.abort();
|
||||||
};
|
};
|
||||||
}, [getVersionInfo, onFinished, setModalView]);
|
}, [checkUpdate, onFinished, setModalView]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
<div className="flex flex-col items-start justify-start space-y-4 text-left">
|
||||||
|
|
@ -377,11 +437,12 @@ function SystemUpToDateState({
|
||||||
|
|
||||||
function UpdateAvailableState({
|
function UpdateAvailableState({
|
||||||
versionInfo,
|
versionInfo,
|
||||||
onConfirmUpdate,
|
onConfirm,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
versionInfo: SystemVersionInfo;
|
versionInfo: SystemVersionInfo;
|
||||||
onConfirmUpdate: () => void;
|
forceCustomUpdate: boolean;
|
||||||
|
onConfirm: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -396,18 +457,23 @@ function UpdateAvailableState({
|
||||||
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
|
<p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
|
||||||
{versionInfo?.systemUpdateAvailable ? (
|
{versionInfo?.systemUpdateAvailable ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.remote?.systemVersion}
|
<span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.systemVersion}
|
||||||
<br />
|
<br />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{versionInfo?.appUpdateAvailable ? (
|
{versionInfo?.appUpdateAvailable ? (
|
||||||
<>
|
<>
|
||||||
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.remote?.appVersion}
|
<span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.appVersion}
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
{versionInfo?.willDisableAutoUpdate ? (
|
||||||
|
<p className="mb-4 text-sm text-red-600 dark:text-red-400">
|
||||||
|
{m.general_update_will_disable_auto_update_description()}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-start gap-x-2">
|
<div className="flex items-center justify-start gap-x-2">
|
||||||
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirmUpdate} />
|
<Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirm} />
|
||||||
<Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
|
<Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
import { SettingsItem } from "@components/SettingsItem";
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
|
||||||
|
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||||
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
|
||||||
import { UsbInfoSetting } from "@components/UsbInfoSetting";
|
import { UsbInfoSetting } from "@components/UsbInfoSetting";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
|
|
@ -156,7 +157,7 @@ export default function SettingsHardwareRoute() {
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
{backlightSettings.max_brightness != 0 && (
|
{backlightSettings.max_brightness != 0 && (
|
||||||
<>
|
<NestedSettingsGroup>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.hardware_dim_display_after_title()}
|
title={m.hardware_dim_display_after_title()}
|
||||||
description={m.hardware_dim_display_after_description()}
|
description={m.hardware_dim_display_after_description()}
|
||||||
|
|
@ -198,7 +199,7 @@ export default function SettingsHardwareRoute() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingsItem>
|
</SettingsItem>
|
||||||
</>
|
</NestedSettingsGroup>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-slate-600 dark:text-slate-400">
|
<p className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
{m.hardware_display_wake_up_note()}
|
{m.hardware_display_wake_up_note()}
|
||||||
|
|
|
||||||
|
|
@ -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 no critical fields are changed, save immediately
|
||||||
if (changes.length === 0) return onSubmit(settings);
|
if (changes.length === 0) return onSubmit(settings);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
|
||||||
import { SettingsItem } from "@components/SettingsItem";
|
import { SettingsItem } from "@components/SettingsItem";
|
||||||
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
import { SettingsPageHeader } from "@components/SettingsPageheader";
|
||||||
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
import { SelectMenuBasic } from "@components/SelectMenuBasic";
|
||||||
|
import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
|
||||||
import Fieldset from "@components/Fieldset";
|
import Fieldset from "@components/Fieldset";
|
||||||
import notifications from "@/notifications";
|
import notifications from "@/notifications";
|
||||||
import { m } from "@localizations/messages.js";
|
import { m } from "@localizations/messages.js";
|
||||||
|
|
@ -184,7 +185,7 @@ export default function SettingsVideoRoute() {
|
||||||
description={m.video_enhancement_description()}
|
description={m.video_enhancement_description()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="space-y-4 pl-4">
|
<NestedSettingsGroup>
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.video_saturation_title()}
|
title={m.video_saturation_title()}
|
||||||
description={m.video_saturation_description({ value: videoSaturation.toFixed(1) })}
|
description={m.video_saturation_description({ value: videoSaturation.toFixed(1) })}
|
||||||
|
|
@ -242,7 +243,7 @@ export default function SettingsVideoRoute() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</NestedSettingsGroup>
|
||||||
<Fieldset disabled={edidLoading} className="space-y-2">
|
<Fieldset disabled={edidLoading} className="space-y-2">
|
||||||
<SettingsItem
|
<SettingsItem
|
||||||
title={m.video_edid_title()}
|
title={m.video_edid_title()}
|
||||||
|
|
|
||||||
|
|
@ -817,7 +817,14 @@ export default function KvmIdRoute() {
|
||||||
if (resp.method === "willReboot") {
|
if (resp.method === "willReboot") {
|
||||||
const postRebootAction = resp.params as unknown as PostRebootAction;
|
const postRebootAction = resp.params as unknown as PostRebootAction;
|
||||||
console.debug("Setting reboot state", 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("/");
|
navigateTo("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -976,6 +983,10 @@ export default function KvmIdRoute() {
|
||||||
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
|
return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFailsafeMode && failsafeReason) {
|
||||||
|
return <FailSafeModeOverlay reason={failsafeReason} />;
|
||||||
|
}
|
||||||
|
|
||||||
const hasConnectionFailed =
|
const hasConnectionFailed =
|
||||||
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
|
connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
|
||||||
|
|
||||||
|
|
@ -1000,7 +1011,7 @@ export default function KvmIdRoute() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
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 (
|
return (
|
||||||
<FeatureFlagProvider appVersion={appVersion}>
|
<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"
|
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">
|
<div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
|
||||||
{isFailsafeMode && failsafeReason ? (
|
{!!ConnectionStatusElement && ConnectionStatusElement}
|
||||||
<FailSafeModeOverlay reason={failsafeReason} />
|
|
||||||
) : !!ConnectionStatusElement && ConnectionStatusElement}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<SidebarContainer sidebarView={sidebarView} />
|
<SidebarContainer sidebarView={sidebarView} />
|
||||||
|
|
|
||||||
|
|
@ -221,16 +221,21 @@ export interface SystemVersionInfo {
|
||||||
remote?: VersionInfo;
|
remote?: VersionInfo;
|
||||||
systemUpdateAvailable: boolean;
|
systemUpdateAvailable: boolean;
|
||||||
appUpdateAvailable: boolean;
|
appUpdateAvailable: boolean;
|
||||||
|
willDisableAutoUpdate?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UPDATE_STATUS_RPC_TIMEOUT_MS = 10000;
|
||||||
|
const UPDATE_STATUS_RPC_MAX_ATTEMPTS = 6;
|
||||||
|
|
||||||
export async function getUpdateStatus() {
|
export async function getUpdateStatus() {
|
||||||
const response = await callJsonRpc<SystemVersionInfo>({
|
const response = await callJsonRpc<SystemVersionInfo>({
|
||||||
method: "getUpdateStatus",
|
method: "getUpdateStatus",
|
||||||
// This function calls our api server to see if there are any updates available.
|
// This function calls our api server to see if there are any updates available.
|
||||||
// It can be called on page load right after a restart, so we need to give it time to
|
// It can be called on page load right after a restart, so we need to give it time to
|
||||||
// establish a connection to the api server.
|
// establish a connection to the api server.
|
||||||
maxAttempts: 6,
|
maxAttempts: UPDATE_STATUS_RPC_MAX_ATTEMPTS,
|
||||||
|
attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.error) throw response.error;
|
if (response.error) throw response.error;
|
||||||
|
|
@ -242,3 +247,27 @@ export async function getLocalVersion() {
|
||||||
if (response.error) throw response.error;
|
if (response.error) throw response.error;
|
||||||
return response.result;
|
return response.result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateComponent = "app" | "system";
|
||||||
|
export type UpdateComponents = Partial<Record<UpdateComponent, string>>;
|
||||||
|
|
||||||
|
export interface updateParams {
|
||||||
|
components?: UpdateComponents;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
|
||||||
|
const response = await callJsonRpc<SystemVersionInfo>({
|
||||||
|
method: "checkUpdateComponents",
|
||||||
|
params: {
|
||||||
|
params,
|
||||||
|
includePreRelease,
|
||||||
|
},
|
||||||
|
// maxAttempts is set to 1,
|
||||||
|
// because it currently retry for all errors,
|
||||||
|
// and we don't want to retry if the error is not a network error
|
||||||
|
maxAttempts: 1,
|
||||||
|
attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
if (response.error) throw response.error;
|
||||||
|
return response.result;
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ export default defineConfig(({ mode, command }) => {
|
||||||
outdir: "./localization/paraglide",
|
outdir: "./localization/paraglide",
|
||||||
outputStructure: 'message-modules',
|
outputStructure: 'message-modules',
|
||||||
cookieName: 'JETKVM_LOCALE',
|
cookieName: 'JETKVM_LOCALE',
|
||||||
strategy: ['cookie', 'preferredLanguage', 'baseLocale'],
|
strategy: ['cookie', 'baseLocale'],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -289,7 +289,7 @@ func newSession(config SessionConfig) (*Session, error) {
|
||||||
})
|
})
|
||||||
// Wait for channel to be open before sending initial state
|
// Wait for channel to be open before sending initial state
|
||||||
d.OnOpen(func() {
|
d.OnOpen(func() {
|
||||||
triggerOTAStateUpdate()
|
triggerOTAStateUpdate(otaState.ToRPCState())
|
||||||
triggerVideoStateUpdate()
|
triggerVideoStateUpdate()
|
||||||
triggerUSBStateUpdate()
|
triggerUSBStateUpdate()
|
||||||
notifyFailsafeMode(session)
|
notifyFailsafeMode(session)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue