mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-02 03:28:28 -04:00
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user