diff --git a/core/internal/server/wallpaper/manager.go b/core/internal/server/wallpaper/manager.go index c8059a9bf..d3c2edbe3 100644 --- a/core/internal/server/wallpaper/manager.go +++ b/core/internal/server/wallpaper/manager.go @@ -16,6 +16,10 @@ type activeSchedule struct { nextFire time.Time } +// Grace period before a missed-while-off schedule fires, so a freshly +// connected frontend has its cycle-seq baseline before the bump arrives. +var catchUpDelay = 3 * time.Second + type Manager struct { config Config configMutex sync.RWMutex @@ -29,9 +33,20 @@ type Manager struct { updateTrigger chan struct{} resetReq chan string wg sync.WaitGroup + + // Owned by schedulerLoop after start; keyed like schedules ("" = global). + lastFires map[string]time.Time + persistFires func(map[string]time.Time) } func NewManager() *Manager { + return newManager(loadLastFires(), saveLastFires) +} + +func newManager(lastFires map[string]time.Time, persistFires func(map[string]time.Time)) *Manager { + if lastFires == nil { + lastFires = map[string]time.Time{} + } m := &Manager{ config: Config{ Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"}, @@ -40,6 +55,8 @@ func NewManager() *Manager { stopChan: make(chan struct{}), updateTrigger: make(chan struct{}, 1), resetReq: make(chan string, 8), + lastFires: lastFires, + persistFires: persistFires, } m.state = &State{Config: m.getConfig()} @@ -115,9 +132,7 @@ func (m *Manager) Close() { func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { ch := lm.Subscribe("wallpaper") - m.wg.Add(1) - go func() { - defer m.wg.Done() + m.wg.Go(func() { defer lm.Unsubscribe("wallpaper") for { select { @@ -133,7 +148,7 @@ func (m *Manager) WatchLoginctl(lm *loginctl.Manager) { m.TriggerUpdate() } } - }() + }) } func (m *Manager) schedulerLoop() { @@ -154,11 +169,26 @@ func (m *Manager) schedulerLoop() { delete(schedules, key) } } + firesDirty := false for key, cfg := range active { s, ok := schedules[key] switch { case !ok: - schedules[key] = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)} + s = &activeSchedule{cfg: cfg, nextFire: computeNext(now, cfg)} + schedules[key] = s + if cfg.Mode == "time" { + last := m.lastFires[key] + prev, valid := prevDailyTime(now, cfg.Time) + switch { + case last.IsZero(): + // First enable (or no history): seed instead of firing. + m.lastFires[key] = now + firesDirty = true + case valid && last.Before(prev): + // Scheduled time passed while dms wasn't running. + s.nextFire = now.Add(catchUpDelay) + } + } case s.cfg != cfg || resets[key]: s.cfg = cfg s.nextFire = computeNext(now, cfg) @@ -171,9 +201,22 @@ func (m *Manager) schedulerLoop() { if !s.nextFire.After(now) { dueKeys = append(dueKeys, key) s.nextFire = computeNext(now, s.cfg) + if s.cfg.Mode == "time" { + m.lastFires[key] = now + firesDirty = true + } } } + if firesDirty { + for key := range m.lastFires { + if _, ok := schedules[key]; !ok { + delete(m.lastFires, key) + } + } + m.persistFires(m.lastFires) + } + next, hasNext := soonest(schedules) if len(dueKeys) == 0 { m.setState(config, next, seq, "") @@ -185,10 +228,7 @@ func (m *Manager) schedulerLoop() { waitDur := 24 * time.Hour if hasNext { - waitDur = time.Until(next) - if waitDur < time.Second { - waitDur = time.Second - } + waitDur = max(time.Until(next), time.Second) } if timer != nil { @@ -262,10 +302,7 @@ func computeNext(now time.Time, cfg ScheduleConfig) time.Time { case "time": return nextDailyTime(now, cfg.Time) default: - sec := cfg.IntervalSec - if sec < 1 { - sec = 1 - } + sec := max(cfg.IntervalSec, 1) return now.Add(time.Duration(sec) * time.Second) } } @@ -282,6 +319,18 @@ func nextDailyTime(now time.Time, hhmm string) time.Time { return next } +func prevDailyTime(now time.Time, hhmm string) (time.Time, bool) { + hour, minute, ok := parseHHMM(hhmm) + if !ok { + return time.Time{}, false + } + prev := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location()) + if prev.After(now) { + prev = prev.Add(-24 * time.Hour) + } + return prev, true +} + func parseHHMM(hhmm string) (int, int, bool) { parts := strings.Split(hhmm, ":") if len(parts) != 2 { diff --git a/core/internal/server/wallpaper/manager_test.go b/core/internal/server/wallpaper/manager_test.go index db63ec577..a80b105cf 100644 --- a/core/internal/server/wallpaper/manager_test.go +++ b/core/internal/server/wallpaper/manager_test.go @@ -83,8 +83,81 @@ func TestActiveSchedules(t *testing.T) { } } +func TestPrevDailyTime(t *testing.T) { + now := time.Date(2026, 6, 30, 12, 0, 0, 0, time.UTC) + + earlier, ok := prevDailyTime(now, "06:00") + if !ok || !earlier.Equal(time.Date(2026, 6, 30, 6, 0, 0, 0, time.UTC)) { + t.Errorf("prev (today) = %v, %v", earlier, ok) + } + + later, ok := prevDailyTime(now, "18:00") + if !ok || !later.Equal(time.Date(2026, 6, 29, 18, 0, 0, 0, time.UTC)) { + t.Errorf("prev (yesterday) = %v, %v", later, ok) + } + + if _, ok := prevDailyTime(now, "bad"); ok { + t.Error("prev (invalid) should not be ok") + } +} + +func TestTimeScheduleCatchUpAfterRestart(t *testing.T) { + origDelay := catchUpDelay + catchUpDelay = 100 * time.Millisecond + defer func() { catchUpDelay = origDelay }() + + now := time.Now() + hhmm := now.Add(-2 * time.Hour).Format("15:04") + seed := map[string]time.Time{"": now.Add(-26 * time.Hour)} + + m := newManager(seed, func(map[string]time.Time) {}) + defer m.Close() + + sub := m.Subscribe("test") + defer m.Unsubscribe("test") + + m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}}) + + deadline := time.After(3 * time.Second) + for { + select { + case state := <-sub: + if state.CycleSeq > 0 && state.Target == "" { + return + } + case <-deadline: + t.Fatal("missed time schedule did not catch up within 3s") + } + } +} + +func TestTimeScheduleNoFireOnFirstEnable(t *testing.T) { + now := time.Now() + hhmm := now.Add(-2 * time.Hour).Format("15:04") + + m := newManager(map[string]time.Time{}, func(map[string]time.Time) {}) + defer m.Close() + + sub := m.Subscribe("test") + defer m.Unsubscribe("test") + + m.SetConfig(Config{Global: ScheduleConfig{Enabled: true, Mode: "time", Time: hhmm}}) + + deadline := time.After(1500 * time.Millisecond) + for { + select { + case state := <-sub: + if state.CycleSeq > 0 { + t.Fatal("first enable must not fire immediately") + } + case <-deadline: + return + } + } +} + func TestSchedulerEmitsCycle(t *testing.T) { - m := NewManager() + m := newManager(map[string]time.Time{}, func(map[string]time.Time) {}) defer m.Close() sub := m.Subscribe("test") diff --git a/core/internal/server/wallpaper/state.go b/core/internal/server/wallpaper/state.go new file mode 100644 index 000000000..7432014c4 --- /dev/null +++ b/core/internal/server/wallpaper/state.go @@ -0,0 +1,45 @@ +package wallpaper + +import ( + "encoding/json" + "os" + "path/filepath" + "time" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type persistentState struct { + LastFires map[string]time.Time `json:"lastFires"` +} + +func stateFilePath() string { + return filepath.Join(utils.XDGCacheHome(), "dms", "wallpaper-schedule.json") +} + +func loadLastFires() map[string]time.Time { + data, err := os.ReadFile(stateFilePath()) + if err != nil { + return map[string]time.Time{} + } + var state persistentState + if err := json.Unmarshal(data, &state); err != nil || state.LastFires == nil { + return map[string]time.Time{} + } + return state.LastFires +} + +func saveLastFires(fires map[string]time.Time) { + path := stateFilePath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return + } + data, err := json.Marshal(persistentState{LastFires: fires}) + if err != nil { + return + } + if err := os.WriteFile(path, data, 0o644); err != nil { + log.Warnf("wallpaper: failed to persist schedule state: %v", err) + } +}