Repository: jetkvm/kvm

Issue:
HidRPC handshake packet being sent multiple times
### Disclaimer

- [x] I have read and understood the disclaimer.

### Application version

0.4.7

### System version

0.2.5

### Device model

None

### Extension model

None

### Remote device Hardware

_No response_

### Remote device OS

_No response_

### Bug description

There are multiple handshakes for hidRpc (as there is no global lock to prevent other `useHidRpc` instances from sending handshake packets). This doesn’t currently have any side effects, but we need to fix it in a later version.

Discussion:

Solution patch:
diff --git a/.devcontainer/install-deps.sh b/.devcontainer/install-deps.sh
index e2ff43e..043c5c4 100755
--- a/.devcontainer/install-deps.sh
+++ b/.devcontainer/install-deps.sh
@@ -18,6 +18,7 @@ sudo apt-get install -y --no-install-recommends \
   build-essential \
   device-tree-compiler \
   gperf g++-multilib gcc-multilib \
+  gdb-multiarch \
   libnl-3-dev libdbus-1-dev libelf-dev libmpc-dev dwarves \
   bc openssl flex bison libssl-dev python3 python-is-python3 texinfo kmod cmake \
   wget zstd \
diff --git a/.vscode/c_cpp_properties.json b/.vscode/c_cpp_properties.json
new file mode 100644
index 0000000..91241db
--- /dev/null
+++ b/.vscode/c_cpp_properties.json
@@ -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
+}
\ No newline at end of file
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 0000000..710996c
--- /dev/null
+++ b/.vscode/launch.json
@@ -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
+        }
+    ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 41aeee5..a610159 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -10,6 +10,19 @@
     ]
   },
   "git.ignoreLimitWarning": true,
-  "cmake.sourceDirectory": "/workspaces/kvm-static-ip/internal/native/cgo",
-  "cmake.ignoreCMakeListsMissing": true
+  "cmake.sourceDirectory": "${workspaceFolder}/internal/native/cgo",
+  "cmake.ignoreCMakeListsMissing": true,
+  "C_Cpp.inlayHints.autoDeclarationTypes.enabled": true,
+  "C_Cpp.inlayHints.parameterNames.enabled": true,
+  "C_Cpp.inlayHints.referenceOperator.enabled": true,
+  "TARGET_IP": "192.168.0.199",
+  "DEBUG_PORT": "2345",
+  "json.schemas": [
+    {
+      "fileMatch": [
+        "/internal/ota/testdata/ota/*.json"
+      ],
+      "url": "./internal/ota/testdata/ota.schema.json"
+    }
+  ]
 }
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..e1f9acc
--- /dev/null
+++ b/.vscode/tasks.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}"
+                }
+            }
+        },
+    ]
+}
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index 0ab65c4..9d3a4b6 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -208,6 +208,12 @@ rm /userdata/kvm_config.json
 systemctl restart jetkvm
 ```
 
+### Debug native code with gdbserver
+
+Change the `TARGET_IP` in `.vscode/settings.json` to your JetKVM device IP, then set breakpoints in your native code and start the `Debug Native` configuration in VSCode.
+
+The code and GDB server will be deployed automatically.
+
 ---
 
 ## Testing Your Changes
diff --git a/Makefile b/Makefile
index e519a75..0390bd0 100644
--- a/Makefile
+++ b/Makefile
@@ -2,8 +2,8 @@ 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
+VERSION_DEV := 0.5.0-dev$(shell date +%Y%m%d%H%M)
+VERSION := 0.4.9
 
 PROMETHEUS_TAG := github.com/prometheus/common/version
 KVM_PKG_NAME := github.com/jetkvm/kvm
@@ -14,6 +14,8 @@ SKIP_NATIVE_IF_EXISTS ?= 0
 SKIP_UI_BUILD ?= 0
 ENABLE_SYNC_TRACE ?= 0
 
+CMAKE_BUILD_TYPE ?= Release
+
 GO_BUILD_ARGS := -tags netgo,timetzdata,nomsgpack
 ifeq ($(ENABLE_SYNC_TRACE), 1)
 	GO_BUILD_ARGS := $(GO_BUILD_ARGS),synctrace
@@ -52,6 +54,7 @@ build_native:
 		echo "Building native..."; \
 			CC="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-gcc" \
 			LD="$(BUILDKIT_PATH)/bin/$(BUILDKIT_FLAVOR)-ld" \
+			CMAKE_BUILD_TYPE=$(CMAKE_BUILD_TYPE) \
 			./scripts/build_cgo.sh; \
 	fi
 
diff --git a/cmd/main.go b/cmd/main.go
index 9a1e189..fcf2cdf 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -13,23 +13,39 @@ import (
 
 	"github.com/erikdubbelboer/gspt"
 	"github.com/jetkvm/kvm"
+	"github.com/jetkvm/kvm/internal/native"
+	"github.com/jetkvm/kvm/internal/supervisor"
 )
 
-const (
-	envChildID        = "JETKVM_CHILD_ID"
-	errorDumpDir      = "/userdata/jetkvm/crashdump"
-	errorDumpLastFile = "last-crash.log"
-	errorDumpTemplate = "jetkvm-%s.log"
+var (
+	subcomponent string
 )
 
 func program() {
-	gspt.SetProcTitle(os.Args[0] + " [app]")
-	kvm.Main()
+	subcomponentOverride := os.Getenv(supervisor.EnvSubcomponent)
+	if subcomponentOverride != "" {
+		subcomponent = subcomponentOverride
+	}
+	switch subcomponent {
+	case "native":
+		native.RunNativeProcess(os.Args[0])
+	default:
+		kvm.Main()
+	}
+}
+
+func setProcTitle(status string) {
+	if status != "" {
+		status = " " + status
+	}
+	title := fmt.Sprintf("jetkvm: [supervisor]%s", status)
+	gspt.SetProcTitle(title)
 }
 
 func main() {
 	versionPtr := flag.Bool("version", false, "print version and exit")
 	versionJSONPtr := flag.Bool("version-json", false, "print version as json and exit")
+	flag.StringVar(&subcomponent, "subcomponent", "", "subcomponent to run")
 	flag.Parse()
 
 	if *versionPtr || *versionJSONPtr {
@@ -42,7 +58,7 @@ func main() {
 		return
 	}
 
-	childID := os.Getenv(envChildID)
+	childID := os.Getenv(supervisor.EnvChildID)
 	switch childID {
 	case "":
 		doSupervise()
@@ -55,6 +71,8 @@ func main() {
 }
 
 func supervise() error {
+	setProcTitle("")
+
 	// check binary path
 	binPath, err := os.Executable()
 	if err != nil {
@@ -74,11 +92,11 @@ func supervise() error {
 	// run the child binary
 	cmd := exec.Command(binPath)
 
-	lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
+	lastFilePath := filepath.Join(supervisor.ErrorDumpDir, supervisor.ErrorDumpLastFile)
 
 	cmd.Env = append(os.Environ(), []string{
-		fmt.Sprintf("%s=%s", envChildID, kvm.GetBuiltAppVersion()),
-		fmt.Sprintf("JETKVM_LAST_ERROR_PATH=%s", lastFilePath),
+		fmt.Sprintf("%s=%s", supervisor.EnvChildID, kvm.GetBuiltAppVersion()),
+		fmt.Sprintf("%s=%s", supervisor.ErrorDumpLastFile, lastFilePath),
 	}...)
 	cmd.Args = os.Args
 
@@ -99,6 +117,8 @@ func supervise() error {
 		return fmt.Errorf("failed to start command: %w", startErr)
 	}
 
+	setProcTitle(fmt.Sprintf("started (pid=%d)", cmd.Process.Pid))
+
 	go func() {
 		sigChan := make(chan os.Signal, 1)
 		signal.Notify(sigChan, syscall.SIGTERM)
@@ -107,8 +127,6 @@ func supervise() error {
 		_ = cmd.Process.Signal(sig)
 	}()
 
-	gspt.SetProcTitle(os.Args[0] + " [sup]")
-
 	cmdErr := cmd.Wait()
 	if cmdErr == nil {
 		return nil
@@ -186,11 +204,11 @@ func renameFile(f *os.File, newName string) error {
 
 func ensureErrorDumpDir() error {
 	// TODO: check if the directory is writable
-	f, err := os.Stat(errorDumpDir)
+	f, err := os.Stat(supervisor.ErrorDumpDir)
 	if err == nil && f.IsDir() {
 		return nil
 	}
-	if err := os.MkdirAll(errorDumpDir, 0755); err != nil {
+	if err := os.MkdirAll(supervisor.ErrorDumpDir, 0755); err != nil {
 		return fmt.Errorf("failed to create error dump directory: %w", err)
 	}
 	return nil
@@ -200,7 +218,7 @@ func createErrorDump(logFile *os.File) {
 	fmt.Println()
 
 	fileName := fmt.Sprintf(
-		errorDumpTemplate,
+		supervisor.ErrorDumpTemplate,
 		time.Now().Format("20060102-150405"),
 	)
 
@@ -210,7 +228,7 @@ func createErrorDump(logFile *os.File) {
 		return
 	}
 
-	filePath := filepath.Join(errorDumpDir, fileName)
+	filePath := filepath.Join(supervisor.ErrorDumpDir, fileName)
 	if err := renameFile(logFile, filePath); err != nil {
 		fmt.Printf("failed to rename file: %v\n", err)
 		return
@@ -218,7 +236,7 @@ func createErrorDump(logFile *os.File) {
 
 	fmt.Printf("error dump copied: %s\n", filePath)
 
-	lastFilePath := filepath.Join(errorDumpDir, errorDumpLastFile)
+	lastFilePath := filepath.Join(supervisor.ErrorDumpDir, supervisor.ErrorDumpLastFile)
 
 	if err := ensureSymlink(filePath, lastFilePath); err != nil {
 		fmt.Printf("failed to create symlink: %v\n", err)
diff --git a/config.go b/config.go
index 5a3e7dc..a06febd 100644
--- a/config.go
+++ b/config.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"os"
 	"strconv"
+	"strings"
 	"sync"
 
 	"github.com/jetkvm/kvm/internal/confparser"
@@ -15,6 +16,10 @@ import (
 	"github.com/prometheus/client_golang/prometheus/promauto"
 )
 
+const (
+	DefaultAPIURL = "https://api.jetkvm.com"
+)
+
 type WakeOnLanDevice struct {
 	Name       string `json:"name"`
 	MacAddress string `json:"macAddress"`
@@ -80,6 +85,7 @@ func (m *KeyboardMacro) Validate() error {
 
 type Config struct {
 	CloudURL             string               `json:"cloud_url"`
+	UpdateAPIURL         string               `json:"update_api_url"`
 	CloudAppURL          string               `json:"cloud_app_url"`
 	CloudToken           string               `json:"cloud_token"`
 	GoogleIdentity       string               `json:"google_identity"`
@@ -107,8 +113,18 @@ type Config struct {
 	DefaultLogLevel      string               `json:"default_log_level"`
 	VideoSleepAfterSec   int                  `json:"video_sleep_after_sec"`
 	VideoQualityFactor   float64              `json:"video_quality_factor"`
+	NativeMaxRestart     uint                 `json:"native_max_restart_attempts"`
+}
+
+// GetUpdateAPIURL returns the update API URL
+func (c *Config) GetUpdateAPIURL() string {
+	if c.UpdateAPIURL == "" {
+		return DefaultAPIURL
+	}
+	return strings.TrimSuffix(c.UpdateAPIURL, "/") + "/releases"
 }
 
+// GetDisplayRotation returns the display rotation
 func (c *Config) GetDisplayRotation() uint16 {
 	rotationInt, err := strconv.ParseUint(c.DisplayRotation, 10, 16)
 	if err != nil {
@@ -118,6 +134,7 @@ func (c *Config) GetDisplayRotation() uint16 {
 	return uint16(rotationInt)
 }
 
+// SetDisplayRotation sets the display rotation
 func (c *Config) SetDisplayRotation(rotation string) error {
 	_, err := strconv.ParseUint(rotation, 10, 16)
 	if err != nil {
@@ -156,7 +173,8 @@ var (
 
 func getDefaultConfig() Config {
 	return Config{
-		CloudURL:             "https://api.jetkvm.com",
+		CloudURL:             DefaultAPIURL,
+		UpdateAPIURL:         DefaultAPIURL,
 		CloudAppURL:          "https://app.jetkvm.com",
 		AutoUpdateEnabled:    true, // Set a default value
 		ActiveExtension:      "",
diff --git a/display.go b/display.go
index 68723b5..bf802e0 100644
--- a/display.go
+++ b/display.go
@@ -232,6 +232,14 @@ func updateStaticContents() {
 	// nativeInstance.UpdateLabelAndChangeVisibility("boot_screen_device_id", GetDeviceID())
 }
 
+// configureDisplayOnNativeRestart is called when the native process restarts
+// it ensures the display is configured correctly after the restart
+func configureDisplayOnNativeRestart() {
+	displayLogger.Info().Msg("native restarted, configuring display")
+	updateStaticContents()
+	requestDisplayUpdate(true, "native_restart")
+}
+
 // setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
 // the backlight brightness of the JetKVM hardware's display.
 func setDisplayBrightness(brightness int, reason string) error {
diff --git a/failsafe.go b/failsafe.go
index 3c6b3d3..d7de1c8 100644
--- a/failsafe.go
+++ b/failsafe.go
@@ -5,6 +5,8 @@ import (
 	"os"
 	"strings"
 	"sync"
+
+	"github.com/jetkvm/kvm/internal/supervisor"
 )
 
 const (
@@ -77,10 +79,16 @@ func checkFailsafeReason() {
 		_ = os.Remove(lastCrashPath)
 
 		// TODO: read the goroutine stack trace and check which goroutine is panicking
+		failsafeModeActive = true
+		if strings.Contains(failsafeCrashLog, supervisor.FailsafeReasonVideoMaxRestartAttemptsReached) {
+			failsafeModeReason = "video"
+			return
+		}
 		if strings.Contains(failsafeCrashLog, "runtime.cgocall") {
-			failsafeModeActive = true
 			failsafeModeReason = "video"
 			return
+		} else {
+			failsafeModeReason = "unknown"
 		}
 	})
 }
diff --git a/go.mod b/go.mod
index 404215b..aac5879 100644
--- a/go.mod
+++ b/go.mod
@@ -44,6 +44,7 @@ require (
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/bytedance/sonic v1.14.0 // indirect
 	github.com/bytedance/sonic/loader v0.3.0 // indirect
+	github.com/caarlos0/env/v11 v11.3.1 // indirect
 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 	github.com/cloudwego/base64x v0.1.6 // indirect
 	github.com/creack/goselect v0.1.2 // indirect
@@ -87,6 +88,7 @@ require (
 	github.com/prometheus/client_model v0.6.2 // indirect
 	github.com/robfig/cron/v3 v3.0.1 // indirect
 	github.com/rogpeppe/go-internal v1.14.1 // indirect
+	github.com/spf13/pflag v1.0.10 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
 	github.com/ugorji/go/codec v1.3.0 // indirect
@@ -97,6 +99,9 @@ require (
 	golang.org/x/oauth2 v0.32.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
 	golang.org/x/text v0.30.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b // indirect
+	google.golang.org/grpc v1.76.0 // indirect
+	google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 // indirect
 	google.golang.org/protobuf v1.36.10 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
 )
diff --git a/go.sum b/go.sum
index 1cb9013..8d33b15 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,8 @@ github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQ
 github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
 github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
 github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
+github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
@@ -173,6 +175,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
 github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
 github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f h1:VgoRCP1efSCEZIcF2THLQ46+pIBzzgNiaUBe9wEDwYU=
 github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzro7BGorij2WgrjEammtrkbo3+xldxo+KaGLGUiD+Q=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -226,6 +230,12 @@ golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
 golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
 golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b h1:zPKJod4w6F1+nRGDI9ubnXYhU9NSWoFAijkHkUXeTK8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
+google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
+google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 h1:F29+wU6Ee6qgu9TddPgooOdaqsxTMunOoj8KA5yuS5A=
+google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1/go.mod h1:5KF+wpkbTSbGcR9zteSqZV6fqFOWBl4Yde8En8MryZA=
 google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
 google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/hw.go b/hw.go
index 7797adc..f6ec57f 100644
--- a/hw.go
+++ b/hw.go
@@ -8,6 +8,8 @@ import (
 	"strings"
 	"sync"
 	"time"
+
+	"github.com/jetkvm/kvm/internal/ota"
 )
 
 func extractSerialNumber() (string, error) {
@@ -29,22 +31,16 @@ func extractSerialNumber() (string, error) {
 	return matches[1], nil
 }
 
-func readOtpEntropy() ([]byte, error) { //nolint:unused
-	content, err := os.ReadFile("/sys/bus/nvmem/devices/rockchip-otp0/nvmem")
-	if err != nil {
-		return nil, err
-	}
-	return content[0x17:0x1C], nil
-}
-
-func hwReboot(force bool, postRebootAction *PostRebootAction, delay time.Duration) error {
-	logger.Info().Msgf("Reboot requested, rebooting in %d seconds...", delay)
+func hwReboot(force bool, postRebootAction *ota.PostRebootAction, delay time.Duration) error {
+	logger.Info().Dur("delayMs", delay).Msg("reboot requested")
 
 	writeJSONRPCEvent("willReboot", postRebootAction, currentSession)
 	time.Sleep(1 * time.Second) // Wait for the JSONRPCEvent to be sent
 
 	nativeInstance.SwitchToScreenIfDifferent("rebooting_screen")
-	time.Sleep(delay - (1 * time.Second)) // wait requested extra settle time
+	if delay > 1*time.Second {
+		time.Sleep(delay - 1*time.Second) // wait requested extra settle time
+	}
 
 	args := []string{}
 	if force {
diff --git a/internal/native/README.md b/internal/native/README.md
new file mode 100644
index 0000000..97a2cf7
--- /dev/null
+++ b/internal/native/README.md
@@ -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
+```
\ No newline at end of file
diff --git a/internal/native/cgo/CMakeLists.txt b/internal/native/cgo/CMakeLists.txt
index 739dabe..c4c01de 100644
--- a/internal/native/cgo/CMakeLists.txt
+++ b/internal/native/cgo/CMakeLists.txt
@@ -42,6 +42,8 @@ FetchContent_MakeAvailable(lvgl)
 # Get source files, excluding CMake generated files
 file(GLOB_RECURSE sources CONFIGURE_DEPENDS "*.c" "ui/*.c")
 list(FILTER sources EXCLUDE REGEX "CMakeFiles.*CompilerId.*\\.c$")
+# Exclude main.c from library sources (it's used for the binary target)
+list(FILTER sources EXCLUDE REGEX "main\\.c$")
 
 add_library(jknative STATIC ${sources} ${CMAKE_CURRENT_SOURCE_DIR}/ctrl.h)
 
@@ -68,4 +70,14 @@ target_link_libraries(jknative PRIVATE
     # libgpiod
 )
 
-install(TARGETS jknative DESTINATION lib)
\ No newline at end of file
+# Binary target using main.c as entry point
+add_executable(jknative-bin ${CMAKE_CURRENT_SOURCE_DIR}/main.c)
+
+# Link the binary to the library (if needed in the future)
+target_link_libraries(jknative-bin PRIVATE
+    jknative
+    pthread
+)
+
+install(TARGETS jknative DESTINATION lib)
+install(TARGETS jknative-bin DESTINATION bin)
\ No newline at end of file
diff --git a/internal/native/cgo/main.c b/internal/native/cgo/main.c
new file mode 100644
index 0000000..a3e0e22
--- /dev/null
+++ b/internal/native/cgo/main.c
@@ -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;
+}
+
diff --git a/internal/native/cgo/main.h b/internal/native/cgo/main.h
new file mode 100644
index 0000000..d9cc6c4
--- /dev/null
+++ b/internal/native/cgo/main.h
@@ -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
\ No newline at end of file
diff --git a/internal/native/cgo/test.patch b/internal/native/cgo/test.patch
new file mode 100644
index 0000000..5f32a35
--- /dev/null
+++ b/internal/native/cgo/test.patch
@@ -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
++}
diff --git a/internal/native/cgo_linux.go b/internal/native/cgo_linux.go
index be1a5a3..b33eb53 100644
--- a/internal/native/cgo_linux.go
+++ b/internal/native/cgo_linux.go
@@ -50,17 +50,9 @@ static inline void jetkvm_cgo_setup_rpc_handler() {
 import "C"
 
 var (
-	cgoLock     sync.Mutex
-	cgoDisabled bool
+	cgoLock sync.Mutex
 )
 
-func setCgoDisabled(disabled bool) {
-	cgoLock.Lock()
-	defer cgoLock.Unlock()
-
-	cgoDisabled = disabled
-}
-
 //export jetkvm_go_video_state_handler
 func jetkvm_go_video_state_handler(state *C.jetkvm_video_state_t) {
 	videoState := VideoState{
@@ -104,10 +96,6 @@ func jetkvm_go_rpc_handler(method *C.cchar_t, params *C.cchar_t) {
 var eventCodeToNameMap = map[int]string{}
 
 func uiEventCodeToName(code int) string {
-	if cgoDisabled {
-		return ""
-	}
-
 	name, ok := eventCodeToNameMap[code]
 	if !ok {
 		cCode := C.int(code)
@@ -120,10 +108,6 @@ func uiEventCodeToName(code int) string {
 }
 
 func setUpNativeHandlers() {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -135,10 +119,6 @@ func setUpNativeHandlers() {
 }
 
 func uiInit(rotation uint16) {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -148,10 +128,6 @@ func uiInit(rotation uint16) {
 }
 
 func uiTick() {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -159,10 +135,6 @@ func uiTick() {
 }
 
 func videoInit(factor float64) error {
-	if cgoDisabled {
-		return nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -176,10 +148,6 @@ func videoInit(factor float64) error {
 }
 
 func videoShutdown() {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -187,10 +155,6 @@ func videoShutdown() {
 }
 
 func videoStart() {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -198,10 +162,6 @@ func videoStart() {
 }
 
 func videoStop() {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -209,10 +169,6 @@ func videoStop() {
 }
 
 func videoLogStatus() string {
-	if cgoDisabled {
-		return ""
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -223,10 +179,6 @@ func videoLogStatus() string {
 }
 
 func uiSetVar(name string, value string) {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -240,10 +192,6 @@ func uiSetVar(name string, value string) {
 }
 
 func uiGetVar(name string) string {
-	if cgoDisabled {
-		return ""
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -254,10 +202,6 @@ func uiGetVar(name string) string {
 }
 
 func uiSwitchToScreen(screen string) {
-	if cgoDisabled {
-		return
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -267,10 +211,6 @@ func uiSwitchToScreen(screen string) {
 }
 
 func uiGetCurrentScreen() string {
-	if cgoDisabled {
-		return ""
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -279,10 +219,6 @@ func uiGetCurrentScreen() string {
 }
 
 func uiObjAddState(objName string, state string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -295,10 +231,6 @@ func uiObjAddState(objName string, state string) (bool, error) {
 }
 
 func uiObjClearState(objName string, state string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -311,10 +243,6 @@ func uiObjClearState(objName string, state string) (bool, error) {
 }
 
 func uiGetLVGLVersion() string {
-	if cgoDisabled {
-		return ""
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -323,10 +251,6 @@ func uiGetLVGLVersion() string {
 
 // TODO: use Enum instead of string but it's not a hot path and performance is not a concern now
 func uiObjAddFlag(objName string, flag string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -339,10 +263,6 @@ func uiObjAddFlag(objName string, flag string) (bool, error) {
 }
 
 func uiObjClearFlag(objName string, flag string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -363,10 +283,6 @@ func uiObjShow(objName string) (bool, error) {
 }
 
 func uiObjSetOpacity(objName string, opacity int) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -378,10 +294,6 @@ func uiObjSetOpacity(objName string, opacity int) (bool, error) {
 }
 
 func uiObjFadeIn(objName string, duration uint32) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -394,10 +306,6 @@ func uiObjFadeIn(objName string, duration uint32) (bool, error) {
 }
 
 func uiObjFadeOut(objName string, duration uint32) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -410,10 +318,6 @@ func uiObjFadeOut(objName string, duration uint32) (bool, error) {
 }
 
 func uiLabelSetText(objName string, text string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -431,10 +335,6 @@ func uiLabelSetText(objName string, text string) (bool, error) {
 }
 
 func uiImgSetSrc(objName string, src string) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -450,10 +350,6 @@ func uiImgSetSrc(objName string, src string) (bool, error) {
 }
 
 func uiDispSetRotation(rotation uint16) (bool, error) {
-	if cgoDisabled {
-		return false, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -466,10 +362,6 @@ func uiDispSetRotation(rotation uint16) (bool, error) {
 }
 
 func videoGetStreamQualityFactor() (float64, error) {
-	if cgoDisabled {
-		return 0, nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -478,10 +370,6 @@ func videoGetStreamQualityFactor() (float64, error) {
 }
 
 func videoSetStreamQualityFactor(factor float64) error {
-	if cgoDisabled {
-		return nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -490,10 +378,6 @@ func videoSetStreamQualityFactor(factor float64) error {
 }
 
 func videoGetEDID() (string, error) {
-	if cgoDisabled {
-		return "", nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
@@ -502,10 +386,6 @@ func videoGetEDID() (string, error) {
 }
 
 func videoSetEDID(edid string) error {
-	if cgoDisabled {
-		return nil
-	}
-
 	cgoLock.Lock()
 	defer cgoLock.Unlock()
 
diff --git a/internal/native/empty.go b/internal/native/empty.go
new file mode 100644
index 0000000..846cf9a
--- /dev/null
+++ b/internal/native/empty.go
@@ -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() {}
diff --git a/internal/native/grpc_client.go b/internal/native/grpc_client.go
new file mode 100644
index 0000000..85a3201
--- /dev/null
+++ b/internal/native/grpc_client.go
@@ -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()
+}
diff --git a/internal/native/grpc_clientmethods.go b/internal/native/grpc_clientmethods.go
new file mode 100644
index 0000000..c9a83ad
--- /dev/null
+++ b/internal/native/grpc_clientmethods.go
@@ -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{})
+}
diff --git a/internal/native/grpc_server.go b/internal/native/grpc_server.go
new file mode 100644
index 0000000..9b54fb5
--- /dev/null
+++ b/internal/native/grpc_server.go
@@ -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
+}
diff --git a/internal/native/grpc_servermethods.go b/internal/native/grpc_servermethods.go
new file mode 100644
index 0000000..cc16dfd
--- /dev/null
+++ b/internal/native/grpc_servermethods.go
@@ -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
+}
diff --git a/internal/native/interface.go b/internal/native/interface.go
new file mode 100644
index 0000000..9974399
--- /dev/null
+++ b/internal/native/interface.go
@@ -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()
+}
diff --git a/internal/native/native.go b/internal/native/native.go
index cb8761c..61c4b0a 100644
--- a/internal/native/native.go
+++ b/internal/native/native.go
@@ -1,6 +1,7 @@
 package native
 
 import (
+	"os"
 	"sync"
 	"time"
 
@@ -9,7 +10,6 @@ import (
 )
 
 type Native struct {
-	disable              bool
 	ready                chan struct{}
 	l                    *zerolog.Logger
 	lD                   *zerolog.Logger
@@ -28,18 +28,23 @@ type Native struct {
 }
 
 type NativeOptions struct {
-	Disable              bool
 	SystemVersion        *semver.Version
 	AppVersion           *semver.Version
 	DisplayRotation      uint16
 	DefaultQualityFactor float64
+	MaxRestartAttempts   uint
 	OnVideoStateChange   func(state VideoState)
 	OnVideoFrameReceived func(frame []byte, duration time.Duration)
 	OnIndevEvent         func(event string)
 	OnRpcEvent           func(event string)
+	OnNativeRestart      func()
 }
 
 func NewNative(opts NativeOptions) *Native {
+	pid := os.Getpid()
+	nativeSubLogger := nativeLogger.With().Int("pid", pid).Str("scope", "native").Logger()
+	displaySubLogger := displayLogger.With().Int("pid", pid).Str("scope", "native").Logger()
+
 	onVideoStateChange := opts.OnVideoStateChange
 	if onVideoStateChange == nil {
 		onVideoStateChange = func(state VideoState) {
@@ -50,7 +55,7 @@ func NewNative(opts NativeOptions) *Native {
 	onVideoFrameReceived := opts.OnVideoFrameReceived
 	if onVideoFrameReceived == nil {
 		onVideoFrameReceived = func(frame []byte, duration time.Duration) {
-			nativeLogger.Info().Interface("frame", frame).Dur("duration", duration).Msg("video frame received")
+			nativeLogger.Trace().Interface("frame", frame).Dur("duration", duration).Msg("video frame received")
 		}
 	}
 
@@ -76,10 +81,9 @@ func NewNative(opts NativeOptions) *Native {
 	}
 
 	return &Native{
-		disable:              opts.Disable,
 		ready:                make(chan struct{}),
-		l:                    nativeLogger,
-		lD:                   displayLogger,
+		l:                    &nativeSubLogger,
+		lD:                   &displaySubLogger,
 		systemVersion:        opts.SystemVersion,
 		appVersion:           opts.AppVersion,
 		displayRotation:      opts.DisplayRotation,
@@ -94,13 +98,7 @@ func NewNative(opts NativeOptions) *Native {
 	}
 }
 
-func (n *Native) Start() {
-	if n.disable {
-		nativeLogger.Warn().Msg("native is disabled, skipping initialization")
-		setCgoDisabled(true)
-		return
-	}
-
+func (n *Native) Start() error {
 	// set up singleton
 	setInstance(n)
 	setUpNativeHandlers()
@@ -117,9 +115,11 @@ func (n *Native) Start() {
 
 	if err := videoInit(n.defaultQualityFactor); err != nil {
 		n.l.Error().Err(err).Msg("failed to initialize video")
+		return err
 	}
 
 	close(n.ready)
+	return nil
 }
 
 // DoNotUseThisIsForCrashTestingOnly
diff --git a/internal/native/proto/README.md b/internal/native/proto/README.md
new file mode 100644
index 0000000..aa619f6
--- /dev/null
+++ b/internal/native/proto/README.md
@@ -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.
+
diff --git a/internal/native/proto/native.pb.go b/internal/native/proto/native.pb.go
new file mode 100644
index 0000000..7eb5fdc
--- /dev/null
+++ b/internal/native/proto/native.pb.go
@@ -0,0 +1,2775 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.36.5
+// 	protoc        v3.21.12
+// source: internal/native/proto/native.proto
+
+package proto
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+	unsafe "unsafe"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Messages
+type Empty struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *Empty) Reset() {
+	*x = Empty{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[0]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *Empty) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Empty) ProtoMessage() {}
+
+func (x *Empty) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[0]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Empty.ProtoReflect.Descriptor instead.
+func (*Empty) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{0}
+}
+
+type IsReadyRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *IsReadyRequest) Reset() {
+	*x = IsReadyRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[1]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *IsReadyRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IsReadyRequest) ProtoMessage() {}
+
+func (x *IsReadyRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[1]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IsReadyRequest.ProtoReflect.Descriptor instead.
+func (*IsReadyRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{1}
+}
+
+type IsReadyResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Ready         bool                   `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
+	Error         string                 `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
+	VideoReady    bool                   `protobuf:"varint,3,opt,name=video_ready,json=videoReady,proto3" json:"video_ready,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *IsReadyResponse) Reset() {
+	*x = IsReadyResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[2]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *IsReadyResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IsReadyResponse) ProtoMessage() {}
+
+func (x *IsReadyResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[2]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IsReadyResponse.ProtoReflect.Descriptor instead.
+func (*IsReadyResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *IsReadyResponse) GetReady() bool {
+	if x != nil {
+		return x.Ready
+	}
+	return false
+}
+
+func (x *IsReadyResponse) GetError() string {
+	if x != nil {
+		return x.Error
+	}
+	return ""
+}
+
+func (x *IsReadyResponse) GetVideoReady() bool {
+	if x != nil {
+		return x.VideoReady
+	}
+	return false
+}
+
+type VideoState struct {
+	state          protoimpl.MessageState `protogen:"open.v1"`
+	Ready          bool                   `protobuf:"varint,1,opt,name=ready,proto3" json:"ready,omitempty"`
+	Error          string                 `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
+	Width          int32                  `protobuf:"varint,3,opt,name=width,proto3" json:"width,omitempty"`
+	Height         int32                  `protobuf:"varint,4,opt,name=height,proto3" json:"height,omitempty"`
+	FramePerSecond float64                `protobuf:"fixed64,5,opt,name=frame_per_second,json=framePerSecond,proto3" json:"frame_per_second,omitempty"`
+	unknownFields  protoimpl.UnknownFields
+	sizeCache      protoimpl.SizeCache
+}
+
+func (x *VideoState) Reset() {
+	*x = VideoState{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[3]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoState) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoState) ProtoMessage() {}
+
+func (x *VideoState) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[3]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoState.ProtoReflect.Descriptor instead.
+func (*VideoState) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *VideoState) GetReady() bool {
+	if x != nil {
+		return x.Ready
+	}
+	return false
+}
+
+func (x *VideoState) GetError() string {
+	if x != nil {
+		return x.Error
+	}
+	return ""
+}
+
+func (x *VideoState) GetWidth() int32 {
+	if x != nil {
+		return x.Width
+	}
+	return 0
+}
+
+func (x *VideoState) GetHeight() int32 {
+	if x != nil {
+		return x.Height
+	}
+	return 0
+}
+
+func (x *VideoState) GetFramePerSecond() float64 {
+	if x != nil {
+		return x.FramePerSecond
+	}
+	return 0
+}
+
+type VideoSetSleepModeRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Enabled       bool                   `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoSetSleepModeRequest) Reset() {
+	*x = VideoSetSleepModeRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[4]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoSetSleepModeRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoSetSleepModeRequest) ProtoMessage() {}
+
+func (x *VideoSetSleepModeRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[4]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoSetSleepModeRequest.ProtoReflect.Descriptor instead.
+func (*VideoSetSleepModeRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *VideoSetSleepModeRequest) GetEnabled() bool {
+	if x != nil {
+		return x.Enabled
+	}
+	return false
+}
+
+type VideoGetSleepModeResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Enabled       bool                   `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoGetSleepModeResponse) Reset() {
+	*x = VideoGetSleepModeResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[5]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoGetSleepModeResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoGetSleepModeResponse) ProtoMessage() {}
+
+func (x *VideoGetSleepModeResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[5]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoGetSleepModeResponse.ProtoReflect.Descriptor instead.
+func (*VideoGetSleepModeResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *VideoGetSleepModeResponse) GetEnabled() bool {
+	if x != nil {
+		return x.Enabled
+	}
+	return false
+}
+
+type VideoSleepModeSupportedResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Supported     bool                   `protobuf:"varint,1,opt,name=supported,proto3" json:"supported,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoSleepModeSupportedResponse) Reset() {
+	*x = VideoSleepModeSupportedResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[6]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoSleepModeSupportedResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoSleepModeSupportedResponse) ProtoMessage() {}
+
+func (x *VideoSleepModeSupportedResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[6]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoSleepModeSupportedResponse.ProtoReflect.Descriptor instead.
+func (*VideoSleepModeSupportedResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *VideoSleepModeSupportedResponse) GetSupported() bool {
+	if x != nil {
+		return x.Supported
+	}
+	return false
+}
+
+type VideoSetQualityFactorRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Factor        float64                `protobuf:"fixed64,1,opt,name=factor,proto3" json:"factor,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoSetQualityFactorRequest) Reset() {
+	*x = VideoSetQualityFactorRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[7]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoSetQualityFactorRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoSetQualityFactorRequest) ProtoMessage() {}
+
+func (x *VideoSetQualityFactorRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[7]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoSetQualityFactorRequest.ProtoReflect.Descriptor instead.
+func (*VideoSetQualityFactorRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *VideoSetQualityFactorRequest) GetFactor() float64 {
+	if x != nil {
+		return x.Factor
+	}
+	return 0
+}
+
+type VideoGetQualityFactorResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Factor        float64                `protobuf:"fixed64,1,opt,name=factor,proto3" json:"factor,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoGetQualityFactorResponse) Reset() {
+	*x = VideoGetQualityFactorResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[8]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoGetQualityFactorResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoGetQualityFactorResponse) ProtoMessage() {}
+
+func (x *VideoGetQualityFactorResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[8]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoGetQualityFactorResponse.ProtoReflect.Descriptor instead.
+func (*VideoGetQualityFactorResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *VideoGetQualityFactorResponse) GetFactor() float64 {
+	if x != nil {
+		return x.Factor
+	}
+	return 0
+}
+
+type VideoSetEDIDRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Edid          string                 `protobuf:"bytes,1,opt,name=edid,proto3" json:"edid,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoSetEDIDRequest) Reset() {
+	*x = VideoSetEDIDRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[9]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoSetEDIDRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoSetEDIDRequest) ProtoMessage() {}
+
+func (x *VideoSetEDIDRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[9]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoSetEDIDRequest.ProtoReflect.Descriptor instead.
+func (*VideoSetEDIDRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *VideoSetEDIDRequest) GetEdid() string {
+	if x != nil {
+		return x.Edid
+	}
+	return ""
+}
+
+type VideoGetEDIDResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Edid          string                 `protobuf:"bytes,1,opt,name=edid,proto3" json:"edid,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoGetEDIDResponse) Reset() {
+	*x = VideoGetEDIDResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[10]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoGetEDIDResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoGetEDIDResponse) ProtoMessage() {}
+
+func (x *VideoGetEDIDResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[10]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoGetEDIDResponse.ProtoReflect.Descriptor instead.
+func (*VideoGetEDIDResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *VideoGetEDIDResponse) GetEdid() string {
+	if x != nil {
+		return x.Edid
+	}
+	return ""
+}
+
+type VideoLogStatusResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Status        string                 `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoLogStatusResponse) Reset() {
+	*x = VideoLogStatusResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[11]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoLogStatusResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoLogStatusResponse) ProtoMessage() {}
+
+func (x *VideoLogStatusResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[11]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoLogStatusResponse.ProtoReflect.Descriptor instead.
+func (*VideoLogStatusResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *VideoLogStatusResponse) GetStatus() string {
+	if x != nil {
+		return x.Status
+	}
+	return ""
+}
+
+type GetLVGLVersionResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Version       string                 `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *GetLVGLVersionResponse) Reset() {
+	*x = GetLVGLVersionResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[12]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *GetLVGLVersionResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetLVGLVersionResponse) ProtoMessage() {}
+
+func (x *GetLVGLVersionResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[12]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetLVGLVersionResponse.ProtoReflect.Descriptor instead.
+func (*GetLVGLVersionResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *GetLVGLVersionResponse) GetVersion() string {
+	if x != nil {
+		return x.Version
+	}
+	return ""
+}
+
+type UIObjHideRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjHideRequest) Reset() {
+	*x = UIObjHideRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[13]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjHideRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjHideRequest) ProtoMessage() {}
+
+func (x *UIObjHideRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[13]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjHideRequest.ProtoReflect.Descriptor instead.
+func (*UIObjHideRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *UIObjHideRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+type UIObjHideResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjHideResponse) Reset() {
+	*x = UIObjHideResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[14]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjHideResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjHideResponse) ProtoMessage() {}
+
+func (x *UIObjHideResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[14]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjHideResponse.ProtoReflect.Descriptor instead.
+func (*UIObjHideResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *UIObjHideResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjShowRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjShowRequest) Reset() {
+	*x = UIObjShowRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[15]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjShowRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjShowRequest) ProtoMessage() {}
+
+func (x *UIObjShowRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[15]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjShowRequest.ProtoReflect.Descriptor instead.
+func (*UIObjShowRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{15}
+}
+
+func (x *UIObjShowRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+type UIObjShowResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjShowResponse) Reset() {
+	*x = UIObjShowResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[16]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjShowResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjShowResponse) ProtoMessage() {}
+
+func (x *UIObjShowResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[16]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjShowResponse.ProtoReflect.Descriptor instead.
+func (*UIObjShowResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{16}
+}
+
+func (x *UIObjShowResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UISetVarRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Name          string                 `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	Value         string                 `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UISetVarRequest) Reset() {
+	*x = UISetVarRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[17]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UISetVarRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UISetVarRequest) ProtoMessage() {}
+
+func (x *UISetVarRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[17]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UISetVarRequest.ProtoReflect.Descriptor instead.
+func (*UISetVarRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{17}
+}
+
+func (x *UISetVarRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *UISetVarRequest) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+type UIGetVarRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Name          string                 `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIGetVarRequest) Reset() {
+	*x = UIGetVarRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[18]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIGetVarRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIGetVarRequest) ProtoMessage() {}
+
+func (x *UIGetVarRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[18]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIGetVarRequest.ProtoReflect.Descriptor instead.
+func (*UIGetVarRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{18}
+}
+
+func (x *UIGetVarRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+type UIGetVarResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Value         string                 `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIGetVarResponse) Reset() {
+	*x = UIGetVarResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[19]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIGetVarResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIGetVarResponse) ProtoMessage() {}
+
+func (x *UIGetVarResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[19]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIGetVarResponse.ProtoReflect.Descriptor instead.
+func (*UIGetVarResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{19}
+}
+
+func (x *UIGetVarResponse) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+type UIObjAddStateRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	State         string                 `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjAddStateRequest) Reset() {
+	*x = UIObjAddStateRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[20]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjAddStateRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjAddStateRequest) ProtoMessage() {}
+
+func (x *UIObjAddStateRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[20]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjAddStateRequest.ProtoReflect.Descriptor instead.
+func (*UIObjAddStateRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{20}
+}
+
+func (x *UIObjAddStateRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjAddStateRequest) GetState() string {
+	if x != nil {
+		return x.State
+	}
+	return ""
+}
+
+type UIObjAddStateResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjAddStateResponse) Reset() {
+	*x = UIObjAddStateResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[21]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjAddStateResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjAddStateResponse) ProtoMessage() {}
+
+func (x *UIObjAddStateResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[21]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjAddStateResponse.ProtoReflect.Descriptor instead.
+func (*UIObjAddStateResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{21}
+}
+
+func (x *UIObjAddStateResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjClearStateRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	State         string                 `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjClearStateRequest) Reset() {
+	*x = UIObjClearStateRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[22]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjClearStateRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjClearStateRequest) ProtoMessage() {}
+
+func (x *UIObjClearStateRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[22]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjClearStateRequest.ProtoReflect.Descriptor instead.
+func (*UIObjClearStateRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{22}
+}
+
+func (x *UIObjClearStateRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjClearStateRequest) GetState() string {
+	if x != nil {
+		return x.State
+	}
+	return ""
+}
+
+type UIObjClearStateResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjClearStateResponse) Reset() {
+	*x = UIObjClearStateResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[23]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjClearStateResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjClearStateResponse) ProtoMessage() {}
+
+func (x *UIObjClearStateResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[23]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjClearStateResponse.ProtoReflect.Descriptor instead.
+func (*UIObjClearStateResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{23}
+}
+
+func (x *UIObjClearStateResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjAddFlagRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Flag          string                 `protobuf:"bytes,2,opt,name=flag,proto3" json:"flag,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjAddFlagRequest) Reset() {
+	*x = UIObjAddFlagRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[24]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjAddFlagRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjAddFlagRequest) ProtoMessage() {}
+
+func (x *UIObjAddFlagRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[24]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjAddFlagRequest.ProtoReflect.Descriptor instead.
+func (*UIObjAddFlagRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{24}
+}
+
+func (x *UIObjAddFlagRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjAddFlagRequest) GetFlag() string {
+	if x != nil {
+		return x.Flag
+	}
+	return ""
+}
+
+type UIObjAddFlagResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjAddFlagResponse) Reset() {
+	*x = UIObjAddFlagResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[25]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjAddFlagResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjAddFlagResponse) ProtoMessage() {}
+
+func (x *UIObjAddFlagResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[25]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjAddFlagResponse.ProtoReflect.Descriptor instead.
+func (*UIObjAddFlagResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{25}
+}
+
+func (x *UIObjAddFlagResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjClearFlagRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Flag          string                 `protobuf:"bytes,2,opt,name=flag,proto3" json:"flag,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjClearFlagRequest) Reset() {
+	*x = UIObjClearFlagRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[26]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjClearFlagRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjClearFlagRequest) ProtoMessage() {}
+
+func (x *UIObjClearFlagRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[26]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjClearFlagRequest.ProtoReflect.Descriptor instead.
+func (*UIObjClearFlagRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{26}
+}
+
+func (x *UIObjClearFlagRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjClearFlagRequest) GetFlag() string {
+	if x != nil {
+		return x.Flag
+	}
+	return ""
+}
+
+type UIObjClearFlagResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjClearFlagResponse) Reset() {
+	*x = UIObjClearFlagResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[27]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjClearFlagResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjClearFlagResponse) ProtoMessage() {}
+
+func (x *UIObjClearFlagResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[27]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjClearFlagResponse.ProtoReflect.Descriptor instead.
+func (*UIObjClearFlagResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{27}
+}
+
+func (x *UIObjClearFlagResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjSetOpacityRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Opacity       int32                  `protobuf:"varint,2,opt,name=opacity,proto3" json:"opacity,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetOpacityRequest) Reset() {
+	*x = UIObjSetOpacityRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[28]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetOpacityRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetOpacityRequest) ProtoMessage() {}
+
+func (x *UIObjSetOpacityRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[28]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetOpacityRequest.ProtoReflect.Descriptor instead.
+func (*UIObjSetOpacityRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{28}
+}
+
+func (x *UIObjSetOpacityRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjSetOpacityRequest) GetOpacity() int32 {
+	if x != nil {
+		return x.Opacity
+	}
+	return 0
+}
+
+type UIObjSetOpacityResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetOpacityResponse) Reset() {
+	*x = UIObjSetOpacityResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[29]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetOpacityResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetOpacityResponse) ProtoMessage() {}
+
+func (x *UIObjSetOpacityResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[29]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetOpacityResponse.ProtoReflect.Descriptor instead.
+func (*UIObjSetOpacityResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{29}
+}
+
+func (x *UIObjSetOpacityResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjFadeInRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Duration      uint32                 `protobuf:"varint,2,opt,name=duration,proto3" json:"duration,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjFadeInRequest) Reset() {
+	*x = UIObjFadeInRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[30]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjFadeInRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjFadeInRequest) ProtoMessage() {}
+
+func (x *UIObjFadeInRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[30]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjFadeInRequest.ProtoReflect.Descriptor instead.
+func (*UIObjFadeInRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{30}
+}
+
+func (x *UIObjFadeInRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjFadeInRequest) GetDuration() uint32 {
+	if x != nil {
+		return x.Duration
+	}
+	return 0
+}
+
+type UIObjFadeInResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjFadeInResponse) Reset() {
+	*x = UIObjFadeInResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[31]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjFadeInResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjFadeInResponse) ProtoMessage() {}
+
+func (x *UIObjFadeInResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[31]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjFadeInResponse.ProtoReflect.Descriptor instead.
+func (*UIObjFadeInResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{31}
+}
+
+func (x *UIObjFadeInResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjFadeOutRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Duration      uint32                 `protobuf:"varint,2,opt,name=duration,proto3" json:"duration,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjFadeOutRequest) Reset() {
+	*x = UIObjFadeOutRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[32]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjFadeOutRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjFadeOutRequest) ProtoMessage() {}
+
+func (x *UIObjFadeOutRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[32]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjFadeOutRequest.ProtoReflect.Descriptor instead.
+func (*UIObjFadeOutRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{32}
+}
+
+func (x *UIObjFadeOutRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjFadeOutRequest) GetDuration() uint32 {
+	if x != nil {
+		return x.Duration
+	}
+	return 0
+}
+
+type UIObjFadeOutResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjFadeOutResponse) Reset() {
+	*x = UIObjFadeOutResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[33]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjFadeOutResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjFadeOutResponse) ProtoMessage() {}
+
+func (x *UIObjFadeOutResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[33]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjFadeOutResponse.ProtoReflect.Descriptor instead.
+func (*UIObjFadeOutResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{33}
+}
+
+func (x *UIObjFadeOutResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjSetLabelTextRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Text          string                 `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetLabelTextRequest) Reset() {
+	*x = UIObjSetLabelTextRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[34]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetLabelTextRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetLabelTextRequest) ProtoMessage() {}
+
+func (x *UIObjSetLabelTextRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[34]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetLabelTextRequest.ProtoReflect.Descriptor instead.
+func (*UIObjSetLabelTextRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{34}
+}
+
+func (x *UIObjSetLabelTextRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjSetLabelTextRequest) GetText() string {
+	if x != nil {
+		return x.Text
+	}
+	return ""
+}
+
+type UIObjSetLabelTextResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetLabelTextResponse) Reset() {
+	*x = UIObjSetLabelTextResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[35]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetLabelTextResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetLabelTextResponse) ProtoMessage() {}
+
+func (x *UIObjSetLabelTextResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[35]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetLabelTextResponse.ProtoReflect.Descriptor instead.
+func (*UIObjSetLabelTextResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{35}
+}
+
+func (x *UIObjSetLabelTextResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UIObjSetImageSrcRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	Image         string                 `protobuf:"bytes,2,opt,name=image,proto3" json:"image,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetImageSrcRequest) Reset() {
+	*x = UIObjSetImageSrcRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[36]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetImageSrcRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetImageSrcRequest) ProtoMessage() {}
+
+func (x *UIObjSetImageSrcRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[36]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetImageSrcRequest.ProtoReflect.Descriptor instead.
+func (*UIObjSetImageSrcRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{36}
+}
+
+func (x *UIObjSetImageSrcRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UIObjSetImageSrcRequest) GetImage() string {
+	if x != nil {
+		return x.Image
+	}
+	return ""
+}
+
+type UIObjSetImageSrcResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UIObjSetImageSrcResponse) Reset() {
+	*x = UIObjSetImageSrcResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[37]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UIObjSetImageSrcResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UIObjSetImageSrcResponse) ProtoMessage() {}
+
+func (x *UIObjSetImageSrcResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[37]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UIObjSetImageSrcResponse.ProtoReflect.Descriptor instead.
+func (*UIObjSetImageSrcResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{37}
+}
+
+func (x *UIObjSetImageSrcResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type DisplaySetRotationRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Rotation      uint32                 `protobuf:"varint,1,opt,name=rotation,proto3" json:"rotation,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DisplaySetRotationRequest) Reset() {
+	*x = DisplaySetRotationRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[38]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DisplaySetRotationRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DisplaySetRotationRequest) ProtoMessage() {}
+
+func (x *DisplaySetRotationRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[38]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DisplaySetRotationRequest.ProtoReflect.Descriptor instead.
+func (*DisplaySetRotationRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{38}
+}
+
+func (x *DisplaySetRotationRequest) GetRotation() uint32 {
+	if x != nil {
+		return x.Rotation
+	}
+	return 0
+}
+
+type DisplaySetRotationResponse struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Success       bool                   `protobuf:"varint,1,opt,name=success,proto3" json:"success,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *DisplaySetRotationResponse) Reset() {
+	*x = DisplaySetRotationResponse{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[39]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *DisplaySetRotationResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DisplaySetRotationResponse) ProtoMessage() {}
+
+func (x *DisplaySetRotationResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[39]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DisplaySetRotationResponse.ProtoReflect.Descriptor instead.
+func (*DisplaySetRotationResponse) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{39}
+}
+
+func (x *DisplaySetRotationResponse) GetSuccess() bool {
+	if x != nil {
+		return x.Success
+	}
+	return false
+}
+
+type UpdateLabelIfChangedRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	NewText       string                 `protobuf:"bytes,2,opt,name=new_text,json=newText,proto3" json:"new_text,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UpdateLabelIfChangedRequest) Reset() {
+	*x = UpdateLabelIfChangedRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[40]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateLabelIfChangedRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateLabelIfChangedRequest) ProtoMessage() {}
+
+func (x *UpdateLabelIfChangedRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[40]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateLabelIfChangedRequest.ProtoReflect.Descriptor instead.
+func (*UpdateLabelIfChangedRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{40}
+}
+
+func (x *UpdateLabelIfChangedRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UpdateLabelIfChangedRequest) GetNewText() string {
+	if x != nil {
+		return x.NewText
+	}
+	return ""
+}
+
+type UpdateLabelAndChangeVisibilityRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ObjName       string                 `protobuf:"bytes,1,opt,name=obj_name,json=objName,proto3" json:"obj_name,omitempty"`
+	NewText       string                 `protobuf:"bytes,2,opt,name=new_text,json=newText,proto3" json:"new_text,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *UpdateLabelAndChangeVisibilityRequest) Reset() {
+	*x = UpdateLabelAndChangeVisibilityRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[41]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *UpdateLabelAndChangeVisibilityRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateLabelAndChangeVisibilityRequest) ProtoMessage() {}
+
+func (x *UpdateLabelAndChangeVisibilityRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[41]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateLabelAndChangeVisibilityRequest.ProtoReflect.Descriptor instead.
+func (*UpdateLabelAndChangeVisibilityRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{41}
+}
+
+func (x *UpdateLabelAndChangeVisibilityRequest) GetObjName() string {
+	if x != nil {
+		return x.ObjName
+	}
+	return ""
+}
+
+func (x *UpdateLabelAndChangeVisibilityRequest) GetNewText() string {
+	if x != nil {
+		return x.NewText
+	}
+	return ""
+}
+
+type SwitchToScreenIfRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ScreenName    string                 `protobuf:"bytes,1,opt,name=screen_name,json=screenName,proto3" json:"screen_name,omitempty"`
+	ShouldSwitch  []string               `protobuf:"bytes,2,rep,name=should_switch,json=shouldSwitch,proto3" json:"should_switch,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *SwitchToScreenIfRequest) Reset() {
+	*x = SwitchToScreenIfRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[42]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *SwitchToScreenIfRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SwitchToScreenIfRequest) ProtoMessage() {}
+
+func (x *SwitchToScreenIfRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[42]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SwitchToScreenIfRequest.ProtoReflect.Descriptor instead.
+func (*SwitchToScreenIfRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{42}
+}
+
+func (x *SwitchToScreenIfRequest) GetScreenName() string {
+	if x != nil {
+		return x.ScreenName
+	}
+	return ""
+}
+
+func (x *SwitchToScreenIfRequest) GetShouldSwitch() []string {
+	if x != nil {
+		return x.ShouldSwitch
+	}
+	return nil
+}
+
+type SwitchToScreenIfDifferentRequest struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	ScreenName    string                 `protobuf:"bytes,1,opt,name=screen_name,json=screenName,proto3" json:"screen_name,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *SwitchToScreenIfDifferentRequest) Reset() {
+	*x = SwitchToScreenIfDifferentRequest{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[43]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *SwitchToScreenIfDifferentRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SwitchToScreenIfDifferentRequest) ProtoMessage() {}
+
+func (x *SwitchToScreenIfDifferentRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[43]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SwitchToScreenIfDifferentRequest.ProtoReflect.Descriptor instead.
+func (*SwitchToScreenIfDifferentRequest) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{43}
+}
+
+func (x *SwitchToScreenIfDifferentRequest) GetScreenName() string {
+	if x != nil {
+		return x.ScreenName
+	}
+	return ""
+}
+
+type Event struct {
+	state protoimpl.MessageState `protogen:"open.v1"`
+	Type  string                 `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"`
+	// Types that are valid to be assigned to Data:
+	//
+	//	*Event_VideoState
+	//	*Event_IndevEvent
+	//	*Event_RpcEvent
+	//	*Event_VideoFrame
+	Data          isEvent_Data `protobuf_oneof:"data"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *Event) Reset() {
+	*x = Event{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[44]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *Event) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Event) ProtoMessage() {}
+
+func (x *Event) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[44]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Event.ProtoReflect.Descriptor instead.
+func (*Event) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{44}
+}
+
+func (x *Event) GetType() string {
+	if x != nil {
+		return x.Type
+	}
+	return ""
+}
+
+func (x *Event) GetData() isEvent_Data {
+	if x != nil {
+		return x.Data
+	}
+	return nil
+}
+
+func (x *Event) GetVideoState() *VideoState {
+	if x != nil {
+		if x, ok := x.Data.(*Event_VideoState); ok {
+			return x.VideoState
+		}
+	}
+	return nil
+}
+
+func (x *Event) GetIndevEvent() string {
+	if x != nil {
+		if x, ok := x.Data.(*Event_IndevEvent); ok {
+			return x.IndevEvent
+		}
+	}
+	return ""
+}
+
+func (x *Event) GetRpcEvent() string {
+	if x != nil {
+		if x, ok := x.Data.(*Event_RpcEvent); ok {
+			return x.RpcEvent
+		}
+	}
+	return ""
+}
+
+func (x *Event) GetVideoFrame() *VideoFrame {
+	if x != nil {
+		if x, ok := x.Data.(*Event_VideoFrame); ok {
+			return x.VideoFrame
+		}
+	}
+	return nil
+}
+
+type isEvent_Data interface {
+	isEvent_Data()
+}
+
+type Event_VideoState struct {
+	VideoState *VideoState `protobuf:"bytes,2,opt,name=video_state,json=videoState,proto3,oneof"`
+}
+
+type Event_IndevEvent struct {
+	IndevEvent string `protobuf:"bytes,3,opt,name=indev_event,json=indevEvent,proto3,oneof"`
+}
+
+type Event_RpcEvent struct {
+	RpcEvent string `protobuf:"bytes,4,opt,name=rpc_event,json=rpcEvent,proto3,oneof"`
+}
+
+type Event_VideoFrame struct {
+	VideoFrame *VideoFrame `protobuf:"bytes,5,opt,name=video_frame,json=videoFrame,proto3,oneof"`
+}
+
+func (*Event_VideoState) isEvent_Data() {}
+
+func (*Event_IndevEvent) isEvent_Data() {}
+
+func (*Event_RpcEvent) isEvent_Data() {}
+
+func (*Event_VideoFrame) isEvent_Data() {}
+
+type VideoFrame struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Frame         []byte                 `protobuf:"bytes,1,opt,name=frame,proto3" json:"frame,omitempty"`
+	DurationNs    int64                  `protobuf:"varint,2,opt,name=duration_ns,json=durationNs,proto3" json:"duration_ns,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *VideoFrame) Reset() {
+	*x = VideoFrame{}
+	mi := &file_internal_native_proto_native_proto_msgTypes[45]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoFrame) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoFrame) ProtoMessage() {}
+
+func (x *VideoFrame) ProtoReflect() protoreflect.Message {
+	mi := &file_internal_native_proto_native_proto_msgTypes[45]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoFrame.ProtoReflect.Descriptor instead.
+func (*VideoFrame) Descriptor() ([]byte, []int) {
+	return file_internal_native_proto_native_proto_rawDescGZIP(), []int{45}
+}
+
+func (x *VideoFrame) GetFrame() []byte {
+	if x != nil {
+		return x.Frame
+	}
+	return nil
+}
+
+func (x *VideoFrame) GetDurationNs() int64 {
+	if x != nil {
+		return x.DurationNs
+	}
+	return 0
+}
+
+var File_internal_native_proto_native_proto protoreflect.FileDescriptor
+
+var file_internal_native_proto_native_proto_rawDesc = string([]byte{
+	0x0a, 0x22, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6e, 0x61, 0x74, 0x69, 0x76,
+	0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x22, 0x07, 0x0a, 0x05,
+	0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x10, 0x0a, 0x0e, 0x49, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x5e, 0x0a, 0x0f, 0x49, 0x73, 0x52, 0x65, 0x61,
+	0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65,
+	0x61, 0x64, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79,
+	0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x5f,
+	0x72, 0x65, 0x61, 0x64, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x76, 0x69, 0x64,
+	0x65, 0x6f, 0x52, 0x65, 0x61, 0x64, 0x79, 0x22, 0x90, 0x01, 0x0a, 0x0a, 0x56, 0x69, 0x64, 0x65,
+	0x6f, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x72, 0x65, 0x61, 0x64, 0x79, 0x12, 0x14, 0x0a, 0x05,
+	0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72,
+	0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x05, 0x52, 0x05, 0x77, 0x69, 0x64, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x69, 0x67,
+	0x68, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74,
+	0x12, 0x28, 0x0a, 0x10, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x5f, 0x70, 0x65, 0x72, 0x5f, 0x73, 0x65,
+	0x63, 0x6f, 0x6e, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0e, 0x66, 0x72, 0x61, 0x6d,
+	0x65, 0x50, 0x65, 0x72, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x22, 0x34, 0x0a, 0x18, 0x56, 0x69,
+	0x64, 0x65, 0x6f, 0x53, 0x65, 0x74, 0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64,
+	0x22, 0x35, 0x0a, 0x19, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x65, 0x65,
+	0x70, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a,
+	0x07, 0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
+	0x65, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x22, 0x3f, 0x0a, 0x1f, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74,
+	0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x75,
+	0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73,
+	0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x22, 0x36, 0x0a, 0x1c, 0x56, 0x69, 0x64, 0x65,
+	0x6f, 0x53, 0x65, 0x74, 0x51, 0x75, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x46, 0x61, 0x63, 0x74, 0x6f,
+	0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x61, 0x63, 0x74,
+	0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01, 0x52, 0x06, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72,
+	0x22, 0x37, 0x0a, 0x1d, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47, 0x65, 0x74, 0x51, 0x75, 0x61, 0x6c,
+	0x69, 0x74, 0x79, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x01, 0x52, 0x06, 0x66, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x22, 0x29, 0x0a, 0x13, 0x56, 0x69, 0x64,
+	0x65, 0x6f, 0x53, 0x65, 0x74, 0x45, 0x44, 0x49, 0x44, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x12, 0x12, 0x0a, 0x04, 0x65, 0x64, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x65, 0x64, 0x69, 0x64, 0x22, 0x2a, 0x0a, 0x14, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47, 0x65, 0x74,
+	0x45, 0x44, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x12, 0x0a, 0x04,
+	0x65, 0x64, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x64, 0x69, 0x64,
+	0x22, 0x30, 0x0a, 0x16, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x4c, 0x6f, 0x67, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74,
+	0x61, 0x74, 0x75, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x22, 0x32, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x4c, 0x56, 0x47, 0x4c, 0x56, 0x65, 0x72,
+	0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07,
+	0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76,
+	0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x2d, 0x0a, 0x10, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x48,
+	0x69, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62,
+	0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62,
+	0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x48, 0x69,
+	0x64, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+	0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+	0x63, 0x65, 0x73, 0x73, 0x22, 0x2d, 0x0a, 0x10, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x68, 0x6f,
+	0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e,
+	0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x11, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x68, 0x6f, 0x77,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63,
+	0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65,
+	0x73, 0x73, 0x22, 0x3b, 0x0a, 0x0f, 0x55, 0x49, 0x53, 0x65, 0x74, 0x56, 0x61, 0x72, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22,
+	0x25, 0x0a, 0x0f, 0x55, 0x49, 0x47, 0x65, 0x74, 0x56, 0x61, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x28, 0x0a, 0x10, 0x55, 0x49, 0x47, 0x65, 0x74, 0x56,
+	0x61, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x22, 0x47, 0x0a, 0x14, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64, 0x64, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e,
+	0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x31, 0x0a, 0x15, 0x55, 0x49, 0x4f,
+	0x62, 0x6a, 0x41, 0x64, 0x64, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0x49, 0x0a, 0x16,
+	0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d,
+	0x65, 0x12, 0x14, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x33, 0x0a, 0x17, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x43, 0x6c, 0x65, 0x61, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0x44, 0x0a, 0x13,
+	0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64, 0x64, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12,
+	0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c,
+	0x61, 0x67, 0x22, 0x30, 0x0a, 0x14, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64, 0x64, 0x46, 0x6c,
+	0x61, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+	0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+	0x63, 0x65, 0x73, 0x73, 0x22, 0x46, 0x0a, 0x15, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65,
+	0x61, 0x72, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a,
+	0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x6c, 0x61, 0x67,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x6c, 0x61, 0x67, 0x22, 0x32, 0x0a, 0x16,
+	0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73,
+	0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
+	0x22, 0x4d, 0x0a, 0x16, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4f, 0x70, 0x61, 0x63,
+	0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62,
+	0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62,
+	0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6f, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x07, 0x6f, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79, 0x22,
+	0x33, 0x0a, 0x17, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4f, 0x70, 0x61, 0x63, 0x69,
+	0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+	0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+	0x63, 0x65, 0x73, 0x73, 0x22, 0x4b, 0x0a, 0x12, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64,
+	0x65, 0x49, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62,
+	0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62,
+	0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x22, 0x2f, 0x0a, 0x13, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x49, 0x6e,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63,
+	0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65,
+	0x73, 0x73, 0x22, 0x4c, 0x0a, 0x13, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x4f,
+	0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a,
+	0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a,
+	0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x22, 0x30, 0x0a, 0x14, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x4f, 0x75, 0x74,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63,
+	0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65,
+	0x73, 0x73, 0x22, 0x49, 0x0a, 0x18, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4c, 0x61,
+	0x62, 0x65, 0x6c, 0x54, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19,
+	0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x65, 0x78,
+	0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x65, 0x78, 0x74, 0x22, 0x35, 0x0a,
+	0x19, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x54, 0x65,
+	0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75,
+	0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63,
+	0x63, 0x65, 0x73, 0x73, 0x22, 0x4a, 0x0a, 0x17, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74,
+	0x49, 0x6d, 0x61, 0x67, 0x65, 0x53, 0x72, 0x63, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6d,
+	0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65,
+	0x22, 0x34, 0x0a, 0x18, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x49, 0x6d, 0x61, 0x67,
+	0x65, 0x53, 0x72, 0x63, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07,
+	0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73,
+	0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0x37, 0x0a, 0x19, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61,
+	0x79, 0x53, 0x65, 0x74, 0x52, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x72, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22,
+	0x36, 0x0a, 0x1a, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x74, 0x52, 0x6f, 0x74,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a,
+	0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07,
+	0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x22, 0x53, 0x0a, 0x1b, 0x55, 0x70, 0x64, 0x61, 0x74,
+	0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x49, 0x66, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d,
+	0x65, 0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x54, 0x65, 0x78, 0x74, 0x22, 0x5d, 0x0a, 0x25,
+	0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x41, 0x6e, 0x64, 0x43, 0x68,
+	0x61, 0x6e, 0x67, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x62, 0x6a, 0x5f, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x62, 0x6a, 0x4e, 0x61, 0x6d, 0x65,
+	0x12, 0x19, 0x0a, 0x08, 0x6e, 0x65, 0x77, 0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x6e, 0x65, 0x77, 0x54, 0x65, 0x78, 0x74, 0x22, 0x5f, 0x0a, 0x17, 0x53,
+	0x77, 0x69, 0x74, 0x63, 0x68, 0x54, 0x6f, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x49, 0x66, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e,
+	0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x63, 0x72,
+	0x65, 0x65, 0x6e, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x68, 0x6f, 0x75, 0x6c,
+	0x64, 0x5f, 0x73, 0x77, 0x69, 0x74, 0x63, 0x68, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c,
+	0x73, 0x68, 0x6f, 0x75, 0x6c, 0x64, 0x53, 0x77, 0x69, 0x74, 0x63, 0x68, 0x22, 0x43, 0x0a, 0x20,
+	0x53, 0x77, 0x69, 0x74, 0x63, 0x68, 0x54, 0x6f, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x49, 0x66,
+	0x44, 0x69, 0x66, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x4e, 0x61, 0x6d,
+	0x65, 0x22, 0xd3, 0x01, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x74,
+	0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12,
+	0x35, 0x0a, 0x0b, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69,
+	0x64, 0x65, 0x6f, 0x53, 0x74, 0x61, 0x74, 0x65, 0x48, 0x00, 0x52, 0x0a, 0x76, 0x69, 0x64, 0x65,
+	0x6f, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x21, 0x0a, 0x0b, 0x69, 0x6e, 0x64, 0x65, 0x76, 0x5f,
+	0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x69,
+	0x6e, 0x64, 0x65, 0x76, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x09, 0x72, 0x70, 0x63,
+	0x5f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x08,
+	0x72, 0x70, 0x63, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x35, 0x0a, 0x0b, 0x76, 0x69, 0x64, 0x65,
+	0x6f, 0x5f, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e,
+	0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x46, 0x72, 0x61, 0x6d,
+	0x65, 0x48, 0x00, 0x52, 0x0a, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x46, 0x72, 0x61, 0x6d, 0x65, 0x42,
+	0x06, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x43, 0x0a, 0x0a, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x46, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0c, 0x52, 0x05, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64,
+	0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4e, 0x73, 0x32, 0xfd, 0x11, 0x0a,
+	0x0d, 0x4e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x3a,
+	0x0a, 0x07, 0x49, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x12, 0x16, 0x2e, 0x6e, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x2e, 0x49, 0x73, 0x52, 0x65, 0x61, 0x64, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x17, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x49, 0x73, 0x52, 0x65, 0x61,
+	0x64, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x44, 0x0a, 0x11, 0x56, 0x69,
+	0x64, 0x65, 0x6f, 0x53, 0x65, 0x74, 0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x12,
+	0x20, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x65,
+	0x74, 0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x12, 0x45, 0x0a, 0x11, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x65, 0x65,
+	0x70, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45,
+	0x6d, 0x70, 0x74, 0x79, 0x1a, 0x21, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69,
+	0x64, 0x65, 0x6f, 0x47, 0x65, 0x74, 0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x17, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74,
+	0x65, 0x64, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74,
+	0x79, 0x1a, 0x27, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x53, 0x6c, 0x65, 0x65, 0x70, 0x4d, 0x6f, 0x64, 0x65, 0x53, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74,
+	0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x15, 0x56, 0x69,
+	0x64, 0x65, 0x6f, 0x53, 0x65, 0x74, 0x51, 0x75, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x46, 0x61, 0x63,
+	0x74, 0x6f, 0x72, 0x12, 0x24, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64,
+	0x65, 0x6f, 0x53, 0x65, 0x74, 0x51, 0x75, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x46, 0x61, 0x63, 0x74,
+	0x6f, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x4d, 0x0a, 0x15, 0x56, 0x69, 0x64, 0x65,
+	0x6f, 0x47, 0x65, 0x74, 0x51, 0x75, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x46, 0x61, 0x63, 0x74, 0x6f,
+	0x72, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x1a, 0x25, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47,
+	0x65, 0x74, 0x51, 0x75, 0x61, 0x6c, 0x69, 0x74, 0x79, 0x46, 0x61, 0x63, 0x74, 0x6f, 0x72, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x53, 0x65, 0x74, 0x45, 0x44, 0x49, 0x44, 0x12, 0x1b, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65,
+	0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x65, 0x74, 0x45, 0x44, 0x49, 0x44, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d,
+	0x70, 0x74, 0x79, 0x12, 0x3b, 0x0a, 0x0c, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x47, 0x65, 0x74, 0x45,
+	0x44, 0x49, 0x44, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70,
+	0x74, 0x79, 0x1a, 0x1c, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65,
+	0x6f, 0x47, 0x65, 0x74, 0x45, 0x44, 0x49, 0x44, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x12, 0x3f, 0x0a, 0x0e, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x4c, 0x6f, 0x67, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74,
+	0x79, 0x1a, 0x1e, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f,
+	0x4c, 0x6f, 0x67, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x29, 0x0a, 0x09, 0x56, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x74, 0x6f, 0x70, 0x12, 0x0d,
+	0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e,
+	0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x2a, 0x0a, 0x0a,
+	0x56, 0x69, 0x64, 0x65, 0x6f, 0x53, 0x74, 0x61, 0x72, 0x74, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x3f, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x4c,
+	0x56, 0x47, 0x4c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x0d, 0x2e, 0x6e, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x1e, 0x2e, 0x6e, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x2e, 0x47, 0x65, 0x74, 0x4c, 0x56, 0x47, 0x4c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x55, 0x49, 0x4f,
+	0x62, 0x6a, 0x48, 0x69, 0x64, 0x65, 0x12, 0x18, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e,
+	0x55, 0x49, 0x4f, 0x62, 0x6a, 0x48, 0x69, 0x64, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x19, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x48,
+	0x69, 0x64, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x09, 0x55,
+	0x49, 0x4f, 0x62, 0x6a, 0x53, 0x68, 0x6f, 0x77, 0x12, 0x18, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76,
+	0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x68, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62,
+	0x6a, 0x53, 0x68, 0x6f, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a,
+	0x08, 0x55, 0x49, 0x53, 0x65, 0x74, 0x56, 0x61, 0x72, 0x12, 0x17, 0x2e, 0x6e, 0x61, 0x74, 0x69,
+	0x76, 0x65, 0x2e, 0x55, 0x49, 0x53, 0x65, 0x74, 0x56, 0x61, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74,
+	0x79, 0x12, 0x3d, 0x0a, 0x08, 0x55, 0x49, 0x47, 0x65, 0x74, 0x56, 0x61, 0x72, 0x12, 0x17, 0x2e,
+	0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x47, 0x65, 0x74, 0x56, 0x61, 0x72, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e,
+	0x55, 0x49, 0x47, 0x65, 0x74, 0x56, 0x61, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x12, 0x4c, 0x0a, 0x0d, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64, 0x64, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x12, 0x1c, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x41, 0x64, 0x64, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x1d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64,
+	0x64, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52,
+	0x0a, 0x0f, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x12, 0x1e, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x43, 0x6c, 0x65, 0x61, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x1f, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x43, 0x6c, 0x65, 0x61, 0x72, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x49, 0x0a, 0x0c, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64, 0x64, 0x46, 0x6c,
+	0x61, 0x67, 0x12, 0x1b, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62,
+	0x6a, 0x41, 0x64, 0x64, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x1c, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x41, 0x64,
+	0x64, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4f, 0x0a,
+	0x0e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65, 0x61, 0x72, 0x46, 0x6c, 0x61, 0x67, 0x12,
+	0x1d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c,
+	0x65, 0x61, 0x72, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e,
+	0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x43, 0x6c, 0x65,
+	0x61, 0x72, 0x46, 0x6c, 0x61, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52,
+	0x0a, 0x0f, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4f, 0x70, 0x61, 0x63, 0x69, 0x74,
+	0x79, 0x12, 0x1e, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x53, 0x65, 0x74, 0x4f, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x1f, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x53, 0x65, 0x74, 0x4f, 0x70, 0x61, 0x63, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x46, 0x0a, 0x0b, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x49,
+	0x6e, 0x12, 0x1a, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a,
+	0x46, 0x61, 0x64, 0x65, 0x49, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e,
+	0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65,
+	0x49, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0c, 0x55, 0x49,
+	0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x4f, 0x75, 0x74, 0x12, 0x1b, 0x2e, 0x6e, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x4f, 0x75, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65,
+	0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x46, 0x61, 0x64, 0x65, 0x4f, 0x75, 0x74, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x58, 0x0a, 0x11, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65,
+	0x74, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x54, 0x65, 0x78, 0x74, 0x12, 0x20, 0x2e, 0x6e, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4c, 0x61, 0x62, 0x65,
+	0x6c, 0x54, 0x65, 0x78, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6e,
+	0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x4c, 0x61,
+	0x62, 0x65, 0x6c, 0x54, 0x65, 0x78, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+	0x55, 0x0a, 0x10, 0x55, 0x49, 0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65,
+	0x53, 0x72, 0x63, 0x12, 0x1f, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49, 0x4f,
+	0x62, 0x6a, 0x53, 0x65, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x53, 0x72, 0x63, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x49,
+	0x4f, 0x62, 0x6a, 0x53, 0x65, 0x74, 0x49, 0x6d, 0x61, 0x67, 0x65, 0x53, 0x72, 0x63, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5b, 0x0a, 0x12, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61,
+	0x79, 0x53, 0x65, 0x74, 0x52, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x2e, 0x6e,
+	0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x53, 0x65, 0x74,
+	0x52, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x22, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79,
+	0x53, 0x65, 0x74, 0x52, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x62,
+	0x65, 0x6c, 0x49, 0x66, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x12, 0x23, 0x2e, 0x6e, 0x61,
+	0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c,
+	0x49, 0x66, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
+	0x5e, 0x0a, 0x1e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x41, 0x6e,
+	0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74,
+	0x79, 0x12, 0x2d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74,
+	0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x41, 0x6e, 0x64, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x56,
+	0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12,
+	0x42, 0x0a, 0x10, 0x53, 0x77, 0x69, 0x74, 0x63, 0x68, 0x54, 0x6f, 0x53, 0x63, 0x72, 0x65, 0x65,
+	0x6e, 0x49, 0x66, 0x12, 0x1f, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x53, 0x77, 0x69,
+	0x74, 0x63, 0x68, 0x54, 0x6f, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x49, 0x66, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d,
+	0x70, 0x74, 0x79, 0x12, 0x54, 0x0a, 0x19, 0x53, 0x77, 0x69, 0x74, 0x63, 0x68, 0x54, 0x6f, 0x53,
+	0x63, 0x72, 0x65, 0x65, 0x6e, 0x49, 0x66, 0x44, 0x69, 0x66, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x74,
+	0x12, 0x28, 0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x53, 0x77, 0x69, 0x74, 0x63, 0x68,
+	0x54, 0x6f, 0x53, 0x63, 0x72, 0x65, 0x65, 0x6e, 0x49, 0x66, 0x44, 0x69, 0x66, 0x66, 0x65, 0x72,
+	0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, 0x2e, 0x6e, 0x61, 0x74,
+	0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x41, 0x0a, 0x21, 0x44, 0x6f, 0x4e,
+	0x6f, 0x74, 0x55, 0x73, 0x65, 0x54, 0x68, 0x69, 0x73, 0x49, 0x73, 0x46, 0x6f, 0x72, 0x43, 0x72,
+	0x61, 0x73, 0x68, 0x54, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x67, 0x4f, 0x6e, 0x6c, 0x79, 0x12, 0x0d,
+	0x2e, 0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e,
+	0x6e, 0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x2e, 0x0a, 0x0c,
+	0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x0d, 0x2e, 0x6e,
+	0x61, 0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0d, 0x2e, 0x6e, 0x61,
+	0x74, 0x69, 0x76, 0x65, 0x2e, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x30, 0x01, 0x42, 0x2d, 0x5a, 0x2b,
+	0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x6a, 0x65, 0x74, 0x6b, 0x76,
+	0x6d, 0x2f, 0x6b, 0x76, 0x6d, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x6e,
+	0x61, 0x74, 0x69, 0x76, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x33,
+})
+
+var (
+	file_internal_native_proto_native_proto_rawDescOnce sync.Once
+	file_internal_native_proto_native_proto_rawDescData []byte
+)
+
+func file_internal_native_proto_native_proto_rawDescGZIP() []byte {
+	file_internal_native_proto_native_proto_rawDescOnce.Do(func() {
+		file_internal_native_proto_native_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_internal_native_proto_native_proto_rawDesc), len(file_internal_native_proto_native_proto_rawDesc)))
+	})
+	return file_internal_native_proto_native_proto_rawDescData
+}
+
+var file_internal_native_proto_native_proto_msgTypes = make([]protoimpl.MessageInfo, 46)
+var file_internal_native_proto_native_proto_goTypes = []any{
+	(*Empty)(nil),                                 // 0: native.Empty
+	(*IsReadyRequest)(nil),                        // 1: native.IsReadyRequest
+	(*IsReadyResponse)(nil),                       // 2: native.IsReadyResponse
+	(*VideoState)(nil),                            // 3: native.VideoState
+	(*VideoSetSleepModeRequest)(nil),              // 4: native.VideoSetSleepModeRequest
+	(*VideoGetSleepModeResponse)(nil),             // 5: native.VideoGetSleepModeResponse
+	(*VideoSleepModeSupportedResponse)(nil),       // 6: native.VideoSleepModeSupportedResponse
+	(*VideoSetQualityFactorRequest)(nil),          // 7: native.VideoSetQualityFactorRequest
+	(*VideoGetQualityFactorResponse)(nil),         // 8: native.VideoGetQualityFactorResponse
+	(*VideoSetEDIDRequest)(nil),                   // 9: native.VideoSetEDIDRequest
+	(*VideoGetEDIDResponse)(nil),                  // 10: native.VideoGetEDIDResponse
+	(*VideoLogStatusResponse)(nil),                // 11: native.VideoLogStatusResponse
+	(*GetLVGLVersionResponse)(nil),                // 12: native.GetLVGLVersionResponse
+	(*UIObjHideRequest)(nil),                      // 13: native.UIObjHideRequest
+	(*UIObjHideResponse)(nil),                     // 14: native.UIObjHideResponse
+	(*UIObjShowRequest)(nil),                      // 15: native.UIObjShowRequest
+	(*UIObjShowResponse)(nil),                     // 16: native.UIObjShowResponse
+	(*UISetVarRequest)(nil),                       // 17: native.UISetVarRequest
+	(*UIGetVarRequest)(nil),                       // 18: native.UIGetVarRequest
+	(*UIGetVarResponse)(nil),                      // 19: native.UIGetVarResponse
+	(*UIObjAddStateRequest)(nil),                  // 20: native.UIObjAddStateRequest
+	(*UIObjAddStateResponse)(nil),                 // 21: native.UIObjAddStateResponse
+	(*UIObjClearStateRequest)(nil),                // 22: native.UIObjClearStateRequest
+	(*UIObjClearStateResponse)(nil),               // 23: native.UIObjClearStateResponse
+	(*UIObjAddFlagRequest)(nil),                   // 24: native.UIObjAddFlagRequest
+	(*UIObjAddFlagResponse)(nil),                  // 25: native.UIObjAddFlagResponse
+	(*UIObjClearFlagRequest)(nil),                 // 26: native.UIObjClearFlagRequest
+	(*UIObjClearFlagResponse)(nil),                // 27: native.UIObjClearFlagResponse
+	(*UIObjSetOpacityRequest)(nil),                // 28: native.UIObjSetOpacityRequest
+	(*UIObjSetOpacityResponse)(nil),               // 29: native.UIObjSetOpacityResponse
+	(*UIObjFadeInRequest)(nil),                    // 30: native.UIObjFadeInRequest
+	(*UIObjFadeInResponse)(nil),                   // 31: native.UIObjFadeInResponse
+	(*UIObjFadeOutRequest)(nil),                   // 32: native.UIObjFadeOutRequest
+	(*UIObjFadeOutResponse)(nil),                  // 33: native.UIObjFadeOutResponse
+	(*UIObjSetLabelTextRequest)(nil),              // 34: native.UIObjSetLabelTextRequest
+	(*UIObjSetLabelTextResponse)(nil),             // 35: native.UIObjSetLabelTextResponse
+	(*UIObjSetImageSrcRequest)(nil),               // 36: native.UIObjSetImageSrcRequest
+	(*UIObjSetImageSrcResponse)(nil),              // 37: native.UIObjSetImageSrcResponse
+	(*DisplaySetRotationRequest)(nil),             // 38: native.DisplaySetRotationRequest
+	(*DisplaySetRotationResponse)(nil),            // 39: native.DisplaySetRotationResponse
+	(*UpdateLabelIfChangedRequest)(nil),           // 40: native.UpdateLabelIfChangedRequest
+	(*UpdateLabelAndChangeVisibilityRequest)(nil), // 41: native.UpdateLabelAndChangeVisibilityRequest
+	(*SwitchToScreenIfRequest)(nil),               // 42: native.SwitchToScreenIfRequest
+	(*SwitchToScreenIfDifferentRequest)(nil),      // 43: native.SwitchToScreenIfDifferentRequest
+	(*Event)(nil),                                 // 44: native.Event
+	(*VideoFrame)(nil),                            // 45: native.VideoFrame
+}
+var file_internal_native_proto_native_proto_depIdxs = []int32{
+	3,  // 0: native.Event.video_state:type_name -> native.VideoState
+	45, // 1: native.Event.video_frame:type_name -> native.VideoFrame
+	1,  // 2: native.NativeService.IsReady:input_type -> native.IsReadyRequest
+	4,  // 3: native.NativeService.VideoSetSleepMode:input_type -> native.VideoSetSleepModeRequest
+	0,  // 4: native.NativeService.VideoGetSleepMode:input_type -> native.Empty
+	0,  // 5: native.NativeService.VideoSleepModeSupported:input_type -> native.Empty
+	7,  // 6: native.NativeService.VideoSetQualityFactor:input_type -> native.VideoSetQualityFactorRequest
+	0,  // 7: native.NativeService.VideoGetQualityFactor:input_type -> native.Empty
+	9,  // 8: native.NativeService.VideoSetEDID:input_type -> native.VideoSetEDIDRequest
+	0,  // 9: native.NativeService.VideoGetEDID:input_type -> native.Empty
+	0,  // 10: native.NativeService.VideoLogStatus:input_type -> native.Empty
+	0,  // 11: native.NativeService.VideoStop:input_type -> native.Empty
+	0,  // 12: native.NativeService.VideoStart:input_type -> native.Empty
+	0,  // 13: native.NativeService.GetLVGLVersion:input_type -> native.Empty
+	13, // 14: native.NativeService.UIObjHide:input_type -> native.UIObjHideRequest
+	15, // 15: native.NativeService.UIObjShow:input_type -> native.UIObjShowRequest
+	17, // 16: native.NativeService.UISetVar:input_type -> native.UISetVarRequest
+	18, // 17: native.NativeService.UIGetVar:input_type -> native.UIGetVarRequest
+	20, // 18: native.NativeService.UIObjAddState:input_type -> native.UIObjAddStateRequest
+	22, // 19: native.NativeService.UIObjClearState:input_type -> native.UIObjClearStateRequest
+	24, // 20: native.NativeService.UIObjAddFlag:input_type -> native.UIObjAddFlagRequest
+	26, // 21: native.NativeService.UIObjClearFlag:input_type -> native.UIObjClearFlagRequest
+	28, // 22: native.NativeService.UIObjSetOpacity:input_type -> native.UIObjSetOpacityRequest
+	30, // 23: native.NativeService.UIObjFadeIn:input_type -> native.UIObjFadeInRequest
+	32, // 24: native.NativeService.UIObjFadeOut:input_type -> native.UIObjFadeOutRequest
+	34, // 25: native.NativeService.UIObjSetLabelText:input_type -> native.UIObjSetLabelTextRequest
+	36, // 26: native.NativeService.UIObjSetImageSrc:input_type -> native.UIObjSetImageSrcRequest
+	38, // 27: native.NativeService.DisplaySetRotation:input_type -> native.DisplaySetRotationRequest
+	40, // 28: native.NativeService.UpdateLabelIfChanged:input_type -> native.UpdateLabelIfChangedRequest
+	41, // 29: native.NativeService.UpdateLabelAndChangeVisibility:input_type -> native.UpdateLabelAndChangeVisibilityRequest
+	42, // 30: native.NativeService.SwitchToScreenIf:input_type -> native.SwitchToScreenIfRequest
+	43, // 31: native.NativeService.SwitchToScreenIfDifferent:input_type -> native.SwitchToScreenIfDifferentRequest
+	0,  // 32: native.NativeService.DoNotUseThisIsForCrashTestingOnly:input_type -> native.Empty
+	0,  // 33: native.NativeService.StreamEvents:input_type -> native.Empty
+	2,  // 34: native.NativeService.IsReady:output_type -> native.IsReadyResponse
+	0,  // 35: native.NativeService.VideoSetSleepMode:output_type -> native.Empty
+	5,  // 36: native.NativeService.VideoGetSleepMode:output_type -> native.VideoGetSleepModeResponse
+	6,  // 37: native.NativeService.VideoSleepModeSupported:output_type -> native.VideoSleepModeSupportedResponse
+	0,  // 38: native.NativeService.VideoSetQualityFactor:output_type -> native.Empty
+	8,  // 39: native.NativeService.VideoGetQualityFactor:output_type -> native.VideoGetQualityFactorResponse
+	0,  // 40: native.NativeService.VideoSetEDID:output_type -> native.Empty
+	10, // 41: native.NativeService.VideoGetEDID:output_type -> native.VideoGetEDIDResponse
+	11, // 42: native.NativeService.VideoLogStatus:output_type -> native.VideoLogStatusResponse
+	0,  // 43: native.NativeService.VideoStop:output_type -> native.Empty
+	0,  // 44: native.NativeService.VideoStart:output_type -> native.Empty
+	12, // 45: native.NativeService.GetLVGLVersion:output_type -> native.GetLVGLVersionResponse
+	14, // 46: native.NativeService.UIObjHide:output_type -> native.UIObjHideResponse
+	16, // 47: native.NativeService.UIObjShow:output_type -> native.UIObjShowResponse
+	0,  // 48: native.NativeService.UISetVar:output_type -> native.Empty
+	19, // 49: native.NativeService.UIGetVar:output_type -> native.UIGetVarResponse
+	21, // 50: native.NativeService.UIObjAddState:output_type -> native.UIObjAddStateResponse
+	23, // 51: native.NativeService.UIObjClearState:output_type -> native.UIObjClearStateResponse
+	25, // 52: native.NativeService.UIObjAddFlag:output_type -> native.UIObjAddFlagResponse
+	27, // 53: native.NativeService.UIObjClearFlag:output_type -> native.UIObjClearFlagResponse
+	29, // 54: native.NativeService.UIObjSetOpacity:output_type -> native.UIObjSetOpacityResponse
+	31, // 55: native.NativeService.UIObjFadeIn:output_type -> native.UIObjFadeInResponse
+	33, // 56: native.NativeService.UIObjFadeOut:output_type -> native.UIObjFadeOutResponse
+	35, // 57: native.NativeService.UIObjSetLabelText:output_type -> native.UIObjSetLabelTextResponse
+	37, // 58: native.NativeService.UIObjSetImageSrc:output_type -> native.UIObjSetImageSrcResponse
+	39, // 59: native.NativeService.DisplaySetRotation:output_type -> native.DisplaySetRotationResponse
+	0,  // 60: native.NativeService.UpdateLabelIfChanged:output_type -> native.Empty
+	0,  // 61: native.NativeService.UpdateLabelAndChangeVisibility:output_type -> native.Empty
+	0,  // 62: native.NativeService.SwitchToScreenIf:output_type -> native.Empty
+	0,  // 63: native.NativeService.SwitchToScreenIfDifferent:output_type -> native.Empty
+	0,  // 64: native.NativeService.DoNotUseThisIsForCrashTestingOnly:output_type -> native.Empty
+	44, // 65: native.NativeService.StreamEvents:output_type -> native.Event
+	34, // [34:66] is the sub-list for method output_type
+	2,  // [2:34] is the sub-list for method input_type
+	2,  // [2:2] is the sub-list for extension type_name
+	2,  // [2:2] is the sub-list for extension extendee
+	0,  // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_internal_native_proto_native_proto_init() }
+func file_internal_native_proto_native_proto_init() {
+	if File_internal_native_proto_native_proto != nil {
+		return
+	}
+	file_internal_native_proto_native_proto_msgTypes[44].OneofWrappers = []any{
+		(*Event_VideoState)(nil),
+		(*Event_IndevEvent)(nil),
+		(*Event_RpcEvent)(nil),
+		(*Event_VideoFrame)(nil),
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: unsafe.Slice(unsafe.StringData(file_internal_native_proto_native_proto_rawDesc), len(file_internal_native_proto_native_proto_rawDesc)),
+			NumEnums:      0,
+			NumMessages:   46,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_internal_native_proto_native_proto_goTypes,
+		DependencyIndexes: file_internal_native_proto_native_proto_depIdxs,
+		MessageInfos:      file_internal_native_proto_native_proto_msgTypes,
+	}.Build()
+	File_internal_native_proto_native_proto = out.File
+	file_internal_native_proto_native_proto_goTypes = nil
+	file_internal_native_proto_native_proto_depIdxs = nil
+}
diff --git a/internal/native/proto/native.proto b/internal/native/proto/native.proto
new file mode 100644
index 0000000..538d330
--- /dev/null
+++ b/internal/native/proto/native.proto
@@ -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;
+}
+
diff --git a/internal/native/proto/native_grpc.pb.go b/internal/native/proto/native_grpc.pb.go
new file mode 100644
index 0000000..abdd38b
--- /dev/null
+++ b/internal/native/proto/native_grpc.pb.go
@@ -0,0 +1,1326 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc             v3.21.12
+// source: internal/native/proto/native.proto
+
+package proto
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.62.0 or later.
+const _ = grpc.SupportPackageIsVersion8
+
+const (
+	NativeService_IsReady_FullMethodName                           = "/native.NativeService/IsReady"
+	NativeService_VideoSetSleepMode_FullMethodName                 = "/native.NativeService/VideoSetSleepMode"
+	NativeService_VideoGetSleepMode_FullMethodName                 = "/native.NativeService/VideoGetSleepMode"
+	NativeService_VideoSleepModeSupported_FullMethodName           = "/native.NativeService/VideoSleepModeSupported"
+	NativeService_VideoSetQualityFactor_FullMethodName             = "/native.NativeService/VideoSetQualityFactor"
+	NativeService_VideoGetQualityFactor_FullMethodName             = "/native.NativeService/VideoGetQualityFactor"
+	NativeService_VideoSetEDID_FullMethodName                      = "/native.NativeService/VideoSetEDID"
+	NativeService_VideoGetEDID_FullMethodName                      = "/native.NativeService/VideoGetEDID"
+	NativeService_VideoLogStatus_FullMethodName                    = "/native.NativeService/VideoLogStatus"
+	NativeService_VideoStop_FullMethodName                         = "/native.NativeService/VideoStop"
+	NativeService_VideoStart_FullMethodName                        = "/native.NativeService/VideoStart"
+	NativeService_GetLVGLVersion_FullMethodName                    = "/native.NativeService/GetLVGLVersion"
+	NativeService_UIObjHide_FullMethodName                         = "/native.NativeService/UIObjHide"
+	NativeService_UIObjShow_FullMethodName                         = "/native.NativeService/UIObjShow"
+	NativeService_UISetVar_FullMethodName                          = "/native.NativeService/UISetVar"
+	NativeService_UIGetVar_FullMethodName                          = "/native.NativeService/UIGetVar"
+	NativeService_UIObjAddState_FullMethodName                     = "/native.NativeService/UIObjAddState"
+	NativeService_UIObjClearState_FullMethodName                   = "/native.NativeService/UIObjClearState"
+	NativeService_UIObjAddFlag_FullMethodName                      = "/native.NativeService/UIObjAddFlag"
+	NativeService_UIObjClearFlag_FullMethodName                    = "/native.NativeService/UIObjClearFlag"
+	NativeService_UIObjSetOpacity_FullMethodName                   = "/native.NativeService/UIObjSetOpacity"
+	NativeService_UIObjFadeIn_FullMethodName                       = "/native.NativeService/UIObjFadeIn"
+	NativeService_UIObjFadeOut_FullMethodName                      = "/native.NativeService/UIObjFadeOut"
+	NativeService_UIObjSetLabelText_FullMethodName                 = "/native.NativeService/UIObjSetLabelText"
+	NativeService_UIObjSetImageSrc_FullMethodName                  = "/native.NativeService/UIObjSetImageSrc"
+	NativeService_DisplaySetRotation_FullMethodName                = "/native.NativeService/DisplaySetRotation"
+	NativeService_UpdateLabelIfChanged_FullMethodName              = "/native.NativeService/UpdateLabelIfChanged"
+	NativeService_UpdateLabelAndChangeVisibility_FullMethodName    = "/native.NativeService/UpdateLabelAndChangeVisibility"
+	NativeService_SwitchToScreenIf_FullMethodName                  = "/native.NativeService/SwitchToScreenIf"
+	NativeService_SwitchToScreenIfDifferent_FullMethodName         = "/native.NativeService/SwitchToScreenIfDifferent"
+	NativeService_DoNotUseThisIsForCrashTestingOnly_FullMethodName = "/native.NativeService/DoNotUseThisIsForCrashTestingOnly"
+	NativeService_StreamEvents_FullMethodName                      = "/native.NativeService/StreamEvents"
+)
+
+// NativeServiceClient is the client API for NativeService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type NativeServiceClient interface {
+	// Ready check
+	IsReady(ctx context.Context, in *IsReadyRequest, opts ...grpc.CallOption) (*IsReadyResponse, error)
+	// Video methods
+	VideoSetSleepMode(ctx context.Context, in *VideoSetSleepModeRequest, opts ...grpc.CallOption) (*Empty, error)
+	VideoGetSleepMode(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetSleepModeResponse, error)
+	VideoSleepModeSupported(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoSleepModeSupportedResponse, error)
+	VideoSetQualityFactor(ctx context.Context, in *VideoSetQualityFactorRequest, opts ...grpc.CallOption) (*Empty, error)
+	VideoGetQualityFactor(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetQualityFactorResponse, error)
+	VideoSetEDID(ctx context.Context, in *VideoSetEDIDRequest, opts ...grpc.CallOption) (*Empty, error)
+	VideoGetEDID(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetEDIDResponse, error)
+	VideoLogStatus(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoLogStatusResponse, error)
+	VideoStop(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error)
+	VideoStart(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error)
+	// UI methods
+	GetLVGLVersion(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*GetLVGLVersionResponse, error)
+	UIObjHide(ctx context.Context, in *UIObjHideRequest, opts ...grpc.CallOption) (*UIObjHideResponse, error)
+	UIObjShow(ctx context.Context, in *UIObjShowRequest, opts ...grpc.CallOption) (*UIObjShowResponse, error)
+	UISetVar(ctx context.Context, in *UISetVarRequest, opts ...grpc.CallOption) (*Empty, error)
+	UIGetVar(ctx context.Context, in *UIGetVarRequest, opts ...grpc.CallOption) (*UIGetVarResponse, error)
+	UIObjAddState(ctx context.Context, in *UIObjAddStateRequest, opts ...grpc.CallOption) (*UIObjAddStateResponse, error)
+	UIObjClearState(ctx context.Context, in *UIObjClearStateRequest, opts ...grpc.CallOption) (*UIObjClearStateResponse, error)
+	UIObjAddFlag(ctx context.Context, in *UIObjAddFlagRequest, opts ...grpc.CallOption) (*UIObjAddFlagResponse, error)
+	UIObjClearFlag(ctx context.Context, in *UIObjClearFlagRequest, opts ...grpc.CallOption) (*UIObjClearFlagResponse, error)
+	UIObjSetOpacity(ctx context.Context, in *UIObjSetOpacityRequest, opts ...grpc.CallOption) (*UIObjSetOpacityResponse, error)
+	UIObjFadeIn(ctx context.Context, in *UIObjFadeInRequest, opts ...grpc.CallOption) (*UIObjFadeInResponse, error)
+	UIObjFadeOut(ctx context.Context, in *UIObjFadeOutRequest, opts ...grpc.CallOption) (*UIObjFadeOutResponse, error)
+	UIObjSetLabelText(ctx context.Context, in *UIObjSetLabelTextRequest, opts ...grpc.CallOption) (*UIObjSetLabelTextResponse, error)
+	UIObjSetImageSrc(ctx context.Context, in *UIObjSetImageSrcRequest, opts ...grpc.CallOption) (*UIObjSetImageSrcResponse, error)
+	DisplaySetRotation(ctx context.Context, in *DisplaySetRotationRequest, opts ...grpc.CallOption) (*DisplaySetRotationResponse, error)
+	UpdateLabelIfChanged(ctx context.Context, in *UpdateLabelIfChangedRequest, opts ...grpc.CallOption) (*Empty, error)
+	UpdateLabelAndChangeVisibility(ctx context.Context, in *UpdateLabelAndChangeVisibilityRequest, opts ...grpc.CallOption) (*Empty, error)
+	SwitchToScreenIf(ctx context.Context, in *SwitchToScreenIfRequest, opts ...grpc.CallOption) (*Empty, error)
+	SwitchToScreenIfDifferent(ctx context.Context, in *SwitchToScreenIfDifferentRequest, opts ...grpc.CallOption) (*Empty, error)
+	// Testing
+	DoNotUseThisIsForCrashTestingOnly(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error)
+	// Events stream
+	StreamEvents(ctx context.Context, in *Empty, opts ...grpc.CallOption) (NativeService_StreamEventsClient, error)
+}
+
+type nativeServiceClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewNativeServiceClient(cc grpc.ClientConnInterface) NativeServiceClient {
+	return &nativeServiceClient{cc}
+}
+
+func (c *nativeServiceClient) IsReady(ctx context.Context, in *IsReadyRequest, opts ...grpc.CallOption) (*IsReadyResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(IsReadyResponse)
+	err := c.cc.Invoke(ctx, NativeService_IsReady_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoSetSleepMode(ctx context.Context, in *VideoSetSleepModeRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_VideoSetSleepMode_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoGetSleepMode(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetSleepModeResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(VideoGetSleepModeResponse)
+	err := c.cc.Invoke(ctx, NativeService_VideoGetSleepMode_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoSleepModeSupported(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoSleepModeSupportedResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(VideoSleepModeSupportedResponse)
+	err := c.cc.Invoke(ctx, NativeService_VideoSleepModeSupported_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoSetQualityFactor(ctx context.Context, in *VideoSetQualityFactorRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_VideoSetQualityFactor_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoGetQualityFactor(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetQualityFactorResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(VideoGetQualityFactorResponse)
+	err := c.cc.Invoke(ctx, NativeService_VideoGetQualityFactor_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoSetEDID(ctx context.Context, in *VideoSetEDIDRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_VideoSetEDID_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoGetEDID(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoGetEDIDResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(VideoGetEDIDResponse)
+	err := c.cc.Invoke(ctx, NativeService_VideoGetEDID_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoLogStatus(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*VideoLogStatusResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(VideoLogStatusResponse)
+	err := c.cc.Invoke(ctx, NativeService_VideoLogStatus_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoStop(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_VideoStop_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) VideoStart(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_VideoStart_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) GetLVGLVersion(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*GetLVGLVersionResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(GetLVGLVersionResponse)
+	err := c.cc.Invoke(ctx, NativeService_GetLVGLVersion_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjHide(ctx context.Context, in *UIObjHideRequest, opts ...grpc.CallOption) (*UIObjHideResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjHideResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjHide_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjShow(ctx context.Context, in *UIObjShowRequest, opts ...grpc.CallOption) (*UIObjShowResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjShowResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjShow_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UISetVar(ctx context.Context, in *UISetVarRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_UISetVar_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIGetVar(ctx context.Context, in *UIGetVarRequest, opts ...grpc.CallOption) (*UIGetVarResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIGetVarResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIGetVar_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjAddState(ctx context.Context, in *UIObjAddStateRequest, opts ...grpc.CallOption) (*UIObjAddStateResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjAddStateResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjAddState_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjClearState(ctx context.Context, in *UIObjClearStateRequest, opts ...grpc.CallOption) (*UIObjClearStateResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjClearStateResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjClearState_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjAddFlag(ctx context.Context, in *UIObjAddFlagRequest, opts ...grpc.CallOption) (*UIObjAddFlagResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjAddFlagResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjAddFlag_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjClearFlag(ctx context.Context, in *UIObjClearFlagRequest, opts ...grpc.CallOption) (*UIObjClearFlagResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjClearFlagResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjClearFlag_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjSetOpacity(ctx context.Context, in *UIObjSetOpacityRequest, opts ...grpc.CallOption) (*UIObjSetOpacityResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjSetOpacityResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjSetOpacity_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjFadeIn(ctx context.Context, in *UIObjFadeInRequest, opts ...grpc.CallOption) (*UIObjFadeInResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjFadeInResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjFadeIn_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjFadeOut(ctx context.Context, in *UIObjFadeOutRequest, opts ...grpc.CallOption) (*UIObjFadeOutResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjFadeOutResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjFadeOut_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjSetLabelText(ctx context.Context, in *UIObjSetLabelTextRequest, opts ...grpc.CallOption) (*UIObjSetLabelTextResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjSetLabelTextResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjSetLabelText_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UIObjSetImageSrc(ctx context.Context, in *UIObjSetImageSrcRequest, opts ...grpc.CallOption) (*UIObjSetImageSrcResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(UIObjSetImageSrcResponse)
+	err := c.cc.Invoke(ctx, NativeService_UIObjSetImageSrc_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) DisplaySetRotation(ctx context.Context, in *DisplaySetRotationRequest, opts ...grpc.CallOption) (*DisplaySetRotationResponse, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(DisplaySetRotationResponse)
+	err := c.cc.Invoke(ctx, NativeService_DisplaySetRotation_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UpdateLabelIfChanged(ctx context.Context, in *UpdateLabelIfChangedRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_UpdateLabelIfChanged_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) UpdateLabelAndChangeVisibility(ctx context.Context, in *UpdateLabelAndChangeVisibilityRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_UpdateLabelAndChangeVisibility_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) SwitchToScreenIf(ctx context.Context, in *SwitchToScreenIfRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_SwitchToScreenIf_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) SwitchToScreenIfDifferent(ctx context.Context, in *SwitchToScreenIfDifferentRequest, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_SwitchToScreenIfDifferent_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) DoNotUseThisIsForCrashTestingOnly(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	out := new(Empty)
+	err := c.cc.Invoke(ctx, NativeService_DoNotUseThisIsForCrashTestingOnly_FullMethodName, in, out, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *nativeServiceClient) StreamEvents(ctx context.Context, in *Empty, opts ...grpc.CallOption) (NativeService_StreamEventsClient, error) {
+	cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+	stream, err := c.cc.NewStream(ctx, &NativeService_ServiceDesc.Streams[0], NativeService_StreamEvents_FullMethodName, cOpts...)
+	if err != nil {
+		return nil, err
+	}
+	x := &nativeServiceStreamEventsClient{ClientStream: stream}
+	if err := x.ClientStream.SendMsg(in); err != nil {
+		return nil, err
+	}
+	if err := x.ClientStream.CloseSend(); err != nil {
+		return nil, err
+	}
+	return x, nil
+}
+
+type NativeService_StreamEventsClient interface {
+	Recv() (*Event, error)
+	grpc.ClientStream
+}
+
+type nativeServiceStreamEventsClient struct {
+	grpc.ClientStream
+}
+
+func (x *nativeServiceStreamEventsClient) Recv() (*Event, error) {
+	m := new(Event)
+	if err := x.ClientStream.RecvMsg(m); err != nil {
+		return nil, err
+	}
+	return m, nil
+}
+
+// NativeServiceServer is the server API for NativeService service.
+// All implementations must embed UnimplementedNativeServiceServer
+// for forward compatibility
+type NativeServiceServer interface {
+	// Ready check
+	IsReady(context.Context, *IsReadyRequest) (*IsReadyResponse, error)
+	// Video methods
+	VideoSetSleepMode(context.Context, *VideoSetSleepModeRequest) (*Empty, error)
+	VideoGetSleepMode(context.Context, *Empty) (*VideoGetSleepModeResponse, error)
+	VideoSleepModeSupported(context.Context, *Empty) (*VideoSleepModeSupportedResponse, error)
+	VideoSetQualityFactor(context.Context, *VideoSetQualityFactorRequest) (*Empty, error)
+	VideoGetQualityFactor(context.Context, *Empty) (*VideoGetQualityFactorResponse, error)
+	VideoSetEDID(context.Context, *VideoSetEDIDRequest) (*Empty, error)
+	VideoGetEDID(context.Context, *Empty) (*VideoGetEDIDResponse, error)
+	VideoLogStatus(context.Context, *Empty) (*VideoLogStatusResponse, error)
+	VideoStop(context.Context, *Empty) (*Empty, error)
+	VideoStart(context.Context, *Empty) (*Empty, error)
+	// UI methods
+	GetLVGLVersion(context.Context, *Empty) (*GetLVGLVersionResponse, error)
+	UIObjHide(context.Context, *UIObjHideRequest) (*UIObjHideResponse, error)
+	UIObjShow(context.Context, *UIObjShowRequest) (*UIObjShowResponse, error)
+	UISetVar(context.Context, *UISetVarRequest) (*Empty, error)
+	UIGetVar(context.Context, *UIGetVarRequest) (*UIGetVarResponse, error)
+	UIObjAddState(context.Context, *UIObjAddStateRequest) (*UIObjAddStateResponse, error)
+	UIObjClearState(context.Context, *UIObjClearStateRequest) (*UIObjClearStateResponse, error)
+	UIObjAddFlag(context.Context, *UIObjAddFlagRequest) (*UIObjAddFlagResponse, error)
+	UIObjClearFlag(context.Context, *UIObjClearFlagRequest) (*UIObjClearFlagResponse, error)
+	UIObjSetOpacity(context.Context, *UIObjSetOpacityRequest) (*UIObjSetOpacityResponse, error)
+	UIObjFadeIn(context.Context, *UIObjFadeInRequest) (*UIObjFadeInResponse, error)
+	UIObjFadeOut(context.Context, *UIObjFadeOutRequest) (*UIObjFadeOutResponse, error)
+	UIObjSetLabelText(context.Context, *UIObjSetLabelTextRequest) (*UIObjSetLabelTextResponse, error)
+	UIObjSetImageSrc(context.Context, *UIObjSetImageSrcRequest) (*UIObjSetImageSrcResponse, error)
+	DisplaySetRotation(context.Context, *DisplaySetRotationRequest) (*DisplaySetRotationResponse, error)
+	UpdateLabelIfChanged(context.Context, *UpdateLabelIfChangedRequest) (*Empty, error)
+	UpdateLabelAndChangeVisibility(context.Context, *UpdateLabelAndChangeVisibilityRequest) (*Empty, error)
+	SwitchToScreenIf(context.Context, *SwitchToScreenIfRequest) (*Empty, error)
+	SwitchToScreenIfDifferent(context.Context, *SwitchToScreenIfDifferentRequest) (*Empty, error)
+	// Testing
+	DoNotUseThisIsForCrashTestingOnly(context.Context, *Empty) (*Empty, error)
+	// Events stream
+	StreamEvents(*Empty, NativeService_StreamEventsServer) error
+	mustEmbedUnimplementedNativeServiceServer()
+}
+
+// UnimplementedNativeServiceServer must be embedded to have forward compatible implementations.
+type UnimplementedNativeServiceServer struct {
+}
+
+func (UnimplementedNativeServiceServer) IsReady(context.Context, *IsReadyRequest) (*IsReadyResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method IsReady not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoSetSleepMode(context.Context, *VideoSetSleepModeRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoSetSleepMode not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoGetSleepMode(context.Context, *Empty) (*VideoGetSleepModeResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoGetSleepMode not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoSleepModeSupported(context.Context, *Empty) (*VideoSleepModeSupportedResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoSleepModeSupported not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoSetQualityFactor(context.Context, *VideoSetQualityFactorRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoSetQualityFactor not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoGetQualityFactor(context.Context, *Empty) (*VideoGetQualityFactorResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoGetQualityFactor not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoSetEDID(context.Context, *VideoSetEDIDRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoSetEDID not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoGetEDID(context.Context, *Empty) (*VideoGetEDIDResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoGetEDID not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoLogStatus(context.Context, *Empty) (*VideoLogStatusResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoLogStatus not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoStop(context.Context, *Empty) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoStop not implemented")
+}
+func (UnimplementedNativeServiceServer) VideoStart(context.Context, *Empty) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method VideoStart not implemented")
+}
+func (UnimplementedNativeServiceServer) GetLVGLVersion(context.Context, *Empty) (*GetLVGLVersionResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetLVGLVersion not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjHide(context.Context, *UIObjHideRequest) (*UIObjHideResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjHide not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjShow(context.Context, *UIObjShowRequest) (*UIObjShowResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjShow not implemented")
+}
+func (UnimplementedNativeServiceServer) UISetVar(context.Context, *UISetVarRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UISetVar not implemented")
+}
+func (UnimplementedNativeServiceServer) UIGetVar(context.Context, *UIGetVarRequest) (*UIGetVarResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIGetVar not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjAddState(context.Context, *UIObjAddStateRequest) (*UIObjAddStateResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjAddState not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjClearState(context.Context, *UIObjClearStateRequest) (*UIObjClearStateResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjClearState not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjAddFlag(context.Context, *UIObjAddFlagRequest) (*UIObjAddFlagResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjAddFlag not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjClearFlag(context.Context, *UIObjClearFlagRequest) (*UIObjClearFlagResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjClearFlag not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjSetOpacity(context.Context, *UIObjSetOpacityRequest) (*UIObjSetOpacityResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjSetOpacity not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjFadeIn(context.Context, *UIObjFadeInRequest) (*UIObjFadeInResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjFadeIn not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjFadeOut(context.Context, *UIObjFadeOutRequest) (*UIObjFadeOutResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjFadeOut not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjSetLabelText(context.Context, *UIObjSetLabelTextRequest) (*UIObjSetLabelTextResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjSetLabelText not implemented")
+}
+func (UnimplementedNativeServiceServer) UIObjSetImageSrc(context.Context, *UIObjSetImageSrcRequest) (*UIObjSetImageSrcResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UIObjSetImageSrc not implemented")
+}
+func (UnimplementedNativeServiceServer) DisplaySetRotation(context.Context, *DisplaySetRotationRequest) (*DisplaySetRotationResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method DisplaySetRotation not implemented")
+}
+func (UnimplementedNativeServiceServer) UpdateLabelIfChanged(context.Context, *UpdateLabelIfChangedRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UpdateLabelIfChanged not implemented")
+}
+func (UnimplementedNativeServiceServer) UpdateLabelAndChangeVisibility(context.Context, *UpdateLabelAndChangeVisibilityRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UpdateLabelAndChangeVisibility not implemented")
+}
+func (UnimplementedNativeServiceServer) SwitchToScreenIf(context.Context, *SwitchToScreenIfRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method SwitchToScreenIf not implemented")
+}
+func (UnimplementedNativeServiceServer) SwitchToScreenIfDifferent(context.Context, *SwitchToScreenIfDifferentRequest) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method SwitchToScreenIfDifferent not implemented")
+}
+func (UnimplementedNativeServiceServer) DoNotUseThisIsForCrashTestingOnly(context.Context, *Empty) (*Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method DoNotUseThisIsForCrashTestingOnly not implemented")
+}
+func (UnimplementedNativeServiceServer) StreamEvents(*Empty, NativeService_StreamEventsServer) error {
+	return status.Errorf(codes.Unimplemented, "method StreamEvents not implemented")
+}
+func (UnimplementedNativeServiceServer) mustEmbedUnimplementedNativeServiceServer() {}
+
+// UnsafeNativeServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to NativeServiceServer will
+// result in compilation errors.
+type UnsafeNativeServiceServer interface {
+	mustEmbedUnimplementedNativeServiceServer()
+}
+
+func RegisterNativeServiceServer(s grpc.ServiceRegistrar, srv NativeServiceServer) {
+	s.RegisterService(&NativeService_ServiceDesc, srv)
+}
+
+func _NativeService_IsReady_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(IsReadyRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).IsReady(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_IsReady_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).IsReady(ctx, req.(*IsReadyRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoSetSleepMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(VideoSetSleepModeRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoSetSleepMode(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoSetSleepMode_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoSetSleepMode(ctx, req.(*VideoSetSleepModeRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoGetSleepMode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoGetSleepMode(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoGetSleepMode_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoGetSleepMode(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoSleepModeSupported_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoSleepModeSupported(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoSleepModeSupported_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoSleepModeSupported(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoSetQualityFactor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(VideoSetQualityFactorRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoSetQualityFactor(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoSetQualityFactor_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoSetQualityFactor(ctx, req.(*VideoSetQualityFactorRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoGetQualityFactor_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoGetQualityFactor(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoGetQualityFactor_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoGetQualityFactor(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoSetEDID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(VideoSetEDIDRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoSetEDID(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoSetEDID_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoSetEDID(ctx, req.(*VideoSetEDIDRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoGetEDID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoGetEDID(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoGetEDID_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoGetEDID(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoLogStatus_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoLogStatus(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoLogStatus_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoLogStatus(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoStop_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoStop(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoStop_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoStop(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_VideoStart_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).VideoStart(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_VideoStart_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).VideoStart(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_GetLVGLVersion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).GetLVGLVersion(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_GetLVGLVersion_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).GetLVGLVersion(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjHide_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjHideRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjHide(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjHide_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjHide(ctx, req.(*UIObjHideRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjShow_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjShowRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjShow(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjShow_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjShow(ctx, req.(*UIObjShowRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UISetVar_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UISetVarRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UISetVar(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UISetVar_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UISetVar(ctx, req.(*UISetVarRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIGetVar_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIGetVarRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIGetVar(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIGetVar_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIGetVar(ctx, req.(*UIGetVarRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjAddState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjAddStateRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjAddState(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjAddState_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjAddState(ctx, req.(*UIObjAddStateRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjClearState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjClearStateRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjClearState(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjClearState_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjClearState(ctx, req.(*UIObjClearStateRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjAddFlag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjAddFlagRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjAddFlag(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjAddFlag_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjAddFlag(ctx, req.(*UIObjAddFlagRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjClearFlag_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjClearFlagRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjClearFlag(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjClearFlag_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjClearFlag(ctx, req.(*UIObjClearFlagRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjSetOpacity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjSetOpacityRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjSetOpacity(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjSetOpacity_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjSetOpacity(ctx, req.(*UIObjSetOpacityRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjFadeIn_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjFadeInRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjFadeIn(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjFadeIn_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjFadeIn(ctx, req.(*UIObjFadeInRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjFadeOut_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjFadeOutRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjFadeOut(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjFadeOut_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjFadeOut(ctx, req.(*UIObjFadeOutRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjSetLabelText_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjSetLabelTextRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjSetLabelText(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjSetLabelText_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjSetLabelText(ctx, req.(*UIObjSetLabelTextRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UIObjSetImageSrc_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UIObjSetImageSrcRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UIObjSetImageSrc(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UIObjSetImageSrc_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UIObjSetImageSrc(ctx, req.(*UIObjSetImageSrcRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_DisplaySetRotation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(DisplaySetRotationRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).DisplaySetRotation(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_DisplaySetRotation_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).DisplaySetRotation(ctx, req.(*DisplaySetRotationRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UpdateLabelIfChanged_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UpdateLabelIfChangedRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UpdateLabelIfChanged(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UpdateLabelIfChanged_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UpdateLabelIfChanged(ctx, req.(*UpdateLabelIfChangedRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_UpdateLabelAndChangeVisibility_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UpdateLabelAndChangeVisibilityRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).UpdateLabelAndChangeVisibility(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_UpdateLabelAndChangeVisibility_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).UpdateLabelAndChangeVisibility(ctx, req.(*UpdateLabelAndChangeVisibilityRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_SwitchToScreenIf_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(SwitchToScreenIfRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).SwitchToScreenIf(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_SwitchToScreenIf_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).SwitchToScreenIf(ctx, req.(*SwitchToScreenIfRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_SwitchToScreenIfDifferent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(SwitchToScreenIfDifferentRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).SwitchToScreenIfDifferent(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_SwitchToScreenIfDifferent_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).SwitchToScreenIfDifferent(ctx, req.(*SwitchToScreenIfDifferentRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_DoNotUseThisIsForCrashTestingOnly_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(Empty)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(NativeServiceServer).DoNotUseThisIsForCrashTestingOnly(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: NativeService_DoNotUseThisIsForCrashTestingOnly_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(NativeServiceServer).DoNotUseThisIsForCrashTestingOnly(ctx, req.(*Empty))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _NativeService_StreamEvents_Handler(srv interface{}, stream grpc.ServerStream) error {
+	m := new(Empty)
+	if err := stream.RecvMsg(m); err != nil {
+		return err
+	}
+	return srv.(NativeServiceServer).StreamEvents(m, &nativeServiceStreamEventsServer{ServerStream: stream})
+}
+
+type NativeService_StreamEventsServer interface {
+	Send(*Event) error
+	grpc.ServerStream
+}
+
+type nativeServiceStreamEventsServer struct {
+	grpc.ServerStream
+}
+
+func (x *nativeServiceStreamEventsServer) Send(m *Event) error {
+	return x.ServerStream.SendMsg(m)
+}
+
+// NativeService_ServiceDesc is the grpc.ServiceDesc for NativeService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var NativeService_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "native.NativeService",
+	HandlerType: (*NativeServiceServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "IsReady",
+			Handler:    _NativeService_IsReady_Handler,
+		},
+		{
+			MethodName: "VideoSetSleepMode",
+			Handler:    _NativeService_VideoSetSleepMode_Handler,
+		},
+		{
+			MethodName: "VideoGetSleepMode",
+			Handler:    _NativeService_VideoGetSleepMode_Handler,
+		},
+		{
+			MethodName: "VideoSleepModeSupported",
+			Handler:    _NativeService_VideoSleepModeSupported_Handler,
+		},
+		{
+			MethodName: "VideoSetQualityFactor",
+			Handler:    _NativeService_VideoSetQualityFactor_Handler,
+		},
+		{
+			MethodName: "VideoGetQualityFactor",
+			Handler:    _NativeService_VideoGetQualityFactor_Handler,
+		},
+		{
+			MethodName: "VideoSetEDID",
+			Handler:    _NativeService_VideoSetEDID_Handler,
+		},
+		{
+			MethodName: "VideoGetEDID",
+			Handler:    _NativeService_VideoGetEDID_Handler,
+		},
+		{
+			MethodName: "VideoLogStatus",
+			Handler:    _NativeService_VideoLogStatus_Handler,
+		},
+		{
+			MethodName: "VideoStop",
+			Handler:    _NativeService_VideoStop_Handler,
+		},
+		{
+			MethodName: "VideoStart",
+			Handler:    _NativeService_VideoStart_Handler,
+		},
+		{
+			MethodName: "GetLVGLVersion",
+			Handler:    _NativeService_GetLVGLVersion_Handler,
+		},
+		{
+			MethodName: "UIObjHide",
+			Handler:    _NativeService_UIObjHide_Handler,
+		},
+		{
+			MethodName: "UIObjShow",
+			Handler:    _NativeService_UIObjShow_Handler,
+		},
+		{
+			MethodName: "UISetVar",
+			Handler:    _NativeService_UISetVar_Handler,
+		},
+		{
+			MethodName: "UIGetVar",
+			Handler:    _NativeService_UIGetVar_Handler,
+		},
+		{
+			MethodName: "UIObjAddState",
+			Handler:    _NativeService_UIObjAddState_Handler,
+		},
+		{
+			MethodName: "UIObjClearState",
+			Handler:    _NativeService_UIObjClearState_Handler,
+		},
+		{
+			MethodName: "UIObjAddFlag",
+			Handler:    _NativeService_UIObjAddFlag_Handler,
+		},
+		{
+			MethodName: "UIObjClearFlag",
+			Handler:    _NativeService_UIObjClearFlag_Handler,
+		},
+		{
+			MethodName: "UIObjSetOpacity",
+			Handler:    _NativeService_UIObjSetOpacity_Handler,
+		},
+		{
+			MethodName: "UIObjFadeIn",
+			Handler:    _NativeService_UIObjFadeIn_Handler,
+		},
+		{
+			MethodName: "UIObjFadeOut",
+			Handler:    _NativeService_UIObjFadeOut_Handler,
+		},
+		{
+			MethodName: "UIObjSetLabelText",
+			Handler:    _NativeService_UIObjSetLabelText_Handler,
+		},
+		{
+			MethodName: "UIObjSetImageSrc",
+			Handler:    _NativeService_UIObjSetImageSrc_Handler,
+		},
+		{
+			MethodName: "DisplaySetRotation",
+			Handler:    _NativeService_DisplaySetRotation_Handler,
+		},
+		{
+			MethodName: "UpdateLabelIfChanged",
+			Handler:    _NativeService_UpdateLabelIfChanged_Handler,
+		},
+		{
+			MethodName: "UpdateLabelAndChangeVisibility",
+			Handler:    _NativeService_UpdateLabelAndChangeVisibility_Handler,
+		},
+		{
+			MethodName: "SwitchToScreenIf",
+			Handler:    _NativeService_SwitchToScreenIf_Handler,
+		},
+		{
+			MethodName: "SwitchToScreenIfDifferent",
+			Handler:    _NativeService_SwitchToScreenIfDifferent_Handler,
+		},
+		{
+			MethodName: "DoNotUseThisIsForCrashTestingOnly",
+			Handler:    _NativeService_DoNotUseThisIsForCrashTestingOnly_Handler,
+		},
+	},
+	Streams: []grpc.StreamDesc{
+		{
+			StreamName:    "StreamEvents",
+			Handler:       _NativeService_StreamEvents_Handler,
+			ServerStreams: true,
+		},
+	},
+	Metadata: "internal/native/proto/native.proto",
+}
diff --git a/internal/native/proxy.go b/internal/native/proxy.go
new file mode 100644
index 0000000..01f0bb1
--- /dev/null
+++ b/internal/native/proxy.go
@@ -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
+	})
+}
diff --git a/internal/native/server.go b/internal/native/server.go
new file mode 100644
index 0000000..ae98315
--- /dev/null
+++ b/internal/native/server.go
@@ -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")
+}
diff --git a/internal/network/types/config.go b/internal/network/types/config.go
index 364f860..33afbcc 100644
--- a/internal/network/types/config.go
+++ b/internal/network/types/config.go
@@ -30,7 +30,7 @@ type MDNSListenOptions struct {
 
 // NetworkConfig represents the complete network configuration for an interface
 type NetworkConfig struct {
-	DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"jetdhcpc"`
+	DHCPClient null.String `json:"dhcp_client,omitempty" one_of:"jetdhcpc,udhcpc" default:"udhcpc"`
 
 	Hostname  null.String `json:"hostname,omitempty" validate_type:"hostname"`
 	HTTPProxy null.String `json:"http_proxy,omitempty" validate_type:"proxy"`
diff --git a/internal/ota/app.go b/internal/ota/app.go
new file mode 100644
index 0000000..55caa8e
--- /dev/null
+++ b/internal/ota/app.go
@@ -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
+}
diff --git a/internal/ota/errors.go b/internal/ota/errors.go
new file mode 100644
index 0000000..a1d0b4c
--- /dev/null
+++ b/internal/ota/errors.go
@@ -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
+}
diff --git a/internal/ota/ota.go b/internal/ota/ota.go
new file mode 100644
index 0000000..52b38c7
--- /dev/null
+++ b/internal/ota/ota.go
@@ -0,0 +1,436 @@
+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)
+	}
+
+	if !appUpdate.pending && !systemUpdate.pending {
+		scopedLogger.Info().Msg("No updates available")
+		s.updating = false
+		s.triggerStateUpdate()
+		return nil
+	}
+
+	scopedLogger.Trace().Bool("pending", appUpdate.pending).Msg("Checking for app update")
+
+	if appUpdate.pending {
+		scopedLogger.Info().
+			Str("url", appUpdate.url).
+			Str("hash", appUpdate.hash).
+			Msg("App update available")
+
+		if err := s.updateApp(ctx, appUpdate); err != nil {
+			return s.componentUpdateError("Error updating app", err, &scopedLogger)
+		}
+	} else {
+		scopedLogger.Info().Msg("App is up to date")
+	}
+
+	scopedLogger.Trace().Bool("pending", systemUpdate.pending).Msg("Checking for system update")
+
+	if systemUpdate.pending {
+		if err := s.updateSystem(ctx, systemUpdate); err != nil {
+			return s.componentUpdateError("Error updating system", err, &scopedLogger)
+		}
+	} else {
+		scopedLogger.Info().Msg("System is up to date")
+	}
+
+	if s.rebootNeeded {
+		if appUpdate.customVersionUpdate || systemUpdate.customVersionUpdate {
+			scopedLogger.Info().Msg("disabling auto-update due to custom version update")
+			// If they are explicitly updating a custom version, we assume they want to disable auto-update
+			if _, err := s.setAutoUpdate(false); err != nil {
+				scopedLogger.Warn().Err(err).Msg("Failed to disable auto-update")
+			}
+		}
+
+		scopedLogger.Info().Msg("System Rebooting due to OTA update")
+
+		redirectUrl := "/settings/general/update"
+
+		if params.ResetConfig {
+			scopedLogger.Info().Msg("Resetting config")
+			if err := s.resetConfig(); err != nil {
+				return s.componentUpdateError("Error resetting config", err, &scopedLogger)
+			}
+			redirectUrl = "/welcome"
+		}
+
+		postRebootAction := &PostRebootAction{
+			HealthCheck: "/device/status",
+			RedirectTo:  redirectUrl,
+		}
+
+		// REBOOT_REDIRECT_DELAY_MS is 7 seconds in the UI,
+		// it means that healthCheckUrl will be called after 7 seconds that we send willReboot JSONRPC event
+		// so we need to reboot it within 7 seconds to avoid it being called before the device is rebooted
+		if err := s.reboot(true, postRebootAction, 5*time.Second); err != nil {
+			return s.componentUpdateError("Error requesting reboot", err, &scopedLogger)
+		}
+	}
+
+	// We don't need set the updating flag to false here. Either it will;
+	// - set to false by the componentUpdateError function
+	// - device will reboot
+	return nil
+}
+
+// UpdateParams represents the parameters for the update
+type UpdateParams struct {
+	DeviceID          string            `json:"deviceID"`
+	Components        map[string]string `json:"components"`
+	IncludePreRelease bool              `json:"includePreRelease"`
+	ResetConfig       bool              `json:"resetConfig"`
+	// RequestID is a unique identifier for the update request
+	// When it's set, detailed trace logs will be enabled (if the log level is Trace)
+	RequestID string
+}
+
+// getUpdateStatus gets the update status for the given components
+// and updates the componentUpdateStatuses map
+func (s *State) getUpdateStatus(
+	ctx context.Context,
+	params UpdateParams,
+) (
+	appUpdate *componentUpdateStatus,
+	systemUpdate *componentUpdateStatus,
+	err error,
+) {
+	appUpdate = &componentUpdateStatus{}
+	systemUpdate = &componentUpdateStatus{}
+
+	if currentAppUpdate, ok := s.componentUpdateStatuses["app"]; ok {
+		appUpdate = &currentAppUpdate
+	}
+
+	if currentSystemUpdate, ok := s.componentUpdateStatuses["system"]; ok {
+		systemUpdate = &currentSystemUpdate
+	}
+
+	err = s.checkUpdateStatus(ctx, params, appUpdate, systemUpdate)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	s.componentUpdateStatuses["app"] = *appUpdate
+	s.componentUpdateStatuses["system"] = *systemUpdate
+
+	return appUpdate, systemUpdate, nil
+}
+
+// checkUpdateStatus checks the update status for the given components
+func (s *State) checkUpdateStatus(
+	ctx context.Context,
+	params UpdateParams,
+	appUpdateStatus *componentUpdateStatus,
+	systemUpdateStatus *componentUpdateStatus,
+) error {
+	// get the local versions
+	systemVersionLocal, appVersionLocal, err := s.getLocalVersion()
+	if err != nil {
+		return fmt.Errorf("error getting local version: %w", err)
+	}
+	appUpdateStatus.localVersion = appVersionLocal.String()
+	systemUpdateStatus.localVersion = systemVersionLocal.String()
+
+	logger := s.l.With().Logger()
+	if params.RequestID != "" {
+		logger = logger.With().Str("requestID", params.RequestID).Logger()
+	}
+	t := time.Now()
+
+	logger.Trace().
+		Str("appVersionLocal", appVersionLocal.String()).
+		Str("systemVersionLocal", systemVersionLocal.String()).
+		Dur("duration", time.Since(t)).
+		Msg("checkUpdateStatus: getLocalVersion")
+
+	// fetch the remote metadata
+	remoteMetadata, err := s.fetchUpdateMetadata(ctx, params)
+	if err != nil {
+		if err == ErrVersionNotFound || errors.Unwrap(err) == ErrVersionNotFound {
+			err = ErrVersionNotFound
+		} else {
+			err = fmt.Errorf("error checking for updates: %w", err)
+		}
+		return err
+	}
+
+	logger.Trace().
+		Interface("remoteMetadata", remoteMetadata).
+		Dur("duration", time.Since(t)).
+		Msg("checkUpdateStatus: fetchUpdateMetadata")
+
+	// parse the remote metadata to the componentUpdateStatuses
+	if err := remoteMetadataToComponentStatus(
+		remoteMetadata,
+		"app",
+		appUpdateStatus,
+		params,
+	); err != nil {
+		return fmt.Errorf("error parsing remote app version: %w", err)
+	}
+
+	if err := remoteMetadataToComponentStatus(
+		remoteMetadata,
+		"system",
+		systemUpdateStatus,
+		params,
+	); err != nil {
+		return fmt.Errorf("error parsing remote system version: %w", err)
+	}
+
+	if s.l.GetLevel() <= zerolog.TraceLevel {
+		appUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [app]")
+		systemUpdateStatus.getZerologLogger(&logger).Trace().Msg("checkUpdateStatus: remoteMetadataToComponentStatus [system]")
+	}
+
+	logger.Trace().
+		Dur("duration", time.Since(t)).
+		Msg("checkUpdateStatus: completed")
+
+	return nil
+}
+
+// GetUpdateStatus returns the current update status (for backwards compatibility)
+func (s *State) GetUpdateStatus(ctx context.Context, params UpdateParams) (*UpdateStatus, error) {
+	// if no components are specified, use the default components
+	// we should remove this once app router feature is released
+	if len(params.Components) == 0 {
+		params.Components = defaultComponents
+	}
+
+	appUpdateStatus := componentUpdateStatus{}
+	systemUpdateStatus := componentUpdateStatus{}
+	err := s.checkUpdateStatus(ctx, params, &appUpdateStatus, &systemUpdateStatus)
+	if err != nil {
+		return nil, fmt.Errorf("error getting update status: %w", err)
+	}
+
+	return toUpdateStatus(&appUpdateStatus, &systemUpdateStatus, ""), nil
+}
diff --git a/internal/ota/rpc.go b/internal/ota/rpc.go
new file mode 100644
index 0000000..30f132e
--- /dev/null
+++ b/internal/ota/rpc.go
@@ -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
+}
diff --git a/internal/ota/state.go b/internal/ota/state.go
new file mode 100644
index 0000000..2bb7055
--- /dev/null
+++ b/internal/ota/state.go
@@ -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
+}
diff --git a/internal/ota/sys.go b/internal/ota/sys.go
new file mode 100644
index 0000000..6a5002f
--- /dev/null
+++ b/internal/ota/sys.go
@@ -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")
+}
diff --git a/internal/ota/utils.go b/internal/ota/utils.go
new file mode 100644
index 0000000..b03db34
--- /dev/null
+++ b/internal/ota/utils.go
@@ -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
+}
diff --git a/internal/supervisor/consts.go b/internal/supervisor/consts.go
new file mode 100644
index 0000000..ab25055
--- /dev/null
+++ b/internal/supervisor/consts.go
@@ -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"
+)
diff --git a/internal/utils/env.go b/internal/utils/env.go
new file mode 100644
index 0000000..4040b96
--- /dev/null
+++ b/internal/utils/env.go
@@ -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
+}
diff --git a/jsonrpc.go b/jsonrpc.go
index d30f666..b401ac5 100644
--- a/jsonrpc.go
+++ b/jsonrpc.go
@@ -123,6 +123,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
 		Interface("id", request.ID).Logger()
 
 	scopedLogger.Trace().Msg("Received RPC request")
+	t := time.Now()
 
 	handler, ok := rpcHandlers[request.Method]
 	if !ok {
@@ -154,7 +155,7 @@ func onRPCMessage(message webrtc.DataChannelMessage, session *Session) {
 		return
 	}
 
-	scopedLogger.Trace().Interface("result", result).Msg("RPC handler returned")
+	scopedLogger.Trace().Dur("duration", time.Since(t)).Interface("result", result).Msg("RPC handler returned")
 
 	response := JSONRPCResponse{
 		JSONRPC: "2.0",
@@ -236,55 +237,6 @@ func rpcGetVideoLogStatus() (string, error) {
 	return nativeInstance.VideoLogStatus()
 }
 
-func rpcGetDevChannelState() (bool, error) {
-	return config.IncludePreRelease, nil
-}
-
-func rpcSetDevChannelState(enabled bool) error {
-	config.IncludePreRelease = enabled
-	if err := SaveConfig(); err != nil {
-		return fmt.Errorf("failed to save config: %w", err)
-	}
-	return nil
-}
-
-func rpcGetUpdateStatus() (*UpdateStatus, error) {
-	includePreRelease := config.IncludePreRelease
-	updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
-	// to ensure backwards compatibility,
-	// if there's an error, we won't return an error, but we will set the error field
-	if err != nil {
-		if updateStatus == nil {
-			return nil, fmt.Errorf("error checking for updates: %w", err)
-		}
-		updateStatus.Error = err.Error()
-	}
-
-	return updateStatus, nil
-}
-
-func rpcGetLocalVersion() (*LocalMetadata, error) {
-	systemVersion, appVersion, err := GetLocalVersion()
-	if err != nil {
-		return nil, fmt.Errorf("error getting local version: %w", err)
-	}
-	return &LocalMetadata{
-		AppVersion:    appVersion.String(),
-		SystemVersion: systemVersion.String(),
-	}, nil
-}
-
-func rpcTryUpdate() error {
-	includePreRelease := config.IncludePreRelease
-	go func() {
-		err := TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
-		if err != nil {
-			logger.Warn().Err(err).Msg("failed to try update")
-		}
-	}()
-	return nil
-}
-
 func rpcSetDisplayRotation(params DisplayRotationSettings) error {
 	currentRotation := config.DisplayRotation
 	if currentRotation == params.Rotation {
@@ -654,7 +606,7 @@ func rpcGetMassStorageMode() (string, error) {
 }
 
 func rpcIsUpdatePending() (bool, error) {
-	return IsUpdatePending(), nil
+	return otaState.IsUpdatePending(), nil
 }
 
 func rpcGetUsbEmulationState() (bool, error) {
@@ -1204,7 +1156,10 @@ var rpcHandlers = map[string]RPCHandler{
 	"setDevChannelState":     {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
 	"getLocalVersion":        {Func: rpcGetLocalVersion},
 	"getUpdateStatus":        {Func: rpcGetUpdateStatus},
+	"checkUpdateComponents":  {Func: rpcCheckUpdateComponents, Params: []string{"params", "includePreRelease"}},
+	"getUpdateStatusChannel": {Func: rpcGetUpdateStatusChannel},
 	"tryUpdate":              {Func: rpcTryUpdate},
+	"tryUpdateComponents":    {Func: rpcTryUpdateComponents, Params: []string{"params", "includePreRelease", "resetConfig"}},
 	"getDevModeState":        {Func: rpcGetDevModeState},
 	"setDevModeState":        {Func: rpcSetDevModeState, Params: []string{"enabled"}},
 	"getSSHKeyState":         {Func: rpcGetSSHKeyState},
diff --git a/main.go b/main.go
index d7ecc3e..88d2dec 100644
--- a/main.go
+++ b/main.go
@@ -2,22 +2,37 @@ package kvm
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 	"os"
 	"os/signal"
 	"syscall"
 	"time"
 
+	"github.com/erikdubbelboer/gspt"
 	"github.com/gwatts/rootcerts"
+	"github.com/jetkvm/kvm/internal/ota"
 )
 
 var appCtx context.Context
+var procPrefix string = "jetkvm: [app]"
+
+func setProcTitle(status string) {
+	if status != "" {
+		status = " " + status
+	}
+	title := fmt.Sprintf("%s%s", procPrefix, status)
+	gspt.SetProcTitle(title)
+}
 
 func Main() {
+	setProcTitle("starting")
+
 	logger.Log().Msg("JetKVM Starting Up")
 
 	checkFailsafeReason()
 	if failsafeModeActive {
+		procPrefix = "jetkvm: [app+failsafe]"
 		logger.Warn().Str("reason", failsafeModeReason).Msg("failsafe mode activated")
 	}
 
@@ -38,10 +53,10 @@ func Main() {
 		Msg("starting JetKVM")
 
 	go runWatchdog()
-	go confirmCurrentSystem()
 
-	initDisplay()
+	setProcTitle("initNative")
 	initNative(systemVersionLocal, appVersionLocal)
+	initDisplay()
 
 	http.DefaultClient.Timeout = 1 * time.Minute
 
@@ -53,7 +68,12 @@ func Main() {
 		Int("ca_certs_loaded", len(rootcerts.Certs())).
 		Msg("loaded Root CA certificates")
 
+	initOta()
+
+	http.DefaultClient.Timeout = 1 * time.Minute
+
 	// Initialize network
+	setProcTitle("initNetwork")
 	if err := initNetwork(); err != nil {
 		logger.Error().Err(err).Msg("failed to initialize network")
 		// TODO: reset config to default
@@ -61,17 +81,21 @@ func Main() {
 	}
 
 	// Initialize time sync
+	setProcTitle("initTimeSync")
 	initTimeSync()
 	timeSync.Start()
 
 	// Initialize mDNS
+	setProcTitle("initMdns")
 	if err := initMdns(); err != nil {
 		logger.Error().Err(err).Msg("failed to initialize mDNS")
 	}
 
+	setProcTitle("initPrometheus")
 	initPrometheus()
 
 	// initialize usb gadget
+	setProcTitle("initUsbGadget")
 	initUsbGadget()
 	if err := setInitialVirtualMediaState(); err != nil {
 		logger.Warn().Err(err).Msg("failed to set initial virtual media state")
@@ -112,7 +136,10 @@ func Main() {
 			}
 
 			includePreRelease := config.IncludePreRelease
-			err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
+			err = otaState.TryUpdate(context.Background(), ota.UpdateParams{
+				DeviceID:          GetDeviceID(),
+				IncludePreRelease: includePreRelease,
+			})
 			if err != nil {
 				logger.Warn().Err(err).Msg("failed to auto update")
 			}
@@ -135,6 +162,9 @@ func Main() {
 	initPublicIPState()
 
 	initSerialPort()
+
+	setProcTitle("ready")
+
 	sigs := make(chan os.Signal, 1)
 	signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
 	<-sigs
diff --git a/native.go b/native.go
index 81a0e50..5f09ef8 100644
--- a/native.go
+++ b/native.go
@@ -11,17 +11,28 @@ import (
 )
 
 var (
-	nativeInstance *native.Native
+	nativeInstance native.NativeInterface
 	nativeCmdLock  = sync.Mutex{}
 )
 
 func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
-	nativeInstance = native.NewNative(native.NativeOptions{
-		Disable:              failsafeModeActive,
+	if failsafeModeActive {
+		nativeInstance = &native.EmptyNativeInterface{}
+		nativeLogger.Warn().Msg("failsafe mode active, using empty native interface")
+		return
+	}
+
+	nativeLogger.Info().Msg("initializing native proxy")
+	var err error
+	nativeInstance, err = native.NewNativeProxy(native.NativeOptions{
 		SystemVersion:        systemVersion,
 		AppVersion:           appVersion,
 		DisplayRotation:      config.GetDisplayRotation(),
 		DefaultQualityFactor: config.VideoQualityFactor,
+		MaxRestartAttempts:   config.NativeMaxRestart,
+		OnNativeRestart: func() {
+			configureDisplayOnNativeRestart()
+		},
 		OnVideoStateChange: func(state native.VideoState) {
 			lastVideoState = state
 			triggerVideoStateUpdate()
@@ -63,8 +74,13 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
 			}
 		},
 	})
+	if err != nil {
+		nativeLogger.Fatal().Err(err).Msg("failed to create native proxy")
+	}
 
-	nativeInstance.Start()
+	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")
diff --git a/network.go b/network.go
index 3a2b24f..eb14c70 100644
--- a/network.go
+++ b/network.go
@@ -11,6 +11,7 @@ import (
 	"github.com/jetkvm/kvm/internal/confparser"
 	"github.com/jetkvm/kvm/internal/mdns"
 	"github.com/jetkvm/kvm/internal/network/types"
+	"github.com/jetkvm/kvm/internal/ota"
 	"github.com/jetkvm/kvm/pkg/myip"
 	"github.com/jetkvm/kvm/pkg/nmlite"
 	"github.com/jetkvm/kvm/pkg/nmlite/link"
@@ -225,7 +226,7 @@ func setHostname(nm *nmlite.NetworkManager, hostname, domain string) error {
 	return nm.SetHostname(hostname, domain)
 }
 
-func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *PostRebootAction) {
+func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (rebootRequired bool, postRebootAction *ota.PostRebootAction) {
 	oldDhcpClient := oldConfig.DHCPClient.String
 
 	l := networkLogger.With().
@@ -249,7 +250,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
 		l.Info().Msg("IPv4 mode changed with udhcpc, reboot required")
 
 		if newIPv4Mode == "static" && oldIPv4Mode != "static" {
-			postRebootAction = &PostRebootAction{
+			postRebootAction = &ota.PostRebootAction{
 				HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
 				RedirectTo:  fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
 			}
@@ -266,7 +267,7 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
 		// Handle IP change for redirect (only if both are not nil and IP changed)
 		if newConfig.IPv4Static != nil && oldConfig.IPv4Static != nil &&
 			newConfig.IPv4Static.Address.String != oldConfig.IPv4Static.Address.String {
-			postRebootAction = &PostRebootAction{
+			postRebootAction = &ota.PostRebootAction{
 				HealthCheck: fmt.Sprintf("//%s/device/status", newConfig.IPv4Static.Address.String),
 				RedirectTo:  fmt.Sprintf("//%s", newConfig.IPv4Static.Address.String),
 			}
@@ -283,6 +284,11 @@ func shouldRebootForNetworkChange(oldConfig, newConfig *types.NetworkConfig) (re
 		l.Info().Msg("IPv6 mode changed with udhcpc, reboot required")
 	}
 
+	if newConfig.Hostname.String != oldConfig.Hostname.String {
+		rebootRequired = true
+		l.Info().Msg("Hostname changed, reboot required")
+	}
+
 	return rebootRequired, postRebootAction
 }
 
diff --git a/ota.go b/ota.go
index 5371e42..ef7f9c2 100644
--- a/ota.go
+++ b/ota.go
@@ -1,59 +1,65 @@
 package kvm
 
 import (
-	"bytes"
 	"context"
-	"crypto/sha256"
-	"crypto/tls"
-	"encoding/hex"
-	"encoding/json"
 	"fmt"
-	"io"
 	"net/http"
-	"net/url"
 	"os"
-	"os/exec"
 	"strings"
-	"time"
 
 	"github.com/Masterminds/semver/v3"
-	"github.com/gwatts/rootcerts"
-	"github.com/rs/zerolog"
+	"github.com/google/uuid"
+	"github.com/jetkvm/kvm/internal/ota"
 )
 
-type UpdateMetadata struct {
-	AppVersion    string `json:"appVersion"`
-	AppUrl        string `json:"appUrl"`
-	AppHash       string `json:"appHash"`
-	SystemVersion string `json:"systemVersion"`
-	SystemUrl     string `json:"systemUrl"`
-	SystemHash    string `json:"systemHash"`
-}
+var builtAppVersion = "0.1.0+dev"
 
-type LocalMetadata struct {
-	AppVersion    string `json:"appVersion"`
-	SystemVersion string `json:"systemVersion"`
-}
+var otaState *ota.State
 
-// 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"`
+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()
 
-	// for backwards compatibility
-	Error string `json:"error,omitempty"`
+			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)
+		},
+	})
 }
 
-const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
-
-var builtAppVersion = "0.1.0+dev"
+func triggerOTAStateUpdate(state *ota.RPCState) {
+	go func() {
+		if currentSession == nil || (otaState == nil && state == nil) {
+			return
+		}
+		if state == nil {
+			state = otaState.ToRPCState()
+		}
+		writeJSONRPCEvent("otaState", state, currentSession)
+	}()
+}
 
+// GetBuiltAppVersion returns the built-in app version
 func GetBuiltAppVersion() string {
 	return builtAppVersion
 }
 
+// GetLocalVersion returns the local version of the system and app
 func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
 	appVersion, err = semver.NewVersion(builtAppVersion)
 	if err != nil {
@@ -73,519 +79,107 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
 	return systemVersion, appVersion, nil
 }
 
-func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
-	metadata := &UpdateMetadata{}
-
-	updateUrl, err := url.Parse(UpdateMetadataUrl)
-	if err != nil {
-		return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
-	}
-
-	query := updateUrl.Query()
-	query.Set("deviceId", deviceId)
-	query.Set("prerelease", fmt.Sprintf("%v", includePreRelease))
-	updateUrl.RawQuery = query.Encode()
-
-	logger.Info().Str("url", updateUrl.String()).Msg("Checking for updates")
-
-	req, err := http.NewRequestWithContext(ctx, "GET", updateUrl.String(), nil)
-	if err != nil {
-		return nil, fmt.Errorf("error creating request: %w", err)
-	}
-
-	transport := http.DefaultTransport.(*http.Transport).Clone()
-	transport.Proxy = config.NetworkConfig.GetTransportProxyFunc()
-
-	client := &http.Client{
-		Transport: transport,
-	}
+func getUpdateStatus(includePreRelease bool) (*ota.UpdateStatus, error) {
+	updateStatus, err := otaState.GetUpdateStatus(context.Background(), ota.UpdateParams{
+		DeviceID:          GetDeviceID(),
+		IncludePreRelease: includePreRelease,
+		RequestID:         uuid.New().String(),
+	})
 
-	resp, err := client.Do(req)
+	// to ensure backwards compatibility,
+	// if there's an error, we won't return an error, but we will set the error field
 	if err != nil {
-		return nil, fmt.Errorf("error sending request: %w", err)
-	}
-	defer resp.Body.Close()
-
-	if resp.StatusCode != http.StatusOK {
-		return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
-	}
-
-	err = json.NewDecoder(resp.Body).Decode(metadata)
-	if err != nil {
-		return nil, fmt.Errorf("error decoding response: %w", err)
-	}
-
-	return metadata, nil
-}
-
-func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
-	if _, err := os.Stat(path); err == nil {
-		if err := os.Remove(path); err != nil {
-			return fmt.Errorf("error removing existing file: %w", err)
-		}
-	}
-
-	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)
+		if updateStatus == nil {
+			return nil, fmt.Errorf("error checking for updates: %w", err)
 		}
+		updateStatus.Error = err.Error()
 	}
 
-	file, err := os.Create(unverifiedPath)
-	if err != nil {
-		return fmt.Errorf("error creating file: %w", err)
+	// otaState doesn't have the current auto-update state, so we need to get it from the config
+	if updateStatus.WillDisableAutoUpdate {
+		updateStatus.WillDisableAutoUpdate = config.AutoUpdateEnabled
 	}
-	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(),
-			},
-		},
-	}
+	otaLogger.Info().Interface("updateStatus", updateStatus).Msg("Update status")
 
-	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()
+	return updateStatus, nil
+}
 
-	// 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)
-	}
+func rpcGetDevChannelState() (bool, error) {
+	return config.IncludePreRelease, nil
+}
 
-	// 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)
+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 verifyFile(path string, expectedHash string, verifyProgress *float32, scopedLogger *zerolog.Logger) error {
-	if scopedLogger == nil {
-		scopedLogger = otaLogger
-	}
+func rpcGetUpdateStatus() (*ota.UpdateStatus, error) {
+	return getUpdateStatus(config.IncludePreRelease)
+}
 
-	unverifiedPath := path + ".unverified"
-	fileToHash, err := os.Open(unverifiedPath)
-	if err != nil {
-		return fmt.Errorf("error opening file for hashing: %w", err)
+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)
 	}
-	defer fileToHash.Close()
+}
 
-	hash := sha256.New()
-	fileInfo, err := fileToHash.Stat()
+func rpcGetLocalVersion() (*ota.LocalMetadata, error) {
+	systemVersion, appVersion, err := GetLocalVersion()
 	if err != nil {
-		return fmt.Errorf("error getting file info: %w", err)
+		return nil, fmt.Errorf("error getting local version: %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
+	return &ota.LocalMetadata{
+		AppVersion:    appVersion.String(),
+		SystemVersion: systemVersion.String(),
+	}, 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"`
+type updateParams struct {
+	Components map[string]string `json:"components,omitempty"`
 }
 
-var otaState = OTAState{}
-
-func triggerOTAStateUpdate() {
-	go func() {
-		if currentSession == nil {
-			logger.Info().Msg("No active RPC session, skipping update state update")
-			return
-		}
-		writeJSONRPCEvent("otaState", otaState, currentSession)
-	}()
+func rpcTryUpdate() error {
+	return rpcTryUpdateComponents(updateParams{
+		Components: make(map[string]string),
+	}, config.IncludePreRelease, false)
 }
 
-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,
+// 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,
 	}
-	triggerOTAStateUpdate()
-
-	defer func() {
-		otaState.Updating = false
-		triggerOTAStateUpdate()
-	}()
-
-	updateStatus, err := GetUpdateStatus(ctx, deviceId, includePreRelease)
+	info, err := otaState.GetUpdateStatus(context.Background(), updateParams)
 	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)
+		return nil, fmt.Errorf("failed to check update: %w", err)
 	}
+	return info, nil
+}
 
-	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")
+func rpcTryUpdateComponents(params updateParams, includePreRelease bool, resetConfig bool) error {
+	updateParams := ota.UpdateParams{
+		DeviceID:          GetDeviceID(),
+		IncludePreRelease: includePreRelease,
+		ResetConfig:       resetConfig,
+		Components:        params.Components,
 	}
 
-	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()
+	go func() {
+		err := otaState.TryUpdate(context.Background(), updateParams)
 		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,
+			otaLogger.Warn().Err(err).Msg("failed to try update")
 		}
-
-		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
-}
-
-func IsUpdatePending() bool {
-	return otaState.Updating
-}
-
-// make sure our current a/b partition is set as default
-func confirmCurrentSystem() {
-	output, err := exec.Command("rk_ota", "--misc=now").CombinedOutput()
-	if err != nil {
-		logger.Warn().Str("output", string(output)).Msg("failed to set current partition in A/B setup")
-	}
-}
diff --git a/pkg/nmlite/interface_state.go b/pkg/nmlite/interface_state.go
index 087cf01..efa5f08 100644
--- a/pkg/nmlite/interface_state.go
+++ b/pkg/nmlite/interface_state.go
@@ -2,6 +2,7 @@ package nmlite
 
 import (
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/jetkvm/kvm/internal/network/types"
@@ -9,6 +10,40 @@ import (
 	"github.com/vishvananda/netlink"
 )
 
+type IfStateChangeReason uint
+
+const (
+	IfStateOperStateChanged   IfStateChangeReason = 1
+	IfStateOnlineStateChanged IfStateChangeReason = 2
+	IfStateMACAddressChanged  IfStateChangeReason = 3
+	IfStateIPAddressesChanged IfStateChangeReason = 4
+)
+
+type IfStateChangeReasons []IfStateChangeReason
+
+func (r IfStateChangeReason) String() string {
+	switch r {
+	case IfStateOperStateChanged:
+		return "oper state changed"
+	case IfStateOnlineStateChanged:
+		return "online state changed"
+	case IfStateMACAddressChanged:
+		return "MAC address changed"
+	case IfStateIPAddressesChanged:
+		return "IP addresses changed"
+	default:
+		return fmt.Sprintf("unknown change reason %d", r)
+	}
+}
+
+func (rs IfStateChangeReasons) String() string {
+	reasons := []string{}
+	for _, r := range rs {
+		reasons = append(reasons, r.String())
+	}
+	return strings.Join(reasons, ", ")
+}
+
 // updateInterfaceState updates the current interface state
 func (im *InterfaceManager) updateInterfaceState() error {
 	nl, err := im.link()
@@ -16,7 +51,10 @@ func (im *InterfaceManager) updateInterfaceState() error {
 		return fmt.Errorf("failed to get interface: %w", err)
 	}
 
-	var stateChanged bool
+	var (
+		stateChanged  bool
+		changeReasons IfStateChangeReasons
+	)
 
 	attrs := nl.Attrs()
 
@@ -29,6 +67,7 @@ func (im *InterfaceManager) updateInterfaceState() error {
 	if im.state.Up != isUp {
 		im.state.Up = isUp
 		stateChanged = true
+		changeReasons = append(changeReasons, IfStateOperStateChanged)
 	}
 
 	// Check if the interface is online
@@ -36,12 +75,14 @@ func (im *InterfaceManager) updateInterfaceState() error {
 	if im.state.Online != isOnline {
 		im.state.Online = isOnline
 		stateChanged = true
+		changeReasons = append(changeReasons, IfStateOnlineStateChanged)
 	}
 
 	// Check if the MAC address has changed
 	if im.state.MACAddress != attrs.HardwareAddr.String() {
 		im.state.MACAddress = attrs.HardwareAddr.String()
 		stateChanged = true
+		changeReasons = append(changeReasons, IfStateMACAddressChanged)
 	}
 
 	// Update IP addresses
@@ -49,6 +90,7 @@ func (im *InterfaceManager) updateInterfaceState() error {
 		im.logger.Error().Err(err).Msg("failed to update IP addresses")
 	} else if ipChanged {
 		stateChanged = true
+		changeReasons = append(changeReasons, IfStateIPAddressesChanged)
 	}
 
 	im.state.LastUpdated = time.Now()
@@ -56,7 +98,10 @@ func (im *InterfaceManager) updateInterfaceState() error {
 
 	// Notify callback if state changed
 	if stateChanged && im.onStateChange != nil {
-		im.logger.Debug().Interface("state", im.state).Msg("notifying state change")
+		im.logger.Debug().
+			Stringer("changeReasons", changeReasons).
+			Interface("state", im.state).
+			Msg("notifying state change")
 		im.onStateChange(*im.state)
 	}
 
@@ -80,6 +125,7 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool,
 		ipv6Gateway          string
 		ipv4Ready, ipv6Ready = false, false
 		stateChanged         = false
+		stateChangeReason    string
 	)
 
 	routes, _ := mgr.ListDefaultRoutes(link.AfInet6)
@@ -123,40 +169,55 @@ func (im *InterfaceManager) updateInterfaceStateAddresses(nl *link.Link) (bool,
 	if !sortAndCompareStringSlices(im.state.IPv4Addresses, ipv4Addresses) {
 		im.state.IPv4Addresses = ipv4Addresses
 		stateChanged = true
+		stateChangeReason = "IPv4 addresses changed"
 	}
 
 	if !sortAndCompareIPv6AddressSlices(im.state.IPv6Addresses, ipv6Addresses) {
 		im.state.IPv6Addresses = ipv6Addresses
 		stateChanged = true
+		stateChangeReason = "IPv6 addresses changed"
 	}
 
 	if im.state.IPv4Address != ipv4Addr {
 		im.state.IPv4Address = ipv4Addr
 		stateChanged = true
+		stateChangeReason = "IPv4 address changed"
 	}
 
 	if im.state.IPv6Address != ipv6Addr {
 		im.state.IPv6Address = ipv6Addr
 		stateChanged = true
+		stateChangeReason = "IPv6 address changed"
 	}
 	if im.state.IPv6LinkLocal != ipv6LinkLocal {
 		im.state.IPv6LinkLocal = ipv6LinkLocal
 		stateChanged = true
+		stateChangeReason = "IPv6 link local address changed"
 	}
 
 	if im.state.IPv6Gateway != ipv6Gateway {
 		im.state.IPv6Gateway = ipv6Gateway
 		stateChanged = true
+		stateChangeReason = "IPv6 gateway changed"
 	}
 
 	if im.state.IPv4Ready != ipv4Ready {
 		im.state.IPv4Ready = ipv4Ready
 		stateChanged = true
+		stateChangeReason = "IPv4 ready state changed"
 	}
 
 	if im.state.IPv6Ready != ipv6Ready {
 		im.state.IPv6Ready = ipv6Ready
 		stateChanged = true
+		stateChangeReason = "IPv6 ready state changed"
+	}
+
+	if stateChanged {
+		im.logger.Trace().
+			Str("changeReason", stateChangeReason).
+			Interface("state", im.state).
+			Msg("interface state changed")
 	}
 
 	return stateChanged, nil
diff --git a/pkg/nmlite/utils.go b/pkg/nmlite/utils.go
index 49ed007..11952d0 100644
--- a/pkg/nmlite/utils.go
+++ b/pkg/nmlite/utils.go
@@ -42,17 +42,19 @@ func sortAndCompareStringSlices(a, b []string) bool {
 	return true
 }
 
+func sortIPv6AddressSlicesStable(a []types.IPv6Address) {
+	sort.SliceStable(a, func(i, j int) bool {
+		return a[i].Address.String() < a[j].Address.String()
+	})
+}
+
 func sortAndCompareIPv6AddressSlices(a, b []types.IPv6Address) bool {
 	if len(a) != len(b) {
 		return false
 	}
 
-	sort.SliceStable(a, func(i, j int) bool {
-		return a[i].Address.String() < b[j].Address.String()
-	})
-	sort.SliceStable(b, func(i, j int) bool {
-		return b[i].Address.String() < a[j].Address.String()
-	})
+	sortIPv6AddressSlicesStable(a)
+	sortIPv6AddressSlicesStable(b)
 
 	for i := range a {
 		if a[i].Address.String() != b[i].Address.String() {
diff --git a/scripts/build_cgo.sh b/scripts/build_cgo.sh
index 87577e3..d67fa7c 100755
--- a/scripts/build_cgo.sh
+++ b/scripts/build_cgo.sh
@@ -4,6 +4,8 @@ set -e
 SCRIPT_PATH=$(realpath "$(dirname $(realpath "${BASH_SOURCE[0]}"))")
 source ${SCRIPT_PATH}/build_utils.sh
 
+CMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE:-Release}
+
 CGO_PATH=$(realpath "${SCRIPT_PATH}/../internal/native/cgo")
 BUILD_DIR=${CGO_PATH}/build
 
@@ -31,7 +33,7 @@ VERBOSE=1 cmake -B "${BUILD_DIR}" \
     -DCONFIG_LV_BUILD_EXAMPLES=OFF \
     -DCONFIG_LV_BUILD_DEMOS=OFF \
     -DSKIP_GLIBC_NAMES=ON \
-    -DCMAKE_BUILD_TYPE=Release \
+    -DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE} \
     -DCMAKE_INSTALL_PREFIX="${TMP_DIR}"
 
 msg_info "▶ Copying built library and header files"
diff --git a/scripts/configure_vscode.py b/scripts/configure_vscode.py
new file mode 100755
index 0000000..cac5468
--- /dev/null
+++ b/scripts/configure_vscode.py
@@ -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()
\ No newline at end of file
diff --git a/scripts/dev_deploy.sh b/scripts/dev_deploy.sh
index 6c8b204..96e7cf6 100755
--- a/scripts/dev_deploy.sh
+++ b/scripts/dev_deploy.sh
@@ -12,12 +12,14 @@ show_help() {
     echo
     echo "Optional:"
     echo "  -u, --user <remote_user>   Remote username (default: root)"
+    echo "      --gdb-port <port>      GDB debug port (default: 2345)"
     echo "      --run-go-tests         Run go tests"
     echo "      --run-go-tests-only    Run go tests and exit"
     echo "      --skip-ui-build        Skip frontend/UI build"
     echo "      --skip-native-build    Skip native build"
     echo "      --disable-docker       Disable docker build"
     echo "      --enable-sync-trace    Enable sync trace (do not use in release builds)"
+    echo "      --native-binary        Build and deploy the native binary (FOR DEBUGGING ONLY)"
     echo "  -i, --install              Build for release and install the app"
     echo "      --help                 Display this help message"
     echo
@@ -58,6 +60,8 @@ REMOTE_PATH="/userdata/jetkvm/bin"
 SKIP_UI_BUILD=false
 SKIP_UI_BUILD_RELEASE=0
 SKIP_NATIVE_BUILD=0
+GDB_DEBUG_PORT=2345
+BUILD_NATIVE_BINARY=false
 ENABLE_SYNC_TRACE=0
 RESET_USB_HID_DEVICE=false
 LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
@@ -79,6 +83,10 @@ while [[ $# -gt 0 ]]; do
             REMOTE_USER="$2"
             shift 2
             ;;
+        --gdb-port)
+            GDB_DEBUG_PORT="$2"
+            shift 2
+            ;;
         --skip-ui-build)
             SKIP_UI_BUILD=true
             shift
@@ -113,6 +121,10 @@ while [[ $# -gt 0 ]]; do
             RUN_GO_TESTS=true
             shift
             ;;
+        --native-binary)
+            BUILD_NATIVE_BINARY=true
+            shift
+            ;;
         -i|--install)
             INSTALL_APP=true
             shift
@@ -141,6 +153,10 @@ fi
 # Check device connectivity before proceeding
 check_ping "${REMOTE_HOST}"
 check_ssh "${REMOTE_USER}" "${REMOTE_HOST}"
+function sshdev() {
+    ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "$@"
+    return $?
+}
 
 # check if the current CPU architecture is x86_64
 if [ "$(uname -m)" != "x86_64" ]; then
@@ -152,6 +168,34 @@ if [ "$BUILD_IN_DOCKER" = true ]; then
     build_docker_image
 fi
 
+if [ "$BUILD_NATIVE_BINARY" = true ]; then
+    msg_info "▶ Building native binary"
+    CMAKE_BUILD_TYPE=Debug make build_native
+    msg_info "▶ Checking if GDB is available on remote host"
+    if ! sshdev "command -v gdbserver > /dev/null 2>&1"; then
+        msg_warn "Error: gdbserver is not installed on the remote host"
+        tar -czf - -C /opt/jetkvm-native-buildkit/gdb/ . | sshdev "tar -xzf - -C /usr/bin"
+        msg_info "✓ gdbserver installed on remote host"
+    fi
+    msg_info "▶ Stopping any existing instances of jetkvm_native_debug on remote host"
+    sshdev "killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug gdbserver || true >> /dev/null 2>&1"
+    sshdev "cat > ${REMOTE_PATH}/jetkvm_native_debug" < internal/native/cgo/build/jknative-bin
+    sshdev -t ash << EOF
+set -e
+
+# Set the library path to include the directory where librockit.so is located
+export LD_LIBRARY_PATH=/oem/usr/lib:\$LD_LIBRARY_PATH
+
+cd ${REMOTE_PATH}
+killall -9 jetkvm_app jetkvm_app_debug jetkvm_native_debug || true
+sleep 5
+echo 'V' > /dev/watchdog
+chmod +x jetkvm_native_debug
+gdbserver localhost:${GDB_DEBUG_PORT} ./jetkvm_native_debug
+EOF
+    exit 0
+fi
+
 # Build the development version on the host
 # When using `make build_release`, the frontend will be built regardless of the `SKIP_UI_BUILD` flag
 # check if static/index.html exists
@@ -176,10 +220,10 @@ if [ "$RUN_GO_TESTS" = true ]; then
     make build_dev_test
 
     msg_info "▶ Copying device-tests.tar.gz to remote host"
-    ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
+    sshdev "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
 
     msg_info "▶ Running go tests"
-    ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
+    sshdev ash << 'EOF'
 set -e
 TMP_DIR=$(mktemp -d)
 cd ${TMP_DIR}
@@ -222,10 +266,10 @@ then
     ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
 
 	# Copy the binary to the remote host as if we were the OTA updater.
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
+	sshdev "cat > /userdata/jetkvm/jetkvm_app.update" < bin/jetkvm_app
 
 	# Reboot the device, the new app will be deployed by the startup process.
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "reboot"
+	sshdev "reboot"
 else
 	msg_info "▶ Building development binary"
 	do_make build_dev \
@@ -234,21 +278,21 @@ else
     ENABLE_SYNC_TRACE=${ENABLE_SYNC_TRACE}
 
 	# Kill any existing instances of the application
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
+	sshdev "killall jetkvm_app_debug || true"
 
 	# Copy the binary to the remote host
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
+	sshdev "cat > ${REMOTE_PATH}/jetkvm_app_debug" < bin/jetkvm_app
 
 	if [ "$RESET_USB_HID_DEVICE" = true ]; then
 	msg_info "▶ Resetting USB HID device"
 	msg_warn "The option has been deprecated and will be removed in a future version, as JetKVM will now reset USB gadget configuration when needed"
 	# Remove the old USB gadget configuration
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
+	sshdev "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
+	sshdev "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
 	fi
 
 	# Deploy and run the application on the remote host
-	ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no "${REMOTE_USER}@${REMOTE_HOST}" ash << EOF
+	sshdev ash << EOF
 set -e
 
 # Set the library path to include the directory where librockit.so is located
diff --git a/scripts/generate_proto.sh b/scripts/generate_proto.sh
new file mode 100755
index 0000000..150bef9
--- /dev/null
+++ b/scripts/generate_proto.sh
@@ -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!"
+
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100644
index 0000000..b6afef9
--- /dev/null
+++ b/scripts/release.sh
@@ -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."
\ No newline at end of file
diff --git a/ui/localization/messages/da.json b/ui/localization/messages/da.json
index dae6e90..dc7d580 100644
--- a/ui/localization/messages/da.json
+++ b/ui/localization/messages/da.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Kunne ikke opdatere SSH-nøglen: {error}",
     "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
     "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
+    "advanced_error_version_update": "Kunne ikke starte versionsopdatering: {error}",
     "advanced_loopback_only_description": "Begræns webgrænsefladeadgang kun til localhost (127.0.0.1)",
     "advanced_loopback_only_title": "Kun loopback-tilstand",
     "advanced_loopback_warning_before": "Før du aktiverer denne funktion, skal du sikre dig, at du har enten:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Opdater SSH-nøgle",
     "advanced_usb_emulation_description": "Styr USB-emuleringstilstanden",
     "advanced_usb_emulation_title": "USB-emulering",
+    "advanced_version_update_app_label": "App-version",
+    "advanced_version_update_button": "Opdatering til version",
+    "advanced_version_update_description": "Installer en specifik version fra GitHub-udgivelser",
+    "advanced_version_update_github_link": "JetKVM-udgivelsesside",
+    "advanced_version_update_helper": "Find tilgængelige versioner på",
+    "advanced_version_update_reset_config_description": "Nulstil konfigurationen efter opdateringen",
+    "advanced_version_update_reset_config_label": "Nulstil konfiguration",
+    "advanced_version_update_system_label": "Systemversion",
+    "advanced_version_update_target_app": "Kun i appen",
+    "advanced_version_update_target_both": "Både app og system",
+    "advanced_version_update_target_label": "Hvad skal opdateres",
+    "advanced_version_update_target_system": "Kun systemet",
+    "advanced_version_update_title": "Opdatering til specifik version",
     "already_adopted_new_owner": "Hvis du er den nye ejer, bedes du bede den tidligere ejer om at afregistrere enheden fra sin konto i cloud-dashboardet. Hvis du mener, at dette er en fejl, kan du kontakte vores supportteam for at få hjælp.",
     "already_adopted_other_user": "Denne enhed er i øjeblikket registreret til en anden bruger i vores cloud-dashboard.",
     "already_adopted_return_to_dashboard": "Tilbage til dashboardet",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Antal mistede indgående video-RTP-pakker.",
     "connection_stats_playback_delay": "Afspilningsforsinkelse",
     "connection_stats_playback_delay_description": "Forsinkelse tilføjet af jitterbufferen for at jævne afspilningen, når billeder ankommer ujævnt.",
+    "connection_stats_remote_ip_address": "Fjern IP-adresse",
+    "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere fjern-IP-adresse",
+    "connection_stats_remote_ip_address_copy_success": "Fjern IP-adresse { ip } kopieret til udklipsholder",
+    "connection_stats_remote_ip_address_description": "IP-adressen på den eksterne enhed.",
     "connection_stats_round_trip_time": "Rundturstid",
     "connection_stats_round_trip_time_description": "Rundrejsetid for det aktive ICE-kandidatpar mellem peers.",
     "connection_stats_sidebar": "Forbindelsesstatistik",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Opdater automatisk enheden til den nyeste version",
     "general_auto_update_error": "Kunne ikke indstille automatisk opdatering: {error}",
     "general_auto_update_title": "Automatisk opdatering",
+    "general_check_for_stable_updates": "Nedgradering",
     "general_check_for_updates": "Tjek for opdateringer",
     "general_page_description": "Konfigurer enhedsindstillinger og opdater præferencer",
     "general_reboot_description": "Vil du fortsætte med at genstarte systemet?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Søger efter opdateringer…",
     "general_update_completed_description": "Din enhed er blevet opdateret til den nyeste version. Nyd de nye funktioner og forbedringer!",
     "general_update_completed_title": "Opdatering gennemført",
+    "general_update_downgrade_available_description": "En nedgradering er tilgængelig for at vende tilbage til en tidligere version.",
+    "general_update_downgrade_available_title": "Nedgradering tilgængelig",
+    "general_update_downgrade_button": "Nedgrader nu",
     "general_update_error_description": "Der opstod en fejl under opdateringen af din enhed. Prøv igen senere.",
     "general_update_error_details": "Fejldetaljer: {errorMessage}",
     "general_update_error_title": "Opdateringsfejl",
+    "general_update_keep_current_button": "Behold den aktuelle version",
     "general_update_later_button": "Opdater senere",
     "general_update_now_button": "Opdater nu",
     "general_update_rebooting": "Genstarter for at fuldføre opdateringen…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Systemet er opdateret",
     "general_update_updating_description": "Sluk ikke enheden. Denne proces kan tage et par minutter.",
     "general_update_updating_title": "Opdatering af din enhed",
+    "general_update_will_disable_auto_update_description": "Du er ved at ændre din enhedsversion manuelt. Automatisk opdatering vil blive deaktiveret, når opdateringen er fuldført, for at forhindre utilsigtede opdateringer.",
     "getting_remote_session_description": "Henter beskrivelse af fjernsessionsforsøg {attempt}",
     "hardware_backlight_settings_error": "Kunne ikke indstille baggrundsbelysningsindstillinger: {error}",
     "hardware_backlight_settings_get_error": "Kunne ikke hente indstillinger for baggrundsbelysning: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Forbindelsen mislykkedes",
     "peer_connection_new": "Forbinder",
     "previous": "Tidligere",
+    "public_ip_card_header": "Offentlige IP-adresser",
+    "public_ip_card_refresh": "Opfriske",
+    "public_ip_card_refresh_error": "Kunne ikke opdatere offentlige IP-adresser: {error}",
     "register_device_error": "Der opstod en fejl {error} under registrering af din enhed.",
     "register_device_finish_button": "Afslut opsætning",
     "register_device_name_description": "Navngiv din enhed, så du nemt kan identificere den senere. Du kan til enhver tid ændre dette navn.",
diff --git a/ui/localization/messages/de.json b/ui/localization/messages/de.json
index 04e2584..ef20ed9 100644
--- a/ui/localization/messages/de.json
+++ b/ui/localization/messages/de.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "SSH-Schlüssel konnte nicht aktualisiert werden: {error}",
     "advanced_error_usb_emulation_disable": "USB-Emulation konnte nicht deaktiviert werden: {error}",
     "advanced_error_usb_emulation_enable": "USB-Emulation konnte nicht aktiviert werden: {error}",
+    "advanced_error_version_update": "Versionsaktualisierung konnte nicht initiiert werden: {error}",
     "advanced_loopback_only_description": "Beschränken Sie den Zugriff auf die Weboberfläche nur auf den lokalen Host (127.0.0.1).",
     "advanced_loopback_only_title": "Nur-Loopback-Modus",
     "advanced_loopback_warning_before": "Bevor Sie diese Funktion aktivieren, stellen Sie sicher, dass Sie über Folgendes verfügen:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "SSH-Schlüssel aktualisieren",
     "advanced_usb_emulation_description": "Steuern des USB-Emulationsstatus",
     "advanced_usb_emulation_title": "USB-Emulation",
+    "advanced_version_update_app_label": "App-Version",
+    "advanced_version_update_button": "Aktualisierung auf Version",
+    "advanced_version_update_description": "Installieren Sie eine bestimmte Version aus den GitHub-Releases.",
+    "advanced_version_update_github_link": "JetKVM-Releases-Seite",
+    "advanced_version_update_helper": "Finden Sie verfügbare Versionen auf der",
+    "advanced_version_update_reset_config_description": "Konfiguration nach dem Update zurücksetzen",
+    "advanced_version_update_reset_config_label": "Konfiguration zurücksetzen",
+    "advanced_version_update_system_label": "Systemversion",
+    "advanced_version_update_target_app": "Nur App",
+    "advanced_version_update_target_both": "Sowohl App als auch System",
+    "advanced_version_update_target_label": "Was sollte aktualisiert werden?",
+    "advanced_version_update_target_system": "System nur",
+    "advanced_version_update_title": "Aktualisierung auf eine bestimmte Version",
     "already_adopted_new_owner": "Wenn Sie der neue Besitzer sind, bitten Sie den Vorbesitzer, das Gerät im Cloud-Dashboard von seinem Konto abzumelden. Wenn Sie glauben, dass dies ein Fehler ist, wenden Sie sich an unser Support-Team.",
     "already_adopted_other_user": "Dieses Gerät ist derzeit in unserem Cloud-Dashboard auf einen anderen Benutzer registriert.",
     "already_adopted_return_to_dashboard": "Zurück zum Dashboard",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Anzahl der verlorenen eingehenden Video-RTP-Pakete.",
     "connection_stats_playback_delay": "Wiedergabeverzögerung",
     "connection_stats_playback_delay_description": "Durch den Jitter-Puffer hinzugefügte Verzögerung, um die Wiedergabe zu glätten, wenn die Frames ungleichmäßig ankommen.",
+    "connection_stats_remote_ip_address": "Remote-IP-Adresse",
+    "connection_stats_remote_ip_address_copy_error": "Fehler beim Kopieren der Remote-IP-Adresse",
+    "connection_stats_remote_ip_address_copy_success": "Remote-IP-Adresse { ip } in die Zwischenablage kopiert",
+    "connection_stats_remote_ip_address_description": "Die IP-Adresse des Remote-Geräts.",
     "connection_stats_round_trip_time": "Round-Trip-Zeit",
     "connection_stats_round_trip_time_description": "Roundtrip-Zeit für das aktive ICE-Kandidatenpaar zwischen Peers.",
     "connection_stats_sidebar": "Verbindungsstatistiken",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Aktualisieren Sie das Gerät automatisch auf die neueste Version",
     "general_auto_update_error": "Automatische Aktualisierung konnte nicht eingestellt werden: {error}",
     "general_auto_update_title": "Automatische Aktualisierung",
+    "general_check_for_stable_updates": "Herabstufung",
     "general_check_for_updates": "Nach Updates suchen",
     "general_page_description": "Geräteeinstellungen konfigurieren und Voreinstellungen aktualisieren",
     "general_reboot_description": "Möchten Sie mit dem Neustart des Systems fortfahren?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Suche nach Updates…",
     "general_update_completed_description": "Ihr Gerät wurde erfolgreich auf die neueste Version aktualisiert. Viel Spaß mit den neuen Funktionen und Verbesserungen!",
     "general_update_completed_title": "Update erfolgreich abgeschlossen",
+    "general_update_downgrade_available_description": "Es besteht die Möglichkeit, auf eine frühere Version zurückzukehren.",
+    "general_update_downgrade_available_title": "Downgrade verfügbar",
+    "general_update_downgrade_button": "Jetzt downgraden",
     "general_update_error_description": "Beim Aktualisieren Ihres Geräts ist ein Fehler aufgetreten. Bitte versuchen Sie es später noch einmal.",
     "general_update_error_details": "Fehlerdetails: {errorMessage}",
     "general_update_error_title": "Aktualisierungsfehler",
+    "general_update_keep_current_button": "Aktuelle Version beibehalten",
     "general_update_later_button": "Später",
     "general_update_now_button": "Jetzt aktualisieren",
     "general_update_rebooting": "Neustart zum Abschließen des Updates …",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Das System ist auf dem neuesten Stand",
     "general_update_updating_description": "Bitte schalten Sie Ihr Gerät nicht aus. Dieser Vorgang kann einige Minuten dauern.",
     "general_update_updating_title": "Aktualisieren Ihres Geräts",
+    "general_update_will_disable_auto_update_description": "Sie sind im Begriff, die Version Ihres Geräts manuell zu ändern. Die automatische Aktualisierung wird nach Abschluss der Aktualisierung deaktiviert, um versehentliche Updates zu verhindern.",
     "getting_remote_session_description": "Versuch, eine Beschreibung der Remote-Sitzung abzurufen {attempt}",
     "hardware_backlight_settings_error": "Fehler beim Festlegen der Hintergrundbeleuchtungseinstellungen: {error}",
     "hardware_backlight_settings_get_error": "Die Einstellungen für die Hintergrundbeleuchtung konnten nicht abgerufen werden: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Verbindung fehlgeschlagen",
     "peer_connection_new": "Verbinden",
     "previous": "Vorherige",
+    "public_ip_card_header": "Öffentliche IP-Adressen",
+    "public_ip_card_refresh": "Aktualisieren",
+    "public_ip_card_refresh_error": "Aktualisierung der öffentlichen IP-Adressen fehlgeschlagen: {error}",
     "register_device_error": "Beim Registrieren Ihres Geräts ist ein Fehler {error} aufgetreten.",
     "register_device_finish_button": "Einrichtung abschließen",
     "register_device_name_description": "Geben Sie Ihrem Gerät einen Namen, damit Sie es später leicht identifizieren können. Sie können diesen Namen jederzeit ändern.",
diff --git a/ui/localization/messages/en.json b/ui/localization/messages/en.json
index 06d2bc3..3730fe3 100644
--- a/ui/localization/messages/en.json
+++ b/ui/localization/messages/en.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Failed to update SSH key: {error}",
     "advanced_error_usb_emulation_disable": "Failed to disable USB emulation: {error}",
     "advanced_error_usb_emulation_enable": "Failed to enable USB emulation: {error}",
+    "advanced_error_version_update": "Failed to initiate version update: {error}",
     "advanced_loopback_only_description": "Restrict web interface access to localhost only (127.0.0.1)",
     "advanced_loopback_only_title": "Loopback-Only Mode",
     "advanced_loopback_warning_before": "Before enabling this feature, make sure you have either:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Update SSH Key",
     "advanced_usb_emulation_description": "Control the USB emulation state",
     "advanced_usb_emulation_title": "USB Emulation",
+    "advanced_version_update_app_label": "App Version",
+    "advanced_version_update_button": "Update to Version",
+    "advanced_version_update_description": "Install a specific version from GitHub releases",
+    "advanced_version_update_github_link": "JetKVM releases page",
+    "advanced_version_update_helper": "Find available versions on the",
+    "advanced_version_update_reset_config_description": "Reset configuration after the update",
+    "advanced_version_update_reset_config_label": "Reset configuration",
+    "advanced_version_update_system_label": "System Version",
+    "advanced_version_update_target_app": "App only",
+    "advanced_version_update_target_both": "Both App and System",
+    "advanced_version_update_target_label": "What to update",
+    "advanced_version_update_target_system": "System only",
+    "advanced_version_update_title": "Update to Specific Version",
     "already_adopted_new_owner": "If you're the new owner, please ask the previous owner to de-register the device from their account in the cloud dashboard. If you believe this is an error, contact our support team for assistance.",
     "already_adopted_other_user": "This device is currently registered to another user in our cloud dashboard.",
     "already_adopted_return_to_dashboard": "Return to Dashboard",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Count of lost inbound video RTP packets.",
     "connection_stats_playback_delay": "Playback Delay",
     "connection_stats_playback_delay_description": "Delay added by the jitter buffer to smooth playback when frames arrive unevenly.",
+    "connection_stats_remote_ip_address": "Remote IP Address",
+    "connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
+    "connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
+    "connection_stats_remote_ip_address_description": "The IP address of the remote device.",
     "connection_stats_round_trip_time": "Round-Trip Time",
     "connection_stats_round_trip_time_description": "Round-trip time for the active ICE candidate pair between peers.",
     "connection_stats_sidebar": "Connection Stats",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Automatically update the device to the latest version",
     "general_auto_update_error": "Failed to set auto-update: {error}",
     "general_auto_update_title": "Auto Update",
+    "general_check_for_stable_updates": "Downgrade",
     "general_check_for_updates": "Check for Updates",
     "general_page_description": "Configure device settings and update preferences",
     "general_reboot_description": "Do you want to proceed with rebooting the system?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Checking for updates…",
     "general_update_completed_description": "Your device has been successfully updated to the latest version. Enjoy the new features and improvements!",
     "general_update_completed_title": "Update Completed Successfully",
+    "general_update_downgrade_available_description": "A downgrade is available to revert to a previous version.",
+    "general_update_downgrade_available_title": "Downgrade Available",
+    "general_update_downgrade_button": "Downgrade Now",
     "general_update_error_description": "An error occurred while updating your device. Please try again later.",
     "general_update_error_details": "Error details: {errorMessage}",
     "general_update_error_title": "Update Error",
+    "general_update_keep_current_button": "Keep Current Version",
     "general_update_later_button": "Do it later",
     "general_update_now_button": "Update Now",
     "general_update_rebooting": "Rebooting to complete the update…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "System is up to date",
     "general_update_updating_description": "Please don't turn off your device. This process may take a few minutes.",
     "general_update_updating_title": "Updating your device",
+    "general_update_will_disable_auto_update_description": "You're about to manually change your device version. Auto-update will be disabled after the update is completed to prevent accidental updates.",
     "getting_remote_session_description": "Getting remote session description attempt {attempt}",
     "hardware_backlight_settings_error": "Failed to set backlight settings: {error}",
     "hardware_backlight_settings_get_error": "Failed to get backlight settings: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Connection failed",
     "peer_connection_new": "Connecting",
     "previous": "Previous",
+    "public_ip_card_header": "Public IP addresses",
+    "public_ip_card_refresh": "Refresh",
+    "public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}",
     "register_device_error": "There was an error {error} registering your device.",
     "register_device_finish_button": "Finish Setup",
     "register_device_name_description": "Name your device so you can easily identify it later. You can change this name at any time.",
@@ -897,11 +924,5 @@
     "wake_on_lan_invalid_mac": "Invalid MAC address",
     "wake_on_lan_magic_sent_success": "Magic Packet sent successfully",
     "welcome_to_jetkvm": "Welcome to JetKVM",
-    "welcome_to_jetkvm_description": "Control any computer remotely","connection_stats_remote_ip_address": "Remote IP Address",
-    "connection_stats_remote_ip_address_description": "The IP address of the remote device.",
-    "connection_stats_remote_ip_address_copy_error": "Failed to copy remote IP address",
-    "connection_stats_remote_ip_address_copy_success": "Remote IP address { ip } copied to clipboard",
-    "public_ip_card_header": "Public IP addresses",
-    "public_ip_card_refresh": "Refresh",
-    "public_ip_card_refresh_error": "Failed to refresh public IP addresses: {error}"
+    "welcome_to_jetkvm_description": "Control any computer remotely"
 }
diff --git a/ui/localization/messages/es.json b/ui/localization/messages/es.json
index a74e35e..32d54d9 100644
--- a/ui/localization/messages/es.json
+++ b/ui/localization/messages/es.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "No se pudo actualizar la clave SSH: {error}",
     "advanced_error_usb_emulation_disable": "No se pudo deshabilitar la emulación USB: {error}",
     "advanced_error_usb_emulation_enable": "No se pudo habilitar la emulación USB: {error}",
+    "advanced_error_version_update": "Error al iniciar la actualización de versión: {error}",
     "advanced_loopback_only_description": "Restringir el acceso a la interfaz web solo al host local (127.0.0.1)",
     "advanced_loopback_only_title": "Modo de solo bucle invertido",
     "advanced_loopback_warning_before": "Antes de habilitar esta función, asegúrese de tener:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Actualizar clave SSH",
     "advanced_usb_emulation_description": "Controlar el estado de emulación USB",
     "advanced_usb_emulation_title": "Emulación USB",
+    "advanced_version_update_app_label": "Versión de la aplicación",
+    "advanced_version_update_button": "Actualización a la versión",
+    "advanced_version_update_description": "Instala una versión específica desde las versiones de GitHub.",
+    "advanced_version_update_github_link": "Página de lanzamientos de JetKVM",
+    "advanced_version_update_helper": "Encuentra las versiones disponibles en el",
+    "advanced_version_update_reset_config_description": "Restablecer la configuración después de la actualización",
+    "advanced_version_update_reset_config_label": "Restablecer configuración",
+    "advanced_version_update_system_label": "Versión del sistema",
+    "advanced_version_update_target_app": "Solo aplicación",
+    "advanced_version_update_target_both": "Tanto la aplicación como el sistema",
+    "advanced_version_update_target_label": "Qué actualizar",
+    "advanced_version_update_target_system": "Solo sistema",
+    "advanced_version_update_title": "Actualización a una versión específica",
     "already_adopted_new_owner": "Si eres el nuevo propietario, solicita al anterior propietario que cancele el registro del dispositivo en su cuenta en el panel de control de la nube. Si crees que se trata de un error, contacta con nuestro equipo de soporte para obtener ayuda.",
     "already_adopted_other_user": "Este dispositivo está actualmente registrado por otro usuario en nuestro panel de control en la nube.",
     "already_adopted_return_to_dashboard": "Regresar al panel de control",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Recuento de paquetes de vídeo RTP entrantes perdidos.",
     "connection_stats_playback_delay": "Retraso de reproducción",
     "connection_stats_playback_delay_description": "Retraso agregado por el buffer de fluctuación para suavizar la reproducción cuando los cuadros llegan de manera desigual.",
+    "connection_stats_remote_ip_address": "Dirección IP remota",
+    "connection_stats_remote_ip_address_copy_error": "No se pudo copiar la dirección IP remota",
+    "connection_stats_remote_ip_address_copy_success": "Dirección IP remota { ip } copiada al portapapeles",
+    "connection_stats_remote_ip_address_description": "La dirección IP del dispositivo remoto.",
     "connection_stats_round_trip_time": "Tiempo de ida y vuelta",
     "connection_stats_round_trip_time_description": "Tiempo de ida y vuelta para el par de candidatos ICE activos entre pares.",
     "connection_stats_sidebar": "Estadísticas de conexión",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Actualizar automáticamente el dispositivo a la última versión",
     "general_auto_update_error": "No se pudo configurar la actualización automática: {error}",
     "general_auto_update_title": "Actualización automática",
+    "general_check_for_stable_updates": "Degradar",
     "general_check_for_updates": "Buscar actualizaciones",
     "general_page_description": "Configurar los ajustes del dispositivo y actualizar las preferencias",
     "general_reboot_description": "¿Desea continuar con el reinicio del sistema?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Buscando actualizaciones…",
     "general_update_completed_description": "Tu dispositivo se ha actualizado correctamente a la última versión. ¡Disfruta de las nuevas funciones y mejoras!",
     "general_update_completed_title": "Actualización completada con éxito",
+    "general_update_downgrade_available_description": "Es posible realizar una reversión a una versión anterior.",
+    "general_update_downgrade_available_title": "Opción de cambio a una versión inferior disponible",
+    "general_update_downgrade_button": "Revierte ahora",
     "general_update_error_description": "Se produjo un error al actualizar tu dispositivo. Inténtalo de nuevo más tarde.",
     "general_update_error_details": "Detalles del error: {errorMessage}",
     "general_update_error_title": "Error de actualización",
+    "general_update_keep_current_button": "Mantener la versión actual",
     "general_update_later_button": "Posponer",
     "general_update_now_button": "Actualizar ahora",
     "general_update_rebooting": "Reiniciando para completar la actualización…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "El sistema está actualizado",
     "general_update_updating_description": "No apagues tu dispositivo. Este proceso puede tardar unos minutos.",
     "general_update_updating_title": "Actualizar su dispositivo",
+    "general_update_will_disable_auto_update_description": "Estás a punto de cambiar manualmente la versión de tu dispositivo. La actualización automática se desactivará una vez completada la actualización para evitar actualizaciones accidentales.",
     "getting_remote_session_description": "Obtener un intento de descripción de sesión remota {attempt}",
     "hardware_backlight_settings_error": "No se pudieron configurar los ajustes de la retroiluminación: {error}",
     "hardware_backlight_settings_get_error": "No se pudieron obtener los ajustes de la retroiluminación: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "La conexión falló",
     "peer_connection_new": "Conectando",
     "previous": "Anterior",
+    "public_ip_card_header": "Direcciones IP públicas",
+    "public_ip_card_refresh": "Refrescar",
+    "public_ip_card_refresh_error": "Error al actualizar las direcciones IP públicas: {error}",
     "register_device_error": "Se produjo un error {error} al registrar su dispositivo.",
     "register_device_finish_button": "Finalizar configuración",
     "register_device_name_description": "Ponle un nombre a tu dispositivo para que puedas identificarlo fácilmente más tarde. Puedes cambiarlo en cualquier momento.",
diff --git a/ui/localization/messages/fr.json b/ui/localization/messages/fr.json
index eb7361d..e13a44b 100644
--- a/ui/localization/messages/fr.json
+++ b/ui/localization/messages/fr.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Échec de la mise à jour de la clé SSH : {error}",
     "advanced_error_usb_emulation_disable": "Échec de la désactivation de l'émulation USB : {error}",
     "advanced_error_usb_emulation_enable": "Échec de l'activation de l'émulation USB : {error}",
+    "advanced_error_version_update": "Échec de la mise à jour de version : {error}",
     "advanced_loopback_only_description": "Restreindre l'accès à l'interface Web à l'hôte local uniquement (127.0.0.1)",
     "advanced_loopback_only_title": "Mode de bouclage uniquement",
     "advanced_loopback_warning_before": "Avant d'activer cette fonctionnalité, assurez-vous d'avoir :",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Mettre à jour la clé SSH",
     "advanced_usb_emulation_description": "Contrôler l'état de l'émulation USB",
     "advanced_usb_emulation_title": "Émulation USB",
+    "advanced_version_update_app_label": "Version de l'application",
+    "advanced_version_update_button": "Mise à jour vers la version",
+    "advanced_version_update_description": "Installer une version spécifique à partir des versions GitHub",
+    "advanced_version_update_github_link": "page des versions de JetKVM",
+    "advanced_version_update_helper": "Trouvez les versions disponibles sur le",
+    "advanced_version_update_reset_config_description": "Réinitialiser la configuration après la mise à jour",
+    "advanced_version_update_reset_config_label": "Réinitialiser la configuration",
+    "advanced_version_update_system_label": "Version du système",
+    "advanced_version_update_target_app": "Application uniquement",
+    "advanced_version_update_target_both": "L'application et le système",
+    "advanced_version_update_target_label": "Que mettre à jour",
+    "advanced_version_update_target_system": "Système uniquement",
+    "advanced_version_update_title": "Mise à jour vers une version spécifique",
     "already_adopted_new_owner": "Si vous êtes le nouveau propriétaire, veuillez demander à l'ancien propriétaire de désenregistrer l'appareil de son compte dans le tableau de bord cloud. Si vous pensez qu'il s'agit d'une erreur, contactez notre équipe d'assistance pour obtenir de l'aide.",
     "already_adopted_other_user": "Cet appareil est actuellement enregistré auprès d'un autre utilisateur dans notre tableau de bord cloud.",
     "already_adopted_return_to_dashboard": "Retour au tableau de bord",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Nombre de paquets vidéo RTP entrants perdus.",
     "connection_stats_playback_delay": "Délai de lecture",
     "connection_stats_playback_delay_description": "Retard ajouté par le tampon de gigue pour fluidifier la lecture lorsque les images arrivent de manière inégale.",
+    "connection_stats_remote_ip_address": "Adresse IP distante",
+    "connection_stats_remote_ip_address_copy_error": "Échec de la copie de l'adresse IP distante",
+    "connection_stats_remote_ip_address_copy_success": "Adresse IP distante { ip } copiée dans le presse-papiers",
+    "connection_stats_remote_ip_address_description": "L'adresse IP du périphérique distant.",
     "connection_stats_round_trip_time": "Temps de trajet aller-retour",
     "connection_stats_round_trip_time_description": "Temps de trajet aller-retour pour la paire de candidats ICE actifs entre pairs.",
     "connection_stats_sidebar": "Statistiques de connexion",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Mettre à jour automatiquement l'appareil vers la dernière version",
     "general_auto_update_error": "Échec de la définition de la mise à jour automatique : {error}",
     "general_auto_update_title": "Mise à jour automatique",
+    "general_check_for_stable_updates": "Rétrograder",
     "general_check_for_updates": "Vérifier les mises à jour",
     "general_page_description": "Configurer les paramètres de l'appareil et mettre à jour les préférences",
     "general_reboot_description": "Voulez-vous procéder au redémarrage du système ?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Vérification des mises à jour…",
     "general_update_completed_description": "Votre appareil a été mis à jour avec succès vers la dernière version. Profitez des nouvelles fonctionnalités et améliorations !",
     "general_update_completed_title": "Mise à jour terminée avec succès",
+    "general_update_downgrade_available_description": "Il est possible de revenir à une version antérieure.",
+    "general_update_downgrade_available_title": "Rétrogradation possible",
+    "general_update_downgrade_button": "Rétrograder maintenant",
     "general_update_error_description": "Une erreur s'est produite lors de la mise à jour de votre appareil. Veuillez réessayer ultérieurement.",
     "general_update_error_details": "Détails de l'erreur : {errorMessage}",
     "general_update_error_title": "Erreur de mise à jour",
+    "general_update_keep_current_button": "Conserver la version actuelle",
     "general_update_later_button": "Faire plus tard",
     "general_update_now_button": "Mettre à jour maintenant",
     "general_update_rebooting": "Redémarrage pour terminer la mise à jour…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Le système est à jour",
     "general_update_updating_description": "Veuillez ne pas éteindre votre appareil. Ce processus peut prendre quelques minutes.",
     "general_update_updating_title": "Mise à jour de votre appareil",
+    "general_update_will_disable_auto_update_description": "Vous allez modifier manuellement la version de votre appareil. La mise à jour automatique sera désactivée une fois la mise à jour terminée afin d'éviter toute mise à jour accidentelle.",
     "getting_remote_session_description": "Obtention d'{attempt} description de session à distance",
     "hardware_backlight_settings_error": "Échec de la définition des paramètres de rétroéclairage : {error}",
     "hardware_backlight_settings_get_error": "Échec de l'obtention des paramètres de rétroéclairage : {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "La connexion a échoué",
     "peer_connection_new": "Nouveau",
     "previous": "Précédent",
+    "public_ip_card_header": "Adresses IP publiques",
+    "public_ip_card_refresh": "Rafraîchir",
+    "public_ip_card_refresh_error": "Échec de l'actualisation des adresses IP publiques : {error}",
     "register_device_error": "Une erreur {error} s'est produite lors de l'enregistrement de votre appareil.",
     "register_device_finish_button": "Terminer la configuration",
     "register_device_name_description": "Nommez votre appareil pour pouvoir l'identifier facilement plus tard. Vous pouvez modifier ce nom à tout moment.",
diff --git a/ui/localization/messages/it.json b/ui/localization/messages/it.json
index b2aa552..928ba99 100644
--- a/ui/localization/messages/it.json
+++ b/ui/localization/messages/it.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Impossibile aggiornare la chiave SSH: {error}",
     "advanced_error_usb_emulation_disable": "Impossibile disabilitare l'emulazione USB: {error}",
     "advanced_error_usb_emulation_enable": "Impossibile abilitare l'emulazione USB: {error}",
+    "advanced_error_version_update": "Impossibile avviare l'aggiornamento della versione: {error}",
     "advanced_loopback_only_description": "Limita l'accesso all'interfaccia web solo a localhost (127.0.0.1)",
     "advanced_loopback_only_title": "Modalità solo loopback",
     "advanced_loopback_warning_before": "Prima di abilitare questa funzione, assicurati di avere:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Aggiorna la chiave SSH",
     "advanced_usb_emulation_description": "Controlla lo stato di emulazione USB",
     "advanced_usb_emulation_title": "Emulazione USB",
+    "advanced_version_update_app_label": "Versione dell'app",
+    "advanced_version_update_button": "Aggiorna alla versione",
+    "advanced_version_update_description": "Installa una versione specifica dalle versioni di GitHub",
+    "advanced_version_update_github_link": "Pagina delle versioni di JetKVM",
+    "advanced_version_update_helper": "Trova le versioni disponibili su",
+    "advanced_version_update_reset_config_description": "Ripristina la configurazione dopo l'aggiornamento",
+    "advanced_version_update_reset_config_label": "Reimposta configurazione",
+    "advanced_version_update_system_label": "Versione del sistema",
+    "advanced_version_update_target_app": "Solo app",
+    "advanced_version_update_target_both": "Sia l'app che il sistema",
+    "advanced_version_update_target_label": "Cosa aggiornare",
+    "advanced_version_update_target_system": "Solo sistema",
+    "advanced_version_update_title": "Aggiorna alla versione specifica",
     "already_adopted_new_owner": "Se sei il nuovo proprietario, chiedi al precedente proprietario di annullare la registrazione del dispositivo dal suo account nella dashboard cloud. Se ritieni che si tratti di un errore, contatta il nostro team di supporto per ricevere assistenza.",
     "already_adopted_other_user": "Questo dispositivo è attualmente registrato a un altro utente nella nostra dashboard cloud.",
     "already_adopted_return_to_dashboard": "Torna alla dashboard",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Conteggio dei pacchetti video RTP in entrata persi.",
     "connection_stats_playback_delay": "Ritardo di riproduzione",
     "connection_stats_playback_delay_description": "Ritardo aggiunto dal buffer jitter per rendere più fluida la riproduzione quando i fotogrammi arrivano in modo non uniforme.",
+    "connection_stats_remote_ip_address": "Indirizzo IP remoto",
+    "connection_stats_remote_ip_address_copy_error": "Impossibile copiare l'indirizzo IP remoto",
+    "connection_stats_remote_ip_address_copy_success": "Indirizzo IP remoto { ip } copiato negli appunti",
+    "connection_stats_remote_ip_address_description": "L'indirizzo IP del dispositivo remoto.",
     "connection_stats_round_trip_time": "Tempo di andata e ritorno",
     "connection_stats_round_trip_time_description": "Tempo di andata e ritorno per la coppia di candidati ICE attivi tra pari.",
     "connection_stats_sidebar": "Statistiche di connessione",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Aggiorna automaticamente il dispositivo all'ultima versione",
     "general_auto_update_error": "Impossibile impostare l'aggiornamento automatico: {error}",
     "general_auto_update_title": "Aggiornamento automatico",
+    "general_check_for_stable_updates": "Declassare",
     "general_check_for_updates": "Verifica aggiornamenti",
     "general_page_description": "Configurare le impostazioni del dispositivo e aggiornare le preferenze",
     "general_reboot_description": "Vuoi procedere con il riavvio del sistema?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Controllo degli aggiornamenti…",
     "general_update_completed_description": "Il tuo dispositivo è stato aggiornato con successo all'ultima versione. Goditi le nuove funzionalità e i miglioramenti!",
     "general_update_completed_title": "Aggiornamento completato con successo",
+    "general_update_downgrade_available_description": "È possibile effettuare il downgrade per tornare a una versione precedente.",
+    "general_update_downgrade_available_title": "Downgrade disponibile",
+    "general_update_downgrade_button": "Effettua il downgrade ora",
     "general_update_error_description": "Si è verificato un errore durante l'aggiornamento del dispositivo. Riprova più tardi.",
     "general_update_error_details": "Dettagli errore: {errorMessage}",
     "general_update_error_title": "Errore di aggiornamento",
+    "general_update_keep_current_button": "Mantieni la versione corrente",
     "general_update_later_button": "Fallo più tardi",
     "general_update_now_button": "Aggiorna ora",
     "general_update_rebooting": "Riavvio per completare l'aggiornamento…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Il sistema è aggiornato",
     "general_update_updating_description": "Non spegnere il dispositivo. Questo processo potrebbe richiedere alcuni minuti.",
     "general_update_updating_title": "Aggiornamento del dispositivo",
+    "general_update_will_disable_auto_update_description": "Stai per modificare manualmente la versione del tuo dispositivo. L'aggiornamento automatico verrà disattivato al termine dell'aggiornamento per evitare aggiornamenti accidentali.",
     "getting_remote_session_description": "Tentativo di ottenimento della descrizione della sessione remota {attempt}",
     "hardware_backlight_settings_error": "Impossibile impostare le impostazioni della retroilluminazione: {error}",
     "hardware_backlight_settings_get_error": "Impossibile ottenere le impostazioni della retroilluminazione: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Connessione fallita",
     "peer_connection_new": "Collegamento",
     "previous": "Precedente",
+    "public_ip_card_header": "Indirizzi IP pubblici",
+    "public_ip_card_refresh": "Aggiorna",
+    "public_ip_card_refresh_error": "Impossibile aggiornare gli indirizzi IP pubblici: {error}",
     "register_device_error": "Si è verificato un errore {error} durante la registrazione del dispositivo.",
     "register_device_finish_button": "Completa l'installazione",
     "register_device_name_description": "Assegna un nome al tuo dispositivo per poterlo identificare facilmente in seguito. Puoi cambiare questo nome in qualsiasi momento.",
diff --git a/ui/localization/messages/nb.json b/ui/localization/messages/nb.json
index 26b8584..4c80dc6 100644
--- a/ui/localization/messages/nb.json
+++ b/ui/localization/messages/nb.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Kunne ikke oppdatere SSH-nøkkelen: {error}",
     "advanced_error_usb_emulation_disable": "Kunne ikke deaktivere USB-emulering: {error}",
     "advanced_error_usb_emulation_enable": "Kunne ikke aktivere USB-emulering: {error}",
+    "advanced_error_version_update": "Kunne ikke starte versjonsoppdatering: {error}",
     "advanced_loopback_only_description": "Begrens tilgang til webgrensesnittet kun til lokal vert (127.0.0.1)",
     "advanced_loopback_only_title": "Kun lokal tilgang",
     "advanced_loopback_warning_before": "Før du aktiverer denne funksjonen, må du sørge for at du har enten:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Oppdater SSH-nøkkel",
     "advanced_usb_emulation_description": "Kontroller USB-emuleringstilstanden",
     "advanced_usb_emulation_title": "USB-emulering",
+    "advanced_version_update_app_label": "Appversjon",
+    "advanced_version_update_button": "Oppdater til versjon",
+    "advanced_version_update_description": "Installer en spesifikk versjon fra GitHub-utgivelser",
+    "advanced_version_update_github_link": "JetKVM-utgivelsesside",
+    "advanced_version_update_helper": "Finn tilgjengelige versjoner på",
+    "advanced_version_update_reset_config_description": "Tilbakestill konfigurasjonen etter oppdateringen",
+    "advanced_version_update_reset_config_label": "Tilbakestill konfigurasjon",
+    "advanced_version_update_system_label": "Systemversjon",
+    "advanced_version_update_target_app": "Kun app",
+    "advanced_version_update_target_both": "Både app og system",
+    "advanced_version_update_target_label": "Hva som skal oppdateres",
+    "advanced_version_update_target_system": "Kun systemet",
+    "advanced_version_update_title": "Oppdatering til spesifikk versjon",
     "already_adopted_new_owner": "Hvis du er den nye eieren, ber du den forrige eieren om å avregistrere enheten fra kontoen sin i skydashbordet. Hvis du mener dette er en feil, kan du kontakte supportteamet vårt for å få hjelp.",
     "already_adopted_other_user": "Denne enheten er for øyeblikket registrert til en annen bruker i vårt skydashbord.",
     "already_adopted_return_to_dashboard": "Gå tilbake til dashbordet",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Antall tapte innkommende RTP-videopakker.",
     "connection_stats_playback_delay": "Avspillingsforsinkelse",
     "connection_stats_playback_delay_description": "Forsinkelse lagt til av jitterbufferen for jevn avspilling når bilder ankommer ujevnt.",
+    "connection_stats_remote_ip_address": "Ekstern IP-adresse",
+    "connection_stats_remote_ip_address_copy_error": "Kunne ikke kopiere den eksterne IP-adressen",
+    "connection_stats_remote_ip_address_copy_success": "Ekstern IP-adresse { ip } kopiert til utklippstavlen",
+    "connection_stats_remote_ip_address_description": "IP-adressen til den eksterne enheten.",
     "connection_stats_round_trip_time": "Tur-retur-tid",
     "connection_stats_round_trip_time_description": "Rundturstid for det aktive ICE-kandidatparet mellom jevnaldrende.",
     "connection_stats_sidebar": "Tilkoblingsstatistikk",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Oppdater enheten automatisk til den nyeste versjonen",
     "general_auto_update_error": "Klarte ikke å angi automatisk oppdatering: {error}",
     "general_auto_update_title": "Automatisk oppdatering",
+    "general_check_for_stable_updates": "Nedgrader",
     "general_check_for_updates": "Se etter oppdateringer",
     "general_page_description": "Konfigurer enhetsinnstillinger og oppdater preferanser",
     "general_reboot_description": "Vil du fortsette med å starte systemet på nytt?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Ser etter oppdateringer …",
     "general_update_completed_description": "Enheten din er oppdatert til den nyeste versjonen. Kos deg med de nye funksjonene og forbedringene!",
     "general_update_completed_title": "Oppdatering fullført",
+    "general_update_downgrade_available_description": "En nedgradering er tilgjengelig for å gå tilbake til en tidligere versjon.",
+    "general_update_downgrade_available_title": "Nedgradering tilgjengelig",
+    "general_update_downgrade_button": "Nedgrader nå",
     "general_update_error_description": "Det oppsto en feil under oppdatering av enheten din. Prøv på nytt senere.",
     "general_update_error_details": "Feildetaljer: {errorMessage}",
     "general_update_error_title": "Oppdateringsfeil",
+    "general_update_keep_current_button": "Behold gjeldende versjon",
     "general_update_later_button": "Oppdater senere",
     "general_update_now_button": "Oppdater nå",
     "general_update_rebooting": "Starter på nytt for å fullføre oppdateringen …",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Alt er oppdatert",
     "general_update_updating_description": "Ikke slå av enheten. Denne prosessen kan ta noen minutter.",
     "general_update_updating_title": "Oppdaterer enheten din",
+    "general_update_will_disable_auto_update_description": "Du er i ferd med å endre enhetsversjonen manuelt. Automatisk oppdatering vil bli deaktivert etter at oppdateringen er fullført for å forhindre utilsiktede oppdateringer.",
     "getting_remote_session_description": "Henter beskrivelse av ekstern øktforsøk {attempt}",
     "hardware_backlight_settings_error": "Kunne ikke angi innstillinger for bakgrunnsbelysning: {error}",
     "hardware_backlight_settings_get_error": "Klarte ikke å hente bakgrunnsbelysningsinnstillinger: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Tilkoblingen mislyktes",
     "peer_connection_new": "Tilkobling",
     "previous": "Tidligere",
+    "public_ip_card_header": "Offentlige IP-adresser",
+    "public_ip_card_refresh": "Forfriske",
+    "public_ip_card_refresh_error": "Kunne ikke oppdatere offentlige IP-adresser: {error}",
     "register_device_error": "Det oppsto en feil {error} under registrering av enheten din.",
     "register_device_finish_button": "Fullfør oppsettet",
     "register_device_name_description": "Gi enheten din et navn slik at du enkelt kan identifisere den senere. Du kan endre dette navnet når som helst.",
diff --git a/ui/localization/messages/sv.json b/ui/localization/messages/sv.json
index a4df327..c51e577 100644
--- a/ui/localization/messages/sv.json
+++ b/ui/localization/messages/sv.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "Misslyckades med att uppdatera SSH-nyckeln: {error}",
     "advanced_error_usb_emulation_disable": "Misslyckades med att inaktivera USB-emulering: {error}",
     "advanced_error_usb_emulation_enable": "Misslyckades med att aktivera USB-emulering: {error}",
+    "advanced_error_version_update": "Misslyckades med att initiera versionsuppdatering: {error}",
     "advanced_loopback_only_description": "Begränsa åtkomst till webbgränssnittet endast till lokal värd (127.0.0.1)",
     "advanced_loopback_only_title": "Loopback-läge",
     "advanced_loopback_warning_before": "Innan du aktiverar den här funktionen, se till att du har antingen:",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "Uppdatera SSH-nyckel",
     "advanced_usb_emulation_description": "Kontrollera USB-emuleringsstatusen",
     "advanced_usb_emulation_title": "USB-emulering",
+    "advanced_version_update_app_label": "Appversion",
+    "advanced_version_update_button": "Uppdatera till version",
+    "advanced_version_update_description": "Installera en specifik version från GitHub-utgåvor",
+    "advanced_version_update_github_link": "JetKVM-utgåvorsida",
+    "advanced_version_update_helper": "Hitta tillgängliga versioner på",
+    "advanced_version_update_reset_config_description": "Återställ konfigurationen efter uppdateringen",
+    "advanced_version_update_reset_config_label": "Återställ konfigurationen",
+    "advanced_version_update_system_label": "Systemversion",
+    "advanced_version_update_target_app": "Endast app",
+    "advanced_version_update_target_both": "Både app och system",
+    "advanced_version_update_target_label": "Vad som ska uppdateras",
+    "advanced_version_update_target_system": "Endast systemet",
+    "advanced_version_update_title": "Uppdatera till specifik version",
     "already_adopted_new_owner": "Om du är den nya ägaren ber du den tidigare ägaren att avregistrera enheten från sitt konto i molnöversikten. Om du tror att detta är ett fel kan du kontakta vårt supportteam för hjälp.",
     "already_adopted_other_user": "Den här enheten är för närvarande registrerad till en annan användare i vår molnpanel.",
     "already_adopted_return_to_dashboard": "Återgå till instrumentpanelen",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "Antal förlorade inkommande RTP-videopaket.",
     "connection_stats_playback_delay": "Uppspelningsfördröjning",
     "connection_stats_playback_delay_description": "Fördröjning som läggs till av jitterbufferten för att jämna ut uppspelningen när bildrutor anländer ojämnt.",
+    "connection_stats_remote_ip_address": "Fjärr-IP-adress",
+    "connection_stats_remote_ip_address_copy_error": "Misslyckades med att kopiera fjärr-IP-adressen",
+    "connection_stats_remote_ip_address_copy_success": "Fjärr-IP-adress { ip } kopierad till urklipp",
+    "connection_stats_remote_ip_address_description": "IP-adressen för den fjärranslutna enheten.",
     "connection_stats_round_trip_time": "Tur- och returtid",
     "connection_stats_round_trip_time_description": "Tur- och returtid för det aktiva ICE-kandidatparet mellan peers.",
     "connection_stats_sidebar": "Anslutningsstatistik",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "Uppdatera enheten automatiskt till den senaste versionen",
     "general_auto_update_error": "Misslyckades med att ställa in automatisk uppdatering: {error}",
     "general_auto_update_title": "Automatisk uppdatering",
+    "general_check_for_stable_updates": "Nedvärdera",
     "general_check_for_updates": "Kontrollera efter uppdateringar",
     "general_page_description": "Konfigurera enhetsinställningar och uppdatera inställningar",
     "general_reboot_description": "Vill du fortsätta med att starta om systemet?",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "Söker efter uppdateringar…",
     "general_update_completed_description": "Din enhet har uppdaterats till den senaste versionen. Njut av de nya funktionerna och förbättringarna!",
     "general_update_completed_title": "Uppdateringen är slutförd",
+    "general_update_downgrade_available_description": "En nedgradering är tillgänglig för att återgå till en tidigare version.",
+    "general_update_downgrade_available_title": "Nedgradering tillgänglig",
+    "general_update_downgrade_button": "Nedgradera nu",
     "general_update_error_description": "Ett fel uppstod när enheten uppdaterades. Försök igen senare.",
     "general_update_error_details": "Felinformation: {errorMessage}",
     "general_update_error_title": "Uppdateringsfel",
+    "general_update_keep_current_button": "Behåll aktuell version",
     "general_update_later_button": "Gör det senare",
     "general_update_now_button": "Uppdatera nu",
     "general_update_rebooting": "Startar om för att slutföra uppdateringen…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "Systemet är uppdaterat",
     "general_update_updating_description": "Stäng inte av enheten. Den här processen kan ta några minuter.",
     "general_update_updating_title": "Uppdaterar din enhet",
+    "general_update_will_disable_auto_update_description": "Du håller på att ändra din enhetsversion manuellt. Automatisk uppdatering inaktiveras efter att uppdateringen är klar för att förhindra oavsiktliga uppdateringar.",
     "getting_remote_session_description": "Hämtar beskrivning av fjärrsession försök {attempt}",
     "hardware_backlight_settings_error": "Misslyckades med att ställa in bakgrundsbelysning: {error}",
     "hardware_backlight_settings_get_error": "Misslyckades med att hämta inställningar för bakgrundsbelysning: {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "Anslutningen misslyckades",
     "peer_connection_new": "Ansluter",
     "previous": "Föregående",
+    "public_ip_card_header": "Offentliga IP-adresser",
+    "public_ip_card_refresh": "Uppdatera",
+    "public_ip_card_refresh_error": "Misslyckades med att uppdatera offentliga IP-adresser: {error}",
     "register_device_error": "Det uppstod ett fel {error} din enhet registrerades.",
     "register_device_finish_button": "Slutför installationen",
     "register_device_name_description": "Namnge din enhet så att du enkelt kan identifiera den senare. Du kan ändra namnet när som helst.",
diff --git a/ui/localization/messages/zh.json b/ui/localization/messages/zh.json
index 14a5588..7ed0d86 100644
--- a/ui/localization/messages/zh.json
+++ b/ui/localization/messages/zh.json
@@ -74,6 +74,7 @@
     "advanced_error_update_ssh_key": "无法更新 SSH 密钥： {error}",
     "advanced_error_usb_emulation_disable": "无法禁用 USB 仿真： {error}",
     "advanced_error_usb_emulation_enable": "无法启用 USB 仿真： {error}",
+    "advanced_error_version_update": "版本更新失败： {error}",
     "advanced_loopback_only_description": "限制 Web 界面仅可访问本地主机（127.0.0.1）",
     "advanced_loopback_only_title": "仅环回模式",
     "advanced_loopback_warning_before": "在启用此功能之前，请确保您已：",
@@ -100,6 +101,19 @@
     "advanced_update_ssh_key_button": "更新 SSH 密钥",
     "advanced_usb_emulation_description": "控制 USB 仿真状态",
     "advanced_usb_emulation_title": "USB 仿真",
+    "advanced_version_update_app_label": "应用版本",
+    "advanced_version_update_button": "更新至版本",
+    "advanced_version_update_description": "从 GitHub 发布页面安装特定版本",
+    "advanced_version_update_github_link": "JetKVM 发布页面",
+    "advanced_version_update_helper": "在以下平台查找可用版本",
+    "advanced_version_update_reset_config_description": "更新后重置配置",
+    "advanced_version_update_reset_config_label": "重置配置",
+    "advanced_version_update_system_label": "系统版本",
+    "advanced_version_update_target_app": "仅限应用内购买",
+    "advanced_version_update_target_both": "应用程序和系统",
+    "advanced_version_update_target_label": "需要更新什么",
+    "advanced_version_update_target_system": "仅系统",
+    "advanced_version_update_title": "更新至特定版本",
     "already_adopted_new_owner": "如果您是新用户，请要求前用户在云端控制面板中从其帐户中取消注册该设备。如果您认为此操作有误，请联系我们的支持团队寻求帮助。",
     "already_adopted_other_user": "该设备目前已在我们的云仪表板中注册给另一个用户。",
     "already_adopted_return_to_dashboard": "返回仪表板",
@@ -176,6 +190,10 @@
     "connection_stats_packets_lost_description": "丢失的入站视频 RTP 数据包的数量。",
     "connection_stats_playback_delay": "播放延迟",
     "connection_stats_playback_delay_description": "当帧不均匀到达时，抖动缓冲区添加延迟以平滑播放。",
+    "connection_stats_remote_ip_address": "远程 IP 地址",
+    "connection_stats_remote_ip_address_copy_error": "复制远程 IP 地址失败",
+    "connection_stats_remote_ip_address_copy_success": "远程 IP 地址{ ip }已复制到剪贴板",
+    "connection_stats_remote_ip_address_description": "远程设备的IP地址。",
     "connection_stats_round_trip_time": "往返时间",
     "connection_stats_round_trip_time_description": "对等体之间活跃 ICE 候选对的往返时间。",
     "connection_stats_sidebar": "连接统计",
@@ -241,6 +259,7 @@
     "general_auto_update_description": "自动将设备更新到最新版本",
     "general_auto_update_error": "无法设置自动更新： {error}",
     "general_auto_update_title": "自动更新",
+    "general_check_for_stable_updates": "降级",
     "general_check_for_updates": "检查更新",
     "general_page_description": "配置设备设置并更新首选项",
     "general_reboot_description": "您想继续重新启动系统吗？",
@@ -261,9 +280,13 @@
     "general_update_checking_title": "正在检查更新…",
     "general_update_completed_description": "您的设备已成功更新至最新版本。尽情享受新功能和改进吧！",
     "general_update_completed_title": "更新已成功完成",
+    "general_update_downgrade_available_description": "可以降级到以前的版本。",
+    "general_update_downgrade_available_title": "可降级",
+    "general_update_downgrade_button": "立即降级",
     "general_update_error_description": "更新您的设备时出错。请稍后重试。",
     "general_update_error_details": "错误详细信息： {errorMessage}",
     "general_update_error_title": "更新错误",
+    "general_update_keep_current_button": "保持当前版本",
     "general_update_later_button": "稍后再说",
     "general_update_now_button": "立即更新",
     "general_update_rebooting": "重新启动以完成更新…",
@@ -279,6 +302,7 @@
     "general_update_up_to_date_title": "系统已更新",
     "general_update_updating_description": "正在更新，请勿关闭设备。该过程可能需要数分钟。",
     "general_update_updating_title": "更新您的设备",
+    "general_update_will_disable_auto_update_description": "您即将手动更改设备版本。更新完成后，自动更新功能将被禁用，以防止意外更新。",
     "getting_remote_session_description": "获取远程会话描述尝试 {attempt}",
     "hardware_backlight_settings_error": "无法设置背光设置： {error}",
     "hardware_backlight_settings_get_error": "无法获取背光设置： {error}",
@@ -701,6 +725,9 @@
     "peer_connection_failed": "连接失败",
     "peer_connection_new": "正在连接",
     "previous": "上一步",
+    "public_ip_card_header": "公共 IP 地址",
+    "public_ip_card_refresh": "刷新",
+    "public_ip_card_refresh_error": "刷新公网 IP 地址失败： {error}",
     "register_device_error": "注册您的设备时出现错误{error} 。",
     "register_device_finish_button": "完成设置",
     "register_device_name_description": "为您的设备命名，以便日后轻松识别。您可以随时更改此名称。",
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 437b0c9..bd1d54e 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -37,6 +37,7 @@
         "react-xtermjs": "^1.0.10",
         "recharts": "^3.3.0",
         "tailwind-merge": "^3.3.1",
+        "tslog": "^4.10.2",
         "usehooks-ts": "^3.1.1",
         "validator": "^13.15.20",
         "zustand": "^4.5.2"
@@ -7330,6 +7331,18 @@
       "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
       "license": "0BSD"
     },
+    "node_modules/tslog": {
+      "version": "4.10.2",
+      "resolved": "https://registry.npmjs.org/tslog/-/tslog-4.10.2.tgz",
+      "integrity": "sha512-XuELoRpMR+sq8fuWwX7P0bcj+PRNiicOKDEb3fGNURhxWVyykCi9BNq7c4uVz7h7P0sj8qgBsr5SWS6yBClq3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=16"
+      },
+      "funding": {
+        "url": "https://github.com/fullstack-build/tslog?sponsor=1"
+      }
+    },
     "node_modules/type-check": {
       "version": "0.4.0",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index 0f4d7f6..70eaa66 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -56,6 +56,7 @@
     "react-xtermjs": "^1.0.10",
     "recharts": "^3.3.0",
     "tailwind-merge": "^3.3.1",
+    "tslog": "^4.10.2",
     "usehooks-ts": "^3.1.1",
     "validator": "^13.15.20",
     "zustand": "^4.5.2"
diff --git a/ui/src/components/FailSafeModeOverlay.tsx b/ui/src/components/FailSafeModeOverlay.tsx
index eadc5d9..f60f421 100644
--- a/ui/src/components/FailSafeModeOverlay.tsx
+++ b/ui/src/components/FailSafeModeOverlay.tsx
@@ -1,10 +1,9 @@
 import { useState } from "react";
 import { ExclamationTriangleIcon } from "@heroicons/react/24/solid";
 import { motion, AnimatePresence } from "framer-motion";
-import { LuInfo } from "react-icons/lu";
 
 import { Button } from "@/components/Button";
-import Card, { GridCard } from "@components/Card";
+import { GridCard } from "@components/Card";
 import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
 import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
 import { useVersion } from "@/hooks/useVersion";
@@ -34,40 +33,12 @@ function OverlayContent({ children }: OverlayContentProps) {
   );
 }
 
-interface TooltipProps {
-  readonly children: React.ReactNode;
-  readonly text: string;
-  readonly show: boolean;
-}
-
-function Tooltip({ children, text, show }: TooltipProps) {
-  if (!show) {
-    return <>{children}</>;
-  }
-
-
-  return (
-    <div className="group/tooltip relative">
-      {children}
-      <div className="pointer-events-none absolute bottom-full left-1/2 mb-2 hidden -translate-x-1/2 opacity-0 transition-opacity group-hover/tooltip:block group-hover/tooltip:opacity-100">
-        <Card>
-          <div className="whitespace-nowrap px-2 py-1 text-xs flex items-center gap-1 justify-center">
-            <LuInfo className="h-3 w-3 text-slate-700 dark:text-slate-300" />
-            {text}
-          </div>
-        </Card>
-      </div>
-    </div>
-  );
-}
-
 export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
   const { send } = useJsonRpc();
   const { navigateTo } = useDeviceUiNavigation();
   const { appVersion } = useVersion();
   const { systemVersion } = useDeviceStore();
   const [isDownloadingLogs, setIsDownloadingLogs] = useState(false);
-  const [hasDownloadedLogs, setHasDownloadedLogs] = useState(false);
 
   const getReasonCopy = () => {
     switch (reason) {
@@ -115,7 +86,6 @@ export function FailSafeModeOverlay({ reason }: FailSafeModeOverlayProps) {
       URL.revokeObjectURL(url);
 
       notifications.success("Crash logs downloaded successfully");
-      setHasDownloadedLogs(true);
 
       // Open GitHub issue
       const issueBody = `## Issue Description
@@ -146,7 +116,7 @@ Please attach the recovery logs file that was downloaded to your computer:
   };
 
   const handleDowngrade = () => {
-    navigateTo(`/settings/general/update?app=${DOWNGRADE_VERSION}`);
+    navigateTo(`/settings/general/update?custom_app_version=${DOWNGRADE_VERSION}`);
   };
 
   return (
@@ -182,25 +152,19 @@ Please attach the recovery logs file that was downloaded to your computer:
                     />
 
                     <div className="h-8 w-px bg-slate-200 dark:bg-slate-700 block" />
-                    <Tooltip text="Download logs first" show={!hasDownloadedLogs}>
-                      <Button
-                        onClick={() => navigateTo("/settings/general/reboot")}
-                        theme="light"
-                        size="SM"
-                        text="Reboot Device"
-                        disabled={!hasDownloadedLogs}
-                      />
-                    </Tooltip>
-
-                    <Tooltip text="Download logs first" show={!hasDownloadedLogs}>
-                      <Button
-                        size="SM"
-                        onClick={handleDowngrade}
-                        theme="light"
-                        text={`Downgrade to v${DOWNGRADE_VERSION}`}
-                        disabled={!hasDownloadedLogs}
-                      />
-                    </Tooltip>
+                    <Button
+                      onClick={() => navigateTo("/settings/general/reboot")}
+                      theme="light"
+                      size="SM"
+                      text="Reboot Device"
+                    />
+
+                    <Button
+                      size="SM"
+                      onClick={handleDowngrade}
+                      theme="light"
+                      text={`Downgrade to v${DOWNGRADE_VERSION}`}
+                    />
                   </div>
 
 
diff --git a/ui/src/components/NestedSettingsGroup.tsx b/ui/src/components/NestedSettingsGroup.tsx
new file mode 100644
index 0000000..3ee57b0
--- /dev/null
+++ b/ui/src/components/NestedSettingsGroup.tsx
@@ -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>
+  );
+}
+
diff --git a/ui/src/components/SettingsItem.tsx b/ui/src/components/SettingsItem.tsx
index e2cd948..f37c554 100644
--- a/ui/src/components/SettingsItem.tsx
+++ b/ui/src/components/SettingsItem.tsx
@@ -5,13 +5,22 @@ interface SettingsItemProps {
   readonly title: string;
   readonly description: string | React.ReactNode;
   readonly badge?: string;
+  readonly badgeTheme?: keyof typeof badgeTheme;
   readonly className?: string;
   readonly loading?: boolean;
   readonly children?: React.ReactNode;
 }
 
+const badgeTheme = {
+  info: "bg-blue-500 text-white",
+  success: "bg-green-500 text-white",
+  warning: "bg-yellow-500 text-white",
+  danger: "bg-red-500 text-white",
+};
+
 export function SettingsItem(props: SettingsItemProps) {
-  const { title, description, badge, children, className, loading } = props;
+  const { title, description, badge, badgeTheme: badgeThemeProp = "danger", children, className, loading } = props;
+  const badgeThemeClass = badgeTheme[badgeThemeProp];
 
   return (
     <label
@@ -25,7 +34,7 @@ export function SettingsItem(props: SettingsItemProps) {
           <div className="flex items-center text-base font-semibold text-black dark:text-white">
             {title}
             {badge && (
-              <span className="ml-2 rounded-full bg-red-500 px-2 py-1 text-[10px] font-medium leading-none text-white dark:border dark:border-red-700 dark:bg-red-800 dark:text-red-50">
+              <span className={cx("ml-2 rounded-full px-2 py-1 text-[10px] font-medium leading-none text-white", badgeThemeClass)}>
                 {badge}
               </span>
             )}
diff --git a/ui/src/components/WebRTCVideo.tsx b/ui/src/components/WebRTCVideo.tsx
index c54d2b8..1419deb 100644
--- a/ui/src/components/WebRTCVideo.tsx
+++ b/ui/src/components/WebRTCVideo.tsx
@@ -529,7 +529,7 @@ export default function WebRTCVideo({ hasConnectionIssues }: { hasConnectionIssu
                         controlsList="nofullscreen"
                         style={videoStyle}
                         className={cx(
-                          "max-h-full min-h-[384px] max-w-full min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
+                          "max-h-full max-w-full sm:min-h-[384px] sm:min-w-[512px] bg-black/50 object-contain transition-all duration-1000",
                           {
                             "cursor-none": settings.isCursorHidden,
                             "!opacity-0":
diff --git a/ui/src/hooks/useHidRpc.ts b/ui/src/hooks/useHidRpc.ts
index 2db8279..38014d2 100644
--- a/ui/src/hooks/useHidRpc.ts
+++ b/ui/src/hooks/useHidRpc.ts
@@ -1,4 +1,5 @@
 import { useCallback, useEffect, useMemo } from "react";
+import { Logger } from "tslog";
 
 import { useRTCStore } from "@hooks/stores";
 
@@ -25,6 +26,128 @@ interface sendMessageParams {
   requireOrdered?: boolean;
 }
 
+const HANDSHAKE_TIMEOUT = 30 * 1000; // 30 seconds
+const HANDSHAKE_MAX_ATTEMPTS = 10;
+const logger = new Logger({ name: "hidrpc" });
+
+export function doRpcHidHandshake(rpcHidChannel: RTCDataChannel, setRpcHidProtocolVersion: (version: number | null) => void) {
+  let attempts = 0;
+  let lastConnectedTime: Date | undefined;
+  let lastSendTime: Date | undefined;
+  let handshakeCompleted = false;
+  let handshakeInterval: ReturnType<typeof setInterval> | null = null;
+
+  const shouldGiveUp = () => {
+    if (attempts > HANDSHAKE_MAX_ATTEMPTS) {
+      logger.error(`Failed to send handshake message after ${HANDSHAKE_MAX_ATTEMPTS} attempts`);
+      return true;
+    }
+
+    const timeSinceConnected = lastConnectedTime ? Date.now() - lastConnectedTime.getTime() : 0;
+    if (timeSinceConnected > HANDSHAKE_TIMEOUT) {
+      logger.error(`Handshake timed out after ${timeSinceConnected}ms`);
+      return true;
+    }
+
+    return false;
+  }
+
+  const sendHandshake = (initial: boolean) => {
+    if (handshakeCompleted) return;
+
+    attempts++;
+    lastSendTime = new Date();
+
+    if (!initial && shouldGiveUp()) {
+      if (handshakeInterval) {
+        clearInterval(handshakeInterval);
+        handshakeInterval = null;
+      }
+      return;
+    }
+
+    let data: Uint8Array | undefined;
+    try {
+      const message = new HandshakeMessage(HID_RPC_VERSION);
+      data = message.marshal();
+    } catch (e) {
+      logger.error("Failed to marshal message", e);
+      return;
+    }
+    if (!data) return;
+    rpcHidChannel.send(data as unknown as ArrayBuffer);
+
+    if (initial) {
+      handshakeInterval = setInterval(() => {
+        sendHandshake(false);
+      }, 1000);
+    }
+  };
+
+  const onMessage = (ev: MessageEvent) => {
+    const message = unmarshalHidRpcMessage(new Uint8Array(ev.data));
+    if (!message || !(message instanceof HandshakeMessage)) return;
+
+    if (!message.version) {
+      logger.error("Received handshake message without version", message);
+      return;
+    }
+
+    if (message.version > HID_RPC_VERSION) {
+      // we assume that the UI is always using the latest version of the HID RPC protocol
+      // so we can't support this
+      // TODO: use capabilities to determine rather than version number
+      logger.error("Server is using a newer version than the client", message);
+      return;
+    }
+
+    setRpcHidProtocolVersion(message.version);
+
+    const timeUsed = lastSendTime ? Date.now() - lastSendTime.getTime() : 0;
+    logger.info(`Handshake completed in ${timeUsed}ms after ${attempts} attempts (Version: ${message.version} / ${HID_RPC_VERSION})`);
+
+    // clean up 
+    rpcHidChannel.removeEventListener("message", onMessage);
+    resetHandshake({ completed: true });
+  };
+
+  const resetHandshake = ({ lastConnectedTime: newLastConnectedTime, completed }: { lastConnectedTime?: Date | undefined, completed?: boolean }) => {
+    if (newLastConnectedTime) lastConnectedTime = newLastConnectedTime;
+    lastSendTime = undefined;
+    attempts = 0;
+    if (completed !== undefined) handshakeCompleted = completed;
+    if (handshakeInterval) {
+      clearInterval(handshakeInterval);
+      handshakeInterval = null;
+    }
+  };
+
+  const onConnected = () => {
+    resetHandshake({ lastConnectedTime: new Date() });
+    logger.info("Channel connected");
+
+    sendHandshake(true);
+    rpcHidChannel.addEventListener("message", onMessage);
+  };
+
+  const onClose = () => {
+    resetHandshake({ lastConnectedTime: undefined, completed: false });
+
+    logger.info("Channel closed");
+    setRpcHidProtocolVersion(null);
+
+    rpcHidChannel.removeEventListener("message", onMessage);
+  };
+
+  rpcHidChannel.addEventListener("open", onConnected);
+  rpcHidChannel.addEventListener("close", onClose);
+
+  // handle case where channel is already open when the hook is mounted
+  if (rpcHidChannel.readyState === "open") {
+    onConnected();
+  }
+}
+
 export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
   const {
     rpcHidChannel,
@@ -78,7 +201,7 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
       try {
         data = message.marshal();
       } catch (e) {
-        console.error("Failed to marshal HID RPC message", e);
+        logger.error("Failed to marshal message", e);
       }
       if (!data) return;
 
@@ -151,99 +274,46 @@ export function useHidRpc(onHidRpcMessage?: (payload: RpcMessage) => void) {
     sendMessage(KEEPALIVE_MESSAGE);
   }, [sendMessage]);
 
-  const sendHandshake = useCallback(() => {
-    if (hidRpcDisabled) return;
-    if (rpcHidProtocolVersion) return;
-    if (!rpcHidChannel) return;
-
-    sendMessage(new HandshakeMessage(HID_RPC_VERSION), { ignoreHandshakeState: true });
-  }, [rpcHidChannel, rpcHidProtocolVersion, sendMessage, hidRpcDisabled]);
-
-  const handleHandshake = useCallback(
-    (message: HandshakeMessage) => {
-      if (hidRpcDisabled) return;
-
-      if (!message.version) {
-        console.error("Received handshake message without version", message);
-        return;
-      }
-
-      if (message.version > HID_RPC_VERSION) {
-        // we assume that the UI is always using the latest version of the HID RPC protocol
-        // so we can't support this
-        // TODO: use capabilities to determine rather than version number
-        console.error("Server is using a newer HID RPC version than the client", message);
-        return;
-      }
-
-      setRpcHidProtocolVersion(message.version);
-    },
-    [setRpcHidProtocolVersion, hidRpcDisabled],
-  );
-
   useEffect(() => {
     if (!rpcHidChannel) return;
     if (hidRpcDisabled) return;
 
-    // send handshake message
-    sendHandshake();
-
     const messageHandler = (e: MessageEvent) => {
       if (typeof e.data === "string") {
-        console.warn("Received string data in HID RPC message handler", e.data);
+        logger.warn("Received string data in message handler", e.data);
         return;
       }
 
       const message = unmarshalHidRpcMessage(new Uint8Array(e.data));
       if (!message) {
-        console.warn("Received invalid HID RPC message", e.data);
+        logger.warn("Received invalid message", e.data);
         return;
       }
 
-      console.debug("Received HID RPC message", message);
-      switch (message.constructor) {
-        case HandshakeMessage:
-          handleHandshake(message as HandshakeMessage);
-          break;
-        default:
-          // not all events are handled here, the rest are handled by the onHidRpcMessage callback
-          break;
-      }
+      if (message instanceof HandshakeMessage) return; // handshake message is handled by the doRpcHidHandshake function
 
-      onHidRpcMessage?.(message);
-    };
+      // to remove it from the production build, we need to use the /* @__PURE__ */ comment here
+      // setting `esbuild.pure` doesn't work
+      /* @__PURE__ */ logger.debug("Received message", message);
 
-    const openHandler = () => {
-      console.info("HID RPC channel opened");
-      sendHandshake();
-    };
-
-    const closeHandler = () => {
-      console.info("HID RPC channel closed");
-      setRpcHidProtocolVersion(null);
+      onHidRpcMessage?.(message);
     };
 
     const errorHandler = (e: Event) => {
-      console.error(`Error on rpcHidChannel '${rpcHidChannel.label}': ${e}`)
+      logger.error(`Error on channel '${rpcHidChannel.label}'`, e);
     };
 
     rpcHidChannel.addEventListener("message", messageHandler);
-    rpcHidChannel.addEventListener("close", closeHandler);
     rpcHidChannel.addEventListener("error", errorHandler);
-    rpcHidChannel.addEventListener("open", openHandler);
 
     return () => {
       rpcHidChannel.removeEventListener("message", messageHandler);
-      rpcHidChannel.removeEventListener("close", closeHandler);
       rpcHidChannel.removeEventListener("error", errorHandler);
-      rpcHidChannel.removeEventListener("open", openHandler);
     };
   }, [
     rpcHidChannel,
     onHidRpcMessage,
     setRpcHidProtocolVersion,
-    sendHandshake,
-    handleHandshake,
     hidRpcDisabled,
   ]);
 
diff --git a/ui/src/hooks/useVersion.tsx b/ui/src/hooks/useVersion.tsx
index 94c2f99..feb9961 100644
--- a/ui/src/hooks/useVersion.tsx
+++ b/ui/src/hooks/useVersion.tsx
@@ -6,6 +6,19 @@ import { getUpdateStatus, getLocalVersion as getLocalVersionRpc } from "@/utils/
 import notifications from "@/notifications";
 import { m } from "@localizations/messages.js";
 
+export interface VersionInfo {
+  appVersion: string;
+  systemVersion: string;
+}
+
+export interface SystemVersionInfo {
+  local: VersionInfo;
+  remote?: VersionInfo;
+  systemUpdateAvailable: boolean;
+  appUpdateAvailable: boolean;
+  error?: string;
+}
+
 export function useVersion() {
   const {
     appVersion,
diff --git a/ui/src/routes/devices.$id.settings.access._index.tsx b/ui/src/routes/devices.$id.settings.access._index.tsx
index 766b8c4..9b2d3cd 100644
--- a/ui/src/routes/devices.$id.settings.access._index.tsx
+++ b/ui/src/routes/devices.$id.settings.access._index.tsx
@@ -11,6 +11,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
 import { SettingsItem } from "@components/SettingsItem";
 import { SettingsPageHeader } from "@components/SettingsPageheader";
 import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
+import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
 import { TextAreaWithLabel } from "@components/TextArea";
 import api from "@/api";
 import notifications from "@/notifications";
@@ -237,39 +238,30 @@ export default function SettingsAccessIndexRoute() {
               </SettingsItem>
 
               {tlsMode === "custom" && (
-                <div className="mt-4 space-y-4">
-                  <div className="space-y-4">
-                    <SettingsItem
-                      title={m.access_tls_certificate_title()}
-                      description={m.access_tls_certificate_description()}
-                    />
-                    <div className="space-y-4">
-                      <TextAreaWithLabel
-                        label={m.access_certificate_label()}
-                        rows={3}
-                        placeholder={
-                          "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
-                        }
-                        value={tlsCert}
-                        onChange={e => handleTlsCertChange(e.target.value)}
-                      />
-                    </div>
-
-                    <div className="space-y-4">
-                      <div className="space-y-4">
-                        <TextAreaWithLabel
-                          label={m.access_private_key_label()}
-                          description={m.access_private_key_description()}
-                          rows={3}
-                          placeholder={
-                            "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
-                          }
-                          value={tlsKey}
-                          onChange={e => handleTlsKeyChange(e.target.value)}
-                        />
-                      </div>
-                    </div>
-                  </div>
+                <NestedSettingsGroup className="mt-4">
+                  <SettingsItem
+                    title={m.access_tls_certificate_title()}
+                    description={m.access_tls_certificate_description()}
+                  />
+                  <TextAreaWithLabel
+                    label={m.access_certificate_label()}
+                    rows={3}
+                    placeholder={
+                      "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"
+                    }
+                    value={tlsCert}
+                    onChange={e => handleTlsCertChange(e.target.value)}
+                  />
+                  <TextAreaWithLabel
+                    label={m.access_private_key_label()}
+                    description={m.access_private_key_description()}
+                    rows={3}
+                    placeholder={
+                      "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
+                    }
+                    value={tlsKey}
+                    onChange={e => handleTlsKeyChange(e.target.value)}
+                  />
                   <div className="flex items-center gap-x-2">
                     <Button
                       size="SM"
@@ -278,7 +270,7 @@ export default function SettingsAccessIndexRoute() {
                       onClick={handleCustomTlsUpdate}
                     />
                   </div>
-                </div>
+                </NestedSettingsGroup>
               )}
 
               <SettingsItem
@@ -352,7 +344,7 @@ export default function SettingsAccessIndexRoute() {
               </SettingsItem>
 
               {selectedProvider === "custom" && (
-                <div className="mt-4 space-y-4">
+                <NestedSettingsGroup className="mt-4">
                   <div className="flex items-end gap-x-2">
                     <InputFieldWithLabel
                       size="SM"
@@ -371,7 +363,7 @@ export default function SettingsAccessIndexRoute() {
                       placeholder="https://app.example.com"
                     />
                   </div>
-                </div>
+                </NestedSettingsGroup>
               )}
             </>
           )}
diff --git a/ui/src/routes/devices.$id.settings.advanced.tsx b/ui/src/routes/devices.$id.settings.advanced.tsx
index 4c3c9e9..dd39f9b 100644
--- a/ui/src/routes/devices.$id.settings.advanced.tsx
+++ b/ui/src/routes/devices.$id.settings.advanced.tsx
@@ -1,21 +1,30 @@
 import { useCallback, useEffect, useState } from "react";
 
 import { useSettingsStore } from "@hooks/stores";
-import { JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
+import { JsonRpcError, JsonRpcResponse, useJsonRpc } from "@hooks/useJsonRpc";
+import { useDeviceUiNavigation } from "@hooks/useAppNavigation";
 import { Button } from "@components/Button";
-import Checkbox from "@components/Checkbox";
+import Checkbox, { CheckboxWithLabel } from "@components/Checkbox";
 import { ConfirmDialog } from "@components/ConfirmDialog";
 import { GridCard } from "@components/Card";
 import { SettingsItem } from "@components/SettingsItem";
 import { SettingsPageHeader } from "@components/SettingsPageheader";
+import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
 import { TextAreaWithLabel } from "@components/TextArea";
+import { InputFieldWithLabel } from "@components/InputField";
+import { SelectMenuBasic } from "@components/SelectMenuBasic";
 import { isOnDevice } from "@/main";
 import notifications from "@/notifications";
 import { m } from "@localizations/messages.js";
 import { sleep } from "@/utils";
+import { checkUpdateComponents, UpdateComponents } from "@/utils/jsonrpc";
+import { SystemVersionInfo } from "@hooks/useVersion";
+
+import { FeatureFlag } from "../components/FeatureFlag";
 
 export default function SettingsAdvancedRoute() {
   const { send } = useJsonRpc();
+  const { navigateTo } = useDeviceUiNavigation();
 
   const [sshKey, setSSHKey] = useState<string>("");
   const { setDeveloperMode } = useSettingsStore();
@@ -23,7 +32,12 @@ export default function SettingsAdvancedRoute() {
   const [usbEmulationEnabled, setUsbEmulationEnabled] = useState(false);
   const [showLoopbackWarning, setShowLoopbackWarning] = useState(false);
   const [localLoopbackOnly, setLocalLoopbackOnly] = useState(false);
-
+  const [updateTarget, setUpdateTarget] = useState<string>("app");
+  const [appVersion, setAppVersion] = useState<string>("");
+  const [systemVersion, setSystemVersion] = useState<string>("");
+  const [resetConfig, setResetConfig] = useState(false);
+  const [versionChangeAcknowledged, setVersionChangeAcknowledged] = useState(false);
+  const [customVersionUpdateLoading, setCustomVersionUpdateLoading] = useState(false);
   const settings = useSettingsStore();
 
   useEffect(() => {
@@ -173,6 +187,61 @@ export default function SettingsAdvancedRoute() {
     setShowLoopbackWarning(false);
   }, [applyLoopbackOnlyMode, setShowLoopbackWarning]);
 
+  const handleVersionUpdateError = useCallback((error?: JsonRpcError | string) => {
+    notifications.error(
+      m.advanced_error_version_update({
+        error: typeof error === "string" ? error : (error?.data ?? error?.message ?? m.unknown_error())
+      }),
+      { duration: 1000 * 15 } // 15 seconds
+    );
+    setCustomVersionUpdateLoading(false);
+  }, []);
+
+  const handleCustomVersionUpdate = useCallback(async () => {
+    const components: UpdateComponents = {};
+    if (["app", "both"].includes(updateTarget) && appVersion) components.app = appVersion;
+    if (["system", "both"].includes(updateTarget) && systemVersion) components.system = systemVersion;
+    let versionInfo: SystemVersionInfo | undefined;
+
+    try {
+      // we do not need to set it to false if check succeeds,
+      // because it will be redirected to the update page later
+      setCustomVersionUpdateLoading(true);
+      versionInfo = await checkUpdateComponents({
+        components,
+      }, devChannel);
+    } catch (error: unknown) {
+      const jsonRpcError = error as JsonRpcError;
+      handleVersionUpdateError(jsonRpcError);
+      return;
+    }
+
+    let hasUpdate = false;
+
+    const pageParams = new URLSearchParams();
+    if (components.app && versionInfo?.remote?.appVersion && versionInfo?.appUpdateAvailable) {
+      hasUpdate = true;
+      pageParams.set("custom_app_version", versionInfo.remote?.appVersion);
+    }
+    if (components.system && versionInfo?.remote?.systemVersion && versionInfo?.systemUpdateAvailable) {
+      hasUpdate = true;
+      pageParams.set("custom_system_version", versionInfo.remote?.systemVersion);
+    }
+    pageParams.set("reset_config", resetConfig.toString());
+
+    if (!hasUpdate) {
+      handleVersionUpdateError("No update available");
+      return;
+    }
+
+    // Navigate to update page
+    navigateTo(`/settings/general/update?${pageParams.toString()}`);
+  }, [
+    updateTarget, appVersion, systemVersion, devChannel,
+    navigateTo, resetConfig, handleVersionUpdateError,
+    setCustomVersionUpdateLoading
+  ]);
+
   return (
     <div className="space-y-4">
       <SettingsPageHeader
@@ -201,41 +270,151 @@ export default function SettingsAdvancedRoute() {
             onChange={e => handleDevModeChange(e.target.checked)}
           />
         </SettingsItem>
+        {settings.developerMode ? (
+          <NestedSettingsGroup>
+            <GridCard>
+              <div className="flex items-start gap-x-4 p-4 select-none">
+                <svg
+                  xmlns="http://www.w3.org/2000/svg"
+                  viewBox="0 0 24 24"
+                  fill="currentColor"
+                  className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
+                >
+                  <path
+                    fillRule="evenodd"
+                    d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
+                    clipRule="evenodd"
+                  />
+                </svg>
+                <div className="space-y-3">
+                  <div className="space-y-2">
+                    <h3 className="text-base font-bold text-slate-900 dark:text-white">
+                      {m.advanced_developer_mode_enabled_title()}
+                    </h3>
+                    <div>
+                      <ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
+                        <li>{m.advanced_developer_mode_warning_security()}</li>
+                        <li>{m.advanced_developer_mode_warning_risks()}</li>
+                      </ul>
+                    </div>
+                  </div>
+                  <div className="text-xs text-slate-700 dark:text-slate-300">
+                    {m.advanced_developer_mode_warning_advanced()}
+                  </div>
+                </div>
+              </div>
+            </GridCard>
 
-        {settings.developerMode && (
-          <GridCard>
-            <div className="flex items-start gap-x-4 p-4 select-none">
-              <svg
-                xmlns="http://www.w3.org/2000/svg"
-                viewBox="0 0 24 24"
-                fill="currentColor"
-                className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
-              >
-                <path
-                  fillRule="evenodd"
-                  d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
-                  clipRule="evenodd"
+            {isOnDevice && (
+              <div className="space-y-4">
+                <SettingsItem
+                  title={m.advanced_ssh_access_title()}
+                  description={m.advanced_ssh_access_description()}
                 />
-              </svg>
-              <div className="space-y-3">
-                <div className="space-y-2">
-                  <h3 className="text-base font-bold text-slate-900 dark:text-white">
-                    {m.advanced_developer_mode_enabled_title()}
-                  </h3>
-                  <div>
-                    <ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
-                      <li>{m.advanced_developer_mode_warning_security()}</li>
-                      <li>{m.advanced_developer_mode_warning_risks()}</li>
-                    </ul>
-                  </div>
+                <TextAreaWithLabel
+                  label={m.advanced_ssh_public_key_label()}
+                  value={sshKey || ""}
+                  rows={3}
+                  onChange={e => setSSHKey(e.target.value)}
+                  placeholder={m.advanced_ssh_public_key_placeholder()}
+                />
+                <p className="text-xs text-slate-600 dark:text-slate-400">
+                  {m.advanced_ssh_default_user()}<strong>root</strong>.
+                </p>
+                <div className="flex items-center gap-x-2">
+                  <Button
+                    size="SM"
+                    theme="primary"
+                    text={m.advanced_update_ssh_key_button()}
+                    onClick={handleUpdateSSHKey}
+                  />
                 </div>
-                <div className="text-xs text-slate-700 dark:text-slate-300">
-                  {m.advanced_developer_mode_warning_advanced()}
+              </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>
-            </div>
-          </GridCard>
-        )}
+            </FeatureFlag>
+          </NestedSettingsGroup>
+        ) : null}
 
         <SettingsItem
           title={m.advanced_loopback_only_title()}
@@ -247,34 +426,7 @@ export default function SettingsAdvancedRoute() {
           />
         </SettingsItem>
 
-        {isOnDevice && settings.developerMode && (
-          <div className="space-y-4">
-            <SettingsItem
-              title={m.advanced_ssh_access_title()}
-              description={m.advanced_ssh_access_description()}
-            />
-            <div className="space-y-4">
-              <TextAreaWithLabel
-                label={m.advanced_ssh_public_key_label()}
-                value={sshKey || ""}
-                rows={3}
-                onChange={e => setSSHKey(e.target.value)}
-                placeholder={m.advanced_ssh_public_key_placeholder()}
-              />
-              <p className="text-xs text-slate-600 dark:text-slate-400">
-                {m.advanced_ssh_default_user()}<strong>root</strong>.
-              </p>
-              <div className="flex items-center gap-x-2">
-                <Button
-                  size="SM"
-                  theme="primary"
-                  text={m.advanced_update_ssh_key_button()}
-                  onClick={handleUpdateSSHKey}
-                />
-              </div>
-            </div>
-          </div>
-        )}
+
 
         <SettingsItem
           title={m.advanced_troubleshooting_mode_title()}
@@ -289,7 +441,7 @@ export default function SettingsAdvancedRoute() {
         </SettingsItem>
 
         {settings.debugMode && (
-          <>
+          <NestedSettingsGroup>
             <SettingsItem
               title={m.advanced_usb_emulation_title()}
               description={m.advanced_usb_emulation_description()}
@@ -320,7 +472,7 @@ export default function SettingsAdvancedRoute() {
                 }}
               />
             </SettingsItem>
-          </>
+          </NestedSettingsGroup>
         )}
       </div>
 
diff --git a/ui/src/routes/devices.$id.settings.general._index.tsx b/ui/src/routes/devices.$id.settings.general._index.tsx
index 86e92bc..f8d8457 100644
--- a/ui/src/routes/devices.$id.settings.general._index.tsx
+++ b/ui/src/routes/devices.$id.settings.general._index.tsx
@@ -17,7 +17,6 @@ export default function SettingsGeneralRoute() {
   const { send } = useJsonRpc();
   const { navigateTo } = useDeviceUiNavigation();
   const [autoUpdate, setAutoUpdate] = useState(true);
-
   const currentVersions = useDeviceStore(state => {
     const { appVersion, systemVersion } = state;
     if (!appVersion || !systemVersion) return null;
@@ -48,10 +47,10 @@ export default function SettingsGeneralRoute() {
   const localeOptions = useMemo(() => {
     return ["", ...locales]
       .map((code) => {
-          const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code);
-          // don't repeat the name if it's the same in both locales (or blank)
-          const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName;
-          return { value: code, label: label }
+        const [localizedName, nativeName] = map_locale_code_to_name(currentLocale, code);
+        // don't repeat the name if it's the same in both locales (or blank)
+        const label = nativeName && nativeName !== localizedName ? `${localizedName} - ${nativeName}` : localizedName;
+        return { value: code, label: label }
       });
   }, [currentLocale]);
 
@@ -85,6 +84,8 @@ export default function SettingsGeneralRoute() {
         <div className="space-y-4 pb-2">
           <div className="space-y-4">
             <SettingsItem
+              badge="Beta"
+              badgeTheme="info"
               title={m.user_interface_language_title()}
               description={m.user_interface_language_description()}
             >
@@ -108,7 +109,7 @@ export default function SettingsGeneralRoute() {
                 </>
               }
             />
-            <div>
+            <div className="flex items-center justify-start gap-x-2">
               <Button
                 size="SM"
                 theme="light"
diff --git a/ui/src/routes/devices.$id.settings.general.reboot.tsx b/ui/src/routes/devices.$id.settings.general.reboot.tsx
index b8c32b5..ec7e7e8 100644
--- a/ui/src/routes/devices.$id.settings.general.reboot.tsx
+++ b/ui/src/routes/devices.$id.settings.general.reboot.tsx
@@ -11,7 +11,7 @@ import LoadingSpinner from "../components/LoadingSpinner";
 import { useDeviceUiNavigation } from "../hooks/useAppNavigation";
 
 // Time to wait after initiating reboot before redirecting to home
-const REBOOT_REDIRECT_DELAY_MS = 5000;
+const REBOOT_REDIRECT_DELAY_MS = 7000;
 
 export default function SettingsGeneralRebootRoute() {
   const navigate = useNavigate();
@@ -19,7 +19,7 @@ export default function SettingsGeneralRebootRoute() {
   const [isRebooting, setIsRebooting] = useState(false);
   const { navigateTo } = useDeviceUiNavigation();
   const { setFailsafeMode } = useFailsafeModeStore();
-  
+
   const onClose = useCallback(async () => {
     navigate(".."); // back to the devices.$id.settings page
     // Add 1s delay between navigation and calling reload() to prevent reload from interrupting the navigation.
diff --git a/ui/src/routes/devices.$id.settings.general.update.tsx b/ui/src/routes/devices.$id.settings.general.update.tsx
index dd80001..5aead15 100644
--- a/ui/src/routes/devices.$id.settings.general.update.tsx
+++ b/ui/src/routes/devices.$id.settings.general.update.tsx
@@ -1,5 +1,5 @@
 import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-import { useLocation, useNavigate } from "react-router";
+import { useLocation, useNavigate, useSearchParams } from "react-router";
 
 import { useJsonRpc } from "@hooks/useJsonRpc";
 import { UpdateState, useUpdateStore } from "@hooks/stores";
@@ -11,16 +11,21 @@ import LoadingSpinner from "@components/LoadingSpinner";
 import UpdatingStatusCard, { type UpdatePart } from "@components/UpdatingStatusCard";
 import { m } from "@localizations/messages.js";
 import { sleep } from "@/utils";
-import { SystemVersionInfo } from "@/utils/jsonrpc";
+import { checkUpdateComponents, SystemVersionInfo, UpdateComponents, updateParams } from "@/utils/jsonrpc";
 
 export default function SettingsGeneralUpdateRoute() {
   const navigate = useNavigate();
   const location = useLocation();
+  const [searchParams] = useSearchParams();
   const { updateSuccess } = location.state || {};
 
   const { setModalView, otaState, shouldReload, setShouldReload } = useUpdateStore();
   const { send } = useJsonRpc();
 
+  const customAppVersion = useMemo(() => searchParams.get("custom_app_version") || undefined, [searchParams]);
+  const customSystemVersion = useMemo(() => searchParams.get("custom_system_version") || undefined, [searchParams]);
+  const resetConfig = useMemo(() => searchParams.get("reset_config") === "true", [searchParams]);
+
   const onClose = useCallback(async () => {
     navigate(".."); // back to the devices.$id.settings page
 
@@ -37,6 +42,21 @@ export default function SettingsGeneralUpdateRoute() {
     setModalView("updating");
   }, [send, setModalView, setShouldReload]);
 
+  const onConfirmCustomUpdate = useCallback((appTargetVersion?: string, systemTargetVersion?: string) => {
+    const components: UpdateComponents = {};
+    if (appTargetVersion) components.app = appTargetVersion;
+    if (systemTargetVersion) components.system = systemTargetVersion;
+
+    setShouldReload(true);
+    setModalView("updating");
+
+    send("tryUpdateComponents", {
+      params: { components, },
+      includePreRelease: false,
+      resetConfig,
+    });
+  }, [resetConfig, send, setModalView, setShouldReload]);
+
   useEffect(() => {
     if (otaState.updating) {
       setModalView("updating");
@@ -49,20 +69,39 @@ export default function SettingsGeneralUpdateRoute() {
     }
   }, [otaState.error, otaState.updating, setModalView, updateSuccess]);
 
-  return <Dialog onClose={onClose} onConfirmUpdate={onConfirmUpdate} />;
+  return <Dialog
+    onClose={onClose}
+    onConfirmUpdate={onConfirmUpdate}
+    onConfirmCustomUpdate={onConfirmCustomUpdate}
+    customAppVersion={customAppVersion}
+    customSystemVersion={customSystemVersion}
+  />;
 }
 
 export function Dialog({
   onClose,
   onConfirmUpdate,
+  onConfirmCustomUpdate: onConfirmCustomUpdateCallback,
+  customAppVersion,
+  customSystemVersion,
 }: Readonly<{
   onClose: () => void;
   onConfirmUpdate: () => void;
+  onConfirmCustomUpdate: (appVersion?: string, systemVersion?: string) => void;
+  customAppVersion?: string;
+  customSystemVersion?: string;
 }>) {
   const { navigateTo } = useDeviceUiNavigation();
 
   const [versionInfo, setVersionInfo] = useState<null | SystemVersionInfo>(null);
   const { modalView, setModalView, otaState } = useUpdateStore();
+  const forceCustomUpdate = customSystemVersion !== undefined || customAppVersion !== undefined;
+  const onConfirmCustomUpdate = useCallback(() => {
+    onConfirmCustomUpdateCallback(
+      customAppVersion !== undefined ? versionInfo?.remote?.appVersion : undefined,
+      customSystemVersion !== undefined ? versionInfo?.remote?.systemVersion : undefined,
+    );
+  }, [onConfirmCustomUpdateCallback, customAppVersion, customSystemVersion, versionInfo]);
 
   const onFinishedLoading = useCallback(
     (versionInfo: SystemVersionInfo) => {
@@ -71,13 +110,13 @@ export function Dialog({
 
       setVersionInfo(versionInfo);
 
-      if (hasUpdate) {
+      if (hasUpdate || forceCustomUpdate) {
         setModalView("updateAvailable");
       } else {
         setModalView("upToDate");
       }
     },
-    [setModalView],
+    [setModalView, forceCustomUpdate],
   );
 
   return (
@@ -92,12 +131,18 @@ export function Dialog({
         )}
 
         {modalView === "loading" && (
-          <LoadingState onFinished={onFinishedLoading} onCancelCheck={onClose} />
+          <LoadingState
+            onFinished={onFinishedLoading}
+            onCancelCheck={onClose}
+            customAppVersion={customAppVersion}
+            customSystemVersion={customSystemVersion}
+          />
         )}
 
         {modalView === "updateAvailable" && (
           <UpdateAvailableState
-            onConfirmUpdate={onConfirmUpdate}
+            forceCustomUpdate={forceCustomUpdate}
+            onConfirm={forceCustomUpdate ? onConfirmCustomUpdate : onConfirmUpdate}
             onClose={onClose}
             versionInfo={versionInfo!}
           />
@@ -126,9 +171,13 @@ export function Dialog({
 function LoadingState({
   onFinished,
   onCancelCheck,
+  customAppVersion,
+  customSystemVersion,
 }: {
   onFinished: (versionInfo: SystemVersionInfo) => void;
   onCancelCheck: () => void;
+  customAppVersion?: string;
+  customSystemVersion?: string;
 }) {
   const [progressWidth, setProgressWidth] = useState("0%");
   const abortControllerRef = useRef<AbortController | null>(null);
@@ -138,6 +187,17 @@ function LoadingState({
 
   const progressBarRef = useRef<HTMLDivElement>(null);
 
+  const checkUpdate = useCallback(async () => {
+    if (!customAppVersion && !customSystemVersion) {
+      return await getVersionInfo();
+    }
+    const params: updateParams = { components: {} as UpdateComponents };
+    if (customAppVersion) params.components!.app = customAppVersion;
+    if (customSystemVersion) params.components!.system = customSystemVersion;
+
+    return await checkUpdateComponents(params, false);
+  }, [customAppVersion, customSystemVersion, getVersionInfo]);
+
   useEffect(() => {
     abortControllerRef.current = new AbortController();
     const signal = abortControllerRef.current.signal;
@@ -147,7 +207,7 @@ function LoadingState({
       setProgressWidth("100%");
     }, 0);
 
-    getVersionInfo()
+    checkUpdate()
       .then(async versionInfo => {
         // Add a small delay to ensure it's not just flickering
         await sleep(600);
@@ -169,7 +229,7 @@ function LoadingState({
       clearTimeout(animationTimer);
       abortControllerRef.current?.abort();
     };
-  }, [getVersionInfo, onFinished, setModalView]);
+  }, [checkUpdate, onFinished, setModalView]);
 
   return (
     <div className="flex flex-col items-start justify-start space-y-4 text-left">
@@ -377,11 +437,12 @@ function SystemUpToDateState({
 
 function UpdateAvailableState({
   versionInfo,
-  onConfirmUpdate,
+  onConfirm,
   onClose,
 }: {
   versionInfo: SystemVersionInfo;
-  onConfirmUpdate: () => void;
+  forceCustomUpdate: boolean;
+  onConfirm: () => void;
   onClose: () => void;
 }) {
   return (
@@ -396,18 +457,23 @@ function UpdateAvailableState({
         <p className="mb-4 text-sm text-slate-600 dark:text-slate-300">
           {versionInfo?.systemUpdateAvailable ? (
             <>
-              <span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.remote?.systemVersion}
+              <span className="font-semibold">{m.general_update_system_type()}</span>: {versionInfo?.local?.systemVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.systemVersion}
               <br />
             </>
           ) : null}
           {versionInfo?.appUpdateAvailable ? (
             <>
-              <span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.remote?.appVersion}
+              <span className="font-semibold">{m.general_update_application_type()}</span>: {versionInfo?.local?.appVersion} <span className="text-slate-600 dark:text-slate-300">→</span> {versionInfo?.remote?.appVersion}
             </>
           ) : null}
+          {versionInfo?.willDisableAutoUpdate ? (
+            <p className="mb-4 text-sm text-red-600 dark:text-red-400">
+              {m.general_update_will_disable_auto_update_description()}
+            </p>
+          ) : null}
         </p>
         <div className="flex items-center justify-start gap-x-2">
-          <Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirmUpdate} />
+          <Button size="SM" theme="primary" text={m.general_update_now_button()} onClick={onConfirm} />
           <Button size="SM" theme="light" text={m.general_update_later_button()} onClick={onClose} />
         </div>
       </div>
diff --git a/ui/src/routes/devices.$id.settings.hardware.tsx b/ui/src/routes/devices.$id.settings.hardware.tsx
index 4277641..3527f26 100644
--- a/ui/src/routes/devices.$id.settings.hardware.tsx
+++ b/ui/src/routes/devices.$id.settings.hardware.tsx
@@ -8,6 +8,7 @@ import { SelectMenuBasic } from "@components/SelectMenuBasic";
 import { SettingsItem } from "@components/SettingsItem";
 import { SettingsPageHeader } from "@components/SettingsPageheader";
 import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
+import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
 import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
 import { UsbInfoSetting } from "@components/UsbInfoSetting";
 import notifications from "@/notifications";
@@ -156,7 +157,7 @@ export default function SettingsHardwareRoute() {
           />
         </SettingsItem>
         {backlightSettings.max_brightness != 0 && (
-          <>
+          <NestedSettingsGroup>
             <SettingsItem
               title={m.hardware_dim_display_after_title()}
               description={m.hardware_dim_display_after_description()}
@@ -198,7 +199,7 @@ export default function SettingsHardwareRoute() {
                 }}
               />
             </SettingsItem>
-          </>
+          </NestedSettingsGroup>
         )}
         <p className="text-xs text-slate-600 dark:text-slate-400">
           {m.hardware_display_wake_up_note()}
diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx
index d4dd0bd..8fdece8 100644
--- a/ui/src/routes/devices.$id.settings.network.tsx
+++ b/ui/src/routes/devices.$id.settings.network.tsx
@@ -278,6 +278,14 @@ export default function SettingsNetworkRoute() {
       });
     }
 
+    if (dirty.hostname) {
+      changes.push({
+        label: m.network_hostname_title(),
+        from: initialSettingsRef.current?.hostname?.toString() ?? "",
+        to: data.hostname?.toString() ?? "",
+      });
+    }
+
     // If no critical fields are changed, save immediately
     if (changes.length === 0) return onSubmit(settings);
 
diff --git a/ui/src/routes/devices.$id.settings.video.tsx b/ui/src/routes/devices.$id.settings.video.tsx
index d6eb32d..f1e68a0 100644
--- a/ui/src/routes/devices.$id.settings.video.tsx
+++ b/ui/src/routes/devices.$id.settings.video.tsx
@@ -7,6 +7,7 @@ import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
 import { SettingsItem } from "@components/SettingsItem";
 import { SettingsPageHeader } from "@components/SettingsPageheader";
 import { SelectMenuBasic } from "@components/SelectMenuBasic";
+import { NestedSettingsGroup } from "@components/NestedSettingsGroup";
 import Fieldset from "@components/Fieldset";
 import notifications from "@/notifications";
 import { m } from "@localizations/messages.js";
@@ -174,7 +175,7 @@ export default function SettingsVideoRoute() {
               description={m.video_enhancement_description()}
             />
 
-            <div className="space-y-4 pl-4">
+            <NestedSettingsGroup>
               <SettingsItem
                 title={m.video_saturation_title()}
                 description={m.video_saturation_description({ value: videoSaturation.toFixed(1) })}
@@ -232,7 +233,7 @@ export default function SettingsVideoRoute() {
                   }}
                 />
               </div>
-            </div>
+            </NestedSettingsGroup>
             <Fieldset disabled={edidLoading} className="space-y-2">
               <SettingsItem
                 title={m.video_edid_title()}
diff --git a/ui/src/routes/devices.$id.tsx b/ui/src/routes/devices.$id.tsx
index 1cc28a1..043125c 100644
--- a/ui/src/routes/devices.$id.tsx
+++ b/ui/src/routes/devices.$id.tsx
@@ -53,6 +53,7 @@ import {
 } from "@components/VideoOverlay";
 import { FeatureFlagProvider } from "@providers/FeatureFlagProvider";
 import { m } from "@localizations/messages.js";
+import { doRpcHidHandshake } from "@hooks/useHidRpc";
 
 export type AuthMode = "password" | "noPassword" | null;
 
@@ -127,6 +128,7 @@ export default function KvmIdRoute() {
     setRpcHidChannel,
     setRpcHidUnreliableNonOrderedChannel,
     setRpcHidUnreliableChannel,
+    setRpcHidProtocolVersion,
   } = useRTCStore();
 
   const location = useLocation();
@@ -498,6 +500,7 @@ export default function KvmIdRoute() {
     rpcHidChannel.onopen = () => {
       setRpcHidChannel(rpcHidChannel);
     };
+    doRpcHidHandshake(rpcHidChannel, setRpcHidProtocolVersion);
 
     const rpcHidUnreliableChannel = pc.createDataChannel("hidrpc-unreliable-ordered", {
       ordered: true,
@@ -534,6 +537,7 @@ export default function KvmIdRoute() {
     setRpcHidChannel,
     setRpcHidUnreliableNonOrderedChannel,
     setRpcHidUnreliableChannel,
+    setRpcHidProtocolVersion,
     setTransceiver,
   ]);
 
@@ -615,7 +619,7 @@ export default function KvmIdRoute() {
     });
   }, 10000);
 
-  const { setNetworkState  } = useNetworkStateStore();
+  const { setNetworkState } = useNetworkStateStore();
   const { setHdmiState } = useVideoStore();
   const {
     keyboardLedState, setKeyboardLedState,
@@ -697,7 +701,14 @@ export default function KvmIdRoute() {
     if (resp.method === "willReboot") {
       const postRebootAction = resp.params as unknown as PostRebootAction;
       console.debug("Setting reboot state", postRebootAction);
-      setRebootState({ isRebooting: true, postRebootAction });
+
+      setRebootState({
+        isRebooting: true,
+        postRebootAction: {
+          healthCheck: postRebootAction?.healthCheck || `${window.location.origin}/device/status`,
+          redirectTo: postRebootAction?.redirectTo || window.location.href,
+        }
+      });
       navigateTo("/");
     }
 
@@ -815,6 +826,10 @@ export default function KvmIdRoute() {
       return <RebootingOverlay show={true} postRebootAction={rebootState.postRebootAction} />;
     }
 
+    if (isFailsafeMode && failsafeReason) {
+      return <FailSafeModeOverlay reason={failsafeReason} />;
+    }
+
     const hasConnectionFailed =
       connectionFailed || ["failed", "closed"].includes(peerConnectionState ?? "");
 
@@ -839,7 +854,7 @@ export default function KvmIdRoute() {
     }
 
     return null;
-  }, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
+  }, [location.pathname, rebootState?.isRebooting, rebootState?.postRebootAction, isFailsafeMode, failsafeReason, connectionFailed, peerConnectionState, peerConnection, setupPeerConnection, loadingMessage]);
 
   return (
     <FeatureFlagProvider appVersion={appVersion}>
@@ -887,9 +902,7 @@ export default function KvmIdRoute() {
               className="animate-slideUpFade pointer-events-none absolute inset-0 flex items-center justify-center p-4"
             >
               <div className="relative h-full max-h-[720px] w-full max-w-[1280px] rounded-md">
-                {isFailsafeMode && failsafeReason ? (
-                  <FailSafeModeOverlay reason={failsafeReason} />
-                ) : !!ConnectionStatusElement && ConnectionStatusElement}
+                {!!ConnectionStatusElement && ConnectionStatusElement}
               </div>
             </div>
             <SidebarContainer sidebarView={sidebarView} />
diff --git a/ui/src/utils/jsonrpc.ts b/ui/src/utils/jsonrpc.ts
index ae97be1..d67b5c2 100644
--- a/ui/src/utils/jsonrpc.ts
+++ b/ui/src/utils/jsonrpc.ts
@@ -221,16 +221,21 @@ export interface SystemVersionInfo {
   remote?: VersionInfo;
   systemUpdateAvailable: boolean;
   appUpdateAvailable: boolean;
+  willDisableAutoUpdate?: boolean;
   error?: string;
 }
 
+const UPDATE_STATUS_RPC_TIMEOUT_MS = 10000;
+const UPDATE_STATUS_RPC_MAX_ATTEMPTS = 6;
+
 export async function getUpdateStatus() {
   const response = await callJsonRpc<SystemVersionInfo>({
     method: "getUpdateStatus",
     // This function calls our api server to see if there are any updates available.
     // It can be called on page load right after a restart, so we need to give it time to
     // establish a connection to the api server.
-    maxAttempts: 6,
+    maxAttempts: UPDATE_STATUS_RPC_MAX_ATTEMPTS,
+    attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
   });
 
   if (response.error) throw response.error;
@@ -242,3 +247,27 @@ export async function getLocalVersion() {
   if (response.error) throw response.error;
   return response.result;
 }
+
+export type UpdateComponent = "app" | "system";
+export type UpdateComponents = Partial<Record<UpdateComponent, string>>;
+
+export interface updateParams {
+  components?: UpdateComponents;
+}
+
+export async function checkUpdateComponents(params: updateParams, includePreRelease: boolean) {
+  const response = await callJsonRpc<SystemVersionInfo>({
+    method: "checkUpdateComponents",
+    params: {
+      params,
+      includePreRelease,
+    },
+    // maxAttempts is set to 1,
+    // because it currently retry for all errors,
+    // and we don't want to retry if the error is not a network error
+    maxAttempts: 1,
+    attemptTimeoutMs: UPDATE_STATUS_RPC_TIMEOUT_MS,
+  });
+  if (response.error) throw response.error;
+  return response.result;
+}
\ No newline at end of file
diff --git a/ui/vite.config.ts b/ui/vite.config.ts
index 3935c2d..b526050 100644
--- a/ui/vite.config.ts
+++ b/ui/vite.config.ts
@@ -33,13 +33,13 @@ export default defineConfig(({ mode, command }) => {
     outdir: "./localization/paraglide",
     outputStructure: 'message-modules',
     cookieName: 'JETKVM_LOCALE',
-    strategy: ['cookie', 'preferredLanguage', 'baseLocale'],
+    strategy: ['cookie', 'baseLocale'],
   }))
 
   return {
     plugins,
     esbuild: {
-      pure: ["console.debug"],
+      pure: command === "build" ? ["console.debug"]: [],
     },
     assetsInclude: ["**/*.woff2"],
     build: {
diff --git a/webrtc.go b/webrtc.go
index 46fca2b..76de291 100644
--- a/webrtc.go
+++ b/webrtc.go
@@ -288,7 +288,7 @@ func newSession(config SessionConfig) (*Session, error) {
 			})
 			// Wait for channel to be open before sending initial state
 			d.OnOpen(func() {
-				triggerOTAStateUpdate()
+				triggerOTAStateUpdate(otaState.ToRPCState())
 				triggerVideoStateUpdate()
 				triggerUSBStateUpdate()
 				notifyFailsafeMode(session)



Test patch:
diff --git a/internal/ota/ota_test.go b/internal/ota/ota_test.go
new file mode 100644
index 0000000..2c8ce66
--- /dev/null
+++ b/internal/ota/ota_test.go
@@ -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")
+}
diff --git a/internal/ota/testdata/ota.schema.json b/internal/ota/testdata/ota.schema.json
new file mode 100644
index 0000000..1596585
--- /dev/null
+++ b/internal/ota/testdata/ota.schema.json
@@ -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
+}
+
diff --git a/internal/ota/testdata/ota/app_only_downgrade.json b/internal/ota/testdata/ota/app_only_downgrade.json
new file mode 100644
index 0000000..e8e2f7d
--- /dev/null
+++ b/internal/ota/testdata/ota/app_only_downgrade.json
@@ -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
+    }
+}
\ No newline at end of file
diff --git a/internal/ota/testdata/ota/app_only_upgrade.json b/internal/ota/testdata/ota/app_only_upgrade.json
new file mode 100644
index 0000000..69aa7fb
--- /dev/null
+++ b/internal/ota/testdata/ota/app_only_upgrade.json
@@ -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
+    }
+}
\ No newline at end of file
diff --git a/internal/ota/testdata/ota/both_downgrade.json b/internal/ota/testdata/ota/both_downgrade.json
new file mode 100644
index 0000000..3c57461
--- /dev/null
+++ b/internal/ota/testdata/ota/both_downgrade.json
@@ -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
+    }
+}
+
diff --git a/internal/ota/testdata/ota/both_upgrade.json b/internal/ota/testdata/ota/both_upgrade.json
new file mode 100644
index 0000000..c3d3dae
--- /dev/null
+++ b/internal/ota/testdata/ota/both_upgrade.json
@@ -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
+    }
+}
\ No newline at end of file
diff --git a/internal/ota/testdata/ota/no_components.json b/internal/ota/testdata/ota/no_components.json
new file mode 100644
index 0000000..9fb8b25
--- /dev/null
+++ b/internal/ota/testdata/ota/no_components.json
@@ -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
+    }
+}
+
diff --git a/internal/ota/testdata/ota/system_only_downgrade.json b/internal/ota/testdata/ota/system_only_downgrade.json
new file mode 100644
index 0000000..007f527
--- /dev/null
+++ b/internal/ota/testdata/ota/system_only_downgrade.json
@@ -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
+    }
+}
\ No newline at end of file
diff --git a/internal/ota/testdata/ota/system_only_upgrade.json b/internal/ota/testdata/ota/system_only_upgrade.json
new file mode 100644
index 0000000..b32c943
--- /dev/null
+++ b/internal/ota/testdata/ota/system_only_upgrade.json
@@ -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
+    }
+}
\ No newline at end of file
diff --git a/internal/ota/testdata/ota/without_certs.json b/internal/ota/testdata/ota/without_certs.json
new file mode 100644
index 0000000..d515089
--- /dev/null
+++ b/internal/ota/testdata/ota/without_certs.json
@@ -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"
+    }
+}
+
diff --git a/internal/utils/env_test.go b/internal/utils/env_test.go
new file mode 100644
index 0000000..2531367
--- /dev/null
+++ b/internal/utils/env_test.go
@@ -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)
+			}
+		})
+	}
+}