From f7b8efde7caffa930ed6a76a2c5ee823019409e1 Mon Sep 17 00:00:00 2001 From: jackislanding Date: Tue, 12 Aug 2025 13:50:03 -0500 Subject: [PATCH] Added crontab scheduler for jiggler (#316) --- config.go | 9 +- go.mod | 9 +- go.sum | 20 +- jiggler.go | 127 +++++++++-- jsonrpc.go | 2 + ui/src/components/FieldLabel.tsx | 8 +- ui/src/components/InputField.tsx | 2 +- ui/src/components/JigglerSetting.tsx | 96 +++++++++ ui/src/components/SelectMenuBasic.tsx | 4 +- ui/src/components/SettingsNestedSection.tsx | 11 + ui/src/routes/devices.$id.settings.mouse.tsx | 208 +++++++++++++++---- 11 files changed, 424 insertions(+), 72 deletions(-) create mode 100644 ui/src/components/JigglerSetting.tsx create mode 100644 ui/src/components/SettingsNestedSection.tsx diff --git a/config.go b/config.go index d48e25b..46f83e6 100644 --- a/config.go +++ b/config.go @@ -82,6 +82,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"` @@ -117,7 +118,13 @@ var defaultConfig = &Config{ DisplayMaxBrightness: 64, DisplayDimAfterSec: 120, // 2 minutes DisplayOffAfterSec: 1800, // 30 minutes - TLSMode: "", + // This is the "Standard" jiggler option in the UI + JigglerConfig: &JigglerConfig{ + InactivityLimitSeconds: 60, + JitterPercentage: 25, + ScheduleCronTab: "0 * * * * *", + }, + TLSMode: "", UsbConfig: &usbgadget.Config{ VendorId: "0x1d6b", //The Linux Foundation ProductId: "0x0104", //Multifunction Composite Gadget diff --git a/go.mod b/go.mod index 426f656..3e41071 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 github.com/gin-gonic/gin v1.10.1 + github.com/go-co-op/gocron/v2 v2.16.3 github.com/google/uuid v1.6.0 github.com/guregu/null/v6 v6.0.0 github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 @@ -28,9 +29,9 @@ require ( github.com/stretchr/testify v1.10.0 github.com/vishvananda/netlink v1.3.1 go.bug.st/serial v1.6.4 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.40.0 golang.org/x/net v0.41.0 - golang.org/x/sys v0.33.0 + golang.org/x/sys v0.34.0 ) replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b @@ -50,6 +51,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -75,6 +77,7 @@ require ( github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect @@ -82,7 +85,7 @@ require ( github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/arch v0.18.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/text v0.27.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a9f9b77..6b75ad1 100644 --- a/go.sum +++ b/go.sum @@ -38,6 +38,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI= +github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -62,6 +64,8 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs= github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= +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.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -146,6 +150,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -175,10 +181,12 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= @@ -188,10 +196,10 @@ golang.org/x/sys v0.2.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/jiggler.go b/jiggler.go index 0a33fd6..e5aa14e 100644 --- a/jiggler.go +++ b/jiggler.go @@ -1,12 +1,22 @@ package kvm import ( + "fmt" + "math/rand" "time" + + "github.com/go-co-op/gocron/v2" ) -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,25 +25,112 @@ func rpcGetJigglerState() bool { return jigglerEnabled } +func rpcGetJigglerConfig() (JigglerConfig, error) { + return *config.JigglerConfig, nil +} + +func rpcSetJigglerConfig(jigglerConfig JigglerConfig) error { + logger.Info().Msgf("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 initJiggler() { - go runJiggler() + ensureConfigLoaded() + err := runJigglerCronTab() + if err != nil { + logger.Error().Msgf("Error scheduling jiggler crontab: %v", err) + return + } +} + +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.Info().Msgf("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.Warn().Err(err).Msg("Failed to jiggle mouse") - } - err = rpcAbsMouseReport(0, 0, 0) - if err != nil { - logger.Warn().Err(err).Msg("Failed to reset mouse position") - } + if jigglerEnabled { + if config.JigglerConfig.JitterPercentage != 0 { + jitter := calculateJitterDuration(jobDelta) + time.Sleep(jitter) + } + inactivitySeconds := config.JigglerConfig.InactivityLimitSeconds + timeSinceLastInput := time.Since(gadget.GetLastUserInputTime()) + logger.Debug().Msgf("Time since last user input %v", timeSinceLastInput) + if timeSinceLastInput > time.Duration(inactivitySeconds)*time.Second { + logger.Debug().Msg("Jiggling mouse...") + //TODO: change to rel mouse + err := rpcAbsMouseReport(1, 1, 0) + if err != nil { + logger.Warn().Msgf("Failed to jiggle mouse: %v", err) + } + err = rpcAbsMouseReport(0, 0, 0) + if err != nil { + logger.Warn().Msgf("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 e930f49..a0264b8 100644 --- a/jsonrpc.go +++ b/jsonrpc.go @@ -1056,6 +1056,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/FieldLabel.tsx b/ui/src/components/FieldLabel.tsx index f9065a1..dc7018d 100644 --- a/ui/src/components/FieldLabel.tsx +++ b/ui/src/components/FieldLabel.tsx @@ -27,7 +27,7 @@ export default function FieldLabel({ > {label} {description && ( - + {description} )} @@ -36,11 +36,11 @@ export default function FieldLabel({ } else if (as === "span") { return (
- + {label} {description && ( - + {description} )} @@ -49,4 +49,4 @@ export default function FieldLabel({ } else { return <>; } -} \ No newline at end of file +} diff --git a/ui/src/components/InputField.tsx b/ui/src/components/InputField.tsx index ff2ad55..dfa7a4f 100644 --- a/ui/src/components/InputField.tsx +++ b/ui/src/components/InputField.tsx @@ -26,7 +26,7 @@ type InputFieldProps = { type InputFieldWithLabelProps = InputFieldProps & { label: React.ReactNode; - description?: string | null; + description?: React.ReactNode | string | null; }; const InputField = forwardRef(function InputField( diff --git a/ui/src/components/JigglerSetting.tsx b/ui/src/components/JigglerSetting.tsx new file mode 100644 index 0000000..d881089 --- /dev/null +++ b/ui/src/components/JigglerSetting.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; + +import { Button } from "@components/Button"; + +import { InputFieldWithLabel } from "./InputField"; +import ExtLink from "./ExtLink"; + +export interface JigglerConfig { + inactivity_limit_seconds: number; + jitter_percentage: number; + schedule_cron_tab: string; +} + +export function JigglerSetting({ + onSave, +}: { + onSave: (jigglerConfig: JigglerConfig) => void; +}) { + const [jigglerConfigState, setJigglerConfigState] = useState({ + inactivity_limit_seconds: 20, + jitter_percentage: 0, + schedule_cron_tab: "*/20 * * * * *", + }); + + return ( +
+
+ + Generate with{" "} + + crontab.guru + + + } + placeholder="*/20 * * * * *" + defaultValue={jigglerConfigState.schedule_cron_tab} + onChange={e => + setJigglerConfigState({ + ...jigglerConfigState, + schedule_cron_tab: e.target.value, + }) + } + /> + + + setJigglerConfigState({ + ...jigglerConfigState, + inactivity_limit_seconds: Number(e.target.value), + }) + } + /> + + %} + defaultValue={jigglerConfigState.jitter_percentage} + type="number" + min="0" + max="100" + onChange={e => + setJigglerConfigState({ + ...jigglerConfigState, + jitter_percentage: Number(e.target.value), + }) + } + /> +
+ +
+
+
+ ); +} diff --git a/ui/src/components/SelectMenuBasic.tsx b/ui/src/components/SelectMenuBasic.tsx index d5e9597..b92f837 100644 --- a/ui/src/components/SelectMenuBasic.tsx +++ b/ui/src/components/SelectMenuBasic.tsx @@ -26,7 +26,7 @@ type SelectMenuProps = Pick< const sizes = { XS: "h-[24.5px] pl-3 pr-8 text-xs", - SM: "h-[32px] pl-3 pr-8 text-[13px]", + SM: "h-[36px] pl-3 pr-8 text-[13px]", MD: "h-[40px] pl-4 pr-10 text-sm", LG: "h-[48px] pl-4 pr-10 px-5 text-base", }; @@ -62,7 +62,7 @@ export const SelectMenuBasic = React.forwardRef - {label && } + {label && }