diff --git a/config.go b/config.go index 642f113f..a88c492f 100644 --- a/config.go +++ b/config.go @@ -20,6 +20,7 @@ type Config struct { CloudToken string `json:"cloud_token"` GoogleIdentity string `json:"google_identity"` JigglerEnabled bool `json:"jiggler_enabled"` + JigglerConfig *JigglerConfig `json:"jiggler_config"` AutoUpdateEnabled bool `json:"auto_update_enabled"` IncludePreRelease bool `json:"include_pre_release"` HashedPassword string `json:"hashed_password"` @@ -46,7 +47,12 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes - TLSMode: "", + JigglerConfig: &JigglerConfig{ + InactivityLimitSeconds: 20, + JitterPercentage: 0, + ScheduleCronTab: "*/20 * * * * *", + }, + TLSMode: "", UsbConfig: &usbgadget.Config{ VendorId: "0x1d6b", //The Linux Foundation ProductId: "0x0104", //Multifunction Composite Gadget diff --git a/go.mod b/go.mod index 93fedab4..a2bbed5f 100644 --- a/go.mod +++ b/go.mod @@ -40,11 +40,13 @@ require ( github.com/creack/goselect v0.1.2 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-co-op/gocron/v2 v2.16.1 // indirect github.com/go-jose/go-jose/v4 v4.0.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/goccy/go-json v0.10.2 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect @@ -70,6 +72,7 @@ require ( github.com/pion/turn/v4 v4.0.0 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect + github.com/robfig/cron/v3 v3.0.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 diff --git a/go.sum b/go.sum index b5769d89..134074b8 100644 --- a/go.sum +++ b/go.sum @@ -33,6 +33,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo= +github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc= github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -56,6 +58,8 @@ github.com/hanwen/go-fuse/v2 v2.5.1 h1:OQBE8zVemSocRxA4OaFJbjJ5hlpCmIWbGr7r0M4uo 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/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= 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.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= @@ -135,6 +139,8 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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= diff --git a/jiggler.go b/jiggler.go index daec1921..783c2761 100644 --- a/jiggler.go +++ b/jiggler.go @@ -1,12 +1,21 @@ package kvm import ( + "fmt" + "github.com/go-co-op/gocron/v2" + "math/rand" "time" ) -var lastUserInput = time.Now() +type JigglerConfig struct { + InactivityLimitSeconds int `json:"inactivity_limit_seconds"` + JitterPercentage int `json:"jitter_percentage"` + ScheduleCronTab string `json:"schedule_cron_tab"` +} var jigglerEnabled = false +var jobDelta time.Duration = 0 +var scheduler gocron.Scheduler = nil func rpcSetJigglerState(enabled bool) { jigglerEnabled = enabled @@ -15,27 +24,112 @@ func rpcGetJigglerState() bool { return jigglerEnabled } +func rpcGetJigglerConfig() (JigglerConfig, error) { + return *config.JigglerConfig, nil +} + +func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error { + logger.Infof("jigglerConfig: %v, %v, %v", jigglerConfig.InactivityLimitSeconds, jigglerConfig.JitterPercentage, jigglerConfig.ScheduleCronTab) + config.JigglerConfig = &jigglerConfig + err := removeExistingCrobJobs(scheduler) + if err != nil { + return fmt.Errorf("error removing cron jobs from scheduler %v", err) + } + err = runJigglerCronTab() + if err != nil { + return fmt.Errorf("error scheduling jiggler crontab: %v", err) + } + err = SaveConfig() + if err != nil { + return fmt.Errorf("failed to save config: %w", err) + } + return nil +} + +func removeExistingCrobJobs(s gocron.Scheduler) error { + for _, j := range s.Jobs() { + err := s.RemoveJob(j.ID()) + if err != nil { + return err + } + } + return nil +} + func init() { ensureConfigLoaded() + err := runJigglerCronTab() + if err != nil { + logger.Errorf("Error scheduling jiggler crontab: %v", err) + return + } +} - go runJiggler() +func runJigglerCronTab() error { + cronTab := config.JigglerConfig.ScheduleCronTab + s, err := gocron.NewScheduler() + if err != nil { + return err + } + scheduler = s + _, err = s.NewJob( + gocron.CronJob( + cronTab, + true, + ), + gocron.NewTask( + func() { + runJiggler() + }, + ), + ) + if err != nil { + return err + } + s.Start() + delta, err := calculateJobDelta(s) + jobDelta = delta + logger.Infof("Time between jiggler runs: %v", jobDelta) + if err != nil { + return err + } + return nil } func runJiggler() { - for { - if jigglerEnabled { - if time.Since(lastUserInput) > 20*time.Second { - //TODO: change to rel mouse - err := rpcAbsMouseReport(1, 1, 0) - if err != nil { - logger.Warnf("Failed to jiggle mouse: %v", err) - } - err = rpcAbsMouseReport(0, 0, 0) - if err != nil { - logger.Warnf("Failed to reset mouse position: %v", err) - } + if jigglerEnabled { + if config.JigglerConfig.JitterPercentage != 0 { + jitter := calculateJitterDuration(jobDelta) + time.Sleep(jitter) + } + inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds + timeSinceLastInput := time.Since(gadget.GetLastUserInputTime()) + logger.Debugf("Time since last user input %v", timeSinceLastInput) + if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second { + logger.Debug("Jiggling mouse...") + //TODO: change to rel mouse + err := rpcAbsMouseReport(1, 1, 0) + if err != nil { + logger.Warnf("Failed to jiggle mouse: %v", err) + } + err = rpcAbsMouseReport(0, 0, 0) + if err != nil { + logger.Warnf("Failed to reset mouse position: %v", err) } } - time.Sleep(20 * time.Second) } } + +func calculateJobDelta(s gocron.Scheduler) (time.Duration, error) { + j := s.Jobs()[0] + runs, err := j.NextRuns(2) + if err != nil { + return 0.0, err + } + return runs[1].Sub(runs[0]), nil +} + +func calculateJitterDuration(delta time.Duration) time.Duration { + jitter := rand.Float64() * float64(config.JigglerConfig.JitterPercentage) / 100 * delta.Seconds() + return time.Duration(jitter * float64(time.Second)) +} diff --git a/jsonrpc.go b/jsonrpc.go index 64935e11..c9663ec5 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -807,6 +807,8 @@ var rpcHandlers = map[string]RPCHandler{ "rpcMountBuiltInImage": {Func: rpcMountBuiltInImage, Params: []string{"filename"}}, "setJigglerState": {Func: rpcSetJigglerState, Params: []string{"enabled"}}, "getJigglerState": {Func: rpcGetJigglerState}, + "setJigglerConfig": {Func: rpcSetJigglerConfig, Params: []string{"jigglerConfig"}}, + "getJigglerConfig": {Func: rpcGetJigglerConfig}, "sendWOLMagicPacket": {Func: rpcSendWOLMagicPacket, Params: []string{"macAddress"}}, "getStreamQualityFactor": {Func: rpcGetStreamQualityFactor}, "setStreamQualityFactor": {Func: rpcSetStreamQualityFactor, Params: []string{"factor"}}, diff --git a/ui/src/components/JigglerSetting.tsx b/ui/src/components/JigglerSetting.tsx new file mode 100644 index 00000000..0f2c745b --- /dev/null +++ b/ui/src/components/JigglerSetting.tsx @@ -0,0 +1,221 @@ +import { useCallback, useEffect, useState } from "react"; + +import { Button } from "@components/Button"; +import { SelectMenuBasic } from "@components/SelectMenuBasic"; + +import { useJsonRpc } from "../hooks/useJsonRpc"; +import notifications from "../notifications"; + +import { InputFieldWithLabel } from "./InputField"; + + +export interface JigglerConfig { + inactivity_limit_seconds: number; + jitter_percentage: number; + schedule_cron_tab: string; +} + +const jigglerCrontabConfigs = [ + { + label: "Every 20 seconds", + value: "*/20 * * * * *", + }, + { + label: "Every 40 seconds", + value: "*/40 * * * * *", + }, + { + label: "Every 1 minute", + value: "0 * * * * *", + }, + { + label: "Every 3 minutes", + value: "0 */3 * * * *", + }, +]; + +const jigglerJitterConfigs = [ + { + label: "No Jitter", + value: "0", + }, + { + label: "10%", + value: "20", + }, + { + label: "25%", + value: "25", + }, + { + label: "50%", + value: "50", + }, +]; + +const jigglerInactivityConfigs = [ + { + label: "20 Seconds", + value: "20", + }, + { + label: "40 Seconds", + value: "40", + }, + { + label: "1 Minute", + value: "60", + }, + { + label: "3 Minutes", + value: "180", + }, +]; + +export function JigglerSetting() { + const [send] = useJsonRpc(); + const [loading, setLoading] = useState(false); + const [jitterPercentage, setJitterPercentage] = useState(""); + const [scheduleCronTab, setScheduleCronTab] = useState(""); + + const [jigglerConfigState, setJigglerConfigState] = useState({ + inactivity_limit_seconds: 20, + jitter_percentage: 0, + schedule_cron_tab: "*/20 * * * * *" + }); + + const syncJigglerConfig = useCallback(() => { + send("getJigglerConfig", {}, resp => { + if ("error" in resp) return; + const result = resp.result as JigglerConfig; + setJigglerConfigState(result); + + const jitterPercentage = jigglerJitterConfigs.map(u => u.value).includes(result.jitter_percentage.toString()) + ? result.jitter_percentage.toString() + : "custom"; + setJitterPercentage(jitterPercentage) + + const scheduleCronTab = jigglerCrontabConfigs.map(u => u.value).includes(result.schedule_cron_tab) + ? result.schedule_cron_tab + : "custom"; + setScheduleCronTab(scheduleCronTab) + }); + }, [send]); + + useEffect(() => { + syncJigglerConfig() + }, [send, syncJigglerConfig]); + + const handleJigglerInactivityLimitSecondsChange = (value: string) => { + setJigglerConfigState({ ...jigglerConfigState, inactivity_limit_seconds: Number(value) }); + }; + + const handleJigglerJitterPercentageChange = (value: string) => { + setJigglerConfigState({ ...jigglerConfigState, jitter_percentage: Number(value) }); + }; + + const handleJigglerScheduleCronTabChange = (value: string) => { + setJigglerConfigState({ ...jigglerConfigState, schedule_cron_tab: value }); + }; + + const handleJigglerConfigSave = useCallback( + (jigglerConfig: JigglerConfig) => { + setLoading(true); + send("setJigglerConfig", { jigglerConfig }, async resp => { + if ("error" in resp) { + notifications.error( + `Failed to set jiggler config: ${resp.error.data || "Unknown error"}`, + ); + setLoading(false); + return; + } + setLoading(false); + notifications.success( + `Jiggler Config successfully updated`, + ); + syncJigglerConfig(); + }); + }, + [send, syncJigglerConfig], + ); + + return ( +
+
+ { + setScheduleCronTab(e.target.value); + if (e.target.value != "custom") { + handleJigglerScheduleCronTabChange(e.target.value); + } + }} + options={[...jigglerCrontabConfigs, {value: "custom", label: "Custom"}]} + /> + {scheduleCronTab === "custom" && ( + handleJigglerScheduleCronTabChange(e.target.value)} + /> + )} +
+
+ { + setJitterPercentage(e.target.value); + if (e.target.value != "custom") { + handleJigglerJitterPercentageChange(e.target.value) + } + }} + options={[...jigglerJitterConfigs, {value: "custom", label: "Custom"}]} + /> + {jitterPercentage === "custom" && ( + handleJigglerJitterPercentageChange(e.target.value)} + /> + )} +
+
+ { + handleJigglerInactivityLimitSecondsChange(e.target.value); + }} + options={[...jigglerInactivityConfigs]} + /> +
+
+
+
+ ); +} diff --git a/ui/src/routes/devices.$id.settings.mouse.tsx b/ui/src/routes/devices.$id.settings.mouse.tsx index d6223d01..0a6d6392 100644 --- a/ui/src/routes/devices.$id.settings.mouse.tsx +++ b/ui/src/routes/devices.$id.settings.mouse.tsx @@ -9,6 +9,8 @@ import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores"; import { useJsonRpc } from "@/hooks/useJsonRpc"; import notifications from "@/notifications"; import { SettingsPageHeader } from "@components/SettingsPageheader"; +import { JigglerSetting } from "@components/JigglerSetting"; +import { SettingsSectionHeader } from "@components/SettingsSectionHeader"; import { FeatureFlag } from "../components/FeatureFlag"; import { SelectMenuBasic } from "../components/SelectMenuBasic"; @@ -16,6 +18,7 @@ import { useFeatureFlag } from "../hooks/useFeatureFlag"; import { SettingsItem } from "./devices.$id.settings"; + type ScrollSensitivity = "low" | "default" | "high"; export default function SettingsKeyboardMouseRoute() { @@ -48,6 +51,11 @@ export default function SettingsKeyboardMouseRoute() { setScrollSensitivity(resp.result as ScrollSensitivity); }); } + + send("getJigglerConfig", {}, resp => { + if ("error" in resp) return; + setJiggler(resp.result as boolean); + }); }, [isScrollSensitivityEnabled, send, setScrollSensitivity]); const handleJigglerChange = (enabled: boolean) => { @@ -127,6 +135,16 @@ export default function SettingsKeyboardMouseRoute() { onChange={e => handleJigglerChange(e.target.checked)} /> + + {jiggler && ( + <> + + + + )}