mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-08-08 14:38:30 -04:00
@@ -16,6 +16,10 @@ type activeSchedule struct {
|
|||||||
nextFire time.Time
|
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 {
|
type Manager struct {
|
||||||
config Config
|
config Config
|
||||||
configMutex sync.RWMutex
|
configMutex sync.RWMutex
|
||||||
@@ -29,9 +33,20 @@ type Manager struct {
|
|||||||
updateTrigger chan struct{}
|
updateTrigger chan struct{}
|
||||||
resetReq chan string
|
resetReq chan string
|
||||||
wg sync.WaitGroup
|
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 {
|
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{
|
m := &Manager{
|
||||||
config: Config{
|
config: Config{
|
||||||
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
|
Global: ScheduleConfig{Mode: "interval", IntervalSec: 300, Time: "06:00"},
|
||||||
@@ -40,6 +55,8 @@ func NewManager() *Manager {
|
|||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
updateTrigger: make(chan struct{}, 1),
|
updateTrigger: make(chan struct{}, 1),
|
||||||
resetReq: make(chan string, 8),
|
resetReq: make(chan string, 8),
|
||||||
|
lastFires: lastFires,
|
||||||
|
persistFires: persistFires,
|
||||||
}
|
}
|
||||||
m.state = &State{Config: m.getConfig()}
|
m.state = &State{Config: m.getConfig()}
|
||||||
|
|
||||||
@@ -115,9 +132,7 @@ func (m *Manager) Close() {
|
|||||||
|
|
||||||
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
||||||
ch := lm.Subscribe("wallpaper")
|
ch := lm.Subscribe("wallpaper")
|
||||||
m.wg.Add(1)
|
m.wg.Go(func() {
|
||||||
go func() {
|
|
||||||
defer m.wg.Done()
|
|
||||||
defer lm.Unsubscribe("wallpaper")
|
defer lm.Unsubscribe("wallpaper")
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -133,7 +148,7 @@ func (m *Manager) WatchLoginctl(lm *loginctl.Manager) {
|
|||||||
m.TriggerUpdate()
|
m.TriggerUpdate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) schedulerLoop() {
|
func (m *Manager) schedulerLoop() {
|
||||||
@@ -154,11 +169,26 @@ func (m *Manager) schedulerLoop() {
|
|||||||
delete(schedules, key)
|
delete(schedules, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
firesDirty := false
|
||||||
for key, cfg := range active {
|
for key, cfg := range active {
|
||||||
s, ok := schedules[key]
|
s, ok := schedules[key]
|
||||||
switch {
|
switch {
|
||||||
case !ok:
|
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]:
|
case s.cfg != cfg || resets[key]:
|
||||||
s.cfg = cfg
|
s.cfg = cfg
|
||||||
s.nextFire = computeNext(now, cfg)
|
s.nextFire = computeNext(now, cfg)
|
||||||
@@ -171,9 +201,22 @@ func (m *Manager) schedulerLoop() {
|
|||||||
if !s.nextFire.After(now) {
|
if !s.nextFire.After(now) {
|
||||||
dueKeys = append(dueKeys, key)
|
dueKeys = append(dueKeys, key)
|
||||||
s.nextFire = computeNext(now, s.cfg)
|
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)
|
next, hasNext := soonest(schedules)
|
||||||
if len(dueKeys) == 0 {
|
if len(dueKeys) == 0 {
|
||||||
m.setState(config, next, seq, "")
|
m.setState(config, next, seq, "")
|
||||||
@@ -185,10 +228,7 @@ func (m *Manager) schedulerLoop() {
|
|||||||
|
|
||||||
waitDur := 24 * time.Hour
|
waitDur := 24 * time.Hour
|
||||||
if hasNext {
|
if hasNext {
|
||||||
waitDur = time.Until(next)
|
waitDur = max(time.Until(next), time.Second)
|
||||||
if waitDur < time.Second {
|
|
||||||
waitDur = time.Second
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if timer != nil {
|
if timer != nil {
|
||||||
@@ -262,10 +302,7 @@ func computeNext(now time.Time, cfg ScheduleConfig) time.Time {
|
|||||||
case "time":
|
case "time":
|
||||||
return nextDailyTime(now, cfg.Time)
|
return nextDailyTime(now, cfg.Time)
|
||||||
default:
|
default:
|
||||||
sec := cfg.IntervalSec
|
sec := max(cfg.IntervalSec, 1)
|
||||||
if sec < 1 {
|
|
||||||
sec = 1
|
|
||||||
}
|
|
||||||
return now.Add(time.Duration(sec) * time.Second)
|
return now.Add(time.Duration(sec) * time.Second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -282,6 +319,18 @@ func nextDailyTime(now time.Time, hhmm string) time.Time {
|
|||||||
return next
|
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) {
|
func parseHHMM(hhmm string) (int, int, bool) {
|
||||||
parts := strings.Split(hhmm, ":")
|
parts := strings.Split(hhmm, ":")
|
||||||
if len(parts) != 2 {
|
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) {
|
func TestSchedulerEmitsCycle(t *testing.T) {
|
||||||
m := NewManager()
|
m := newManager(map[string]time.Time{}, func(map[string]time.Time) {})
|
||||||
defer m.Close()
|
defer m.Close()
|
||||||
|
|
||||||
sub := m.Subscribe("test")
|
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