Add support for Luckfox PicoKVM

Signed-off-by: luckfox-eng29 <eng29@luckfox.com>
This commit is contained in:
luckfox-eng29
2025-08-07 14:26:01 +08:00
parent 3e7d8fb0f5
commit 8fbd6bcf0d
114 changed files with 4676 additions and 3270 deletions

View File

@@ -1,27 +0,0 @@
{
"name": "JetKVM",
"image": "mcr.microsoft.com/devcontainers/go:1-1.23-bookworm",
"features": {
"ghcr.io/devcontainers/features/node:1": {
// Should match what is defined in ui/package.json
"version": "22.15.0"
}
},
"mounts": [
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
],
"customizations": {
"vscode": {
"extensions": [
"bradlc.vscode-tailwindcss",
"GitHub.vscode-pull-request-github",
"dbaeumer.vscode-eslint",
"golang.go",
"ms-vscode.makefile-tools",
"esbenp.prettier-vscode",
"github.vscode-github-actions"
]
}
}
}

View File

@@ -45,7 +45,7 @@ jobs:
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: jetkvm-app
name: kvm-app
path: |
bin/jetkvm_app
bin/kvm_app
device-tests.tar.gz

View File

@@ -42,13 +42,13 @@ jobs:
sudo ip r r $CI_HOST via $CI_WG_GATEWAY dev wg-ci
ping -c1 $CI_HOST || (echo "Failed to ping $CI_HOST" && sudo wg show wg-ci && ip r && exit 1)
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_WG_IPS: ${{ vars.JETKVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.JETKVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.JETKVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.JETKVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.JETKVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.JETKVM_CI_WG_ENDPOINT }}
CI_HOST: ${{ vars.KVM_CI_HOST }}
CI_WG_IPS: ${{ vars.KVM_CI_WG_IPS }}
CI_WG_GATEWAY: ${{ vars.KVM_CI_GATEWAY }}
CI_WG_ALLOWED_IPS: ${{ vars.KVM_CI_WG_ALLOWED_IPS }}
CI_WG_PUBLIC: ${{ secrets.KVM_CI_WG_PUBLIC }}
CI_WG_PRIVATE: ${{ secrets.KVM_CI_WG_PRIVATE }}
CI_WG_ENDPOINT: ${{ secrets.KVM_CI_WG_ENDPOINT }}
- name: Configure SSH
run: |
# Write SSH private key to a file
@@ -66,9 +66,9 @@ jobs:
IdentityFile $SSH_PRIVATE_KEY
EOF
env:
CI_USER: ${{ vars.JETKVM_CI_USER }}
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.JETKVM_CI_SSH_PRIVATE }}
CI_USER: ${{ vars.KVM_CI_USER }}
CI_HOST: ${{ vars.KVM_CI_HOST }}
CI_SSH_PRIVATE: ${{ secrets.KVM_CI_SSH_PRIVATE }}
- name: Run tests
run: |
set -e
@@ -116,12 +116,12 @@ jobs:
set -e
# Copy the binary to the remote host
echo "+ Copying the application to the remote host"
cat bin/jetkvm_app | gzip | ssh jkci "cat > /userdata/jetkvm/jetkvm_app.update.gz"
cat bin/kvm_app | gzip | ssh jkci "cat > /userdata/picokvm/kvm_app.update.gz"
# Deploy and run the application on the remote host
echo "+ Deploying the application on the remote host"
ssh jkci ash <<EOF
# Extract the binary
gzip -d /userdata/jetkvm/jetkvm_app.update.gz
gzip -d /userdata/picokvm/kvm_app.update.gz
# Flush filesystem buffers to ensure all data is written to disk
sync
# Clear the filesystem caches to force a read from disk
@@ -130,22 +130,22 @@ jobs:
reboot -d 5 -f &
EOF
sleep 10
echo "Deployment complete, waiting for JetKVM to come back online "
echo "Deployment complete, waiting for KVM to come back online "
function check_online() {
for i in {1..60}; do
if ping -c1 -w1 -W1 -q $CI_HOST >/dev/null; then
echo "JetKVM is back online"
echo "KVM is back online"
return 0
fi
echo -n "."
sleep 1
done
echo "JetKVM did not come back online within 60 seconds"
echo "KVM did not come back online within 60 seconds"
return 1
}
check_online
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_HOST: ${{ vars.KVM_CI_HOST }}
- name: Run smoke tests
run: |
echo "+ Checking the status of the device"
@@ -157,13 +157,13 @@ jobs:
ssh jkci ash > $local_log_tar <<'EOF'
log_path=$(mktemp -d)
dmesg > $log_path/dmesg.log
cp /userdata/jetkvm/last.log $log_path/last.log
cp /userdata/picokvm/last.log $log_path/last.log
tar -czf - -C $log_path .
EOF
tar -xf $local_log_tar
cat dmesg.log last.log
env:
CI_HOST: ${{ vars.JETKVM_CI_HOST }}
CI_HOST: ${{ vars.KVM_CI_HOST }}
- name: Upload logs
uses: actions/upload-artifact@v4
with:

1
.gitignore vendored
View File

@@ -2,5 +2,6 @@ bin/*
static/*
.idea
.DS_Store
.vscode
device-tests.tar.gz

View File

@@ -1,130 +0,0 @@
CODE_OF_CONDUCT.md
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
- Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or
advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email
address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
support@jetkvm.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.

View File

@@ -2,11 +2,11 @@ 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.5-dev$(shell date +%Y%m%d%H%M)
VERSION ?= 0.4.4
VERSION_DEV ?= 0.0.1-dev
VERSION ?= 0.0.1
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm
KVM_PKG_NAME := kvm
GO_BUILD_ARGS := -tags netgo
GO_RELEASE_BUILD_ARGS := -trimpath $(GO_BUILD_ARGS)
@@ -22,68 +22,19 @@ BIN_DIR := $(shell pwd)/bin
TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u)
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
build_dev:
@echo "Building..."
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_RELEASE_BUILD_ARGS) \
-o $(BIN_DIR)/jetkvm_app cmd/main.go
build_test2json:
$(GO_CMD) build -o $(BIN_DIR)/test2json cmd/test2json
build_gotestsum:
@echo "Building gotestsum..."
$(GO_CMD) install gotest.tools/gotestsum@latest
cp $(shell $(GO_CMD) env GOPATH)/bin/linux_arm/gotestsum $(BIN_DIR)/gotestsum
build_dev_test: build_test2json build_gotestsum
# collect all directories that contain tests
@echo "Building tests for devices ..."
@rm -rf $(BIN_DIR)/tests && mkdir -p $(BIN_DIR)/tests
@cat resource/dev_test.sh > $(BIN_DIR)/tests/run_all_tests
@for test in $(TEST_DIRS); do \
test_pkg_name=$$(echo $$test | sed 's/^.\///g'); \
test_pkg_full_name=$(KVM_PKG_NAME)/$$(echo $$test | sed 's/^.\///g'); \
test_filename=$$(echo $$test_pkg_name | sed 's/\//__/g')_test; \
$(GO_CMD) test -v \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION_DEV)" \
$(GO_BUILD_ARGS) \
-c -o $(BIN_DIR)/tests/$$test_filename $$test; \
echo "runTest ./$$test_filename $$test_pkg_full_name" >> $(BIN_DIR)/tests/run_all_tests; \
done; \
chmod +x $(BIN_DIR)/tests/run_all_tests; \
cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \
cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \
tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests .
-o $(BIN_DIR)/kvm_app cmd/main.go
frontend:
cd ui && npm ci && npm run build:device
dev_release: frontend build_dev
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION_DEV)/jetkvm_app.sha256
build_release: frontend hash_resource
build_release: frontend
@echo "Building release..."
$(GO_CMD) build \
-ldflags="$(GO_LDFLAGS) -X $(KVM_PKG_NAME).builtAppVersion=$(VERSION)" \
$(GO_RELEASE_BUILD_ARGS) \
-o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \
echo "Error: Version $(VERSION) already exists. Please update the VERSION variable."; \
exit 1; \
fi
make build_release
@echo "Uploading release..."
@shasum -a 256 bin/jetkvm_app | cut -d ' ' -f 1 > bin/jetkvm_app.sha256
rclone copyto bin/jetkvm_app r2://jetkvm-update/app/$(VERSION)/jetkvm_app
rclone copyto bin/jetkvm_app.sha256 r2://jetkvm-update/app/$(VERSION)/jetkvm_app.sha256
-o bin/kvm_app cmd/main.go

View File

@@ -1,48 +0,0 @@
<div align="center">
<img alt="JetKVM logo" src="https://jetkvm.com/logo-blue.png" height="28">
### KVM
[Discord](https://jetkvm.com/discord) | [Website](https://jetkvm.com) | [Issues](https://github.com/jetkvm/cloud-api/issues) | [Docs](https://jetkvm.com/docs)
[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/jetkvm.svg?style=social&label=Follow%20%40JetKVM)](https://twitter.com/jetkvm)
[![Go Report Card](https://goreportcard.com/badge/github.com/jetkvm/kvm)](https://goreportcard.com/report/github.com/jetkvm/kvm)
</div>
JetKVM is a high-performance, open-source KVM over IP (Keyboard, Video, Mouse) solution designed for efficient remote management of computers, servers, and workstations. Whether you're dealing with boot failures, installing a new operating system, adjusting BIOS settings, or simply taking control of a machine from afar, JetKVM provides the tools to get it done effectively.
## Features
- **Ultra-low Latency** - 1080p@60FPS video with 30-60ms latency using H.264 encoding. Smooth mouse and keyboard interaction for responsive remote control.
- **Free & Optional Remote Access** - Remote management via JetKVM Cloud using WebRTC.
- **Open-source software** - Written in Golang on Linux. Easily customizable through SSH access to the JetKVM device.
## Contributing
We welcome contributions from the community! Whether it's improving the firmware, adding new features, or enhancing documentation, your input is valuable. We also have some rules and taboos here, so please read this page and our [Code of Conduct](/CODE_OF_CONDUCT.md) carefully.
## I need help
The best place to search for answers is our [Documentation](https://jetkvm.com/docs). If you can't find the answer there, check our [Discord Server](https://jetkvm.com/discord).
## I want to report an issue
If you've found an issue and want to report it, please check our [Issues](https://github.com/jetkvm/kvm/issues) page. Make sure the description contains information about the firmware version you're using, your platform, and a clear explanation of the steps to reproduce the issue.
# Development
JetKVM is written in Go & TypeScript. with some bits and pieces written in C. An intermediate level of Go & TypeScript knowledge is recommended for comfortable programming.
The project contains two main parts, the backend software that runs on the KVM device and the frontend software that is served by the KVM device, and also the cloud.
For most of local device development, all you need is to use the `./dev_deploy.sh` script. It will build the frontend and backend and deploy them to the local KVM device. Run `./dev_deploy.sh --help` for more information.
## Backend
The backend is written in Go and is responsible for the KVM device management, the cloud API and the cloud web.
## Frontend
The frontend is written in React and TypeScript and is served by the KVM device. It has three build targets: `device`, `development` and `production`. Development is used for development of the cloud version on your local machine, device is used for building the frontend for the KVM device and production is used for building the frontend for the cloud.

170
audio.go Normal file
View File

@@ -0,0 +1,170 @@
package kvm
import (
"encoding/json"
"fmt"
"net"
"sync"
"time"
"github.com/pion/rtp"
)
var (
audioListener *net.UDPConn
currentPort int
mutex sync.Mutex
portList = []int{3333}
portIndex = 0
)
const (
maxAudioFrameSize = 1500
frameDurationMs = 20
timestampRate = 48000
timestampStep = timestampRate * frameDurationMs / 1000
)
func waitUDPPortReleased(port int, retries int, delay time.Duration) error {
addr := fmt.Sprintf("127.0.0.1:%d", port)
for i := 0; i < retries; i++ {
conn, err := net.ListenPacket("udp", addr)
if err == nil {
conn.Close()
return nil
}
time.Sleep(delay)
}
return fmt.Errorf("port %d still in use after %d retries", port, retries)
}
func getNextAvailablePort() int {
for i := 0; i < len(portList); i++ {
port := portList[portIndex]
portIndex = (portIndex + 1) % len(portList)
addr := fmt.Sprintf("127.0.0.1:%d", port)
conn, err := net.ListenPacket("udp", addr)
if err == nil {
conn.Close()
return port
}
}
return 0
}
func StartNtpAudioServer(handleClient func(net.Conn)) {
mutex.Lock()
defer mutex.Unlock()
if audioListener != nil || lastAudioState.Ready {
StopNtpAudioServer()
}
port := getNextAvailablePort()
if port == 0 {
audioLogger.Error().Msg("no available ports to start audio server")
return
}
listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: port})
if err != nil {
audioLogger.Error().Err(err).Msg("failed to start server on port %d")
return
}
audioListener = listener
currentPort = port
if config.AudioMode == "usb" {
_, err := CallAudioCtrlAction("set_audio_mode", map[string]interface{}{"audio_mode": "usb", "rtp_port": port})
if err != nil {
audioLogger.Error().Err(err).Msg("failed to set audio mode")
}
_, err = CallAudioCtrlAction("set_audio_enable", map[string]interface{}{"audio_enable": true})
if err != nil {
audioLogger.Error().Err(err).Msg("failed to set audio enable")
}
} else {
_, err := CallAudioCtrlAction("set_audio_mode", map[string]interface{}{"audio_mode": "hdmi", "rtp_port": port})
if err != nil {
audioLogger.Error().Err(err).Msg("failed to set audio mode")
}
_, err = CallAudioCtrlAction("set_audio_enable", map[string]interface{}{"audio_enable": true})
if err != nil {
audioLogger.Error().Err(err).Msg("failed to set audio enable")
}
}
go handleClient(listener)
}
func StopNtpAudioServer() {
CallAudioCtrlAction("set_audio_enable", map[string]interface{}{"audio_enable": false})
if audioListener != nil {
audioListener.Close()
audioListener = nil
}
if currentPort != 0 {
if err := waitUDPPortReleased(currentPort, 10, 200*time.Millisecond); err != nil {
audioLogger.Error().Err(err).Msg("port not released")
}
currentPort = 0
}
audioLogger.Info().Msg("audio server stopped")
}
func handleAudioClient(conn net.Conn) {
defer conn.Close()
audioLogger.Info().Msg("native audio socket client connected")
inboundPacket := make([]byte, maxAudioFrameSize)
var timestamp uint32
var packet rtp.Packet
for {
n, err := conn.Read(inboundPacket)
if err != nil {
audioLogger.Warn().Err(err).Msg("error during read")
return
}
if currentSession != nil {
if err := packet.Unmarshal(inboundPacket[:n]); err != nil {
audioLogger.Warn().Err(err).Msg("error unmarshalling audio socket packet")
continue
}
timestamp += timestampStep
packet.Header.Timestamp = timestamp
buf, err := packet.Marshal()
if err != nil {
audioLogger.Warn().Err(err).Msg("error marshalling packet")
continue
}
if _, err := currentSession.AudioTrack.Write(buf); err != nil {
audioLogger.Warn().Err(err).Msg("error writing sample")
}
}
}
}
type AudioInputState struct {
Ready bool `json:"ready"`
Error string `json:"error,omitempty"` //no_signal, no_lock, out_of_range
}
var lastAudioState AudioInputState
func HandleAudioStateMessage(event CtrlResponse) {
audioState := AudioInputState{}
err := json.Unmarshal(event.Data, &audioState)
if err != nil {
audioLogger.Warn().Err(err).Msg("Error parsing audio state json")
return
}
lastAudioState = audioState
}

View File

@@ -133,7 +133,7 @@ func (d *NBDDevice) runServerConn() {
d.serverConn,
[]*server.Export{
{
Name: "jetkvm",
Name: "kvm",
Description: "",
Backend: &remoteImageBackend{},
},

View File

@@ -8,7 +8,7 @@ import (
func (d *NBDDevice) runClientConn() {
err := client.Connect(d.clientConn, d.dev, &client.Options{
ExportName: "jetkvm",
ExportName: "kvm",
BlockSize: uint32(4 * 1024),
})
d.l.Info().Err(err).Msg("nbd client exited")

430
cloud.go
View File

@@ -1,23 +1,13 @@
package kvm
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"github.com/coder/websocket/wsjson"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog"
@@ -43,42 +33,30 @@ const (
)
var (
metricCloudConnectionStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_status",
Help: "The status of the cloud connection",
},
)
metricCloudConnectionEstablishedTimestamp = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_cloud_connection_established_timestamp_seconds",
Help: "The timestamp when the cloud connection was established",
},
)
metricConnectionLastPingTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_timestamp_seconds",
Name: "kvm_connection_last_ping_timestamp_seconds",
Help: "The timestamp when the last ping response was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingReceivedTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_received_timestamp_seconds",
Name: "kvm_connection_last_ping_received_timestamp_seconds",
Help: "The timestamp when the last ping request was received",
},
[]string{"type", "source"},
)
metricConnectionLastPingDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_ping_duration_seconds",
Name: "kvm_connection_last_ping_duration_seconds",
Help: "The duration of the last ping response",
},
[]string{"type", "source"},
)
metricConnectionPingDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_ping_duration_seconds",
Name: "kvm_connection_ping_duration_seconds",
Help: "The duration of the ping response",
Buckets: []float64{
0.1, 0.5, 1, 10,
@@ -88,28 +66,28 @@ var (
)
metricConnectionTotalPingSentCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_sent_total",
Name: "kvm_connection_ping_sent_total",
Help: "The total number of pings sent to the connection",
},
[]string{"type", "source"},
)
metricConnectionTotalPingReceivedCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_ping_received_total",
Name: "kvm_connection_ping_received_total",
Help: "The total number of pings received from the connection",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_connection_session_requests_total",
Name: "kvm_connection_session_requests_total",
Help: "The total number of session requests received",
},
[]string{"type", "source"},
)
metricConnectionSessionRequestDuration = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_connection_session_request_duration_seconds",
Name: "kvm_connection_session_request_duration_seconds",
Help: "The duration of session requests",
Buckets: []float64{
0.1, 0.5, 1, 10,
@@ -119,320 +97,29 @@ var (
)
metricConnectionLastSessionRequestTimestamp = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_timestamp_seconds",
Name: "kvm_connection_last_session_request_timestamp_seconds",
Help: "The timestamp of the last session request",
},
[]string{"type", "source"},
)
metricConnectionLastSessionRequestDuration = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_connection_last_session_request_duration",
Name: "kvm_connection_last_session_request_duration",
Help: "The duration of the last session request",
},
[]string{"type", "source"},
)
metricCloudConnectionFailureCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_cloud_connection_failure_total",
Help: "The number of times the cloud connection has failed",
},
)
)
type CloudConnectionState uint8
const (
CloudConnectionStateNotConfigured CloudConnectionState = iota
CloudConnectionStateDisconnected
CloudConnectionStateConnecting
CloudConnectionStateConnected
)
var (
cloudConnectionState CloudConnectionState = CloudConnectionStateNotConfigured
cloudConnectionStateLock = &sync.Mutex{}
cloudDisconnectChan chan error
cloudDisconnectLock = &sync.Mutex{}
)
func setCloudConnectionState(state CloudConnectionState) {
cloudConnectionStateLock.Lock()
defer cloudConnectionStateLock.Unlock()
if cloudConnectionState == CloudConnectionStateDisconnected &&
(config.CloudToken == "" || config.CloudURL == "") {
state = CloudConnectionStateNotConfigured
}
previousState := cloudConnectionState
cloudConnectionState = state
go waitCtrlAndRequestDisplayUpdate(
previousState != state,
)
}
func wsResetMetrics(established bool, sourceType string, source string) {
metricConnectionLastPingTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingDuration.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastPingReceivedTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestTimestamp.WithLabelValues(sourceType, source).Set(-1)
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(-1)
if sourceType != "cloud" {
return
}
if established {
metricCloudConnectionEstablishedTimestamp.SetToCurrentTime()
metricCloudConnectionStatus.Set(1)
} else {
metricCloudConnectionEstablishedTimestamp.Set(-1)
metricCloudConnectionStatus.Set(-1)
}
}
func handleCloudRegister(c *gin.Context) {
var req CloudRegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(400, gin.H{"error": "Invalid request body"})
return
}
// Exchange the temporary token for a permanent auth token
payload := struct {
TempToken string `json:"tempToken"`
}{
TempToken: req.Token,
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to encode JSON payload: " + err.Error()})
return
}
client := &http.Client{Timeout: CloudAPIRequestTimeout}
apiReq, err := http.NewRequest(http.MethodPost, config.CloudURL+"/devices/token", bytes.NewBuffer(jsonPayload))
if err != nil {
c.JSON(500, gin.H{"error": "Failed to create register request: " + err.Error()})
return
}
apiReq.Header.Set("Content-Type", "application/json")
apiResp, err := client.Do(apiReq)
if err != nil {
c.JSON(500, gin.H{"error": "Failed to exchange token: " + err.Error()})
return
}
defer apiResp.Body.Close()
if apiResp.StatusCode != http.StatusOK {
c.JSON(apiResp.StatusCode, gin.H{"error": "Failed to exchange token: " + apiResp.Status})
return
}
var tokenResp struct {
SecretToken string `json:"secretToken"`
}
if err := json.NewDecoder(apiResp.Body).Decode(&tokenResp); err != nil {
c.JSON(500, gin.H{"error": "Failed to parse token response: " + err.Error()})
return
}
if tokenResp.SecretToken == "" {
c.JSON(500, gin.H{"error": "Received empty secret token"})
return
}
config.CloudToken = tokenResp.SecretToken
provider, err := oidc.NewProvider(c, "https://accounts.google.com")
if err != nil {
c.JSON(500, gin.H{"error": "Failed to initialize OIDC provider: " + err.Error()})
return
}
oidcConfig := &oidc.Config{
ClientID: req.ClientId,
}
verifier := provider.Verifier(oidcConfig)
idToken, err := verifier.Verify(c, req.OidcGoogle)
if err != nil {
c.JSON(400, gin.H{"error": "Invalid OIDC token: " + err.Error()})
return
}
config.GoogleIdentity = idToken.Audience[0] + ":" + idToken.Subject
// Save the updated configuration
if err := SaveConfig(); err != nil {
c.JSON(500, gin.H{"error": "Failed to save configuration"})
return
}
c.JSON(200, gin.H{"message": "Cloud registration successful"})
}
func disconnectCloud(reason error) {
cloudDisconnectLock.Lock()
defer cloudDisconnectLock.Unlock()
if cloudDisconnectChan == nil {
cloudLogger.Trace().Msg("cloud disconnect channel is not set, no need to disconnect")
return
}
// just in case the channel is closed, we don't want to panic
defer func() {
if r := recover(); r != nil {
cloudLogger.Warn().Interface("reason", r).Msg("cloud disconnect channel is closed, no need to disconnect")
}
}()
cloudDisconnectChan <- reason
}
func runWebsocketClient() error {
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
return fmt.Errorf("cloud token is not set")
}
wsURL, err := url.Parse(config.CloudURL)
if err != nil {
return fmt.Errorf("failed to parse config.CloudURL: %w", err)
}
if wsURL.Scheme == "http" {
wsURL.Scheme = "ws"
} else {
wsURL.Scheme = "wss"
}
setCloudConnectionState(CloudConnectionStateConnecting)
header := http.Header{}
header.Set("X-Device-ID", GetDeviceID())
header.Set("X-App-Version", builtAppVersion)
header.Set("Authorization", "Bearer "+config.CloudToken)
dialCtx, cancelDial := context.WithTimeout(context.Background(), CloudWebSocketConnectTimeout)
l := websocketLogger.With().
Str("source", wsURL.Host).
Str("sourceType", "cloud").
Logger()
scopedLogger := &l
defer cancelDial()
c, resp, err := websocket.Dial(dialCtx, wsURL.String(), &websocket.DialOptions{
HTTPHeader: header,
OnPingReceived: func(ctx context.Context, payload []byte) bool {
scopedLogger.Debug().Bytes("payload", payload).Int("length", len(payload)).Msg("ping frame received")
metricConnectionTotalPingReceivedCount.WithLabelValues("cloud", wsURL.Host).Inc()
metricConnectionLastPingReceivedTimestamp.WithLabelValues("cloud", wsURL.Host).SetToCurrentTime()
setCloudConnectionState(CloudConnectionStateConnected)
return true
},
})
var connectionId string
if resp != nil {
// get the request id from the response header
connectionId = resp.Header.Get("X-Request-ID")
if connectionId == "" {
connectionId = resp.Header.Get("Cf-Ray")
}
}
if connectionId == "" {
connectionId = uuid.New().String()
scopedLogger.Warn().
Str("connectionId", connectionId).
Msg("no connection id received from the server, generating a new one")
}
lWithConnectionId := scopedLogger.With().
Str("connectionID", connectionId).
Logger()
scopedLogger = &lWithConnectionId
// if the context is canceled, we don't want to return an error
if err != nil {
if errors.Is(err, context.Canceled) {
cloudLogger.Info().Msg("websocket connection canceled")
setCloudConnectionState(CloudConnectionStateDisconnected)
return nil
}
return err
}
defer c.CloseNow() //nolint:errcheck
cloudLogger.Info().
Str("url", wsURL.String()).
Str("connectionID", connectionId).
Msg("websocket connected")
// set the metrics when we successfully connect to the cloud.
wsResetMetrics(true, "cloud", wsURL.Host)
// we don't have a source for the cloud connection
return handleWebRTCSignalWsMessages(c, true, wsURL.Host, connectionId, scopedLogger)
}
func authenticateSession(ctx context.Context, c *websocket.Conn, req WebRTCSessionRequest) error {
oidcCtx, cancelOIDC := context.WithTimeout(ctx, CloudOidcRequestTimeout)
defer cancelOIDC()
provider, err := oidc.NewProvider(oidcCtx, "https://accounts.google.com")
if err != nil {
_ = wsjson.Write(context.Background(), c, gin.H{
"error": fmt.Sprintf("failed to initialize OIDC provider: %v", err),
})
cloudLogger.Warn().Err(err).Msg("failed to initialize OIDC provider")
return err
}
oidcConfig := &oidc.Config{
SkipClientIDCheck: true,
}
verifier := provider.Verifier(oidcConfig)
idToken, err := verifier.Verify(oidcCtx, req.OidcGoogle)
if err != nil {
return err
}
googleIdentity := idToken.Audience[0] + ":" + idToken.Subject
if config.GoogleIdentity != googleIdentity {
_ = wsjson.Write(context.Background(), c, gin.H{"error": "google identity mismatch"})
return fmt.Errorf("google identity mismatch")
}
return nil
}
func handleSessionRequest(
ctx context.Context,
c *websocket.Conn,
req WebRTCSessionRequest,
isCloudConnection bool,
source string,
scopedLogger *zerolog.Logger,
) error {
var sourceType string
if isCloudConnection {
sourceType = "cloud"
} else {
sourceType = "local"
}
sourceType = "local"
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
metricConnectionLastSessionRequestDuration.WithLabelValues(sourceType, source).Set(v)
@@ -440,16 +127,8 @@ func handleSessionRequest(
}))
defer timer.ObserveDuration()
// If the message is from the cloud, we need to authenticate the session.
if isCloudConnection {
if err := authenticateSession(ctx, c, req); err != nil {
return err
}
}
session, err := newSession(SessionConfig{
ws: c,
IsCloud: isCloudConnection,
LocalIP: req.IP,
ICEServers: req.ICEServers,
Logger: scopedLogger,
@@ -479,90 +158,3 @@ func handleSessionRequest(
_ = wsjson.Write(context.Background(), c, gin.H{"type": "answer", "data": sd})
return nil
}
func RunWebsocketClient() {
for {
// If the cloud token is not set, we don't need to run the websocket client.
if config.CloudToken == "" {
time.Sleep(5 * time.Second)
continue
}
// If the network is not up, well, we can't connect to the cloud.
if !networkState.IsOnline() {
cloudLogger.Warn().Msg("waiting for network to be online, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
// If the system time is not synchronized, the API request will fail anyway because the TLS handshake will fail.
if isTimeSyncNeeded() && !timeSync.IsSyncSuccess() {
cloudLogger.Warn().Msg("system time is not synced, will retry in 3 seconds")
time.Sleep(3 * time.Second)
continue
}
err := runWebsocketClient()
if err != nil {
cloudLogger.Warn().Err(err).Msg("websocket client error")
metricCloudConnectionStatus.Set(0)
metricCloudConnectionFailureCount.Inc()
time.Sleep(5 * time.Second)
}
}
}
type CloudState struct {
Connected bool `json:"connected"`
URL string `json:"url,omitempty"`
AppURL string `json:"appUrl,omitempty"`
}
func rpcGetCloudState() CloudState {
return CloudState{
Connected: config.CloudToken != "" && config.CloudURL != "",
URL: config.CloudURL,
AppURL: config.CloudAppURL,
}
}
func rpcDeregisterDevice() error {
if config.CloudToken == "" || config.CloudURL == "" {
return fmt.Errorf("cloud token or URL is not set")
}
req, err := http.NewRequest(http.MethodDelete, config.CloudURL+"/devices/"+GetDeviceID(), nil)
if err != nil {
return fmt.Errorf("failed to create deregister request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+config.CloudToken)
client := &http.Client{Timeout: CloudAPIRequestTimeout}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send deregister request: %w", err)
}
defer resp.Body.Close()
// We consider both 200 OK and 404 Not Found as successful deregistration.
// 200 OK means the device was found and deregistered.
// 404 Not Found means the device is not in the database, which could be due to various reasons
// (e.g., wrong cloud token, already deregistered). Regardless of the reason, we can safely remove it.
if resp.StatusCode == http.StatusNotFound || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
config.CloudToken = ""
config.GoogleIdentity = ""
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save configuration after deregistering: %w", err)
}
cloudLogger.Info().Msg("device deregistered, disconnecting from cloud")
disconnectCloud(fmt.Errorf("device deregistered"))
setCloudConnectionState(CloudConnectionStateNotConfigured)
return nil
}
return fmt.Errorf("deregister request failed with status: %s", resp.Status)
}

View File

@@ -1,7 +1,7 @@
package main
import (
"github.com/jetkvm/kvm"
"kvm"
)
func main() {

View File

@@ -6,9 +6,9 @@ import (
"os"
"sync"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/usbgadget"
"kvm/internal/logging"
"kvm/internal/network"
"kvm/internal/usbgadget"
)
type WakeOnLanDevice struct {
@@ -75,8 +75,6 @@ func (m *KeyboardMacro) Validate() error {
}
type Config struct {
CloudURL string `json:"cloud_url"`
CloudAppURL string `json:"cloud_app_url"`
CloudToken string `json:"cloud_token"`
GoogleIdentity string `json:"google_identity"`
JigglerEnabled bool `json:"jiggler_enabled"`
@@ -100,17 +98,26 @@ type Config struct {
UsbDevices *usbgadget.Devices `json:"usb_devices"`
NetworkConfig *network.NetworkConfig `json:"network_config"`
DefaultLogLevel string `json:"default_log_level"`
TailScaleAutoStart bool `json:"tailscale_autostart"`
TailScaleXEdge bool `json:"tailscale_xedge"`
ZeroTierNetworkID string `json:"zerotier_network_id"`
ZeroTierAutoStart bool `json:"zerotier_autostart"`
IO0Status bool `json:"io0_status"`
IO1Status bool `json:"io1_status"`
AudioMode string `json:"audio_mode"`
TimeZone string `json:"time_zone"`
LEDGreenMode string `json:"led_green_mode"`
LEDYellowMode string `json:"led_yellow_mode"`
}
const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
CloudAppURL: "https://app.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
AutoUpdateEnabled: false, // Set a default value
ActiveExtension: "",
KeyboardMacros: []KeyboardMacro{},
DisplayRotation: "270",
DisplayRotation: "180",
TimeZone: "UTC-8",
KeyboardLayout: "en_US",
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
@@ -120,7 +127,7 @@ var defaultConfig = &Config{
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Manufacturer: "KVM",
Product: "USB Emulation Device",
},
UsbDevices: &usbgadget.Devices{
@@ -128,9 +135,18 @@ var defaultConfig = &Config{
RelativeMouse: true,
Keyboard: true,
MassStorage: true,
Audio: true,
},
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
NetworkConfig: &network.NetworkConfig{},
DefaultLogLevel: "INFO",
ZeroTierAutoStart: false,
TailScaleAutoStart: false,
TailScaleXEdge: false,
IO0Status: true,
IO1Status: true,
AudioMode: "disabled",
LEDGreenMode: "network-rx",
LEDYellowMode: "kernel-activity",
}
var (

View File

@@ -1,180 +0,0 @@
#!/usr/bin/env bash
#
# Exit immediately if a command exits with a non-zero status
set -e
C_RST="$(tput sgr0)"
C_ERR="$(tput setaf 1)"
C_OK="$(tput setaf 2)"
C_WARN="$(tput setaf 3)"
C_INFO="$(tput setaf 5)"
msg() { printf '%s%s%s\n' $2 "$1" $C_RST; }
msg_info() { msg "$1" $C_INFO; }
msg_ok() { msg "$1" $C_OK; }
msg_err() { msg "$1" $C_ERR; }
msg_warn() { msg "$1" $C_WARN; }
# Function to display help message
show_help() {
echo "Usage: $0 [options] -r <remote_ip>"
echo
echo "Required:"
echo " -r, --remote <remote_ip> Remote host IP address"
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 " --help Display this help message"
echo
echo "Example:"
echo " $0 -r 192.168.0.17"
echo " $0 -r 192.168.0.17 -u admin"
}
# Default values
REMOTE_USER="root"
REMOTE_PATH="/userdata/jetkvm/bin"
SKIP_UI_BUILD=false
RESET_USB_HID_DEVICE=false
LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}"
RUN_GO_TESTS=false
RUN_GO_TESTS_ONLY=false
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
-r|--remote)
REMOTE_HOST="$2"
shift 2
;;
-u|--user)
REMOTE_USER="$2"
shift 2
;;
--skip-ui-build)
SKIP_UI_BUILD=true
shift
;;
--reset-usb-hid)
RESET_USB_HID_DEVICE=true
shift
;;
--run-go-tests)
RUN_GO_TESTS=true
shift
;;
--run-go-tests-only)
RUN_GO_TESTS_ONLY=true
RUN_GO_TESTS=true
shift
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Verify required parameters
if [ -z "$REMOTE_HOST" ]; then
msg_err "Error: Remote IP is a required parameter"
show_help
exit 1
fi
# Build the development version on the host
if [ "$SKIP_UI_BUILD" = false ]; then
msg_info "▶ Building frontend"
make frontend
fi
if [ "$RUN_GO_TESTS" = true ]; then
msg_info "▶ Building go tests"
make build_dev_test
msg_info "▶ Copying device-tests.tar.gz to remote host"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz
msg_info "▶ Running go tests"
ssh "${REMOTE_USER}@${REMOTE_HOST}" ash << 'EOF'
set -e
TMP_DIR=$(mktemp -d)
cd ${TMP_DIR}
tar zxf /tmp/device-tests.tar.gz
./gotestsum --format=testdox \
--jsonfile=/tmp/device-tests.json \
--post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \
--raw-command -- ./run_all_tests -json
GOTESTSUM_EXIT_CODE=$?
if [ $GOTESTSUM_EXIT_CODE -ne 0 ]; then
echo "❌ Tests failed (exit code: $GOTESTSUM_EXIT_CODE)"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
TESTS_FAILED=$(cat /tmp/device-tests.failed)
if [ "$TESTS_FAILED" -ne 0 ]; then
echo "❌ Tests failed $TESTS_FAILED tests failed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
exit 1
fi
echo "✅ Tests passed"
rm -rf ${TMP_DIR} /tmp/device-tests.tar.gz
EOF
if [ "$RUN_GO_TESTS_ONLY" = true ]; then
msg_info "▶ Go tests completed"
exit 0
fi
fi
msg_info "▶ Building go binary"
make build_dev
# Kill any existing instances of the application
ssh "${REMOTE_USER}@${REMOTE_HOST}" "killall jetkvm_app_debug || true"
# Copy the binary to the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" "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 "${REMOTE_USER}@${REMOTE_HOST}" "rm -rf /sys/kernel/config/usb_gadget/jetkvm/configs/c.1/hid.usb*"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls /sys/class/udc > /sys/kernel/config/usb_gadget/jetkvm/UDC"
fi
# Deploy and run the application on the remote host
ssh "${REMOTE_USER}@${REMOTE_HOST}" 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
# Kill any existing instances of the application
killall jetkvm_app || true
killall jetkvm_app_debug || true
# Navigate to the directory where the binary will be stored
cd "${REMOTE_PATH}"
# Make the new binary executable
chmod +x jetkvm_app_debug
# Run the application in the background
PION_LOG_TRACE=${LOG_TRACE_SCOPES} GODEBUG=netdns=1 ./jetkvm_app_debug
EOF
echo "Deployment complete."

View File

@@ -2,7 +2,6 @@ package kvm
import (
"errors"
"fmt"
"os"
"strconv"
"sync"
@@ -18,12 +17,12 @@ var (
)
const (
touchscreenDevice string = "/dev/input/event1"
touchscreenDevice string = "/dev/input/event0"
backlightControlClass string = "/sys/class/backlight/backlight/brightness"
)
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
_, err := CallDisplayCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
if err != nil {
displayLogger.Warn().Err(err).Str("screen", screen).Msg("failed to switch to screen")
return
@@ -34,15 +33,15 @@ func switchToScreen(screen string) {
var displayedTexts = make(map[string]string)
func lvObjSetState(objName string, state string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
return CallDisplayCtrlAction("lv_obj_set_state", map[string]interface{}{"obj": objName, "state": state})
}
func lvObjAddFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
return CallDisplayCtrlAction("lv_obj_add_flag", map[string]interface{}{"obj": objName, "flag": flag})
}
func lvObjClearFlag(objName string, flag string) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
return CallDisplayCtrlAction("lv_obj_clear_flag", map[string]interface{}{"obj": objName, "flag": flag})
}
func lvObjHide(objName string) (*CtrlResponse, error) {
@@ -54,27 +53,31 @@ func lvObjShow(objName string) (*CtrlResponse, error) {
}
func lvObjSetOpacity(objName string, opacity int) (*CtrlResponse, error) { // nolint:unused
return CallCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity})
return CallDisplayCtrlAction("lv_obj_set_style_opa_layered", map[string]interface{}{"obj": objName, "opa": opacity})
}
func lvObjFadeIn(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
return CallDisplayCtrlAction("lv_obj_fade_in", map[string]interface{}{"obj": objName, "time": duration})
}
func lvObjFadeOut(objName string, duration uint32) (*CtrlResponse, error) {
return CallCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
return CallDisplayCtrlAction("lv_obj_fade_out", map[string]interface{}{"obj": objName, "time": duration})
}
func lvLabelSetText(objName string, text string) (*CtrlResponse, error) {
return CallCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
return CallDisplayCtrlAction("lv_label_set_text", map[string]interface{}{"obj": objName, "text": text})
}
func lvImgSetSrc(objName string, src string) (*CtrlResponse, error) {
return CallCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
return CallDisplayCtrlAction("lv_img_set_src", map[string]interface{}{"obj": objName, "src": src})
}
func lvDispSetRotation(rotation string) (*CtrlResponse, error) {
return CallCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
return CallDisplayCtrlAction("lv_disp_set_rotation", map[string]interface{}{"rotation": rotation})
}
func lvObjSetStyleBgColor(objName string, color string) (*CtrlResponse, error) {
return CallDisplayCtrlAction("lv_obj_set_style_bg_color", map[string]interface{}{"obj": objName, "color": color})
}
func updateLabelIfChanged(objName string, newText string) {
@@ -98,83 +101,26 @@ var (
)
func updateDisplay() {
updateLabelIfChanged("ui_Home_Content_Ip", networkState.IPv4String())
updateLabelIfChanged("Network_Address_IP_Label", networkState.IPv4String())
updateLabelIfChanged("Version_Hostname_Label", GetHostname())
if usbState == "configured" {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Connected")
_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_DEFAULT")
_, _ = lvObjSetState("Main", "USB_CONNECTED")
} else {
updateLabelIfChanged("ui_Home_Footer_Usb_Status_Label", "Disconnected")
_, _ = lvObjSetState("ui_Home_Footer_Usb_Status_Label", "LV_STATE_USER_2")
_, _ = lvObjSetState("Main", "USB_DISCONNECTED")
}
if lastVideoState.Ready {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Connected")
_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_DEFAULT")
_, _ = lvObjSetState("Main", "HDMI_CONNECTED")
} else {
updateLabelIfChanged("ui_Home_Footer_Hdmi_Status_Label", "Disconnected")
_, _ = lvObjSetState("ui_Home_Footer_Hdmi_Status_Label", "LV_STATE_USER_2")
_, _ = lvObjSetState("Main", "HDMI_DISCONNECTED")
}
updateLabelIfChanged("ui_Home_Header_Cloud_Status_Label", fmt.Sprintf("%d active", actionSessions))
if networkState.IsUp() {
switchToScreenIfDifferent("ui_Home_Screen")
_, _ = lvObjSetState("Network", "NETWORK")
} else {
switchToScreenIfDifferent("ui_No_Network_Screen")
_, _ = lvObjSetState("Network", "NO_NETWORK")
}
if cloudConnectionState == CloudConnectionStateNotConfigured {
_, _ = lvObjHide("ui_Home_Header_Cloud_Status_Icon")
} else {
_, _ = lvObjShow("ui_Home_Header_Cloud_Status_Icon")
}
switch cloudConnectionState {
case CloudConnectionStateDisconnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud_disconnected.png")
stopCloudBlink()
case CloudConnectionStateConnecting:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
startCloudBlink()
case CloudConnectionStateConnected:
_, _ = lvImgSetSrc("ui_Home_Header_Cloud_Status_Icon", "cloud.png")
stopCloudBlink()
}
}
func startCloudBlink() {
if cloudBlinkTicker == nil {
cloudBlinkTicker = time.NewTicker(2 * time.Second)
} else {
// do nothing if the blink isn't stopped
if cloudBlinkStopped {
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = false
cloudBlinkTicker.Reset(2 * time.Second)
}
}
go func() {
for range cloudBlinkTicker.C {
if cloudConnectionState != CloudConnectionStateConnecting {
continue
}
_, _ = lvObjFadeOut("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
_, _ = lvObjFadeIn("ui_Home_Header_Cloud_Status_Icon", 1000)
time.Sleep(1000 * time.Millisecond)
}
}()
}
func stopCloudBlink() {
if cloudBlinkTicker != nil {
cloudBlinkTicker.Stop()
}
cloudBlinkLock.Lock()
defer cloudBlinkLock.Unlock()
cloudBlinkStopped = true
}
var (
@@ -205,34 +151,31 @@ func waitCtrlAndRequestDisplayUpdate(shouldWakeDisplay bool) {
waitDisplayUpdate.Lock()
defer waitDisplayUpdate.Unlock()
waitCtrlClientConnected()
waitDisplayCtrlClientConnected()
requestDisplayUpdate(shouldWakeDisplay)
}
func updateStaticContents() {
//contents that never change
updateLabelIfChanged("ui_Home_Content_Mac", networkState.MACString())
systemVersion, appVersion, err := GetLocalVersion()
updateLabelIfChanged("Network_Address_Mac_Label", networkState.MACString())
_, appVersion, err := GetLocalVersion()
if err == nil {
updateLabelIfChanged("ui_About_Content_Operating_System_Version_ContentLabel", systemVersion.String())
updateLabelIfChanged("ui_About_Content_App_Version_Content_Label", appVersion.String())
updateLabelIfChanged("Version_App_Version_Label", appVersion.String())
}
updateLabelIfChanged("ui_Status_Content_Device_Id_Content_Label", GetDeviceID())
}
// setDisplayBrightness sets /sys/class/backlight/backlight/brightness to alter
// the backlight brightness of the JetKVM hardware's display.
// the backlight brightness of the KVM hardware's display.
func setDisplayBrightness(brightness int) error {
// NOTE: The actual maximum value for this is 255, but out-of-the-box, the value is set to 64.
// The maximum set here is set to 100 to reduce the risk of drawing too much power (and besides, 255 is very bright!).
if brightness > 100 || brightness < 0 {
if brightness > 200 || brightness < 0 {
return errors.New("brightness value out of bounds, must be between 0 and 100")
}
// Check the display backlight class is available
if _, err := os.Stat(backlightControlClass); errors.Is(err, os.ErrNotExist) {
return errors.New("brightness value cannot be set, possibly not running on JetKVM hardware")
return errors.New("brightness value cannot be set, possibly not running on KVM hardware")
}
// Set the value
@@ -302,8 +245,6 @@ func wakeDisplay(force bool) {
// watchTsEvents monitors the touchscreen for events and simply calls wakeDisplay() to ensure the
// touchscreen interface still works even with LCD dimming/off.
// TODO: This is quite a hack, really we should be getting an event from jetkvm_native, or the whole display backlight
// control should be hoisted up to jetkvm_native.
func watchTsEvents() {
ts, err := os.OpenFile(touchscreenDevice, os.O_RDONLY, 0666)
if err != nil {
@@ -379,11 +320,12 @@ func startBacklightTickers() {
func initDisplay() {
go func() {
waitCtrlClientConnected()
waitDisplayCtrlClientConnected()
displayLogger.Info().Msg("setting initial display contents")
time.Sleep(500 * time.Millisecond)
_, _ = lvDispSetRotation(config.DisplayRotation)
updateStaticContents()
initTimeZone()
displayInited = true
displayLogger.Info().Msg("display inited")
startBacklightTickers()

10
go.mod
View File

@@ -1,4 +1,4 @@
module github.com/jetkvm/kvm
module kvm
go 1.23.4
@@ -63,18 +63,26 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v2 v2.2.12 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v2 v2.3.36 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/interceptor v0.1.40 // indirect
github.com/pion/mdns v0.0.12 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/rtp v1.8.18 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/sdp/v3 v3.0.13 // indirect
github.com/pion/srtp/v2 v2.0.20 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun v0.6.1 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v2 v2.2.10 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v2 v2.1.6 // indirect
github.com/pion/turn/v4 v4.0.2 // indirect
github.com/pion/webrtc/v3 v3.3.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/rogpeppe/go-internal v1.11.0 // indirect

80
go.sum
View File

@@ -54,6 +54,7 @@ github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5x
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/guregu/null/v6 v6.0.0 h1:N14VRS+4di81i1PXRiprbQJ9EM9gqBa0+KVMeS/QSjQ=
@@ -70,6 +71,7 @@ github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -103,34 +105,59 @@ github.com/pilebones/go-udev v0.9.0 h1:N1uEO/SxUwtIctc0WLU0t69JeBxIYEYnj8lT/Nabl
github.com/pilebones/go-udev v0.9.0/go.mod h1:T2eI2tUSK0hA2WS5QLjXJUfQkluZQu+18Cqvem3CaXI=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
github.com/pion/dtls/v2 v2.2.12 h1:KP7H5/c1EiVAAKUmXyCzPiQe5+bCJrpOeKg/L05dunk=
github.com/pion/dtls/v2 v2.2.12/go.mod h1:d9SYc9fch0CqK90mRk1dC7AkzzpwJj6u2GU3u+9pqFE=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v2 v2.3.36 h1:SopeXiVbbcooUg2EIR8sq4b13RQ8gzrkkldOVg+bBsc=
github.com/pion/ice/v2 v2.3.36/go.mod h1:mBF7lnigdqgtB+YHkaY/Y6s6tsyRyo4u4rPGRuOjUBQ=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns v0.0.12 h1:CiMYlY+O0azojWDmxdNr7ADGrnZ+V6Ilfner+6mSVK8=
github.com/pion/mdns v0.0.12/go.mod h1:VExJjv8to/6Wqm1FXK+Ii/Z9tsVk/F5sD/N70cnYFbk=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.12/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.3/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU=
github.com/pion/rtp v1.8.18 h1:yEAb4+4a8nkPCecWzQB6V/uEU18X1lQCGAQCjP+pyvU=
github.com/pion/rtp v1.8.18/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v2 v2.0.20 h1:HNNny4s+OUmG280ETrCdgFndp4ufx3/uy85EawYEhTk=
github.com/pion/srtp/v2 v2.0.20/go.mod h1:0KJQjA99A6/a0DOVTu1PhDSw0CXF2jTkqOoMg3ODqdA=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun v0.6.1 h1:8lp6YejULeHBF8NmV8e2787BogQhduZugh5PdhDyyN4=
github.com/pion/stun v0.6.1/go.mod h1:/hO7APkX4hZKu/D0f2lHzNyvdkTGtIy3NDmLR7kSz/8=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g=
github.com/pion/transport/v2 v2.2.3/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.4/go.mod h1:q2U/tf9FEfnSBGSW6w5Qp5PFWRLRj3NjLhCCgpRK4p0=
github.com/pion/transport/v2 v2.2.10 h1:ucLBLE8nuxiHfvkFKnkDQRYWYfp8ejf4YBOPfaQpw6Q=
github.com/pion/transport/v2 v2.2.10/go.mod h1:sq1kSLWs+cHW9E+2fJP95QudkzbK7wscs8yYgQToO5E=
github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v2 v2.1.3/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v2 v2.1.6 h1:Xr2niVsiPTB0FPtt+yAWKFUkU1eotQbGgpTIld4x1Gc=
github.com/pion/turn/v2 v2.1.6/go.mod h1:huEpByKKHix2/b9kmTAM3YoX6MKP+/D//0ClgUYR2fY=
github.com/pion/turn/v4 v4.0.2 h1:ZqgQ3+MjP32ug30xAbD6Mn+/K4Sxi3SdNOTFf+7mpps=
github.com/pion/turn/v4 v4.0.2/go.mod h1:pMMKP/ieNAG/fN5cZiN4SDuyKsXtNTr0ccN7IToA1zs=
github.com/pion/webrtc/v3 v3.3.5 h1:ZsSzaMz/i9nblPdiAkZoP+E6Kmjw+jnyq3bEmU3EtRg=
github.com/pion/webrtc/v3 v3.3.5/go.mod h1:liNa+E1iwyzyXqNUwvoMRNQ10x8h8FOeJKL8RkIbamE=
github.com/pion/webrtc/v4 v4.0.16 h1:5f8QMVIbNvJr2mPRGi2QamkPa/LVUB6NWolOCwphKHA=
github.com/pion/webrtc/v4 v4.0.16/go.mod h1:C3uTCPzVafUA0eUzru9f47OgNt3nEO7ZJ6zNY6VSJno=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -161,6 +188,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -171,30 +200,81 @@ github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQ
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/wlynxg/anet v0.0.3/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.bug.st/serial v1.6.2 h1:kn9LRX3sdm+WxWKufMlIRndwGfPWsH1/9lCWXQCasq8=
go.bug.st/serial v1.6.2/go.mod h1:UABfsluHAiaNI+La2iESysd9Vetq7VRdpxvjx7CmmOE=
golang.org/x/arch v0.17.0 h1:4O3dfLzd+lQewptAHqjewQZQDyEdejz3VwgeYwkZneU=
golang.org/x/arch v0.17.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.8.0/go.mod h1:mRqEX+O9/h5TFCrQhkgjo2yKi0yYA+9ecGkdQoHrywE=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

18
hw.go
View File

@@ -53,12 +53,22 @@ func GetDeviceID() string {
}
func GetDefaultHostname() string {
deviceId := GetDeviceID()
if deviceId == "unknown_device_id" {
return "jetkvm"
//deviceId := GetDeviceID()
//if deviceId == "unknown_device_id" {
// return "kvm"
//}
//return fmt.Sprintf("kvm-%s", strings.ToLower(deviceId))
return "picokvm"
}
func GetHostname() string {
content, err := os.ReadFile("/etc/hostname")
if err != nil {
return GetDefaultHostname()
}
return fmt.Sprintf("jetkvm-%s", strings.ToLower(deviceId))
return strings.TrimSpace(string(content))
}
func runWatchdog() {

View File

@@ -97,7 +97,7 @@ func (l *Logger) updateLogLevel() {
finalDefaultLogLevel := l.defaultLogLevel
for name, level := range zerologLevels {
env := os.Getenv(fmt.Sprintf("JETKVM_LOG_%s", name))
env := os.Getenv(fmt.Sprintf("KVM_LOG_%s", name))
if env == "" {
env = os.Getenv(fmt.Sprintf("PION_LOG_%s", name))

View File

@@ -48,10 +48,17 @@ func (c pionLogger) Errorf(format string, args ...interface{}) {
type pionLoggerFactory struct{}
func (c pionLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
logger := rootLogger.getLogger(subsystem).With().
var logger zerolog.Logger
base := rootLogger.getLogger(subsystem).With().
Str("scope", "pion").
Str("component", subsystem).
Logger()
Str("component", subsystem)
if subsystem == "mdns" {
logger = base.Logger().Level(zerolog.ErrorLevel) // 或 ErrorLevel
} else {
logger = base.Logger()
}
return pionLogger{logger: &logger}
}

View File

@@ -4,7 +4,7 @@ import "github.com/rs/zerolog"
var (
rootZerologLogger = zerolog.New(defaultLogOutput).With().
Str("scope", "jetkvm").
Str("scope", "kvm").
Timestamp().
Stack().
Logger()

View File

@@ -7,7 +7,8 @@ import (
"strings"
"sync"
"github.com/jetkvm/kvm/internal/logging"
"kvm/internal/logging"
pion_mdns "github.com/pion/mdns/v2"
"github.com/rs/zerolog"
"golang.org/x/net/ipv4"

View File

@@ -5,8 +5,9 @@ import (
"net"
"time"
"kvm/internal/mdns"
"github.com/guregu/null/v6"
"github.com/jetkvm/kvm/internal/mdns"
"golang.org/x/net/idna"
)

View File

@@ -5,9 +5,10 @@ import (
"net"
"sync"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/logging"
"github.com/jetkvm/kvm/internal/udhcpc"
"kvm/internal/confparser"
"kvm/internal/logging"
"kvm/internal/udhcpc"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
@@ -58,7 +59,7 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS
}
if opts.DefaultHostname == "" {
opts.DefaultHostname = "jetkvm"
opts.DefaultHostname = "picokvm"
}
err := confparser.SetDefaultsAndValidate(opts.NetworkConfig)

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"time"
"github.com/jetkvm/kvm/internal/confparser"
"github.com/jetkvm/kvm/internal/udhcpc"
"kvm/internal/confparser"
"kvm/internal/udhcpc"
)
type RpcIPv6Address struct {

View File

@@ -8,64 +8,64 @@ import (
var (
metricTimeSyncStatus = promauto.NewGauge(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_status",
Name: "kvm_timesync_status",
Help: "The status of the timesync, 1 if successful, 0 if not",
},
)
metricTimeSyncCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_total",
Name: "kvm_timesync_total",
Help: "The number of times the timesync has been run",
},
)
metricTimeSyncSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_success_total",
Name: "kvm_timesync_success_total",
Help: "The number of times the timesync has been successful",
},
)
metricRTCUpdateCount = promauto.NewCounter( //nolint:unused
prometheus.CounterOpts{
Name: "jetkvm_timesync_rtc_update_total",
Name: "kvm_timesync_rtc_update_total",
Help: "The number of times the RTC has been updated",
},
)
metricNtpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_success_total",
Name: "kvm_timesync_ntp_total_success_total",
Help: "The total number of successful NTP requests",
},
)
metricNtpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_total_request_total",
Name: "kvm_timesync_ntp_total_request_total",
Help: "The total number of NTP requests sent",
},
)
metricNtpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_success_total",
Name: "kvm_timesync_ntp_success_total",
Help: "The number of successful NTP requests",
},
[]string{"url"},
)
metricNtpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_ntp_request_total",
Name: "kvm_timesync_ntp_request_total",
Help: "The number of NTP requests sent to the server",
},
[]string{"url"},
)
metricNtpServerLastRTT = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_last_rtt",
Name: "kvm_timesync_ntp_server_last_rtt",
Help: "The last RTT of the NTP server in milliseconds",
},
[]string{"url"},
)
metricNtpServerRttHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_timesync_ntp_server_rtt",
Name: "kvm_timesync_ntp_server_rtt",
Help: "The histogram of the RTT of the NTP server in milliseconds",
Buckets: []float64{
10, 25, 50, 100, 200, 300, 500, 1000,
@@ -75,7 +75,7 @@ var (
)
metricNtpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_ntp_server_info",
Name: "kvm_timesync_ntp_server_info",
Help: "The info of the NTP server",
},
[]string{"url", "reference", "stratum", "precision"},
@@ -83,53 +83,53 @@ var (
metricHttpTotalSuccessCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_success_total",
Name: "kvm_timesync_http_total_success_total",
Help: "The total number of successful HTTP requests",
},
)
metricHttpTotalRequestCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_request_total",
Name: "kvm_timesync_http_total_request_total",
Help: "The total number of HTTP requests sent",
},
)
metricHttpTotalCancelCount = promauto.NewCounter(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_total_cancel_total",
Name: "kvm_timesync_http_total_cancel_total",
Help: "The total number of HTTP requests cancelled",
},
)
metricHttpSuccessCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_success_total",
Name: "kvm_timesync_http_success_total",
Help: "The number of successful HTTP requests",
},
[]string{"url"},
)
metricHttpRequestCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_request_total",
Name: "kvm_timesync_http_request_total",
Help: "The number of HTTP requests sent to the server",
},
[]string{"url"},
)
metricHttpCancelCount = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "jetkvm_timesync_http_cancel_total",
Name: "kvm_timesync_http_cancel_total",
Help: "The number of HTTP requests cancelled",
},
[]string{"url"},
)
metricHttpServerLastRTT = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_http_server_last_rtt",
Name: "kvm_timesync_http_server_last_rtt",
Help: "The last RTT of the HTTP server in milliseconds",
},
[]string{"url"},
)
metricHttpServerRttHistogram = promauto.NewHistogramVec(
prometheus.HistogramOpts{
Name: "jetkvm_timesync_http_server_rtt",
Name: "kvm_timesync_http_server_rtt",
Help: "The histogram of the RTT of the HTTP server in milliseconds",
Buckets: []float64{
10, 25, 50, 100, 200, 300, 500, 1000,
@@ -139,7 +139,7 @@ var (
)
metricHttpServerInfo = promauto.NewGaugeVec(
prometheus.GaugeOpts{
Name: "jetkvm_timesync_http_server_info",
Name: "kvm_timesync_http_server_info",
Help: "The info of the HTTP server",
},
[]string{"url", "http_code"},

View File

@@ -7,7 +7,8 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/network"
"kvm/internal/network"
"github.com/rs/zerolog"
)

View File

@@ -4,12 +4,15 @@ import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"time"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
const (
@@ -66,6 +69,36 @@ func (c *DHCPClient) getWatchPaths() []string {
return paths
}
func (c *DHCPClient) watchLink() {
ch := make(chan netlink.LinkUpdate)
done := make(chan struct{})
if err := netlink.LinkSubscribe(ch, done); err != nil {
c.logger.Error().Err(err).Msg("failed to subscribe to netlink")
return
}
for update := range ch {
if update.Link.Attrs().Name == c.InterfaceName {
if update.IfInfomsg.Flags&unix.IFF_RUNNING != 0 {
fmt.Printf("[watchLink]link is up, starting udhcpc")
go c.runUDHCPC()
} else {
c.logger.Info().Msg("link is down")
}
}
}
}
func (c *DHCPClient) runUDHCPC() {
cmd := exec.Command("udhcpc", "-i", c.InterfaceName, "-t", "1")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
c.logger.Error().Err(err).Msg("failed to run udhcpc")
}
}
// Run starts the DHCP client and watches the lease file for changes.
// this isn't a blocking call, and the lease file is reloaded when a change is detected.
func (c *DHCPClient) Run() error {
@@ -80,6 +113,8 @@ func (c *DHCPClient) Run() error {
}
defer watcher.Close()
go c.watchLink()
go func() {
for {
select {

View File

@@ -15,7 +15,7 @@ var (
VendorId: "0x1d6b", //The Linux Foundation
ProductId: "0x0104", //Multifunction Composite Gadget
SerialNumber: "",
Manufacturer: "JetKVM",
Manufacturer: "KVM",
Product: "USB Emulation Device",
strictMode: true,
}
@@ -25,7 +25,7 @@ var (
Keyboard: true,
MassStorage: true,
}
usbGadgetName = "jetkvm"
usbGadgetName = "kvm"
usbGadget *UsbGadget
)
@@ -109,7 +109,7 @@ func TestUsbGadgetUDCNotBoundAfterReportDescrChanged(t *testing.T) {
udc := udcs[0]
assert.NotNil(udc, "UDC should exist")
udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/jetkvm/UDC")
udcStr, err := os.ReadFile("/sys/kernel/config/usb_gadget/kvm/UDC")
assert.Nil(err, "usb_gadget/UDC should exist")
assert.Equal(strings.TrimSpace(udc), strings.TrimSpace(string(udcStr)), "UDC should be the same")
}

View File

@@ -34,7 +34,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
"bcdDevice": "0100",
},
configAttrs: gadgetAttributes{
"MaxPower": "250", // in unit of 2mA
"MaxPower": "250", // in unit of 2mA
"bmAttributes": "0xa0", // 0x80 = bus-powered, 0xa0 = bus-powered + remote wakeup
},
},
"base_info": {
@@ -43,8 +44,8 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
configPath: []string{"strings", "0x409"},
attrs: gadgetAttributes{
"serialnumber": "",
"manufacturer": "JetKVM",
"product": "JetKVM USB Emulation Device",
"manufacturer": "KVM",
"product": "KVM USB Emulation Device",
},
configAttrs: gadgetAttributes{
"configuration": "Config 1: HID",
@@ -59,6 +60,23 @@ var defaultGadgetConfig = map[string]gadgetConfigItem{
// mass storage
"mass_storage_base": massStorageBaseConfig,
"mass_storage_lun0": massStorageLun0Config,
// audio
"audio": {
order: 4000,
device: "uac1.usb0",
path: []string{"functions", "uac1.usb0"},
configPath: []string{"uac1.usb0"},
attrs: gadgetAttributes{
"p_chmask": "3",
"p_srate": "48000",
"p_ssize": "2",
"p_volume_present": "0",
"c_chmask": "3",
"c_srate": "48000",
"c_ssize": "2",
"c_volume_present": "0",
},
},
}
func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
@@ -73,6 +91,8 @@ func (u *UsbGadget) isGadgetConfigItemEnabled(itemKey string) bool {
return u.enabledDevices.MassStorage
case "mass_storage_lun0":
return u.enabledDevices.MassStorage
case "audio":
return u.enabledDevices.Audio
default:
return true
}

View File

@@ -18,9 +18,8 @@ var massStorageLun0Config = gadgetConfigItem{
"ro": "1",
"removable": "1",
"file": "\n",
// the additional whitespace is intentional to avoid the "JetKVM V irtual Media" string
// https://github.com/jetkvm/rv1106-system/blob/778133a1c153041e73f7de86c9c434a2753ea65d/sysdrv/source/uboot/u-boot/drivers/usb/gadget/f_mass_storage.c#L2556
// the additional whitespace is intentional to avoid the "KVM V irtual Media" string
// Vendor (8 chars), product (16 chars)
"inquiry_string": "JetKVM Virtual Media",
"inquiry_string": "KVM Virtual Media",
},
}

View File

@@ -9,7 +9,8 @@ import (
"sync"
"time"
"github.com/jetkvm/kvm/internal/logging"
"kvm/internal/logging"
"github.com/rs/zerolog"
)
@@ -19,6 +20,7 @@ type Devices struct {
RelativeMouse bool `json:"relative_mouse"`
Keyboard bool `json:"keyboard"`
MassStorage bool `json:"mass_storage"`
Audio bool `json:"audio"`
}
// Config is a struct that represents the customizations for a USB gadget.

View File

@@ -1,55 +0,0 @@
package websecure
import (
"os"
"testing"
)
var (
fixtureEd25519Certificate = `-----BEGIN CERTIFICATE-----
MIIBQDCB86ADAgECAhQdB4qB6dV0/u1lwhJofQgkmjjV1zAFBgMrZXAwLzELMAkG
A1UEBhMCREUxIDAeBgNVBAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMB4XDTI1
MDUyMzEyNTkyN1oXDTI3MDQyMzEyNTkyN1owLzELMAkGA1UEBhMCREUxIDAeBgNV
BAMMF2VkMjU1MTktdGVzdC5qZXRrdm0uY29tMCowBQYDK2VwAyEA9tLyoulJn7Ev
bf8kuD1ZGdA092773pCRjFEDKpXHonyjITAfMB0GA1UdDgQWBBRkmrVMfsLY57iy
r/0POP0S4QxCADAFBgMrZXADQQBfTRvqavLHDYQiKQTgbGod+Yn+fIq2lE584+1U
C4wh9peIJDFocLBEAYTQpEMKxa4s0AIRxD+a7aCS5oz0e/0I
-----END CERTIFICATE-----`
fixtureEd25519PrivateKey = `-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIKV08xUsLRHBfMXqZwxVRzIbViOp8G7aQGjPvoRFjujB
-----END PRIVATE KEY-----`
certStore *CertStore
certSigner *SelfSigner
)
func TestMain(m *testing.M) {
tlsStorePath, err := os.MkdirTemp("", "jktls.*")
if err != nil {
defaultLogger.Fatal().Err(err).Msg("failed to create temp directory")
}
certStore = NewCertStore(tlsStorePath, nil)
certStore.LoadCertificates()
certSigner = NewSelfSigner(
certStore,
nil,
"ci.jetkvm.com",
"JetKVM",
"JetKVM",
"JetKVM",
)
m.Run()
os.RemoveAll(tlsStorePath)
}
func TestSaveEd25519Certificate(t *testing.T) {
err, _ := certStore.ValidateAndSaveCertificate("ed25519-test.jetkvm.com", fixtureEd25519Certificate, fixtureEd25519PrivateKey, true)
if err != nil {
t.Fatalf("failed to save certificate: %v", err)
}
}

118
io.go Normal file
View File

@@ -0,0 +1,118 @@
package kvm
import (
"fmt"
"os"
"strconv"
)
const (
gpioBasePath = "/sys/class/gpio"
ledGreenPath = "/sys/class/leds/led:g"
ledYellowPath = "/sys/class/leds/led:y"
)
func exportGPIO(pin int) error {
exportFile := gpioBasePath + "/export"
return os.WriteFile(exportFile, []byte(strconv.Itoa(pin)), 0644)
}
func unexportGPIO(pin int) error {
unexportFile := gpioBasePath + "/unexport"
return os.WriteFile(unexportFile, []byte(strconv.Itoa(pin)), 0644)
}
func isGPIOExported(pin int) bool {
gpioPath := fmt.Sprintf("%s/gpio%d", gpioBasePath, pin)
_, err := os.Stat(gpioPath)
return err == nil
}
func setGPIODirection(pin int, direction string) error {
if !isGPIOExported(pin) {
if err := exportGPIO(pin); err != nil {
return fmt.Errorf("failed to export GPIO: %v", err)
}
}
directionFile := fmt.Sprintf("%s/gpio%d/direction", gpioBasePath, pin)
return os.WriteFile(directionFile, []byte(direction), 0644)
}
func setGPIOValue(pin int, status bool) error {
var value int
if status {
value = 1
} else {
value = 0
}
if !isGPIOExported(pin) {
if err := exportGPIO(pin); err != nil {
return fmt.Errorf("failed to export GPIO: %v", err)
}
if err := setGPIODirection(pin, "out"); err != nil {
return fmt.Errorf("failed to set GPIO direction: %v", err)
}
}
valueFile := fmt.Sprintf("%s/gpio%d/value", gpioBasePath, pin)
return os.WriteFile(valueFile, []byte(strconv.Itoa(value)), 0644)
}
func setLedMode(ledConfigPath string, mode string) error {
if mode == "network-link" {
err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
err = os.WriteFile(ledConfigPath+"/device_name", []byte("eth0"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED device name: %v", err)
}
err = os.WriteFile(ledConfigPath+"/link", []byte("1"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED link: %v", err)
}
} else if mode == "network-tx" {
err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
err = os.WriteFile(ledConfigPath+"/device_name", []byte("eth0"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED device name: %v", err)
}
err = os.WriteFile(ledConfigPath+"/tx", []byte("1"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED tx: %v", err)
}
} else if mode == "network-rx" {
err := os.WriteFile(ledConfigPath+"/trigger", []byte("netdev"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
err = os.WriteFile(ledConfigPath+"/device_name", []byte("eth0"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED device name: %v", err)
}
err = os.WriteFile(ledConfigPath+"/rx", []byte("1"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED rx: %v", err)
}
} else if mode == "kernel-activity" {
err := os.WriteFile(ledConfigPath+"/trigger", []byte("activity"), 0644)
if err != nil {
return fmt.Errorf("failed to set LED trigger: %v", err)
}
} else {
return fmt.Errorf("invalid LED mode: %s", mode)
}
return nil
}
func initGPIO() {
LoadConfig()
// IO0: GPIO58 IO1: GPIO59
_ = setGPIOValue(58, config.IO0Status)
_ = setGPIOValue(59, config.IO1Status)
_ = setLedMode(ledYellowPath, config.LEDYellowMode)
_ = setLedMode(ledGreenPath, config.LEDGreenMode)
}

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"os/exec"
"path/filepath"
"reflect"
"strconv"
"time"
@@ -15,7 +14,7 @@ import (
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
"github.com/jetkvm/kvm/internal/usbgadget"
"kvm/internal/usbgadget"
)
type JSONRPCRequest struct {
@@ -263,6 +262,17 @@ func rpcSetDevChannelState(enabled bool) error {
return nil
}
func rpcGetLocalUpdateStatus() (*LocalMetadata, error) {
var localStatus LocalMetadata
systemVersionLocal, appVersionLocal, err := GetLocalVersion()
if err != nil {
return nil, fmt.Errorf("failed to get local version: %w", err)
}
localStatus.AppVersion = appVersionLocal.String()
localStatus.SystemVersion = systemVersionLocal.String()
return &localStatus, nil
}
func rpcGetUpdateStatus() (*UpdateStatus, error) {
includePreRelease := config.IncludePreRelease
updateStatus, err := GetUpdateStatus(context.Background(), GetDeviceID(), includePreRelease)
@@ -353,10 +363,28 @@ func rpcGetBacklightSettings() (*BacklightSettings, error) {
}, nil
}
func rpcSetTimeZone(timeZone string) error {
var err error
_, err = CallDisplayCtrlAction("set_timezone", map[string]interface{}{"timezone": timeZone})
if err == nil {
config.TimeZone = timeZone
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
}
return err
}
func rpcGetTimeZone() (string, error) {
return config.TimeZone, nil
}
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
sshKeyFile = "/userdata/dropbear/.ssh/authorized_keys"
devModeFile = "/userdata/picokvm/devmode.enable"
sshKeyDir = "/userdata/openssh/.ssh"
sshKeyFile = "/userdata/openssh/.ssh/authorized_keys"
)
type DevModeState struct {
@@ -382,42 +410,6 @@ func rpcGetDevModeState() (DevModeState, error) {
}, nil
}
func rpcSetDevModeState(enabled bool) error {
if enabled {
if _, err := os.Stat(devModeFile); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(devModeFile), 0755); err != nil {
return fmt.Errorf("failed to create directory for devmode file: %w", err)
}
if err := os.WriteFile(devModeFile, []byte{}, 0644); err != nil {
return fmt.Errorf("failed to create devmode file: %w", err)
}
} else {
logger.Debug().Msg("dev mode already enabled")
return nil
}
} else {
if _, err := os.Stat(devModeFile); err == nil {
if err := os.Remove(devModeFile); err != nil {
return fmt.Errorf("failed to remove devmode file: %w", err)
}
} else if os.IsNotExist(err) {
logger.Debug().Msg("dev mode already disabled")
return nil
} else {
return fmt.Errorf("error checking dev mode file: %w", err)
}
}
cmd := exec.Command("dropbear.sh")
output, err := cmd.CombinedOutput()
if err != nil {
logger.Warn().Err(err).Bytes("output", output).Msg("Failed to start/stop SSH")
return fmt.Errorf("failed to start/stop SSH, you may need to reboot for changes to take effect")
}
return nil
}
func rpcGetSSHKeyState() (string, error) {
keyData, err := os.ReadFile(sshKeyFile)
if err != nil {
@@ -680,26 +672,6 @@ func rpcResetConfig() error {
return nil
}
type DCPowerState struct {
IsOn bool `json:"isOn"`
Voltage float64 `json:"voltage"`
Current float64 `json:"current"`
Power float64 `json:"power"`
}
func rpcGetDCPowerState() (DCPowerState, error) {
return dcState, nil
}
func rpcSetDCPowerState(enabled bool) error {
logger.Info().Bool("enabled", enabled).Msg("Setting DC power state")
err := setDCPowerState(enabled)
if err != nil {
return fmt.Errorf("failed to set DC power state: %w", err)
}
return nil
}
func rpcGetActiveExtension() (string, error) {
return config.ActiveExtension, nil
}
@@ -708,55 +680,13 @@ func rpcSetActiveExtension(extensionId string) error {
if config.ActiveExtension == extensionId {
return nil
}
switch config.ActiveExtension {
case "atx-power":
_ = unmountATXControl()
case "dc-power":
_ = unmountDCControl()
}
config.ActiveExtension = extensionId
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
switch extensionId {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
return nil
}
func rpcSetATXPowerAction(action string) error {
logger.Debug().Str("action", action).Msg("Executing ATX power action")
switch action {
case "power-short":
logger.Debug().Msg("Simulating short power button press")
return pressATXPowerButton(200 * time.Millisecond)
case "power-long":
logger.Debug().Msg("Simulating long power button press")
return pressATXPowerButton(5 * time.Second)
case "reset":
logger.Debug().Msg("Simulating reset button press")
return pressATXResetButton(200 * time.Millisecond)
default:
return fmt.Errorf("invalid action: %s", action)
}
}
type ATXState struct {
Power bool `json:"power"`
HDD bool `json:"hdd"`
}
func rpcGetATXState() (ATXState, error) {
state := ATXState{
Power: ledPWRState,
HDD: ledHDDState,
}
return state, nil
}
type SerialSettings struct {
BaudRate string `json:"baudRate"`
DataBits string `json:"dataBits"`
@@ -885,22 +815,6 @@ func rpcSetUsbDeviceState(device string, enabled bool) error {
return updateUsbRelatedConfig()
}
func rpcSetCloudUrl(apiUrl string, appUrl string) error {
currentCloudURL := config.CloudURL
config.CloudURL = apiUrl
config.CloudAppURL = appUrl
if currentCloudURL != apiUrl {
disconnectCloud(fmt.Errorf("cloud url changed from %s to %s", currentCloudURL, apiUrl))
}
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetKeyboardLayout() (string, error) {
return config.KeyboardLayout, nil
}
@@ -1025,83 +939,178 @@ func rpcSetLocalLoopbackOnly(enabled bool) error {
return nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"deregisterDevice": {Func: rpcDeregisterDevice},
"getCloudState": {Func: rpcGetCloudState},
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"setDevModeState": {Func: rpcSetDevModeState, Params: []string{"enabled"}},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"getDCPowerState": {Func: rpcGetDCPowerState},
"setDCPowerState": {Func: rpcSetDCPowerState, Params: []string{"enabled"}},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getATXState": {Func: rpcGetATXState},
"setATXPowerAction": {Func: rpcSetATXPowerAction, Params: []string{"action"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"setCloudUrl": {Func: rpcSetCloudUrl, Params: []string{"apiUrl", "appUrl"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
type IOSettings struct {
IO0Status bool `json:"io0Status"`
IO1Status bool `json:"io1Status"`
}
func rpcGetIOSettings() (IOSettings, error) {
LoadConfig()
settings := IOSettings{
IO0Status: config.IO0Status,
IO1Status: config.IO1Status,
}
return settings, nil
}
func rpcSetIOSettings(settings IOSettings) error {
LoadConfig()
// IO0: GPIO58 IO1: GPIO59
_ = setGPIOValue(58, settings.IO0Status)
_ = setGPIOValue(59, settings.IO1Status)
config.IO0Status = settings.IO0Status
config.IO1Status = settings.IO1Status
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetAudioMode() (string, error) {
return config.AudioMode, nil
}
func rpcSetAudioMode(mode string) error {
config.AudioMode = mode
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetLedGreenMode(mode string) error {
err := setLedMode(ledGreenPath, mode)
if err != nil {
return err
}
config.LEDGreenMode = mode
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcSetLedYellowMode(mode string) error {
err := setLedMode(ledYellowPath, mode)
if err != nil {
return err
}
config.LEDYellowMode = mode
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}
func rpcGetLedGreenMode() (string, error) {
return config.LEDGreenMode, nil
}
func rpcGetLedYellowMode() (string, error) {
return config.LEDYellowMode, nil
}
var rpcHandlers = map[string]RPCHandler{
"ping": {Func: rpcPing},
"reboot": {Func: rpcReboot, Params: []string{"force"}},
"getDeviceID": {Func: rpcGetDeviceID},
"getNetworkState": {Func: rpcGetNetworkState},
"getNetworkSettings": {Func: rpcGetNetworkSettings},
"setNetworkSettings": {Func: rpcSetNetworkSettings, Params: []string{"settings"}},
"renewDHCPLease": {Func: rpcRenewDHCPLease},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"getKeyboardLedState": {Func: rpcGetKeyboardLedState},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState},
"unmountImage": {Func: rpcUnmountImage},
"rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}},
"setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}},
"getJigglerState": {Func: rpcGetJigglerState},
"sendUsbWakeupSignal": {Func: rpcSendUsbWakeupSignal},
"sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}},
"getStreamQualityFactor": {Func: rpcGetStreamQualityFactor},
"setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}},
"getAutoUpdateState": {Func: rpcGetAutoUpdateState},
"setAutoUpdateState": {Func: rpcSetAutoUpdateState, Params: []string{"enabled"}},
"getEDID": {Func: rpcGetEDID},
"setEDID": {Func: rpcSetEDID, Params: []string{"edid"}},
"getDevChannelState": {Func: rpcGetDevChannelState},
"setDevChannelState": {Func: rpcSetDevChannelState, Params: []string{"enabled"}},
"getLocalUpdateStatus": {Func: rpcGetLocalUpdateStatus},
"getUpdateStatus": {Func: rpcGetUpdateStatus},
"tryUpdate": {Func: rpcTryUpdate},
"getDevModeState": {Func: rpcGetDevModeState},
"getSSHKeyState": {Func: rpcGetSSHKeyState},
"setSSHKeyState": {Func: rpcSetSSHKeyState, Params: []string{"sshKey"}},
"getTLSState": {Func: rpcGetTLSState},
"setTLSState": {Func: rpcSetTLSState, Params: []string{"state"}},
"setMassStorageMode": {Func: rpcSetMassStorageMode, Params: []string{"mode"}},
"getMassStorageMode": {Func: rpcGetMassStorageMode},
"isUpdatePending": {Func: rpcIsUpdatePending},
"getUsbEmulationState": {Func: rpcGetUsbEmulationState},
"setUsbEmulationState": {Func: rpcSetUsbEmulationState, Params: []string{"enabled"}},
"getUsbConfig": {Func: rpcGetUsbConfig},
"setUsbConfig": {Func: rpcSetUsbConfig, Params: []string{"usbConfig"}},
"checkMountUrl": {Func: rpcCheckMountUrl, Params: []string{"url"}},
"getVirtualMediaState": {Func: rpcGetVirtualMediaState},
"getStorageSpace": {Func: rpcGetStorageSpace},
"getSDStorageSpace": {Func: rpcGetSDStorageSpace},
"resetSDStorage": {Func: rpcResetSDStorage},
"mountWithHTTP": {Func: rpcMountWithHTTP, Params: []string{"url", "mode"}},
"mountWithWebRTC": {Func: rpcMountWithWebRTC, Params: []string{"filename", "size", "mode"}},
"mountWithStorage": {Func: rpcMountWithStorage, Params: []string{"filename", "mode"}},
"mountWithSDStorage": {Func: rpcMountWithSDStorage, Params: []string{"filename", "mode"}},
"listStorageFiles": {Func: rpcListStorageFiles},
"deleteStorageFile": {Func: rpcDeleteStorageFile, Params: []string{"filename"}},
"startStorageFileUpload": {Func: rpcStartStorageFileUpload, Params: []string{"filename", "size"}},
"listSDStorageFiles": {Func: rpcListSDStorageFiles},
"deleteSDStorageFile": {Func: rpcDeleteSDStorageFile, Params: []string{"filename"}},
"startSDStorageFileUpload": {Func: rpcStartSDStorageFileUpload, Params: []string{"filename", "size"}},
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setDisplayRotation": {Func: rpcSetDisplayRotation, Params: []string{"params"}},
"getDisplayRotation": {Func: rpcGetDisplayRotation},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
"setTimeZone": {Func: rpcSetTimeZone, Params: []string{"timeZone"}},
"getTimeZone": {Func: rpcGetTimeZone},
"setLedGreenMode": {Func: rpcSetLedGreenMode, Params: []string{"mode"}},
"setLedYellowMode": {Func: rpcSetLedYellowMode, Params: []string{"mode"}},
"getLedGreenMode": {Func: rpcGetLedGreenMode},
"getLedYellowMode": {Func: rpcGetLedYellowMode},
"getActiveExtension": {Func: rpcGetActiveExtension},
"setActiveExtension": {Func: rpcSetActiveExtension, Params: []string{"extensionId"}},
"getSerialSettings": {Func: rpcGetSerialSettings},
"setSerialSettings": {Func: rpcSetSerialSettings, Params: []string{"settings"}},
"getUsbDevices": {Func: rpcGetUsbDevices},
"setUsbDevices": {Func: rpcSetUsbDevices, Params: []string{"devices"}},
"setUsbDeviceState": {Func: rpcSetUsbDeviceState, Params: []string{"device", "enabled"}},
"getKeyboardLayout": {Func: rpcGetKeyboardLayout},
"setKeyboardLayout": {Func: rpcSetKeyboardLayout, Params: []string{"layout"}},
"getKeyboardMacros": {Func: getKeyboardMacros},
"setKeyboardMacros": {Func: setKeyboardMacros, Params: []string{"params"}},
"getLocalLoopbackOnly": {Func: rpcGetLocalLoopbackOnly},
"setLocalLoopbackOnly": {Func: rpcSetLocalLoopbackOnly, Params: []string{"enabled"}},
"getIOSettings": {Func: rpcGetIOSettings},
"setIOSettings": {Func: rpcSetIOSettings, Params: []string{"settings"}},
"getSDMountStatus": {Func: rpcGetSDMountStatus},
"loginTailScale": {Func: rpcLoginTailScale, Params: []string{"xEdge"}},
"logoutTailScale": {Func: rpcLogoutTailScale},
"canelTailScale": {Func: rpcCanelTailScale},
"getTailScaleSettings": {Func: rpcGetTailScaleSettings},
"loginZeroTier": {Func: rpcLoginZeroTier, Params: []string{"networkID"}},
"logoutZeroTier": {Func: rpcLogoutZeroTier, Params: []string{"networkID"}},
"getZeroTierSettings": {Func: rpcGetZeroTierSettings},
"setUpdateSource": {Func: rpcSetUpdateSource, Params: []string{"source"}},
"getAudioMode": {Func: rpcGetAudioMode},
"setAudioMode": {Func: rpcSetAudioMode, Params: []string{"mode"}},
}

9
log.go
View File

@@ -1,7 +1,8 @@
package kvm
import (
"github.com/jetkvm/kvm/internal/logging"
"kvm/internal/logging"
"github.com/rs/zerolog"
)
@@ -10,12 +11,14 @@ func ErrorfL(l *zerolog.Logger, format string, err error, args ...interface{}) e
}
var (
logger = logging.GetSubsystemLogger("jetkvm")
logger = logging.GetSubsystemLogger("kvm")
networkLogger = logging.GetSubsystemLogger("network")
vpnLogger = logging.GetSubsystemLogger("vpn")
cloudLogger = logging.GetSubsystemLogger("cloud")
websocketLogger = logging.GetSubsystemLogger("websocket")
webrtcLogger = logging.GetSubsystemLogger("webrtc")
nativeLogger = logging.GetSubsystemLogger("native")
videoLogger = logging.GetSubsystemLogger("video")
audioLogger = logging.GetSubsystemLogger("audio")
nbdLogger = logging.GetSubsystemLogger("nbd")
timesyncLogger = logging.GetSubsystemLogger("timesync")
jsonRpcLogger = logging.GetSubsystemLogger("jsonrpc")

98
main.go
View File

@@ -28,10 +28,10 @@ func Main() {
logger.Info().
Interface("system_version", systemVersionLocal).
Interface("app_version", appVersionLocal).
Msg("starting JetKVM")
Msg("starting KVM")
go runWatchdog()
go confirmCurrentSystem()
go confirmCurrentSystem() //A/B system
http.DefaultClient.Timeout = 1 * time.Minute
@@ -58,25 +58,62 @@ func Main() {
logger.Error().Err(err).Msg("failed to initialize mDNS")
os.Exit(1)
}
//if mDNS != nil {
// _ = mDNS.SetListenOptions(config.NetworkConfig.GetMDNSMode())
// _ = mDNS.SetLocalNames([]string{
// networkState.GetHostname(),
// networkState.GetFQDN(),
// }, true)
//}
// Initialize native ctrl socket server
StartNativeCtrlSocketServer()
StartVideoCtrlSocketServer()
// Initialize native video socket server
StartNativeVideoSocketServer()
StartVideoDataSocketServer()
// Initialize native audio socket server
StartAudioCtrlSocketServer()
StartVpnCtrlSocketServer()
StartDisplayCtrlSocketServer()
initPrometheus()
go func() {
err = ExtractAndRunNativeBin()
err = ExtractAndRunVideoBin()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract and run native bin")
logger.Warn().Err(err).Msg("failed to extract and run video bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
err = ExtractAndRunDisplayBin()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract and run display bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
err = ExtractAndRunAudioBin()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract and run audio bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
err = ExtractAndRunVpnBin()
if err != nil {
logger.Warn().Err(err).Msg("failed to extract and run vpn bin")
//TODO: prepare an error message screen buffer to show on kvm screen
}
}()
// initialize usb gadget
initUsbGadget()
// initialize GPIO
initGPIO()
if err := setInitialVirtualMediaState(); err != nil {
logger.Warn().Err(err).Msg("failed to set initial virtual media state")
}
@@ -89,26 +126,30 @@ func Main() {
// initialize display
initDisplay()
go func() {
time.Sleep(15 * time.Minute)
for {
logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
if !config.AutoUpdateEnabled {
return
}
if currentSession != nil {
logger.Debug().Msg("skipping update since a session is active")
time.Sleep(1 * time.Minute)
continue
}
includePreRelease := config.IncludePreRelease
err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
if err != nil {
logger.Warn().Err(err).Msg("failed to auto update")
}
time.Sleep(1 * time.Hour)
}
}()
// Initialize VPN
initVPN()
//Auto update
//go func() {
// time.Sleep(15 * time.Minute)
// for {
// logger.Debug().Bool("auto_update_enabled", config.AutoUpdateEnabled).Msg("UPDATING")
// if !config.AutoUpdateEnabled {
// return
// }
// if currentSession != nil {
// logger.Debug().Msg("skipping update since a session is active")
// time.Sleep(1 * time.Minute)
// continue
// }
// includePreRelease := config.IncludePreRelease
// err = TryUpdate(context.Background(), GetDeviceID(), includePreRelease)
// if err != nil {
// logger.Warn().Err(err).Msg("failed to auto update")
// }
// time.Sleep(1 * time.Hour)
// }
//}()
//go RunFuseServer()
go RunWebServer()
@@ -118,14 +159,11 @@ func Main() {
startWebSecureServer()
}
// As websocket client already checks if the cloud token is set, we can start it here.
go RunWebsocketClient()
initSerialPort()
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs
logger.Info().Msg("JetKVM Shutting Down")
logger.Info().Msg("KVM Shutting Down")
//if fuseServer != nil {
// err := setMassStorageImage(" ")
// if err != nil {

View File

@@ -1,7 +1,7 @@
package kvm
import (
"github.com/jetkvm/kvm/internal/mdns"
"kvm/internal/mdns"
)
var mDNS *mdns.MDNS

156
native.go
View File

@@ -1,19 +1,15 @@
package kvm
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"sync"
"time"
"github.com/jetkvm/kvm/resource"
"github.com/pion/webrtc/v4/pkg/media"
)
@@ -43,8 +39,8 @@ var ongoingRequests = make(map[int32]chan *CtrlResponse)
var lock = &sync.Mutex{}
var (
nativeCmd *exec.Cmd
nativeCmdLock = &sync.Mutex{}
videoCmd *exec.Cmd
videoCmdLock = &sync.Mutex{}
)
func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
@@ -66,7 +62,7 @@ func CallCtrlAction(action string, params map[string]interface{}) (*CtrlResponse
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := nativeLogger.With().
scopedLogger := videoLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
@@ -104,8 +100,8 @@ func WriteCtrlMessage(message []byte) error {
return err
}
var nativeCtrlSocketListener net.Listener //nolint:unused
var nativeVideoSocketListener net.Listener //nolint:unused
var videoCtrlSocketListener net.Listener //nolint:unused
var videoSocketListener net.Listener //nolint:unused
var ctrlClientConnected = make(chan struct{})
@@ -113,8 +109,8 @@ func waitCtrlClientConnected() {
<-ctrlClientConnected
}
func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := nativeLogger.With().
func StartVideoSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := videoLogger.With().
Str("socket_path", socketPath).
Logger()
@@ -160,20 +156,20 @@ func StartNativeSocketServer(socketPath string, handleClient func(net.Conn), isC
return listener
}
func StartNativeCtrlSocketServer() {
nativeCtrlSocketListener = StartNativeSocketServer("/var/run/jetkvm_ctrl.sock", handleCtrlClient, true)
nativeLogger.Debug().Msg("native app ctrl sock started")
func StartVideoCtrlSocketServer() {
videoCtrlSocketListener = StartVideoSocketServer("/var/run/kvm_ctrl.sock", handleCtrlClient, true)
videoLogger.Debug().Msg("native app ctrl sock started")
}
func StartNativeVideoSocketServer() {
nativeVideoSocketListener = StartNativeSocketServer("/var/run/jetkvm_video.sock", handleVideoClient, false)
nativeLogger.Debug().Msg("native app video sock started")
func StartVideoDataSocketServer() {
videoSocketListener = StartVideoSocketServer("/var/run/kvm_video.sock", handleVideoClient, false)
videoLogger.Debug().Msg("native app video sock started")
}
func handleCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
scopedLogger := videoLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "ctrl").
Logger()
@@ -224,7 +220,7 @@ func handleCtrlClient(conn net.Conn) {
func handleVideoClient(conn net.Conn) {
defer conn.Close()
scopedLogger := nativeLogger.With().
scopedLogger := videoLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "video").
Logger()
@@ -251,63 +247,60 @@ func handleVideoClient(conn net.Conn) {
}
}
func startNativeBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
func startVideoBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
videoCmdLock.Lock()
defer videoCmdLock.Unlock()
cmd, err := startNativeBinary(binaryPath)
cmd, err := startVideoBinary(binaryPath)
if err != nil {
return nil, err
}
nativeCmd = cmd
videoCmd = cmd
return cmd, nil
}
func restartNativeBinary(binaryPath string) error {
func restartVideoBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
nativeLogger.Info().Msg("restarting jetkvm_native binary")
cmd, err := startNativeBinary(binaryPath)
videoLogger.Info().Msg("restarting kvm_video binary")
cmd, err := startVideoBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to restart binary")
videoLogger.Warn().Err(err).Msg("failed to restart binary")
}
nativeCmd = cmd
videoCmd = cmd
return err
}
func superviseNativeBinary(binaryPath string) error {
nativeCmdLock.Lock()
defer nativeCmdLock.Unlock()
func superviseVideoBinary(binaryPath string) error {
videoCmdLock.Lock()
defer videoCmdLock.Unlock()
if nativeCmd == nil || nativeCmd.Process == nil {
return restartNativeBinary(binaryPath)
if videoCmd == nil || videoCmd.Process == nil {
return restartVideoBinary(binaryPath)
}
err := nativeCmd.Wait()
err := videoCmd.Wait()
if err == nil {
nativeLogger.Info().Err(err).Msg("jetkvm_native binary exited with no error")
videoLogger.Info().Err(err).Msg("kvm_video binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
nativeLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("jetkvm_native binary exited with error")
videoLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("kvm_video binary exited with error")
} else {
nativeLogger.Warn().Err(err).Msg("jetkvm_native binary exited with unknown error")
videoLogger.Warn().Err(err).Msg("kvm_video binary exited with unknown error")
}
return restartNativeBinary(binaryPath)
return restartVideoBinary(binaryPath)
}
func ExtractAndRunNativeBin() error {
binaryPath := "/userdata/jetkvm/bin/jetkvm_native"
if err := ensureBinaryUpdated(binaryPath); err != nil {
return fmt.Errorf("failed to extract binary: %w", err)
}
func ExtractAndRunVideoBin() error {
binaryPath := "/userdata/picokvm/bin/kvm_video"
// Make the binary executable
if err := os.Chmod(binaryPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
cmd, err := startNativeBinaryWithLock(binaryPath)
cmd, err := startVideoBinaryWithLock(binaryPath)
if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
@@ -317,12 +310,12 @@ func ExtractAndRunNativeBin() error {
for {
select {
case <-appCtx.Done():
nativeLogger.Info().Msg("stopping native binary supervisor")
videoLogger.Info().Msg("stopping native binary supervisor")
return
default:
err := superviseNativeBinary(binaryPath)
err := superviseVideoBinary(binaryPath)
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to supervise native binary")
videoLogger.Warn().Err(err).Msg("failed to supervise native binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
@@ -331,83 +324,26 @@ func ExtractAndRunNativeBin() error {
go func() {
<-appCtx.Done()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
videoLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
nativeLogger.Warn().Err(err).Msg("failed to kill process")
videoLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
nativeLogger.Info().Int("pid", cmd.Process.Pid).Msg("jetkvm_native binary started")
return nil
}
func shouldOverwrite(destPath string, srcHash []byte) bool {
if srcHash == nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, doing overwriting")
return true
}
dstHash, err := os.ReadFile(destPath + ".sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading existing jetkvm_native.sha256, doing overwriting")
return true
}
return !bytes.Equal(srcHash, dstHash)
}
func ensureBinaryUpdated(destPath string) error {
srcFile, err := resource.ResourceFS.Open("jetkvm_native")
if err != nil {
return err
}
defer srcFile.Close()
srcHash, err := resource.ResourceFS.ReadFile("jetkvm_native.sha256")
if err != nil {
nativeLogger.Debug().Msg("error reading embedded jetkvm_native.sha256, proceeding with update")
srcHash = nil
}
_, err = os.Stat(destPath)
if shouldOverwrite(destPath, srcHash) || err != nil {
nativeLogger.Info().
Interface("hash", srcHash).
Msg("writing jetkvm_native")
_ = os.Remove(destPath)
destFile, err := os.OpenFile(destPath, os.O_CREATE|os.O_RDWR, 0755)
if err != nil {
return err
}
_, err = io.Copy(destFile, srcFile)
destFile.Close()
if err != nil {
return err
}
if srcHash != nil {
err = os.WriteFile(destPath+".sha256", srcHash, 0644)
if err != nil {
return err
}
}
nativeLogger.Info().Msg("jetkvm_native updated")
}
videoLogger.Info().Int("pid", cmd.Process.Pid).Msg("kvm_video binary started")
return nil
}
// Restore the HDMI EDID value from the config.
// Called after successful connection to jetkvm_native.
func restoreHdmiEdid() {
if config.EdidString != "" {
nativeLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
videoLogger.Info().Str("edid", config.EdidString).Msg("Restoring HDMI EDID")
_, err := CallCtrlAction("set_edid", map[string]interface{}{"edid": config.EdidString})
if err != nil {
nativeLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
videoLogger.Warn().Err(err).Msg("Failed to restore HDMI EDID")
}
}
}

279
native_audio.go Normal file
View File

@@ -0,0 +1,279 @@
package kvm
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"sync"
"time"
)
var (
audioCmd *exec.Cmd
audioCmdLock = &sync.Mutex{}
)
var audioSocketConn net.Conn
var audioOngoingRequests = make(map[int32]chan *CtrlResponse)
var audioLock = &sync.Mutex{}
func CallAudioCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
audioLock.Lock()
defer audioLock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
}
responseChan := make(chan *CtrlResponse)
audioOngoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
if err != nil {
delete(audioOngoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := audioLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending audio ctrl action")
err = WriteAudioCtrlMessage(jsonData)
if err != nil {
delete(audioOngoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing audio ctrl message", err)
}
select {
case response := <-responseChan:
delete(audioOngoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error audio response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(5 * time.Second):
close(responseChan)
delete(audioOngoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
func WriteAudioCtrlMessage(message []byte) error {
if audioSocketConn == nil {
return fmt.Errorf("audio socket not conn ected")
}
_, err := audioSocketConn.Write(message)
return err
}
var audioCtrlSocketListener net.Listener
var audioCtrlClientConnected = make(chan struct{})
func waitAudioCtrlClientConnected() {
<-audioCtrlClientConnected
}
func StartAudioSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := audioLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-audioCtrlClientConnected:
scopedLogger.Debug().Msg("audio ctrl client reconnected")
default:
close(audioCtrlClientConnected)
scopedLogger.Debug().Msg("first audio ctrl socket client connected")
}
}
//conn.Write([]byte("[handleAudioCtrlClient]audio sock test"))
go handleClient(conn)
}
}()
return listener
}
func StartAudioCtrlSocketServer() {
audioCtrlSocketListener = StartAudioSocketServer("/var/run/kvm_audio.sock", handleAudioCtrlClient, true)
audioLogger.Debug().Msg("audio ctrl sock started")
}
func handleAudioCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := audioLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "audio_ctrl").
Logger()
scopedLogger.Info().Msg("audio socket client connected")
if audioSocketConn != nil {
scopedLogger.Debug().Msg("closing existing audio socket connection")
audioSocketConn.Close()
}
audioSocketConn = conn
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from audio sock")
break
}
readMsg := string(readBuf[:n])
audioResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &audioResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing audio sock msg")
continue
}
scopedLogger.Trace().Interface("data", audioResp).Msg("audio sock msg")
if audioResp.Seq != 0 {
responseChan, ok := audioOngoingRequests[audioResp.Seq]
if ok {
responseChan <- &audioResp
}
}
switch audioResp.Event {
case "audio_input_state":
HandleAudioStateMessage(audioResp)
}
}
scopedLogger.Debug().Msg("audio sock disconnected")
}
func startAudioBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
audioCmdLock.Lock()
defer audioCmdLock.Unlock()
cmd, err := startAudioBinary(binaryPath)
if err != nil {
return nil, err
}
audioCmd = cmd
return cmd, nil
}
func restartAudioBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
audioLogger.Info().Msg("restarting audio_video binary")
cmd, err := startAudioBinary(binaryPath)
if err != nil {
audioLogger.Warn().Err(err).Msg("failed to restart binary")
}
audioCmd = cmd
return err
}
func superviseAudioBinary(binaryPath string) error {
audioCmdLock.Lock()
defer audioCmdLock.Unlock()
if audioCmd == nil || audioCmd.Process == nil {
return restartAudioBinary(binaryPath)
}
err := audioCmd.Wait()
if err == nil {
audioLogger.Info().Err(err).Msg("kvm_audio binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
audioLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("kvm_audio binary exited with error")
} else {
audioLogger.Warn().Err(err).Msg("kvm_audio binary exited with unknown error")
}
return restartAudioBinary(binaryPath)
}
func ExtractAndRunAudioBin() error {
binaryPath := "/userdata/picokvm/bin/kvm_audio"
// Make the binary executable
if err := os.Chmod(binaryPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
cmd, err := startAudioBinaryWithLock(binaryPath)
if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
// check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
audioLogger.Info().Msg("stopping audio binary supervisor")
return
default:
err := superviseAudioBinary(binaryPath)
if err != nil {
audioLogger.Warn().Err(err).Msg("failed to supervise audio binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() {
<-appCtx.Done()
audioLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
audioLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
audioLogger.Info().Int("pid", cmd.Process.Pid).Msg("kvm_audio binary started")
return nil
}

275
native_display.go Normal file
View File

@@ -0,0 +1,275 @@
package kvm
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"sync"
"time"
)
var (
displayCmd *exec.Cmd
displayCmdLock = &sync.Mutex{}
)
var displaySocketConn net.Conn
var displayOngoingRequests = make(map[int32]chan *CtrlResponse)
var displayLock = &sync.Mutex{}
func CallDisplayCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
displayLock.Lock()
defer displayLock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
}
responseChan := make(chan *CtrlResponse)
displayOngoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
if err != nil {
delete(displayOngoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := displayLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending display ctrl action")
err = WriteDisplayCtrlMessage(jsonData)
if err != nil {
delete(displayOngoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing display ctrl message", err)
}
select {
case response := <-responseChan:
delete(displayOngoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error display response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(10 * time.Second):
close(responseChan)
delete(displayOngoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
func WriteDisplayCtrlMessage(message []byte) error {
if displaySocketConn == nil {
return fmt.Errorf("display socket not conn ected")
}
_, err := displaySocketConn.Write(message)
return err
}
var displayCtrlSocketListener net.Listener
var displayCtrlClientConnected = make(chan struct{})
func waitDisplayCtrlClientConnected() {
<-displayCtrlClientConnected
}
func StartDisplaySocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := displayLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-displayCtrlClientConnected:
scopedLogger.Debug().Msg("display ctrl client reconnected")
default:
close(displayCtrlClientConnected)
scopedLogger.Debug().Msg("first display ctrl socket client connected")
}
}
//conn.Write([]byte("[handleDisplayCtrlClient]display sock test"))
go handleClient(conn)
}
}()
return listener
}
func StartDisplayCtrlSocketServer() {
displayCtrlSocketListener = StartDisplaySocketServer("/var/run/kvm_display.sock", handleDisplayCtrlClient, true)
displayLogger.Debug().Msg("display ctrl sock started")
}
func handleDisplayCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := displayLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "display_ctrl").
Logger()
scopedLogger.Info().Msg("display socket client connected")
if displaySocketConn != nil {
scopedLogger.Debug().Msg("closing existing display socket connection")
displaySocketConn.Close()
}
displaySocketConn = conn
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from display sock")
break
}
readMsg := string(readBuf[:n])
displayResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &displayResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing display sock msg")
continue
}
scopedLogger.Trace().Interface("data", displayResp).Msg("display sock msg")
if displayResp.Seq != 0 {
responseChan, ok := displayOngoingRequests[displayResp.Seq]
if ok {
responseChan <- &displayResp
}
}
}
scopedLogger.Debug().Msg("display sock disconnected")
}
func startDisplayBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
displayCmdLock.Lock()
defer displayCmdLock.Unlock()
cmd, err := startDisplayBinary(binaryPath)
if err != nil {
return nil, err
}
displayCmd = cmd
return cmd, nil
}
func restartDisplayBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
displayLogger.Info().Msg("restarting display_video binary")
cmd, err := startDisplayBinary(binaryPath)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to restart binary")
}
displayCmd = cmd
return err
}
func superviseDisplayBinary(binaryPath string) error {
displayCmdLock.Lock()
defer displayCmdLock.Unlock()
if displayCmd == nil || displayCmd.Process == nil {
return restartDisplayBinary(binaryPath)
}
err := displayCmd.Wait()
if err == nil {
displayLogger.Info().Err(err).Msg("kvm_display binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
displayLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("kvm_display binary exited with error")
} else {
displayLogger.Warn().Err(err).Msg("kvm_display binary exited with unknown error")
}
return restartDisplayBinary(binaryPath)
}
func ExtractAndRunDisplayBin() error {
binaryPath := "/userdata/picokvm/bin/kvm_display"
// Make the binary executable
if err := os.Chmod(binaryPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
cmd, err := startDisplayBinaryWithLock(binaryPath)
if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
// check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
displayLogger.Info().Msg("stopping display binary supervisor")
return
default:
err := superviseDisplayBinary(binaryPath)
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to supervise display binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() {
<-appCtx.Done()
displayLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
displayLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
displayLogger.Info().Int("pid", cmd.Process.Pid).Msg("kvm_display binary started")
return nil
}

View File

@@ -24,23 +24,119 @@ func (w *nativeOutput) Write(p []byte) (n int, err error) {
return len(p), nil
}
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary in the background
func startVideoBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary inthe background
cmd := exec.Command(binaryPath)
nativeOutputLock := sync.Mutex{}
nativeStdout := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stdout"),
vidoeOutputLock := sync.Mutex{}
videoStdout := &nativeOutput{
mu: &vidoeOutputLock,
logger: videoLogger.Info().Str("pipe", "stdout"),
}
nativeStderr := &nativeOutput{
mu: &nativeOutputLock,
logger: nativeLogger.Info().Str("pipe", "stderr"),
videoStderr := &nativeOutput{
mu: &vidoeOutputLock,
logger: videoLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = nativeStdout
cmd.Stderr = nativeStderr
cmd.Stdout = videoStdout
cmd.Stderr = videoStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return cmd, nil
}
func startAudioBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary inthe background
cmd := exec.Command(binaryPath)
audioOutputLock := sync.Mutex{}
audioStdout := &nativeOutput{
mu: &audioOutputLock,
logger: audioLogger.Info().Str("pipe", "stdout"),
}
audioStderr := &nativeOutput{
mu: &audioOutputLock,
logger: audioLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = audioStdout
cmd.Stderr = audioStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return cmd, nil
}
func startVpnBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary inthe background
cmd := exec.Command(binaryPath)
vpnOutputLock := sync.Mutex{}
vpnStdout := &nativeOutput{
mu: &vpnOutputLock,
logger: audioLogger.Info().Str("pipe", "stdout"),
}
vpnStderr := &nativeOutput{
mu: &vpnOutputLock,
logger: audioLogger.Info().Str("pipe", "stderr"),
}
// Redirect stdout and stderr to the current process
cmd.Stdout = vpnStdout
cmd.Stderr = vpnStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
// Start the command
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start binary: %w", err)
}
return cmd, nil
}
func startDisplayBinary(binaryPath string) (*exec.Cmd, error) {
// Run the binary inthe background
cmd := exec.Command(binaryPath)
displayOutputLock := sync.Mutex{}
displayStdout := &nativeOutput{
mu: &displayOutputLock,
logger: displayLogger.Info().Str("pipe", "stdout"),
}
displayStderr := &nativeOutput{
mu: &displayOutputLock,
logger: displayLogger.Info().Str("pipe", "stderr"),
}
//// Redirect stdout and stderr to the current process
cmd.Stdout = displayStdout
cmd.Stderr = displayStderr
// Set the process group ID so we can kill the process and its children when this process exits
cmd.SysProcAttr = &syscall.SysProcAttr{

View File

@@ -7,6 +7,18 @@ import (
"os/exec"
)
func startNativeBinary(binaryPath string) (*exec.Cmd, error) {
func startVideoBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
}
func startAudioBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
}
func startVpnBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
}
func startDisplayBinary(binaryPath string) (*exec.Cmd, error) {
return nil, fmt.Errorf("not supported")
}

279
native_vpn.go Normal file
View File

@@ -0,0 +1,279 @@
package kvm
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"sync"
"time"
)
var (
vpnCmd *exec.Cmd
vpnCmdLock = &sync.Mutex{}
)
var vpnSocketConn net.Conn
var vpnOngoingRequests = make(map[int32]chan *CtrlResponse)
var vpnLock = &sync.Mutex{}
func CallVpnCtrlAction(action string, params map[string]interface{}) (*CtrlResponse, error) {
vpnLock.Lock()
defer vpnLock.Unlock()
ctrlAction := CtrlAction{
Action: action,
Seq: seq,
Params: params,
}
responseChan := make(chan *CtrlResponse)
vpnOngoingRequests[seq] = responseChan
seq++
jsonData, err := json.Marshal(ctrlAction)
if err != nil {
delete(vpnOngoingRequests, ctrlAction.Seq)
return nil, fmt.Errorf("error marshaling ctrl action: %w", err)
}
scopedLogger := vpnLogger.With().
Str("action", ctrlAction.Action).
Interface("params", ctrlAction.Params).Logger()
scopedLogger.Debug().Msg("sending vpn ctrl action")
err = WriteVpnCtrlMessage(jsonData)
if err != nil {
delete(vpnOngoingRequests, ctrlAction.Seq)
return nil, ErrorfL(&scopedLogger, "error writing vpn ctrl message", err)
}
select {
case response := <-responseChan:
delete(vpnOngoingRequests, seq)
if response.Error != "" {
return nil, ErrorfL(
&scopedLogger,
"error vpn response: %s",
errors.New(response.Error),
)
}
return response, nil
case <-time.After(10 * time.Second):
close(responseChan)
delete(vpnOngoingRequests, seq)
return nil, ErrorfL(&scopedLogger, "timeout waiting for response", nil)
}
}
func WriteVpnCtrlMessage(message []byte) error {
if vpnSocketConn == nil {
return fmt.Errorf("vpn socket not conn ected")
}
_, err := vpnSocketConn.Write(message)
return err
}
var vpnCtrlSocketListener net.Listener
var vpnCtrlClientConnected = make(chan struct{})
func waitVpnCtrlClientConnected() {
<-vpnCtrlClientConnected
}
func StartVpnSocketServer(socketPath string, handleClient func(net.Conn), isCtrl bool) net.Listener {
scopedLogger := vpnLogger.With().
Str("socket_path", socketPath).
Logger()
// Remove the socket file if it already exists
if _, err := os.Stat(socketPath); err == nil {
if err := os.Remove(socketPath); err != nil {
scopedLogger.Warn().Err(err).Msg("failed to remove existing socket file")
os.Exit(1)
}
}
listener, err := net.Listen("unixpacket", socketPath)
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to start server")
os.Exit(1)
}
scopedLogger.Info().Msg("server listening")
go func() {
for {
conn, err := listener.Accept()
if err != nil {
scopedLogger.Warn().Err(err).Msg("failed to accept socket")
continue
}
if isCtrl {
// check if the channel is closed
select {
case <-vpnCtrlClientConnected:
scopedLogger.Debug().Msg("vpn ctrl client reconnected")
default:
close(vpnCtrlClientConnected)
scopedLogger.Debug().Msg("first vpn ctrl socket client connected")
}
}
//conn.Write([]byte("[handleVpnCtrlClient]vpn sock test"))
go handleClient(conn)
}
}()
return listener
}
func StartVpnCtrlSocketServer() {
vpnCtrlSocketListener = StartVpnSocketServer("/var/run/kvm_vpn.sock", handleVpnCtrlClient, true)
vpnLogger.Debug().Msg("vpn ctrl sock started")
}
func handleVpnCtrlClient(conn net.Conn) {
defer conn.Close()
scopedLogger := vpnLogger.With().
Str("addr", conn.RemoteAddr().String()).
Str("type", "vpn_ctrl").
Logger()
scopedLogger.Info().Msg("vpn socket client connected")
if vpnSocketConn != nil {
scopedLogger.Debug().Msg("closing existing vpn socket connection")
vpnSocketConn.Close()
}
vpnSocketConn = conn
readBuf := make([]byte, 4096)
for {
n, err := conn.Read(readBuf)
if err != nil {
scopedLogger.Warn().Err(err).Msg("error reading from vpn sock")
break
}
readMsg := string(readBuf[:n])
vpnResp := CtrlResponse{}
err = json.Unmarshal([]byte(readMsg), &vpnResp)
if err != nil {
scopedLogger.Warn().Err(err).Str("data", readMsg).Msg("error parsing vpn sock msg")
continue
}
scopedLogger.Trace().Interface("data", vpnResp).Msg("vpn sock msg")
if vpnResp.Seq != 0 {
responseChan, ok := vpnOngoingRequests[vpnResp.Seq]
if ok {
responseChan <- &vpnResp
}
}
switch vpnResp.Event {
case "vpn_display_update":
HandleVpnDisplayUpdateMessage(vpnResp)
}
}
scopedLogger.Debug().Msg("vpn sock disconnected")
}
func startVpnBinaryWithLock(binaryPath string) (*exec.Cmd, error) {
vpnCmdLock.Lock()
defer vpnCmdLock.Unlock()
cmd, err := startVpnBinary(binaryPath)
if err != nil {
return nil, err
}
vpnCmd = cmd
return cmd, nil
}
func restartVpnBinary(binaryPath string) error {
time.Sleep(10 * time.Second)
// restart the binary
vpnLogger.Info().Msg("restarting vpn_video binary")
cmd, err := startVpnBinary(binaryPath)
if err != nil {
vpnLogger.Warn().Err(err).Msg("failed to restart binary")
}
vpnCmd = cmd
return err
}
func superviseVpnBinary(binaryPath string) error {
vpnCmdLock.Lock()
defer vpnCmdLock.Unlock()
if vpnCmd == nil || vpnCmd.Process == nil {
return restartVpnBinary(binaryPath)
}
err := vpnCmd.Wait()
if err == nil {
vpnLogger.Info().Err(err).Msg("kvm_vpn binary exited with no error")
} else if exiterr, ok := err.(*exec.ExitError); ok {
vpnLogger.Warn().Int("exit_code", exiterr.ExitCode()).Msg("kvm_vpn binary exited with error")
} else {
vpnLogger.Warn().Err(err).Msg("kvm_vpn binary exited with unknown error")
}
return restartVpnBinary(binaryPath)
}
func ExtractAndRunVpnBin() error {
binaryPath := "/userdata/picokvm/bin/kvm_vpn"
// Make the binary executable
if err := os.Chmod(binaryPath, 0755); err != nil {
return fmt.Errorf("failed to make binary executable: %w", err)
}
// Run the binary in the background
cmd, err := startVpnBinaryWithLock(binaryPath)
if err != nil {
return fmt.Errorf("failed to start binary: %w", err)
}
// check if the binary is still running every 10 seconds
go func() {
for {
select {
case <-appCtx.Done():
vpnLogger.Info().Msg("stopping vpn binary supervisor")
return
default:
err := superviseVpnBinary(binaryPath)
if err != nil {
vpnLogger.Warn().Err(err).Msg("failed to supervise vpn binary")
time.Sleep(1 * time.Second) // Add a short delay to prevent rapid successive calls
}
}
}
}()
go func() {
<-appCtx.Done()
vpnLogger.Info().Int("pid", cmd.Process.Pid).Msg("killing process")
err := cmd.Process.Kill()
if err != nil {
vpnLogger.Warn().Err(err).Msg("failed to kill process")
return
}
}()
vpnLogger.Info().Int("pid", cmd.Process.Pid).Msg("kvm_vpn binary started")
return nil
}

View File

@@ -3,8 +3,10 @@ package kvm
import (
"fmt"
"github.com/jetkvm/kvm/internal/network"
"github.com/jetkvm/kvm/internal/udhcpc"
"kvm/internal/network"
"kvm/internal/udhcpc"
"github.com/guregu/null/v6"
)
const (
@@ -79,7 +81,10 @@ func rpcGetNetworkState() network.RpcNetworkState {
}
func rpcGetNetworkSettings() network.RpcNetworkSettings {
return networkState.RpcGetNetworkSettings()
rpcSettings := networkState.RpcGetNetworkSettings()
hostname := GetHostname()
rpcSettings.Hostname = null.NewString(hostname, hostname != "")
return rpcSettings
}
func rpcSetNetworkSettings(settings network.RpcNetworkSettings) (*network.RpcNetworkSettings, error) {

166
ota.go
View File

@@ -10,7 +10,6 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"strings"
@@ -35,10 +34,18 @@ type LocalMetadata struct {
SystemVersion string `json:"systemVersion"`
}
type RemoteMetadata struct {
AppVersion string `json:"appVersion"`
AppUrl string `json:"appUrl"`
AppHash string `json:"appHash"`
SystemUrl string `json:"systemUrl"`
SystemVersion string `json:"systemVersion"`
}
// UpdateStatus represents the current update status
type UpdateStatus struct {
Local *LocalMetadata `json:"local"`
Remote *UpdateMetadata `json:"remote"`
Remote *RemoteMetadata `json:"remote"`
SystemUpdateAvailable bool `json:"systemUpdateAvailable"`
AppUpdateAvailable bool `json:"appUpdateAvailable"`
@@ -46,9 +53,20 @@ type UpdateStatus struct {
Error string `json:"error,omitempty"`
}
const UpdateMetadataUrl = "https://api.jetkvm.com/releases"
var UpdateMetadataUrls = []string{
"https://api.github.com/repos/LuckfoxTECH/PicoKVM/releases/latest",
"https://api.github.com/repos/LuckfoxTECH/kvm/releases/latest",
"https://api.github.com/repos/luckfox-eng29/kvm/releases/latest",
}
var builtAppVersion = "0.1.0+dev"
var builtAppVersion = "0.0.1+dev"
var updateSource = "github"
func rpcSetUpdateSource(source string) error {
updateSource = source
return nil
}
func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Version, err error) {
appVersion, err = semver.NewVersion(builtAppVersion)
@@ -69,50 +87,81 @@ func GetLocalVersion() (systemVersion *semver.Version, appVersion *semver.Versio
return systemVersion, appVersion, nil
}
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*UpdateMetadata, error) {
metadata := &UpdateMetadata{}
func fetchUpdateMetadata(ctx context.Context, deviceId string, includePreRelease bool) (*RemoteMetadata, error) {
//cmd := exec.Command("curl", "-s", UpdateMetadataUrl)
//output, err := cmd.Output()
//if err != nil {
// return nil, fmt.Errorf("failed to fetch GitHub releases: %w", err)
//}
//_ = cmd.Process.Release()
var lastErr error
updateUrl, err := url.Parse(UpdateMetadataUrl)
if err != nil {
return nil, fmt.Errorf("error parsing update metadata URL: %w", err)
for _, url := range UpdateMetadataUrls {
resp, err := http.Get(url)
if err != nil {
lastErr = fmt.Errorf("failed to fetch GitHub releases from %s: %w", url, err)
continue
}
defer resp.Body.Close()
output, err := io.ReadAll(resp.Body)
if err != nil {
lastErr = fmt.Errorf("failed to read GitHub releases from %s: %w", url, err)
continue
}
if strings.Contains(string(output), "404") {
lastErr = fmt.Errorf("failed to find release from %s: %w", url, err)
continue
}
var release struct {
TagName string `json:"tag_name"`
Assets []struct {
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
} `json:"assets"`
}
if err := json.Unmarshal(output, &release); err != nil {
lastErr = fmt.Errorf("failed to parse GitHub releases JSON from %s: %w", url, err)
continue
}
appVersionRemote := release.TagName
var updateUrl string
var appSha256 string
if len(release.Assets) > 0 {
updateUrl = release.Assets[0].BrowserDownloadURL
appSha256 = release.Assets[0].Digest
}
// add sha256 prefix
if strings.HasPrefix(appSha256, "sha256:") {
// delete "sha256:"
appSha256 = appSha256[7:]
}
remoteMetadata := &RemoteMetadata{
AppUrl: updateUrl,
AppVersion: appVersionRemote,
AppHash: appSha256,
SystemUrl: "",
SystemVersion: "0.1.0",
}
return remoteMetadata, nil
}
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)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("error sending request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
err = json.NewDecoder(resp.Body).Decode(metadata)
if err != nil {
return nil, fmt.Errorf("error decoding response: %w", err)
}
return metadata, nil
return nil, lastErr
}
func downloadFile(ctx context.Context, path string, url string, downloadProgress *float32) error {
if _, err := os.Stat(path); err == nil {
if err := os.Remove(path); err != nil {
return fmt.Errorf("error removing existing file: %w", err)
}
}
//if _, 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 {
@@ -127,6 +176,10 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
}
defer file.Close()
if updateSource == "gitee" {
url = strings.Replace(url, "github", "gitee", 1)
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
@@ -198,6 +251,15 @@ func downloadFile(ctx context.Context, path string, url string, downloadProgress
return fmt.Errorf("error clearing filesystem caches: %w", err)
}
// without check
//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
}
@@ -345,7 +407,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("remote", remote.AppVersion).
Msg("App update available")
err := downloadFile(ctx, "/userdata/jetkvm/jetkvm_app.update", remote.AppUrl, &otaState.AppDownloadProgress)
err := downloadFile(ctx, "/userdata/picokvm/bin/kvm_app", 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")
@@ -358,7 +420,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
triggerOTAStateUpdate()
err = verifyFile(
"/userdata/jetkvm/jetkvm_app.update",
"/userdata/picokvm/bin/kvm_app",
remote.AppHash,
&otaState.AppVerificationProgress,
&scopedLogger,
@@ -388,7 +450,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
Str("remote", remote.SystemVersion).
Msg("System update available")
err := downloadFile(ctx, "/userdata/jetkvm/update_system.tar", remote.SystemUrl, &otaState.SystemDownloadProgress)
err := downloadFile(ctx, "/userdata/picokvm/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")
@@ -400,18 +462,6 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
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 err
}
scopedLogger.Info().Msg("System update downloaded")
verifyFinished := time.Now()
otaState.SystemVerifiedAt = &verifyFinished
@@ -419,7 +469,7 @@ func TryUpdate(ctx context.Context, deviceId string, includePreRelease bool) err
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")
cmd := exec.Command("rk_ota", "--misc=update", "--tar_path=/userdata/picokvm/update_system.tar", "--save_dir=/userdata/picokvm/ota_save", "--partition=all")
var b bytes.Buffer
cmd.Stdout = &b
cmd.Stderr = &b

View File

@@ -9,5 +9,5 @@ import (
func initPrometheus() {
// A Prometheus metrics endpoint.
version.Version = builtAppVersion
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
prometheus.MustRegister(versioncollector.NewCollector("kvm"))
}

View File

@@ -1,46 +0,0 @@
#!/usr/bin/env bash
# Check if a commit message was provided
if [ -z "$1" ]; then
echo "Usage: $0 \"Your commit message here\""
exit 1
fi
COMMIT_MESSAGE="$1"
# Ensure you're on the main branch
git checkout main
# Add 'public' remote if it doesn't exist
if ! git remote | grep -q '^public$'; then
git remote add public https://github.com/jetkvm/kvm.git
fi
# Fetch the latest from the public repository
git fetch public || true
# Create a temporary branch for the release
git checkout -b release-temp
# If public/main exists, reset to it; else, use the root commit
if git ls-remote --heads public main | grep -q 'refs/heads/main'; then
git reset --soft public/main
else
git reset --soft "$(git rev-list --max-parents=0 HEAD)"
fi
# Merge changes from main
git merge --squash main
# Commit all changes as a single release commit
git commit -m "$COMMIT_MESSAGE"
# Force push the squashed commit to the public repository
git push --force public release-temp:main
# Switch back to main and delete the temporary branch
git checkout main
git branch -D release-temp
# Remove the public remote
git remote remove public

View File

@@ -4,5 +4,4 @@ import (
"embed"
)
//go:embed jetkvm_native jetkvm_native.sha256 netboot.xyz-multiarch.iso
var ResourceFS embed.FS

226
serial.go
View File

@@ -1,215 +1,16 @@
package kvm
import (
"bufio"
"io"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
"go.bug.st/serial"
)
const serialPortPath = "/dev/ttyS3"
const serialPortPath = "/dev/ttyS0"
var port serial.Port
func mountATXControl() error {
_ = port.SetMode(defaultMode)
go runATXControl()
return nil
}
func unmountATXControl() error {
_ = reopenSerialPort()
return nil
}
var (
ledHDDState bool
ledPWRState bool
btnRSTState bool
btnPWRState bool
)
func runATXControl() {
scopedLogger := serialLogger.With().Str("service", "atx_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Each line should be 4 binary digits + newline
if len(line) != 5 {
scopedLogger.Warn().Int("length", len(line)).Msg("Invalid line length")
continue
}
// Parse new states
newLedHDDState := line[0] == '0'
newLedPWRState := line[1] == '0'
newBtnRSTState := line[2] == '1'
newBtnPWRState := line[3] == '1'
if currentSession != nil {
writeJSONRPCEvent("atxState", ATXState{
Power: newLedPWRState,
HDD: newLedHDDState,
}, currentSession)
}
if newLedHDDState != ledHDDState ||
newLedPWRState != ledPWRState ||
newBtnRSTState != btnRSTState ||
newBtnPWRState != btnPWRState {
scopedLogger.Debug().
Bool("hdd", newLedHDDState).
Bool("pwr", newLedPWRState).
Bool("rst", newBtnRSTState).
Bool("pwr", newBtnPWRState).
Msg("Status changed")
// Update states
ledHDDState = newLedHDDState
ledPWRState = newLedPWRState
btnRSTState = newBtnRSTState
btnPWRState = newBtnPWRState
}
}
}
func pressATXPowerButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_PWR_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_PWR_OFF\n"))
if err != nil {
return err
}
return nil
}
func pressATXResetButton(duration time.Duration) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
_, err = port.Write([]byte("BTN_RST_ON\n"))
if err != nil {
return err
}
time.Sleep(duration)
_, err = port.Write([]byte("BTN_RST_OFF\n"))
if err != nil {
return err
}
return nil
}
func mountDCControl() error {
_ = port.SetMode(defaultMode)
go runDCControl()
return nil
}
func unmountDCControl() error {
_ = reopenSerialPort()
return nil
}
var dcState DCPowerState
func runDCControl() {
scopedLogger := serialLogger.With().Str("service", "dc_control").Logger()
reader := bufio.NewReader(port)
for {
line, err := reader.ReadString('\n')
if err != nil {
scopedLogger.Warn().Err(err).Msg("Error reading from serial port")
return
}
// Split the line by semicolon
parts := strings.Split(strings.TrimSpace(line), ";")
if len(parts) != 4 {
scopedLogger.Warn().Str("line", line).Msg("Invalid line")
continue
}
// Parse new states
powerState, err := strconv.Atoi(parts[0])
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power state")
continue
}
dcState.IsOn = powerState == 1
milliVolts, err := strconv.ParseFloat(parts[1], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid voltage")
continue
}
volts := milliVolts / 1000 // Convert mV to V
milliAmps, err := strconv.ParseFloat(parts[2], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid current")
continue
}
amps := milliAmps / 1000 // Convert mA to A
milliWatts, err := strconv.ParseFloat(parts[3], 64)
if err != nil {
scopedLogger.Warn().Err(err).Msg("Invalid power")
continue
}
watts := milliWatts / 1000 // Convert mW to W
dcState.Voltage = volts
dcState.Current = amps
dcState.Power = watts
if currentSession != nil {
writeJSONRPCEvent("dcState", dcState, currentSession)
}
}
}
func setDCPowerState(on bool) error {
_, err := port.Write([]byte("\n"))
if err != nil {
return err
}
command := "PWR_OFF\n"
if on {
command = "PWR_ON\n"
}
_, err = port.Write([]byte(command))
if err != nil {
return err
}
return nil
}
var defaultMode = &serial.Mode{
BaudRate: 115200,
DataBits: 8,
@@ -219,17 +20,12 @@ var defaultMode = &serial.Mode{
func initSerialPort() {
_ = reopenSerialPort()
switch config.ActiveExtension {
case "atx-power":
_ = mountATXControl()
case "dc-power":
_ = mountDCControl()
}
}
func reopenSerialPort() error {
if port != nil {
port.Close()
port = nil
}
var err error
port, err = serial.Open(serialPortPath, defaultMode)
@@ -239,7 +35,12 @@ func reopenSerialPort() error {
Str("path", serialPortPath).
Interface("mode", defaultMode).
Msg("Error opening serial port")
return err
}
serialLogger.Info().
Str("path", serialPortPath).
Interface("mode", defaultMode).
Msg("Serial port opened successfully")
return nil
}
@@ -248,6 +49,12 @@ func handleSerialChannel(d *webrtc.DataChannel) {
Uint16("data_channel_id", *d.ID()).Logger()
d.OnOpen(func() {
if err := reopenSerialPort(); err != nil {
scopedLogger.Error().Err(err).Msg("Failed to open serial port")
d.Close()
return
}
go func() {
buf := make([]byte, 1024)
for {
@@ -258,8 +65,7 @@ func handleSerialChannel(d *webrtc.DataChannel) {
}
break
}
err = d.Send(buf[:n])
if err != nil {
if err := d.Send(buf[:n]); err != nil {
scopedLogger.Warn().Err(err).Msg("Failed to send serial output")
break
}
@@ -282,6 +88,10 @@ func handleSerialChannel(d *webrtc.DataChannel) {
})
d.OnClose(func() {
if port != nil {
port.Close()
port = nil
}
scopedLogger.Info().Msg("Serial channel closed")
})
}

View File

@@ -4,7 +4,7 @@ import (
"strconv"
"time"
"github.com/jetkvm/kvm/internal/timesync"
"kvm/internal/timesync"
)
var (
@@ -51,3 +51,11 @@ func initTimeSync() {
},
})
}
func initTimeZone() {
LoadConfig()
_, err := CallDisplayCtrlAction("set_timezone", map[string]interface{}{"timezone": config.TimeZone})
if err != nil {
timesyncLogger.Error().Err(err).Msg("failed to set time zone")
}
}

View File

@@ -1,4 +0,0 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://api.jetkvm.com

View File

@@ -1,4 +0,0 @@
# No need for VITE_CLOUD_APP it's only needed for the device build
# We use this for all the cloud API requests from the browser
VITE_CLOUD_API=https://staging-api.jetkvm.com

View File

@@ -2,7 +2,7 @@
# Check if an IP address was provided as an argument
if [ -z "$1" ]; then
echo "Usage: $0 <JetKVM IP Address>"
echo "Usage: $0 <KVM IP Address>"
exit 1
fi
@@ -10,11 +10,11 @@ ip_address="$1"
# Print header
echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │"
echo "│ KVM Development Setup │"
echo "└──────────────────────────────────────┘"
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
echo "Starting development server with KVM device at: $ip_address"
# Check if pwd is the current directory of the script
if [ "$(pwd)" != "$(dirname "$0")" ]; then
@@ -24,6 +24,6 @@ fi
sleep 1
JETKVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device
KVM_PROXY_URL="ws://$ip_address" npx vite dev --mode=device
popd > /dev/null

View File

@@ -25,9 +25,9 @@
type="font/woff2"
crossorigin
/>
<title>JetKVM</title>
<title>KVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
<link rel="icon" href="/logo-luckfox.png" />
<script>
// Initial theme setup
document.documentElement.classList.toggle(

10
ui/package-lock.json generated
View File

@@ -24,6 +24,7 @@
"focus-trap-react": "^11.0.3",
"framer-motion": "^12.11.4",
"lodash.throttle": "^4.1.1",
"lucide-react": "^0.522.0",
"mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0",
"react-animate-height": "^3.2.3",
@@ -5062,6 +5063,15 @@
"loose-envify": "cli.js"
}
},
"node_modules/lucide-react": {
"version": "0.522.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.522.0.tgz",
"integrity": "sha512-jnJbw974yZ7rQHHEFKJOlWAefG3ATSCZHANZxIdx8Rk/16siuwjgA4fBULpXEAWx/RlTs3FzmKW/udWUuO0aRw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",

View File

@@ -35,6 +35,7 @@
"focus-trap-react": "^11.0.3",
"framer-motion": "^12.11.4",
"lodash.throttle": "^4.1.1",
"lucide-react": "^0.522.0",
"mini-svg-data-uri": "^1.4.4",
"react": "^19.1.0",
"react-animate-height": "^3.2.3",

BIN
ui/public/logo-luckfox.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -2,7 +2,7 @@ import { MdOutlineContentPasteGo } from "react-icons/md";
import { LuCable, LuHardDrive, LuMaximize, LuSettings, LuSignal } from "react-icons/lu";
import { FaKeyboard } from "react-icons/fa6";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Fragment, useCallback, useRef } from "react";
import { Fragment, useCallback, useRef, useState, useEffect } from "react";
import { CommandLineIcon } from "@heroicons/react/20/solid";
import { Button } from "@components/Button";
@@ -19,6 +19,8 @@ import WakeOnLanModal from "@/components/popovers/WakeOnLan/Index";
import MountPopopover from "@/components/popovers/MountPopover";
import ExtensionPopover from "@/components/popovers/ExtensionPopover";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import VolumeControl from "./VolumeControl";
import { useJsonRpc } from "@/hooks/useJsonRpc";
export default function Actionbar({
requestFullscreen,
@@ -38,6 +40,11 @@ export default function Actionbar({
);
const developerMode = useSettingsStore(state => state.developerMode);
// Audio related
const [audioMode, setAudioMode] = useState("disabled");
const [send] = useJsonRpc();
// This is the only way to get a reliable state change for the popover
// at time of writing this there is no mount, or unmount event for the popover
const isOpen = useRef<boolean>(false);
@@ -55,7 +62,14 @@ export default function Actionbar({
},
[setDisableFocusTrap],
);
useEffect(() => {
send("getAudioMode", {}, resp => {
if ("error" in resp) return;
setAudioMode(String(resp.result));
});
}, [send]);
return (
<Container className="border-b border-b-slate-800/20 bg-white dark:border-b-slate-300/20 dark:bg-slate-900">
<div
@@ -64,15 +78,13 @@ export default function Actionbar({
className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 py-1.5"
>
<div className="relative flex flex-wrap items-center gap-x-2 gap-y-2">
{developerMode && (
<Button
size="XS"
theme="light"
text="Web Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
)}
<Button
size="XS"
theme="light"
text="Terminal"
LeadingIcon={({ className }) => <CommandLineIcon className={className} />}
onClick={() => setTerminalType(terminalType === "kvm" ? "none" : "kvm")}
/>
<Popover>
<PopoverButton as={Fragment}>
<Button
@@ -152,7 +164,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Wake on LAN"
text="Wake"
onClick={() => {
setDisableFocusTrap(true);
}}
@@ -207,6 +219,16 @@ export default function Actionbar({
onClick={() => setVirtualKeyboard(!virtualKeyboard)}
/>
</div>
{(audioMode !== "disabled") && (
<div className="hidden lg:block">
<VolumeControl
size="XS"
theme="light"
/>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
@@ -250,7 +272,7 @@ export default function Actionbar({
<Button
size="XS"
theme="light"
text="Connection Stats"
text="Connection State"
LeadingIcon={({ className }) => (
<LuSignal
className={cx(className, "mb-0.5 text-green-500")}

View File

@@ -7,12 +7,10 @@ import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config";
interface AuthLayoutProps {
title: string;
description: string;
action: string;
cta: string;
ctaHref: string;
showCounter?: boolean;
@@ -21,7 +19,6 @@ interface AuthLayoutProps {
export default function AuthLayout({
title,
description,
action,
cta,
ctaHref,
showCounter,
@@ -60,35 +57,6 @@ export default function AuthLayout({
</h1>
<p className="text-slate-600 dark:text-slate-400">{description}</p>
</div>
<Fieldset className="space-y-12">
<div className="mx-auto max-w-sm space-y-4">
<form action={`${CLOUD_API}/oidc/google`} method="POST">
{/*This could be the KVM ID*/}
{deviceId ? (
<input type="hidden" name="deviceId" value={deviceId} />
) : null}
{returnTo ? (
<input type="hidden" name="returnTo" value={returnTo} />
) : null}
<Button
size="LG"
theme="light"
fullWidth
text={`${action}`}
LeadingIcon={GoogleIcon}
textAlign="center"
type="submit"
loading={
(navigation.state === "submitting" ||
navigation.state === "loading") &&
navigation.formMethod?.toLowerCase() === "post" &&
navigation.formAction?.includes("auth/google")
}
/>
</form>
</div>
</Fieldset>
</div>
</div>
</Container>

View File

@@ -6,12 +6,12 @@ import { LuMonitorSmartphone } from "react-icons/lu";
import Container from "@/components/Container";
import Card from "@/components/Card";
import { useHidStore, useRTCStore, useUserStore } from "@/hooks/stores";
import LogoBlueIcon from "@/assets/logo-blue.svg";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import { useHidStore, useRTCStore, useUserStore, useVpnStore } from "@/hooks/stores";
import LogoLuckfox from "@/assets/logo-luckfox.png";
import USBStateStatus from "@components/USBStateStatus";
import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import VpnConnectionStatusCard from "@components/VpnConnectionStatusCard";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
import { isOnDevice } from "../main";
@@ -36,10 +36,12 @@ export default function DashboardNavbar({
kvmName,
}: NavbarProps) {
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
const setUser = useUserStore(state => state.setUser);
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice ? `${DEVICE_API}/auth/logout` : `${CLOUD_API}/logout`;
const logoutUrl = `${DEVICE_API}/auth/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;
@@ -60,8 +62,18 @@ export default function DashboardNavbar({
<div className="flex h-14 items-center justify-between">
<div className="flex shrink-0 items-center gap-x-8">
<div className="inline-block shrink-0">
<img src={LogoBlueIcon} alt="" className="h-[24px] dark:hidden" />
<img src={LogoWhiteIcon} alt="" className="hidden h-[24px] dark:block" />
<div className="flex items-center gap-4">
<a
href="https://wiki.luckfox.com/Luckfox-Pico/Download"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-4"
>
<img src={LogoLuckfox} alt="" className="h-[24px] dark:hidden" />
<img src={LogoLuckfox} alt="" className="hidden h-[24px] dark:block" />
<b className="navbar__title text--truncate dark:text-white">LUCKFOX</b>
</a>
</div>
</div>
<div className="flex gap-x-2">
@@ -96,6 +108,18 @@ export default function DashboardNavbar({
peerConnectionState={peerConnectionState}
/>
</div>
<div className="hidden w-[159px] md:block">
<VpnConnectionStatusCard
state={peerConnectionState === "connected" ? tailScaleConnectionState : "disconnected"}
title="TailScale"
/>
</div>
<div className="hidden w-[159px] md:block">
<VpnConnectionStatusCard
state={peerConnectionState === "connected" ? zeroTierConnectionState : "disconnected"}
title="ZeroTier"
/>
</div>
</>
)}
{isLoggedIn ? (

View File

@@ -93,7 +93,7 @@ const InputFieldWithLabel = forwardRef<HTMLInputElement, InputFieldWithLabelProp
InputFieldWithLabel.displayName = "InputFieldWithLabel";
export default InputField;
export { InputFieldWithLabel };
export { InputField, InputFieldWithLabel };
export function FieldError({ error }: { error: string | React.ReactNode }) {
return <div className="mt-[6px] text-[13px] leading-normal text-red-500">{error}</div>;

View File

@@ -59,7 +59,7 @@ export default function PeerConnectionStatusCard({
return (
<StatusCard
title={title || "JetKVM Device"}
title={title || "KVM Device"}
status={PeerConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>

View File

@@ -88,6 +88,7 @@ export const SelectMenuBasic = React.forwardRef<HTMLSelectElement, SelectMenuPro
// Disabled
"disabled:pointer-events-none disabled:select-none disabled:bg-slate-50 disabled:text-slate-500/80 dark:disabled:bg-slate-800 dark:disabled:text-slate-400/80",
)}
value={value}
id={id}

View File

@@ -2,8 +2,7 @@ import { Link } from "react-router-dom";
import React from "react";
import Container from "@/components/Container";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import LogoLuckfox from "@/assets/logo-luckfox.png";
interface Props { logoHref?: string; actionElement?: React.ReactNode }
@@ -14,8 +13,8 @@ export default function SimpleNavbar({ logoHref, actionElement }: Props) {
<div className="pb-4 my-4 border-b border-b-800/20 isolate dark:border-b-slate-300/20">
<div className="flex items-center justify-between">
<Link to={logoHref ?? "/"} className="hidden h-[26px] dark:inline-block">
<img src={LogoWhiteIcon} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoBlueIcon} alt="" className="h-[26px] dark:hidden" />
<img src={LogoLuckfox} alt="" className="h-[26px] dark:block hidden" />
<img src={LogoLuckfox} alt="" className="h-[26px] dark:hidden" />
</Link>
<div>{actionElement}</div>
</div>

View File

@@ -1,6 +1,6 @@
import "react-simple-keyboard/build/css/index.css";
import { ChevronDownIcon } from "@heroicons/react/16/solid";
import { useEffect } from "react";
import { LuPin, LuPinOff } from 'react-icons/lu'
import { useEffect, useRef, useState } from "react";
import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
@@ -71,6 +71,25 @@ function Terminal({
const { instance, ref } = useXTerm({ options: TERMINAL_CONFIG });
const [pinned, setPinned] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!enableTerminal) return;
const handleClickOutside = (e: MouseEvent) => {
if (pinned) return;
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setTerminalType("none");
setDisableKeyboardFocusTrap(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [enableTerminal, pinned, setTerminalType, setDisableKeyboardFocusTrap]);
useEffect(() => {
setTimeout(() => {
setDisableKeyboardFocusTrap(enableTerminal);
@@ -155,13 +174,18 @@ function Terminal({
// Handle resize event
window.addEventListener("resize", handleResize);
if (enableTerminal) {
setTimeout(handleResize, 50);
}
return () => {
window.removeEventListener("resize", handleResize);
};
}, [ref, instance]);
}, [ref, instance, enableTerminal]);
return (
<div
ref={containerRef}
onKeyDown={e => e.stopPropagation()}
onKeyUp={e => e.stopPropagation()}
>
@@ -190,9 +214,8 @@ function Terminal({
<Button
size="XS"
theme="light"
text="Hide"
LeadingIcon={ChevronDownIcon}
onClick={() => setTerminalType("none")}
LeadingIcon={pinned ? LuPinOff : LuPin}
onClick={() => setPinned(p => !p)}
/>
</div>
</div>

View File

@@ -0,0 +1,39 @@
import Modal from "@/components/Modal";
interface UploadDialogProps {
open: boolean;
title: string;
description: React.ReactNode
children?: React.ReactNode;
}
export function UploadDialog({
open,
title,
description,
children,
}: UploadDialogProps) {
return (
<Modal open={open} onClose={ () => {} }>
<div className="mx-auto max-w-xl px-4 transition-all duration-300 ease-in-out">
<div className="pointer-events-auto relative w-full overflow-hidden rounded-lg bg-white p-6 text-left align-middle shadow-xl transition-all dark:bg-slate-800">
<div className="space-y-4">
<div className="sm:flex sm:items-start">
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
<h2 className="text-lg leading-tight font-bold text-black dark:text-white">
{title}
</h2>
<div className="text-sm leading-snug text-slate-600 dark:text-slate-400">
{description}
</div>
</div>
</div>
{children}
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -34,7 +34,7 @@ export interface USBConfig {
const usbConfigs = [
{
label: "JetKVM Default",
label: "KVM Default",
value: "USB Emulation Device",
},
{
@@ -65,7 +65,7 @@ export function UsbInfoSetting() {
vendor_id: "0x1d6b",
product_id: "0x0104",
serial_number: deviceId,
manufacturer: "JetKVM",
manufacturer: "KVM",
product: "USB Emulation Device",
},
"Logitech USB Input Device": {

View File

@@ -128,7 +128,7 @@ export function ConnectionFailedOverlay({
</div>
<div className="flex items-center gap-x-2">
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
to={"https://wiki.luckfox.com/intro"}
theme="primary"
text="Troubleshooting Guide"
TrailingIcon={ArrowRightIcon}
@@ -249,7 +249,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
to={"https://wiki.luckfox.com/intro"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}
@@ -291,7 +291,7 @@ export function HDMIErrorOverlay({ show, hdmiState }: HDMIErrorOverlayProps) {
</div>
<div>
<LinkButton
to={"https://jetkvm.com/docs/getting-started/troubleshooting"}
to={"https://wiki.luckfox.com/intro"}
theme="light"
text="Learn more"
TrailingIcon={ArrowRightIcon}

View File

@@ -0,0 +1,210 @@
import React, { useEffect, useState } from "react";
import { LuVolume2, LuVolumeX } from "react-icons/lu";
import clsx from "clsx";
import { cva, cx } from "@/cva.config";
interface VolumeControlProps {
theme?: "primary" | "danger" | "light" | "lightDanger" | "blank";
size?: "XS" | "SM" | "MD" | "LG" | "XL";
fullWidth?: boolean;
className?: string;
}
const sizes = {
XS: "h-[28px] px-2 text-xs",
SM: "h-[36px] px-3 text-[13px]",
MD: "h-[40px] px-3.5 text-sm",
LG: "h-[48px] px-4 text-base",
XL: "h-[56px] px-5 text-base",
};
const themes = {
primary: cx(
// Base styles
"bg-blue-700 dark:border-blue-600 border border-blue-900/60 text-white shadow-sm",
// Hover states
"group-hover:bg-blue-800",
// Active states
"group-active:bg-blue-900",
),
danger: cx(
// Base styles
"bg-red-600 text-white border-red-700 shadow-xs shadow-red-200/80 dark:border-red-600 dark:shadow-red-900/20",
// Hover states
"group-hover:bg-red-700 group-hover:border-red-800 dark:group-hover:bg-red-700 dark:group-hover:border-red-600",
// Active states
"group-active:bg-red-800 dark:group-active:bg-red-800",
// Focus states
"group-focus:ring-red-700 dark:group-focus:ring-red-600",
),
light: cx(
// Base styles
"bg-white text-black border-slate-800/30 shadow-xs dark:bg-slate-800 dark:border-slate-300/20 dark:text-white",
// Hover states
"group-hover:bg-blue-50/80 dark:group-hover:bg-slate-700",
// Active states
"group-active:bg-blue-100/60 dark:group-active:bg-slate-600",
// Disabled states
"group-disabled:group-hover:bg-white dark:group-disabled:group-hover:bg-slate-800",
),
lightDanger: cx(
// Base styles
"bg-white text-black border-red-400/60 shadow-xs",
// Hover states
"group-hover:bg-red-50/80",
// Active states
"group-active:bg-red-100/60",
// Focus states
"group-focus:ring-red-700",
),
blank: cx(
// Base styles
"bg-white/0 text-black border-transparent dark:text-white",
// Hover states
"group-hover:bg-white group-hover:border-slate-800/30 group-hover:shadow-sm dark:group-hover:bg-slate-700 dark:group-hover:border-slate-600",
// Active states
"group-active:bg-slate-100/80",
),
};
const btnVariants = cva({
base: cx(
// Base styles
"border rounded-sm select-none",
// Size classes
"justify-center items-center shrink-0",
// Transition classes
"outline-hidden transition-all duration-200",
// Text classes
"font-display text-center font-medium leading-tight",
// States
"group-focus:outline-hidden group-focus:ring-2 group-focus:ring-offset-2 group-focus:ring-blue-700",
"group-disabled:opacity-50 group-disabled:pointer-events-none",
),
variants: {
size: sizes,
theme: themes,
},
});
const iconVariants = cva({
variants: {
size: {
XS: "h-3.5",
SM: "h-3.5",
MD: "h-5",
LG: "h-6",
XL: "h-6",
},
theme: {
primary: "text-white",
danger: "text-white ",
light: "text-black dark:text-white",
lightDanger: "text-black dark:text-white",
blank: "text-black dark:text-white",
},
},
});
const VolumeControl: React.FC<VolumeControlProps> = ({
theme = "light",
size = "XS",
fullWidth = false,
className,
}) => {
const [volume, setVolume] = useState(1);
const [muted, setMuted] = useState(true);
const [showSlider, setShowSlider] = useState(false);
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null);
useEffect(() => {
const audio = document.querySelector("audio#global-audio") as HTMLAudioElement | null;
setAudioElement(audio);
if (audio) {
const savedVolume = parseFloat(localStorage.getItem("audioVolume") || "1");
const savedMuted = localStorage.getItem("audioMuted") === "true";
audio.volume = savedVolume;
audio.muted = savedMuted;
setVolume(savedVolume);
setMuted(savedMuted);
audio
.play()
.catch(() => {
audio.muted = true;
setMuted(true);
});
}
}, []);
const handlePlay = () => {
if (!audioElement) return;
audioElement.muted = false;
audioElement.volume = volume;
audioElement.play().catch((err) => {
console.warn("Failed to play:", err);
});
setMuted(false);
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
setMuted(newVolume === 0);
if (audioElement) {
audioElement.volume = newVolume;
audioElement.muted = newVolume === 0;
}
localStorage.setItem("audioVolume", String(newVolume));
localStorage.setItem("audioMuted", String(newVolume === 0));
};
const iconClass = iconVariants({ theme, size });
const btnClass = btnVariants({ theme, size });
return (
<div
className={clsx(
"relative group flex items-center gap-2",
fullWidth ? "w-full" : "w-fit",
className
)}
onMouseEnter={() => setShowSlider(true)}
onMouseLeave={() => setShowSlider(false)}
>
<button
onClick={handlePlay}
className={clsx("group p-2 flex items-center", btnClass)}
aria-label="Unmute & Play"
>
{muted || volume === 0 ? (
<LuVolumeX className={clsx(iconClass, "shrink-0")} />
) : (
<LuVolume2 className={clsx(iconClass, "shrink-0")} />
)}
</button>
<div
className={clsx(
"transition-all duration-300 ease-in-out",
showSlider ? "w-16 opacity-100 ml-2" : "w-0 opacity-0"
)}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={muted ? 0 : volume}
onChange={handleVolumeChange}
className="w-16 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
aria-label="Volume slider"
/>
</div>
</div>
);
};
export default VolumeControl;

View File

@@ -0,0 +1,54 @@
import StatusCard from "@components/StatusCards";
const VpnConnectionStatusMap = {
connected: "Connected",
connecting: "Connecting",
disconnected: "Disconnected",
closed: "Closed",
logined: "Logined",
};
export type VpnConnections = keyof typeof VpnConnectionStatusMap;
type StatusProps = {
[key in VpnConnections]: {
statusIndicatorClassName: string;
};
};
export default function VpnConnectionStatusCard({
state,
title,
}: {
state?: VpnConnections;
title?: string;
}) {
if (!state) return null;
const StatusCardProps: StatusProps = {
logined: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connected: {
statusIndicatorClassName: "bg-green-500 border-green-600",
},
connecting: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
disconnected: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
closed: {
statusIndicatorClassName: "bg-slate-300 border-slate-400",
},
};
const props = StatusCardProps[state];
if (!props) return;
return (
<StatusCard
title={title || "Vpn Network"}
status={VpnConnectionStatusMap[state]}
{...StatusCardProps[state]}
/>
);
}

View File

@@ -25,9 +25,11 @@ import {
PointerLockBar,
} from "./VideoOverlay";
export default function WebRTCVideo() {
// Video and stream related refs and states
const videoElm = useRef<HTMLVideoElement>(null);
const audioElm = useRef<HTMLAudioElement>(null);
const mediaStream = useRTCStore(state => state.mediaStream);
const [isPlaying, setIsPlaying] = useState(false);
const peerConnectionState = useRTCStore(state => state.peerConnectionState);
@@ -517,6 +519,16 @@ export default function WebRTCVideo() {
[updateVideoSizeStore],
);
const addStreamToAudioElm = useCallback(
(mediaStream: MediaStream) => {
if (!audioElm.current) return;
const audioElmRefValue = audioElm.current;
audioElmRefValue.srcObject = mediaStream;
//audioElm.current.play();
},
[],
);
useEffect(
function updateVideoStreamOnNewTrack() {
if (!peerConnection) return;
@@ -543,6 +555,7 @@ export default function WebRTCVideo() {
if (!mediaStream) return;
// We set the as early as possible
addStreamToVideoElm(mediaStream);
addStreamToAudioElm(mediaStream);
},
[
setVideoClientSize,
@@ -550,6 +563,7 @@ export default function WebRTCVideo() {
updateVideoSizeStore,
peerConnection,
addStreamToVideoElm,
addStreamToAudioElm,
],
);
@@ -671,6 +685,14 @@ export default function WebRTCVideo() {
</div>
</div>
<audio
id="global-audio"
ref={audioElm}
autoPlay
muted={true}
controls={false}
/>
<div ref={containerRef} className="h-full overflow-hidden">
<div className="relative h-full">
<div

View File

@@ -1,173 +0,0 @@
import { LuHardDrive, LuPower, LuRotateCcw } from "react-icons/lu";
import { useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useJsonRpc } from "../../hooks/useJsonRpc";
const LONG_PRESS_DURATION = 3000; // 3 seconds for long press
interface ATXState {
power: boolean;
hdd: boolean;
}
export function ATXPowerControl() {
const [isPowerPressed, setIsPowerPressed] = useState(false);
const [powerPressTimer, setPowerPressTimer] = useState<ReturnType<
typeof setTimeout
> | null>(null);
const [atxState, setAtxState] = useState<ATXState | null>(null);
const [send] = useJsonRpc(function onRequest(resp) {
if (resp.method === "atxState") {
setAtxState(resp.params as ATXState);
}
});
// Request initial state
useEffect(() => {
send("getATXState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get ATX state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setAtxState(resp.result as ATXState);
});
}, [send]);
const handlePowerPress = (pressed: boolean) => {
// Prevent phantom releases
if (!pressed && !isPowerPressed) return;
setIsPowerPressed(pressed);
// Handle button press
if (pressed) {
// Start long press timer
const timer = setTimeout(() => {
// Send long press action
console.log("Sending long press ATX power action");
send("setATXPowerAction", { action: "power-long" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
setIsPowerPressed(false);
});
}, LONG_PRESS_DURATION);
setPowerPressTimer(timer);
}
// Handle button release
else {
// If timer exists, was a short press
if (powerPressTimer) {
clearTimeout(powerPressTimer);
setPowerPressTimer(null);
// Send short press action
console.log("Sending short press ATX power action");
send("setATXPowerAction", { action: "power-short" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
}
});
}
}
};
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (powerPressTimer) {
clearTimeout(powerPressTimer);
}
};
}, [powerPressTimer]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="ATX Power Control"
description="Control your ATX power settings"
/>
{atxState === null ? (
<Card className="flex h-[120px] items-center justify-center p-3">
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[120px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Control Buttons */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power"
onMouseDown={() => handlePowerPress(true)}
onMouseUp={() => handlePowerPress(false)}
onMouseLeave={() => handlePowerPress(false)}
className={isPowerPressed ? "opacity-75" : ""}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuRotateCcw}
text="Reset"
onClick={() => {
send("setATXPowerAction", { action: "reset" }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send ATX power action: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Indicators */}
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuPower
strokeWidth={3}
className={`mr-1 inline ${
atxState?.power ? "text-green-600" : "text-slate-300"
}`}
/>
Power LED
</span>
</div>
<div className="flex items-center space-x-2">
<span className="text-sm text-slate-600 dark:text-slate-400">
<LuHardDrive
strokeWidth={3}
className={`mr-1 inline ${
atxState?.hdd ? "text-blue-400" : "text-slate-300"
}`}
/>
HDD LED
</span>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -1,115 +0,0 @@
import { LuPower } from "react-icons/lu";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@components/Button";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import notifications from "@/notifications";
import FieldLabel from "@components/FieldLabel";
import LoadingSpinner from "@components/LoadingSpinner";
interface DCPowerState {
isOn: boolean;
voltage: number;
current: number;
power: number;
}
export function DCPowerControl() {
const [send] = useJsonRpc();
const [powerState, setPowerState] = useState<DCPowerState | null>(null);
const getDCPowerState = useCallback(() => {
send("getDCPowerState", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setPowerState(resp.result as DCPowerState);
});
}, [send]);
const handlePowerToggle = (enabled: boolean) => {
send("setDCPowerState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set DC power state: ${resp.error.data || "Unknown error"}`,
);
return;
}
getDCPowerState(); // Refresh state after change
});
};
useEffect(() => {
getDCPowerState();
// Set up polling interval to update status
const interval = setInterval(getDCPowerState, 1000);
return () => clearInterval(interval);
}, [getDCPowerState]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="DC Power Control"
description="Control your DC power settings"
/>
{powerState === null ? (
<Card className="flex h-[160px] justify-center p-3">
<LoadingSpinner className="h-6 w-6 text-blue-500 dark:text-blue-400" />
</Card>
) : (
<Card className="h-[160px] animate-fadeIn opacity-0">
<div className="space-y-4 p-3">
{/* Power Controls */}
<div className="flex items-center space-x-2">
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power On"
onClick={() => handlePowerToggle(true)}
disabled={powerState.isOn}
/>
<Button
size="SM"
theme="light"
LeadingIcon={LuPower}
text="Power Off"
disabled={!powerState.isOn}
onClick={() => handlePowerToggle(false)}
/>
</div>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
{/* Status Display */}
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<FieldLabel label="Voltage" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.voltage.toFixed(1)}V
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Current" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.current.toFixed(1)}A
</p>
</div>
<div className="space-y-1">
<FieldLabel label="Power" />
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{powerState.power.toFixed(1)}W
</p>
</div>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,123 @@
import { Button } from "@components/Button";
import { LuSun, LuSunset } from "react-icons/lu";
import Card from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { useEffect, useState } from "react";
import notifications from "@/notifications";
import { cx } from "@/cva.config";
interface IOSettings {
io0Status: boolean;
io1Status: boolean;
}
export function IOControl() {
const [send] = useJsonRpc();
const [settings, setSettings] = useState<IOSettings>({
io0Status: true,
io1Status: true,
});
useEffect(() => {
send("getIOSettings", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get IO settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(resp.result as IOSettings);
});
}, [send]);
const handleSettingChange = (setting: keyof IOSettings, value: boolean) => {
const newSettings = { ...settings, [setting]: value };
send("setIOSettings", { settings: newSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update IO settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
setSettings(newSettings);
});
};
return (
<div className="space-y-4">
<SettingsPageHeader
title="IO Control"
description="Configure your io control settings"
/>
<hr className="border-slate-700/30 dark:border-slate-600/30" />
<Card className="animate-fadeIn opacity-0">
<div className="space-y-2 p-1">
<div className="flex items-center gap-2">
<div className="text-bm text-black dark:text-slate-300">IO_0</div>
<div className={cx("w-2 h-2 rounded-full bg-red-400", {
hidden: !settings.io0Status
})} />
</div>
<div className="flex justify-between gap-x-6">
<Button
size="SM"
theme="primary"
LeadingIcon={LuSun}
text="High"
onClick={() => {
handleSettingChange("io0Status", true);
}}
/>
<Button
size="SM"
theme="primary"
LeadingIcon={LuSunset}
text="Low"
onClick={() => {
handleSettingChange("io0Status", false);
}}
/>
<div className="w-4"></div>
</div>
</div>
<div className="space-y-2 p-1">
<div className="flex items-center gap-2">
<div className="text-bm text-black dark:text-slate-300">IO_1</div>
<div className={cx("w-2 h-2 rounded-full bg-red-400", {
hidden: !settings.io1Status
})} />
</div>
<div className="flex justify-between gap-x-6">
<Button
size="SM"
theme="primary"
LeadingIcon={LuSun}
text="High"
onClick={() => {
handleSettingChange("io1Status", true);
}}
/>
<Button
size="SM"
theme="primary"
LeadingIcon={LuSunset}
text="Low"
onClick={() => {
handleSettingChange("io1Status", false);
}}
/>
<div className="w-4"></div>
</div>
</div>
<div className="h-2"></div>
</Card>
</div>
);
}

View File

@@ -1,12 +1,11 @@
import { useEffect, useState } from "react";
import { LuPower, LuTerminal, LuPlugZap } from "react-icons/lu";
import { LuPower, LuTerminal } from "react-icons/lu";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import Card, { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { ATXPowerControl } from "@components/extensions/ATXPowerControl";
import { DCPowerControl } from "@components/extensions/DCPowerControl";
import { SerialConsole } from "@components/extensions/SerialConsole";
import { IOControl } from "@components/extensions/IOControl";
import { Button } from "@components/Button";
import notifications from "@/notifications";
@@ -18,24 +17,18 @@ interface Extension {
}
const AVAILABLE_EXTENSIONS: Extension[] = [
{
id: "atx-power",
name: "ATX Power Control",
description: "Control your ATX Power extension",
icon: LuPower,
},
{
id: "dc-power",
name: "DC Power Control",
description: "Control your DC Power extension",
icon: LuPlugZap,
},
{
id: "serial-console",
name: "Serial Console",
description: "Access your serial console extension",
icon: LuTerminal,
},
{
id: "io-console",
name: "IO Control",
description: "Control IO port high and low level output",
icon: LuPower,
}
];
export default function ExtensionPopover() {
@@ -70,12 +63,10 @@ export default function ExtensionPopover() {
const renderActiveExtension = () => {
switch (activeExtension?.id) {
case "atx-power":
return <ATXPowerControl />;
case "dc-power":
return <DCPowerControl />;
case "serial-console":
return <SerialConsole />;
case "io-console":
return <IOControl />;
default:
return null;
}
@@ -101,7 +92,7 @@ export default function ExtensionPopover() {
<Button
size="SM"
theme="light"
text="Unload Extension"
text="Quit"
onClick={() => handleSetActiveExtension(null)}
/>
</div>

View File

@@ -165,7 +165,7 @@ const MountPopopover = forwardRef<HTMLDivElement, object>((_props, ref) => {
</Card>
</div>
<h3 className="text-base font-semibold text-black dark:text-white">
Mounted from JetKVM Storage
Mounted from KVM Storage
</h3>
<p className="text-sm text-slate-900 dark:text-slate-100">
{formatters.truncateMiddle(path, 50)}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useClose } from "@headlessui/react";
import { Button } from "@/components/Button";
import { GridCard } from "@components/Card";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { useJsonRpc } from "@/hooks/useJsonRpc";
@@ -28,6 +29,17 @@ export default function WakeOnLanModal() {
setDisableFocusTrap(false);
}, [close, setDisableFocusTrap]);
const onSendUsbWakeupSignal = useCallback(() => {
send("sendUsbWakeupSignal", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to send USB wakeup signal: ${resp.error.data || "Unknown error"}`,
);
return;
}
});
}, [send]);
const onSendMagicPacket = useCallback(
(macAddress: string) => {
setErrorMessage(null);
@@ -104,6 +116,26 @@ export default function WakeOnLanModal() {
<div className="space-y-4 p-4 py-3">
<div className="grid h-full grid-rows-(--grid-headerBody)">
<div className="space-y-4">
<SettingsPageHeader
title="Wake On USB"
description="Send a Signal to wake up the device connected via USB."
/>
<div
className="flex animate-fadeIn opacity-0 items-center justify-end space-x-2"
style={{
animationDuration: "0.7s",
animationDelay: "0.2s",
}}
>
<Button
size="SM"
theme="primary"
text="Wake"
onClick={onSendUsbWakeupSignal}
/>
</div>
<SettingsPageHeader
title="Wake On LAN"
description="Send a Magic Packet to wake up a remote device."

View File

@@ -100,7 +100,7 @@ export default function ConnectionStatsSidebar() {
return (
<div className="grid h-full grid-rows-(--grid-headerBody) shadow-xs">
<SidebarHeader title="Connection Stats" setSidebarView={setSidebarView} />
<SidebarHeader title="Connection State" setSidebarView={setSidebarView} />
<div className="h-full space-y-4 overflow-y-scroll bg-white px-4 py-2 pb-8 dark:bg-slate-900">
<div className="space-y-4">
{/*

View File

@@ -114,6 +114,9 @@ interface RTCState {
transceiver: RTCRtpTransceiver | null;
setTransceiver: (transceiver: RTCRtpTransceiver) => void;
audioTransceiver: RTCRtpTransceiver | null;
setAudioTransceiver: (transceiver: RTCRtpTransceiver) => void;
mediaStream: MediaStream | null;
setMediaStream: (stream: MediaStream) => void;
@@ -121,6 +124,10 @@ interface RTCState {
appendVideoStreamStats: (state: RTCInboundRtpStreamStats) => void;
videoStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
audioStreamStats: RTCInboundRtpStreamStats | null;
appendAudioStreamStats: (state: RTCInboundRtpStreamStats) => void;
audioStreamStatsHistory: Map<number, RTCInboundRtpStreamStats>;
isTurnServerInUse: boolean;
setTurnServerInUse: (inUse: boolean) => void;
@@ -157,6 +164,9 @@ export const useRTCStore = create<RTCState>(set => ({
transceiver: null,
setTransceiver: transceiver => set({ transceiver }),
audioTransceiver: null,
setAudioTransceiver: audioTransceiver => set({ audioTransceiver }),
peerConnectionState: null,
setPeerConnectionState: state => set({ peerConnectionState: state }),
@@ -170,6 +180,10 @@ export const useRTCStore = create<RTCState>(set => ({
appendVideoStreamStats: stats => set({ videoStreamStats: stats }),
videoStreamStatsHistory: new Map(),
audioStreamStats: null,
appendAudioStreamStats: stats => set({ audioStreamStats: stats }),
audioStreamStatsHistory: new Map(),
isTurnServerInUse: false,
setTurnServerInUse: inUse => set({ isTurnServerInUse: inUse }),
@@ -304,6 +318,15 @@ interface SettingsState {
backlightSettings: BacklightSettings;
setBacklightSettings: (settings: BacklightSettings) => void;
timeZone: string;
setTimeZone: (timezone: string) => void;
ledGreenMode: string;
setLedGreenMode: (mode: string) => void;
ledYellowMode: string;
setLedYellowMode: (mode: string) => void;
keyboardLayout: string;
setKeyboardLayout: (layout: string) => void;
@@ -345,7 +368,7 @@ export const useSettingsStore = create(
developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }),
displayRotation: "270",
displayRotation: "180",
setDisplayRotation: (rotation: string) => set({ displayRotation: rotation }),
backlightSettings: {
@@ -356,6 +379,15 @@ export const useSettingsStore = create(
setBacklightSettings: (settings: BacklightSettings) =>
set({ backlightSettings: settings }),
timeZone: "CST-8",
setTimeZone: (timezone: string) => set({ timeZone: timezone }),
ledGreenMode: "network-rx",
setLedGreenMode: (mode: string) => set({ ledGreenMode: mode }),
ledYellowMode: "activity",
setLedYellowMode: (mode: string) => set({ ledYellowMode: mode }),
keyboardLayout: "en-US",
setKeyboardLayout: layout => set({ keyboardLayout: layout }),
@@ -402,7 +434,7 @@ export interface MountMediaState {
remoteVirtualMediaState: RemoteVirtualMediaState | null;
setRemoteVirtualMediaState: (state: MountMediaState["remoteVirtualMediaState"]) => void;
modalView: "mode" | "browser" | "url" | "device" | "upload" | "error" | null;
modalView: "mode" | "browser" | "url" | "device" | "sd" | "upload" | "upload_sd" | "error" | null;
setModalView: (view: MountMediaState["modalView"]) => void;
isMountMediaDialogOpen: boolean;
@@ -943,3 +975,45 @@ export const useMacrosStore = create<MacrosState>((set, get) => ({
}
},
}));
export interface VpnState {
tailScaleConnectionState: "connecting" | "connected" | "disconnected" | "closed" | "logined";
setTailScaleConnectionState: (state: VpnState["tailScaleConnectionState"]) => void;
tailScaleLoginUrl: string | null;
setTailScaleLoginUrl: (url: string) => void;
tailScaleXEdge: boolean;
setTailScaleXEdge: (xEdge: boolean) => void;
tailScaleIP: string | null;
setTailScaleIP: (ip: string) => void;
zeroTierConnectionState: "connecting" | "connected" | "disconnected" | "closed" | "logined";
setZeroTierConnectionState: (state: VpnState["zeroTierConnectionState"]) => void;
zeroTierNetworkID: string | null;
setZeroTierNetworkID: (networkID: string) => void;
zeroTierIP: string | null;
setZeroTierIP: (ip: string) => void;
};
export const useVpnStore = create<VpnState>(set => ({
tailScaleConnectionState: "disconnected",
setTailScaleConnectionState: state => set({ tailScaleConnectionState: state }),
tailScaleLoginUrl: null,
setTailScaleLoginUrl: url => set({ tailScaleLoginUrl: url }),
tailScaleXEdge: false,
setTailScaleXEdge: xEdge => set({ tailScaleXEdge: xEdge }),
tailScaleIP: null,
setTailScaleIP: url => set({ tailScaleIP: url }),
zeroTierConnectionState: "disconnected",
setZeroTierConnectionState: state => set({ zeroTierConnectionState: state }),
zeroTierNetworkID: null,
setZeroTierNetworkID: networkID => set({ zeroTierNetworkID: networkID }),
zeroTierIP: null,
setZeroTierIP: networkID => set({ zeroTierIP: networkID }),
}));

View File

@@ -28,7 +28,7 @@ import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute, { DeviceStatus } from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API, DEVICE_API } from "./ui.config";
import { DEVICE_API } from "./ui.config";
import OtherSessionRoute from "./routes/devices.$id.other-session";
import MountRoute from "./routes/devices.$id.mount";
import * as SettingsRoute from "./routes/devices.$id.settings";
@@ -49,29 +49,15 @@ import SettingsMacrosRoute from "./routes/devices.$id.settings.macros";
import SettingsMacrosAddRoute from "./routes/devices.$id.settings.macros.add";
import SettingsMacrosEditRoute from "./routes/devices.$id.settings.macros.edit";
export const isOnDevice = import.meta.env.MODE === "device";
export const isOnDevice = true;
export const isInCloud = !isOnDevice;
export async function checkCloudAuth() {
const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (res.status === 401) {
throw redirect(`/login?returnTo=${window.location.href}`);
}
return await res.json();
}
export async function checkDeviceAuth() {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
if (!res.isSetup) return redirect("/mode");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
@@ -84,27 +70,25 @@ export async function checkDeviceAuth() {
}
export async function checkAuth() {
return import.meta.env.MODE === "device" ? checkDeviceAuth() : checkCloudAuth();
return checkDeviceAuth();
}
let router;
if (isOnDevice) {
router = createBrowserRouter([
let router = createBrowserRouter([
{
path: "/welcome/mode",
path: "/mode",
element: <WelcomeLocalModeRoute />,
action: WelcomeLocalModeRoute.action,
},
{
path: "/welcome/password",
path: "/mode/password",
element: <WelcomeLocalPasswordRoute />,
action: WelcomeLocalPasswordRoute.action,
},
{
path: "/welcome",
element: <WelcomeRoute />,
loader: WelcomeRoute.loader,
},
//{
// path: "/welcome",
// element: <WelcomeRoute />,
// loader: WelcomeRoute.loader,
//},
{
path: "/login-local",
element: <LoginLocalRoute />,
@@ -216,155 +200,6 @@ if (isOnDevice) {
errorElement: <ErrorBoundary />,
},
]);
} else {
router = createBrowserRouter([
{
errorElement: <ErrorBoundary />,
children: [
{ path: "signup", element: <SignupRoute /> },
{ path: "login", element: <LoginRoute /> },
{
path: "/",
element: <Root />,
children: [
{
index: true,
loader: async () => {
await checkAuth();
return redirect(`/devices`);
},
},
{
path: "devices/:id/setup",
element: <SetupRoute />,
action: SetupRoute.action,
loader: SetupRoute.loader,
},
{
path: "devices/already-adopted",
element: <DevicesAlreadyAdopted />,
},
{
path: "devices/:id",
element: <DeviceRoute />,
loader: DeviceRoute.loader,
children: [
{
path: "other-session",
element: <OtherSessionRoute />,
},
{
path: "mount",
element: <MountRoute />,
},
{
path: "settings",
element: <SettingsRoute.default />,
children: [
{
index: true,
loader: SettingsIndexRoute.loader,
},
{
path: "general",
children: [
{
index: true,
element: <SettingsGeneralIndexRoute.default />,
},
{
path: "update",
element: <SettingsGeneralUpdateRoute />,
},
],
},
{
path: "mouse",
element: <SettingsMouseRoute />,
},
{
path: "keyboard",
element: <SettingsKeyboardRoute />,
},
{
path: "advanced",
element: <SettingsAdvancedRoute />,
},
{
path: "hardware",
element: <SettingsHardwareRoute />,
},
{
path: "network",
element: <SettingsNetworkRoute />,
},
{
path: "access",
children: [
{
index: true,
element: <SettingsAccessIndexRoute />,
loader: SettingsAccessIndexRoute.loader,
},
{
path: "local-auth",
element: <SecurityAccessLocalAuthRoute />,
},
],
},
{
path: "video",
element: <SettingsVideoRoute />,
},
{
path: "appearance",
element: <SettingsAppearanceRoute />,
},
{
path: "macros",
children: [
{
index: true,
element: <SettingsMacrosRoute />,
},
{
path: "add",
element: <SettingsMacrosAddRoute />,
},
{
path: ":macroId/edit",
element: <SettingsMacrosEditRoute />,
},
],
},
],
},
],
},
{
path: "devices/:id/deregister",
element: <DevicesIdDeregister />,
loader: DevicesIdDeregister.loader,
action: DevicesIdDeregister.action,
},
{
path: "devices/:id/rename",
element: <DeviceIdRename />,
loader: DeviceIdRename.loader,
action: DeviceIdRename.action,
},
{
path: "devices",
element: <DevicesRoute />,
loader: DevicesRoute.loader
},
],
},
],
},
]);
}
document.addEventListener("DOMContentLoaded", () => {
ReactDOM.createRoot(document.getElementById("root")!).render(

View File

@@ -15,57 +15,12 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
user: User;
}
const action = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData());
try {
const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
mode: "cors",
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
}
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;
try {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { device } = (await res.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DevicesIdDeregister() {
const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string };
@@ -140,6 +95,3 @@ export default function DevicesIdDeregister() {
</div>
);
}
DevicesIdDeregister.loader = loader;
DevicesIdDeregister.action = action;

File diff suppressed because it is too large Load Diff

View File

@@ -2,8 +2,7 @@ import { useNavigate, useOutletContext } from "react-router-dom";
import { GridCard } from "@/components/Card";
import { Button } from "@components/Button";
import LogoBlue from "@/assets/logo-blue.svg";
import LogoWhite from "@/assets/logo-white.svg";
import LogoLuckfox from "@/assets/logo-luckfox.png";
interface ContextType {
setupPeerConnection: () => Promise<void>;
@@ -24,8 +23,8 @@ export default function OtherSessionRoute() {
<div className="p-10">
<div className="flex min-h-[140px] flex-col items-start justify-start space-y-4 text-left">
<div className="h-[24px]">
<img src={LogoBlue} alt="" className="h-full dark:hidden" />
<img src={LogoWhite} alt="" className="hidden h-full dark:block" />
<img src={LogoLuckfox} alt="" className="h-full dark:hidden" />
<img src={LogoLuckfox} alt="" className="hidden h-full dark:block" />
</div>
<div className="text-left">

View File

@@ -16,7 +16,6 @@ import DashboardNavbar from "@components/Header";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
@@ -33,43 +32,9 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
return { message: "Please specify a name" };
}
try {
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, {
name,
});
if (!res.ok) {
return { message: "There was an error renaming your device. Please try again." };
}
} catch (e) {
console.error(e);
return { message: "There was an error renaming your device. Please try again." };
}
return redirect("/devices");
};
const loader = async ({ params }: LoaderFunctionArgs) => {
const user = await checkAuth();
const { id } = params;
try {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { device } = (await res.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { device, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DeviceIdRename() {
const { device, user } = useLoaderData() as LoaderData;
const error = useActionData() as { message: string };
@@ -135,5 +100,4 @@ export default function DeviceIdRename() {
);
}
DeviceIdRename.loader = loader;
DeviceIdRename.action = action;

View File

@@ -19,6 +19,21 @@ import { TextAreaWithLabel } from "@components/TextArea";
import { LocalDevice } from "./devices.$id";
import { SettingsItem } from "./devices.$id.settings";
import { CloudState } from "./adopt";
import { useVpnStore } from "@/hooks/stores";
import Checkbox from "../components/Checkbox";
export interface TailScaleResponse {
state: string;
loginUrl: string;
ip: string;
xEdge: boolean;
}
export interface ZeroTierResponse {
state: string;
networkID: string;
ip: string;
}
export interface TLSState {
mode: "self-signed" | "custom" | "disabled";
@@ -40,41 +55,34 @@ export default function SettingsAccessIndexRoute() {
const loaderData = useLoaderData() as LocalDevice | null;
const { navigateTo } = useDeviceUiNavigation();
const navigate = useNavigate();
const [send] = useJsonRpc();
const [isAdopted, setAdopted] = useState(false);
const [deviceId, setDeviceId] = useState<string | null>(null);
const [cloudApiUrl, setCloudApiUrl] = useState("");
const [cloudAppUrl, setCloudAppUrl] = useState("");
// Use a simple string identifier for the selected provider
const [selectedProvider, setSelectedProvider] = useState<string>("jetkvm");
const [tlsMode, setTlsMode] = useState<string>("unknown");
const [tlsCert, setTlsCert] = useState<string>("");
const [tlsKey, setTlsKey] = useState<string>("");
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
const tailScaleLoginUrl = useVpnStore(state => state.tailScaleLoginUrl);
const tailScaleXEdge = useVpnStore(state => state.tailScaleXEdge)
const tailScaleIP = useVpnStore(state => state.tailScaleIP);
const setTailScaleConnectionState = useVpnStore(state => state.setTailScaleConnectionState);
const setTailScaleLoginUrl = useVpnStore(state => state.setTailScaleLoginUrl);
const setTailScaleXEdge = useVpnStore(state => state.setTailScaleXEdge);
const setTailScaleIP = useVpnStore(state => state.setTailScaleIP);
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
const zeroTierNetworkID = useVpnStore(state => state.zeroTierNetworkID);
const zeroTierIP = useVpnStore(state => state.zeroTierIP);
const setZeroTierConnectionState = useVpnStore(state => state.setZeroTierConnectionState);
const setZeroTierNetworkID = useVpnStore(state => state.setZeroTierNetworkID);
const setZeroTierIP = useVpnStore(state => state.setZeroTierIP);
const getCloudState = useCallback(() => {
send("getCloudState", {}, resp => {
if ("error" in resp) return console.error(resp.error);
const cloudState = resp.result as CloudState;
setAdopted(cloudState.connected);
setCloudApiUrl(cloudState.url);
if (cloudState.appUrl) setCloudAppUrl(cloudState.appUrl);
// Find if the API URL matches any of our predefined providers
const isAPIJetKVMProd = cloudState.url === "https://api.jetkvm.com";
const isAppJetKVMProd = cloudState.appUrl === "https://app.jetkvm.com";
if (isAPIJetKVMProd && isAppJetKVMProd) {
setSelectedProvider("jetkvm");
} else {
setSelectedProvider("custom");
}
});
}, [send]);
const [tempNetworkID, setTempNetworkID] = useState("");
const [isDisconnecting, setIsDisconnecting] = useState(false);
const getTLSState = useCallback(() => {
send("getTLSState", {}, resp => {
@@ -87,66 +95,6 @@ export default function SettingsAccessIndexRoute() {
});
}, [send]);
const deregisterDevice = async () => {
send("deregisterDevice", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to de-register device: ${resp.error.data || "Unknown error"}`,
);
return;
}
getCloudState();
// In cloud mode, we need to navigate to the device overview page, as we don't a connection anymore
if (!isOnDevice) navigate("/");
return;
});
};
const onCloudAdoptClick = useCallback(
(cloudApiUrl: string, cloudAppUrl: string) => {
if (!deviceId) {
notifications.error("No device ID available");
return;
}
send("setCloudUrl", { apiUrl: cloudApiUrl, appUrl: cloudAppUrl }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to update cloud URL: ${resp.error.data || "Unknown error"}`,
);
return;
}
const returnTo = new URL(window.location.href);
returnTo.pathname = "/adopt";
returnTo.search = "";
returnTo.hash = "";
window.location.href =
cloudAppUrl +
"/signup?deviceId=" +
deviceId +
`&returnTo=${returnTo.toString()}`;
});
},
[deviceId, send],
);
// Handle provider selection change
const handleProviderChange = (value: string) => {
setSelectedProvider(value);
// If selecting a predefined provider, update both URLs
if (value === "jetkvm") {
setCloudApiUrl("https://api.jetkvm.com");
setCloudAppUrl("https://app.jetkvm.com");
} else {
if (cloudApiUrl || cloudAppUrl) return;
setCloudApiUrl("");
setCloudAppUrl("");
}
};
// Function to update TLS state - accepts a mode parameter
const updateTlsState = useCallback(
(mode: string, cert?: string, key?: string) => {
@@ -195,14 +143,124 @@ export default function SettingsAccessIndexRoute() {
// Fetch device ID and cloud state on component mount
useEffect(() => {
getCloudState();
getTLSState();
send("getDeviceID", {}, async resp => {
if ("error" in resp) return console.error(resp.error);
setDeviceId(resp.result as string);
});
}, [send, getCloudState, getTLSState]);
}, [send, getTLSState]);
const handleTailScaleLogin = useCallback(() => {
setTailScaleConnectionState("connecting");
send("loginTailScale", { xEdge: tailScaleXEdge }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to login TailScale: ${resp.error.data || "Unknown error"}`,
);
setTailScaleConnectionState("closed");
setTailScaleLoginUrl("");
setTailScaleIP("");
return;
}
const result = resp.result as TailScaleResponse;
const validState = ["closed", "connecting", "connected", "disconnected" , "logined"].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
setTailScaleConnectionState(validState);
setTailScaleLoginUrl(result.loginUrl);
setTailScaleIP(result.ip);
});
}, [send, tailScaleXEdge]);
const handleTailScaleXEdgeChange = (enabled: boolean) => {
setTailScaleXEdge(enabled);
};
const handleTailScaleLogout = useCallback(() => {
setIsDisconnecting(true);
send("logoutTailScale", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to logout TailScale: ${resp.error.data || "Unknown error"}`,
);
setIsDisconnecting(false);
return;
}
setTailScaleConnectionState("disconnected");
setTailScaleLoginUrl("");
setTailScaleIP("");
setIsDisconnecting(false);
});
},[send]);
const handleTailScaleCanel = useCallback(() => {
setIsDisconnecting(true);
send("canelTailScale", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to logout TailScale: ${resp.error.data || "Unknown error"}`,
);
setIsDisconnecting(false);
return;
}
setTailScaleConnectionState("disconnected");
setTailScaleLoginUrl("");
setTailScaleIP("");
setIsDisconnecting(false);
});
},[send]);
const handleZeroTierLogin = useCallback(() => {
setZeroTierConnectionState("connecting");
const currentNetworkID = tempNetworkID;
if (!/^[0-9a-f]{16}$/.test(currentNetworkID)) {
notifications.error("Please enter a valid Network ID");
setZeroTierConnectionState("disconnected");
return;
}
setZeroTierNetworkID(currentNetworkID);
send("loginZeroTier", { networkID: currentNetworkID }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to login ZeroTier: ${resp.error.data || "Unknown error"}`,
);
setZeroTierConnectionState("closed");
setZeroTierNetworkID("");
setZeroTierIP("");
return;
}
const result = resp.result as ZeroTierResponse;
const validState = ["closed", "connecting", "connected", "disconnected" , "logined" ].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
setZeroTierConnectionState(validState);
setZeroTierIP(result.ip);
});
}, [send, tempNetworkID]);
const handleZeroTierNetworkIdChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
setTempNetworkID(value);
}, []);
const handleZeroTierLogout = useCallback(() => {
send("logoutZeroTier", { networkID: zeroTierNetworkID }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to logout ZeroTier: ${resp.error.data || "Unknown error"}`,
);
return;
}
setZeroTierConnectionState("disconnected");
setZeroTierNetworkID("");
setZeroTierIP("");
});
},[send, zeroTierNetworkID]);
return (
<div className="space-y-4">
@@ -334,136 +392,172 @@ export default function SettingsAccessIndexRoute() {
description="Manage the mode of Remote access to the device"
/>
<div className="space-y-4">
{!isAdopted && (
<>
<SettingsItem
title="Cloud Provider"
description="Select the cloud provider for your device"
>
<SelectMenuBasic
size="SM"
value={selectedProvider}
onChange={e => handleProviderChange(e.target.value)}
options={[
{ value: "jetkvm", label: "JetKVM Cloud" },
{ value: "custom", label: "Custom" },
]}
/>
</SettingsItem>
{selectedProvider === "custom" && (
<div className="mt-4 space-y-4">
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud API URL"
value={cloudApiUrl}
onChange={e => setCloudApiUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Cloud App URL"
value={cloudAppUrl}
onChange={e => setCloudAppUrl(e.target.value)}
placeholder="https://app.example.com"
/>
</div>
</div>
)}
</>
)}
{/* Show security info for JetKVM Cloud */}
{selectedProvider === "jetkvm" && (
<GridCard>
<div className="flex items-start gap-x-4 p-4">
<ShieldCheckIcon className="mt-1 h-8 w-8 shrink-0 text-blue-600 dark:text-blue-500" />
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Cloud Security
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>End-to-end encryption using WebRTC (DTLS and SRTP)</li>
<li>Zero Trust security model</li>
<li>OIDC (OpenID Connect) authentication</li>
<li>All streams encrypted in transit</li>
</ul>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
All cloud components are open-source and available on{" "}
<a
href="https://github.com/jetkvm"
target="_blank"
rel="noopener noreferrer"
className="font-medium text-blue-600 hover:text-blue-800 dark:text-blue-500 dark:hover:text-blue-400"
>
GitHub
</a>
.
</div>
</div>
<hr className="block w-full border-slate-800/20 dark:border-slate-300/20" />
<div>
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
size="SM"
theme="light"
text="Learn about our cloud security"
/>
</div>
</div>
</div>
</GridCard>
)}
{!isAdopted ? (
<div className="flex items-end gap-x-2">
<div className="space-y-4">
{/* Add TailScale settings item */}
<SettingsItem
title="TailScale"
badge="Experimental"
description="Connect to TailScale VPN network"
>
<div className="space-y-4">
{ ((tailScaleConnectionState === "disconnected") || (tailScaleConnectionState === "closed")) && (
<Button
onClick={() => onCloudAdoptClick(cloudApiUrl, cloudAppUrl)}
size="SM"
theme="primary"
text="Adopt KVM to Cloud"
theme="light"
text="Enable TailScale"
onClick={handleTailScaleLogin}
/>
)}
</div>
</SettingsItem>
<SettingsItem
title=""
description="TailScale use xEdge server"
>
<Checkbox
checked={tailScaleXEdge}
onChange={e => {
if (tailScaleConnectionState !== "disconnected") {
notifications.error("TailScale is running and this setting cannot be modified");
return;
}
handleTailScaleXEdgeChange(e.target.checked);
}}
/>
</SettingsItem>
</div>
<div className="space-y-4">
{tailScaleConnectionState === "connecting" && (
<div className="flex items-center justify-between gap-x-2">
<p>Connecting...</p>
<Button
size="SM"
theme="light"
text="Canel"
onClick={handleTailScaleCanel}
/>
</div>
)}
{tailScaleConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
{tailScaleLoginUrl && (
<p>Login URL: <a href={tailScaleLoginUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 dark:text-blue-400">LoginUrl</a></p>
)}
{!tailScaleLoginUrl && (
<p>Wait to obtain the Login URL</p>
)}
<Button
size="SM"
theme="light"
text= { isDisconnecting ? "Quitting..." : "Quit"}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
) : (
<div>
<div className="space-y-2">
<p className="text-sm text-slate-600 dark:text-slate-300">
Your device is adopted to the Cloud
</p>
<div>
<Button
size="SM"
theme="light"
text="De-register from Cloud"
className="text-red-600"
onClick={() => {
if (deviceId) {
if (
window.confirm(
"Are you sure you want to de-register this device?",
)
) {
deregisterDevice();
}
} else {
notifications.error("No device ID available");
}
}}
/>
</div>
</div>
</div>
)}
{tailScaleConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>IP: {tailScaleIP}</p>
<Button
size="SM"
theme="light"
text= { isDisconnecting ? "Quitting..." : "Quit"}
onClick={handleTailScaleLogout}
disabled={ isDisconnecting === true }
/>
</div>
)}
</div>
</div>
)}
{tailScaleConnectionState === "closed" && (
<div className="text-sm text-red-600 dark:text-red-400">
<p>Connect fail, please retry</p>
</div>
)}
</div>
<div className="space-y-4">
{/* Add ZeroTier settings item */}
<SettingsItem
title="ZeroTier"
badge="Experimental"
description="Connect to ZeroTier VPN network"
>
</SettingsItem>
</div>
<div className="space-y-4">
{zeroTierConnectionState === "connecting" && (
<div className="text-sm text-slate-700 dark:text-slate-300">
<p>Connecting...</p>
</div>
)}
{zeroTierConnectionState === "connected" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>Network ID: {zeroTierNetworkID}</p>
<Button
size="SM"
theme="light"
text="Quit"
onClick={handleZeroTierLogout}
/>
</div>
</div>
)}
{zeroTierConnectionState === "logined" && (
<div className="space-y-4">
<div className="flex items-center gap-x-2 justify-between">
<p>Network ID: {zeroTierNetworkID}</p>
<Button
size="SM"
theme="light"
text="Quit"
onClick={handleZeroTierLogout}
/>
</div>
<div className="flex items-center gap-x-2 justify-between">
<p>Network IP: {zeroTierIP}</p>
</div>
</div>
)}
{zeroTierConnectionState === "closed" && (
<div className="flex items-center gap-x-2 justify-between">
<p>Connect fail, please retry</p>
<Button
size="SM"
theme="light"
text="Retry"
onClick={handleZeroTierLogout}
/>
</div>
)
}
</div>
<div className="space-y-4">
{(zeroTierConnectionState === "disconnected") && (
<div className="flex items-end gap-x-2">
<InputFieldWithLabel
size="SM"
label="Network ID"
value={tempNetworkID}
onChange={handleZeroTierNetworkIdChange}
placeholder="Enter ZeroTier Network ID"
/>
<Button
size="SM"
theme="light"
text="Join in"
onClick={handleZeroTierLogin}
/>
</div>
)}
</div>
</div>
</div>
);

View File

@@ -27,12 +27,6 @@ export default function SettingsAdvancedRoute() {
const settings = useSettingsStore();
useEffect(() => {
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
setDeveloperMode(result.enabled);
});
send("getSSHKeyState", {}, resp => {
if ("error" in resp) return;
setSSHKey(resp.result as string);
@@ -43,11 +37,6 @@ export default function SettingsAdvancedRoute() {
setUsbEmulationEnabled(resp.result as boolean);
});
send("getDevChannelState", {}, resp => {
if ("error" in resp) return;
setDevChannel(resp.result as boolean);
});
send("getLocalLoopbackOnly", {}, resp => {
if ("error" in resp) return;
setLocalLoopbackOnly(resp.result as boolean);
@@ -101,36 +90,6 @@ export default function SettingsAdvancedRoute() {
});
}, [send, sshKey]);
const handleDevModeChange = useCallback(
(developerMode: boolean) => {
send("setDevModeState", { enabled: developerMode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDeveloperMode(developerMode);
});
},
[send, setDeveloperMode],
);
const handleDevChannelChange = useCallback(
(enabled: boolean) => {
send("setDevChannelState", { enabled }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set dev channel state: ${resp.error.data || "Unknown error"}`,
);
return;
}
setDevChannel(enabled);
});
},
[send, setDevChannel],
);
const applyLoopbackOnlyMode = useCallback(
(enabled: boolean) => {
send("setLocalLoopbackOnly", { enabled }, resp => {
@@ -181,63 +140,6 @@ export default function SettingsAdvancedRoute() {
/>
<div className="space-y-4">
<SettingsItem
title="Dev Channel Updates"
description="Receive early updates from the development channel"
>
<Checkbox
checked={devChannel}
onChange={e => {
handleDevChannelChange(e.target.checked);
}}
/>
</SettingsItem>
<SettingsItem
title="Developer Mode"
description="Enable advanced features for developers"
>
<Checkbox
checked={settings.developerMode}
onChange={e => handleDevModeChange(e.target.checked)}
/>
</SettingsItem>
{settings.developerMode && (
<GridCard>
<div className="flex items-start gap-x-4 p-4 select-none">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="mt-1 h-8 w-8 shrink-0 text-amber-600 dark:text-amber-500"
>
<path
fillRule="evenodd"
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
clipRule="evenodd"
/>
</svg>
<div className="space-y-3">
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-900 dark:text-white">
Developer Mode Enabled
</h3>
<div>
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-700 dark:text-slate-300">
<li>Security is weakened while active</li>
<li>Only use if you understand the risks</li>
</ul>
</div>
</div>
<div className="text-xs text-slate-700 dark:text-slate-300">
For advanced users only. Not for production use.
</div>
</div>
</div>
</GridCard>
)}
<SettingsItem
title="Loopback-Only Mode"
description="Restrict web interface access to localhost only (127.0.0.1)"
@@ -248,7 +150,7 @@ export default function SettingsAdvancedRoute() {
/>
</SettingsItem>
{isOnDevice && settings.developerMode && (
{isOnDevice && (
<div className="space-y-4">
<SettingsItem
title="SSH Access"

View File

@@ -32,7 +32,7 @@ export default function SettingsAppearanceRoute() {
<div className="space-y-4">
<SettingsPageHeader
title="Appearance"
description="Customize the look and feel of your JetKVM interface"
description="Customize the look and feel of your KVM interface"
/>
<SettingsItem title="Theme" description="Choose your preferred color theme">
<SelectMenuBasic

View File

@@ -13,7 +13,7 @@ export default function SettingsCtrlAltDelRoute() {
<div className="space-y-4">
<SettingsPageHeader
title="Action Bar"
description="Customize the action bar of your JetKVM interface"
description="Customize the action bar of your KVM interface"
/>
<div className="space-y-4">
<SettingsItem title="Enable Ctrl-Alt-Del" description="Enable the Ctrl-Alt-Del key on the virtual keyboard">

View File

@@ -53,7 +53,7 @@ export default function SettingsGeneralRoute() {
<div className="space-y-4 pb-2">
<div className="mt-2 flex items-center justify-between gap-x-2">
<SettingsItem
title="Check for Updates"
title="Version"
description={
currentVersions ? (
<>
@@ -79,7 +79,7 @@ export default function SettingsGeneralRoute() {
/>
</div>
</div>
<div className="space-y-4">
<div className="hidden space-y-4">
<SettingsItem
title="Auto Update"
description="Automatically update the device to the latest version"

View File

@@ -9,6 +9,13 @@ import { UpdateState, useDeviceStore, useUpdateStore } from "@/hooks/stores";
import notifications from "@/notifications";
import LoadingSpinner from "@/components/LoadingSpinner";
import { useDeviceUiNavigation } from "@/hooks/useAppNavigation";
import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { SettingsItem } from "./devices.$id.settings";
const updateSourceOptions = [
{ value: "github", label:"github"},
{ value: "gitee", label:"gitee"},
];
export default function SettingsGeneralUpdateRoute() {
const navigate = useNavigate();
@@ -49,6 +56,12 @@ export interface SystemVersionInfo {
error?: string;
}
export interface LocalVersionInfo {
appVersion: string;
systemVersion: string;
}
export function Dialog({
onClose,
onConfirmUpdate,
@@ -435,6 +448,23 @@ function UpdateAvailableState({
onConfirmUpdate: () => void;
onClose: () => void;
}) {
const [send] = useJsonRpc();
const [updateSource, setUpdateSource] = useState("github");
const handleUpdateSourceChange = (source: string) => {
send("setUpdateSource", { source: source }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set stream quality: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Update source set to ${updateSourceOptions.find(x => x.value === source)?.label}`);
setUpdateSource(source);
});
};
return (
<div className="flex flex-col items-start justify-start space-y-4 text-left">
<div className="text-left">
@@ -460,9 +490,27 @@ function UpdateAvailableState({
</>
) : null}
</p>
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
<div className="space-y-4">
<SettingsItem
title="Update Source"
description="Select the update source"
>
<SelectMenuBasic
size="SM"
label=""
value={updateSource}
options={updateSourceOptions}
onChange={e => handleUpdateSourceChange(e.target.value)}
/>
</SettingsItem>
</div>
<div className="space-y-4">
<div className="flex items-center justify-start gap-x-2">
<Button size="SM" theme="primary" text="Update Now" onClick={onConfirmUpdate} />
<Button size="SM" theme="light" text="Do it later" onClick={onClose} />
</div>
</div>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useCallback } from "react";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsItem } from "@routes/devices.$id.settings";
@@ -6,6 +6,8 @@ import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { InputField } from "@/components/InputField";
import { Button, LinkButton } from "@/components/Button";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
@@ -71,11 +73,96 @@ export default function SettingsHardwareRoute() {
});
}, [send, setBacklightSettings]);
const setTimeZone = useSettingsStore(state => state.setTimeZone);
const handleTimeZoneSave = () => {
send("setTimeZone", { timeZone: settings.timeZone }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set time zone: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Time zone updated successfully");
});
};
const handleTimeZoneChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.trim();
setTimeZone(value);
}, []);
useEffect(() => {
send("getTimeZone", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get time zone: ${resp.error.data || "Unknown error"}`,
);
}
console.log("Time zone:", resp.result);
const result = resp.result as string;
setTimeZone(result);
});
}, [send, setTimeZone]);
const setLedGreenMode = useSettingsStore(state => state.setLedGreenMode);
const setLedYellowMode = useSettingsStore(state => state.setLedYellowMode);
const handleLedGreenModeChange = (mode: string) => {
setLedGreenMode(mode);
send("setLedGreenMode", { mode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set LED-Green mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("LED-Green mode updated successfully");
});
};
const handleLedYellowModeChange = (mode: string) => {
setLedYellowMode(mode);
send("setLedYellowMode", { mode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set LED-Yellow mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("LED-Yellow mode updated successfully");
});
};
useEffect(() => {
send("getLedGreenMode", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get LED-Green mode: ${resp.error.data || "Unknown error"}`,
);
}
console.log("LED-Green mode:", resp.result);
const result = resp.result as string;
setLedGreenMode(result);
});
send("getLedYellowMode", {}, resp => {
if ("error" in resp) {
return notifications.error(
`Failed to get LED-Yellow mode: ${resp.error.data || "Unknown error"}`,
);
}
console.log("LED-Yellow mode:", resp.result);
const result = resp.result as string;
setLedYellowMode(result);
});
}, [send, setLedGreenMode, setLedYellowMode]);
return (
<div className="space-y-4">
<SettingsPageHeader
title="Hardware"
description="Configure display settings and hardware options for your JetKVM device"
description="Configure display settings and hardware options for your KVM device"
/>
<div className="space-y-4">
<SettingsItem
@@ -87,8 +174,10 @@ export default function SettingsHardwareRoute() {
label=""
value={settings.displayRotation.toString()}
options={[
{ value: "270", label: "Normal" },
{ value: "90", label: "Inverted" },
{ value: "180", label: "Normal" },
{ value: "90", label: "90" },
{ value: "0", label: "180" },
{ value: "270", label: "270" },
]}
onChange={e => {
settings.displayRotation = e.target.value;
@@ -106,9 +195,9 @@ export default function SettingsHardwareRoute() {
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
{ value: "64", label: "Low" },
{ value: "128", label: "Medium" },
{ value: "200", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value);
@@ -170,11 +259,78 @@ export default function SettingsHardwareRoute() {
}}
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
</>
)}
<p className="text-xs text-slate-600 dark:text-slate-400">
The display will wake up when the connection state changes, or when touched.
</p>
<SettingsItem
title="Time Zone"
description="Set the time zone for the clock"
>
</SettingsItem>
<div className="space-y-4">
<div className="flex items-end gap-x-2">
<InputField
size="SM"
value={settings.timeZone.toString()}
onChange={handleTimeZoneChange}
placeholder="Enter Time Zone"
/>
<Button
size="SM"
theme="light"
text="Set"
onClick={handleTimeZoneSave}
/>
</div>
</div>
<SettingsItem
title="LED-Green Type"
description="Set the type of system status indicated by the LED-Green"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.ledGreenMode.toString()}
options={[
{ value: "network-link", label: "network-link" },
{ value: "network-tx", label: "network-tx" },
{ value: "network-rx", label: "network-rx" },
{ value: "kernel-activity", label: "kernel-activity" },
]}
onChange={e => {
settings.ledGreenMode = e.target.value;
handleLedGreenModeChange(settings.ledGreenMode);
}}
/>
</SettingsItem>
<SettingsItem
title="LED-Yellow Type"
description="Set the type of system status indicated by the LED-Yellow"
>
<SelectMenuBasic
size="SM"
label=""
value={settings.ledYellowMode.toString()}
options={[
{ value: "network-link", label: "network-link" },
{ value: "network-tx", label: "network-tx" },
{ value: "network-rx", label: "network-rx" },
{ value: "kernel-activity", label: "kernel-activity" },
]}
onChange={e => {
settings.ledYellowMode = e.target.value;
handleLedYellowModeChange(settings.ledYellowMode);
}}
/>
</SettingsItem>
</div>
<FeatureFlag minAppVersion="0.3.8">

View File

@@ -87,7 +87,7 @@ export default function SettingsKeyboardRoute() {
/>
</SettingsItem>
<p className="text-xs text-slate-600 dark:text-slate-400">
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in JetKVM matches the settings in the operating system.
Pasting text sends individual key strokes to the target device. The keyboard layout determines which key codes are being sent. Ensure that the keyboard layout in KVM matches the settings in the operating system.
</p>
</div>

View File

@@ -86,7 +86,7 @@ export default function SettingsNetworkRoute() {
const [networkSettingsLoaded, setNetworkSettingsLoaded] = useState(false);
const [customDomain, setCustomDomain] = useState<string>("");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("dhcp");
const [selectedDomainOption, setSelectedDomainOption] = useState<string>("local");
useEffect(() => {
if (networkSettings.domain && networkSettingsLoaded) {
@@ -243,7 +243,8 @@ export default function SettingsNetworkRoute() {
<InputField
size="SM"
type="text"
placeholder="jetkvm"
value={networkSettings.hostname}
placeholder={networkSettings.hostname}
defaultValue={networkSettings.hostname}
onChange={e => {
handleHostnameChange(e.target.value);

View File

@@ -15,7 +15,7 @@ const defaultEdid =
const edids = [
{
value: defaultEdid,
label: "JetKVM Default",
label: "KVM Default",
},
{
value:
@@ -40,9 +40,16 @@ const streamQualityOptions = [
{ value: "0.1", label: "Low" },
];
const audioModeOptions = [
{ value: "disabled", label: "Disabled"},
{ value: "usb", label: "USB"},
//{ value: "hdmi", label: "HDMI"},
]
export default function SettingsVideoRoute() {
const [send] = useJsonRpc();
const [streamQuality, setStreamQuality] = useState("1");
const [audioMode, setAudioMode] = useState("disabled");
const [customEdidValue, setCustomEdidValue] = useState<string | null>(null);
const [edid, setEdid] = useState<string | null>(null);
@@ -55,6 +62,11 @@ export default function SettingsVideoRoute() {
const setVideoContrast = useSettingsStore(state => state.setVideoContrast);
useEffect(() => {
send("getAudioMode", {}, resp => {
if ("error" in resp) return;
setAudioMode(String(resp.result));
});
send("getStreamQualityFactor", {}, resp => {
if ("error" in resp) return;
setStreamQuality(String(resp.result));
@@ -84,6 +96,20 @@ export default function SettingsVideoRoute() {
});
}, [send]);
const handleAudioModeChange = (mode: string) => {
send("setAudioMode", { mode }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set Audio Mode: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success(`Audio Mode set to ${audioModeOptions.find(x => x.value === mode )?.label}.It takes effect after refreshing the page`);
setAudioMode(mode);
});
};
const handleStreamQualityChange = (factor: string) => {
send("setStreamQualityFactor", { factor: Number(factor) }, resp => {
if ("error" in resp) {
@@ -123,6 +149,20 @@ export default function SettingsVideoRoute() {
<div className="space-y-4">
<div className="space-y-4">
<SettingsItem
title="Audio Mode"
badge="Experimental"
description="Set the working mode of the audio"
>
<SelectMenuBasic
size="SM"
label=""
value={audioMode}
options={audioModeOptions}
onChange={e => handleAudioModeChange(e.target.value)}
/>
</SettingsItem>
<SettingsItem
title="Stream Quality"
description="Adjust the quality of the video stream"
@@ -169,7 +209,7 @@ export default function SettingsVideoRoute() {
step="0.1"
value={videoBrightness}
onChange={e => setVideoBrightness(parseFloat(e.target.value))}
className="w-32 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
className="w-32 h-2 appearance-none bg-gray-200 dark:bg-gray-700 rounded-lg cursor-pointer"
/>
</SettingsItem>

View File

@@ -16,37 +16,9 @@ import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config";
import api from "../api";
const loader = async ({ params }: LoaderFunctionArgs) => {
await checkAuth();
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
method: "GET",
mode: "cors",
credentials: "include",
});
if (res.ok) {
return res.json();
} else {
return redirect("/devices");
}
};
const action = async ({ request }: ActionFunctionArgs) => {
// Handle form submission
const { name, id, returnTo } = Object.fromEntries(await request.formData());
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name });
if (res.ok) {
return redirect(returnTo?.toString() ?? `/devices/${id}`);
} else {
return { error: "There was an error creating your device" };
}
};
export default function SetupRoute() {
const action = useActionData() as { error?: string };
const { id } = useParams() as { id: string };
@@ -105,6 +77,3 @@ export default function SetupRoute() {
</>
);
}
SetupRoute.loader = loader;
SetupRoute.action = action;

View File

@@ -39,7 +39,7 @@ import DashboardNavbar from "@components/Header";
import ConnectionStatsSidebar from "@/components/sidebar/connectionStats";
import { JsonRpcRequest, useJsonRpc } from "@/hooks/useJsonRpc";
import Terminal from "@components/Terminal";
import { CLOUD_API, DEVICE_API } from "@/ui.config";
import { DEVICE_API } from "@/ui.config";
import UpdateInProgressStatusCard from "../components/UpdateInProgressStatusCard";
import api from "../api";
@@ -54,7 +54,10 @@ import { FeatureFlagProvider } from "../providers/FeatureFlagProvider";
import notifications from "../notifications";
import { DeviceStatus } from "./welcome-local";
import { SystemVersionInfo } from "./devices.$id.settings.general.update";
import { SystemVersionInfo, LocalVersionInfo } from "./devices.$id.settings.general.update";
import { useVpnStore } from "@/hooks/stores";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@@ -74,12 +77,25 @@ export interface LocalDevice {
deviceId: string;
}
interface TailScaleResponse {
state: string;
loginUrl: string;
ip: string;
xEdge: boolean;
}
interface ZeroTierResponse {
state: string;
networkID: string;
ip: string;
}
const deviceLoader = async () => {
const res = await api
.GET(`${DEVICE_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
if (!res.isSetup) return redirect("/mode");
const deviceRes = await api.GET(`${DEVICE_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
@@ -91,31 +107,9 @@ const deviceLoader = async () => {
throw new Error("Error fetching device");
};
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
const user = await checkAuth();
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json();
const deviceResp = await api.GET(`${CLOUD_API}/devices/${params.id}`);
if (!deviceResp.ok) {
if (deviceResp.status === 404) {
throw new Response("Device not found", { status: 404 });
}
throw new Error("Error fetching device");
}
const { device } = (await deviceResp.json()) as {
device: { id: string; name: string; user: { googleId: string } };
};
return { user, iceConfig, deviceName: device.name || device.id };
};
const loader = async ({ params }: LoaderFunctionArgs) => {
return import.meta.env.MODE === "device" ? deviceLoader() : cloudLoader(params);
const loader = async ({}: LoaderFunctionArgs) => {
return deviceLoader();
};
export default function KvmIdRoute() {
@@ -139,6 +133,7 @@ export default function KvmIdRoute() {
const setDiskChannel = useRTCStore(state => state.setDiskChannel);
const setRpcDataChannel = useRTCStore(state => state.setRpcDataChannel);
const setTransceiver = useRTCStore(state => state.setTransceiver);
const setAudioTransceiver = useRTCStore(state => state.setAudioTransceiver);
const location = useLocation();
const isLegacySignalingEnabled = useRef(false);
@@ -238,9 +233,7 @@ export default function KvmIdRoute() {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { sendMessage, getWebSocket } = useWebSocket(
isOnDevice
? `${wsProtocol}//${window.location.host}/webrtc/signaling/client`
: `${CLOUD_API.replace("http", "ws")}/webrtc/signaling/client?id=${params.id}`,
`${wsProtocol}//${window.location.host}/webrtc/signaling/client?id=${params.id}`,
{
heartbeat: true,
retryOnError: true,
@@ -358,42 +351,6 @@ export default function KvmIdRoute() {
[sendMessage],
);
const legacyHTTPSignaling = useCallback(
async (pc: RTCPeerConnection) => {
const sd = btoa(JSON.stringify(pc.localDescription));
// Legacy mode == UI in cloud with updated code connecting to older device version.
// In device mode, old devices wont server this JS, and on newer devices legacy mode wont be enabled
const sessionUrl = `${CLOUD_API}/webrtc/session`;
console.log("Trying to get remote session description");
setLoadingMessage(
`Getting remote session description... ${signalingAttempts.current > 0 ? `(attempt ${signalingAttempts.current + 1})` : ""}`,
);
const res = await api.POST(sessionUrl, {
sd,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
});
const json = await res.json();
if (res.status === 401) return navigate(isOnDevice ? "/login-local" : "/login");
if (!res.ok) {
console.error("Error getting SDP", { status: res.status, json });
cleanupAndStopReconnecting();
return;
}
console.log("Successfully got Remote Session Description. Setting.");
setLoadingMessage("Setting remote session description...");
const decodedSd = atob(json.sd);
const parsedSd = JSON.parse(decodedSd);
setRemoteSessionDescription(pc, new RTCSessionDescription(parsedSd));
},
[cleanupAndStopReconnecting, navigate, params.id, setRemoteSessionDescription],
);
const setupPeerConnection = useCallback(async () => {
console.log("[setupPeerConnection] Setting up peer connection");
setConnectionFailed(false);
@@ -464,10 +421,6 @@ export default function KvmIdRoute() {
console.log("ICE Gathering completed");
setLoadingMessage("ICE Gathering completed");
if (isLegacySignalingEnabled.current) {
// We can now start the https/ws connection to get the remote session description from the KVM device
legacyHTTPSignaling(pc);
}
} else if (pc.iceGatheringState === "gathering") {
console.log("ICE Gathering Started");
setLoadingMessage("Gathering ICE candidates...");
@@ -479,6 +432,7 @@ export default function KvmIdRoute() {
};
setTransceiver(pc.addTransceiver("video", { direction: "recvonly" }));
pc.addTransceiver("audio", { direction: "recvonly" });
const rpcDataChannel = pc.createDataChannel("rpc");
rpcDataChannel.onopen = () => {
@@ -494,7 +448,6 @@ export default function KvmIdRoute() {
}, [
cleanupAndStopReconnecting,
iceConfig?.iceServers,
legacyHTTPSignaling,
sendWebRTCSignal,
setDiskChannel,
setMediaMediaStream,
@@ -502,6 +455,7 @@ export default function KvmIdRoute() {
setPeerConnectionState,
setRpcDataChannel,
setTransceiver,
setAudioTransceiver,
]);
useEffect(() => {
@@ -547,40 +501,57 @@ export default function KvmIdRoute() {
setIsTurnServerInUse(localCandidateIsUsingTurn || remoteCandidateIsUsingTurn);
}, [peerConnectionState, setIsTurnServerInUse]);
// Vpn State Update
const tailScaleConnectionState = useVpnStore(state => state.tailScaleConnectionState);
const setTailScaleConnectionState = useVpnStore(state => state.setTailScaleConnectionState);
const tailScaleXEdge = useVpnStore(state => state.tailScaleXEdge);
const setTailScaleXEdge = useVpnStore(state => state.setTailScaleXEdge);
const setTailScaleLoginUrl = useVpnStore(state => state.setTailScaleLoginUrl);
const setTailScaleIP = useVpnStore(state => state.setTailScaleIP);
// TURN server usage reporting
const isTurnServerInUse = useRTCStore(state => state.isTurnServerInUse);
const lastBytesReceived = useRef<number>(0);
const lastBytesSent = useRef<number>(0);
const zeroTierConnectionState = useVpnStore(state => state.zeroTierConnectionState);
const zeroTierNetworkID = useVpnStore(state => state.zeroTierNetworkID);
const setZeroTierConnectionState = useVpnStore(state => state.setZeroTierConnectionState);
const setZeroTierNetworkID = useVpnStore(state => state.setZeroTierNetworkID);
const setZeroTierIP = useVpnStore(state => state.setZeroTierIP);
const updateVpnStates = () => {
// TailScaleState
if (tailScaleConnectionState !== "connecting" && tailScaleConnectionState !== "closed") {
send("getTailScaleSettings", {}, resp => {
if ("error" in resp) return;
const result = resp.result as TailScaleResponse;
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
useInterval(() => {
// Don't report usage if we're not using the turn server
if (!isTurnServerInUse) return;
const { candidatePairStats } = useRTCStore.getState();
const lastCandidatePair = Array.from(candidatePairStats).pop();
const report = lastCandidatePair?.[1];
if (!report) return;
let bytesReceivedDelta = 0;
let bytesSentDelta = 0;
if (report.bytesReceived) {
bytesReceivedDelta = report.bytesReceived - lastBytesReceived.current;
lastBytesReceived.current = report.bytesReceived;
if(tailScaleConnectionState !== "disconnected" ) {
setTailScaleXEdge(result.xEdge);
}
setTailScaleConnectionState(validState);
setTailScaleLoginUrl(result.loginUrl);
setTailScaleIP(result.ip);
});
}
if (report.bytesSent) {
bytesSentDelta = report.bytesSent - lastBytesSent.current;
lastBytesSent.current = report.bytesSent;
// ZeroTier
if (zeroTierConnectionState !== "connecting" && zeroTierConnectionState !== "closed") {
send("getZeroTierSettings", {}, resp => {
if ("error" in resp) return;
const result = resp.result as ZeroTierResponse;
const validState = ["closed", "connecting", "connected", "disconnected", "logined"].includes(result.state)
? result.state as "closed" | "connecting" | "connected" | "disconnected" | "logined"
: "closed";
setZeroTierConnectionState(validState);
setZeroTierNetworkID(result.networkID);
setZeroTierIP(result.ip);
});
}
}
// Fire and forget
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
});
}, 10000);
useInterval(updateVpnStates, 5000);
const setNetworkState = useNetworkStateStore(state => state.setNetworkState);
@@ -654,6 +625,7 @@ export default function KvmIdRoute() {
if ("error" in resp) return;
setHdmiState(resp.result as Parameters<VideoState["setHdmiState"]>[0]);
});
updateVpnStates();
}, [rpcDataChannel?.readyState, send, setHdmiState]);
// request keyboard led state from the device
@@ -714,14 +686,20 @@ export default function KvmIdRoute() {
useEffect(() => {
if (!peerConnection) return;
if (!kvmTerminal) {
setKvmTerminal(peerConnection.createDataChannel("terminal"));
}
//if (!kvmTerminal) {
// setKvmTerminal(peerConnection.createDataChannel("terminal"));
//}
if (!serialConsole) {
setSerialConsole(peerConnection.createDataChannel("serial"));
}
}, [kvmTerminal, peerConnection, serialConsole]);
//if (!serialConsole) {
// setSerialConsole(peerConnection.createDataChannel("serial"));
//}
const terminalChannel = peerConnection.createDataChannel("terminal");
setKvmTerminal(terminalChannel);
const serialChannel = peerConnection.createDataChannel("serial");
setSerialConsole(serialChannel);
//}, [kvmTerminal, peerConnection, serialConsole]);
}, [peerConnection]);
const outlet = useOutlet();
const onModalClose = useCallback(() => {
@@ -735,19 +713,15 @@ export default function KvmIdRoute() {
useEffect(() => {
if (appVersion) return;
send("getUpdateStatus", {}, async resp => {
send("getLocalUpdateStatus", {}, async resp => {
if ("error" in resp) {
notifications.error(`Failed to get device version: ${resp.error}`);
return
}
const result = resp.result as SystemVersionInfo;
if (result.error) {
notifications.error(`Failed to get device version: ${result.error}`);
}
setAppVersion(result.local.appVersion);
setSystemVersion(result.local.systemVersion);
const result = resp.result as LocalVersionInfo;
setAppVersion(result.appVersion);
setSystemVersion(result.systemVersion);
});
}, [appVersion, send, setAppVersion, setSystemVersion]);
@@ -824,7 +798,7 @@ export default function KvmIdRoute() {
isLoggedIn={authMode === "password" || !!user}
userEmail={user?.email}
picture={user?.picture}
kvmName={deviceName ?? "JetKVM Device"}
kvmName={deviceName ?? "KVM Device"}
/>
<div className="relative flex h-full w-full overflow-hidden">

View File

@@ -9,31 +9,12 @@ import KvmCard from "@components/KvmCard";
import { LinkButton } from "@components/Button";
import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
user: User;
}
const loader = async () => {
const user = await checkAuth();
try {
const res = await fetch(`${CLOUD_API}/devices`, {
method: "GET",
credentials: "include",
mode: "cors",
});
const { devices } = await res.json();
return { devices, user };
} catch (e) {
console.error(e);
return { devices: [] };
}
};
export default function DevicesRoute() {
const { devices, user } = useLoaderData() as LoaderData;
const revalidate = useRevalidator();
@@ -66,10 +47,10 @@ export default function DevicesRoute() {
<EmptyCard
IconElm={LuMonitorSmartphone}
headline="No devices found"
description="You don't have any devices with enabled JetKVM Cloud yet."
description="You don't have any devices with enabled KVM Cloud yet."
BtnElm={
<LinkButton
to="https://jetkvm.com/docs/networking/remote-access"
to="https://wiki.luckfox.com/intro"
size="SM"
theme="primary"
TrailingIcon={ArrowRightIcon}
@@ -101,5 +82,3 @@ export default function DevicesRoute() {
</div>
);
}
DevicesRoute.loader = loader;

View File

@@ -8,8 +8,7 @@ import Container from "@components/Container";
import Fieldset from "@components/Fieldset";
import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import LogoLuckfox from "@/assets/logo-luckfox.png";
import { DEVICE_API } from "@/ui.config";
import api from "../api";
@@ -63,19 +62,19 @@ export default function LoginLocalRoute() {
<div className="-mt-32 max-w-2xl space-y-8">
<div className="flex items-center justify-center">
<img
src={LogoWhiteIcon}
src={LogoLuckfox}
alt=""
className="-ml-4 hidden h-[32px] dark:block"
/>
<img src={LogoBlueIcon} alt="" className="-ml-4 h-[32px] dark:hidden" />
<img src={LogoLuckfox} alt="" className="-ml-4 h-[32px] dark:hidden" />
</div>
<div className="space-y-2 text-center">
<h1 className="text-4xl font-semibold text-black dark:text-white">
Welcome back to JetKVM
Welcome back to KVM
</h1>
<p className="font-medium text-slate-600 dark:text-slate-400">
Enter your password to access your JetKVM.
Enter your password to access your KVM.
</p>
</div>
@@ -120,7 +119,7 @@ export default function LoginLocalRoute() {
<div className="mt-4 flex justify-start text-xs text-slate-500 dark:text-slate-400">
<ExtLink
href="https://jetkvm.com/docs/networking/local-access#reset-password"
href="https://wiki.luckfox.com/intro"
className="hover:underline"
>
Forgot password?

Some files were not shown because too many files have changed in this diff Show More