package thememode import ( "errors" "sync" "time" "github.com/AvengeMedia/DankMaterialShell/core/internal/server/wayland" "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" ) const ( defaultStartHour = 18 defaultStartMinute = 0 defaultEndHour = 6 defaultEndMinute = 0 defaultElevationTwilight = -6.0 defaultElevationDaylight = 3.0 ) type Manager struct { config Config configMutex sync.RWMutex state *State stateMutex sync.RWMutex subscribers syncmap.Map[string, chan State] locationMutex sync.RWMutex cachedIPLat *float64 cachedIPLon *float64 stopChan chan struct{} updateTrigger chan struct{} wg sync.WaitGroup } func NewManager() *Manager { m := &Manager{ config: Config{ Enabled: false, Mode: "time", StartHour: defaultStartHour, StartMinute: defaultStartMinute, EndHour: defaultEndHour, EndMinute: defaultEndMinute, ElevationTwilight: defaultElevationTwilight, ElevationDaylight: defaultElevationDaylight, }, stopChan: make(chan struct{}), updateTrigger: make(chan struct{}, 1), } m.updateState(time.Now()) 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()} } stateCopy := *m.state return stateCopy } 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) SetEnabled(enabled bool) { m.configMutex.Lock() if m.config.Enabled == enabled { m.configMutex.Unlock() return } m.config.Enabled = enabled m.configMutex.Unlock() m.TriggerUpdate() } func (m *Manager) SetMode(mode string) { m.configMutex.Lock() if m.config.Mode == mode { m.configMutex.Unlock() return } m.config.Mode = mode m.configMutex.Unlock() m.TriggerUpdate() } func (m *Manager) SetSchedule(startHour, startMinute, endHour, endMinute int) { m.configMutex.Lock() changed := m.config.StartHour != startHour || m.config.StartMinute != startMinute || m.config.EndHour != endHour || m.config.EndMinute != endMinute if !changed { m.configMutex.Unlock() return } m.config.StartHour = startHour m.config.StartMinute = startMinute m.config.EndHour = endHour m.config.EndMinute = endMinute m.configMutex.Unlock() m.TriggerUpdate() } func (m *Manager) SetLocation(lat, lon float64) { m.configMutex.Lock() if m.config.Latitude != nil && m.config.Longitude != nil && *m.config.Latitude == lat && *m.config.Longitude == lon && !m.config.UseIPLocation { m.configMutex.Unlock() return } m.config.Latitude = &lat m.config.Longitude = &lon m.config.UseIPLocation = false m.configMutex.Unlock() m.locationMutex.Lock() m.cachedIPLat = nil m.cachedIPLon = nil m.locationMutex.Unlock() m.TriggerUpdate() } func (m *Manager) SetUseIPLocation(use bool) { m.configMutex.Lock() if m.config.UseIPLocation == use { m.configMutex.Unlock() return } m.config.UseIPLocation = use if use { m.config.Latitude = nil m.config.Longitude = nil } m.configMutex.Unlock() if use { m.locationMutex.Lock() m.cachedIPLat = nil m.cachedIPLon = nil m.locationMutex.Unlock() } m.TriggerUpdate() } 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) schedulerLoop() { defer m.wg.Done() var timer *time.Timer for { config := m.getConfig() now := time.Now() var isLight bool var next time.Time if config.Enabled { isLight, next = m.computeSchedule(now, config) } else { m.stateMutex.RLock() if m.state != nil { isLight = m.state.IsLight } m.stateMutex.RUnlock() next = now.Add(24 * time.Hour) } m.updateStateWithValues(config, isLight, next) waitDur := time.Until(next) if !config.Enabled { waitDur = 24 * time.Hour } 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() continue case <-timer.C: continue } } } func (m *Manager) updateState(now time.Time) { config := m.getConfig() var isLight bool var next time.Time if config.Enabled { isLight, next = m.computeSchedule(now, config) } else { m.stateMutex.RLock() if m.state != nil { isLight = m.state.IsLight } m.stateMutex.RUnlock() next = now.Add(24 * time.Hour) } m.updateStateWithValues(config, isLight, next) } func (m *Manager) updateStateWithValues(config Config, isLight bool, next time.Time) { newState := State{ Config: config, IsLight: isLight, NextTransition: next, } 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 (m *Manager) getLocation(config Config) (*float64, *float64) { if config.Latitude != nil && config.Longitude != nil { return config.Latitude, config.Longitude } if !config.UseIPLocation { return nil, nil } m.locationMutex.RLock() if m.cachedIPLat != nil && m.cachedIPLon != nil { lat, lon := m.cachedIPLat, m.cachedIPLon m.locationMutex.RUnlock() return lat, lon } m.locationMutex.RUnlock() lat, lon, err := wayland.FetchIPLocation() if err != nil { return nil, nil } m.locationMutex.Lock() m.cachedIPLat = lat m.cachedIPLon = lon m.locationMutex.Unlock() return lat, lon } func statesEqual(a, b *State) bool { if a == nil || b == nil { return a == b } if a.IsLight != b.IsLight || !a.NextTransition.Equal(b.NextTransition) { return false } return a.Config == b.Config } func (m *Manager) computeSchedule(now time.Time, config Config) (bool, time.Time) { if config.Mode == "location" { return m.computeLocationSchedule(now, config) } return computeTimeSchedule(now, config) } func computeTimeSchedule(now time.Time, config Config) (bool, time.Time) { startMinutes := config.StartHour*60 + config.StartMinute endMinutes := config.EndHour*60 + config.EndMinute currentMinutes := now.Hour()*60 + now.Minute() startTime := time.Date(now.Year(), now.Month(), now.Day(), config.StartHour, config.StartMinute, 0, 0, now.Location()) endTime := time.Date(now.Year(), now.Month(), now.Day(), config.EndHour, config.EndMinute, 0, 0, now.Location()) if startMinutes == endMinutes { next := startTime if !next.After(now) { next = next.Add(24 * time.Hour) } return true, next } if startMinutes < endMinutes { if currentMinutes < startMinutes { return true, startTime } if currentMinutes >= endMinutes { return true, startTime.Add(24 * time.Hour) } return false, endTime } if currentMinutes >= startMinutes { return false, endTime.Add(24 * time.Hour) } if currentMinutes < endMinutes { return false, endTime } return true, startTime } func (m *Manager) computeLocationSchedule(now time.Time, config Config) (bool, time.Time) { lat, lon := m.getLocation(config) if lat == nil || lon == nil { currentIsLight := false m.stateMutex.RLock() if m.state != nil { currentIsLight = m.state.IsLight } m.stateMutex.RUnlock() return currentIsLight, now.Add(10 * time.Minute) } times, cond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight) if cond != wayland.SunNormal { if cond == wayland.SunMidnightSun { return true, startOfNextDay(now) } return false, startOfNextDay(now) } if now.Before(times.Sunrise) { return false, times.Sunrise } if now.Before(times.Sunset) { return true, times.Sunset } nextDay := startOfNextDay(now) nextTimes, nextCond := wayland.CalculateSunTimesWithTwilight(*lat, *lon, nextDay, config.ElevationTwilight, config.ElevationDaylight) if nextCond != wayland.SunNormal { if nextCond == wayland.SunMidnightSun { return true, startOfNextDay(nextDay) } return false, startOfNextDay(nextDay) } return false, nextTimes.Sunrise } func startOfNextDay(t time.Time) time.Time { next := t.Add(24 * time.Hour) return time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location()) } func validateHourMinute(hour, minute int) bool { if hour < 0 || hour > 23 { return false } if minute < 0 || minute > 59 { return false } return true } func (m *Manager) ValidateSchedule(startHour, startMinute, endHour, endMinute int) error { if !validateHourMinute(startHour, startMinute) || !validateHourMinute(endHour, endMinute) { return errInvalidTime } return nil } var errInvalidTime = errors.New("invalid schedule time")