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)
BUILDTS := $(shell date -u +%s)
REVISION := $(shell git rev-parse HEAD)
VERSION_DEV := 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION := 0.4.8
VERSION_DEV ?= 0.4.9-dev$(shell date +%Y%m%d%H%M)
VERSION ?= 0.4.8
PROMETHEUS_TAG := github.com/prometheus/common/version
KVM_PKG_NAME := github.com/jetkvm/kvm

View File

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

View File

@ -70,7 +70,7 @@ func updateDisplay() {
nativeInstance.UpdateLabelIfChanged("hdmi_status_label", "Disconnected")
_, _ = 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() {
nativeInstance.UISetVar("main_screen", "home_screen")

View File

@ -405,8 +405,8 @@ char *jetkvm_video_log_status() {
return (char *)videoc_log_status();
}
int jetkvm_video_init() {
return video_init();
int jetkvm_video_init(float factor) {
return video_init(factor);
}
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);
int jetkvm_video_init();
int jetkvm_video_init(float quality_factor);
void jetkvm_video_shutdown();
void jetkvm_video_start();
void jetkvm_video_stop();

View File

@ -29,6 +29,7 @@
#define VIDEO_DEV "/dev/video0"
#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_2(x) RK_ALIGN(x, 2)
@ -39,6 +40,7 @@ int sub_dev_fd = -1;
#define VENC_CHANNEL 0
MB_POOL memPool = MB_INVALID_POOLID;
bool sleep_mode_available = false;
bool should_exit = false;
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 */
}
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)
{
const int32_t base_bitrate_high = 2000;
@ -190,8 +231,15 @@ static int32_t buf_init()
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)
{
log_error("RK_MPI_SYS_Init failed");
@ -301,11 +349,29 @@ static void *venc_read_stream(void *arg)
}
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_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)
{
FILE *file = fopen(filename, "wb");
@ -319,6 +385,8 @@ void *run_video_stream(void *arg)
log_info("running video stream");
streaming_stopped = false;
while (streaming_flag)
{
if (detected_signal == false)
@ -401,7 +469,7 @@ void *run_video_stream(void *arg)
{
log_error("get mb blk failed!");
close(video_dev_fd);
return ;
return (void *)errno;
}
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));
}
// 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();
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");
streaming_stopped = true;
return NULL;
}
@ -577,61 +660,80 @@ void video_shutdown()
RK_MPI_MB_DestroyPool(memPool);
}
log_info("Destroyed memory pool");
pthread_mutex_destroy(&streaming_mutex);
log_info("Destroyed streaming mutex");
}
void video_start_streaming()
{
pthread_mutex_lock(&streaming_mutex);
log_info("starting video streaming");
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");
goto cleanup;
return;
}
pthread_t *new_thread = malloc(sizeof(pthread_t));
if (new_thread == NULL)
{
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);
if (result != 0)
{
log_error("Failed to create streaming thread: %s", strerror(result));
streaming_flag = false;
set_streaming_flag(false);
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;
cleanup:
pthread_mutex_unlock(&streaming_mutex);
return;
}
void video_stop_streaming()
{
pthread_mutex_lock(&streaming_mutex);
if (streaming_thread != NULL)
{
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");
if (streaming_thread == NULL) {
log_info("video streaming already stopped");
return;
}
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)
@ -650,6 +752,8 @@ void *run_detect_format(void *arg)
while (!should_exit)
{
ensure_sleep_mode_disabled();
memset(&dv_timings, 0, sizeof(dv_timings));
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.hbackporch));
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_height = dv_timings.bt.height;
detected_signal = true;
video_report_format(true, NULL, detected_width, detected_height, frames_per_second);
pthread_mutex_lock(&streaming_mutex);
if (streaming_flag == true)
{
pthread_mutex_unlock(&streaming_mutex);
log_info("restarting on going video streaming");
video_stop_streaming();
video_start_streaming();
}
else
{
pthread_mutex_unlock(&streaming_mutex);
if (should_restart) {
log_info("restarting video streaming due to format change");
video_restart_streaming();
}
}
@ -731,21 +831,9 @@ void video_set_quality_factor(float factor)
quality_factor = factor;
// TODO: update venc bitrate without stopping 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);
}
video_restart_streaming();
}
float video_get_quality_factor() {
return quality_factor;
}
}

View File

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

View File

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

View File

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

View File

@ -1,11 +1,18 @@
package native
import (
"fmt"
"os"
"time"
)
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.
type VideoState struct {
Ready bool `json:"ready"`
@ -66,12 +73,32 @@ func (n *Native) VideoSleepModeSupported() bool {
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.
func (n *Native) VideoSetQualityFactor(factor float64) error {
n.videoLock.Lock()
defer n.videoLock.Unlock()
return videoSetStreamQualityFactor(factor)
return n.useExtraLock(func() error {
return videoSetStreamQualityFactor(factor)
})
}
// VideoGetQualityFactor gets the quality factor for the video stream.
@ -87,7 +114,13 @@ func (n *Native) VideoSetEDID(edid string) error {
n.videoLock.Lock()
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.

View File

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

View File

@ -17,9 +17,10 @@ var (
func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
nativeInstance = native.NewNative(native.NativeOptions{
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
SystemVersion: systemVersion,
AppVersion: appVersion,
DisplayRotation: config.GetDisplayRotation(),
DefaultQualityFactor: config.VideoQualityFactor,
OnVideoStateChange: func(state native.VideoState) {
lastVideoState = state
triggerVideoStateUpdate()
@ -70,7 +71,13 @@ func initNative(systemVersion *semver.Version, appVersion *semver.Version) {
})
},
})
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" {
nativeInstance.DoNotUseThisIsForCrashTestingOnly()

View File

@ -25,6 +25,18 @@ func (sm *SessionManager) attemptEmergencyPromotion(ctx emergencyPromotionContex
sm.emergencyWindowMutex.Lock()
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 maxEmergencyPromotionsPerMinute = 3
@ -187,19 +199,21 @@ func (sm *SessionManager) promoteAfterGraceExpiration(expiredSessionID string, n
// handlePendingSessionTimeout removes timed-out pending sessions (DoS protection)
// Returns true if any pending session was removed
func (sm *SessionManager) handlePendingSessionTimeout(now time.Time) bool {
needsCleanup := false
toDelete := make([]string, 0)
for id, session := range sm.sessions {
if session.Mode == SessionModePending &&
now.Sub(session.CreatedAt) > defaultPendingSessionTimeout {
websocketLogger.Info().
websocketLogger.Debug().
Str("sessionId", id).
Dur("age", now.Sub(session.CreatedAt)).
Msg("Removing timed-out pending session")
delete(sm.sessions, id)
needsCleanup = true
toDelete = append(toDelete, id)
}
}
return needsCleanup
for _, id := range toDelete {
delete(sm.sessions, id)
}
return len(toDelete) > 0
}
// 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
}
needsCleanup := false
toDelete := make([]string, 0)
for id, session := range sm.sessions {
if session.Mode == SessionModeObserver {
if session.RPCChannel == nil && now.Sub(session.LastActive) > observerTimeout {
sm.logger.Info().
sm.logger.Debug().
Str("sessionId", id).
Dur("inactiveFor", now.Sub(session.LastActive)).
Dur("observerTimeout", observerTimeout).
Msg("Removing inactive observer session with closed RPC channel")
delete(sm.sessions, id)
needsCleanup = true
toDelete = append(toDelete, id)
}
}
}
return needsCleanup
for _, id := range toDelete {
delete(sm.sessions, id)
}
return len(toDelete) > 0
}
// 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
sm.ensureNickname(session)
if !nicknameReserved && session.Nickname != "" {
sm.nicknameIndex[session.Nickname] = session
}
sm.sessions[session.ID] = session
// If this was the primary, try to restore primary status
@ -1200,8 +1204,8 @@ func (sm *SessionManager) transferPrimaryRole(fromSessionID, toSessionID, transf
// Promote target session
toSession.Mode = SessionModePrimary
toSession.hidRPCAvailable = false
// Reset LastActive only for emergency promotions to prevent immediate re-timeout
if transferType == "emergency_timeout_promotion" || transferType == "emergency_promotion_deadlock_prevention" {
// Reset LastActive for all emergency promotions to prevent immediate re-timeout
if strings.HasPrefix(transferType, "emergency_") {
toSession.LastActive = time.Now()
}
sm.primarySessionID = toSessionID

View File

@ -1,13 +1,15 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { SettingsItem } from "@components/SettingsItem";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { SettingsSectionHeader } from "@components/SettingsSectionHeader";
import { BacklightSettings, useSettingsStore } from "@/hooks/stores";
import { JsonRpcResponse, useJsonRpc } from "@/hooks/useJsonRpc";
import { SelectMenuBasic } from "@components/SelectMenuBasic";
import { UsbDeviceSetting } from "@components/UsbDeviceSetting";
import { usePermissions } from "@/hooks/usePermissions";
import { Permission } from "@/types/permissions";
import { Checkbox } from "@components/Checkbox";
import notifications from "../notifications";
import { UsbInfoSetting } from "../components/UsbInfoSetting";
@ -18,6 +20,7 @@ export default function SettingsHardwareRoute() {
const settings = useSettingsStore();
const { setDisplayRotation } = useSettingsStore();
const { hasPermission, isLoading, permissions } = usePermissions();
const [powerSavingEnabled, setPowerSavingEnabled] = useState(false);
const handleDisplayRotationChange = (rotation: string) => {
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(() => {
// Only fetch settings if user has permission
if (!isLoading && permissions[Permission.SETTINGS_READ] === true) {
@ -77,6 +94,17 @@ export default function SettingsHardwareRoute() {
}
}, [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
if (isLoading) {
return (
@ -192,6 +220,26 @@ export default function SettingsHardwareRoute() {
</p>
</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">
<UsbDeviceSetting />
</FeatureFlag>

View File

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

View File

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