1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-08-02 03:28:28 -04:00

wallpaper: persist cycling state to disk

fixes #1010
This commit is contained in:
bbedward
2026-07-04 11:16:32 -04:00
parent 7b1c542585
commit 68d5d7c5ed
3 changed files with 181 additions and 14 deletions
+62 -13
View File
@@ -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 {
+74 -1
View File
@@ -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")
+45
View File
@@ -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)
}
}