Compare commits

...

6 Commits

Author SHA1 Message Date
Alex P f56e1480d1 build: allow VERSION and VERSION_DEV to be overridden via environment
Changed from immediate assignment (:=) to conditional assignment (?=)
to allow developers to override version numbers when building.

This enables building with custom versions for testing:
  VERSION=0.4.9 ./dev_deploy.sh -r <ip> --install

Useful for testing version-gated features without bumping the
default production version.
2025-10-18 00:48:20 +03:00
Alex P 8189861bfa fix: version info not loading in feature flags and settings
The getLocalVersion() call was happening before the WebRTC RPC data
channel was established, causing version fetch to fail silently.

This resulted in:
- Feature flags always returning false (no version = disabled)
- Settings UI showing "App: Loading..." and "System: Loading..." indefinitely
- Version-gated features (like HDMI Sleep Mode) never appearing

Fixed by adding rpcDataChannel readyState check before calling
getLocalVersion(), matching the pattern used by all other RPC calls
in the same file.
2025-10-18 00:33:22 +03:00
Alex P c8808ee3b2 fix: resolve React hooks violation in hardware settings
Moved getVideoSleepMode useEffect before early returns to comply with
React Rules of Hooks. All hooks must be called in the same order on
every component render, before any conditional returns.

This completes the merge from dev branch, preserving both:
- Permission-based access control from multi-session branch
- HDMI sleep mode power saving feature from dev branch
2025-10-18 00:30:45 +03:00
Alex P 8dc013d8fe Merge branch 'dev' into feat/multisession-support
Integrate upstream changes from dev branch including power saving feature
and video improvements. Resolved conflict in hardware settings by merging
permission checks with new power saving controls.

Changes from dev:
- Add HDMI sleep mode power saving feature
- Video capture improvements
- Native interface updates

Multi-session features preserved:
- Permission-based settings access control
- All session management functionality intact
2025-10-17 23:34:44 +03:00
Alex P ba2fa34385 fix: address critical issues in multi-session management
- Fix nickname index stale pointer during session reconnection
- Reset LastActive for all emergency promotions to prevent cascade timeouts
- Bypass rate limits when no primary exists to prevent system deadlock
- Replace manual mutex with atomic.Int32 for session counter (fixes race condition)
- Implement collect-then-delete pattern for safe map iteration
- Reduce logging verbosity for routine cleanup operations
2025-10-17 23:27:27 +03:00
Aveline 2444817455
chore: disable sleep mode when detecting video format (#887)
Co-authored-by: Adam Shiervani <adam.shiervani@gmail.com>
Co-authored-by: Adam Shiervani <adamshiervani@fastmail.com>
2025-10-17 17:51:02 +02:00
17 changed files with 303 additions and 104 deletions

View File

@ -2,8 +2,8 @@ BRANCH := $(shell git rev-parse --abbrev-ref HEAD)
BUILDDATE := $(shell date -u +%FT%T%z) BUILDDATE := $(shell date -u +%FT%T%z)
BUILDTS := $(shell date -u +%s) BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD) REVISION := $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M) VERSION_DEV ?= 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8 VERSION ?= 0.4.8
PROMETHEUS_TAG := github.com/prometheus/common/version PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm KVM_PKG_NAME := github.com/jetkvm/kvm

View File

@ -117,6 +117,7 @@ type Config struct {
DefaultLogLevel string `json:"default_log_level"` DefaultLogLevel string `json:"default_log_level"`
SessionSettings *SessionSettings `json:"session_settings"` SessionSettings *SessionSettings `json:"session_settings"`
VideoSleepAfterSec int `json:"video_sleep_after_sec"` VideoSleepAfterSec int `json:"video_sleep_after_sec"`
VideoQualityFactor float64 `json:"video_quality_factor"`
} }
func (c *Config) GetDisplayRotation() uint16 { func (c *Config) GetDisplayRotation() uint16 {

View File

@ -70,7 +70,7 @@ func updateDisplay() {
nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected") nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected")
_, _ = nativeInstance.UIObjClearState("hdmi_status_label", "LV_STATE_CHECKED") _, _ = nativeInstance.UIObjClearState("hdmi_status_label", "LV_STATE_CHECKED")
} }
nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", actionSessions)) nativeInstance.UpdateLabelIfChanged("cloud_status_label", fmt.Sprintf("%d active", getActiveSessions()))
if networkManager != nil && networkManager.IsUp() { if networkManager != nil && networkManager.IsUp() {
nativeInstance.UISetVar("main_screen", "home_screen") nativeInstance.UISetVar("main_screen", "home_screen")

View File

@ -405,8 +405,8 @@ char *jetkvm_video_log_status() {
return (char *)videoc_log_status(); return (char *)videoc_log_status();
} }
int jetkvm_video_init() { int jetkvm_video_init(float factor) {
return video_init(); return video_init(factor);
} }
void jetkvm_video_shutdown() { void jetkvm_video_shutdown() {

View File

@ -52,7 +52,7 @@ const char *jetkvm_ui_get_lvgl_version();
const char *jetkvm_ui_event_code_to_name(int code); const char *jetkvm_ui_event_code_to_name(int code);
int jetkvm_video_init(); int jetkvm_video_init(float quality_factor);
void jetkvm_video_shutdown(); void jetkvm_video_shutdown();
void jetkvm_video_start(); void jetkvm_video_start();
void jetkvm_video_stop(); void jetkvm_video_stop();

View File

@ -29,6 +29,7 @@
#define VIDEO_DEV "/dev/video0" #define VIDEO_DEV "/dev/video0"
#define SUB_DEV "/dev/v4l-subdev2" #define SUB_DEV "/dev/v4l-subdev2"
#define SLEEP_MODE_FILE "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
#define RK_ALIGN(x, a) (((x) + (a)-1) & ~((a)-1)) #define RK_ALIGN(x, a) (((x) + (a)-1) & ~((a)-1))
#define RK_ALIGN_2(x) RK_ALIGN(x, 2) #define RK_ALIGN_2(x) RK_ALIGN(x, 2)
@ -39,6 +40,7 @@ int sub_dev_fd = -1;
#define VENC_CHANNEL 0 #define VENC_CHANNEL 0
MB_POOL memPool = MB_INVALID_POOLID; MB_POOL memPool = MB_INVALID_POOLID;
bool sleep_mode_available = false;
bool should_exit = false; bool should_exit = false;
float quality_factor = 1.0f; float quality_factor = 1.0f;
@ -51,6 +53,45 @@ RK_U64 get_us()
return (RK_U64)time.tv_sec * 1000000 + (RK_U64)time.tv_nsec / 1000; /* microseconds */ return (RK_U64)time.tv_sec * 1000000 + (RK_U64)time.tv_nsec / 1000; /* microseconds */
} }
static void ensure_sleep_mode_disabled()
{
if (!sleep_mode_available)
{
return;
}
int fd = open(SLEEP_MODE_FILE, O_RDWR);
if (fd < 0)
{
log_error("Failed to open sleep mode file: %s", strerror(errno));
return;
}
lseek(fd, 0, SEEK_SET);
char buffer[1];
read(fd, buffer, 1);
if (buffer[0] == '0') {
close(fd);
return;
}
log_warn("HDMI sleep mode is not disabled, disabling it");
lseek(fd, 0, SEEK_SET);
write(fd, "0", 1);
close(fd);
usleep(1000); // give some time to the system to disable the sleep mode
return;
}
static void detect_sleep_mode()
{
if (access(SLEEP_MODE_FILE, F_OK) != 0) {
sleep_mode_available = false;
return;
}
sleep_mode_available = true;
ensure_sleep_mode_disabled();
}
double calculate_bitrate(float bitrate_factor, int width, int height) double calculate_bitrate(float bitrate_factor, int width, int height)
{ {
const int32_t base_bitrate_high = 2000; const int32_t base_bitrate_high = 2000;
@ -190,8 +231,15 @@ static int32_t buf_init()
pthread_t *format_thread = NULL; pthread_t *format_thread = NULL;
int video_init() int video_init(float factor)
{ {
detect_sleep_mode();
if (factor < 0 || factor > 1) {
factor = 1.0f;
}
quality_factor = factor;
if (RK_MPI_SYS_Init() != RK_SUCCESS) if (RK_MPI_SYS_Init() != RK_SUCCESS)
{ {
log_error("RK_MPI_SYS_Init failed"); log_error("RK_MPI_SYS_Init failed");
@ -301,11 +349,29 @@ static void *venc_read_stream(void *arg)
} }
uint32_t detected_width, detected_height; uint32_t detected_width, detected_height;
bool detected_signal = false, streaming_flag = false; bool detected_signal = false, streaming_flag = false, streaming_stopped = true;
pthread_t *streaming_thread = NULL; pthread_t *streaming_thread = NULL;
pthread_mutex_t streaming_mutex = PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t streaming_mutex = PTHREAD_MUTEX_INITIALIZER;
bool get_streaming_flag()
{
log_info("getting streaming flag");
pthread_mutex_lock(&streaming_mutex);
bool flag = streaming_flag;
pthread_mutex_unlock(&streaming_mutex);
return flag;
}
void set_streaming_flag(bool flag)
{
log_info("setting streaming flag to %d", flag);
pthread_mutex_lock(&streaming_mutex);
streaming_flag = flag;
pthread_mutex_unlock(&streaming_mutex);
}
void write_buffer_to_file(const uint8_t *buffer, size_t length, const char *filename) void write_buffer_to_file(const uint8_t *buffer, size_t length, const char *filename)
{ {
FILE *file = fopen(filename, "wb"); FILE *file = fopen(filename, "wb");
@ -319,6 +385,8 @@ void *run_video_stream(void *arg)
log_info("running video stream"); log_info("running video stream");
streaming_stopped = false;
while (streaming_flag) while (streaming_flag)
{ {
if (detected_signal == false) if (detected_signal == false)
@ -401,7 +469,7 @@ void *run_video_stream(void *arg)
{ {
log_error("get mb blk failed!"); log_error("get mb blk failed!");
close(video_dev_fd); close(video_dev_fd);
return ; return (void *)errno;
} }
log_info("Got memory block for buffer %d", i); log_info("Got memory block for buffer %d", i);
@ -538,6 +606,18 @@ void *run_video_stream(void *arg)
log_error("VIDIOC_STREAMOFF failed: %s", strerror(errno)); log_error("VIDIOC_STREAMOFF failed: %s", strerror(errno));
} }
// Explicitly free V4L2 buffer queue
struct v4l2_requestbuffers req_free;
memset(&req_free, 0, sizeof(req_free));
req_free.count = 0;
req_free.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
req_free.memory = V4L2_MEMORY_DMABUF;
if (ioctl(video_dev_fd, VIDIOC_REQBUFS, &req_free) < 0)
{
log_error("Failed to free V4L2 buffers: %s", strerror(errno));
}
venc_stop(); venc_stop();
for (int i = 0; i < input_buffer_count; i++) for (int i = 0; i < input_buffer_count; i++)
@ -553,6 +633,9 @@ void *run_video_stream(void *arg)
} }
log_info("video stream thread exiting"); log_info("video stream thread exiting");
streaming_stopped = true;
return NULL; return NULL;
} }
@ -577,61 +660,80 @@ void video_shutdown()
RK_MPI_MB_DestroyPool(memPool); RK_MPI_MB_DestroyPool(memPool);
} }
log_info("Destroyed memory pool"); log_info("Destroyed memory pool");
pthread_mutex_destroy(&streaming_mutex); pthread_mutex_destroy(&streaming_mutex);
log_info("Destroyed streaming mutex"); log_info("Destroyed streaming mutex");
} }
void video_start_streaming() void video_start_streaming()
{ {
pthread_mutex_lock(&streaming_mutex); log_info("starting video streaming");
if (streaming_thread != NULL) if (streaming_thread != NULL)
{ {
if (streaming_stopped == true) {
log_error("video streaming already stopped but streaming_thread is not NULL");
assert(streaming_stopped == true);
}
log_warn("video streaming already started"); log_warn("video streaming already started");
goto cleanup; return;
} }
pthread_t *new_thread = malloc(sizeof(pthread_t)); pthread_t *new_thread = malloc(sizeof(pthread_t));
if (new_thread == NULL) if (new_thread == NULL)
{ {
log_error("Failed to allocate memory for streaming thread"); log_error("Failed to allocate memory for streaming thread");
goto cleanup; return;
} }
streaming_flag = true; set_streaming_flag(true);
int result = pthread_create(new_thread, NULL, run_video_stream, NULL); int result = pthread_create(new_thread, NULL, run_video_stream, NULL);
if (result != 0) if (result != 0)
{ {
log_error("Failed to create streaming thread: %s", strerror(result)); log_error("Failed to create streaming thread: %s", strerror(result));
streaming_flag = false; set_streaming_flag(false);
free(new_thread); free(new_thread);
goto cleanup; return;
} }
// Only set streaming_thread after successful creation, and before unlocking the mutex // Only set streaming_thread after successful creation
streaming_thread = new_thread; streaming_thread = new_thread;
cleanup:
pthread_mutex_unlock(&streaming_mutex);
return;
} }
void video_stop_streaming() void video_stop_streaming()
{ {
pthread_mutex_lock(&streaming_mutex); if (streaming_thread == NULL) {
if (streaming_thread != NULL) log_info("video streaming already stopped");
{ return;
streaming_flag = false;
log_info("stopping video streaming");
// wait 100ms for the thread to exit
usleep(1000000);
log_info("waiting for video streaming thread to exit");
pthread_join(*streaming_thread, NULL);
free(streaming_thread);
streaming_thread = NULL;
log_info("video streaming stopped");
} }
pthread_mutex_unlock(&streaming_mutex);
log_info("stopping video streaming");
set_streaming_flag(false);
log_info("waiting for video streaming thread to exit");
int attempts = 0;
while (!streaming_stopped && attempts < 30) {
usleep(100000); // 100ms
attempts++;
}
if (!streaming_stopped) {
log_error("video streaming thread did not exit after 30s");
}
pthread_join(*streaming_thread, NULL);
free(streaming_thread);
streaming_thread = NULL;
log_info("video streaming stopped");
}
void video_restart_streaming()
{
if (get_streaming_flag() == true)
{
log_info("restarting video streaming");
video_stop_streaming();
}
video_start_streaming();
} }
void *run_detect_format(void *arg) void *run_detect_format(void *arg)
@ -650,6 +752,8 @@ void *run_detect_format(void *arg)
while (!should_exit) while (!should_exit)
{ {
ensure_sleep_mode_disabled();
memset(&dv_timings, 0, sizeof(dv_timings)); memset(&dv_timings, 0, sizeof(dv_timings));
if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0) if (ioctl(sub_dev_fd, VIDIOC_QUERY_DV_TIMINGS, &dv_timings) != 0)
{ {
@ -689,21 +793,17 @@ void *run_detect_format(void *arg)
(dv_timings.bt.width + dv_timings.bt.hfrontporch + dv_timings.bt.hsync + (dv_timings.bt.width + dv_timings.bt.hfrontporch + dv_timings.bt.hsync +
dv_timings.bt.hbackporch)); dv_timings.bt.hbackporch));
log_info("Frames per second: %.2f fps", frames_per_second); log_info("Frames per second: %.2f fps", frames_per_second);
bool should_restart = dv_timings.bt.width != detected_width || dv_timings.bt.height != detected_height || !detected_signal;
detected_width = dv_timings.bt.width; detected_width = dv_timings.bt.width;
detected_height = dv_timings.bt.height; detected_height = dv_timings.bt.height;
detected_signal = true; detected_signal = true;
video_report_format(true, NULL, detected_width, detected_height, frames_per_second); video_report_format(true, NULL, detected_width, detected_height, frames_per_second);
pthread_mutex_lock(&streaming_mutex);
if (streaming_flag == true) if (should_restart) {
{ log_info("restarting video streaming due to format change");
pthread_mutex_unlock(&streaming_mutex); video_restart_streaming();
log_info("restarting on going video streaming");
video_stop_streaming();
video_start_streaming();
}
else
{
pthread_mutex_unlock(&streaming_mutex);
} }
} }
@ -731,21 +831,9 @@ void video_set_quality_factor(float factor)
quality_factor = factor; quality_factor = factor;
// TODO: update venc bitrate without stopping streaming // TODO: update venc bitrate without stopping streaming
video_restart_streaming();
pthread_mutex_lock(&streaming_mutex);
if (streaming_flag == true)
{
pthread_mutex_unlock(&streaming_mutex);
log_info("restarting on going video streaming due to quality factor change");
video_stop_streaming();
video_start_streaming();
}
else
{
pthread_mutex_unlock(&streaming_mutex);
}
} }
float video_get_quality_factor() { float video_get_quality_factor() {
return quality_factor; return quality_factor;
} }

View File

@ -6,7 +6,7 @@
* *
* @return int 0 on success, -1 on failure * @return int 0 on success, -1 on failure
*/ */
int video_init(); int video_init(float quality_factor);
/** /**
* @brief Shutdown the video subsystem * @brief Shutdown the video subsystem

View File

@ -129,11 +129,13 @@ func uiTick() {
C.jetkvm_ui_tick() C.jetkvm_ui_tick()
} }
func videoInit() error { func videoInit(factor float64) error {
cgoLock.Lock() cgoLock.Lock()
defer cgoLock.Unlock() defer cgoLock.Unlock()
ret := C.jetkvm_video_init() factorC := C.float(factor)
ret := C.jetkvm_video_init(factorC)
if ret != 0 { if ret != 0 {
return fmt.Errorf("failed to initialize video: %d", ret) return fmt.Errorf("failed to initialize video: %d", ret)
} }

View File

@ -15,6 +15,7 @@ type Native struct {
systemVersion *semver.Version systemVersion *semver.Version
appVersion *semver.Version appVersion *semver.Version
displayRotation uint16 displayRotation uint16
defaultQualityFactor float64
onVideoStateChange func(state VideoState) onVideoStateChange func(state VideoState)
onVideoFrameReceived func(frame []byte, duration time.Duration) onVideoFrameReceived func(frame []byte, duration time.Duration)
onIndevEvent func(event string) onIndevEvent func(event string)
@ -22,12 +23,14 @@ type Native struct {
sleepModeSupported bool sleepModeSupported bool
videoLock sync.Mutex videoLock sync.Mutex
screenLock sync.Mutex screenLock sync.Mutex
extraLock sync.Mutex
} }
type NativeOptions struct { type NativeOptions struct {
SystemVersion *semver.Version SystemVersion *semver.Version
AppVersion *semver.Version AppVersion *semver.Version
DisplayRotation uint16 DisplayRotation uint16
DefaultQualityFactor float64
OnVideoStateChange func(state VideoState) OnVideoStateChange func(state VideoState)
OnVideoFrameReceived func(frame []byte, duration time.Duration) OnVideoFrameReceived func(frame []byte, duration time.Duration)
OnIndevEvent func(event string) OnIndevEvent func(event string)
@ -65,6 +68,11 @@ func NewNative(opts NativeOptions) *Native {
sleepModeSupported := isSleepModeSupported() sleepModeSupported := isSleepModeSupported()
defaultQualityFactor := opts.DefaultQualityFactor
if defaultQualityFactor < 0 || defaultQualityFactor > 1 {
defaultQualityFactor = 1.0
}
return &Native{ return &Native{
ready: make(chan struct{}), ready: make(chan struct{}),
l: nativeLogger, l: nativeLogger,
@ -72,6 +80,7 @@ func NewNative(opts NativeOptions) *Native {
systemVersion: opts.SystemVersion, systemVersion: opts.SystemVersion,
appVersion: opts.AppVersion, appVersion: opts.AppVersion,
displayRotation: opts.DisplayRotation, displayRotation: opts.DisplayRotation,
defaultQualityFactor: defaultQualityFactor,
onVideoStateChange: onVideoStateChange, onVideoStateChange: onVideoStateChange,
onVideoFrameReceived: onVideoFrameReceived, onVideoFrameReceived: onVideoFrameReceived,
onIndevEvent: onIndevEvent, onIndevEvent: onIndevEvent,
@ -97,7 +106,7 @@ func (n *Native) Start() {
n.initUI() n.initUI()
go n.tickUI() go n.tickUI()
if err := videoInit(); err != nil { if err := videoInit(n.defaultQualityFactor); err != nil {
n.l.Error().Err(err).Msg("failed to initialize video") n.l.Error().Err(err).Msg("failed to initialize video")
} }

View File

@ -1,11 +1,18 @@
package native package native
import ( import (
"fmt"
"os" "os"
"time"
) )
const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode" const sleepModeFile = "/sys/devices/platform/ff470000.i2c/i2c-4/4-000f/sleep_mode"
// DefaultEDID is the default EDID for the video stream.
const DefaultEDID = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
var extraLockTimeout = 5 * time.Second
// VideoState is the state of the video stream. // VideoState is the state of the video stream.
type VideoState struct { type VideoState struct {
Ready bool `json:"ready"` Ready bool `json:"ready"`
@ -66,12 +73,32 @@ func (n *Native) VideoSleepModeSupported() bool {
return n.sleepModeSupported return n.sleepModeSupported
} }
// useExtraLock uses the extra lock to execute a function.
// if the lock is currently held by another goroutine, returns an error.
//
// it's used to ensure that only one change is made to the video stream at a time.
// as the change usually requires to restart video streaming
// TODO: check video streaming status instead of using a hardcoded timeout
func (n *Native) useExtraLock(fn func() error) error {
if !n.extraLock.TryLock() {
return fmt.Errorf("the previous change hasn't been completed yet")
}
err := fn()
if err == nil {
time.Sleep(extraLockTimeout)
}
n.extraLock.Unlock()
return err
}
// VideoSetQualityFactor sets the quality factor for the video stream. // VideoSetQualityFactor sets the quality factor for the video stream.
func (n *Native) VideoSetQualityFactor(factor float64) error { func (n *Native) VideoSetQualityFactor(factor float64) error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
return videoSetStreamQualityFactor(factor) return n.useExtraLock(func() error {
return videoSetStreamQualityFactor(factor)
})
} }
// VideoGetQualityFactor gets the quality factor for the video stream. // VideoGetQualityFactor gets the quality factor for the video stream.
@ -87,7 +114,13 @@ func (n *Native) VideoSetEDID(edid string) error {
n.videoLock.Lock() n.videoLock.Lock()
defer n.videoLock.Unlock() defer n.videoLock.Unlock()
return videoSetEDID(edid) if edid == "" {
edid = DefaultEDID
}
return n.useExtraLock(func() error {
return videoSetEDID(edid)
})
} }
// VideoGetEDID gets the EDID for the video stream. // VideoGetEDID gets the EDID for the video stream.

View File

@ -366,7 +366,6 @@ func rpcGetEDID() (string, error) {
func rpcSetEDID(edid string) error { func rpcSetEDID(edid string) error {
if edid == "" { if edid == "" {
logger.Info().Msg("Restoring EDID to default") logger.Info().Msg("Restoring EDID to default")
edid = "00ffffffffffff0052620188008888881c150103800000780a0dc9a05747982712484c00000001010101010101010101010101010101023a801871382d40582c4500c48e2100001e011d007251d01e206e285500c48e2100001e000000fc00543734392d6648443732300a20000000fd00147801ff1d000a202020202020017b"
} else { } else {
logger.Info().Str("edid", edid).Msg("Setting EDID") logger.Info().Str("edid", edid).Msg("Setting EDID")
} }

View File

@ -17,9 +17,10 @@ var (
func initNative(systemVersion *semver.Version, appVersion *semver.Version) { func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{ nativeInstance = native.NewNative(native.NativeOptions{
SystemVersion: systemVersion, SystemVersion: systemVersion,
AppVersion: appVersion, AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(), DisplayRotation: config.GetDisplayRotation(),
DefaultQualityFactor: config.VideoQualityFactor,
OnVideoStateChange: func(state native.VideoState) { OnVideoStateChange: func(state native.VideoState) {
lastVideoState = state lastVideoState = state
triggerVideoStateUpdate() triggerVideoStateUpdate()
@ -70,7 +71,13 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
}) })
}, },
}) })
nativeInstance.Start() nativeInstance.Start()
go func() {
if err := nativeInstance.VideoSetEDID(config.EdidString); err != nil {
nativeLogger.Warn().Err(err).Msg("error setting EDID")
}
}()
if os.Getenv("JETKVM_CRASH_TESTING") == "1" { if os.Getenv("JETKVM_CRASH_TESTING") == "1" {
nativeInstance.DoNotUseThisIsForCrashTestingOnly() nativeInstance.DoNotUseThisIsForCrashTestingOnly()

View File

@ -25,6 +25,18 @@ func (sm *SessionManager) attemptEmergencyPromotion(ctx emergencyPromotionContex
sm.emergencyWindowMutex.Lock() sm.emergencyWindowMutex.Lock()
defer sm.emergencyWindowMutex.Unlock() defer sm.emergencyWindowMutex.Unlock()
// CRITICAL: Bypass all rate limits if no primary exists to prevent deadlock
// System availability takes priority over DoS protection
noPrimaryExists := (sm.primarySessionID == "")
if noPrimaryExists {
sm.logger.Info().
Str("triggerSessionID", ctx.triggerSessionID).
Str("triggerReason", ctx.triggerReason).
Msg("Bypassing emergency promotion rate limits - no primary exists")
promotedSessionID := sm.findMostTrustedSessionForEmergency()
return promotedSessionID, true, false
}
const slidingWindowDuration = 60 * time.Second const slidingWindowDuration = 60 * time.Second
const maxEmergencyPromotionsPerMinute = 3 const maxEmergencyPromotionsPerMinute = 3
@ -187,19 +199,21 @@ func (sm *SessionManager) promoteAfterGraceExpiration(expiredSessionID string, n
// handlePendingSessionTimeout removes timed-out pending sessions (DoS protection) // handlePendingSessionTimeout removes timed-out pending sessions (DoS protection)
// Returns true if any pending session was removed // Returns true if any pending session was removed
func (sm *SessionManager) handlePendingSessionTimeout(now time.Time) bool { func (sm *SessionManager) handlePendingSessionTimeout(now time.Time) bool {
needsCleanup := false toDelete := make([]string, 0)
for id, session := range sm.sessions { for id, session := range sm.sessions {
if session.Mode == SessionModePending && if session.Mode == SessionModePending &&
now.Sub(session.CreatedAt) > defaultPendingSessionTimeout { now.Sub(session.CreatedAt) > defaultPendingSessionTimeout {
websocketLogger.Info(). websocketLogger.Debug().
Str("sessionId", id). Str("sessionId", id).
Dur("age", now.Sub(session.CreatedAt)). Dur("age", now.Sub(session.CreatedAt)).
Msg("Removing timed-out pending session") Msg("Removing timed-out pending session")
delete(sm.sessions, id) toDelete = append(toDelete, id)
needsCleanup = true
} }
} }
return needsCleanup for _, id := range toDelete {
delete(sm.sessions, id)
}
return len(toDelete) > 0
} }
// handleObserverSessionCleanup removes inactive observer sessions with closed RPC channels // handleObserverSessionCleanup removes inactive observer sessions with closed RPC channels
@ -210,21 +224,23 @@ func (sm *SessionManager) handleObserverSessionCleanup(now time.Time) bool {
observerTimeout = time.Duration(currentSessionSettings.ObserverTimeout) * time.Second observerTimeout = time.Duration(currentSessionSettings.ObserverTimeout) * time.Second
} }
needsCleanup := false toDelete := make([]string, 0)
for id, session := range sm.sessions { for id, session := range sm.sessions {
if session.Mode == SessionModeObserver { if session.Mode == SessionModeObserver {
if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout { if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout {
sm.logger.Info(). sm.logger.Debug().
Str("sessionId", id). Str("sessionId", id).
Dur("inactiveFor", now.Sub(session.LastActive)). Dur("inactiveFor", now.Sub(session.LastActive)).
Dur("observerTimeout", observerTimeout). Dur("observerTimeout", observerTimeout).
Msg("Removing inactive observer session with closed RPC channel") Msg("Removing inactive observer session with closed RPC channel")
delete(sm.sessions, id) toDelete = append(toDelete, id)
needsCleanup = true
} }
} }
} }
return needsCleanup for _, id := range toDelete {
delete(sm.sessions, id)
}
return len(toDelete) > 0
} }
// handlePrimarySessionTimeout checks and handles primary session timeout // handlePrimarySessionTimeout checks and handles primary session timeout

View File

@ -250,6 +250,10 @@ func (sm *SessionManager) AddSession(session *Session, clientSettings *SessionSe
// Ensure session has auto-generated nickname if needed // Ensure session has auto-generated nickname if needed
sm.ensureNickname(session) sm.ensureNickname(session)
if !nicknameReserved && session.Nickname != "" {
sm.nicknameIndex[session.Nickname] = session
}
sm.sessions[session.ID] = session sm.sessions[session.ID] = session
// If this was the primary, try to restore primary status // If this was the primary, try to restore primary status
@ -1200,8 +1204,8 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
// Promote target session // Promote target session
toSession.Mode = SessionModePrimary toSession.Mode = SessionModePrimary
toSession.hidRPCAvailable = false toSession.hidRPCAvailable = false
// Reset LastActive only for emergency promotions to prevent immediate re-timeout // Reset LastActive for all emergency promotions to prevent immediate re-timeout
if transferType == "emergency_timeout_promotion" || transferType == "emergency_promotion_deadlock_prevention" { if strings.HasPrefix(transferType, "emergency_") {
toSession.LastActive = time.Now() toSession.LastActive = time.Now()
} }
sm.primarySessionID = toSessionID sm.primarySessionID = toSessionID

View File

@ -1,13 +1,15 @@
import { useEffect } from "react"; import { useEffect, useState } from "react";
import { SettingsItem } from "@components/SettingsItem"; import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader"; import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores"; import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc"; import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic"; import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting"; import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions } from "@/hooks/usePermissions"; import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions"; import { Permission } from "@/types/permissions";
import { Checkbox } from "@components/Checkbox";
import notifications from "../notifications"; import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting"; import { UsbInfoSetting } from "../components/UsbInfoSetting";
@ -18,6 +20,7 @@ export default function SettingsHardwareRoute() {
const settings = useSettingsStore(); const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore(); const { setDisplayRotation } = useSettingsStore();
const { hasPermission, isLoading, permissions } = usePermissions(); const { hasPermission, isLoading, permissions } = usePermissions();
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
const handleDisplayRotationChange = (rotation: string) => { const handleDisplayRotationChange = (rotation: string) => {
setDisplayRotation(rotation); setDisplayRotation(rotation);
@ -61,7 +64,21 @@ export default function SettingsHardwareRoute() {
}); });
}; };
// Check permissions before fetching settings data const handlePowerSavingChange = (enabled: boolean) => {
setPowerSavingEnabled(enabled);
const duration = enabled ? 90 : -1;
send("setVideoSleepMode", { duration }, (resp: JsonRpcResponse) => {
if ("error" in resp) {
notifications.error(
`Failed to set power saving mode: ${resp.error.data || "Unknown error"}`,
);
setPowerSavingEnabled(!enabled); // Revert on error
return;
}
notifications.success(`Power saving mode ${enabled ? "enabled" : "disabled"}`);
});
};
useEffect(() => { useEffect(() => {
// Only fetch settings if user has permission // Only fetch settings if user has permission
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) { if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
@ -77,6 +94,17 @@ export default function SettingsHardwareRoute() {
} }
}, [send, setBacklightSettings, isLoading, permissions]); }, [send, setBacklightSettings, isLoading, permissions]);
useEffect(() => {
send("getVideoSleepMode", {}, (resp: JsonRpcResponse) => {
if ("error" in resp) {
console.error("Failed to get power saving mode:", resp.error);
return;
}
const result = resp.result as { enabled: boolean; duration: number };
setPowerSavingEnabled(result.duration >= 0);
});
}, [send]);
// Return early if permissions are loading // Return early if permissions are loading
if (isLoading) { if (isLoading) {
return ( return (
@ -192,6 +220,26 @@ export default function SettingsHardwareRoute() {
</p> </p>
</div> </div>
<FeatureFlag minAppVersion="0.4.9">
<div className="space-y-4">
<div className="h-px w-full bg-slate-800/10 dark:bg-slate-300/20" />
<SettingsSectionHeader
title="Power Saving"
description="Reduce power consumption when not in use"
/>
<SettingsItem
badge="Experimental"
title="HDMI Sleep Mode"
description="Turn off capture after 90 seconds of inactivity"
>
<Checkbox
checked={powerSavingEnabled}
onChange={(e) => handlePowerSavingChange(e.target.checked)}
/>
</SettingsItem>
</div>
</FeatureFlag>
<FeatureFlag minAppVersion="0.3.8"> <FeatureFlag minAppVersion="0.3.8">
<UsbDeviceSetting /> <UsbDeviceSetting />
</FeatureFlag> </FeatureFlag>

View File

@ -898,10 +898,11 @@ export default function KvmIdRoute() {
useEffect(() => { useEffect(() => {
if (appVersion) return; if (appVersion) return;
if (rpcDataChannel?.readyState !== "open") return;
getLocalVersion(); getLocalVersion();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [appVersion]); }, [appVersion, rpcDataChannel?.readyState]);
const ConnectionStatusElement = useMemo(() => { const ConnectionStatusElement = useMemo(() => {
const isOtherSession = location.pathname.includes("other-session"); const isOtherSession = location.pathname.includes("other-session");

View File

@ -7,6 +7,7 @@ import (
"net" "net"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/coder/websocket" "github.com/coder/websocket"
@ -66,24 +67,14 @@ type Session struct {
keysDownStateQueue chan usbgadget.KeysDownState keysDownStateQueue chan usbgadget.KeysDownState
} }
var ( var actionSessions atomic.Int32
actionSessions int = 0
activeSessionsMutex = &sync.Mutex{}
)
func incrActiveSessions() int { func incrActiveSessions() int32 {
activeSessionsMutex.Lock() return actionSessions.Add(1)
defer activeSessionsMutex.Unlock()
actionSessions++
return actionSessions
} }
func getActiveSessions() int { func getActiveSessions() int32 {
activeSessionsMutex.Lock() return actionSessions.Load()
defer activeSessionsMutex.Unlock()
return actionSessions
} }
// CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection) // CheckRPCRateLimit checks if the session has exceeded RPC rate limits (DoS protection)
@ -494,9 +485,9 @@ func newSession(config SessionConfig) (*Session, error) {
if isConnected { if isConnected {
isConnected = false isConnected = false
actionSessions-- newCount := actionSessions.Add(-1)
onActiveSessionsChanged() onActiveSessionsChanged()
if actionSessions == 0 { if newCount == 0 {
onLastSessionDisconnected() onLastSessionDisconnected()
} }
} }