mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-30 15:07:49 -04:00
2008 lines
52 KiB
Go
2008 lines
52 KiB
Go
package wayland
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/duxlabs-dev/dux-go/boottimer"
|
|
wlclient "github.com/duxlabs-dev/dux-go/wayland/client"
|
|
"github.com/godbus/dbus/v5"
|
|
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/errdefs"
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/geolocation"
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/icc"
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/log"
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/proto/wlr_gamma_control"
|
|
"github.com/duxlabs-dev/dux-shell/core/internal/wayland/shm"
|
|
)
|
|
|
|
const animKelvinStep = 25
|
|
|
|
const neutralTemp = 6500
|
|
|
|
// noTempTarget marks "the night light has no temperature target". Plain outputs
|
|
// then get the neutral ramp, while outputs with an ICC profile stay at the
|
|
// white point their profile was produced at.
|
|
const noTempTarget = -1
|
|
|
|
func NewManager(display wlclient.WaylandDisplay, config Config) (*Manager, error) {
|
|
if err := config.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if config.ElevationTwilight == 0 {
|
|
config.ElevationTwilight = -6.0
|
|
}
|
|
if config.ElevationDaylight == 0 {
|
|
config.ElevationDaylight = 3.0
|
|
}
|
|
|
|
m := &Manager{
|
|
config: config,
|
|
display: display,
|
|
ctx: display.Context(),
|
|
cmdq: make(chan cmd, 128),
|
|
stopChan: make(chan struct{}),
|
|
updateTrigger: make(chan struct{}, 1),
|
|
dirty: make(chan struct{}, 1),
|
|
dbusSignal: make(chan *dbus.Signal, 16),
|
|
}
|
|
|
|
if err := m.setupRegistry(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := m.setupDBusMonitor(); err != nil {
|
|
log.Warnf("Failed to setup D-Bus monitoring: %v", err)
|
|
}
|
|
|
|
m.alive = true
|
|
// Publish before the first state push so the snapshot is never nil, and
|
|
// before the actor starts so no other goroutine touches the fields yet.
|
|
m.publishICCState()
|
|
m.recalcSchedule(time.Now())
|
|
m.updateStateFromSchedule()
|
|
|
|
m.notifierWg.Add(1)
|
|
go m.notifier()
|
|
|
|
m.wg.Add(1)
|
|
go m.schedulerLoop()
|
|
|
|
if m.dbusConn != nil {
|
|
m.wg.Add(1)
|
|
go m.dbusMonitor()
|
|
}
|
|
|
|
m.wg.Add(1)
|
|
go m.waylandActor()
|
|
|
|
// needsControls() also accounts for ICC profiles and per-output
|
|
// temperatures, so controls are set up whenever any of them is configured.
|
|
if m.needsControls() {
|
|
m.post(func() {
|
|
// Profiles and temperatures are configured state, not control state:
|
|
// the registry handler may already have established the controls, and
|
|
// the configuration still has to be attached.
|
|
loaded := m.loadConfiguredICC()
|
|
m.publishICCState()
|
|
|
|
if !m.controlsInitialized {
|
|
log.Info("Gamma control enabled at startup")
|
|
m.createControls()
|
|
}
|
|
|
|
if loaded {
|
|
// The attached configuration is invisible to the per-output
|
|
// dedup, which only compares the schedule-derived
|
|
// temp/gamma/contrast, so clear that state before applying.
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
out.lastTemp = 0
|
|
return true
|
|
})
|
|
m.applyCurrentTemp("startup")
|
|
}
|
|
})
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m *Manager) post(fn func()) {
|
|
select {
|
|
case m.cmdq <- cmd{fn: fn}:
|
|
default:
|
|
log.Warn("Actor command queue full")
|
|
}
|
|
}
|
|
|
|
// cloneConfig returns a copy of cfg whose maps belong to the copy. The manager
|
|
// mutates the config's maps under configMutex, so a value copy that still
|
|
// aliased them would turn a marshal or an iteration after the unlock into a
|
|
// concurrent map read/write.
|
|
func cloneConfig(cfg Config) Config {
|
|
cfg.ICCProfiles = maps.Clone(cfg.ICCProfiles)
|
|
cfg.OutputTemps = maps.Clone(cfg.OutputTemps)
|
|
cfg.OutputBrightness = maps.Clone(cfg.OutputBrightness)
|
|
return cfg
|
|
}
|
|
|
|
// configSnapshot returns a clone of the current config that stays valid after
|
|
// configMutex is released.
|
|
func (m *Manager) configSnapshot() Config {
|
|
m.configMutex.RLock()
|
|
defer m.configMutex.RUnlock()
|
|
return cloneConfig(m.config)
|
|
}
|
|
|
|
func (m *Manager) waylandActor() {
|
|
defer m.wg.Done()
|
|
for {
|
|
select {
|
|
case <-m.stopChan:
|
|
return
|
|
case c := <-m.cmdq:
|
|
c.fn()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) anyOutputReady() bool {
|
|
anyReady := false
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if out.rampSize > 0 && !out.failed {
|
|
anyReady = true
|
|
return false // stop iteration
|
|
}
|
|
return true
|
|
})
|
|
return anyReady
|
|
}
|
|
|
|
const prepareForSleepMatchRule = "type='signal',interface='org.freedesktop.login1.Manager',member='PrepareForSleep',path='/org/freedesktop/login1'"
|
|
|
|
func (m *Manager) setupDBusMonitor() error {
|
|
conn, err := dbus.SystemBus()
|
|
if err != nil {
|
|
return fmt.Errorf("system bus: %w", err)
|
|
}
|
|
|
|
if err := conn.BusObject().Call("org.freedesktop.DBus.AddMatch", 0, prepareForSleepMatchRule).Err; err != nil {
|
|
return fmt.Errorf("add match: %w", err)
|
|
}
|
|
|
|
conn.Signal(m.dbusSignal)
|
|
m.dbusConn = conn
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) setupRegistry() error {
|
|
registry, err := m.display.GetRegistry()
|
|
if err != nil {
|
|
return fmt.Errorf("get registry: %w", err)
|
|
}
|
|
m.registry = registry
|
|
|
|
outputs := make([]*wlclient.Output, 0)
|
|
outputNames := make(map[uint32]string)
|
|
var gammaMgr *wlr_gamma_control.ZwlrGammaControlManagerV1
|
|
|
|
registry.SetGlobalHandler(func(e wlclient.RegistryGlobalEvent) {
|
|
switch e.Interface {
|
|
case wlr_gamma_control.ZwlrGammaControlManagerV1InterfaceName:
|
|
manager := wlr_gamma_control.NewZwlrGammaControlManagerV1(m.ctx)
|
|
version := min(e.Version, 1)
|
|
if err := registry.Bind(e.Name, e.Interface, version, manager); err == nil {
|
|
gammaMgr = manager
|
|
}
|
|
case "wl_output":
|
|
output := wlclient.NewOutput(m.ctx)
|
|
version := min(e.Version, 4)
|
|
if err := registry.Bind(e.Name, e.Interface, version, output); err != nil {
|
|
return
|
|
}
|
|
outputID := output.ID()
|
|
output.SetNameHandler(func(ev wlclient.OutputNameEvent) {
|
|
outputNames[outputID] = ev.Name
|
|
m.outputNames.Store(outputID, ev.Name)
|
|
m.post(func() {
|
|
m.attachConfiguredICC(outputID, ev.Name)
|
|
m.updateStateFromSchedule()
|
|
})
|
|
})
|
|
if gammaMgr != nil {
|
|
outputs = append(outputs, output)
|
|
m.addAvailableOutput(output)
|
|
}
|
|
m.outputRegNames.Store(outputID, e.Name)
|
|
|
|
if !m.needsControls() {
|
|
return
|
|
}
|
|
m.post(func() {
|
|
if err := m.addOutputControl(output); err != nil {
|
|
log.Warnf("gamma: failed to add output control: %v", err)
|
|
return
|
|
}
|
|
if m.controlsInitialized {
|
|
return
|
|
}
|
|
// All outputs had been torn down (monitor sleep/disconnect),
|
|
// clearing controlsInitialized. Mark it ready again so the
|
|
// gamma_size event that follows control creation drives the
|
|
// reapply, instead of waiting for a manual toggle. No explicit
|
|
// apply here: applyGamma's dedup would suppress a no-change
|
|
// write anyway, and the new control isn't ready until gamma_size.
|
|
log.Info("gamma: output returned, re-establishing controls")
|
|
m.controlsInitialized = true
|
|
})
|
|
}
|
|
})
|
|
|
|
registry.SetGlobalRemoveHandler(func(e wlclient.RegistryGlobalRemoveEvent) {
|
|
m.post(func() {
|
|
m.removeOutputByRegistryName(e.Name)
|
|
m.updateStateFromSchedule()
|
|
})
|
|
})
|
|
|
|
if err := m.display.Roundtrip(); err != nil {
|
|
return fmt.Errorf("roundtrip 1: %w", err)
|
|
}
|
|
if err := m.display.Roundtrip(); err != nil {
|
|
return fmt.Errorf("roundtrip 2: %w", err)
|
|
}
|
|
|
|
if gammaMgr == nil {
|
|
return errdefs.ErrNoGammaControl
|
|
}
|
|
if len(outputs) == 0 {
|
|
return fmt.Errorf("no outputs")
|
|
}
|
|
|
|
physicalOutputs := make([]*wlclient.Output, 0, len(outputs))
|
|
for _, output := range outputs {
|
|
name := outputNames[output.ID()]
|
|
if len(name) >= 9 && name[:9] == "HEADLESS-" {
|
|
continue
|
|
}
|
|
physicalOutputs = append(physicalOutputs, output)
|
|
}
|
|
|
|
m.gammaControl = gammaMgr
|
|
m.availableOutputs = physicalOutputs
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) setupOutputControls(outputs []*wlclient.Output, manager *wlr_gamma_control.ZwlrGammaControlManagerV1) error {
|
|
for _, output := range outputs {
|
|
control, err := manager.GetGammaControl(output)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
outputID := output.ID()
|
|
registryName, _ := m.outputRegNames.Load(outputID)
|
|
outState := m.controlStateFor(outputID, registryName, output, control)
|
|
m.setupControlHandlers(outState, control)
|
|
m.outputs.Store(outputID, outState)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// controlStateFor returns the state a (re)created gamma control is attached to.
|
|
// A re-created control must not drop what belongs to the output rather than to
|
|
// the control: the attached ICC profile and the per-output temperature survive,
|
|
// while the ramp bookkeeping and the failure budget start over so the new
|
|
// control receives a fresh ramp once it reports gamma_size.
|
|
func (m *Manager) controlStateFor(outputID, registryName uint32, output *wlclient.Output, control any) *outputState {
|
|
existing, ok := m.outputs.Load(outputID)
|
|
if !ok {
|
|
return &outputState{
|
|
id: outputID,
|
|
registryName: registryName,
|
|
output: output,
|
|
gammaControl: control,
|
|
}
|
|
}
|
|
|
|
existing.registryName = registryName
|
|
existing.output = output
|
|
existing.gammaControl = control
|
|
existing.rampSize = 0
|
|
existing.failed = false
|
|
existing.retryCount = 0
|
|
existing.lastFailTime = time.Time{}
|
|
existing.lastTemp = 0
|
|
existing.lastGamma = 0
|
|
existing.lastContrast = 0
|
|
return existing
|
|
}
|
|
|
|
func (m *Manager) setupControlHandlers(state *outputState, control *wlr_gamma_control.ZwlrGammaControlV1) {
|
|
outputID := state.id
|
|
|
|
control.SetGammaSizeHandler(func(e wlr_gamma_control.ZwlrGammaControlV1GammaSizeEvent) {
|
|
size := e.Size
|
|
m.post(func() {
|
|
if out, ok := m.outputs.Load(outputID); ok {
|
|
out.rampSize = size
|
|
out.failed = false
|
|
out.retryCount = 0
|
|
out.lastTemp = 0
|
|
if out.gammaRefused {
|
|
out.gammaRefused = false
|
|
m.publishICCState()
|
|
m.updateStateFromSchedule()
|
|
}
|
|
}
|
|
m.applyCurrentTemp("gamma_size")
|
|
})
|
|
})
|
|
|
|
control.SetFailedHandler(func(_ wlr_gamma_control.ZwlrGammaControlV1FailedEvent) {
|
|
m.post(func() {
|
|
out, ok := m.outputs.Load(outputID)
|
|
if !ok {
|
|
return
|
|
}
|
|
if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && ctrl != nil && !ctrl.IsZombie() {
|
|
ctrl.Destroy()
|
|
}
|
|
out.gammaControl = nil
|
|
out.markFailed(time.Now())
|
|
if out.gammaRefused {
|
|
m.publishICCState()
|
|
m.updateStateFromSchedule()
|
|
}
|
|
|
|
if !m.outputStillValid(out) {
|
|
return
|
|
}
|
|
|
|
backoff := time.Duration(300<<uint(min(out.retryCount-1, 4))) * time.Millisecond
|
|
time.AfterFunc(backoff, func() {
|
|
m.post(func() {
|
|
if !m.outputStillValid(out) {
|
|
return
|
|
}
|
|
if _, stillTracked := m.outputs.Load(outputID); !stillTracked {
|
|
return
|
|
}
|
|
m.recreateOutputControl(out)
|
|
})
|
|
})
|
|
})
|
|
})
|
|
}
|
|
|
|
func (m *Manager) addAvailableOutput(o *wlclient.Output) {
|
|
if o == nil {
|
|
return
|
|
}
|
|
m.availOutputsMu.Lock()
|
|
defer m.availOutputsMu.Unlock()
|
|
if slices.Contains(m.availableOutputs, o) {
|
|
return
|
|
}
|
|
m.availableOutputs = append(m.availableOutputs, o)
|
|
}
|
|
|
|
func (m *Manager) removeAvailableOutput(o *wlclient.Output) {
|
|
if o == nil {
|
|
return
|
|
}
|
|
m.availOutputsMu.Lock()
|
|
defer m.availOutputsMu.Unlock()
|
|
m.availableOutputs = slices.DeleteFunc(m.availableOutputs, func(existing *wlclient.Output) bool {
|
|
return existing == o
|
|
})
|
|
}
|
|
|
|
// removeOutputByRegistryName tears down the state of an output that left the
|
|
// compositor (unplugged or asleep). Only the wayland actor goroutine calls it.
|
|
func (m *Manager) removeOutputByRegistryName(registryName uint32) {
|
|
// Resolve the object ID from the registry name map: both name maps are
|
|
// populated for every wl_output, whether or not a gamma control exists for
|
|
// it, while m.outputs only has entries once the controls exist. On a default
|
|
// install (night light off, no profile, no override) no control is ever
|
|
// created, and the name entries still have to go with the output.
|
|
foundID, found := m.outputIDForRegistryName(registryName)
|
|
|
|
var foundOut *outputState
|
|
if found {
|
|
if out, ok := m.outputs.Load(foundID); ok {
|
|
foundOut = out
|
|
}
|
|
} else {
|
|
// An output whose name never arrived is only known through its state.
|
|
m.outputs.Range(func(id uint32, out *outputState) bool {
|
|
if out.registryName == registryName {
|
|
foundID, foundOut, found = id, out, true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
if !found {
|
|
return
|
|
}
|
|
|
|
// The name entries go first: they are what `dms icc status`/`listOutputs`
|
|
// report, and a rebound wl_output reusing a released object ID would
|
|
// otherwise attach the previous monitor's profile from the stale name.
|
|
m.outputNames.Delete(foundID)
|
|
m.outputRegNames.Delete(foundID)
|
|
|
|
if foundOut != nil {
|
|
if foundOut.gammaControl != nil {
|
|
foundOut.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
|
foundOut.gammaControl = nil
|
|
}
|
|
m.removeAvailableOutput(foundOut.output)
|
|
if foundOut.output != nil && !foundOut.output.IsZombie() {
|
|
_ = foundOut.output.Release()
|
|
}
|
|
m.outputs.Delete(foundID)
|
|
}
|
|
m.publishICCState()
|
|
|
|
hasOutputs := false
|
|
m.outputs.Range(func(_ uint32, _ *outputState) bool {
|
|
hasOutputs = true
|
|
return false
|
|
})
|
|
if !hasOutputs {
|
|
m.controlsInitialized = false
|
|
}
|
|
}
|
|
|
|
// outputIDForRegistryName maps a wl_output registry name back to the object ID
|
|
// it was announced with.
|
|
func (m *Manager) outputIDForRegistryName(registryName uint32) (uint32, bool) {
|
|
var foundID uint32
|
|
var found bool
|
|
m.outputRegNames.Range(func(id, name uint32) bool {
|
|
if name == registryName {
|
|
foundID, found = id, true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
return foundID, found
|
|
}
|
|
|
|
func (m *Manager) outputStillValid(out *outputState) bool {
|
|
switch {
|
|
case out == nil:
|
|
return false
|
|
case out.output == nil:
|
|
return false
|
|
case out.output.IsZombie():
|
|
return false
|
|
}
|
|
m.availOutputsMu.RLock()
|
|
defer m.availOutputsMu.RUnlock()
|
|
return slices.Contains(m.availableOutputs, out.output)
|
|
}
|
|
|
|
func isConnectionDeadErr(err error) bool {
|
|
switch {
|
|
case err == nil:
|
|
return false
|
|
case errors.Is(err, syscall.EPIPE):
|
|
return true
|
|
case errors.Is(err, syscall.ECONNRESET):
|
|
return true
|
|
case errors.Is(err, syscall.EBADF):
|
|
return true
|
|
case errors.Is(err, io.EOF):
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (m *Manager) addOutputControl(output *wlclient.Output) error {
|
|
switch {
|
|
case m.connectionDead.Load():
|
|
return nil
|
|
case output == nil || output.IsZombie():
|
|
return nil
|
|
}
|
|
|
|
outputID := output.ID()
|
|
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
|
|
|
control, err := gammaMgr.GetGammaControl(output)
|
|
if err != nil {
|
|
if isConnectionDeadErr(err) {
|
|
m.markConnectionDead(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
registryName, _ := m.outputRegNames.Load(outputID)
|
|
outState := &outputState{
|
|
id: outputID,
|
|
registryName: registryName,
|
|
output: output,
|
|
gammaControl: control,
|
|
}
|
|
m.setupControlHandlers(outState, control)
|
|
m.outputs.Store(outputID, outState)
|
|
|
|
// The name may already be known (hotplug after the output was announced),
|
|
// in which case the configured profile/temperature attaches right away.
|
|
if name, ok := m.outputNames.Load(outputID); ok {
|
|
m.attachConfiguredICC(outputID, name)
|
|
}
|
|
m.publishICCState()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) recreateOutputControl(out *outputState) error {
|
|
switch {
|
|
case m.connectionDead.Load():
|
|
return nil
|
|
case !m.needsControls() || !m.controlsInitialized:
|
|
return nil
|
|
case out.isVirtual:
|
|
return nil
|
|
case out.retryCount >= 10:
|
|
return nil
|
|
case !m.outputStillValid(out):
|
|
return nil
|
|
}
|
|
if _, ok := m.outputs.Load(out.id); !ok {
|
|
return nil
|
|
}
|
|
|
|
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
|
if !ok {
|
|
return fmt.Errorf("no gamma manager")
|
|
}
|
|
|
|
if existing, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok && existing != nil && !existing.IsZombie() {
|
|
existing.Destroy()
|
|
out.gammaControl = nil
|
|
}
|
|
|
|
control, err := gammaMgr.GetGammaControl(out.output)
|
|
if err != nil {
|
|
if isConnectionDeadErr(err) {
|
|
m.markConnectionDead(err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
m.setupControlHandlers(out, control)
|
|
out.gammaControl = control
|
|
out.failed = false
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) markConnectionDead(err error) {
|
|
if m.connectionDead.Swap(true) {
|
|
return
|
|
}
|
|
log.Errorf("gamma: wayland connection appears dead (%v); pausing gamma operations", err)
|
|
}
|
|
|
|
func (m *Manager) recalcSchedule(now time.Time) {
|
|
config := m.configSnapshot()
|
|
|
|
m.scheduleMutex.Lock()
|
|
defer m.scheduleMutex.Unlock()
|
|
|
|
dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
alreadyValid := !m.schedule.times.Sunrise.IsZero()
|
|
if m.schedule.calcDay.Equal(dayStart) && alreadyValid {
|
|
return
|
|
}
|
|
|
|
var times SunTimes
|
|
var cond SunCondition
|
|
|
|
if config.ManualSunrise != nil && config.ManualSunset != nil {
|
|
dur := time.Hour
|
|
if config.ManualDuration != nil {
|
|
dur = *config.ManualDuration
|
|
}
|
|
sunrise := time.Date(now.Year(), now.Month(), now.Day(),
|
|
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, now.Location())
|
|
sunset := time.Date(now.Year(), now.Month(), now.Day(),
|
|
config.ManualSunset.Hour(), config.ManualSunset.Minute(), config.ManualSunset.Second(), 0, now.Location())
|
|
if !sunset.After(sunrise) {
|
|
// night start past midnight belongs to the next day
|
|
sunset = sunset.Add(24 * time.Hour)
|
|
}
|
|
times = SunTimes{
|
|
Dawn: sunrise.Add(-dur),
|
|
Sunrise: sunrise,
|
|
Sunset: sunset,
|
|
Night: sunset.Add(dur),
|
|
}
|
|
cond = SunNormal
|
|
} else {
|
|
lat, lon := m.getLocation()
|
|
if lat == nil || lon == nil {
|
|
m.gammaState = StateStatic
|
|
// stale times from a previous config must not drive applies
|
|
m.schedule = sunSchedule{}
|
|
return
|
|
}
|
|
times, cond = CalculateSunTimesWithTwilight(*lat, *lon, now, config.ElevationTwilight, config.ElevationDaylight)
|
|
}
|
|
|
|
m.schedule.calcDay = dayStart
|
|
m.schedule.times = times
|
|
m.schedule.condition = cond
|
|
|
|
switch cond {
|
|
case SunNormal:
|
|
m.gammaState = StateNormal
|
|
tempDiff := config.HighTemp - config.LowTemp
|
|
if tempDiff > 0 {
|
|
dawnDur := times.Sunrise.Sub(times.Dawn)
|
|
nightDur := times.Night.Sub(times.Sunset)
|
|
m.schedule.dawnStepTime = time.Duration(max(1, int(dawnDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
|
|
m.schedule.nightStepTime = time.Duration(max(1, int(nightDur.Seconds())*animKelvinStep/tempDiff)) * time.Second
|
|
}
|
|
case SunMidnightSun:
|
|
m.gammaState = StateStatic
|
|
case SunPolarNight:
|
|
m.gammaState = StateStatic
|
|
}
|
|
}
|
|
|
|
func (m *Manager) SetGeoClient(client geolocation.Client) {
|
|
m.geoClient = client
|
|
}
|
|
|
|
func (m *Manager) getLocation() (*float64, *float64) {
|
|
config := m.configSnapshot()
|
|
|
|
if config.Latitude != nil && config.Longitude != nil {
|
|
return config.Latitude, config.Longitude
|
|
}
|
|
if !config.UseIPLocation {
|
|
return nil, nil
|
|
}
|
|
if m.geoClient == nil {
|
|
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()
|
|
|
|
location, err := m.geoClient.GetLocation()
|
|
if err != nil {
|
|
return nil, nil
|
|
}
|
|
|
|
m.locationMutex.Lock()
|
|
m.cachedIPLat = &location.Latitude
|
|
m.cachedIPLon = &location.Longitude
|
|
m.locationMutex.Unlock()
|
|
return m.cachedIPLat, m.cachedIPLon
|
|
}
|
|
|
|
func (m *Manager) hasValidSchedule() bool {
|
|
m.scheduleMutex.RLock()
|
|
defer m.scheduleMutex.RUnlock()
|
|
return !m.schedule.times.Sunrise.IsZero()
|
|
}
|
|
|
|
func (m *Manager) getSunPosition(now time.Time) float64 {
|
|
m.scheduleMutex.RLock()
|
|
sched := m.schedule
|
|
state := m.gammaState
|
|
m.scheduleMutex.RUnlock()
|
|
|
|
if sched.times.Sunrise.IsZero() {
|
|
return 1.0
|
|
}
|
|
|
|
switch state {
|
|
case StateStatic:
|
|
if sched.condition == SunMidnightSun {
|
|
return 1.0
|
|
}
|
|
return 0.0
|
|
case StateNormal:
|
|
return m.getSunPositionNormal(now, sched.times)
|
|
}
|
|
return 1.0
|
|
}
|
|
|
|
func shiftTimes(times SunTimes, d time.Duration) SunTimes {
|
|
return SunTimes{
|
|
Dawn: times.Dawn.Add(d),
|
|
Sunrise: times.Sunrise.Add(d),
|
|
Sunset: times.Sunset.Add(d),
|
|
Night: times.Night.Add(d),
|
|
}
|
|
}
|
|
|
|
// activeCycle maps early-morning hours back to yesterday's cycle when the
|
|
// schedule crosses midnight.
|
|
func activeCycle(now time.Time, times SunTimes) SunTimes {
|
|
if now.Before(times.Night.Add(-24 * time.Hour)) {
|
|
return shiftTimes(times, -24*time.Hour)
|
|
}
|
|
return times
|
|
}
|
|
|
|
func (m *Manager) getSunPositionNormal(now time.Time, times SunTimes) float64 {
|
|
times = activeCycle(now, times)
|
|
if now.Before(times.Dawn) {
|
|
return 0.0
|
|
}
|
|
if now.Before(times.Sunrise) {
|
|
return interpolate(now, times.Dawn, times.Sunrise)
|
|
}
|
|
if now.Before(times.Sunset) {
|
|
return 1.0
|
|
}
|
|
if now.Before(times.Night) {
|
|
return interpolate(now, times.Night, times.Sunset)
|
|
}
|
|
return 0.0
|
|
}
|
|
|
|
func interpolate(now time.Time, start, stop time.Time) float64 {
|
|
if start.Equal(stop) {
|
|
return 1.0
|
|
}
|
|
pos := float64(now.Sub(start)) / float64(stop.Sub(start))
|
|
switch {
|
|
case pos > 1.0:
|
|
return 1.0
|
|
case pos < 0.0:
|
|
return 0.0
|
|
default:
|
|
return pos
|
|
}
|
|
}
|
|
|
|
func (m *Manager) getTempFromPosition(pos float64) int {
|
|
m.configMutex.RLock()
|
|
low, high := m.config.LowTemp, m.config.HighTemp
|
|
m.configMutex.RUnlock()
|
|
return low + int(float64(high-low)*pos)
|
|
}
|
|
|
|
func (m *Manager) getNextDeadline(now time.Time) time.Time {
|
|
m.scheduleMutex.RLock()
|
|
sched := m.schedule
|
|
state := m.gammaState
|
|
m.scheduleMutex.RUnlock()
|
|
|
|
switch state {
|
|
case StateStatic:
|
|
return m.tomorrow(now)
|
|
case StateNormal:
|
|
return m.getDeadlineNormal(now, sched)
|
|
default:
|
|
return m.tomorrow(now)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) getDeadlineNormal(now time.Time, sched sunSchedule) time.Time {
|
|
times := activeCycle(now, sched.times)
|
|
switch {
|
|
case now.Before(times.Dawn):
|
|
return times.Dawn
|
|
case now.Before(times.Sunrise):
|
|
return now.Add(sched.dawnStepTime)
|
|
case now.Before(times.Sunset):
|
|
return times.Sunset
|
|
case now.Before(times.Night):
|
|
return now.Add(sched.nightStepTime)
|
|
default:
|
|
return m.tomorrowDawn(now)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) tomorrowDawn(now time.Time) time.Time {
|
|
tomorrow := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
|
|
|
config := m.configSnapshot()
|
|
|
|
if config.ManualSunrise != nil {
|
|
dur := time.Hour
|
|
if config.ManualDuration != nil {
|
|
dur = *config.ManualDuration
|
|
}
|
|
return time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(),
|
|
config.ManualSunrise.Hour(), config.ManualSunrise.Minute(), config.ManualSunrise.Second(), 0, tomorrow.Location()).Add(-dur)
|
|
}
|
|
|
|
lat, lon := m.getLocation()
|
|
if lat == nil || lon == nil {
|
|
return tomorrow
|
|
}
|
|
|
|
times, cond := CalculateSunTimesWithTwilight(*lat, *lon, tomorrow, config.ElevationTwilight, config.ElevationDaylight)
|
|
if cond != SunNormal {
|
|
return tomorrow
|
|
}
|
|
return times.Dawn
|
|
}
|
|
|
|
func (m *Manager) tomorrow(now time.Time) time.Time {
|
|
return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location())
|
|
}
|
|
|
|
func (m *Manager) schedulerLoop() {
|
|
defer m.wg.Done()
|
|
|
|
if m.needsControls() {
|
|
m.post(func() { m.applyCurrentTemp("startup") })
|
|
}
|
|
|
|
timer := boottimer.New(24 * time.Hour)
|
|
defer timer.Stop()
|
|
for {
|
|
m.configMutex.RLock()
|
|
enabled := m.config.Enabled
|
|
m.configMutex.RUnlock()
|
|
|
|
now := time.Now()
|
|
m.recalcSchedule(now)
|
|
// publish independent of output readiness so night status never
|
|
// presents a stale schedule while applies are blocked (#2967)
|
|
m.updateStateFromSchedule()
|
|
|
|
waitDur := 24 * time.Hour
|
|
if enabled {
|
|
deadline := m.getNextDeadline(now)
|
|
if waitDur = time.Until(deadline); waitDur < time.Second {
|
|
waitDur = time.Second
|
|
}
|
|
}
|
|
timer.Reset(waitDur)
|
|
|
|
select {
|
|
case <-m.stopChan:
|
|
return
|
|
case <-m.updateTrigger:
|
|
m.scheduleMutex.Lock()
|
|
m.schedule.calcDay = time.Time{}
|
|
m.scheduleMutex.Unlock()
|
|
m.recalcSchedule(time.Now())
|
|
m.updateStateFromSchedule()
|
|
if m.needsControls() {
|
|
m.post(func() { m.applyCurrentTemp("updateTrigger") })
|
|
}
|
|
case <-timer.C:
|
|
if m.needsControls() {
|
|
m.post(func() { m.applyCurrentTemp("timer") })
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) applyCurrentTemp(_ string) {
|
|
if !m.controlsInitialized || !m.anyOutputReady() {
|
|
return
|
|
}
|
|
|
|
// Ensure schedule is up-to-date (handles display wake after overnight sleep)
|
|
m.recalcSchedule(time.Now())
|
|
|
|
m.configMutex.RLock()
|
|
enabled := m.config.Enabled
|
|
low, high := m.config.LowTemp, m.config.HighTemp
|
|
m.configMutex.RUnlock()
|
|
|
|
// With the night light disabled, outputs with an ICC profile keep the ramp
|
|
// their profile describes and the rest stay at identity (noTempTarget).
|
|
if !enabled {
|
|
m.applyGamma(noTempTarget)
|
|
m.updateStateFromSchedule()
|
|
return
|
|
}
|
|
|
|
if low == high {
|
|
m.applyGamma(low)
|
|
m.updateStateFromSchedule()
|
|
return
|
|
}
|
|
|
|
if !m.hasValidSchedule() {
|
|
m.updateStateFromSchedule()
|
|
return
|
|
}
|
|
|
|
now := time.Now()
|
|
pos := m.getSunPosition(now)
|
|
temp := m.getTempFromPosition(pos)
|
|
|
|
m.applyGamma(temp)
|
|
m.updateStateFromSchedule()
|
|
}
|
|
|
|
// effectiveTempTarget returns the temperature to render an output at: a
|
|
// per-output override wins over the night light schedule, so each display can
|
|
// keep its own temperature (0 = no override). Without either, the output keeps
|
|
// whatever its profile describes.
|
|
func effectiveTempTarget(out *outputState, scheduleTemp int) int {
|
|
if out.outputTemp != 0 {
|
|
return out.outputTemp
|
|
}
|
|
if scheduleTemp > 0 {
|
|
return scheduleTemp
|
|
}
|
|
return noTempTarget
|
|
}
|
|
|
|
func (m *Manager) applyGamma(temp int) {
|
|
m.configMutex.RLock()
|
|
gamma, contrast := m.config.Gamma, m.config.Contrast
|
|
m.configMutex.RUnlock()
|
|
|
|
switch {
|
|
case m.connectionDead.Load():
|
|
return
|
|
case !m.controlsInitialized:
|
|
return
|
|
}
|
|
|
|
var outs []*outputState
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
outs = append(outs, out)
|
|
return true
|
|
})
|
|
if len(outs) == 0 {
|
|
return
|
|
}
|
|
|
|
type job struct {
|
|
out *outputState
|
|
targetTemp int
|
|
data []byte
|
|
}
|
|
var jobs []job
|
|
|
|
for _, out := range outs {
|
|
targetTemp := effectiveTempTarget(out, temp)
|
|
switch {
|
|
case out.failed:
|
|
continue
|
|
case out.rampSize == 0:
|
|
continue
|
|
case out.gammaControl == nil:
|
|
continue
|
|
case out.rampCurrent(targetTemp, gamma, contrast):
|
|
continue
|
|
case !m.outputStillValid(out):
|
|
continue
|
|
}
|
|
|
|
var ramp GammaRamp
|
|
if out.iccPath != "" && out.iccProfile != nil {
|
|
// The profile describes the display at the white point display
|
|
// profiles are produced at (D65); a target temperature is composed
|
|
// on top of it.
|
|
profileRamp, err := ProfileRampWithTemp(out.rampSize, out.iccProfile, neutralTemp, targetTemp, gamma, contrast)
|
|
if err != nil {
|
|
log.Warnf("icc: failed to generate ramp for output %d: %v, falling back to temperature", out.id, err)
|
|
fallbackTemp := targetTemp
|
|
if fallbackTemp <= 0 {
|
|
fallbackTemp = neutralTemp
|
|
}
|
|
ramp = GenerateGammaRamp(out.rampSize, fallbackTemp, gamma, contrast)
|
|
} else {
|
|
ramp = profileRamp
|
|
log.Debugf("icc: applied ICC ramp to output %d (size=%d, ref=%dK, target=%dK)", out.id, out.rampSize, neutralTemp, targetTemp)
|
|
}
|
|
} else {
|
|
outTemp := targetTemp
|
|
if outTemp <= 0 {
|
|
outTemp = neutralTemp
|
|
}
|
|
ramp = GenerateGammaRamp(out.rampSize, outTemp, gamma, contrast)
|
|
}
|
|
if out.dimScale > 0 {
|
|
scaleRamp(ramp, out.dimScale)
|
|
}
|
|
buf := bytes.NewBuffer(make([]byte, 0, int(out.rampSize)*6))
|
|
for _, v := range ramp.Red {
|
|
binary.Write(buf, binary.LittleEndian, v)
|
|
}
|
|
for _, v := range ramp.Green {
|
|
binary.Write(buf, binary.LittleEndian, v)
|
|
}
|
|
for _, v := range ramp.Blue {
|
|
binary.Write(buf, binary.LittleEndian, v)
|
|
}
|
|
jobs = append(jobs, job{out: out, targetTemp: targetTemp, data: buf.Bytes()})
|
|
}
|
|
|
|
for _, j := range jobs {
|
|
err := m.setGammaBytes(j.out, j.data)
|
|
if err == nil {
|
|
j.out.lastTemp = j.targetTemp
|
|
j.out.lastGamma = gamma
|
|
j.out.lastContrast = contrast
|
|
continue
|
|
}
|
|
log.Warnf("gamma: failed to set output %d: %v", j.out.id, err)
|
|
j.out.failed = true
|
|
j.out.rampSize = 0
|
|
j.out.lastTemp = 0
|
|
if isConnectionDeadErr(err) {
|
|
m.markConnectionDead(err)
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// rampCurrent compares against the output's effective target, not the schedule
|
|
// temperature, so an overridden output is not resent on every schedule step.
|
|
// lastTemp 0 never matches a target and marks a forced resend.
|
|
func (out *outputState) rampCurrent(targetTemp int, gamma, contrast float64) bool {
|
|
return out.lastTemp == targetTemp && out.lastGamma == gamma && out.lastContrast == contrast
|
|
}
|
|
|
|
func (m *Manager) setGammaBytes(out *outputState, data []byte) error {
|
|
if out.gammaControl == nil {
|
|
return fmt.Errorf("no gamma control")
|
|
}
|
|
ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1)
|
|
if !ok || ctrl == nil || ctrl.IsZombie() {
|
|
return fmt.Errorf("gamma control invalid")
|
|
}
|
|
|
|
fd, err := shm.CreateAnonFd("gamma-ramp")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer syscall.Close(fd)
|
|
|
|
if err := syscall.Ftruncate(fd, int64(len(data))); err != nil {
|
|
return err
|
|
}
|
|
|
|
dupFd, err := syscall.Dup(fd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f := os.NewFile(uintptr(dupFd), "gamma")
|
|
defer f.Close()
|
|
|
|
if _, err := f.Write(data); err != nil {
|
|
return err
|
|
}
|
|
syscall.Seek(fd, 0, 0)
|
|
|
|
return ctrl.SetGamma(fd)
|
|
}
|
|
|
|
func (m *Manager) updateStateFromSchedule() {
|
|
now := time.Now()
|
|
|
|
config := m.configSnapshot()
|
|
|
|
m.scheduleMutex.RLock()
|
|
times := m.schedule.times
|
|
m.scheduleMutex.RUnlock()
|
|
|
|
var pos float64
|
|
var temp int
|
|
var isDay bool
|
|
var deadline time.Time
|
|
|
|
if times.Sunrise.IsZero() {
|
|
pos = 1.0
|
|
temp = config.HighTemp
|
|
isDay = true
|
|
deadline = m.tomorrow(now)
|
|
} else {
|
|
pos = m.getSunPosition(now)
|
|
temp = m.getTempFromPosition(pos)
|
|
deadline = m.getNextDeadline(now)
|
|
cycle := activeCycle(now, times)
|
|
isDay = now.After(cycle.Sunrise) && now.Before(cycle.Sunset)
|
|
}
|
|
|
|
newState := State{
|
|
Config: config,
|
|
CurrentTemp: temp,
|
|
NextTransition: deadline,
|
|
SunriseTime: times.Sunrise,
|
|
SunsetTime: times.Sunset,
|
|
DawnTime: times.Dawn,
|
|
NightTime: times.Night,
|
|
IsDay: isDay,
|
|
SunPosition: pos,
|
|
ICCProfiles: m.GetICCStatus(),
|
|
OutputTemps: m.GetOutputTemps(),
|
|
GammaRefused: m.GammaRefusedOutputs(),
|
|
Outputs: m.ListOutputs(),
|
|
}
|
|
|
|
m.stateMutex.Lock()
|
|
m.state = &newState
|
|
m.stateMutex.Unlock()
|
|
|
|
m.notifySubscribers()
|
|
}
|
|
|
|
func (m *Manager) notifier() {
|
|
defer m.notifierWg.Done()
|
|
const minGap = 100 * time.Millisecond
|
|
timer := time.NewTimer(minGap)
|
|
timer.Stop()
|
|
var pending bool
|
|
|
|
for {
|
|
select {
|
|
case <-m.stopChan:
|
|
timer.Stop()
|
|
return
|
|
case <-m.dirty:
|
|
if pending {
|
|
continue
|
|
}
|
|
pending = true
|
|
timer.Reset(minGap)
|
|
case <-timer.C:
|
|
if !pending {
|
|
continue
|
|
}
|
|
currentState := m.GetState()
|
|
if m.lastNotified != nil && !stateChanged(m.lastNotified, ¤tState) {
|
|
pending = false
|
|
continue
|
|
}
|
|
m.subscribers.Range(func(_ string, ch chan State) bool {
|
|
select {
|
|
case ch <- currentState:
|
|
default:
|
|
}
|
|
return true
|
|
})
|
|
stateCopy := currentState
|
|
m.lastNotified = &stateCopy
|
|
pending = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) dbusMonitor() {
|
|
defer m.wg.Done()
|
|
for {
|
|
select {
|
|
case <-m.stopChan:
|
|
return
|
|
case sig := <-m.dbusSignal:
|
|
if sig == nil {
|
|
continue
|
|
}
|
|
m.handleDBusSignal(sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Manager) handleDBusSignal(sig *dbus.Signal) {
|
|
switch {
|
|
case sig.Name != "org.freedesktop.login1.Manager.PrepareForSleep":
|
|
return
|
|
case len(sig.Body) == 0:
|
|
return
|
|
}
|
|
preparing, ok := sig.Body[0].(bool)
|
|
if !ok || preparing {
|
|
return
|
|
}
|
|
if !m.needsControls() {
|
|
return
|
|
}
|
|
time.AfterFunc(500*time.Millisecond, func() {
|
|
m.post(m.handleResume)
|
|
})
|
|
}
|
|
|
|
func (m *Manager) handleResume() {
|
|
switch {
|
|
case !m.needsControls():
|
|
return
|
|
case !m.controlsInitialized:
|
|
return
|
|
case m.connectionDead.Load():
|
|
return
|
|
}
|
|
|
|
// Compositor gamma state is unknown after resume; force a resend (#1235)
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
out.lastTemp = 0
|
|
return true
|
|
})
|
|
m.applyCurrentTemp("resume")
|
|
}
|
|
|
|
func (m *Manager) triggerUpdate() {
|
|
select {
|
|
case m.updateTrigger <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
func (m *Manager) SetConfig(config Config) error {
|
|
if err := config.Validate(); err != nil {
|
|
return err
|
|
}
|
|
m.configMutex.Lock()
|
|
m.config = cloneConfig(config)
|
|
m.configMutex.Unlock()
|
|
m.triggerUpdate()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) SetTemperature(low, high int) error {
|
|
m.configMutex.Lock()
|
|
if m.config.LowTemp == low && m.config.HighTemp == high {
|
|
m.configMutex.Unlock()
|
|
return nil
|
|
}
|
|
updated := m.config
|
|
updated.LowTemp = low
|
|
updated.HighTemp = high
|
|
if err := updated.Validate(); err != nil {
|
|
m.configMutex.Unlock()
|
|
return err
|
|
}
|
|
m.config = updated
|
|
m.configMutex.Unlock()
|
|
m.triggerUpdate()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) SetLocation(lat, lon float64) error {
|
|
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 nil
|
|
}
|
|
updated := m.config
|
|
updated.Latitude = &lat
|
|
updated.Longitude = &lon
|
|
updated.UseIPLocation = false
|
|
if err := updated.Validate(); err != nil {
|
|
m.configMutex.Unlock()
|
|
return err
|
|
}
|
|
m.config = updated
|
|
m.configMutex.Unlock()
|
|
m.triggerUpdate()
|
|
return nil
|
|
}
|
|
|
|
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) SetManualTimes(sunrise, sunset time.Time, duration *time.Duration) error {
|
|
m.configMutex.Lock()
|
|
if m.config.ManualSunrise != nil && m.config.ManualSunset != nil &&
|
|
m.config.ManualSunrise.Hour() == sunrise.Hour() && m.config.ManualSunrise.Minute() == sunrise.Minute() &&
|
|
m.config.ManualSunset.Hour() == sunset.Hour() && m.config.ManualSunset.Minute() == sunset.Minute() &&
|
|
durationEqual(m.config.ManualDuration, duration) {
|
|
m.configMutex.Unlock()
|
|
return nil
|
|
}
|
|
updated := m.config
|
|
updated.ManualSunrise = &sunrise
|
|
updated.ManualSunset = &sunset
|
|
updated.ManualDuration = duration
|
|
if err := updated.Validate(); err != nil {
|
|
m.configMutex.Unlock()
|
|
return err
|
|
}
|
|
m.config = updated
|
|
m.configMutex.Unlock()
|
|
m.triggerUpdate()
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) ClearManualTimes() {
|
|
m.configMutex.Lock()
|
|
if m.config.ManualSunrise == nil && m.config.ManualSunset == nil && m.config.ManualDuration == nil {
|
|
m.configMutex.Unlock()
|
|
return
|
|
}
|
|
m.config.ManualSunrise = nil
|
|
m.config.ManualSunset = nil
|
|
m.config.ManualDuration = nil
|
|
m.configMutex.Unlock()
|
|
m.triggerUpdate()
|
|
}
|
|
|
|
func durationEqual(a, b *time.Duration) bool {
|
|
if a == nil || b == nil {
|
|
return a == b
|
|
}
|
|
return *a == *b
|
|
}
|
|
|
|
func (m *Manager) Adjustments() (gamma, contrast float64) {
|
|
m.configMutex.RLock()
|
|
defer m.configMutex.RUnlock()
|
|
return m.config.Gamma, m.config.Contrast
|
|
}
|
|
|
|
func (m *Manager) SetAdjustments(gamma, contrast float64) error {
|
|
m.configMutex.Lock()
|
|
if m.config.Gamma == gamma && m.config.Contrast == contrast {
|
|
m.configMutex.Unlock()
|
|
return nil
|
|
}
|
|
updated := m.config
|
|
updated.Gamma = gamma
|
|
updated.Contrast = contrast
|
|
if err := updated.Validate(); err != nil {
|
|
m.configMutex.Unlock()
|
|
return err
|
|
}
|
|
m.config = updated
|
|
m.configMutex.Unlock()
|
|
m.syncControls()
|
|
return nil
|
|
}
|
|
|
|
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.syncControls()
|
|
}
|
|
|
|
func (m *Manager) needsControls() bool {
|
|
m.configMutex.RLock()
|
|
defer m.configMutex.RUnlock()
|
|
return m.config.Enabled ||
|
|
m.config.Gamma != 1.0 ||
|
|
m.config.Contrast != 1.0 ||
|
|
len(m.config.ICCProfiles) > 0 ||
|
|
len(m.config.OutputTemps) > 0 ||
|
|
len(m.config.OutputBrightness) > 0
|
|
}
|
|
|
|
func (m *Manager) syncControls() {
|
|
m.post(func() {
|
|
switch {
|
|
case m.needsControls() && !m.controlsInitialized:
|
|
m.createControls()
|
|
case !m.needsControls() && m.controlsInitialized:
|
|
m.destroyControls()
|
|
default:
|
|
m.triggerUpdate()
|
|
}
|
|
})
|
|
}
|
|
|
|
// ensureOutputControls creates the gamma controls when the manager has none.
|
|
// Called on the wayland actor goroutine. A default install (night light off,
|
|
// gamma and contrast neutral, no profile, no override) has no controls, but
|
|
// applying an ICC profile or a per-output temperature still needs them.
|
|
func (m *Manager) ensureOutputControls() bool {
|
|
if m.controlsInitialized {
|
|
return true
|
|
}
|
|
gammaMgr, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
|
if !ok || gammaMgr == nil {
|
|
log.Warn("icc: gamma control manager not available")
|
|
return false
|
|
}
|
|
m.availOutputsMu.RLock()
|
|
outs := slices.Clone(m.availableOutputs)
|
|
m.availOutputsMu.RUnlock()
|
|
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
|
log.Errorf("icc: failed to initialize output controls: %v", err)
|
|
return false
|
|
}
|
|
m.controlsInitialized = true
|
|
log.Info("icc: output controls initialized (gamma was disabled)")
|
|
return true
|
|
}
|
|
|
|
func (m *Manager) createControls() {
|
|
gammaMgr := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1)
|
|
m.availOutputsMu.RLock()
|
|
outs := slices.Clone(m.availableOutputs)
|
|
m.availOutputsMu.RUnlock()
|
|
if err := m.setupOutputControls(outs, gammaMgr); err != nil {
|
|
log.Errorf("gamma: failed to create controls: %v", err)
|
|
return
|
|
}
|
|
m.controlsInitialized = true
|
|
// Recreated controls carry fresh output state, so the snapshot has to
|
|
// follow it rather than keep reporting what the old state held.
|
|
m.publishICCState()
|
|
m.triggerUpdate()
|
|
}
|
|
|
|
func (m *Manager) destroyControls() {
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if out.gammaControl != nil {
|
|
out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1).Destroy()
|
|
}
|
|
return true
|
|
})
|
|
m.outputs.Range(func(key uint32, _ *outputState) bool {
|
|
m.outputs.Delete(key)
|
|
return true
|
|
})
|
|
m.controlsInitialized = false
|
|
m.publishICCState()
|
|
}
|
|
|
|
// loadConfiguredICC attaches the configured ICC profiles and per-output
|
|
// temperatures to the outputs that are already known, and reports whether
|
|
// anything was attached.
|
|
func (m *Manager) loadConfiguredICC() bool {
|
|
config := m.configSnapshot()
|
|
iccProfiles := config.ICCProfiles
|
|
outputTemps := config.OutputTemps
|
|
|
|
if len(iccProfiles) == 0 && len(outputTemps) == 0 && len(config.OutputBrightness) == 0 {
|
|
return false
|
|
}
|
|
|
|
attached := 0
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
name, ok := m.outputNames.Load(out.id)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if m.applyConfiguredICCForOutput(out, name, config) {
|
|
attached++
|
|
}
|
|
return true
|
|
})
|
|
|
|
log.Infof("icc: config has %d profile(s) and %d temperature override(s), attached to %d output(s)",
|
|
len(iccProfiles), len(outputTemps), attached)
|
|
m.publishICCState()
|
|
return attached > 0
|
|
}
|
|
|
|
// attachConfiguredICC attaches the configured profile and temperature to an
|
|
// output whose name just became known, so a hotplugged output comes up
|
|
// corrected without an explicit re-apply.
|
|
func (m *Manager) attachConfiguredICC(outputID uint32, outputName string) {
|
|
config := m.configSnapshot()
|
|
iccProfiles := config.ICCProfiles
|
|
outputTemps := config.OutputTemps
|
|
|
|
if len(iccProfiles) == 0 && len(outputTemps) == 0 && len(config.OutputBrightness) == 0 {
|
|
m.publishICCState()
|
|
return
|
|
}
|
|
|
|
changed := false
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if out.id != outputID {
|
|
return true
|
|
}
|
|
changed = m.applyConfiguredICCForOutput(out, outputName, config)
|
|
if changed {
|
|
log.Infof("icc: attached configured profile/temperature to output %s", outputName)
|
|
out.lastTemp = 0
|
|
}
|
|
return false
|
|
})
|
|
|
|
// Publish either way: a hotplugged output is new to the snapshot even when
|
|
// it has no configured profile or temperature.
|
|
m.publishICCState()
|
|
|
|
if changed {
|
|
m.applyCurrentTemp("output-name")
|
|
}
|
|
}
|
|
|
|
// applyConfiguredICCForOutput applies the configured profile and temperature of
|
|
// one output and reports whether anything changed.
|
|
func (m *Manager) applyConfiguredICCForOutput(out *outputState, outputName string, config Config) bool {
|
|
changed := false
|
|
|
|
if temp, ok := config.OutputTemps[outputName]; ok {
|
|
out.outputTemp = temp
|
|
changed = true
|
|
}
|
|
|
|
if percent, ok := config.OutputBrightness[outputName]; ok {
|
|
out.dimScale = brightnessScale(percent)
|
|
changed = true
|
|
}
|
|
|
|
if profileName, ok := config.ICCProfiles[outputName]; ok {
|
|
iccPath := resolveICCProfile(profileName)
|
|
profile, err := icc.ParseFile(iccPath)
|
|
switch {
|
|
case err != nil:
|
|
log.Warnf("icc: failed to load profile for output %q: %v", outputName, err)
|
|
case !profile.HasVCGT:
|
|
// Only vcgt can be written to the GPU ramp; the TRC curve describes
|
|
// the display itself and would wash the output out if applied.
|
|
log.Warnf("icc: profile %q for output %q has no vcgt table, not attaching it", iccPath, outputName)
|
|
default:
|
|
out.iccPath = iccPath
|
|
out.iccProfile = profile
|
|
changed = true
|
|
}
|
|
}
|
|
|
|
return changed
|
|
}
|
|
|
|
// validateOutputName rejects names no wl_output can report, so a typo or an
|
|
// empty argument cannot persist an entry in icc.json that nothing will ever
|
|
// match.
|
|
func validateOutputName(name string) error {
|
|
switch {
|
|
case name == "":
|
|
return errors.New("icc: output name is required")
|
|
case strings.TrimSpace(name) != name:
|
|
return fmt.Errorf("icc: output name %q has surrounding whitespace", name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validateICCProfilePath rejects relative paths, the daemon does not share the caller's working directory.
|
|
func validateICCProfilePath(path string) error {
|
|
switch {
|
|
case path == "":
|
|
return errors.New("icc: profile path is required")
|
|
case !filepath.IsAbs(path):
|
|
return fmt.Errorf("icc: profile path %q must be absolute", path)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ApplyICC copies an ICC profile into ICCProfilesDir and applies it to an output.
|
|
// outputName is the wl_output name (e.g., "DP-1").
|
|
func (m *Manager) ApplyICC(outputName, iccPath string) error {
|
|
if err := validateOutputName(outputName); err != nil {
|
|
return err
|
|
}
|
|
if err := validateICCProfilePath(iccPath); err != nil {
|
|
return err
|
|
}
|
|
|
|
profile, err := icc.ParseFile(iccPath)
|
|
if err != nil {
|
|
return fmt.Errorf("icc: failed to parse %s: %w", iccPath, err)
|
|
}
|
|
if profile.ColorSpace != "RGB" {
|
|
return fmt.Errorf("icc: unsupported color space %q, only RGB supported", profile.ColorSpace)
|
|
}
|
|
// Only vcgt holds a video card gamma table, which is what the GPU ramp
|
|
// takes. The TRC tags describe the display's own transfer function, so a
|
|
// profile without vcgt has no correction to apply.
|
|
if !profile.HasVCGT {
|
|
return fmt.Errorf("icc: %s has no vcgt table, so it has no gamma ramp to apply", iccPath)
|
|
}
|
|
|
|
profilesDir := ICCProfilesDir()
|
|
profileName, err := stageICCProfile(profilesDir, iccPath)
|
|
if err != nil {
|
|
return fmt.Errorf("icc: failed to store %s in %s: %w", iccPath, profilesDir, err)
|
|
}
|
|
stagedPath := filepath.Join(profilesDir, profileName)
|
|
|
|
m.post(func() {
|
|
defer m.updateStateFromSchedule()
|
|
|
|
// ICC works with the night light off, but the gamma controls that carry
|
|
// the ramp may not exist yet, so create them before attaching.
|
|
if !m.ensureOutputControls() {
|
|
return
|
|
}
|
|
|
|
var targetOutput *outputState
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
|
|
targetOutput = out
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
if targetOutput == nil {
|
|
log.Warnf("icc: output %q not found", outputName)
|
|
return
|
|
}
|
|
|
|
targetOutput.iccPath = stagedPath
|
|
targetOutput.iccProfile = profile
|
|
m.publishICCState()
|
|
|
|
// The new profile is invisible to the per-output dedup, so clear this
|
|
// output's state to force a fresh ramp.
|
|
targetOutput.lastTemp = 0
|
|
m.applyCurrentTemp("icc-apply")
|
|
})
|
|
|
|
m.configMutex.Lock()
|
|
if m.config.ICCProfiles == nil {
|
|
m.config.ICCProfiles = make(map[string]string)
|
|
}
|
|
m.config.ICCProfiles[outputName] = profileName
|
|
savedConfig := cloneConfig(m.config)
|
|
m.configMutex.Unlock()
|
|
|
|
if err := SaveICCConfig(savedConfig); err != nil {
|
|
log.Warnf("icc: failed to save config: %v", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RemoveICC removes the ICC profile from a specific output, reverting to temperature-based gamma.
|
|
func (m *Manager) RemoveICC(outputName string) error {
|
|
if err := validateOutputName(outputName); err != nil {
|
|
return err
|
|
}
|
|
|
|
m.configMutex.Lock()
|
|
delete(m.config.ICCProfiles, outputName)
|
|
savedConfig := cloneConfig(m.config)
|
|
m.configMutex.Unlock()
|
|
|
|
if err := SaveICCConfig(savedConfig); err != nil {
|
|
log.Warnf("icc: failed to save config: %v", err)
|
|
}
|
|
|
|
m.post(func() {
|
|
defer m.updateStateFromSchedule()
|
|
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
|
|
if out.iccPath == "" {
|
|
return false
|
|
}
|
|
out.iccPath = ""
|
|
out.iccProfile = nil
|
|
out.lastTemp = 0
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
if m.releaseIdleControls() {
|
|
return
|
|
}
|
|
m.publishICCState()
|
|
m.applyCurrentTemp("icc-remove")
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// releaseIdleControls tears the gamma controls down once nothing needs them,
|
|
// so another gamma client can take over. Called on the wayland actor goroutine.
|
|
func (m *Manager) releaseIdleControls() bool {
|
|
if m.needsControls() || !m.controlsInitialized {
|
|
return false
|
|
}
|
|
m.destroyControls()
|
|
return true
|
|
}
|
|
|
|
// publishICCState republishes the ICC snapshot the getters serve. The
|
|
// per-output ICC fields are written on the wayland actor goroutine only, so
|
|
// this must run there; the getters are called from the IPC handlers, the CLI
|
|
// and the scheduler goroutine, which is why they never touch those fields.
|
|
// Publishing on ICC changes rather than on every state push also keeps the
|
|
// per-profile stat below out of the night light animation path. The status map
|
|
// reflects what is attached to an output; the temperature map reports the
|
|
// configured value, so a value set for an output that is not known yet is still
|
|
// reported instead of snapping the settings slider back to "Default".
|
|
func (m *Manager) publishICCState() {
|
|
status := make(map[string]*ICCStatus)
|
|
temps := make(map[string]int)
|
|
refused := []string{}
|
|
|
|
m.configMutex.RLock()
|
|
for name, temp := range m.config.OutputTemps {
|
|
if temp != 0 {
|
|
temps[name] = temp
|
|
}
|
|
}
|
|
m.configMutex.RUnlock()
|
|
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
name, ok := m.outputNames.Load(out.id)
|
|
if !ok {
|
|
return true
|
|
}
|
|
if _, configured := temps[name]; !configured && out.outputTemp != 0 {
|
|
temps[name] = out.outputTemp
|
|
}
|
|
if out.iccPath != "" {
|
|
status[name] = describeICCProfile(out.iccPath, out.iccProfile)
|
|
}
|
|
if out.gammaRefused {
|
|
refused = append(refused, name)
|
|
}
|
|
return true
|
|
})
|
|
slices.Sort(refused)
|
|
|
|
m.iccStateMutex.Lock()
|
|
m.iccStatus = status
|
|
m.iccTemps = temps
|
|
m.iccRefused = refused
|
|
m.iccStateMutex.Unlock()
|
|
}
|
|
|
|
// describeICCProfile builds the record served to the CLI and the settings UI.
|
|
// It is the only place that stats the profile file.
|
|
func describeICCProfile(path string, profile *icc.Profile) *ICCStatus {
|
|
status := &ICCStatus{
|
|
Path: path,
|
|
Active: profile != nil,
|
|
}
|
|
if profile != nil {
|
|
status.Description = profile.Description
|
|
status.Version = profile.Version
|
|
status.ColorSpace = profile.ColorSpace
|
|
status.Class = profile.Class
|
|
status.HasVCGT = profile.HasVCGT
|
|
status.TRCKind, status.TRCGamma, status.TRCEntries = profile.TRCKind()
|
|
if profile.VCGT != nil {
|
|
status.VCGTChannels = profile.VCGT.Channels
|
|
status.VCGTEntries = profile.VCGT.Entries
|
|
}
|
|
if x, y, ok := profile.WhitePointXY(); ok {
|
|
status.WhitePointX = x
|
|
status.WhitePointY = y
|
|
status.WhitePointCCT = profile.WhitePointCCT()
|
|
status.WhitePointName = profile.WhitePointName()
|
|
}
|
|
}
|
|
if info, err := os.Stat(path); err == nil {
|
|
status.Size = info.Size()
|
|
status.Modified = info.ModTime().Unix()
|
|
}
|
|
return status
|
|
}
|
|
|
|
// GetICCStatus returns the published ICC status for all outputs.
|
|
func (m *Manager) GetICCStatus() map[string]*ICCStatus {
|
|
m.iccStateMutex.RLock()
|
|
defer m.iccStateMutex.RUnlock()
|
|
return maps.Clone(m.iccStatus)
|
|
}
|
|
|
|
// ListOutputs returns a list of all output names for ICC assignment.
|
|
func (m *Manager) ListOutputs() []string {
|
|
var names []string
|
|
m.outputNames.Range(func(_ uint32, name string) bool {
|
|
names = append(names, name)
|
|
return true
|
|
})
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
// SetOutputTemp sets a per-output color temperature (1000K-10000K).
|
|
// A value of 0 resets to the global default.
|
|
func (m *Manager) SetOutputTemp(outputName string, temp int) error {
|
|
if err := validateOutputName(outputName); err != nil {
|
|
return err
|
|
}
|
|
if temp != 0 && (temp < 1000 || temp > 10000) {
|
|
return fmt.Errorf("temperature %d out of range (1000-10000)", temp)
|
|
}
|
|
|
|
// Persist first: on a default install (night light off, no profile) no
|
|
// gamma control exists yet, and the stored value is what makes
|
|
// needsControls() true for the controls the apply below creates.
|
|
m.configMutex.Lock()
|
|
if m.config.OutputTemps == nil {
|
|
m.config.OutputTemps = make(map[string]int)
|
|
}
|
|
if temp == 0 {
|
|
delete(m.config.OutputTemps, outputName)
|
|
} else {
|
|
m.config.OutputTemps[outputName] = temp
|
|
}
|
|
savedConfig := cloneConfig(m.config)
|
|
m.configMutex.Unlock()
|
|
|
|
if err := SaveICCConfig(savedConfig); err != nil {
|
|
log.Warnf("icc: failed to save config: %v", err)
|
|
}
|
|
|
|
m.post(func() {
|
|
defer m.updateStateFromSchedule()
|
|
|
|
if m.releaseIdleControls() {
|
|
return
|
|
}
|
|
if !m.needsControls() {
|
|
m.publishICCState()
|
|
return
|
|
}
|
|
|
|
// Without controls there are no outputs to write a ramp for, which is
|
|
// why the slider and `dms icc set-temp` used to do nothing until a
|
|
// restart on an install that had never enabled the night light.
|
|
m.ensureOutputControls()
|
|
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if name, ok := m.outputNames.Load(out.id); ok && name == outputName {
|
|
if out.outputTemp == temp {
|
|
return false
|
|
}
|
|
out.outputTemp = temp
|
|
out.lastTemp = 0
|
|
m.publishICCState()
|
|
m.applyCurrentTemp("output-temp-change")
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
|
|
// An output whose name is not known yet picks the value up through
|
|
// attachConfiguredICC; the published state carries the configured value
|
|
// either way, so the settings slider does not snap back to Default.
|
|
m.publishICCState()
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetOutputBrightness dims an output through its gamma ramp, for outputs
|
|
// without a backlight or DDC/CI. 100 removes the override.
|
|
func (m *Manager) SetOutputBrightness(outputName string, percent int) error {
|
|
if err := validateOutputName(outputName); err != nil {
|
|
return err
|
|
}
|
|
percent = min(max(percent, 0), 100)
|
|
|
|
m.configMutex.Lock()
|
|
current, configured := m.config.OutputBrightness[outputName]
|
|
if (configured && current == percent) || (!configured && percent == 100) {
|
|
m.configMutex.Unlock()
|
|
return nil
|
|
}
|
|
if m.config.OutputBrightness == nil {
|
|
m.config.OutputBrightness = make(map[string]int)
|
|
}
|
|
if percent == 100 {
|
|
delete(m.config.OutputBrightness, outputName)
|
|
} else {
|
|
m.config.OutputBrightness[outputName] = percent
|
|
}
|
|
savedConfig := cloneConfig(m.config)
|
|
m.configMutex.Unlock()
|
|
|
|
if err := SaveICCConfig(savedConfig); err != nil {
|
|
log.Warnf("gamma: failed to save config: %v", err)
|
|
}
|
|
|
|
scale := brightnessScale(percent)
|
|
if percent == 100 {
|
|
scale = 0
|
|
}
|
|
|
|
m.post(func() {
|
|
defer m.updateStateFromSchedule()
|
|
|
|
if m.releaseIdleControls() {
|
|
return
|
|
}
|
|
if !m.ensureOutputControls() {
|
|
return
|
|
}
|
|
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if name, ok := m.outputNames.Load(out.id); !ok || name != outputName {
|
|
return true
|
|
}
|
|
out.dimScale = scale
|
|
out.lastTemp = 0
|
|
m.applyCurrentTemp("output-brightness-change")
|
|
return false
|
|
})
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetOutputBrightness returns the configured software brightness per output;
|
|
// outputs at full brightness are absent.
|
|
func (m *Manager) GetOutputBrightness() map[string]int {
|
|
m.configMutex.RLock()
|
|
defer m.configMutex.RUnlock()
|
|
return maps.Clone(m.config.OutputBrightness)
|
|
}
|
|
|
|
// GammaRefusedOutputs returns the outputs whose gamma control the compositor
|
|
// refused, so software brightness cannot dim them.
|
|
func (m *Manager) GammaRefusedOutputs() []string {
|
|
m.iccStateMutex.RLock()
|
|
defer m.iccStateMutex.RUnlock()
|
|
return slices.Clone(m.iccRefused)
|
|
}
|
|
|
|
// GetOutputTemps returns the published per-output temperatures.
|
|
func (m *Manager) GetOutputTemps() map[string]int {
|
|
m.iccStateMutex.RLock()
|
|
defer m.iccStateMutex.RUnlock()
|
|
return maps.Clone(m.iccTemps)
|
|
}
|
|
|
|
func (m *Manager) Close() {
|
|
close(m.stopChan)
|
|
m.wg.Wait()
|
|
m.notifierWg.Wait()
|
|
|
|
m.subscribers.Range(func(key string, ch chan State) bool {
|
|
close(ch)
|
|
m.subscribers.Delete(key)
|
|
return true
|
|
})
|
|
|
|
m.outputs.Range(func(_ uint32, out *outputState) bool {
|
|
if ctrl, ok := out.gammaControl.(*wlr_gamma_control.ZwlrGammaControlV1); ok {
|
|
ctrl.Destroy()
|
|
}
|
|
return true
|
|
})
|
|
m.outputs.Range(func(key uint32, _ *outputState) bool {
|
|
m.outputs.Delete(key)
|
|
return true
|
|
})
|
|
|
|
if manager, ok := m.gammaControl.(*wlr_gamma_control.ZwlrGammaControlManagerV1); ok {
|
|
manager.Destroy()
|
|
}
|
|
|
|
if m.dbusConn != nil {
|
|
m.dbusConn.RemoveSignal(m.dbusSignal)
|
|
m.dbusConn.BusObject().Call("org.freedesktop.DBus.RemoveMatch", 0, prepareForSleepMatchRule)
|
|
}
|
|
}
|