Compare commits

...

6 Commits

Author SHA1 Message Date
Ben Kochie c30fda5fa4
Merge 9b3a3d0977 into 1973a65635 2025-02-17 16:37:37 +01:00
Adam Shiervani 1973a65635
feat(dev): change default npm dev script to device mode and add wrapper script to set environment variable and proxy signal api (#157) 2025-02-17 16:29:42 +01:00
Aveline f3b4dbce49
feat: use the api url from device config (#161) 2025-02-17 11:34:38 +01:00
Aveline 806792203f
feat(ntp): add delay between sync attempts and support NTP servers from DHCP (#162)
* feat(ntp): use ntp server from dhcp info

* feat(ntp): use ntp server from dhcp info

* feat(ntp): add delay between time sync attempts

* chore(ntp): more logging
2025-02-17 11:02:28 +01:00
Cameron Fleming 5217377175
feat(display.go): Add display brightness settings (#17)
* feat(display.go): impl setDisplayBrightness()

Implements setDisplayBrightness(brightness int) which allows setting the
backlight brightness on JetKVM's hardware.

Needs to be implemented into the RPC, config and frontend.

* feat(config): add backlight control settings

* feat(display): add automatic dimming & switch off to display

WIP, dims the display to 50% of the BacklightMaxBrightness after
BacklightDimAfterMS expires. Turns the display off after
BacklightOffAfterMS

* feat(rpc): add methods to get and set BacklightSettings

* WIP: feat(settings): add Max backlight setting

* chore: use constant for backlight control file

* fix: only attempt to wake the display if it's off

* feat(display): wake on touch

* fix: re-use buffer between reads

* fix: wakeDisplay() on start to fix warm start issue

If the application had turned off the display before exiting, it
wouldn't be brought on when the application restarted without a device
reboot.

* chore: various comment & string updates

* fix: newline on set brightness log

Noticed by @eric
https://github.com/jetkvm/kvm/pull/17#discussion_r1903338705

* fix: set default value for display

Set the DisplayMaxBrightness to the default brightness used
out-of-the-box by JetKVM. Also sets the dim/timeout to 2 minutes and 30
mintes respectively.

* feat(display.go): use tickers to countdown to dim/off

As suggested by tutman in https://github.com/jetkvm/kvm/pull/17, use
tickers set to the duration of dim/off to avoid a loop running every
second. The tickers are reset to the dim/off times whenever
wakeDisplay() is called.

* chore: update config

Changed Dim & Off values to seconds instead of milliseconds, there's no
need for it to be that precise.

* feat(display.go): wakeDisplay() force

Adds the force boolean to wakedisplay() which allows skipping the
backlightState == 0 check, this means you can force a ticker reset, even
if the display is currently in the "full bright" state

* feat(display.go): move tickers into their own method

This allows them to only be started if necessary. If the user has chosen
to keep the display on and not-dimmed all the time, the tickers can't
start as their tick value must be a positive integer.

* feat(display.go): stop tickers when auto-dim/auto-off is disabled

* feat(rpc): implement display backlight control methods

* feat(ui): implement display backlight control

* chore: update variable names

As part of @joshuasing's review on #17, updated variables & constants to
match the Go best practices.

Signed-off-by: Cameron Fleming <cameron@nevexo.space>

* fix(display): move backlightTicker setup into screen setup goroutine

Signed-off-by: Cameron Fleming <cameron@nevexo.space>

* chore: fix some start-up timing issues

* fix(display): Don't attempt to start the tickers if the display is disabled

If max_brightness is zero, then there's no point in trying to dim it (or
turn it off...)

* fix: wakeDisplay() doesn't need to stop the tickers

The tickers only need to be reset, if they're disabled, they won't have
been started.

* fix: Don't wake up the display if it's turned off

---------

Signed-off-by: Cameron Fleming <cameron@nevexo.space>
2025-02-17 11:00:28 +01:00
SuperQ 9b3a3d0977
Feature: Add a metrics endpoint
Add a basic Prometheus Monitoring metrics endpoint.
* Include a `jetkvm_build_info` metric.
* `go mod tidy`

Signed-off-by: SuperQ <superq@gmail.com>
2025-01-03 11:41:57 +01:00
32 changed files with 685 additions and 111 deletions

View File

@ -1,12 +1,21 @@
BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE ?= $(shell date --iso-8601=seconds)
REVISION ?= $(shell git rev-parse HEAD)
VERSION_DEV := 0.3.5-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.3.4
GO_LDFLAGS := \
-s -w \
-X github.com/prometheus/common/version.Branch=$(BRANCH) \
-X github.com/prometheus/common/version.BuildDate=$(BUILDDATE) \
-X github.com/prometheus/common/version.Revision=$(REVISION)
hash_resource:
@shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256
build_dev: hash_resource
@echo "Building..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION_DEV)" -o bin/jetkvm_app cmd/main.go
frontend:
cd ui && npm ci && npm run build:device
@ -19,7 +28,7 @@ dev_release: build_dev
build_release: frontend hash_resource
@echo "Building release..."
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="$(GO_LDFLAGS) -X kvm.builtAppVersion=$(VERSION)" -o bin/jetkvm_app cmd/main.go
release:
@if rclone lsf r2://jetkvm-update/app/$(VERSION)/ | grep -q "jetkvm_app"; then \

View File

@ -23,6 +23,9 @@ type Config struct {
LocalAuthMode string `json:"localAuthMode"` //TODO: fix it with migration
WakeOnLanDevices []WakeOnLanDevice `json:"wake_on_lan_devices"`
EdidString string `json:"hdmi_edid_string"`
DisplayMaxBrightness int `json:"display_max_brightness"`
DisplayDimAfterSec int `json:"display_dim_after_sec"`
DisplayOffAfterSec int `json:"display_off_after_sec"`
}
const configPath = "/userdata/kvm_config.json"
@ -30,6 +33,9 @@ const configPath = "/userdata/kvm_config.json"
var defaultConfig = &Config{
CloudURL: "https://api.jetkvm.com",
AutoUpdateEnabled: true, // Set a default value
DisplayMaxBrightness: 64,
DisplayDimAfterSec: 120, // 2 minutes
DisplayOffAfterSec: 1800, // 30 minutes
}
var config *Config

View File

@ -1,12 +1,26 @@
package kvm
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
)
var currentScreen = "ui_Boot_Screen"
var backlightState = 0 // 0 - NORMAL, 1 - DIMMED, 2 - OFF
var (
dimTicker *time.Ticker
offTicker *time.Ticker
)
const (
touchscreenDevice string = "/dev/input/event1"
backlightControlClass string = "/sys/class/backlight/backlight/brightness"
)
func switchToScreen(screen string) {
_, err := CallCtrlAction("lv_scr_load", map[string]interface{}{"obj": screen})
@ -65,6 +79,7 @@ func requestDisplayUpdate() {
return
}
go func() {
wakeDisplay(false)
fmt.Println("display updating........................")
//TODO: only run once regardless how many pending updates
updateDisplay()
@ -83,6 +98,156 @@ func updateStaticContents() {
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.
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 {
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")
}
// Set the value
bs := []byte(strconv.Itoa(brightness))
err := os.WriteFile(backlightControlClass, bs, 0644)
if err != nil {
return err
}
fmt.Printf("display: set brightness to %v\n", brightness)
return nil
}
// tick_displayDim() is called when when dim ticker expires, it simply reduces the brightness
// of the display by half of the max brightness.
func tick_displayDim() {
err := setDisplayBrightness(config.DisplayMaxBrightness / 2)
if err != nil {
fmt.Printf("display: failed to dim display: %s\n", err)
}
dimTicker.Stop()
backlightState = 1
}
// tick_displayOff() is called when the off ticker expires, it turns off the display
// by setting the brightness to zero.
func tick_displayOff() {
err := setDisplayBrightness(0)
if err != nil {
fmt.Printf("display: failed to turn off display: %s\n", err)
}
offTicker.Stop()
backlightState = 2
}
// wakeDisplay sets the display brightness back to config.DisplayMaxBrightness and stores the time the display
// last woke, ready for displayTimeoutTick to put the display back in the dim/off states.
// Set force to true to skip the backlight state check, this should be done if altering the tickers.
func wakeDisplay(force bool) {
if backlightState == 0 && !force {
return
}
// Don't try to wake up if the display is turned off.
if config.DisplayMaxBrightness == 0 {
return
}
err := setDisplayBrightness(config.DisplayMaxBrightness)
if err != nil {
fmt.Printf("display wake failed, %s\n", err)
}
if config.DisplayDimAfterSec != 0 {
dimTicker.Reset(time.Duration(config.DisplayDimAfterSec) * time.Second)
}
if config.DisplayOffAfterSec != 0 {
offTicker.Reset(time.Duration(config.DisplayOffAfterSec) * time.Second)
}
backlightState = 0
}
// 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 {
fmt.Printf("display: failed to open touchscreen device: %s\n", err)
return
}
defer ts.Close()
// This buffer is set to 24 bytes as that's the normal size of events on /dev/input
// Reference: https://www.kernel.org/doc/Documentation/input/input.txt
// This could potentially be set higher, to require multiple events to wake the display.
buf := make([]byte, 24)
for {
_, err := ts.Read(buf)
if err != nil {
fmt.Printf("display: failed to read from touchscreen device: %s\n", err)
return
}
wakeDisplay(false)
}
}
// startBacklightTickers starts the two tickers for dimming and switching off the display
// if they're not already set. This is done separately to the init routine as the "never dim"
// option has the value set to zero, but time.NewTicker only accept positive values.
func startBacklightTickers() {
LoadConfig()
// Don't start the tickers if the display is switched off.
// Set the display to off if that's the case.
if config.DisplayMaxBrightness == 0 {
setDisplayBrightness(0)
return
}
if dimTicker == nil && config.DisplayDimAfterSec != 0 {
fmt.Printf("display: dim_ticker has started\n")
dimTicker = time.NewTicker(time.Duration(config.DisplayDimAfterSec) * time.Second)
defer dimTicker.Stop()
go func() {
for {
select {
case <-dimTicker.C:
tick_displayDim()
}
}
}()
}
if offTicker == nil && config.DisplayOffAfterSec != 0 {
fmt.Printf("display: off_ticker has started\n")
offTicker = time.NewTicker(time.Duration(config.DisplayOffAfterSec) * time.Second)
defer offTicker.Stop()
go func() {
for {
select {
case <-offTicker.C:
tick_displayOff()
}
}
}()
}
}
func init() {
go func() {
waitCtrlClientConnected()
@ -91,6 +256,10 @@ func init() {
updateStaticContents()
displayInited = true
fmt.Println("display inited")
startBacklightTickers()
wakeDisplay(true)
requestDisplayUpdate()
}()
go watchTsEvents()
}

24
go.mod
View File

@ -14,22 +14,27 @@ require (
github.com/google/uuid v1.6.0
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf
github.com/hanwen/go-fuse/v2 v2.5.1
github.com/hashicorp/go-envparse v0.1.0
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965
github.com/pion/logging v0.2.2
github.com/pion/mdns/v2 v2.0.7
github.com/pion/webrtc/v4 v4.0.0
github.com/pojntfx/go-nbd v0.3.2
github.com/prometheus/client_golang v1.20.5
github.com/prometheus/common v0.61.0
github.com/psanford/httpreadat v0.1.0
github.com/vishvananda/netlink v1.3.0
golang.org/x/crypto v0.28.0
golang.org/x/net v0.30.0
golang.org/x/crypto v0.30.0
golang.org/x/net v0.32.0
)
replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
@ -40,12 +45,13 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pilebones/go-udev v0.9.0 // indirect
github.com/pion/datachannel v1.5.9 // indirect
@ -61,16 +67,16 @@ require (
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/rogpeppe/go-internal v1.8.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/oauth2 v0.21.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
google.golang.org/protobuf v1.34.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
golang.org/x/oauth2 v0.24.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

66
go.sum
View File

@ -2,10 +2,14 @@ github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/beevik/ntp v1.3.1 h1:Y/srlT8L1yQr58kyPWFPZIxRL8ttx2SRIpVYJqZIlAM=
github.com/beevik/ntp v1.3.1/go.mod h1:fT6PylBq86Tsq23ZMEe47b7QQrZfYBFPnpzt0a9kJxw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b h1:dSbDgy72Y1sjLPWLv7vs0fMFuhMBMViiT9PJZiZWZNs=
github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b/go.mod h1:SehHnbi2e8NiSAKby42Itm8SIoS7b+wAprsfPH3qgYk=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
@ -16,7 +20,6 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA
github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/coreos/go-oidc/v3 v3.11.0 h1:Ia3MxdwpSw702YW0xgfmP1GVCMA9aEFWu12XUZ3/OtI=
github.com/coreos/go-oidc/v3 v3.11.0/go.mod h1:gE3LgjOgFoHi9a4ce4/tJczr0Ai2/BoDhf0r5lltWI0=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0=
github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -40,8 +43,8 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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=
@ -49,22 +52,26 @@ github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf h1:JO6ISZIvEUitto
github.com/gwatts/rootcerts v0.0.0-20240401182218-3ab9db955caf/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g=
github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uoQQ=
github.com/hanwen/go-fuse/v2 v2.5.1/go.mod h1:xKwi1cF7nXAOBCXujD5ie0ZKsxc8GGSA1rlMJc+8IJs=
github.com/hashicorp/go-envparse v0.1.0 h1:bE++6bhIsNCPLvgDZkYqo3nA+/PFI51pkrHdmPSDFPY=
github.com/hashicorp/go-envparse v0.1.0/go.mod h1:OHheN1GoygLlAkTlXLXvAdnXdZxy8JUweQ1rAXx1xnc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
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.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348 h1:MtvEpTB6LX3vkb4ax0b5D2DHbNAUsen0Gx5wZoq3lV4=
github.com/kylelemons/godebug v0.0.0-20170820004349-d65d576e9348/go.mod h1:B69LEHPfb2qLo0BaaOLcbitczOKLWTsrBG9LczfCD4k=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@ -76,6 +83,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965 h1:bZGtUfkOl0dqvem8ltx9KCYied0gSlRuDhaZDxgppN4=
github.com/openstadia/go-usb-gadget v0.0.0-20231115171102-aebd56bbb965/go.mod h1:6cAIK2c4O3/yETSrRjmNwsBL3yE4Vcu9M9p/Qwx5+gM=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
@ -114,14 +123,20 @@ github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8=
github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE=
github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@ -132,8 +147,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@ -147,29 +163,27 @@ github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguH
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/crypto v0.30.0 h1:RwoQn3GkWiMkzlX562cLB7OxWvjH1L8xutO2WoJcRoY=
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
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-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/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.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4=
google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -34,6 +34,12 @@ type JSONRPCEvent struct {
Params interface{} `json:"params,omitempty"`
}
type BacklightSettings struct {
MaxBrightness int `json:"max_brightness"`
DimAfter int `json:"dim_after"`
OffAfter int `json:"off_after"`
}
func writeJSONRPCResponse(response JSONRPCResponse, session *Session) {
responseBytes, err := json.Marshal(response)
if err != nil {
@ -225,6 +231,56 @@ func rpcTryUpdate() error {
return nil
}
func rpcSetBacklightSettings(params BacklightSettings) error {
LoadConfig()
blConfig := params
// NOTE: by default, the frontend limits the brightness to 64, as that's what the device originally shipped with.
if blConfig.MaxBrightness > 255 || blConfig.MaxBrightness < 0 {
return fmt.Errorf("maxBrightness must be between 0 and 255")
}
if blConfig.DimAfter < 0 {
return fmt.Errorf("dimAfter must be a positive integer")
}
if blConfig.OffAfter < 0 {
return fmt.Errorf("offAfter must be a positive integer")
}
config.DisplayMaxBrightness = blConfig.MaxBrightness
config.DisplayDimAfterSec = blConfig.DimAfter
config.DisplayOffAfterSec = blConfig.OffAfter
if err := SaveConfig(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
log.Printf("rpc: display: settings applied, max_brightness: %d, dim after: %ds, off after: %ds", config.DisplayMaxBrightness, config.DisplayDimAfterSec, config.DisplayOffAfterSec)
// If the device started up with auto-dim and/or auto-off set to zero, the display init
// method will not have started the tickers. So in case that has changed, attempt to start the tickers now.
startBacklightTickers()
// Wake the display after the settings are altered, this ensures the tickers
// are reset to the new settings, and will bring the display up to maxBrightness.
// Calling with force set to true, to ignore the current state of the display, and force
// it to reset the tickers.
wakeDisplay(true)
return nil
}
func rpcGetBacklightSettings() (*BacklightSettings, error) {
LoadConfig()
return &BacklightSettings{
MaxBrightness: config.DisplayMaxBrightness,
DimAfter: int(config.DisplayDimAfterSec),
OffAfter: int(config.DisplayOffAfterSec),
}, nil
}
const (
devModeFile = "/userdata/jetkvm/devmode.enable"
sshKeyDir = "/userdata/dropbear/.ssh"
@ -385,7 +441,7 @@ func callRPCHandler(handler RPCHandler, params map[string]interface{}) (interfac
}
args[i] = reflect.ValueOf(newStruct).Elem()
} else {
return nil, fmt.Errorf("invalid parameter type for: %s", paramName)
return nil, fmt.Errorf("invalid parameter type for: %s, type: %s", paramName, paramType.Kind())
}
} else {
args[i] = convertedValue.Convert(paramType)
@ -560,4 +616,6 @@ var rpcHandlers = map[string]RPCHandler{
"getWakeOnLanDevices": {Func: rpcGetWakeOnLanDevices},
"setWakeOnLanDevices": {Func: rpcSetWakeOnLanDevices, Params: []string{"params"}},
"resetConfig": {Func: rpcResetConfig},
"setBacklightSettings": {Func: rpcSetBacklightSettings, Params: []string{"params"}},
"getBacklightSettings": {Func: rpcGetBacklightSettings},
}

View File

@ -1,13 +1,19 @@
package kvm
import (
"bytes"
"fmt"
"net"
"os"
"strings"
"time"
"os/exec"
"github.com/hashicorp/go-envparse"
"github.com/pion/mdns/v2"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
"net"
"os/exec"
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
@ -15,11 +21,15 @@ import (
var mDNSConn *mdns.Conn
var networkState struct {
var networkState NetworkState
type NetworkState struct {
Up bool
IPv4 string
IPv6 string
MAC string
checked bool
}
type LocalIpInfo struct {
@ -28,43 +38,45 @@ type LocalIpInfo struct {
MAC string
}
const (
NetIfName = "eth0"
DHCPLeaseFile = "/run/udhcpc.%s.info"
)
// setDhcpClientState sends signals to udhcpc to change it's current mode
// of operation. Setting active to true will force udhcpc to renew the DHCP lease.
// Setting active to false will put udhcpc into idle mode.
func setDhcpClientState(active bool) {
var signal string;
var signal string
if active {
signal = "-SIGUSR1"
} else {
signal = "-SIGUSR2"
}
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc");
cmd := exec.Command("/usr/bin/killall", signal, "udhcpc")
if err := cmd.Run(); err != nil {
fmt.Printf("network: setDhcpClientState: failed to change udhcpc state: %s\n", err)
}
}
func checkNetworkState() {
iface, err := netlink.LinkByName("eth0")
iface, err := netlink.LinkByName(NetIfName)
if err != nil {
fmt.Printf("failed to get eth0 interface: %v\n", err)
fmt.Printf("failed to get [%s] interface: %v\n", NetIfName, err)
return
}
newState := struct {
Up bool
IPv4 string
IPv6 string
MAC string
}{
newState := NetworkState{
Up: iface.Attrs().OperState == netlink.OperUp,
MAC: iface.Attrs().HardwareAddr.String(),
checked: true,
}
addrs, err := netlink.AddrList(iface, nl.FAMILY_ALL)
if err != nil {
fmt.Printf("failed to get addresses for eth0: %v\n", err)
fmt.Printf("failed to get addresses for [%s]: %v\n", NetIfName, err)
}
// If the link is going down, put udhcpc into idle mode.
@ -94,7 +106,7 @@ func checkNetworkState() {
if newState != networkState {
fmt.Println("network state changed")
//restart MDNS
// restart MDNS
startMDNS()
networkState = newState
requestDisplayUpdate()
@ -102,7 +114,7 @@ func checkNetworkState() {
}
func startMDNS() error {
//If server was previously running, stop it
// If server was previously running, stop it
if mDNSConn != nil {
fmt.Printf("Stopping mDNS server\n")
err := mDNSConn.Close()
@ -111,7 +123,7 @@ func startMDNS() error {
}
}
//Start a new server
// Start a new server
fmt.Printf("Starting mDNS server on jetkvm.local\n")
addr4, err := net.ResolveUDPAddr("udp4", mdns.DefaultAddressIPv4)
if err != nil {
@ -144,6 +156,39 @@ func startMDNS() error {
return nil
}
func getNTPServersFromDHCPInfo() ([]string, error) {
buf, err := os.ReadFile(fmt.Sprintf(DHCPLeaseFile, NetIfName))
if err != nil {
// do not return error if file does not exist
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to load udhcpc info: %w", err)
}
// parse udhcpc info
env, err := envparse.Parse(bytes.NewReader(buf))
if err != nil {
return nil, fmt.Errorf("failed to parse udhcpc info: %w", err)
}
val, ok := env["ntpsrv"]
if !ok {
return nil, nil
}
var servers []string
for _, server := range strings.Fields(val) {
if net.ParseIP(server) == nil {
fmt.Printf("invalid NTP server IP: %s, ignoring ... \n", server)
}
servers = append(servers, server)
}
return servers, nil
}
func init() {
updates := make(chan netlink.LinkUpdate)
done := make(chan struct{})
@ -162,7 +207,7 @@ func init() {
for {
select {
case update := <-updates:
if update.Link.Attrs().Name == "eth0" {
if update.Link.Attrs().Name == NetIfName {
fmt.Printf("link update: %+v\n", update)
checkNetworkState()
}

61
ntp.go
View File

@ -11,20 +11,56 @@ import (
"github.com/beevik/ntp"
)
var timeSynced = false
const (
timeSyncRetryStep = 5 * time.Second
timeSyncRetryMaxInt = 1 * time.Minute
timeSyncWaitNetChkInt = 100 * time.Millisecond
timeSyncWaitNetUpInt = 3 * time.Second
timeSyncInterval = 1 * time.Hour
timeSyncTimeout = 2 * time.Second
)
var (
timeSynced = false
timeSyncRetryInterval = 0 * time.Second
defaultNTPServers = []string{
"time.cloudflare.com",
"time.apple.com",
}
)
func TimeSyncLoop() {
for {
fmt.Println("Syncing system time")
if !networkState.checked {
time.Sleep(timeSyncWaitNetChkInt)
continue
}
if !networkState.Up {
log.Printf("Waiting for network to come up")
time.Sleep(timeSyncWaitNetUpInt)
continue
}
log.Printf("Syncing system time")
start := time.Now()
err := SyncSystemTime()
if err != nil {
log.Printf("Failed to sync system time: %v", err)
// retry after a delay
timeSyncRetryInterval += timeSyncRetryStep
time.Sleep(timeSyncRetryInterval)
// reset the retry interval if it exceeds the max interval
if timeSyncRetryInterval > timeSyncRetryMaxInt {
timeSyncRetryInterval = 0
}
continue
}
log.Printf("Time sync successful, now is: %v, time taken: %v", time.Now(), time.Since(start))
timeSynced = true
time.Sleep(1 * time.Hour) //once the first sync is done, sync every hour
time.Sleep(timeSyncInterval) // after the first sync is done
}
}
@ -41,13 +77,22 @@ func SyncSystemTime() (err error) {
}
func queryNetworkTime() (*time.Time, error) {
ntpServers := []string{
"time.cloudflare.com",
"time.apple.com",
ntpServers, err := getNTPServersFromDHCPInfo()
if err != nil {
log.Printf("failed to get NTP servers from DHCP info: %v\n", err)
}
if ntpServers == nil {
ntpServers = defaultNTPServers
log.Printf("Using default NTP servers: %v\n", ntpServers)
} else {
log.Printf("Using NTP servers from DHCP: %v\n", ntpServers)
}
for _, server := range ntpServers {
now, err := queryNtpServer(server, 2*time.Second)
now, err := queryNtpServer(server, timeSyncTimeout)
if err == nil {
log.Printf("NTP server [%s] returned time: %v\n", server, now)
return now, nil
}
}
@ -56,7 +101,7 @@ func queryNetworkTime() (*time.Time, error) {
"http://cloudflare.com",
}
for _, url := range httpUrls {
now, err := queryHttpTime(url, 2*time.Second)
now, err := queryHttpTime(url, timeSyncTimeout)
if err == nil {
return now, nil
}

View File

@ -2,3 +2,5 @@ VITE_SIGNAL_API=http://localhost:3000
VITE_CLOUD_APP=http://localhost:5173
VITE_CLOUD_API=http://localhost:3000
VITE_JETKVM_HEAD=

View File

@ -2,3 +2,5 @@ VITE_SIGNAL_API= # Uses the KVM device's IP address as the signal API endpoint
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=<script src="/device/ui-config.js"></script>

View File

@ -2,3 +2,5 @@ VITE_SIGNAL_API=https://api.jetkvm.com
VITE_CLOUD_APP=https://app.jetkvm.com
VITE_CLOUD_API=https://api.jetkvm.com
VITE_JETKVM_HEAD=

21
ui/dev_device.sh Executable file
View File

@ -0,0 +1,21 @@
#!/bin/bash
# Print header
echo "┌──────────────────────────────────────┐"
echo "│ JetKVM Development Setup │"
echo "└──────────────────────────────────────┘"
# Prompt for IP address
printf "Please enter the IP address of your JetKVM device: "
read ip_address
# Validate input is not empty
if [ -z "$ip_address" ]; then
echo "Error: IP address cannot be empty"
exit 1
fi
# Set the environment variable and run Vite
echo "Starting development server with JetKVM device at: $ip_address"
sleep 1
JETKVM_PROXY_URL="http://$ip_address" vite dev --mode=device

View File

@ -28,6 +28,7 @@
<title>JetKVM</title>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="icon" href="/favicon.png" />
%VITE_JETKVM_HEAD%
<script>
// Initial theme setup
document.documentElement.classList.toggle(

View File

@ -7,10 +7,10 @@
"node": "21.1.0"
},
"scripts": {
"dev": "vite dev --mode=development",
"dev": "./dev_device.sh",
"dev:cloud": "vite dev --mode=development",
"build": "npm run build:prod",
"build:device": "tsc && vite build --mode=device --emptyOutDir",
"dev:device": "vite dev --mode=device",
"build:prod": "tsc && vite build --mode=production",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},

View File

@ -6,6 +6,7 @@ import { useLocation, useNavigation, useSearchParams } from "react-router-dom";
import Fieldset from "@components/Fieldset";
import GridBackground from "@components/GridBackground";
import StepCounter from "@components/StepCounter";
import { CLOUD_API } from "@/ui.config";
type AuthLayoutProps = {
title: string;
@ -62,7 +63,7 @@ export default function AuthLayout({
<Fieldset className="space-y-12">
<div className="max-w-sm mx-auto space-y-4">
<form
action={`${import.meta.env.VITE_CLOUD_API}/oidc/google`}
action={`${CLOUD_API}/oidc/google`}
method="POST"
>
{/*This could be the KVM ID*/}

View File

@ -14,6 +14,7 @@ import PeerConnectionStatusCard from "@components/PeerConnectionStatusCard";
import api from "../api";
import { isOnDevice } from "../main";
import { Button, LinkButton } from "./Button";
import { CLOUD_API, SIGNAL_API } from "@/ui.config";
interface NavbarProps {
isLoggedIn: boolean;
@ -37,8 +38,8 @@ export default function DashboardNavbar({
const navigate = useNavigate();
const onLogout = useCallback(async () => {
const logoutUrl = isOnDevice
? `${import.meta.env.VITE_SIGNAL_API}/auth/logout`
: `${import.meta.env.VITE_CLOUD_API}/logout`;
? `${SIGNAL_API}/auth/logout`
: `${CLOUD_API}/logout`;
const res = await api.POST(logoutUrl);
if (!res.ok) return;

View File

@ -35,6 +35,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import notifications from "../notifications";
import Fieldset from "./Fieldset";
import { isOnDevice } from "../main";
import { SIGNAL_API } from "@/ui.config";
export default function MountMediaModal({
open,
@ -1119,7 +1120,7 @@ function UploadFileView({
alreadyUploadedBytes: number,
dataChannel: string,
) {
const uploadUrl = `${import.meta.env.VITE_SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
const uploadUrl = `${SIGNAL_API}/storage/upload?uploadId=${dataChannel}`;
const xhr = new XMLHttpRequest();
xhr.open("POST", uploadUrl, true);

View File

@ -1,5 +1,6 @@
import SidebarHeader from "@components/SidebarHeader";
import {
BacklightSettings,
useLocalAuthModalStore,
useSettingsStore,
useUiStore,
@ -25,6 +26,7 @@ import LocalAuthPasswordDialog from "@/components/LocalAuthPasswordDialog";
import { LocalDevice } from "@routes/devices.$id";
import { useRevalidator } from "react-router-dom";
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
import { CLOUD_APP, SIGNAL_API } from "@/ui.config";
export function SettingsItem({
title,
@ -95,6 +97,7 @@ export default function SettingsSidebar() {
const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const setDeveloperMode = useSettingsStore(state => state.setDeveloperMode);
const setBacklightSettings = useSettingsStore(state => state.setBacklightSettings);
const [currentVersions, setCurrentVersions] = useState<{
appVersion: string;
@ -228,6 +231,28 @@ export default function SettingsSidebar() {
[send, setDeveloperMode],
);
const handleBacklightSettingsChange = (settings: BacklightSettings) => {
// If the user has set the display to dim after it turns off, set the dim_after
// value to never.
if (settings.dim_after > settings.off_after && settings.off_after != 0) {
settings.dim_after = 0;
}
setBacklightSettings(settings);
}
const handleBacklightSettingsSave = () => {
send("setBacklightSettings", { params: settings.backlightSettings }, resp => {
if ("error" in resp) {
notifications.error(
`Failed to set backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
notifications.success("Backlight settings updated successfully");
});
};
const handleUpdateSSHKey = useCallback(() => {
send("setSSHKeyState", { sshKey }, resp => {
if ("error" in resp) {
@ -302,6 +327,17 @@ export default function SettingsSidebar() {
}
});
send("getBacklightSettings", {}, resp => {
if ("error" in resp) {
notifications.error(
`Failed to get backlight settings: ${resp.error.data || "Unknown error"}`,
);
return;
}
const result = resp.result as BacklightSettings;
setBacklightSettings(result);
})
send("getDevModeState", {}, resp => {
if ("error" in resp) return;
const result = resp.result as { enabled: boolean };
@ -331,7 +367,7 @@ export default function SettingsSidebar() {
const getDevice = useCallback(async () => {
try {
const status = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device`)
.GET(`${SIGNAL_API}/device`)
.then(res => res.json() as Promise<LocalDevice>);
setLocalDevice(status);
} catch (error) {
@ -642,7 +678,7 @@ export default function SettingsSidebar() {
<div>
<LinkButton
to={
import.meta.env.VITE_CLOUD_APP +
CLOUD_APP +
"/signup?deviceId=" +
deviceId +
`&returnTo=${location.href}adopt`
@ -797,6 +833,80 @@ export default function SettingsSidebar() {
/>
</SettingsItem>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Hardware"
description="Configure the JetKVM Hardware"
/>
</div>
<SettingsItem title="Display Brightness" description="Set the brightness of the display">
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.max_brightness.toString()}
options={[
{ value: "0", label: "Off" },
{ value: "10", label: "Low" },
{ value: "35", label: "Medium" },
{ value: "64", label: "High" },
]}
onChange={e => {
settings.backlightSettings.max_brightness = parseInt(e.target.value)
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
{settings.backlightSettings.max_brightness != 0 && (
<>
<SettingsItem title="Dim Display After" description="Set how long to wait before dimming the display">
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.dim_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "60", label: "1 Minute" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.dim_after = parseInt(e.target.value)
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</SettingsItem>
<SettingsItem title="Turn off Display After" description="Set how long to wait before turning off the display">
<SelectMenuBasic
size="SM"
label=""
value={settings.backlightSettings.off_after.toString()}
options={[
{ value: "0", label: "Never" },
{ value: "300", label: "5 Minutes" },
{ value: "600", label: "10 Minutes" },
{ value: "1800", label: "30 Minutes" },
{ value: "3600", label: "1 Hour" },
]}
onChange={e => {
settings.backlightSettings.off_after = parseInt(e.target.value)
handleBacklightSettingsChange(settings.backlightSettings);
}}
/>
</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>
<Button
size="SM"
theme="primary"
text="Save Display Settings"
onClick={handleBacklightSettingsSave}
/>
<div className="h-[1px] w-full bg-slate-800/10 dark:bg-slate-300/20" />
<div className="pb-2 space-y-4">
<SectionHeader
title="Advanced"

View File

@ -229,6 +229,12 @@ export interface VideoState {
}) => void;
}
export interface BacklightSettings {
max_brightness: number;
dim_after: number;
off_after: number;
}
export const useVideoStore = create<VideoState>(set => ({
width: 0,
height: 0,
@ -270,6 +276,9 @@ interface SettingsState {
// Add new developer mode state
developerMode: boolean;
setDeveloperMode: (enabled: boolean) => void;
backlightSettings: BacklightSettings;
setBacklightSettings: (settings: BacklightSettings) => void;
}
export const useSettingsStore = create(
@ -287,6 +296,13 @@ export const useSettingsStore = create(
// Add developer mode with default value
developerMode: false,
setDeveloperMode: enabled => set({ developerMode: enabled }),
backlightSettings: {
max_brightness: 100,
dim_after: 10000,
off_after: 50000,
},
setBacklightSettings: (settings: BacklightSettings) => set({ backlightSettings: settings }),
}),
{
name: "settings",

View File

@ -27,12 +27,13 @@ import LoginLocalRoute from "./routes/login-local";
import WelcomeLocalModeRoute from "./routes/welcome-local.mode";
import WelcomeRoute from "./routes/welcome-local";
import WelcomeLocalPasswordRoute from "./routes/welcome-local.password";
import { CLOUD_API } from "./ui.config";
export const isOnDevice = import.meta.env.MODE === "device";
export const isInCloud = !isOnDevice;
export async function checkAuth() {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/me`, {
const res = await fetch(`${CLOUD_API}/me`, {
mode: "cors",
credentials: "include",
headers: { "Content-Type": "application/json" },

View File

@ -1,5 +1,6 @@
import { LoaderFunctionArgs, redirect } from "react-router-dom";
import api from "../api";
import { CLOUD_API, CLOUD_APP, SIGNAL_API } from "@/ui.config";
const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
@ -11,17 +12,17 @@ const loader = async ({ request }: LoaderFunctionArgs) => {
const clientId = searchParams.get("clientId");
const res = await api.POST(
`${import.meta.env.VITE_SIGNAL_API}/cloud/register`,
`${SIGNAL_API}/cloud/register`,
{
token: tempToken,
cloudApi: import.meta.env.VITE_CLOUD_API,
cloudApi: CLOUD_API,
oidcGoogle,
clientId,
},
);
if (!res.ok) throw new Error("Failed to register device");
return redirect(import.meta.env.VITE_CLOUD_APP + `/devices/${deviceId}/setup`);
return redirect(CLOUD_APP + `/devices/${deviceId}/setup`);
};
export default function AdoptRoute() {

View File

@ -14,6 +14,7 @@ import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import { ChevronLeftIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -24,7 +25,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
const { deviceId } = Object.fromEntries(await request.formData());
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${deviceId}`, {
const res = await fetch(`${CLOUD_API}/devices/${deviceId}`, {
method: "DELETE",
credentials: "include",
headers: { "Content-Type": "application/json" },
@ -46,7 +47,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",

View File

@ -16,6 +16,7 @@ import { User } from "@/hooks/stores";
import { checkAuth } from "@/main";
import Fieldset from "@components/Fieldset";
import api from "../api";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
device: { id: string; name: string; user: { googleId: string } };
@ -31,7 +32,7 @@ const action = async ({ params, request }: ActionFunctionArgs) => {
}
try {
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, {
name,
});
if (!res.ok) {
@ -49,7 +50,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = params;
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, {
const res = await fetch(`${CLOUD_API}/devices/${id}`, {
method: "GET",
credentials: "include",
mode: "cors",

View File

@ -16,10 +16,11 @@ import { InputFieldWithLabel } from "@components/InputField";
import { Button } from "@components/Button";
import { checkAuth } from "@/main";
import api from "../api";
import { CLOUD_API } from "@/ui.config";
const loader = async ({ params }: LoaderFunctionArgs) => {
await checkAuth();
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`, {
const res = await fetch(`${CLOUD_API}/devices/${params.id}`, {
method: "GET",
mode: "cors",
credentials: "include",
@ -35,7 +36,7 @@ const loader = async ({ params }: LoaderFunctionArgs) => {
const action = async ({ request }: ActionFunctionArgs) => {
// Handle form submission
const { name, id, returnTo } = Object.fromEntries(await request.formData());
const res = await api.PUT(`${import.meta.env.VITE_CLOUD_API}/devices/${id}`, { name });
const res = await api.PUT(`${CLOUD_API}/devices/${id}`, { name });
if (res.ok) {
return redirect(returnTo?.toString() ?? `/devices/${id}`);

View File

@ -36,6 +36,7 @@ import { DeviceStatus } from "./welcome-local";
import FocusTrap from "focus-trap-react";
import OtherSessionConnectedModal from "@/components/OtherSessionConnectedModal";
import TerminalWrapper from "../components/Terminal";
import { CLOUD_API, SIGNAL_API } from "@/ui.config";
interface LocalLoaderResp {
authMode: "password" | "noPassword" | null;
@ -56,12 +57,12 @@ export interface LocalDevice {
const deviceLoader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.GET(`${SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
const deviceRes = await api.GET(`${SIGNAL_API}/device`);
if (deviceRes.status === 401) return redirect("/login-local");
if (deviceRes.ok) {
const device = (await deviceRes.json()) as LocalDevice;
@ -74,11 +75,11 @@ const deviceLoader = async () => {
const cloudLoader = async (params: Params<string>): Promise<CloudLoaderResp> => {
const user = await checkAuth();
const iceResp = await api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/ice_config`);
const iceResp = await api.POST(`${CLOUD_API}/webrtc/ice_config`);
const iceConfig = await iceResp.json();
const deviceResp = await api.GET(
`${import.meta.env.VITE_CLOUD_API}/devices/${params.id}`,
`${CLOUD_API}/devices/${params.id}`,
);
if (!deviceResp.ok) {
@ -142,7 +143,7 @@ export default function KvmIdRoute() {
try {
const sd = btoa(JSON.stringify(pc.localDescription));
const res = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/webrtc/session`, {
const res = await api.POST(`${SIGNAL_API}/webrtc/session`, {
sd,
// When on device, we don't need to specify the device id, as it's already known
...(isOnDevice ? {} : { id: params.id }),
@ -317,7 +318,7 @@ export default function KvmIdRoute() {
}
// Fire and forget
api.POST(`${import.meta.env.VITE_CLOUD_API}/webrtc/turn_activity`, {
api.POST(`${CLOUD_API}/webrtc/turn_activity`, {
bytesReceived: bytesReceivedDelta,
bytesSent: bytesSentDelta,
});

View File

@ -9,6 +9,7 @@ import { User } from "@/hooks/stores";
import EmptyCard from "@components/EmptyCard";
import { LuMonitorSmartphone } from "react-icons/lu";
import { ArrowRightIcon } from "@heroicons/react/16/solid";
import { CLOUD_API } from "@/ui.config";
interface LoaderData {
devices: { id: string; name: string; online: boolean; lastSeen: string }[];
@ -19,7 +20,7 @@ export const loader = async () => {
const user = await checkAuth();
try {
const res = await fetch(`${import.meta.env.VITE_CLOUD_API}/devices`, {
const res = await fetch(`${CLOUD_API}/devices`, {
method: "GET",
credentials: "include",
mode: "cors",

View File

@ -12,15 +12,16 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import ExtLink from "../components/ExtLink";
import { SIGNAL_API } from "@/ui.config";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.GET(`${SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (!res.isSetup) return redirect("/welcome");
const deviceRes = await api.GET(`${import.meta.env.VITE_SIGNAL_API}/device`);
const deviceRes = await api.GET(`${SIGNAL_API}/device`);
if (deviceRes.ok) return redirect("/");
return null;
};
@ -31,7 +32,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
try {
const response = await api.POST(
`${import.meta.env.VITE_SIGNAL_API}/auth/login-local`,
`${SIGNAL_API}/auth/login-local`,
{
password,
},

View File

@ -9,10 +9,11 @@ import LogoWhiteIcon from "@/assets/logo-white.svg";
import { cx } from "../cva.config";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import { SIGNAL_API } from "@/ui.config";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.GET(`${SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");
@ -30,7 +31,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
if (localAuthMode === "noPassword") {
try {
await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
await api.POST(`${SIGNAL_API}/device/setup`, {
localAuthMode,
});
return redirect("/");

View File

@ -10,10 +10,11 @@ import LogoBlueIcon from "@/assets/logo-blue.png";
import LogoWhiteIcon from "@/assets/logo-white.svg";
import api from "../api";
import { DeviceStatus } from "./welcome-local";
import { SIGNAL_API } from "@/ui.config";
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.GET(`${SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");
@ -30,7 +31,7 @@ const action = async ({ request }: ActionFunctionArgs) => {
}
try {
const response = await api.POST(`${import.meta.env.VITE_SIGNAL_API}/device/setup`, {
const response = await api.POST(`${SIGNAL_API}/device/setup`, {
localAuthMode: "password",
password,
});

View File

@ -9,6 +9,7 @@ import LogoMark from "@/assets/logo-mark.png";
import { cx } from "cva";
import api from "../api";
import { redirect } from "react-router-dom";
import { SIGNAL_API } from "@/ui.config";
export interface DeviceStatus {
isSetup: boolean;
@ -16,7 +17,7 @@ export interface DeviceStatus {
const loader = async () => {
const res = await api
.GET(`${import.meta.env.VITE_SIGNAL_API}/device/status`)
.GET(`${SIGNAL_API}/device/status`)
.then(res => res.json() as Promise<DeviceStatus>);
if (res.isSetup) return redirect("/login-local");

23
ui/src/ui.config.ts Normal file
View File

@ -0,0 +1,23 @@
interface JetKVMConfig {
CLOUD_API?: string;
CLOUD_APP?: string;
DEVICE_VERSION?: string;
}
declare global {
interface Window { JETKVM_CONFIG?: JetKVMConfig; }
}
const getAppURL = (api_url?: string) => {
if (!api_url) {
return;
}
const url = new URL(api_url);
url.host = url.host.replace(/api\./, "app.");
// remove the ending slash
return url.toString().replace(/\/$/, "");
}
export const CLOUD_API = window.JETKVM_CONFIG?.CLOUD_API || import.meta.env.VITE_CLOUD_API;
export const CLOUD_APP = window.JETKVM_CONFIG?.CLOUD_APP || getAppURL(CLOUD_API) || import.meta.env.VITE_CLOUD_APP;
export const SIGNAL_API = import.meta.env.VITE_SIGNAL_API;

31
web.go
View File

@ -2,6 +2,8 @@ package kvm
import (
"embed"
"encoding/json"
"fmt"
"io/fs"
"net/http"
"path/filepath"
@ -10,6 +12,10 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
versioncollector "github.com/prometheus/client_golang/prometheus/collectors/version"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/prometheus/common/version"
"golang.org/x/crypto/bcrypt"
)
@ -77,9 +83,17 @@ func setupRouter() *gin.Engine {
// We use this to determine if the device is setup
r.GET("/device/status", handleDeviceStatus)
// We use this to provide the UI with the device configuration
r.GET("/device/ui-config.js", handleDeviceUIConfig)
// We use this to setup the device in the welcome page
r.POST("/device/setup", handleSetup)
// A Prometheus metrics endpoint.
version.Version = builtAppVersion
prometheus.MustRegister(versioncollector.NewCollector("jetkvm"))
r.GET("/metrics", gin.WrapH(promhttp.Handler()))
// Protected routes (allows both password and noPassword modes)
protected := r.Group("/")
protected.Use(protectedMiddleware())
@ -361,6 +375,23 @@ func handleDeviceStatus(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
func handleDeviceUIConfig(c *gin.Context) {
LoadConfig()
config, _ := json.Marshal(gin.H{
"CLOUD_API": config.CloudURL,
"DEVICE_VERSION": builtAppVersion,
})
if config == nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal config"})
return
}
response := fmt.Sprintf("window.JETKVM_CONFIG = %s;", config)
c.Data(http.StatusOK, "text/javascript; charset=utf-8", []byte(response))
}
func handleSetup(c *gin.Context) {
LoadConfig()