mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-06 13:38:28 -04:00
wallpaper: migrate auto-cycling scheduling to backend
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
serverThemes "github.com/AvengeMedia/DankMaterialShell/core/internal/server/themes"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||
)
|
||||
@@ -56,6 +57,15 @@ func RouteRequest(conn net.Conn, req models.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "wallpaper.") {
|
||||
if wallpaperManager == nil {
|
||||
models.RespondError(conn, req.ID, "wallpaper manager not initialized")
|
||||
return
|
||||
}
|
||||
wallpaper.HandleRequest(conn, req, wallpaperManager)
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(req.Method, "loginctl.") {
|
||||
if loginctlManager == nil {
|
||||
models.RespondError(conn, req.ID, "loginctl manager not initialized")
|
||||
|
||||
@@ -33,13 +33,14 @@ import (
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/tailscale"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/thememode"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/trayrecovery"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wallpaper"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlcontext"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/wlroutput"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
const APIVersion = 26
|
||||
const APIVersion = 27
|
||||
|
||||
var CLIVersion = "dev"
|
||||
|
||||
@@ -73,6 +74,7 @@ var clipboardManager *clipboard.Manager
|
||||
var dbusManager *serverDbus.Manager
|
||||
var wlContext *wlcontext.SharedContext
|
||||
var themeModeManager *thememode.Manager
|
||||
var wallpaperManager *wallpaper.Manager
|
||||
var trayRecoveryManager *trayrecovery.Manager
|
||||
var locationManager *location.Manager
|
||||
var sysUpdateManager *sysupdate.Manager
|
||||
@@ -347,6 +349,13 @@ func InitializeThemeModeManager() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeWallpaperManager() error {
|
||||
wallpaperManager = wallpaper.NewManager()
|
||||
|
||||
log.Info("Wallpaper rotation scheduler initialized")
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitializeTrayRecoveryManager() error {
|
||||
manager, err := trayrecovery.NewManager()
|
||||
if err != nil {
|
||||
@@ -463,6 +472,10 @@ func getCapabilities() Capabilities {
|
||||
caps = append(caps, "theme.auto")
|
||||
}
|
||||
|
||||
if wallpaperManager != nil {
|
||||
caps = append(caps, "wallpaper")
|
||||
}
|
||||
|
||||
if dbusManager != nil {
|
||||
caps = append(caps, "dbus")
|
||||
}
|
||||
@@ -529,6 +542,10 @@ func getServerInfo() ServerInfo {
|
||||
caps = append(caps, "theme.auto")
|
||||
}
|
||||
|
||||
if wallpaperManager != nil {
|
||||
caps = append(caps, "wallpaper")
|
||||
}
|
||||
|
||||
if locationManager != nil {
|
||||
caps = append(caps, "location")
|
||||
}
|
||||
@@ -841,6 +858,38 @@ func handleSubscribe(conn net.Conn, req models.Request) {
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("wallpaper") && wallpaperManager != nil {
|
||||
wg.Add(1)
|
||||
wallpaperChan := wallpaperManager.Subscribe(clientID + "-wallpaper")
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer wallpaperManager.Unsubscribe(clientID + "-wallpaper")
|
||||
|
||||
initialState := wallpaperManager.GetState()
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "wallpaper", Data: initialState}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case state, ok := <-wallpaperChan:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case eventChan <- ServiceEvent{Service: "wallpaper", Data: state}:
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
case <-stopChan:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if shouldSubscribe("bluetooth") && bluezManager != nil {
|
||||
wg.Add(1)
|
||||
bluezChan := bluezManager.Subscribe(clientID + "-bluetooth")
|
||||
@@ -1286,6 +1335,9 @@ func cleanupManagers() {
|
||||
if themeModeManager != nil {
|
||||
themeModeManager.Close()
|
||||
}
|
||||
if wallpaperManager != nil {
|
||||
wallpaperManager.Close()
|
||||
}
|
||||
if trayRecoveryManager != nil {
|
||||
trayRecoveryManager.Close()
|
||||
}
|
||||
@@ -1578,6 +1630,19 @@ func Start(printDocs bool) error {
|
||||
}()
|
||||
}
|
||||
|
||||
if err := InitializeWallpaperManager(); err != nil {
|
||||
log.Warnf("Wallpaper scheduler unavailable: %v", err)
|
||||
} else {
|
||||
notifyCapabilityChange()
|
||||
go func() {
|
||||
<-loginctlReady
|
||||
if loginctlManager == nil {
|
||||
return
|
||||
}
|
||||
wallpaperManager.WatchLoginctl(loginctlManager)
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-loginctlReady
|
||||
if loginctlManager == nil {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package wallpaper
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/models"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/params"
|
||||
)
|
||||
|
||||
func HandleRequest(conn net.Conn, req models.Request, manager *Manager) {
|
||||
if manager == nil {
|
||||
models.RespondError(conn, req.ID, "wallpaper manager not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Method {
|
||||
case "wallpaper.getState":
|
||||
handleGetState(conn, req, manager)
|
||||
case "wallpaper.setConfig":
|
||||
handleSetConfig(conn, req, manager)
|
||||
case "wallpaper.trigger":
|
||||
handleTrigger(conn, req, manager)
|
||||
case "wallpaper.subscribe":
|
||||
handleSubscribe(conn, req, manager)
|
||||
default:
|
||||
models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method))
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetState(conn net.Conn, req models.Request, manager *Manager) {
|
||||
models.Respond(conn, req.ID, manager.GetState())
|
||||
}
|
||||
|
||||
func handleSetConfig(conn net.Conn, req models.Request, manager *Manager) {
|
||||
raw, ok := params.Any(req.Params, "config")
|
||||
if !ok {
|
||||
models.RespondError(conn, req.ID, "missing or invalid 'config' parameter")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
models.RespondError(conn, req.ID, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
manager.SetConfig(config)
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule set"})
|
||||
}
|
||||
|
||||
func handleTrigger(conn net.Conn, req models.Request, manager *Manager) {
|
||||
manager.ResetSchedule(params.StringOpt(req.Params, "target", ""))
|
||||
models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "wallpaper schedule reset"})
|
||||
}
|
||||
|
||||
func handleSubscribe(conn net.Conn, req models.Request, manager *Manager) {
|
||||
clientID := fmt.Sprintf("client-%p", conn)
|
||||
stateChan := manager.Subscribe(clientID)
|
||||
defer manager.Unsubscribe(clientID)
|
||||
|
||||
initialState := manager.GetState()
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
ID: req.ID,
|
||||
Result: &initialState,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for state := range stateChan {
|
||||
if err := json.NewEncoder(conn).Encode(models.Response[State]{
|
||||
Result: &state,
|
||||
}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package wallpaper
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/internal/server/loginctl"
|
||||
"github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap"
|
||||
)
|
||||
|
||||
type activeSchedule struct {
|
||||
cfg ScheduleConfig
|
||||
nextFire time.Time
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
config Config
|
||||
configMutex sync.RWMutex
|
||||
|
||||
state *State
|
||||
stateMutex sync.RWMutex
|
||||
|
||||
subscribers syncmap.Map[string, chan State]
|
||||
|
||||
stopChan chan struct{}
|
||||
updateTrigger chan struct{}
|
||||
resetReq chan string
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
m := &Manager{
|
||||
config: Config{
|
||||
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
|
||||
Monitors: map[string]ScheduleConfig{},
|
||||
},
|
||||
stopChan: make(chan struct{}),
|
||||
updateTrigger: make(chan struct{}, 1),
|
||||
resetReq: make(chan string, 8),
|
||||
}
|
||||
m.state = &State{Config: m.getConfig()}
|
||||
|
||||
m.wg.Add(1)
|
||||
go m.schedulerLoop()
|
||||
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) GetState() State {
|
||||
m.stateMutex.RLock()
|
||||
defer m.stateMutex.RUnlock()
|
||||
if m.state == nil {
|
||||
return State{Config: m.getConfig()}
|
||||
}
|
||||
return *m.state
|
||||
}
|
||||
|
||||
func (m *Manager) Subscribe(id string) chan State {
|
||||
ch := make(chan State, 64)
|
||||
m.subscribers.Store(id, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func (m *Manager) Unsubscribe(id string) {
|
||||
if val, ok := m.subscribers.LoadAndDelete(id); ok {
|
||||
close(val)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetConfig(config Config) {
|
||||
if config.Monitors == nil {
|
||||
config.Monitors = map[string]ScheduleConfig{}
|
||||
}
|
||||
m.configMutex.Lock()
|
||||
if reflect.DeepEqual(m.config, config) {
|
||||
m.configMutex.Unlock()
|
||||
return
|
||||
}
|
||||
m.config = config
|
||||
m.configMutex.Unlock()
|
||||
m.TriggerUpdate()
|
||||
}
|
||||
|
||||
func (m *Manager) ResetSchedule(target string) {
|
||||
select {
|
||||
case m.resetReq <- target:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) TriggerUpdate() {
|
||||
select {
|
||||
case m.updateTrigger <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Close() {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
default:
|
||||
close(m.stopChan)
|
||||
}
|
||||
m.wg.Wait()
|
||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||
close(ch)
|
||||
m.subscribers.Delete(key)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||
ch := lm.Subscribe("wallpaper")
|
||||
m.wg.Add(1)
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
defer lm.Unsubscribe("wallpaper")
|
||||
for {
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
return
|
||||
case state, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if state.PreparingForSleep {
|
||||
continue
|
||||
}
|
||||
m.TriggerUpdate()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (m *Manager) schedulerLoop() {
|
||||
defer m.wg.Done()
|
||||
|
||||
schedules := map[string]*activeSchedule{}
|
||||
resets := map[string]bool{}
|
||||
var seq uint64
|
||||
var timer *time.Timer
|
||||
|
||||
for {
|
||||
now := time.Now()
|
||||
config := m.getConfig()
|
||||
active := activeSchedules(config)
|
||||
|
||||
for key := range schedules {
|
||||
if _, ok := active[key]; !ok {
|
||||
delete(schedules, key)
|
||||
}
|
||||
}
|
||||
for key, cfg := range active {
|
||||
s, ok := schedules[key]
|
||||
switch {
|
||||
case !ok:
|
||||
schedules[key] = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)}
|
||||
case s.cfg != cfg || resets[key]:
|
||||
s.cfg = cfg
|
||||
s.nextFire = computeNext(now, cfg)
|
||||
}
|
||||
delete(resets, key)
|
||||
}
|
||||
|
||||
var dueKeys []string
|
||||
for key, s := range schedules {
|
||||
if !s.nextFire.After(now) {
|
||||
dueKeys = append(dueKeys, key)
|
||||
s.nextFire = computeNext(now, s.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
next, hasNext := soonest(schedules)
|
||||
if len(dueKeys) == 0 {
|
||||
m.setState(config, next, seq, "")
|
||||
}
|
||||
for _, key := range dueKeys {
|
||||
seq++
|
||||
m.setState(config, next, seq, key)
|
||||
}
|
||||
|
||||
waitDur := 24 * time.Hour
|
||||
if hasNext {
|
||||
waitDur = time.Until(next)
|
||||
if waitDur < time.Second {
|
||||
waitDur = time.Second
|
||||
}
|
||||
}
|
||||
|
||||
if timer != nil {
|
||||
timer.Stop()
|
||||
}
|
||||
timer = time.NewTimer(waitDur)
|
||||
|
||||
select {
|
||||
case <-m.stopChan:
|
||||
timer.Stop()
|
||||
return
|
||||
case <-m.updateTrigger:
|
||||
timer.Stop()
|
||||
case key := <-m.resetReq:
|
||||
timer.Stop()
|
||||
resets[key] = true
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) setState(config Config, next time.Time, seq uint64, target string) {
|
||||
newState := State{Config: config, NextRotation: next, CycleSeq: seq, Target: target}
|
||||
|
||||
m.stateMutex.Lock()
|
||||
if m.state != nil && statesEqual(m.state, &newState) {
|
||||
m.stateMutex.Unlock()
|
||||
return
|
||||
}
|
||||
m.state = &newState
|
||||
m.stateMutex.Unlock()
|
||||
|
||||
m.notifySubscribers()
|
||||
}
|
||||
|
||||
func (m *Manager) notifySubscribers() {
|
||||
state := m.GetState()
|
||||
m.subscribers.Range(func(key string, ch chan State) bool {
|
||||
select {
|
||||
case ch <- state:
|
||||
default:
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getConfig() Config {
|
||||
m.configMutex.RLock()
|
||||
defer m.configMutex.RUnlock()
|
||||
return m.config
|
||||
}
|
||||
|
||||
func activeSchedules(config Config) map[string]ScheduleConfig {
|
||||
out := map[string]ScheduleConfig{}
|
||||
if config.PerMonitor {
|
||||
for name, cfg := range config.Monitors {
|
||||
if cfg.Enabled {
|
||||
out[name] = cfg
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
if config.Global.Enabled {
|
||||
out[""] = config.Global
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func computeNext(now time.Time, cfg ScheduleConfig) time.Time {
|
||||
switch cfg.Mode {
|
||||
case "time":
|
||||
return nextDailyTime(now, cfg.Time)
|
||||
default:
|
||||
sec := cfg.IntervalSec
|
||||
if sec < 1 {
|
||||
sec = 1
|
||||
}
|
||||
return now.Add(time.Duration(sec) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func nextDailyTime(now time.Time, hhmm string) time.Time {
|
||||
hour, minute, ok := parseHHMM(hhmm)
|
||||
if !ok {
|
||||
return now.Add(24 * time.Hour)
|
||||
}
|
||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
||||
if !next.After(now) {
|
||||
next = next.Add(24 * time.Hour)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func parseHHMM(hhmm string) (int, int, bool) {
|
||||
parts := strings.Split(hhmm, ":")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0, false
|
||||
}
|
||||
hour, err := strconv.Atoi(parts[0])
|
||||
if err != nil || hour < 0 || hour > 23 {
|
||||
return 0, 0, false
|
||||
}
|
||||
minute, err := strconv.Atoi(parts[1])
|
||||
if err != nil || minute < 0 || minute > 59 {
|
||||
return 0, 0, false
|
||||
}
|
||||
return hour, minute, true
|
||||
}
|
||||
|
||||
func soonest(schedules map[string]*activeSchedule) (time.Time, bool) {
|
||||
var best time.Time
|
||||
found := false
|
||||
for _, s := range schedules {
|
||||
if !found || s.nextFire.Before(best) {
|
||||
best = s.nextFire
|
||||
found = true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
func statesEqual(a, b *State) bool {
|
||||
switch {
|
||||
case a == nil || b == nil:
|
||||
return a == b
|
||||
case a.CycleSeq != b.CycleSeq:
|
||||
return false
|
||||
case a.Target != b.Target:
|
||||
return false
|
||||
case !a.NextRotation.Equal(b.NextRotation):
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a.Config, b.Config)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package wallpaper
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseHHMM(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
hour int
|
||||
minute int
|
||||
ok bool
|
||||
}{
|
||||
{"06:00", 6, 0, true},
|
||||
{"23:59", 23, 59, true},
|
||||
{"00:00", 0, 0, true},
|
||||
{"24:00", 0, 0, false},
|
||||
{"6:5", 6, 5, true},
|
||||
{"bad", 0, 0, false},
|
||||
{"12", 0, 0, false},
|
||||
{"12:60", 0, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
hour, minute, ok := parseHHMM(c.in)
|
||||
if ok != c.ok || (ok && (hour != c.hour || minute != c.minute)) {
|
||||
t.Errorf("parseHHMM(%q) = (%d, %d, %v), want (%d, %d, %v)", c.in, hour, minute, ok, c.hour, c.minute, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextInterval(t *testing.T) {
|
||||
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
|
||||
got := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 300})
|
||||
if want := now.Add(300 * time.Second); !got.Equal(want) {
|
||||
t.Errorf("interval next = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
clamped := computeNext(now, ScheduleConfig{Mode: "interval", IntervalSec: 0})
|
||||
if want := now.Add(time.Second); !clamped.Equal(want) {
|
||||
t.Errorf("interval clamp = %v, want %v", clamped, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextTime(t *testing.T) {
|
||||
now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
later := computeNext(now, ScheduleConfig{Mode: "time", Time: "18:00"})
|
||||
if want := time.Date(2026, 6, 30, 18, 0, 0, 0, time.UTC); !later.Equal(want) {
|
||||
t.Errorf("time next (today) = %v, want %v", later, want)
|
||||
}
|
||||
|
||||
tomorrow := computeNext(now, ScheduleConfig{Mode: "time", Time: "06:00"})
|
||||
if want := time.Date(2026, 7, 1, 6, 0, 0, 0, time.UTC); !tomorrow.Equal(want) {
|
||||
t.Errorf("time next (tomorrow) = %v, want %v", tomorrow, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveSchedules(t *testing.T) {
|
||||
global := activeSchedules(Config{
|
||||
Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 60},
|
||||
})
|
||||
if len(global) != 1 {
|
||||
t.Fatalf("global active = %d, want 1", len(global))
|
||||
}
|
||||
if _, ok := global[""]; !ok {
|
||||
t.Errorf("global active missing global key")
|
||||
}
|
||||
|
||||
perMonitor := activeSchedules(Config{
|
||||
PerMonitor: true,
|
||||
Global: ScheduleConfig{Enabled: true},
|
||||
Monitors: map[string]ScheduleConfig{
|
||||
"DP-1": {Enabled: true, Mode: "interval", IntervalSec: 60},
|
||||
"DP-2": {Enabled: false},
|
||||
},
|
||||
})
|
||||
if len(perMonitor) != 1 {
|
||||
t.Fatalf("per-monitor active = %d, want 1", len(perMonitor))
|
||||
}
|
||||
if _, ok := perMonitor["DP-1"]; !ok {
|
||||
t.Errorf("per-monitor active missing DP-1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchedulerEmitsCycle(t *testing.T) {
|
||||
m := NewManager()
|
||||
defer m.Close()
|
||||
|
||||
sub := m.Subscribe("test")
|
||||
defer m.Unsubscribe("test")
|
||||
|
||||
m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "interval", IntervalSec: 1}})
|
||||
|
||||
deadline := time.After(3 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case state := <-sub:
|
||||
if state.CycleSeq > 0 && state.Target == "" {
|
||||
return
|
||||
}
|
||||
case <-deadline:
|
||||
t.Fatal("scheduler did not emit a cycle event within 3s")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package wallpaper
|
||||
|
||||
import "time"
|
||||
|
||||
type ScheduleConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Mode string `json:"mode"`
|
||||
IntervalSec int `json:"intervalSec"`
|
||||
Time string `json:"time"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
PerMonitor bool `json:"perMonitor"`
|
||||
Global ScheduleConfig `json:"global"`
|
||||
Monitors map[string]ScheduleConfig `json:"monitors"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
Config Config `json:"config"`
|
||||
NextRotation time.Time `json:"nextRotation"`
|
||||
CycleSeq uint64 `json:"cycleSeq"`
|
||||
Target string `json:"target"`
|
||||
}
|
||||
@@ -74,6 +74,9 @@ func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error
|
||||
|
||||
if config.Enabled {
|
||||
m.post(func() {
|
||||
if m.controlsInitialized {
|
||||
return
|
||||
}
|
||||
log.Info("Gamma control enabled at startup")
|
||||
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
||||
m.availOutputsMu.RLock()
|
||||
@@ -184,13 +187,26 @@ func (m *Manager) setupRegistry() error {
|
||||
enabled := m.config.Enabled
|
||||
m.configMutex.RUnlock()
|
||||
|
||||
if enabled && m.controlsInitialized {
|
||||
m.post(func() {
|
||||
if err := m.addOutputControl(output); err != nil {
|
||||
log.Warnf("Failed to add output control: %v", err)
|
||||
}
|
||||
})
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
m.post(func() {
|
||||
if err := m.addOutputControl(output); err != nil {
|
||||
log.Warnf("gamma: failed to add output control: %v", err)
|
||||
return
|
||||
}
|
||||
if m.controlsInitialized {
|
||||
return
|
||||
}
|
||||
// All outputs had been torn down (monitor sleep/disconnect),
|
||||
// clearing controlsInitialized. Mark it ready again so the
|
||||
// gamma_size event that follows control creation drives the
|
||||
// reapply, instead of waiting for a manual toggle. No explicit
|
||||
// apply here: applyGamma's dedup would suppress a no-change
|
||||
// write anyway, and the new control isn't ready until gamma_size.
|
||||
log.Info("gamma: output returned, re-establishing controls")
|
||||
m.controlsInitialized = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user