diff --git a/core/go.mod b/core/go.mod index 91669d75..f7416c4f 100644 --- a/core/go.mod +++ b/core/go.mod @@ -11,6 +11,8 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/godbus/dbus/v5 v5.2.0 github.com/holoplot/go-evdev v0.0.0-20250804134636-ab1d56a1fe83 + github.com/pilebones/go-udev v0.9.1 + github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 @@ -32,7 +34,6 @@ require ( github.com/kevinburke/ssh_config v1.4.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pjbgf/sha1cd v0.5.0 // indirect - github.com/sblinch/kdl-go v0.0.0-20250930225324-bf4099d4614a // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/stretchr/objx v0.5.3 // indirect golang.org/x/crypto v0.45.0 // indirect diff --git a/core/go.sum b/core/go.sum index 311eac9f..a7b03664 100644 --- a/core/go.sum +++ b/core/go.sum @@ -24,16 +24,12 @@ github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoF github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/log v0.4.2 h1:hYt8Qj6a8yLnvR+h7MwsJv/XvmBJXiueUcI3cIxsyig= github.com/charmbracelet/log v0.4.2/go.mod h1:qifHGX/tc7eluv2R6pWIpyHDDrrb/AG71Pf2ysQu5nw= -github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= -github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= github.com/charmbracelet/x/ansi v0.11.2 h1:XAG3FSjiVtFvgEgGrNBkCNNYrsucAt8c6bfxHyROLLs= github.com/charmbracelet/x/ansi v0.11.2/go.mod h1:9tY2bzX5SiJCU0iWyskjBeI2BRQfvPqI+J760Mjf+Rg= github.com/charmbracelet/x/cellbuf v0.0.14 h1:iUEMryGyFTelKW3THW4+FfPgi4fkmKnnaLOXuc+/Kj4= github.com/charmbracelet/x/cellbuf v0.0.14/go.mod h1:P447lJl49ywBbil/KjCk2HexGh4tEY9LH0/1QrZZ9rA= github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= -github.com/clipperhouse/displaywidth v0.5.0 h1:AIG5vQaSL2EKqzt0M9JMnvNxOCRTKUc4vUnLWGgP89I= -github.com/clipperhouse/displaywidth v0.5.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= @@ -43,8 +39,6 @@ github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsV github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/cyphar/filepath-securejoin v0.6.0 h1:BtGB77njd6SVO6VztOHfPxKitJvd/VPT+OFBFMOi1Is= -github.com/cyphar/filepath-securejoin v0.6.0/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -62,19 +56,14 @@ github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= -github.com/go-git/go-billy/v6 v6.0.0-20251111123000-fb5ff8f3f0b0 h1:EC9n6hr6yKDoVJ6g7Ko523LbbceJfR0ohbOp809Fyf4= -github.com/go-git/go-billy/v6 v6.0.0-20251111123000-fb5ff8f3f0b0/go.mod h1:E3VhlS+AKkrq6ZNn1axE2/nDRJ87l1FJk9r5HT2vPX0= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 h1:eY5aB2GXiVdgTueBcqsBt53WuJTRZAuCdIS/86Pcq5c= github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0/go.mod h1:0NjwVNrwtVFZBReAp5OoGklGJIgJFEbVyHneAr4lc8k= github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w= github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU= -github.com/go-git/go-git/v6 v6.0.0-20251112161705-8cc3e21f07a9 h1:SOFrnF9LCssC6q6Rb0084Bzg2aBYbe8QXv9xKGXmt/w= -github.com/go-git/go-git/v6 v6.0.0-20251112161705-8cc3e21f07a9/go.mod h1:0wtvm/JfPC9RFVEAP3ks0ec5h64/qmZkTTUE3pjz7Hc= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805 h1:jxQ3BzYeErNRvlI/4+0mpwqMzvB4g97U+ksfgvrUEbY= github.com/go-git/go-git/v6 v6.0.0-20251128074608-48f817f57805/go.mod h1:dIwT3uWK1ooHInyVnK2JS5VfQ3peVGYaw2QPqX7uFvs= github.com/go-logfmt/logfmt v0.6.1 h1:4hvbpePJKnIzH1B+8OR/JPbTx37NktoI9LE2QZBBkvE= github.com/go-logfmt/logfmt v0.6.1/go.mod h1:EV2pOAQoZaT1ZXZbqDl5hrymndi4SY9ED9/z6CO0XAk= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.0 h1:3WexO+U+yg9T70v9FdHr9kCxYlazaAXUhx2VMkbfax8= github.com/godbus/dbus/v5 v5.2.0/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= @@ -95,8 +84,9 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -111,6 +101,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= +github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -139,12 +131,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= -golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU= -golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0= -golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY= golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= diff --git a/core/internal/server/brightness/manager.go b/core/internal/server/brightness/manager.go index 2053c926..2e0e76e0 100644 --- a/core/internal/server/brightness/manager.go +++ b/core/internal/server/brightness/manager.go @@ -54,6 +54,7 @@ func (m *Manager) initSysfs() { m.sysfsBackend = sysfs m.sysfsReady = true m.updateState() + m.initUdev() return } @@ -65,6 +66,11 @@ func (m *Manager) initSysfs() { m.sysfsBackend = sysfs m.sysfsReady = true m.updateState() + m.initUdev() +} + +func (m *Manager) initUdev() { + m.udevMonitor = NewUdevMonitor(m) } func (m *Manager) initDDC() { diff --git a/core/internal/server/brightness/types.go b/core/internal/server/brightness/types.go index e67a97eb..3c5462a2 100644 --- a/core/internal/server/brightness/types.go +++ b/core/internal/server/brightness/types.go @@ -43,6 +43,7 @@ type Manager struct { logindBackend *LogindBackend sysfsBackend *SysfsBackend ddcBackend *DDCBackend + udevMonitor *UdevMonitor logindReady bool sysfsReady bool @@ -181,6 +182,10 @@ func (m *Manager) Close() { return true }) + if m.udevMonitor != nil { + m.udevMonitor.Close() + } + if m.logindBackend != nil { m.logindBackend.Close() } diff --git a/core/internal/server/brightness/udev.go b/core/internal/server/brightness/udev.go new file mode 100644 index 00000000..e455582e --- /dev/null +++ b/core/internal/server/brightness/udev.go @@ -0,0 +1,148 @@ +package brightness + +import ( + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/pilebones/go-udev/netlink" +) + +type UdevMonitor struct { + stop chan struct{} +} + +func NewUdevMonitor(manager *Manager) *UdevMonitor { + m := &UdevMonitor{ + stop: make(chan struct{}), + } + + go m.run(manager) + return m +} + +func (m *UdevMonitor) run(manager *Manager) { + conn := &netlink.UEventConn{} + if err := conn.Connect(netlink.UdevEvent); err != nil { + log.Errorf("Failed to connect to udev netlink: %v", err) + return + } + defer conn.Close() + + matcher := &netlink.RuleDefinitions{ + Rules: []netlink.RuleDefinition{ + {Env: map[string]string{"SUBSYSTEM": "backlight"}}, + {Env: map[string]string{"SUBSYSTEM": "leds"}}, + }, + } + if err := matcher.Compile(); err != nil { + log.Errorf("Failed to compile udev matcher: %v", err) + return + } + + events := make(chan netlink.UEvent) + errs := make(chan error) + conn.Monitor(events, errs, matcher) + + log.Info("Udev monitor started for backlight/leds events") + + for { + select { + case <-m.stop: + return + case err := <-errs: + log.Errorf("Udev monitor error: %v", err) + return + case event := <-events: + m.handleEvent(manager, event) + } + } +} + +func (m *UdevMonitor) handleEvent(manager *Manager, event netlink.UEvent) { + subsystem := event.Env["SUBSYSTEM"] + devpath := event.Env["DEVPATH"] + + if subsystem == "" || devpath == "" { + return + } + + sysname := filepath.Base(devpath) + action := string(event.Action) + + switch action { + case "change": + m.handleChange(manager, subsystem, sysname) + case "add", "remove": + log.Debugf("Udev %s event: %s:%s - triggering rescan", action, subsystem, sysname) + manager.Rescan() + } +} + +func (m *UdevMonitor) handleChange(manager *Manager, subsystem, sysname string) { + deviceID := subsystem + ":" + sysname + + if manager.sysfsBackend == nil { + return + } + + brightnessPath := filepath.Join(manager.sysfsBackend.basePath, subsystem, sysname, "brightness") + data, err := os.ReadFile(brightnessPath) + if err != nil { + log.Debugf("Udev change event for %s but failed to read brightness: %v", deviceID, err) + return + } + + brightness, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + log.Debugf("Failed to parse brightness for %s: %v", deviceID, err) + return + } + + manager.handleUdevBrightnessChange(deviceID, brightness) +} + +func (m *UdevMonitor) Close() { + close(m.stop) +} + +func (m *Manager) handleUdevBrightnessChange(deviceID string, rawBrightness int) { + if m.sysfsBackend == nil { + return + } + + dev, err := m.sysfsBackend.GetDevice(deviceID) + if err != nil { + log.Debugf("Udev event for unknown device %s: %v", deviceID, err) + return + } + + percent := m.sysfsBackend.ValueToPercent(rawBrightness, dev, false) + + m.stateMutex.Lock() + var found bool + for i, d := range m.state.Devices { + if d.ID != deviceID { + continue + } + found = true + if d.Current == rawBrightness { + m.stateMutex.Unlock() + return + } + m.state.Devices[i].Current = rawBrightness + m.state.Devices[i].CurrentPercent = percent + break + } + m.stateMutex.Unlock() + + if !found { + log.Debugf("Udev event for device not in state: %s", deviceID) + return + } + + log.Debugf("Udev brightness change: %s -> %d (%d%%)", deviceID, rawBrightness, percent) + m.broadcastDeviceUpdate(deviceID) +} diff --git a/core/internal/server/brightness/udev_test.go b/core/internal/server/brightness/udev_test.go new file mode 100644 index 00000000..6281acbe --- /dev/null +++ b/core/internal/server/brightness/udev_test.go @@ -0,0 +1,260 @@ +package brightness + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/pilebones/go-udev/netlink" +) + +func setupTestManager(t *testing.T) (*Manager, string) { + tmpDir := t.TempDir() + + backlightDir := filepath.Join(tmpDir, "backlight", "intel_backlight") + if err := os.MkdirAll(backlightDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "max_brightness"), []byte("1000\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backlightDir, "brightness"), []byte("500\n"), 0644); err != nil { + t.Fatal(err) + } + + sysfs := &SysfsBackend{ + basePath: tmpDir, + classes: []string{"backlight"}, + } + if err := sysfs.scanDevices(); err != nil { + t.Fatal(err) + } + + m := &Manager{ + sysfsBackend: sysfs, + sysfsReady: true, + stopChan: make(chan struct{}), + } + + m.state = State{ + Devices: []Device{ + { + Class: ClassBacklight, + ID: "backlight:intel_backlight", + Name: "intel_backlight", + Current: 500, + Max: 1000, + CurrentPercent: 50, + Backend: "sysfs", + }, + }, + } + + return m, tmpDir +} + +func TestHandleUdevBrightnessChange_UpdatesState(t *testing.T) { + m, _ := setupTestManager(t) + + m.handleUdevBrightnessChange("backlight:intel_backlight", 750) + + state := m.GetState() + if len(state.Devices) != 1 { + t.Fatalf("expected 1 device, got %d", len(state.Devices)) + } + + dev := state.Devices[0] + if dev.Current != 750 { + t.Errorf("expected Current=750, got %d", dev.Current) + } + if dev.CurrentPercent != 75 { + t.Errorf("expected CurrentPercent=75, got %d", dev.CurrentPercent) + } +} + +func TestHandleUdevBrightnessChange_NoChangeWhenSameValue(t *testing.T) { + m, _ := setupTestManager(t) + + updateCh := m.SubscribeUpdates("test") + defer m.UnsubscribeUpdates("test") + + m.handleUdevBrightnessChange("backlight:intel_backlight", 500) + + select { + case <-updateCh: + t.Error("should not broadcast when brightness unchanged") + case <-time.After(50 * time.Millisecond): + } +} + +func TestHandleUdevBrightnessChange_BroadcastsOnChange(t *testing.T) { + m, _ := setupTestManager(t) + + updateCh := m.SubscribeUpdates("test") + defer m.UnsubscribeUpdates("test") + + m.handleUdevBrightnessChange("backlight:intel_backlight", 750) + + select { + case update := <-updateCh: + if update.Device.Current != 750 { + t.Errorf("broadcast had wrong Current: got %d, want 750", update.Device.Current) + } + case <-time.After(100 * time.Millisecond): + t.Error("expected broadcast on brightness change") + } +} + +func TestHandleUdevBrightnessChange_UnknownDevice(t *testing.T) { + m, _ := setupTestManager(t) + + m.handleUdevBrightnessChange("backlight:unknown_device", 500) + + state := m.GetState() + if len(state.Devices) != 1 { + t.Errorf("state should be unchanged, got %d devices", len(state.Devices)) + } +} + +func TestHandleUdevBrightnessChange_NilSysfsBackend(t *testing.T) { + m := &Manager{ + sysfsBackend: nil, + stopChan: make(chan struct{}), + } + + m.handleUdevBrightnessChange("backlight:test", 500) +} + +func TestHandleUdevBrightnessChange_DeviceNotInState(t *testing.T) { + m, _ := setupTestManager(t) + + m.sysfsBackend.deviceCache.Store("backlight:other_device", &sysfsDevice{ + class: ClassBacklight, + id: "backlight:other_device", + name: "other_device", + maxBrightness: 100, + minValue: 1, + }) + + m.handleUdevBrightnessChange("backlight:other_device", 50) + + state := m.GetState() + for _, d := range state.Devices { + if d.ID == "backlight:other_device" { + t.Error("device should not be added to state via udev change event") + } + } +} + +func TestHandleEvent_ChangeAction(t *testing.T) { + m, tmpDir := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") + if err := os.WriteFile(brightnessPath, []byte("800\n"), 0644); err != nil { + t.Fatal(err) + } + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{ + "SUBSYSTEM": "backlight", + "DEVPATH": "/devices/pci0000:00/0000:00:02.0/drm/card0/card0-eDP-1/intel_backlight", + }, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 800 { + t.Errorf("expected Current=800 after change event, got %d", state.Devices[0].Current) + } +} + +func TestHandleEvent_MissingEnvVars(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{}, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with missing env vars") + } +} + +func TestHandleEvent_MissingSubsystem(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + event := netlink.UEvent{ + Action: netlink.CHANGE, + Env: map[string]string{ + "DEVPATH": "/devices/foo/bar", + }, + } + + um.handleEvent(m, event) + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with missing SUBSYSTEM") + } +} + +func TestHandleChange_BrightnessFileNotFound(t *testing.T) { + m, _ := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + um.handleChange(m, "backlight", "nonexistent_device") + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged when brightness file not found") + } +} + +func TestHandleChange_InvalidBrightnessValue(t *testing.T) { + m, tmpDir := setupTestManager(t) + um := &UdevMonitor{stop: make(chan struct{})} + + brightnessPath := filepath.Join(tmpDir, "backlight", "intel_backlight", "brightness") + if err := os.WriteFile(brightnessPath, []byte("not_a_number\n"), 0644); err != nil { + t.Fatal(err) + } + + um.handleChange(m, "backlight", "intel_backlight") + + state := m.GetState() + if state.Devices[0].Current != 500 { + t.Error("state should be unchanged with invalid brightness value") + } +} + +func TestUdevMonitor_Close(t *testing.T) { + um := &UdevMonitor{stop: make(chan struct{})} + + um.Close() + + select { + case <-um.stop: + default: + t.Error("stop channel should be closed") + } +} + +func TestHandleChange_NilSysfsBackend(t *testing.T) { + m := &Manager{ + sysfsBackend: nil, + stopChan: make(chan struct{}), + } + um := &UdevMonitor{stop: make(chan struct{})} + + um.handleChange(m, "backlight", "test_device") +} diff --git a/quickshell/Common/SessionData.qml b/quickshell/Common/SessionData.qml index 4c8de82c..8f276ef7 100644 --- a/quickshell/Common/SessionData.qml +++ b/quickshell/Common/SessionData.qml @@ -856,6 +856,13 @@ Singleton { return brightnessUserSetValues[deviceName]; } + function clearBrightnessUserSetValue(deviceName) { + var newValues = Object.assign({}, brightnessUserSetValues); + delete newValues[deviceName]; + brightnessUserSetValues = newValues; + saveSettings(); + } + function setBrightnessExponent(deviceName, exponent) { var newValues = Object.assign({}, brightnessExponentValues); if (exponent !== undefined && exponent !== null) { diff --git a/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml b/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml index 128d25be..0584b66f 100644 --- a/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml +++ b/quickshell/Modules/ControlCenter/Widgets/BrightnessSliderRow.qml @@ -159,7 +159,6 @@ Row { } return targetDevice.displayMax || 100; } - value: !isDragging ? targetBrightness : value showValue: true unit: { if (!targetDevice) @@ -177,5 +176,10 @@ Row { } thumbOutlineColor: Theme.surfaceContainer trackColor: Theme.withAlpha(Theme.surfaceContainerHigh, Theme.popupTransparency) + + Binding on value { + value: root.targetBrightness + when: !brightnessSlider.isDragging + } } } diff --git a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml index d35bfee5..e2ff81fc 100644 --- a/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml +++ b/quickshell/Modules/DankBar/Widgets/ControlCenterButton.qml @@ -134,11 +134,12 @@ BasePill { function handleBrightnessWheel(delta) { const deviceName = getPinnedBrightnessDevice(); - if (!deviceName) + if (!deviceName) { return; + } const currentBrightness = DisplayService.getDeviceBrightness(deviceName); const newBrightness = delta > 0 ? Math.min(100, currentBrightness + 5) : Math.max(1, currentBrightness - 5); - DisplayService.setBrightness(newBrightness, deviceName, false); + DisplayService.setBrightness(newBrightness, deviceName); } function getBatteryIconColor() { diff --git a/quickshell/Modules/OSD/BrightnessOSD.qml b/quickshell/Modules/OSD/BrightnessOSD.qml index 529d0d34..b78bc435 100644 --- a/quickshell/Modules/OSD/BrightnessOSD.qml +++ b/quickshell/Modules/OSD/BrightnessOSD.qml @@ -107,7 +107,6 @@ DankOSD { } thumbOutlineColor: Theme.surfaceContainer alwaysShowValue: SettingsData.osdAlwaysShowValue - value: !isDragging ? root.targetBrightness : value onSliderValueChanged: newValue => { if (DisplayService.brightnessAvailable) { @@ -119,6 +118,11 @@ DankOSD { onContainsMouseChanged: { setChildHovered(containsMouse); } + + Binding on value { + value: root.targetBrightness + when: !brightnessSlider.isDragging + } } } } @@ -163,7 +167,12 @@ DankOSD { y: gap * 2 + Theme.iconSize property bool dragging: false - property int value: !dragging ? root.targetBrightness : value + property int value: 50 + + Binding on value { + value: root.targetBrightness + when: !vertSlider.dragging + } readonly property int minimum: { const deviceInfo = DisplayService.getCurrentDeviceInfo(); @@ -255,12 +264,14 @@ DankOSD { } function updateBrightness(mouse) { - if (DisplayService.brightnessAvailable) { - const ratio = 1.0 - (mouse.y / height); - const newValue = Math.round(vertSlider.minimum + ratio * (vertSlider.maximum - vertSlider.minimum)); - DisplayService.setBrightness(newValue, DisplayService.lastIpcDevice, true); - resetHideTimer(); + if (!DisplayService.brightnessAvailable) { + return; } + const ratio = 1.0 - (mouse.y / height); + const newValue = Math.round(vertSlider.minimum + ratio * (vertSlider.maximum - vertSlider.minimum)); + vertSlider.value = newValue; + DisplayService.setBrightness(newValue, DisplayService.lastIpcDevice, true); + resetHideTimer(); } } } diff --git a/quickshell/Services/DisplayService.qml b/quickshell/Services/DisplayService.qml index 13711285..cfe93963 100644 --- a/quickshell/Services/DisplayService.qml +++ b/quickshell/Services/DisplayService.qml @@ -14,6 +14,8 @@ Singleton { property var deviceBrightness: ({}) property var deviceBrightnessUserSet: ({}) property var deviceMaxCache: ({}) + property var userControlledDevices: ({}) + property var pendingOsdDevices: ({}) property int brightnessVersion: 0 property string currentDevice: "" property string lastIpcDevice: "" @@ -28,6 +30,7 @@ Singleton { } property int maxBrightness: 100 property bool brightnessInitialized: false + property bool suppressOsd: true signal brightnessChanged(bool showOsd) signal deviceSwitched @@ -38,11 +41,47 @@ Singleton { property bool automationAvailable: false property bool gammaControlAvailable: false + function markDeviceUserControlled(deviceId) { + const newControlled = Object.assign({}, userControlledDevices); + newControlled[deviceId] = Date.now(); + userControlledDevices = newControlled; + } + + function isDeviceUserControlled(deviceId) { + const controlTime = userControlledDevices[deviceId]; + if (!controlTime) { + return false; + } + return (Date.now() - controlTime) < 1000; + } + + function clearDeviceUserControlled(deviceId) { + const newControlled = Object.assign({}, userControlledDevices); + delete newControlled[deviceId]; + userControlledDevices = newControlled; + } + + function markDevicePendingOsd(deviceId) { + const newPending = Object.assign({}, pendingOsdDevices); + newPending[deviceId] = true; + pendingOsdDevices = newPending; + } + + function clearDevicePendingOsd(deviceId) { + const newPending = Object.assign({}, pendingOsdDevices); + delete newPending[deviceId]; + pendingOsdDevices = newPending; + } + function updateSingleDevice(device) { + const isUserControlled = isDeviceUserControlled(device.id); + if (isUserControlled) { + return; + } + const deviceIndex = devices.findIndex(d => d.id === device.id); if (deviceIndex !== -1) { const newDevices = [...devices]; - const existingDevice = devices[deviceIndex]; const cachedMax = deviceMaxCache[device.id]; let displayMax = cachedMax || (device.class === "ddc" ? device.max : 100); @@ -71,7 +110,17 @@ Singleton { let displayValue = device.currentPercent; if (isExponential) { if (userSetValue !== undefined) { - displayValue = userSetValue; + const exponent = SessionData.getBrightnessExponent(device.id); + const expectedHardware = Math.round(Math.pow(userSetValue / 100.0, exponent) * 100.0); + if (Math.abs(device.currentPercent - expectedHardware) > 2) { + const newUserSet = Object.assign({}, deviceBrightnessUserSet); + delete newUserSet[device.id]; + deviceBrightnessUserSet = newUserSet; + SessionData.clearBrightnessUserSetValue(device.id); + displayValue = linearToExponential(device.currentPercent, device.id); + } else { + displayValue = userSetValue; + } } else { displayValue = linearToExponential(device.currentPercent, device.id); } @@ -83,9 +132,22 @@ Singleton { deviceBrightness = newBrightness; brightnessVersion++; - if (oldValue !== undefined && oldValue !== displayValue && brightnessInitialized) { - brightnessChanged(true); + const isPendingOsd = pendingOsdDevices[device.id] === true; + if (isPendingOsd) { + clearDevicePendingOsd(device.id); + if (!suppressOsd) { + brightnessChanged(true); + } + return; } + + if (!brightnessInitialized || oldValue === displayValue) { + return; + } + if (suppressOsd) { + return; + } + brightnessChanged(true); } function updateFromBrightnessState(state) { @@ -167,13 +229,12 @@ Singleton { function setBrightness(percentage, device, suppressOsd) { const actualDevice = device === "" ? getDefaultDevice() : (device || currentDevice || getDefaultDevice()); - if (!actualDevice) { console.warn("DisplayService: No device selected for brightness change"); return; } - if (actualDevice && actualDevice !== lastIpcDevice) { + if (actualDevice !== lastIpcDevice) { lastIpcDevice = actualDevice; } @@ -183,12 +244,15 @@ Singleton { let minValue = 0; let maxValue = 100; - if (isExponential) { + switch (true) { + case isExponential: minValue = 1; maxValue = 100; - } else { + break; + default: minValue = (deviceInfo && (deviceInfo.class === "backlight" || deviceInfo.class === "ddc")) ? 1 : 0; maxValue = deviceInfo?.displayMax || 100; + break; } if (maxValue <= 0) { @@ -203,6 +267,12 @@ Singleton { return; } + if (suppressOsd) { + markDeviceUserControlled(actualDevice); + } else { + markDevicePendingOsd(actualDevice); + } + const newBrightness = Object.assign({}, deviceBrightness); newBrightness[actualDevice] = clampedValue; deviceBrightness = newBrightness; @@ -215,10 +285,6 @@ Singleton { SessionData.setBrightnessUserSetValue(actualDevice, clampedValue); } - if (!suppressOsd) { - brightnessChanged(true); - } - const params = { "device": actualDevice, "percent": clampedValue @@ -634,6 +700,13 @@ Singleton { brightnessChanged(); } + Timer { + id: osdSuppressTimer + interval: 2000 + running: true + onTriggered: suppressOsd = false + } + Component.onCompleted: { nightModeEnabled = SessionData.nightModeEnabled; deviceBrightnessUserSet = Object.assign({}, SessionData.brightnessUserSetValues); @@ -674,6 +747,13 @@ Singleton { function onBrightnessDeviceUpdate(device) { updateSingleDevice(device); } + + function onLoginctlEvent(event) { + if (event.event === "unlock" || event.event === "resume") { + suppressOsd = true; + osdSuppressTimer.restart(); + } + } } // Session Data Connections @@ -746,7 +826,7 @@ Singleton { if (targetDevice && targetDevice !== root.currentDevice) { root.setCurrentDevice(targetDevice, false); } - root.setBrightness(clampedValue, targetDevice, false); + root.setBrightness(clampedValue, targetDevice); if (targetDevice) { return "Brightness set to " + clampedValue + "% on " + targetDevice; @@ -787,7 +867,7 @@ Singleton { const newBrightness = Math.min(maxValue, currentBrightness + stepValue); - root.setBrightness(newBrightness, actualDevice, false); + root.setBrightness(newBrightness, actualDevice); return "Brightness increased by " + stepValue + "%" + (targetDevice ? " on " + targetDevice : ""); } @@ -824,7 +904,7 @@ Singleton { const newBrightness = Math.max(minValue, currentBrightness - stepValue); - root.setBrightness(newBrightness, actualDevice, false); + root.setBrightness(newBrightness, actualDevice); return "Brightness decreased by " + stepValue + "%" + (targetDevice ? " on " + targetDevice : ""); }