feat(idle): honour D-Bus idle inhibitors through org.freedesktop.ScreenSaver (#94)
* docs(readme): bar and wallpaper are the user's layer-shell clients * feat(idle): honour D-Bus idle inhibitors through org.freedesktop.ScreenSaver Browsers, Electron apps, VLC and the GTK portal's Inhibit hold idle over D-Bus, which nothing on a NeferWL session served: idle clients such as neferafk faded and locked during video playback. NeferWL now owns org.freedesktop.ScreenSaver on the session bus (DRM backend only) and an inhibition holds ext-idle-notify notifications like an inhibitor surface. Cookies are per connection and end on UnInhibit or when the connection leaves the bus. * fix(screensaver): stand aside without holding the bus, watch departures only Return at once when another program owns org.freedesktop.ScreenSaver, so no unread signal channel spawns godbus delivery goroutines. Match only lost owners, wrap export errors, and raise the per-connection bound: the GTK portal holds every portal app's cookies. * perf(screensaver): register D-Bus handlers with method tables to keep linker pruning * ci: install dbus so the screensaver tests run instead of skipping * fix(screensaver): trust only bus departures, retry the bus, serve the full interface - Ignore NameOwnerChanged not sent by org.freedesktop.DBus: a client could send one directly and end another client's inhibitions. - Drop a first cookie when NameHasOwner fails twice, so a client that left before its call was handled cannot keep the session awake for good. - Serve retries with backoff when the bus is missing or restarts, or the name is owned by another program; Run returns nil on shutdown and ErrNameTaken when it stands aside, so no misleading warning on exit. - Add GetActiveTime, SetActive and SimulateUserActivity; activity reaches wayland through Channels.IdleActivity and resumes idle notifications. - Tests: spoofed departure, early departure, per-sender limit over D-Bus, call after stop, retry after name release and bus restart, a running timer held by a D-Bus inhibition; a missing dbus-daemon fails under CI. - Docs: the portal Inhibit needs a backend that forwards to the name.
- commit
- 245942bb455702a983e049e769e3f6dda6b39c75
- author
- bnema · · 2026-10-03 02:21 UTC
- committer
- GitHub · · 2026-10-03 02:21 UTC
- parent
- b9309c2
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
14 files · +1020 -9
M .github/workflows/ci.yml +3 -2
| @@ -21,11 +21,12 @@ jobs: | |||
| 21 | 21 | XDG_RUNTIME_DIR: /tmp/xdg-runtime | |
| 22 | 22 | steps: | |
| 23 | 23 | # Libraries loaded at runtime through purego; vulkan-swrast (lavapipe) | |
| 24 | - | # gives tests a Vulkan device; gcc is for the race detector. | |
| 24 | + | # gives tests a Vulkan device; gcc is for the race detector; dbus | |
| 25 | + | # gives the screensaver tests a private bus. | |
| 25 | 26 | - run: >- | |
| 26 | 27 | pacman -Syu --noconfirm --needed | |
| 27 | 28 | git go make gcc shaderc wayland libxkbcommon libinput seatd | |
| 28 | - | vulkan-icd-loader vulkan-swrast | |
| 29 | + | vulkan-icd-loader vulkan-swrast dbus | |
| 29 | 30 | - uses: actions/checkout@v4 | |
| 30 | 31 | - run: | | |
| 31 | 32 | git config --global --add safe.directory "$GITHUB_WORKSPACE" | |
| @@ -21,11 +21,12 @@ jobs: | |||||
| 21 | XDG_RUNTIME_DIR: /tmp/xdg-runtime | 21 | XDG_RUNTIME_DIR: /tmp/xdg-runtime | ||
| 22 | steps: | 22 | steps: | ||
| 23 | # Libraries loaded at runtime through purego; vulkan-swrast (lavapipe) | 23 | # Libraries loaded at runtime through purego; vulkan-swrast (lavapipe) | ||
| 24 | - | # gives tests a Vulkan device; gcc is for the race detector. | 24 | + | # gives tests a Vulkan device; gcc is for the race detector; dbus |
| 25 | + | # gives the screensaver tests a private bus. | |||
| 25 | - run: >- | 26 | - run: >- | ||
| 26 | pacman -Syu --noconfirm --needed | 27 | pacman -Syu --noconfirm --needed | ||
| 27 | git go make gcc shaderc wayland libxkbcommon libinput seatd | 28 | git go make gcc shaderc wayland libxkbcommon libinput seatd | ||
| 28 | - | vulkan-icd-loader vulkan-swrast | 29 | + | vulkan-icd-loader vulkan-swrast dbus |
| 29 | - uses: actions/checkout@v4 | 30 | - uses: actions/checkout@v4 | ||
| 30 | - run: | | 31 | - run: | | ||
| 31 | git config --global --add safe.directory "$GITHUB_WORKSPACE" | 32 | git config --global --add safe.directory "$GITHUB_WORKSPACE" | ||
M docs/desktop.md +2 -0
| @@ -79,6 +79,8 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext | |||
| 79 | 79 | ||
| 80 | 80 | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | |
| 81 | 81 | ||
| 82 | + | Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back. | |
| 83 | + | ||
| 82 | 84 | ```text | |
| 83 | 85 | startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | |
| 84 | 86 | ``` | |
| @@ -79,6 +79,8 @@ nefercap, `grim`, recorders and screen sharing use `zwlr_screencopy_v1` and `ext | |||||
| 79 | 79 | ||||
| 80 | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | 80 | NeferWL supports `ext_idle_notifier_v1` and `zwlr_output_power_management_v1`, so [swayidle](https://github.com/swaywm/swayidle) and [wlopm](https://git.sr.ht/~leon_plickat/wlopm) turn the screens off after a delay. A window that inhibits idle, such as a video player or a game, keeps them on. | ||
| 81 | 81 | ||||
| 82 | + | Programs that inhibit idle over D-Bus instead (browsers, Electron apps, VLC) are covered too: NeferWL serves `org.freedesktop.ScreenSaver` on the session bus. The desktop portal's `Inhibit` reaches it only through a portal backend that forwards to that name, such as `xdg-desktop-portal-gtk`; `xdg-desktop-portal-wlr` does not implement `Inhibit`. An inhibition ends when the program releases it or leaves the bus, and `SimulateUserActivity` counts as input. If another program owns the name, or the session bus is missing, NeferWL logs a warning and tries again later, so it takes over once the name is free or the bus is back. | |||
| 83 | + | ||||
| 82 | ```text | 84 | ```text | ||
| 83 | startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | 85 | startup = swayidle -w timeout 300 wlopm-off resume wlopm-on | ||
| 84 | ``` | 86 | ``` | ||
M go.mod +1 -0
| @@ -9,6 +9,7 @@ require ( | |||
| 9 | 9 | github.com/bnema/purego-vulkan v0.6.0 | |
| 10 | 10 | github.com/bnema/wlturbo v0.5.0 | |
| 11 | 11 | github.com/bnema/zerowrap v1.4.1 | |
| 12 | + | github.com/godbus/dbus/v5 v5.2.2 | |
| 12 | 13 | github.com/rs/zerolog v1.35.1 | |
| 13 | 14 | github.com/stretchr/testify v1.12.1 | |
| 14 | 15 | golang.org/x/sys v0.48.0 | |
| @@ -9,6 +9,7 @@ require ( | |||||
| 9 | github.com/bnema/purego-vulkan v0.6.0 | 9 | github.com/bnema/purego-vulkan v0.6.0 | ||
| 10 | github.com/bnema/wlturbo v0.5.0 | 10 | github.com/bnema/wlturbo v0.5.0 | ||
| 11 | github.com/bnema/zerowrap v1.4.1 | 11 | github.com/bnema/zerowrap v1.4.1 | ||
| 12 | + | github.com/godbus/dbus/v5 v5.2.2 | |||
| 12 | github.com/rs/zerolog v1.35.1 | 13 | github.com/rs/zerolog v1.35.1 | ||
| 13 | github.com/stretchr/testify v1.12.1 | 14 | github.com/stretchr/testify v1.12.1 | ||
| 14 | golang.org/x/sys v0.48.0 | 15 | golang.org/x/sys v0.48.0 | ||
M go.sum +2 -0 generated file: collapsed
A internal/adapters/screensaver/registry.go +64 -0
| @@ -0,0 +1,64 @@ | |||
| 1 | + | package screensaver | |
| 2 | + | ||
| 3 | + | // maxPerSender bounds the cookies one D-Bus connection may hold, so a | |
| 4 | + | // looping client cannot grow the registry without limit. It is generous: | |
| 5 | + | // the GTK portal holds the cookies of every app that inhibits through it. | |
| 6 | + | const maxPerSender = 1024 | |
| 7 | + | ||
| 8 | + | // registry is the inhibitions in effect: cookie → owning connection. The | |
| 9 | + | // session stays awake while it is not empty. It is owned by Run's goroutine. | |
| 10 | + | type registry struct { | |
| 11 | + | next uint32 | |
| 12 | + | cookies map[uint32]string | |
| 13 | + | counts map[string]int | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func newRegistry() *registry { | |
| 17 | + | return ®istry{cookies: map[uint32]string{}, counts: map[string]int{}} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // inhibit adds a cookie for sender. ok is false when sender holds too many; | |
| 21 | + | // first is set when sender had none, so the caller checks it is still on | |
| 22 | + | // the bus (it may have left before its call was handled). | |
| 23 | + | func (r *registry) inhibit(sender string) (cookie uint32, first, ok bool) { | |
| 24 | + | if r.counts[sender] >= maxPerSender { | |
| 25 | + | return 0, false, false | |
| 26 | + | } | |
| 27 | + | for { | |
| 28 | + | r.next++ | |
| 29 | + | if _, used := r.cookies[r.next]; r.next != 0 && !used { | |
| 30 | + | break | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | r.cookies[r.next] = sender | |
| 34 | + | r.counts[sender]++ | |
| 35 | + | return r.next, r.counts[sender] == 1, true | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // uninhibit removes cookie if sender owns it: one client cannot release | |
| 39 | + | // another's inhibition. | |
| 40 | + | func (r *registry) uninhibit(sender string, cookie uint32) bool { | |
| 41 | + | if owner, ok := r.cookies[cookie]; !ok || owner != sender { | |
| 42 | + | return false | |
| 43 | + | } | |
| 44 | + | delete(r.cookies, cookie) | |
| 45 | + | if r.counts[sender]--; r.counts[sender] == 0 { | |
| 46 | + | delete(r.counts, sender) | |
| 47 | + | } | |
| 48 | + | return true | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | // drop removes every cookie of a connection that left the bus. | |
| 52 | + | func (r *registry) drop(sender string) { | |
| 53 | + | if r.counts[sender] == 0 { | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | for cookie, owner := range r.cookies { | |
| 57 | + | if owner == sender { | |
| 58 | + | delete(r.cookies, cookie) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | delete(r.counts, sender) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (r *registry) held() bool { return len(r.cookies) > 0 } | |
| @@ -0,0 +1,64 @@ | |||||
| 1 | + | package screensaver | |||
| 2 | + | ||||
| 3 | + | // maxPerSender bounds the cookies one D-Bus connection may hold, so a | |||
| 4 | + | // looping client cannot grow the registry without limit. It is generous: | |||
| 5 | + | // the GTK portal holds the cookies of every app that inhibits through it. | |||
| 6 | + | const maxPerSender = 1024 | |||
| 7 | + | ||||
| 8 | + | // registry is the inhibitions in effect: cookie → owning connection. The | |||
| 9 | + | // session stays awake while it is not empty. It is owned by Run's goroutine. | |||
| 10 | + | type registry struct { | |||
| 11 | + | next uint32 | |||
| 12 | + | cookies map[uint32]string | |||
| 13 | + | counts map[string]int | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func newRegistry() *registry { | |||
| 17 | + | return ®istry{cookies: map[uint32]string{}, counts: map[string]int{}} | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // inhibit adds a cookie for sender. ok is false when sender holds too many; | |||
| 21 | + | // first is set when sender had none, so the caller checks it is still on | |||
| 22 | + | // the bus (it may have left before its call was handled). | |||
| 23 | + | func (r *registry) inhibit(sender string) (cookie uint32, first, ok bool) { | |||
| 24 | + | if r.counts[sender] >= maxPerSender { | |||
| 25 | + | return 0, false, false | |||
| 26 | + | } | |||
| 27 | + | for { | |||
| 28 | + | r.next++ | |||
| 29 | + | if _, used := r.cookies[r.next]; r.next != 0 && !used { | |||
| 30 | + | break | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | r.cookies[r.next] = sender | |||
| 34 | + | r.counts[sender]++ | |||
| 35 | + | return r.next, r.counts[sender] == 1, true | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // uninhibit removes cookie if sender owns it: one client cannot release | |||
| 39 | + | // another's inhibition. | |||
| 40 | + | func (r *registry) uninhibit(sender string, cookie uint32) bool { | |||
| 41 | + | if owner, ok := r.cookies[cookie]; !ok || owner != sender { | |||
| 42 | + | return false | |||
| 43 | + | } | |||
| 44 | + | delete(r.cookies, cookie) | |||
| 45 | + | if r.counts[sender]--; r.counts[sender] == 0 { | |||
| 46 | + | delete(r.counts, sender) | |||
| 47 | + | } | |||
| 48 | + | return true | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // drop removes every cookie of a connection that left the bus. | |||
| 52 | + | func (r *registry) drop(sender string) { | |||
| 53 | + | if r.counts[sender] == 0 { | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | for cookie, owner := range r.cookies { | |||
| 57 | + | if owner == sender { | |||
| 58 | + | delete(r.cookies, cookie) | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | delete(r.counts, sender) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (r *registry) held() bool { return len(r.cookies) > 0 } | |||
A internal/adapters/screensaver/registry_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package screensaver | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestRegistry(t *testing.T) { | |
| 6 | + | r := newRegistry() | |
| 7 | + | a1, first, ok := r.inhibit(":1.1") | |
| 8 | + | if !ok || !first || !r.held() { | |
| 9 | + | t.Fatal("first inhibit") | |
| 10 | + | } | |
| 11 | + | a2, first, _ := r.inhibit(":1.1") | |
| 12 | + | if first || a2 == a1 { | |
| 13 | + | t.Fatalf("second inhibit: first %v, cookies %d %d", first, a1, a2) | |
| 14 | + | } | |
| 15 | + | b, first, _ := r.inhibit(":1.2") | |
| 16 | + | if !first { | |
| 17 | + | t.Fatal("other sender not first") | |
| 18 | + | } | |
| 19 | + | // Another connection cannot release a cookie it does not own. | |
| 20 | + | if r.uninhibit(":1.2", a1) || r.uninhibit(":1.1", 999) { | |
| 21 | + | t.Fatal("foreign or unknown cookie released") | |
| 22 | + | } | |
| 23 | + | if !r.uninhibit(":1.1", a1) || !r.held() { | |
| 24 | + | t.Fatal("uninhibit") | |
| 25 | + | } | |
| 26 | + | r.drop(":1.1") | |
| 27 | + | if !r.held() { | |
| 28 | + | t.Fatal("drop removed another sender's cookie") | |
| 29 | + | } | |
| 30 | + | if !r.uninhibit(":1.2", b) || r.held() { | |
| 31 | + | t.Fatal("still held") | |
| 32 | + | } | |
| 33 | + | if _, first, _ := r.inhibit(":1.1"); !first { | |
| 34 | + | t.Fatal("dropped sender is not first again") | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestRegistryBoundsSender(t *testing.T) { | |
| 39 | + | r := newRegistry() | |
| 40 | + | for range maxPerSender { | |
| 41 | + | if _, _, ok := r.inhibit(":1.1"); !ok { | |
| 42 | + | t.Fatal("refused under the bound") | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | if _, _, ok := r.inhibit(":1.1"); ok { | |
| 46 | + | t.Fatal("accepted over the bound") | |
| 47 | + | } | |
| 48 | + | if _, _, ok := r.inhibit(":1.2"); !ok { | |
| 49 | + | t.Fatal("bound is per sender") | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestRegistryCookieWrapSkipsZeroAndUsed(t *testing.T) { | |
| 54 | + | r := newRegistry() | |
| 55 | + | r.next = ^uint32(0) - 1 | |
| 56 | + | c1, _, _ := r.inhibit(":1.1") // max | |
| 57 | + | c2, _, _ := r.inhibit(":1.1") // wraps past 0 | |
| 58 | + | if c1 != ^uint32(0) || c2 != 1 { | |
| 59 | + | t.Fatalf("cookies %d %d", c1, c2) | |
| 60 | + | } | |
| 61 | + | r.next = 0 | |
| 62 | + | if c3, _, _ := r.inhibit(":1.1"); c3 != 2 { | |
| 63 | + | t.Fatalf("reused cookie %d", c3) | |
| 64 | + | } | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package screensaver | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestRegistry(t *testing.T) { | |||
| 6 | + | r := newRegistry() | |||
| 7 | + | a1, first, ok := r.inhibit(":1.1") | |||
| 8 | + | if !ok || !first || !r.held() { | |||
| 9 | + | t.Fatal("first inhibit") | |||
| 10 | + | } | |||
| 11 | + | a2, first, _ := r.inhibit(":1.1") | |||
| 12 | + | if first || a2 == a1 { | |||
| 13 | + | t.Fatalf("second inhibit: first %v, cookies %d %d", first, a1, a2) | |||
| 14 | + | } | |||
| 15 | + | b, first, _ := r.inhibit(":1.2") | |||
| 16 | + | if !first { | |||
| 17 | + | t.Fatal("other sender not first") | |||
| 18 | + | } | |||
| 19 | + | // Another connection cannot release a cookie it does not own. | |||
| 20 | + | if r.uninhibit(":1.2", a1) || r.uninhibit(":1.1", 999) { | |||
| 21 | + | t.Fatal("foreign or unknown cookie released") | |||
| 22 | + | } | |||
| 23 | + | if !r.uninhibit(":1.1", a1) || !r.held() { | |||
| 24 | + | t.Fatal("uninhibit") | |||
| 25 | + | } | |||
| 26 | + | r.drop(":1.1") | |||
| 27 | + | if !r.held() { | |||
| 28 | + | t.Fatal("drop removed another sender's cookie") | |||
| 29 | + | } | |||
| 30 | + | if !r.uninhibit(":1.2", b) || r.held() { | |||
| 31 | + | t.Fatal("still held") | |||
| 32 | + | } | |||
| 33 | + | if _, first, _ := r.inhibit(":1.1"); !first { | |||
| 34 | + | t.Fatal("dropped sender is not first again") | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestRegistryBoundsSender(t *testing.T) { | |||
| 39 | + | r := newRegistry() | |||
| 40 | + | for range maxPerSender { | |||
| 41 | + | if _, _, ok := r.inhibit(":1.1"); !ok { | |||
| 42 | + | t.Fatal("refused under the bound") | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | if _, _, ok := r.inhibit(":1.1"); ok { | |||
| 46 | + | t.Fatal("accepted over the bound") | |||
| 47 | + | } | |||
| 48 | + | if _, _, ok := r.inhibit(":1.2"); !ok { | |||
| 49 | + | t.Fatal("bound is per sender") | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestRegistryCookieWrapSkipsZeroAndUsed(t *testing.T) { | |||
| 54 | + | r := newRegistry() | |||
| 55 | + | r.next = ^uint32(0) - 1 | |||
| 56 | + | c1, _, _ := r.inhibit(":1.1") // max | |||
| 57 | + | c2, _, _ := r.inhibit(":1.1") // wraps past 0 | |||
| 58 | + | if c1 != ^uint32(0) || c2 != 1 { | |||
| 59 | + | t.Fatalf("cookies %d %d", c1, c2) | |||
| 60 | + | } | |||
| 61 | + | r.next = 0 | |||
| 62 | + | if c3, _, _ := r.inhibit(":1.1"); c3 != 2 { | |||
| 63 | + | t.Fatalf("reused cookie %d", c3) | |||
| 64 | + | } | |||
| 65 | + | } | |||
A internal/adapters/screensaver/screensaver.go +335 -0
| @@ -0,0 +1,335 @@ | |||
| 1 | + | // Package screensaver serves org.freedesktop.ScreenSaver on the session bus, | |
| 2 | + | // so programs that inhibit idle over D-Bus (browsers, video players, the GTK | |
| 3 | + | // portal's Inhibit) keep the session awake like a zwp_idle_inhibit_v1 | |
| 4 | + | // surface. It only reports whether any inhibition is held, and activity | |
| 5 | + | // simulated by SimulateUserActivity: wayland decides what they do. | |
| 6 | + | package screensaver | |
| 7 | + | ||
| 8 | + | import ( | |
| 9 | + | "context" | |
| 10 | + | "errors" | |
| 11 | + | "fmt" | |
| 12 | + | "strings" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/zerowrap" | |
| 16 | + | "github.com/godbus/dbus/v5" | |
| 17 | + | "github.com/godbus/dbus/v5/introspect" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | const ( | |
| 21 | + | busName = "org.freedesktop.ScreenSaver" | |
| 22 | + | iface = "org.freedesktop.ScreenSaver" | |
| 23 | + | // busDaemon is the sender of the bus's own signals. Any client can send | |
| 24 | + | // a NameOwnerChanged to us directly, so only this sender is trusted. | |
| 25 | + | busDaemon = "org.freedesktop.DBus" | |
| 26 | + | // callTimeout bounds the bus calls Run makes itself. | |
| 27 | + | callTimeout = 3 * time.Second | |
| 28 | + | // retryMax bounds the delay between Serve's attempts. | |
| 29 | + | retryMax = time.Minute | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // ErrNameTaken is returned by Run when another program owns the name. | |
| 33 | + | var ErrNameTaken = errors.New(busName + " is owned by another program") | |
| 34 | + | ||
| 35 | + | // paths: freedesktop clients use the first, older KDE-era ones the second. | |
| 36 | + | var paths = []dbus.ObjectPath{"/org/freedesktop/ScreenSaver", "/ScreenSaver"} | |
| 37 | + | ||
| 38 | + | var methods = []introspect.Method{ | |
| 39 | + | {Name: "Inhibit", Args: []introspect.Arg{{Name: "application_name", Type: "s", Direction: "in"}, {Name: "reason_for_inhibit", Type: "s", Direction: "in"}, {Name: "cookie", Type: "u", Direction: "out"}}}, | |
| 40 | + | {Name: "UnInhibit", Args: []introspect.Arg{{Name: "cookie", Type: "u", Direction: "in"}}}, | |
| 41 | + | {Name: "GetActive", Args: []introspect.Arg{{Type: "b", Direction: "out"}}}, | |
| 42 | + | {Name: "GetActiveTime", Args: []introspect.Arg{{Type: "u", Direction: "out"}}}, | |
| 43 | + | {Name: "SetActive", Args: []introspect.Arg{{Name: "e", Type: "b", Direction: "in"}, {Type: "b", Direction: "out"}}}, | |
| 44 | + | {Name: "SimulateUserActivity"}, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Reports are what Run tells wayland. Held carries each change of whether | |
| 48 | + | // any inhibition is in effect; Activity carries SimulateUserActivity calls, | |
| 49 | + | // coalesced: a send never blocks, so give it a buffer of one. | |
| 50 | + | type Reports struct { | |
| 51 | + | Held chan<- bool | |
| 52 | + | Activity chan<- struct{} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Serve runs the service until ctx ends. When the bus goes or another | |
| 56 | + | // program owns the name, it tries again after retry, doubling up to | |
| 57 | + | // retryMax, so a restarted bus or a released name is picked up. | |
| 58 | + | func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) { | |
| 59 | + | delay := retry | |
| 60 | + | var last string | |
| 61 | + | for { | |
| 62 | + | start := time.Now() | |
| 63 | + | s, err := New(ctx, address, log) | |
| 64 | + | if err == nil { | |
| 65 | + | err = s.Run(ctx, r) | |
| 66 | + | } | |
| 67 | + | if ctx.Err() != nil { | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | if err == nil { | |
| 71 | + | err = errors.New("screensaver service stopped") | |
| 72 | + | } | |
| 73 | + | if time.Since(start) > retryMax { | |
| 74 | + | // It served a while: the failure is new, not a retry streak. | |
| 75 | + | delay, last = retry, "" | |
| 76 | + | } | |
| 77 | + | // Warn once per kind of failure, not on every attempt. | |
| 78 | + | if msg := err.Error(); msg != last { | |
| 79 | + | last = msg | |
| 80 | + | log.Warn().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors ignored until the next attempt") | |
| 81 | + | } else { | |
| 82 | + | log.Debug().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors still ignored") | |
| 83 | + | } | |
| 84 | + | select { | |
| 85 | + | case <-ctx.Done(): | |
| 86 | + | return | |
| 87 | + | case <-time.After(delay): | |
| 88 | + | } | |
| 89 | + | delay = min(2*delay, retryMax) | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Service owns one private session bus connection. Run must be called once. | |
| 94 | + | type Service struct { | |
| 95 | + | conn *dbus.Conn | |
| 96 | + | log zerowrap.Logger | |
| 97 | + | requests chan request | |
| 98 | + | done chan struct{} | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // request is a method call handed from a godbus goroutine to Run. | |
| 102 | + | type request struct { | |
| 103 | + | sender string | |
| 104 | + | cookie uint32 // UnInhibit | |
| 105 | + | unhold bool | |
| 106 | + | app string | |
| 107 | + | reason string | |
| 108 | + | reply chan reply | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | type reply struct { | |
| 112 | + | cookie uint32 | |
| 113 | + | err *dbus.Error | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // New connects to the bus at address, the session bus when empty. It never | |
| 117 | + | // starts a bus. | |
| 118 | + | func New(ctx context.Context, address string, log zerowrap.Logger) (*Service, error) { | |
| 119 | + | var conn *dbus.Conn | |
| 120 | + | var err error | |
| 121 | + | if address == "" { | |
| 122 | + | conn, err = dbus.SessionBusPrivateNoAutoStartup(dbus.WithContext(ctx)) | |
| 123 | + | } else { | |
| 124 | + | conn, err = dbus.Dial(address, dbus.WithContext(ctx)) | |
| 125 | + | } | |
| 126 | + | if err != nil { | |
| 127 | + | return nil, fmt.Errorf("session bus: %w", err) | |
| 128 | + | } | |
| 129 | + | if err = conn.Auth(nil); err == nil { | |
| 130 | + | err = conn.Hello() | |
| 131 | + | } | |
| 132 | + | if err != nil { | |
| 133 | + | _ = conn.Close() | |
| 134 | + | return nil, fmt.Errorf("session bus: %w", err) | |
| 135 | + | } | |
| 136 | + | return &Service{conn: conn, log: log, requests: make(chan request), done: make(chan struct{})}, nil | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // Run serves the name until ctx ends or the bus goes, and reports to r. If | |
| 140 | + | // the bus goes while an inhibition is held, it sends false on r.Held before | |
| 141 | + | // it returns. It returns nil only once ctx ends, and ErrNameTaken at once, | |
| 142 | + | // with the connection closed, when another program owns the name. | |
| 143 | + | func (s *Service) Run(ctx context.Context, r Reports) (err error) { | |
| 144 | + | defer close(s.done) | |
| 145 | + | defer func() { _ = s.conn.Close() }() | |
| 146 | + | defer func() { | |
| 147 | + | // A bus call cut short by ctx (WithContext closes the connection) | |
| 148 | + | // is shutdown, not a failure. | |
| 149 | + | if ctx.Err() != nil { | |
| 150 | + | err = nil | |
| 151 | + | } | |
| 152 | + | }() | |
| 153 | + | // Method tables, not Export(v): godbus finds Export's methods by | |
| 154 | + | // reflection, which stops the linker from pruning unused methods and | |
| 155 | + | // grows the binary by ~4 MB. | |
| 156 | + | h := handler{s: s, activity: r.Activity} | |
| 157 | + | table := map[string]any{ | |
| 158 | + | "Inhibit": h.Inhibit, | |
| 159 | + | "UnInhibit": h.UnInhibit, | |
| 160 | + | "GetActive": h.GetActive, | |
| 161 | + | "GetActiveTime": h.GetActiveTime, | |
| 162 | + | "SetActive": h.SetActive, | |
| 163 | + | "SimulateUserActivity": h.SimulateUserActivity, | |
| 164 | + | } | |
| 165 | + | for _, p := range paths { | |
| 166 | + | if err := s.conn.ExportMethodTable(table, p, iface); err != nil { | |
| 167 | + | return fmt.Errorf("export %s: %w", p, err) | |
| 168 | + | } | |
| 169 | + | node := &introspect.Node{Name: string(p), Interfaces: []introspect.Interface{{Name: iface, Methods: methods}}} | |
| 170 | + | xml := string(introspect.NewIntrospectable(node)) | |
| 171 | + | intro := map[string]any{"Introspect": func() (string, *dbus.Error) { return xml, nil }} | |
| 172 | + | if err := s.conn.ExportMethodTable(intro, p, "org.freedesktop.DBus.Introspectable"); err != nil { | |
| 173 | + | return fmt.Errorf("export %s introspection: %w", p, err) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | owner, err := s.conn.RequestName(busName, dbus.NameFlagDoNotQueue) | |
| 177 | + | if err != nil { | |
| 178 | + | return fmt.Errorf("request %s: %w", busName, err) | |
| 179 | + | } | |
| 180 | + | if owner != dbus.RequestNameReplyPrimaryOwner { | |
| 181 | + | return ErrNameTaken | |
| 182 | + | } | |
| 183 | + | // Departures end the inhibitions of crashed clients. The match asks the | |
| 184 | + | // bus for lost owners (new owner "") only, but the channel also gets | |
| 185 | + | // NameAcquired and any signal a client sends to us directly: left trusts | |
| 186 | + | // only the bus's own. A client that leaves before this match exists is | |
| 187 | + | // caught by the onBus check on its first cookie. | |
| 188 | + | signals := make(chan *dbus.Signal, 16) | |
| 189 | + | s.conn.Signal(signals) | |
| 190 | + | if err := s.conn.AddMatchSignalContext(ctx, dbus.WithMatchSender(busDaemon), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(2, "")); err != nil { | |
| 191 | + | return fmt.Errorf("watch bus names: %w", err) | |
| 192 | + | } | |
| 193 | + | s.log.Info().Msg("serving " + busName) | |
| 194 | + | reg := newRegistry() | |
| 195 | + | sent := false | |
| 196 | + | report := func() { | |
| 197 | + | if h := reg.held(); h != sent { | |
| 198 | + | select { | |
| 199 | + | case r.Held <- h: | |
| 200 | + | sent = h | |
| 201 | + | case <-ctx.Done(): | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | defer func() { | |
| 206 | + | if sent && ctx.Err() == nil { | |
| 207 | + | // The bus went, the session goes on: nothing it held keeps it awake. | |
| 208 | + | select { | |
| 209 | + | case r.Held <- false: | |
| 210 | + | case <-ctx.Done(): | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | }() | |
| 214 | + | for { | |
| 215 | + | select { | |
| 216 | + | case <-ctx.Done(): | |
| 217 | + | return nil | |
| 218 | + | case sig, ok := <-signals: | |
| 219 | + | if !ok { | |
| 220 | + | if ctx.Err() != nil { | |
| 221 | + | return nil // WithContext closed the connection | |
| 222 | + | } | |
| 223 | + | return errors.New("session bus connection closed") | |
| 224 | + | } | |
| 225 | + | if name, gone := left(sig); gone { | |
| 226 | + | reg.drop(name) | |
| 227 | + | report() | |
| 228 | + | } | |
| 229 | + | case req := <-s.requests: | |
| 230 | + | if req.unhold { | |
| 231 | + | if reg.uninhibit(req.sender, req.cookie) { | |
| 232 | + | s.log.Debug().Str("sender", req.sender).Uint32("cookie", req.cookie).Msg("uninhibit") | |
| 233 | + | } | |
| 234 | + | req.reply <- reply{} | |
| 235 | + | report() | |
| 236 | + | continue | |
| 237 | + | } | |
| 238 | + | cookie, first, ok := reg.inhibit(req.sender) | |
| 239 | + | if !ok { | |
| 240 | + | req.reply <- reply{err: dbus.MakeFailedError(errors.New("too many inhibitors"))} | |
| 241 | + | continue | |
| 242 | + | } | |
| 243 | + | req.reply <- reply{cookie: cookie} | |
| 244 | + | if first && !s.onBus(ctx, req.sender) { | |
| 245 | + | // It left before its call reached us (its signal came first), | |
| 246 | + | // or the bus could not tell: drop it rather than hold the | |
| 247 | + | // session awake for good. | |
| 248 | + | s.log.Debug().Str("sender", req.sender).Uint32("cookie", cookie).Msg("inhibitor not on the bus; dropped") | |
| 249 | + | reg.drop(req.sender) | |
| 250 | + | } else { | |
| 251 | + | s.log.Debug().Str("sender", req.sender).Str("app", req.app).Str("reason", req.reason).Uint32("cookie", cookie).Msg("inhibit") | |
| 252 | + | } | |
| 253 | + | report() | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // onBus reports whether a unique connection name still has an owner. Its | |
| 259 | + | // departure signal may already be handled, so an unanswered query is tried | |
| 260 | + | // once more, then counts as gone: a lost inhibition is better than one that | |
| 261 | + | // keeps the session awake until the compositor restarts. | |
| 262 | + | func (s *Service) onBus(ctx context.Context, name string) bool { | |
| 263 | + | for range 2 { | |
| 264 | + | callCtx, cancel := context.WithTimeout(ctx, callTimeout) | |
| 265 | + | var has bool | |
| 266 | + | err := s.conn.BusObject().CallWithContext(callCtx, "org.freedesktop.DBus.NameHasOwner", 0, name).Store(&has) | |
| 267 | + | cancel() | |
| 268 | + | if err == nil { | |
| 269 | + | return has | |
| 270 | + | } | |
| 271 | + | s.log.Debug().Err(err).Str("sender", name).Msg("NameHasOwner failed") | |
| 272 | + | if ctx.Err() != nil { | |
| 273 | + | break | |
| 274 | + | } | |
| 275 | + | } | |
| 276 | + | return false | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | // left reports a unique connection name that lost its owner, from the | |
| 280 | + | // bus's own NameOwnerChanged only. | |
| 281 | + | func left(sig *dbus.Signal) (string, bool) { | |
| 282 | + | if sig.Sender != busDaemon || sig.Name != "org.freedesktop.DBus.NameOwnerChanged" || len(sig.Body) != 3 { | |
| 283 | + | return "", false | |
| 284 | + | } | |
| 285 | + | name, _ := sig.Body[0].(string) | |
| 286 | + | owner, _ := sig.Body[2].(string) | |
| 287 | + | return name, strings.HasPrefix(name, ":") && owner == "" | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // call hands a request to Run, or fails once Run has stopped. | |
| 291 | + | func (s *Service) call(req request) reply { | |
| 292 | + | req.reply = make(chan reply, 1) | |
| 293 | + | select { | |
| 294 | + | case s.requests <- req: | |
| 295 | + | return <-req.reply | |
| 296 | + | case <-s.done: | |
| 297 | + | return reply{err: dbus.MakeFailedError(errors.New("screensaver service stopped"))} | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // handler is the exported object; godbus runs each call on its own | |
| 302 | + | // goroutine, so it only forwards to Run. | |
| 303 | + | type handler struct { | |
| 304 | + | s *Service | |
| 305 | + | activity chan<- struct{} | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func (h handler) Inhibit(sender dbus.Sender, app, reason string) (uint32, *dbus.Error) { | |
| 309 | + | r := h.s.call(request{sender: string(sender), app: app, reason: reason}) | |
| 310 | + | return r.cookie, r.err | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (h handler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error { | |
| 314 | + | return h.s.call(request{sender: string(sender), cookie: cookie, unhold: true}).err | |
| 315 | + | } | |
| 316 | + | ||
| 317 | + | // GetActive: there is no screensaver to be active, only idle inhibition. | |
| 318 | + | func (handler) GetActive() (bool, *dbus.Error) { return false, nil } | |
| 319 | + | ||
| 320 | + | // GetActiveTime is the seconds the screensaver has been active: never. | |
| 321 | + | func (handler) GetActiveTime() (uint32, *dbus.Error) { return 0, nil } | |
| 322 | + | ||
| 323 | + | // SetActive cannot start a screensaver there is none of: it reports | |
| 324 | + | // failure, as the specification allows. | |
| 325 | + | func (handler) SetActive(bool) (bool, *dbus.Error) { return false, nil } | |
| 326 | + | ||
| 327 | + | // SimulateUserActivity counts as input: idle notifications resume and | |
| 328 | + | // their timers restart. Calls between two reads collapse into one. | |
| 329 | + | func (h handler) SimulateUserActivity() *dbus.Error { | |
| 330 | + | select { | |
| 331 | + | case h.activity <- struct{}{}: | |
| 332 | + | default: | |
| 333 | + | } | |
| 334 | + | return nil | |
| 335 | + | } | |
| @@ -0,0 +1,335 @@ | |||||
| 1 | + | // Package screensaver serves org.freedesktop.ScreenSaver on the session bus, | |||
| 2 | + | // so programs that inhibit idle over D-Bus (browsers, video players, the GTK | |||
| 3 | + | // portal's Inhibit) keep the session awake like a zwp_idle_inhibit_v1 | |||
| 4 | + | // surface. It only reports whether any inhibition is held, and activity | |||
| 5 | + | // simulated by SimulateUserActivity: wayland decides what they do. | |||
| 6 | + | package screensaver | |||
| 7 | + | ||||
| 8 | + | import ( | |||
| 9 | + | "context" | |||
| 10 | + | "errors" | |||
| 11 | + | "fmt" | |||
| 12 | + | "strings" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/bnema/zerowrap" | |||
| 16 | + | "github.com/godbus/dbus/v5" | |||
| 17 | + | "github.com/godbus/dbus/v5/introspect" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | const ( | |||
| 21 | + | busName = "org.freedesktop.ScreenSaver" | |||
| 22 | + | iface = "org.freedesktop.ScreenSaver" | |||
| 23 | + | // busDaemon is the sender of the bus's own signals. Any client can send | |||
| 24 | + | // a NameOwnerChanged to us directly, so only this sender is trusted. | |||
| 25 | + | busDaemon = "org.freedesktop.DBus" | |||
| 26 | + | // callTimeout bounds the bus calls Run makes itself. | |||
| 27 | + | callTimeout = 3 * time.Second | |||
| 28 | + | // retryMax bounds the delay between Serve's attempts. | |||
| 29 | + | retryMax = time.Minute | |||
| 30 | + | ) | |||
| 31 | + | ||||
| 32 | + | // ErrNameTaken is returned by Run when another program owns the name. | |||
| 33 | + | var ErrNameTaken = errors.New(busName + " is owned by another program") | |||
| 34 | + | ||||
| 35 | + | // paths: freedesktop clients use the first, older KDE-era ones the second. | |||
| 36 | + | var paths = []dbus.ObjectPath{"/org/freedesktop/ScreenSaver", "/ScreenSaver"} | |||
| 37 | + | ||||
| 38 | + | var methods = []introspect.Method{ | |||
| 39 | + | {Name: "Inhibit", Args: []introspect.Arg{{Name: "application_name", Type: "s", Direction: "in"}, {Name: "reason_for_inhibit", Type: "s", Direction: "in"}, {Name: "cookie", Type: "u", Direction: "out"}}}, | |||
| 40 | + | {Name: "UnInhibit", Args: []introspect.Arg{{Name: "cookie", Type: "u", Direction: "in"}}}, | |||
| 41 | + | {Name: "GetActive", Args: []introspect.Arg{{Type: "b", Direction: "out"}}}, | |||
| 42 | + | {Name: "GetActiveTime", Args: []introspect.Arg{{Type: "u", Direction: "out"}}}, | |||
| 43 | + | {Name: "SetActive", Args: []introspect.Arg{{Name: "e", Type: "b", Direction: "in"}, {Type: "b", Direction: "out"}}}, | |||
| 44 | + | {Name: "SimulateUserActivity"}, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Reports are what Run tells wayland. Held carries each change of whether | |||
| 48 | + | // any inhibition is in effect; Activity carries SimulateUserActivity calls, | |||
| 49 | + | // coalesced: a send never blocks, so give it a buffer of one. | |||
| 50 | + | type Reports struct { | |||
| 51 | + | Held chan<- bool | |||
| 52 | + | Activity chan<- struct{} | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Serve runs the service until ctx ends. When the bus goes or another | |||
| 56 | + | // program owns the name, it tries again after retry, doubling up to | |||
| 57 | + | // retryMax, so a restarted bus or a released name is picked up. | |||
| 58 | + | func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) { | |||
| 59 | + | delay := retry | |||
| 60 | + | var last string | |||
| 61 | + | for { | |||
| 62 | + | start := time.Now() | |||
| 63 | + | s, err := New(ctx, address, log) | |||
| 64 | + | if err == nil { | |||
| 65 | + | err = s.Run(ctx, r) | |||
| 66 | + | } | |||
| 67 | + | if ctx.Err() != nil { | |||
| 68 | + | return | |||
| 69 | + | } | |||
| 70 | + | if err == nil { | |||
| 71 | + | err = errors.New("screensaver service stopped") | |||
| 72 | + | } | |||
| 73 | + | if time.Since(start) > retryMax { | |||
| 74 | + | // It served a while: the failure is new, not a retry streak. | |||
| 75 | + | delay, last = retry, "" | |||
| 76 | + | } | |||
| 77 | + | // Warn once per kind of failure, not on every attempt. | |||
| 78 | + | if msg := err.Error(); msg != last { | |||
| 79 | + | last = msg | |||
| 80 | + | log.Warn().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors ignored until the next attempt") | |||
| 81 | + | } else { | |||
| 82 | + | log.Debug().Err(err).Dur("retry", delay).Msg("D-Bus idle inhibitors still ignored") | |||
| 83 | + | } | |||
| 84 | + | select { | |||
| 85 | + | case <-ctx.Done(): | |||
| 86 | + | return | |||
| 87 | + | case <-time.After(delay): | |||
| 88 | + | } | |||
| 89 | + | delay = min(2*delay, retryMax) | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | // Service owns one private session bus connection. Run must be called once. | |||
| 94 | + | type Service struct { | |||
| 95 | + | conn *dbus.Conn | |||
| 96 | + | log zerowrap.Logger | |||
| 97 | + | requests chan request | |||
| 98 | + | done chan struct{} | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // request is a method call handed from a godbus goroutine to Run. | |||
| 102 | + | type request struct { | |||
| 103 | + | sender string | |||
| 104 | + | cookie uint32 // UnInhibit | |||
| 105 | + | unhold bool | |||
| 106 | + | app string | |||
| 107 | + | reason string | |||
| 108 | + | reply chan reply | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | type reply struct { | |||
| 112 | + | cookie uint32 | |||
| 113 | + | err *dbus.Error | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // New connects to the bus at address, the session bus when empty. It never | |||
| 117 | + | // starts a bus. | |||
| 118 | + | func New(ctx context.Context, address string, log zerowrap.Logger) (*Service, error) { | |||
| 119 | + | var conn *dbus.Conn | |||
| 120 | + | var err error | |||
| 121 | + | if address == "" { | |||
| 122 | + | conn, err = dbus.SessionBusPrivateNoAutoStartup(dbus.WithContext(ctx)) | |||
| 123 | + | } else { | |||
| 124 | + | conn, err = dbus.Dial(address, dbus.WithContext(ctx)) | |||
| 125 | + | } | |||
| 126 | + | if err != nil { | |||
| 127 | + | return nil, fmt.Errorf("session bus: %w", err) | |||
| 128 | + | } | |||
| 129 | + | if err = conn.Auth(nil); err == nil { | |||
| 130 | + | err = conn.Hello() | |||
| 131 | + | } | |||
| 132 | + | if err != nil { | |||
| 133 | + | _ = conn.Close() | |||
| 134 | + | return nil, fmt.Errorf("session bus: %w", err) | |||
| 135 | + | } | |||
| 136 | + | return &Service{conn: conn, log: log, requests: make(chan request), done: make(chan struct{})}, nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // Run serves the name until ctx ends or the bus goes, and reports to r. If | |||
| 140 | + | // the bus goes while an inhibition is held, it sends false on r.Held before | |||
| 141 | + | // it returns. It returns nil only once ctx ends, and ErrNameTaken at once, | |||
| 142 | + | // with the connection closed, when another program owns the name. | |||
| 143 | + | func (s *Service) Run(ctx context.Context, r Reports) (err error) { | |||
| 144 | + | defer close(s.done) | |||
| 145 | + | defer func() { _ = s.conn.Close() }() | |||
| 146 | + | defer func() { | |||
| 147 | + | // A bus call cut short by ctx (WithContext closes the connection) | |||
| 148 | + | // is shutdown, not a failure. | |||
| 149 | + | if ctx.Err() != nil { | |||
| 150 | + | err = nil | |||
| 151 | + | } | |||
| 152 | + | }() | |||
| 153 | + | // Method tables, not Export(v): godbus finds Export's methods by | |||
| 154 | + | // reflection, which stops the linker from pruning unused methods and | |||
| 155 | + | // grows the binary by ~4 MB. | |||
| 156 | + | h := handler{s: s, activity: r.Activity} | |||
| 157 | + | table := map[string]any{ | |||
| 158 | + | "Inhibit": h.Inhibit, | |||
| 159 | + | "UnInhibit": h.UnInhibit, | |||
| 160 | + | "GetActive": h.GetActive, | |||
| 161 | + | "GetActiveTime": h.GetActiveTime, | |||
| 162 | + | "SetActive": h.SetActive, | |||
| 163 | + | "SimulateUserActivity": h.SimulateUserActivity, | |||
| 164 | + | } | |||
| 165 | + | for _, p := range paths { | |||
| 166 | + | if err := s.conn.ExportMethodTable(table, p, iface); err != nil { | |||
| 167 | + | return fmt.Errorf("export %s: %w", p, err) | |||
| 168 | + | } | |||
| 169 | + | node := &introspect.Node{Name: string(p), Interfaces: []introspect.Interface{{Name: iface, Methods: methods}}} | |||
| 170 | + | xml := string(introspect.NewIntrospectable(node)) | |||
| 171 | + | intro := map[string]any{"Introspect": func() (string, *dbus.Error) { return xml, nil }} | |||
| 172 | + | if err := s.conn.ExportMethodTable(intro, p, "org.freedesktop.DBus.Introspectable"); err != nil { | |||
| 173 | + | return fmt.Errorf("export %s introspection: %w", p, err) | |||
| 174 | + | } | |||
| 175 | + | } | |||
| 176 | + | owner, err := s.conn.RequestName(busName, dbus.NameFlagDoNotQueue) | |||
| 177 | + | if err != nil { | |||
| 178 | + | return fmt.Errorf("request %s: %w", busName, err) | |||
| 179 | + | } | |||
| 180 | + | if owner != dbus.RequestNameReplyPrimaryOwner { | |||
| 181 | + | return ErrNameTaken | |||
| 182 | + | } | |||
| 183 | + | // Departures end the inhibitions of crashed clients. The match asks the | |||
| 184 | + | // bus for lost owners (new owner "") only, but the channel also gets | |||
| 185 | + | // NameAcquired and any signal a client sends to us directly: left trusts | |||
| 186 | + | // only the bus's own. A client that leaves before this match exists is | |||
| 187 | + | // caught by the onBus check on its first cookie. | |||
| 188 | + | signals := make(chan *dbus.Signal, 16) | |||
| 189 | + | s.conn.Signal(signals) | |||
| 190 | + | if err := s.conn.AddMatchSignalContext(ctx, dbus.WithMatchSender(busDaemon), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(2, "")); err != nil { | |||
| 191 | + | return fmt.Errorf("watch bus names: %w", err) | |||
| 192 | + | } | |||
| 193 | + | s.log.Info().Msg("serving " + busName) | |||
| 194 | + | reg := newRegistry() | |||
| 195 | + | sent := false | |||
| 196 | + | report := func() { | |||
| 197 | + | if h := reg.held(); h != sent { | |||
| 198 | + | select { | |||
| 199 | + | case r.Held <- h: | |||
| 200 | + | sent = h | |||
| 201 | + | case <-ctx.Done(): | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | defer func() { | |||
| 206 | + | if sent && ctx.Err() == nil { | |||
| 207 | + | // The bus went, the session goes on: nothing it held keeps it awake. | |||
| 208 | + | select { | |||
| 209 | + | case r.Held <- false: | |||
| 210 | + | case <-ctx.Done(): | |||
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | }() | |||
| 214 | + | for { | |||
| 215 | + | select { | |||
| 216 | + | case <-ctx.Done(): | |||
| 217 | + | return nil | |||
| 218 | + | case sig, ok := <-signals: | |||
| 219 | + | if !ok { | |||
| 220 | + | if ctx.Err() != nil { | |||
| 221 | + | return nil // WithContext closed the connection | |||
| 222 | + | } | |||
| 223 | + | return errors.New("session bus connection closed") | |||
| 224 | + | } | |||
| 225 | + | if name, gone := left(sig); gone { | |||
| 226 | + | reg.drop(name) | |||
| 227 | + | report() | |||
| 228 | + | } | |||
| 229 | + | case req := <-s.requests: | |||
| 230 | + | if req.unhold { | |||
| 231 | + | if reg.uninhibit(req.sender, req.cookie) { | |||
| 232 | + | s.log.Debug().Str("sender", req.sender).Uint32("cookie", req.cookie).Msg("uninhibit") | |||
| 233 | + | } | |||
| 234 | + | req.reply <- reply{} | |||
| 235 | + | report() | |||
| 236 | + | continue | |||
| 237 | + | } | |||
| 238 | + | cookie, first, ok := reg.inhibit(req.sender) | |||
| 239 | + | if !ok { | |||
| 240 | + | req.reply <- reply{err: dbus.MakeFailedError(errors.New("too many inhibitors"))} | |||
| 241 | + | continue | |||
| 242 | + | } | |||
| 243 | + | req.reply <- reply{cookie: cookie} | |||
| 244 | + | if first && !s.onBus(ctx, req.sender) { | |||
| 245 | + | // It left before its call reached us (its signal came first), | |||
| 246 | + | // or the bus could not tell: drop it rather than hold the | |||
| 247 | + | // session awake for good. | |||
| 248 | + | s.log.Debug().Str("sender", req.sender).Uint32("cookie", cookie).Msg("inhibitor not on the bus; dropped") | |||
| 249 | + | reg.drop(req.sender) | |||
| 250 | + | } else { | |||
| 251 | + | s.log.Debug().Str("sender", req.sender).Str("app", req.app).Str("reason", req.reason).Uint32("cookie", cookie).Msg("inhibit") | |||
| 252 | + | } | |||
| 253 | + | report() | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // onBus reports whether a unique connection name still has an owner. Its | |||
| 259 | + | // departure signal may already be handled, so an unanswered query is tried | |||
| 260 | + | // once more, then counts as gone: a lost inhibition is better than one that | |||
| 261 | + | // keeps the session awake until the compositor restarts. | |||
| 262 | + | func (s *Service) onBus(ctx context.Context, name string) bool { | |||
| 263 | + | for range 2 { | |||
| 264 | + | callCtx, cancel := context.WithTimeout(ctx, callTimeout) | |||
| 265 | + | var has bool | |||
| 266 | + | err := s.conn.BusObject().CallWithContext(callCtx, "org.freedesktop.DBus.NameHasOwner", 0, name).Store(&has) | |||
| 267 | + | cancel() | |||
| 268 | + | if err == nil { | |||
| 269 | + | return has | |||
| 270 | + | } | |||
| 271 | + | s.log.Debug().Err(err).Str("sender", name).Msg("NameHasOwner failed") | |||
| 272 | + | if ctx.Err() != nil { | |||
| 273 | + | break | |||
| 274 | + | } | |||
| 275 | + | } | |||
| 276 | + | return false | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | // left reports a unique connection name that lost its owner, from the | |||
| 280 | + | // bus's own NameOwnerChanged only. | |||
| 281 | + | func left(sig *dbus.Signal) (string, bool) { | |||
| 282 | + | if sig.Sender != busDaemon || sig.Name != "org.freedesktop.DBus.NameOwnerChanged" || len(sig.Body) != 3 { | |||
| 283 | + | return "", false | |||
| 284 | + | } | |||
| 285 | + | name, _ := sig.Body[0].(string) | |||
| 286 | + | owner, _ := sig.Body[2].(string) | |||
| 287 | + | return name, strings.HasPrefix(name, ":") && owner == "" | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // call hands a request to Run, or fails once Run has stopped. | |||
| 291 | + | func (s *Service) call(req request) reply { | |||
| 292 | + | req.reply = make(chan reply, 1) | |||
| 293 | + | select { | |||
| 294 | + | case s.requests <- req: | |||
| 295 | + | return <-req.reply | |||
| 296 | + | case <-s.done: | |||
| 297 | + | return reply{err: dbus.MakeFailedError(errors.New("screensaver service stopped"))} | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // handler is the exported object; godbus runs each call on its own | |||
| 302 | + | // goroutine, so it only forwards to Run. | |||
| 303 | + | type handler struct { | |||
| 304 | + | s *Service | |||
| 305 | + | activity chan<- struct{} | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func (h handler) Inhibit(sender dbus.Sender, app, reason string) (uint32, *dbus.Error) { | |||
| 309 | + | r := h.s.call(request{sender: string(sender), app: app, reason: reason}) | |||
| 310 | + | return r.cookie, r.err | |||
| 311 | + | } | |||
| 312 | + | ||||
| 313 | + | func (h handler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error { | |||
| 314 | + | return h.s.call(request{sender: string(sender), cookie: cookie, unhold: true}).err | |||
| 315 | + | } | |||
| 316 | + | ||||
| 317 | + | // GetActive: there is no screensaver to be active, only idle inhibition. | |||
| 318 | + | func (handler) GetActive() (bool, *dbus.Error) { return false, nil } | |||
| 319 | + | ||||
| 320 | + | // GetActiveTime is the seconds the screensaver has been active: never. | |||
| 321 | + | func (handler) GetActiveTime() (uint32, *dbus.Error) { return 0, nil } | |||
| 322 | + | ||||
| 323 | + | // SetActive cannot start a screensaver there is none of: it reports | |||
| 324 | + | // failure, as the specification allows. | |||
| 325 | + | func (handler) SetActive(bool) (bool, *dbus.Error) { return false, nil } | |||
| 326 | + | ||||
| 327 | + | // SimulateUserActivity counts as input: idle notifications resume and | |||
| 328 | + | // their timers restart. Calls between two reads collapse into one. | |||
| 329 | + | func (h handler) SimulateUserActivity() *dbus.Error { | |||
| 330 | + | select { | |||
| 331 | + | case h.activity <- struct{}{}: | |||
| 332 | + | default: | |||
| 333 | + | } | |||
| 334 | + | return nil | |||
| 335 | + | } | |||
A internal/adapters/screensaver/screensaver_test.go +420 -0
| @@ -0,0 +1,420 @@ | |||
| 1 | + | package screensaver | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "context" | |
| 6 | + | "errors" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/neferwl/internal/logging" | |
| 15 | + | "github.com/godbus/dbus/v5" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // privateBus starts a dbus-daemon of its own, so tests never touch the | |
| 19 | + | // desktop's session bus. | |
| 20 | + | func privateBus(t *testing.T) string { | |
| 21 | + | t.Helper() | |
| 22 | + | address, _ := startBus(t) | |
| 23 | + | return address | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // startBus also returns the daemon, for tests that kill it. | |
| 27 | + | func startBus(t *testing.T) (string, *exec.Cmd) { | |
| 28 | + | t.Helper() | |
| 29 | + | return startBusAt(t, filepath.Join(t.TempDir(), "bus")) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // startBusAt listens on socket, so a test can restart a bus at the same | |
| 33 | + | // address. CI must run these tests: there, a missing daemon fails. | |
| 34 | + | func startBusAt(t *testing.T, socket string) (string, *exec.Cmd) { | |
| 35 | + | t.Helper() | |
| 36 | + | bin, err := exec.LookPath("dbus-daemon") | |
| 37 | + | if err != nil { | |
| 38 | + | if os.Getenv("CI") != "" { | |
| 39 | + | t.Fatal("dbus-daemon not installed") | |
| 40 | + | } | |
| 41 | + | t.Skip("dbus-daemon not installed") | |
| 42 | + | } | |
| 43 | + | _ = os.Remove(socket) | |
| 44 | + | cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket) | |
| 45 | + | out, err := cmd.StdoutPipe() | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | if err := cmd.Start(); err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |
| 53 | + | line, err := bufio.NewReader(out).ReadString('\n') | |
| 54 | + | if err != nil { | |
| 55 | + | t.Fatal(err) | |
| 56 | + | } | |
| 57 | + | return strings.TrimSpace(line), cmd | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func connect(t *testing.T, address string) *dbus.Conn { | |
| 61 | + | t.Helper() | |
| 62 | + | c, err := dbus.Connect(address) | |
| 63 | + | if err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | t.Cleanup(func() { _ = c.Close() }) | |
| 67 | + | return c | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // served is a running service and what it reports. | |
| 71 | + | type served struct { | |
| 72 | + | s *Service | |
| 73 | + | held chan bool | |
| 74 | + | activity chan struct{} | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // serve runs the service on address until the test ends. | |
| 78 | + | func serve(t *testing.T, address string) served { | |
| 79 | + | t.Helper() | |
| 80 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 81 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |
| 82 | + | if err != nil { | |
| 83 | + | cancel() | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | v := served{s: s, held: make(chan bool, 8), activity: make(chan struct{}, 1)} | |
| 87 | + | done := make(chan error, 1) | |
| 88 | + | go func() { done <- s.Run(ctx, Reports{Held: v.held, Activity: v.activity}) }() | |
| 89 | + | t.Cleanup(func() { | |
| 90 | + | cancel() | |
| 91 | + | if err := <-done; err != nil { | |
| 92 | + | t.Error(err) | |
| 93 | + | } | |
| 94 | + | }) | |
| 95 | + | // Wait for the name: Run requests it after exporting. | |
| 96 | + | waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" }) | |
| 97 | + | return v | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // waitOwner polls the owner of busName ("" when none) until ok accepts it. | |
| 101 | + | func waitOwner(t *testing.T, probe *dbus.Conn, ok func(string) bool) { | |
| 102 | + | t.Helper() | |
| 103 | + | deadline := time.Now().Add(2 * time.Second) | |
| 104 | + | for { | |
| 105 | + | var owner string | |
| 106 | + | _ = probe.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner) | |
| 107 | + | if ok(owner) { | |
| 108 | + | return | |
| 109 | + | } | |
| 110 | + | if time.Now().After(deadline) { | |
| 111 | + | t.Fatalf("owner %q not accepted", owner) | |
| 112 | + | } | |
| 113 | + | time.Sleep(5 * time.Millisecond) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func next(t *testing.T, held <-chan bool, want bool) { | |
| 118 | + | t.Helper() | |
| 119 | + | select { | |
| 120 | + | case got := <-held: | |
| 121 | + | if got != want { | |
| 122 | + | t.Fatalf("held %v, want %v", got, want) | |
| 123 | + | } | |
| 124 | + | case <-time.After(2 * time.Second): | |
| 125 | + | t.Fatalf("no held %v", want) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | func none(t *testing.T, held <-chan bool) { | |
| 130 | + | t.Helper() | |
| 131 | + | select { | |
| 132 | + | case got := <-held: | |
| 133 | + | t.Fatalf("unexpected held %v", got) | |
| 134 | + | case <-time.After(100 * time.Millisecond): | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func inhibit(t *testing.T, c *dbus.Conn, path dbus.ObjectPath) uint32 { | |
| 139 | + | t.Helper() | |
| 140 | + | var cookie uint32 | |
| 141 | + | if err := c.Object(busName, path).Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err != nil { | |
| 142 | + | t.Fatal(err) | |
| 143 | + | } | |
| 144 | + | return cookie | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | func TestInhibitAndUnInhibit(t *testing.T) { | |
| 148 | + | address := privateBus(t) | |
| 149 | + | held := serve(t, address).held | |
| 150 | + | a, b := connect(t, address), connect(t, address) | |
| 151 | + | ca := inhibit(t, a, "/org/freedesktop/ScreenSaver") | |
| 152 | + | next(t, held, true) | |
| 153 | + | cb := inhibit(t, b, "/ScreenSaver") | |
| 154 | + | none(t, held) | |
| 155 | + | // b cannot release a's cookie. | |
| 156 | + | if err := b.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil { | |
| 157 | + | t.Fatal(err) | |
| 158 | + | } | |
| 159 | + | if err := a.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil { | |
| 160 | + | t.Fatal(err) | |
| 161 | + | } | |
| 162 | + | none(t, held) | |
| 163 | + | if err := b.Object(busName, "/ScreenSaver").Call(iface+".UnInhibit", 0, cb).Err; err != nil { | |
| 164 | + | t.Fatal(err) | |
| 165 | + | } | |
| 166 | + | next(t, held, false) | |
| 167 | + | obj := a.Object(busName, "/org/freedesktop/ScreenSaver") | |
| 168 | + | var active bool | |
| 169 | + | if err := obj.Call(iface+".GetActive", 0).Store(&active); err != nil || active { | |
| 170 | + | t.Fatalf("GetActive %v %v", active, err) | |
| 171 | + | } | |
| 172 | + | var since uint32 | |
| 173 | + | if err := obj.Call(iface+".GetActiveTime", 0).Store(&since); err != nil || since != 0 { | |
| 174 | + | t.Fatalf("GetActiveTime %v %v", since, err) | |
| 175 | + | } | |
| 176 | + | if err := obj.Call(iface+".SetActive", 0, true).Store(&active); err != nil || active { | |
| 177 | + | t.Fatalf("SetActive %v %v", active, err) | |
| 178 | + | } | |
| 179 | + | var xml string | |
| 180 | + | if err := obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil || !strings.Contains(xml, `<method name="UnInhibit">`) || !strings.Contains(xml, `<method name="SimulateUserActivity">`) { | |
| 181 | + | t.Fatalf("introspection %q %v", xml, err) | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // SimulateUserActivity reports activity without blocking: calls the | |
| 186 | + | // reader has not taken yet collapse into one. | |
| 187 | + | func TestSimulateUserActivity(t *testing.T) { | |
| 188 | + | address := privateBus(t) | |
| 189 | + | v := serve(t, address) | |
| 190 | + | obj := connect(t, address).Object(busName, "/ScreenSaver") | |
| 191 | + | for range 3 { | |
| 192 | + | if err := obj.Call(iface+".SimulateUserActivity", 0).Err; err != nil { | |
| 193 | + | t.Fatal(err) | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | <-v.activity | |
| 197 | + | select { | |
| 198 | + | case <-v.activity: | |
| 199 | + | t.Fatal("activity not coalesced") | |
| 200 | + | default: | |
| 201 | + | } | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // A client that disconnects without UnInhibit, as a crash does, releases | |
| 205 | + | // its inhibitions. | |
| 206 | + | func TestDisconnectReleases(t *testing.T) { | |
| 207 | + | address := privateBus(t) | |
| 208 | + | held := serve(t, address).held | |
| 209 | + | c, err := dbus.Connect(address) | |
| 210 | + | if err != nil { | |
| 211 | + | t.Fatal(err) | |
| 212 | + | } | |
| 213 | + | inhibit(t, c, "/org/freedesktop/ScreenSaver") | |
| 214 | + | inhibit(t, c, "/org/freedesktop/ScreenSaver") | |
| 215 | + | next(t, held, true) | |
| 216 | + | _ = c.Close() | |
| 217 | + | next(t, held, false) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | // A NameOwnerChanged sent straight to the service by a client is not the | |
| 221 | + | // bus's: it cannot end another client's inhibition. | |
| 222 | + | func TestSpoofedDepartureIgnored(t *testing.T) { | |
| 223 | + | address := privateBus(t) | |
| 224 | + | held := serve(t, address).held | |
| 225 | + | victim, attacker := connect(t, address), connect(t, address) | |
| 226 | + | inhibit(t, victim, "/org/freedesktop/ScreenSaver") | |
| 227 | + | next(t, held, true) | |
| 228 | + | var service string | |
| 229 | + | if err := attacker.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&service); err != nil { | |
| 230 | + | t.Fatal(err) | |
| 231 | + | } | |
| 232 | + | gone := victim.Names()[0] | |
| 233 | + | msg := &dbus.Message{ | |
| 234 | + | Type: dbus.TypeSignal, | |
| 235 | + | Headers: map[dbus.HeaderField]dbus.Variant{ | |
| 236 | + | dbus.FieldPath: dbus.MakeVariant(dbus.ObjectPath("/org/freedesktop/DBus")), | |
| 237 | + | dbus.FieldInterface: dbus.MakeVariant("org.freedesktop.DBus"), | |
| 238 | + | dbus.FieldMember: dbus.MakeVariant("NameOwnerChanged"), | |
| 239 | + | dbus.FieldDestination: dbus.MakeVariant(service), | |
| 240 | + | dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf("", "", "")), | |
| 241 | + | }, | |
| 242 | + | Body: []any{gone, gone, ""}, | |
| 243 | + | } | |
| 244 | + | if call := attacker.Send(msg, nil); call != nil && call.Err != nil { | |
| 245 | + | t.Fatal(call.Err) | |
| 246 | + | } | |
| 247 | + | // A call after the signal: the service has read the signal by its reply. | |
| 248 | + | inhibit(t, attacker, "/ScreenSaver") | |
| 249 | + | none(t, held) | |
| 250 | + | _ = attacker.Close() | |
| 251 | + | none(t, held) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | // A client that leaves right after its Inhibit, before the service handles | |
| 255 | + | // the call, holds nothing: its departure may come first. | |
| 256 | + | func TestEarlyDepartureHoldsNothing(t *testing.T) { | |
| 257 | + | address := privateBus(t) | |
| 258 | + | held := serve(t, address).held | |
| 259 | + | for range 20 { | |
| 260 | + | c, err := dbus.Connect(address) | |
| 261 | + | if err != nil { | |
| 262 | + | t.Fatal(err) | |
| 263 | + | } | |
| 264 | + | c.Object(busName, "/ScreenSaver").Go(iface+".Inhibit", dbus.FlagNoReplyExpected, nil, "test", "video") | |
| 265 | + | _ = c.Close() | |
| 266 | + | } | |
| 267 | + | // Each one may hold briefly; once all are handled, none does. | |
| 268 | + | state := false | |
| 269 | + | deadline := time.After(3 * time.Second) | |
| 270 | + | for quiet := false; !quiet; { | |
| 271 | + | select { | |
| 272 | + | case state = <-held: | |
| 273 | + | case <-time.After(300 * time.Millisecond): | |
| 274 | + | quiet = true | |
| 275 | + | case <-deadline: | |
| 276 | + | t.Fatal("never settled") | |
| 277 | + | } | |
| 278 | + | } | |
| 279 | + | if state { | |
| 280 | + | t.Fatal("a departed client still holds an inhibition") | |
| 281 | + | } | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // One connection holds at most maxPerSender cookies; the next is refused. | |
| 285 | + | func TestPerSenderLimit(t *testing.T) { | |
| 286 | + | address := privateBus(t) | |
| 287 | + | held := serve(t, address).held | |
| 288 | + | c := connect(t, address) | |
| 289 | + | obj := c.Object(busName, "/ScreenSaver") | |
| 290 | + | for range maxPerSender { | |
| 291 | + | inhibit(t, c, "/ScreenSaver") | |
| 292 | + | } | |
| 293 | + | next(t, held, true) | |
| 294 | + | var cookie uint32 | |
| 295 | + | if err := obj.Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err == nil { | |
| 296 | + | t.Fatalf("cookie %d past the limit", cookie) | |
| 297 | + | } | |
| 298 | + | // Another connection is not limited by the first. | |
| 299 | + | inhibit(t, connect(t, address), "/ScreenSaver") | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // A call that reaches the handler after Run returned fails at once | |
| 303 | + | // instead of waiting for a loop that is gone. | |
| 304 | + | func TestCallAfterStop(t *testing.T) { | |
| 305 | + | address := privateBus(t) | |
| 306 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 307 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |
| 308 | + | if err != nil { | |
| 309 | + | cancel() | |
| 310 | + | t.Fatal(err) | |
| 311 | + | } | |
| 312 | + | done := make(chan error, 1) | |
| 313 | + | go func() { done <- s.Run(ctx, Reports{Held: make(chan bool, 1), Activity: make(chan struct{}, 1)}) }() | |
| 314 | + | cancel() | |
| 315 | + | if err := <-done; err != nil { | |
| 316 | + | t.Fatal(err) | |
| 317 | + | } | |
| 318 | + | got := make(chan *dbus.Error, 1) | |
| 319 | + | go func() { _, err := (handler{s: s}).Inhibit(":1.9", "test", "video"); got <- err }() | |
| 320 | + | select { | |
| 321 | + | case err := <-got: | |
| 322 | + | if err == nil { | |
| 323 | + | t.Fatal("Inhibit succeeded on a stopped service") | |
| 324 | + | } | |
| 325 | + | case <-time.After(2 * time.Second): | |
| 326 | + | t.Fatal("Inhibit waits on a stopped service") | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // The bus going away while inhibited reports false so idle resumes. | |
| 331 | + | func TestBusLossReleases(t *testing.T) { | |
| 332 | + | address, cmd := startBus(t) | |
| 333 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 334 | + | defer cancel() | |
| 335 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |
| 336 | + | if err != nil { | |
| 337 | + | t.Fatal(err) | |
| 338 | + | } | |
| 339 | + | held := make(chan bool, 8) | |
| 340 | + | done := make(chan error, 1) | |
| 341 | + | go func() { done <- s.Run(ctx, Reports{Held: held, Activity: make(chan struct{}, 1)}) }() | |
| 342 | + | c := connect(t, address) | |
| 343 | + | waitOwner(t, c, func(owner string) bool { return owner != "" }) | |
| 344 | + | inhibit(t, c, "/ScreenSaver") | |
| 345 | + | next(t, held, true) | |
| 346 | + | _ = cmd.Process.Kill() | |
| 347 | + | next(t, held, false) | |
| 348 | + | select { | |
| 349 | + | case err := <-done: | |
| 350 | + | if err == nil { | |
| 351 | + | t.Fatal("bus loss is not an error") | |
| 352 | + | } | |
| 353 | + | case <-time.After(2 * time.Second): | |
| 354 | + | t.Fatal("Run did not return") | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | // Another owner keeps the name: Run stands aside at once, so it holds no | |
| 359 | + | // bus connection. | |
| 360 | + | func TestNameTaken(t *testing.T) { | |
| 361 | + | address := privateBus(t) | |
| 362 | + | other := connect(t, address) | |
| 363 | + | if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner { | |
| 364 | + | t.Fatal(r, err) | |
| 365 | + | } | |
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 367 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |
| 368 | + | if err != nil { | |
| 369 | + | cancel() | |
| 370 | + | t.Fatal(err) | |
| 371 | + | } | |
| 372 | + | defer cancel() | |
| 373 | + | done := make(chan error, 1) | |
| 374 | + | go func() { done <- s.Run(ctx, Reports{Held: make(chan bool), Activity: make(chan struct{}, 1)}) }() | |
| 375 | + | select { | |
| 376 | + | case err := <-done: | |
| 377 | + | if !errors.Is(err, ErrNameTaken) { | |
| 378 | + | t.Fatalf("err %v, want ErrNameTaken", err) | |
| 379 | + | } | |
| 380 | + | case <-time.After(2 * time.Second): | |
| 381 | + | t.Fatal("Run waits instead of standing aside") | |
| 382 | + | } | |
| 383 | + | var owner string | |
| 384 | + | if err := other.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner); err != nil || owner != other.Names()[0] { | |
| 385 | + | t.Fatalf("owner %q, want %q: %v", owner, other.Names()[0], err) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 389 | + | // Serve takes the name once its owner lets it go, and serves again after | |
| 390 | + | // the bus restarts. | |
| 391 | + | func TestServeRetries(t *testing.T) { | |
| 392 | + | socket := filepath.Join(t.TempDir(), "bus") | |
| 393 | + | address, cmd := startBusAt(t, socket) | |
| 394 | + | other := connect(t, address) | |
| 395 | + | if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner { | |
| 396 | + | t.Fatal(r, err) | |
| 397 | + | } | |
| 398 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 399 | + | held := make(chan bool, 8) | |
| 400 | + | stopped := make(chan struct{}) | |
| 401 | + | go func() { | |
| 402 | + | defer close(stopped) | |
| 403 | + | Serve(ctx, address, Reports{Held: held, Activity: make(chan struct{}, 1)}, 10*time.Millisecond, logging.For(ctx, "screensaver")) | |
| 404 | + | }() | |
| 405 | + | t.Cleanup(func() { cancel(); <-stopped }) | |
| 406 | + | if _, err := other.ReleaseName(busName); err != nil { | |
| 407 | + | t.Fatal(err) | |
| 408 | + | } | |
| 409 | + | mine := other.Names()[0] | |
| 410 | + | waitOwner(t, other, func(owner string) bool { return owner != "" && owner != mine }) | |
| 411 | + | inhibit(t, other, "/ScreenSaver") | |
| 412 | + | next(t, held, true) | |
| 413 | + | ||
| 414 | + | // The bus restarts: the inhibition is gone with it, the name comes back. | |
| 415 | + | _ = cmd.Process.Kill() | |
| 416 | + | _ = cmd.Wait() | |
| 417 | + | next(t, held, false) | |
| 418 | + | address, _ = startBusAt(t, socket) | |
| 419 | + | waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" }) | |
| 420 | + | } | |
| @@ -0,0 +1,420 @@ | |||||
| 1 | + | package screensaver | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "context" | |||
| 6 | + | "errors" | |||
| 7 | + | "os" | |||
| 8 | + | "os/exec" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/neferwl/internal/logging" | |||
| 15 | + | "github.com/godbus/dbus/v5" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // privateBus starts a dbus-daemon of its own, so tests never touch the | |||
| 19 | + | // desktop's session bus. | |||
| 20 | + | func privateBus(t *testing.T) string { | |||
| 21 | + | t.Helper() | |||
| 22 | + | address, _ := startBus(t) | |||
| 23 | + | return address | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | // startBus also returns the daemon, for tests that kill it. | |||
| 27 | + | func startBus(t *testing.T) (string, *exec.Cmd) { | |||
| 28 | + | t.Helper() | |||
| 29 | + | return startBusAt(t, filepath.Join(t.TempDir(), "bus")) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // startBusAt listens on socket, so a test can restart a bus at the same | |||
| 33 | + | // address. CI must run these tests: there, a missing daemon fails. | |||
| 34 | + | func startBusAt(t *testing.T, socket string) (string, *exec.Cmd) { | |||
| 35 | + | t.Helper() | |||
| 36 | + | bin, err := exec.LookPath("dbus-daemon") | |||
| 37 | + | if err != nil { | |||
| 38 | + | if os.Getenv("CI") != "" { | |||
| 39 | + | t.Fatal("dbus-daemon not installed") | |||
| 40 | + | } | |||
| 41 | + | t.Skip("dbus-daemon not installed") | |||
| 42 | + | } | |||
| 43 | + | _ = os.Remove(socket) | |||
| 44 | + | cmd := exec.Command(bin, "--session", "--nofork", "--nopidfile", "--print-address=1", "--address=unix:path="+socket) | |||
| 45 | + | out, err := cmd.StdoutPipe() | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | if err := cmd.Start(); err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) | |||
| 53 | + | line, err := bufio.NewReader(out).ReadString('\n') | |||
| 54 | + | if err != nil { | |||
| 55 | + | t.Fatal(err) | |||
| 56 | + | } | |||
| 57 | + | return strings.TrimSpace(line), cmd | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func connect(t *testing.T, address string) *dbus.Conn { | |||
| 61 | + | t.Helper() | |||
| 62 | + | c, err := dbus.Connect(address) | |||
| 63 | + | if err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | t.Cleanup(func() { _ = c.Close() }) | |||
| 67 | + | return c | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // served is a running service and what it reports. | |||
| 71 | + | type served struct { | |||
| 72 | + | s *Service | |||
| 73 | + | held chan bool | |||
| 74 | + | activity chan struct{} | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // serve runs the service on address until the test ends. | |||
| 78 | + | func serve(t *testing.T, address string) served { | |||
| 79 | + | t.Helper() | |||
| 80 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 81 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |||
| 82 | + | if err != nil { | |||
| 83 | + | cancel() | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | v := served{s: s, held: make(chan bool, 8), activity: make(chan struct{}, 1)} | |||
| 87 | + | done := make(chan error, 1) | |||
| 88 | + | go func() { done <- s.Run(ctx, Reports{Held: v.held, Activity: v.activity}) }() | |||
| 89 | + | t.Cleanup(func() { | |||
| 90 | + | cancel() | |||
| 91 | + | if err := <-done; err != nil { | |||
| 92 | + | t.Error(err) | |||
| 93 | + | } | |||
| 94 | + | }) | |||
| 95 | + | // Wait for the name: Run requests it after exporting. | |||
| 96 | + | waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" }) | |||
| 97 | + | return v | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // waitOwner polls the owner of busName ("" when none) until ok accepts it. | |||
| 101 | + | func waitOwner(t *testing.T, probe *dbus.Conn, ok func(string) bool) { | |||
| 102 | + | t.Helper() | |||
| 103 | + | deadline := time.Now().Add(2 * time.Second) | |||
| 104 | + | for { | |||
| 105 | + | var owner string | |||
| 106 | + | _ = probe.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner) | |||
| 107 | + | if ok(owner) { | |||
| 108 | + | return | |||
| 109 | + | } | |||
| 110 | + | if time.Now().After(deadline) { | |||
| 111 | + | t.Fatalf("owner %q not accepted", owner) | |||
| 112 | + | } | |||
| 113 | + | time.Sleep(5 * time.Millisecond) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func next(t *testing.T, held <-chan bool, want bool) { | |||
| 118 | + | t.Helper() | |||
| 119 | + | select { | |||
| 120 | + | case got := <-held: | |||
| 121 | + | if got != want { | |||
| 122 | + | t.Fatalf("held %v, want %v", got, want) | |||
| 123 | + | } | |||
| 124 | + | case <-time.After(2 * time.Second): | |||
| 125 | + | t.Fatalf("no held %v", want) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func none(t *testing.T, held <-chan bool) { | |||
| 130 | + | t.Helper() | |||
| 131 | + | select { | |||
| 132 | + | case got := <-held: | |||
| 133 | + | t.Fatalf("unexpected held %v", got) | |||
| 134 | + | case <-time.After(100 * time.Millisecond): | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func inhibit(t *testing.T, c *dbus.Conn, path dbus.ObjectPath) uint32 { | |||
| 139 | + | t.Helper() | |||
| 140 | + | var cookie uint32 | |||
| 141 | + | if err := c.Object(busName, path).Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err != nil { | |||
| 142 | + | t.Fatal(err) | |||
| 143 | + | } | |||
| 144 | + | return cookie | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestInhibitAndUnInhibit(t *testing.T) { | |||
| 148 | + | address := privateBus(t) | |||
| 149 | + | held := serve(t, address).held | |||
| 150 | + | a, b := connect(t, address), connect(t, address) | |||
| 151 | + | ca := inhibit(t, a, "/org/freedesktop/ScreenSaver") | |||
| 152 | + | next(t, held, true) | |||
| 153 | + | cb := inhibit(t, b, "/ScreenSaver") | |||
| 154 | + | none(t, held) | |||
| 155 | + | // b cannot release a's cookie. | |||
| 156 | + | if err := b.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil { | |||
| 157 | + | t.Fatal(err) | |||
| 158 | + | } | |||
| 159 | + | if err := a.Object(busName, "/org/freedesktop/ScreenSaver").Call(iface+".UnInhibit", 0, ca).Err; err != nil { | |||
| 160 | + | t.Fatal(err) | |||
| 161 | + | } | |||
| 162 | + | none(t, held) | |||
| 163 | + | if err := b.Object(busName, "/ScreenSaver").Call(iface+".UnInhibit", 0, cb).Err; err != nil { | |||
| 164 | + | t.Fatal(err) | |||
| 165 | + | } | |||
| 166 | + | next(t, held, false) | |||
| 167 | + | obj := a.Object(busName, "/org/freedesktop/ScreenSaver") | |||
| 168 | + | var active bool | |||
| 169 | + | if err := obj.Call(iface+".GetActive", 0).Store(&active); err != nil || active { | |||
| 170 | + | t.Fatalf("GetActive %v %v", active, err) | |||
| 171 | + | } | |||
| 172 | + | var since uint32 | |||
| 173 | + | if err := obj.Call(iface+".GetActiveTime", 0).Store(&since); err != nil || since != 0 { | |||
| 174 | + | t.Fatalf("GetActiveTime %v %v", since, err) | |||
| 175 | + | } | |||
| 176 | + | if err := obj.Call(iface+".SetActive", 0, true).Store(&active); err != nil || active { | |||
| 177 | + | t.Fatalf("SetActive %v %v", active, err) | |||
| 178 | + | } | |||
| 179 | + | var xml string | |||
| 180 | + | if err := obj.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil || !strings.Contains(xml, `<method name="UnInhibit">`) || !strings.Contains(xml, `<method name="SimulateUserActivity">`) { | |||
| 181 | + | t.Fatalf("introspection %q %v", xml, err) | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // SimulateUserActivity reports activity without blocking: calls the | |||
| 186 | + | // reader has not taken yet collapse into one. | |||
| 187 | + | func TestSimulateUserActivity(t *testing.T) { | |||
| 188 | + | address := privateBus(t) | |||
| 189 | + | v := serve(t, address) | |||
| 190 | + | obj := connect(t, address).Object(busName, "/ScreenSaver") | |||
| 191 | + | for range 3 { | |||
| 192 | + | if err := obj.Call(iface+".SimulateUserActivity", 0).Err; err != nil { | |||
| 193 | + | t.Fatal(err) | |||
| 194 | + | } | |||
| 195 | + | } | |||
| 196 | + | <-v.activity | |||
| 197 | + | select { | |||
| 198 | + | case <-v.activity: | |||
| 199 | + | t.Fatal("activity not coalesced") | |||
| 200 | + | default: | |||
| 201 | + | } | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // A client that disconnects without UnInhibit, as a crash does, releases | |||
| 205 | + | // its inhibitions. | |||
| 206 | + | func TestDisconnectReleases(t *testing.T) { | |||
| 207 | + | address := privateBus(t) | |||
| 208 | + | held := serve(t, address).held | |||
| 209 | + | c, err := dbus.Connect(address) | |||
| 210 | + | if err != nil { | |||
| 211 | + | t.Fatal(err) | |||
| 212 | + | } | |||
| 213 | + | inhibit(t, c, "/org/freedesktop/ScreenSaver") | |||
| 214 | + | inhibit(t, c, "/org/freedesktop/ScreenSaver") | |||
| 215 | + | next(t, held, true) | |||
| 216 | + | _ = c.Close() | |||
| 217 | + | next(t, held, false) | |||
| 218 | + | } | |||
| 219 | + | ||||
| 220 | + | // A NameOwnerChanged sent straight to the service by a client is not the | |||
| 221 | + | // bus's: it cannot end another client's inhibition. | |||
| 222 | + | func TestSpoofedDepartureIgnored(t *testing.T) { | |||
| 223 | + | address := privateBus(t) | |||
| 224 | + | held := serve(t, address).held | |||
| 225 | + | victim, attacker := connect(t, address), connect(t, address) | |||
| 226 | + | inhibit(t, victim, "/org/freedesktop/ScreenSaver") | |||
| 227 | + | next(t, held, true) | |||
| 228 | + | var service string | |||
| 229 | + | if err := attacker.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&service); err != nil { | |||
| 230 | + | t.Fatal(err) | |||
| 231 | + | } | |||
| 232 | + | gone := victim.Names()[0] | |||
| 233 | + | msg := &dbus.Message{ | |||
| 234 | + | Type: dbus.TypeSignal, | |||
| 235 | + | Headers: map[dbus.HeaderField]dbus.Variant{ | |||
| 236 | + | dbus.FieldPath: dbus.MakeVariant(dbus.ObjectPath("/org/freedesktop/DBus")), | |||
| 237 | + | dbus.FieldInterface: dbus.MakeVariant("org.freedesktop.DBus"), | |||
| 238 | + | dbus.FieldMember: dbus.MakeVariant("NameOwnerChanged"), | |||
| 239 | + | dbus.FieldDestination: dbus.MakeVariant(service), | |||
| 240 | + | dbus.FieldSignature: dbus.MakeVariant(dbus.SignatureOf("", "", "")), | |||
| 241 | + | }, | |||
| 242 | + | Body: []any{gone, gone, ""}, | |||
| 243 | + | } | |||
| 244 | + | if call := attacker.Send(msg, nil); call != nil && call.Err != nil { | |||
| 245 | + | t.Fatal(call.Err) | |||
| 246 | + | } | |||
| 247 | + | // A call after the signal: the service has read the signal by its reply. | |||
| 248 | + | inhibit(t, attacker, "/ScreenSaver") | |||
| 249 | + | none(t, held) | |||
| 250 | + | _ = attacker.Close() | |||
| 251 | + | none(t, held) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | // A client that leaves right after its Inhibit, before the service handles | |||
| 255 | + | // the call, holds nothing: its departure may come first. | |||
| 256 | + | func TestEarlyDepartureHoldsNothing(t *testing.T) { | |||
| 257 | + | address := privateBus(t) | |||
| 258 | + | held := serve(t, address).held | |||
| 259 | + | for range 20 { | |||
| 260 | + | c, err := dbus.Connect(address) | |||
| 261 | + | if err != nil { | |||
| 262 | + | t.Fatal(err) | |||
| 263 | + | } | |||
| 264 | + | c.Object(busName, "/ScreenSaver").Go(iface+".Inhibit", dbus.FlagNoReplyExpected, nil, "test", "video") | |||
| 265 | + | _ = c.Close() | |||
| 266 | + | } | |||
| 267 | + | // Each one may hold briefly; once all are handled, none does. | |||
| 268 | + | state := false | |||
| 269 | + | deadline := time.After(3 * time.Second) | |||
| 270 | + | for quiet := false; !quiet; { | |||
| 271 | + | select { | |||
| 272 | + | case state = <-held: | |||
| 273 | + | case <-time.After(300 * time.Millisecond): | |||
| 274 | + | quiet = true | |||
| 275 | + | case <-deadline: | |||
| 276 | + | t.Fatal("never settled") | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | if state { | |||
| 280 | + | t.Fatal("a departed client still holds an inhibition") | |||
| 281 | + | } | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // One connection holds at most maxPerSender cookies; the next is refused. | |||
| 285 | + | func TestPerSenderLimit(t *testing.T) { | |||
| 286 | + | address := privateBus(t) | |||
| 287 | + | held := serve(t, address).held | |||
| 288 | + | c := connect(t, address) | |||
| 289 | + | obj := c.Object(busName, "/ScreenSaver") | |||
| 290 | + | for range maxPerSender { | |||
| 291 | + | inhibit(t, c, "/ScreenSaver") | |||
| 292 | + | } | |||
| 293 | + | next(t, held, true) | |||
| 294 | + | var cookie uint32 | |||
| 295 | + | if err := obj.Call(iface+".Inhibit", 0, "test", "video").Store(&cookie); err == nil { | |||
| 296 | + | t.Fatalf("cookie %d past the limit", cookie) | |||
| 297 | + | } | |||
| 298 | + | // Another connection is not limited by the first. | |||
| 299 | + | inhibit(t, connect(t, address), "/ScreenSaver") | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | // A call that reaches the handler after Run returned fails at once | |||
| 303 | + | // instead of waiting for a loop that is gone. | |||
| 304 | + | func TestCallAfterStop(t *testing.T) { | |||
| 305 | + | address := privateBus(t) | |||
| 306 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 307 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |||
| 308 | + | if err != nil { | |||
| 309 | + | cancel() | |||
| 310 | + | t.Fatal(err) | |||
| 311 | + | } | |||
| 312 | + | done := make(chan error, 1) | |||
| 313 | + | go func() { done <- s.Run(ctx, Reports{Held: make(chan bool, 1), Activity: make(chan struct{}, 1)}) }() | |||
| 314 | + | cancel() | |||
| 315 | + | if err := <-done; err != nil { | |||
| 316 | + | t.Fatal(err) | |||
| 317 | + | } | |||
| 318 | + | got := make(chan *dbus.Error, 1) | |||
| 319 | + | go func() { _, err := (handler{s: s}).Inhibit(":1.9", "test", "video"); got <- err }() | |||
| 320 | + | select { | |||
| 321 | + | case err := <-got: | |||
| 322 | + | if err == nil { | |||
| 323 | + | t.Fatal("Inhibit succeeded on a stopped service") | |||
| 324 | + | } | |||
| 325 | + | case <-time.After(2 * time.Second): | |||
| 326 | + | t.Fatal("Inhibit waits on a stopped service") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | // The bus going away while inhibited reports false so idle resumes. | |||
| 331 | + | func TestBusLossReleases(t *testing.T) { | |||
| 332 | + | address, cmd := startBus(t) | |||
| 333 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 334 | + | defer cancel() | |||
| 335 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |||
| 336 | + | if err != nil { | |||
| 337 | + | t.Fatal(err) | |||
| 338 | + | } | |||
| 339 | + | held := make(chan bool, 8) | |||
| 340 | + | done := make(chan error, 1) | |||
| 341 | + | go func() { done <- s.Run(ctx, Reports{Held: held, Activity: make(chan struct{}, 1)}) }() | |||
| 342 | + | c := connect(t, address) | |||
| 343 | + | waitOwner(t, c, func(owner string) bool { return owner != "" }) | |||
| 344 | + | inhibit(t, c, "/ScreenSaver") | |||
| 345 | + | next(t, held, true) | |||
| 346 | + | _ = cmd.Process.Kill() | |||
| 347 | + | next(t, held, false) | |||
| 348 | + | select { | |||
| 349 | + | case err := <-done: | |||
| 350 | + | if err == nil { | |||
| 351 | + | t.Fatal("bus loss is not an error") | |||
| 352 | + | } | |||
| 353 | + | case <-time.After(2 * time.Second): | |||
| 354 | + | t.Fatal("Run did not return") | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | // Another owner keeps the name: Run stands aside at once, so it holds no | |||
| 359 | + | // bus connection. | |||
| 360 | + | func TestNameTaken(t *testing.T) { | |||
| 361 | + | address := privateBus(t) | |||
| 362 | + | other := connect(t, address) | |||
| 363 | + | if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner { | |||
| 364 | + | t.Fatal(r, err) | |||
| 365 | + | } | |||
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 367 | + | s, err := New(ctx, address, logging.For(ctx, "screensaver")) | |||
| 368 | + | if err != nil { | |||
| 369 | + | cancel() | |||
| 370 | + | t.Fatal(err) | |||
| 371 | + | } | |||
| 372 | + | defer cancel() | |||
| 373 | + | done := make(chan error, 1) | |||
| 374 | + | go func() { done <- s.Run(ctx, Reports{Held: make(chan bool), Activity: make(chan struct{}, 1)}) }() | |||
| 375 | + | select { | |||
| 376 | + | case err := <-done: | |||
| 377 | + | if !errors.Is(err, ErrNameTaken) { | |||
| 378 | + | t.Fatalf("err %v, want ErrNameTaken", err) | |||
| 379 | + | } | |||
| 380 | + | case <-time.After(2 * time.Second): | |||
| 381 | + | t.Fatal("Run waits instead of standing aside") | |||
| 382 | + | } | |||
| 383 | + | var owner string | |||
| 384 | + | if err := other.BusObject().Call("org.freedesktop.DBus.GetNameOwner", 0, busName).Store(&owner); err != nil || owner != other.Names()[0] { | |||
| 385 | + | t.Fatalf("owner %q, want %q: %v", owner, other.Names()[0], err) | |||
| 386 | + | } | |||
| 387 | + | } | |||
| 388 | + | ||||
| 389 | + | // Serve takes the name once its owner lets it go, and serves again after | |||
| 390 | + | // the bus restarts. | |||
| 391 | + | func TestServeRetries(t *testing.T) { | |||
| 392 | + | socket := filepath.Join(t.TempDir(), "bus") | |||
| 393 | + | address, cmd := startBusAt(t, socket) | |||
| 394 | + | other := connect(t, address) | |||
| 395 | + | if r, err := other.RequestName(busName, dbus.NameFlagDoNotQueue); err != nil || r != dbus.RequestNameReplyPrimaryOwner { | |||
| 396 | + | t.Fatal(r, err) | |||
| 397 | + | } | |||
| 398 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 399 | + | held := make(chan bool, 8) | |||
| 400 | + | stopped := make(chan struct{}) | |||
| 401 | + | go func() { | |||
| 402 | + | defer close(stopped) | |||
| 403 | + | Serve(ctx, address, Reports{Held: held, Activity: make(chan struct{}, 1)}, 10*time.Millisecond, logging.For(ctx, "screensaver")) | |||
| 404 | + | }() | |||
| 405 | + | t.Cleanup(func() { cancel(); <-stopped }) | |||
| 406 | + | if _, err := other.ReleaseName(busName); err != nil { | |||
| 407 | + | t.Fatal(err) | |||
| 408 | + | } | |||
| 409 | + | mine := other.Names()[0] | |||
| 410 | + | waitOwner(t, other, func(owner string) bool { return owner != "" && owner != mine }) | |||
| 411 | + | inhibit(t, other, "/ScreenSaver") | |||
| 412 | + | next(t, held, true) | |||
| 413 | + | ||||
| 414 | + | // The bus restarts: the inhibition is gone with it, the name comes back. | |||
| 415 | + | _ = cmd.Process.Kill() | |||
| 416 | + | _ = cmd.Wait() | |||
| 417 | + | next(t, held, false) | |||
| 418 | + | address, _ = startBusAt(t, socket) | |||
| 419 | + | waitOwner(t, connect(t, address), func(owner string) bool { return owner != "" }) | |||
| 420 | + | } | |||
M internal/adapters/wayland/idle.go +46 -4
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/go-wayland-bindings/server/extidlenotify" | |
| @@ -11,8 +12,10 @@ import ( | |||
| 11 | 12 | ) | |
| 12 | 13 | ||
| 13 | 14 | // Idle and output power. ext_idle_notifier_v1: each notification has its | |
| 14 | - | // own timeout, restarted by user activity (ports.UserActivity from core); | |
| 15 | - | // idle inhibitors hold every notification but input-idle ones. | |
| 15 | + | // own timeout, restarted by user activity (ports.UserActivity from core, | |
| 16 | + | // or Channels.IdleActivity from D-Bus clients); idle inhibitors, surfaces | |
| 17 | + | // or D-Bus clients (Channels.IdleInhibited), hold every notification but | |
| 18 | + | // input-idle ones. | |
| 16 | 19 | // zwlr_output_power_v1: a client such as wlopm turns a display off or on; | |
| 17 | 20 | // core decides and the scene carries it. | |
| 18 | 21 | ||
| @@ -80,7 +83,46 @@ func (s *Server) addIdle(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, | |||
| 80 | 83 | ||
| 81 | 84 | // idleHeld reports whether an idle inhibitor keeps n from firing. | |
| 82 | 85 | func (s *Server) idleHeld(n *idleNotification) bool { | |
| 83 | - | return !n.input && len(s.idleWindows) > 0 | |
| 86 | + | return !n.input && s.inhibited() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // inhibited reports whether any idle inhibitor is in effect. | |
| 90 | + | func (s *Server) inhibited() bool { | |
| 91 | + | return len(s.idleWindows) > 0 || s.busInhibited | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // forwardBusIdle applies D-Bus idle inhibition and simulated activity on | |
| 95 | + | // the display goroutine. A closed channel stops only its own forwarding. | |
| 96 | + | func (s *Server) forwardBusIdle(ctx context.Context) { | |
| 97 | + | inhibited, activity := s.channels.IdleInhibited, s.channels.IdleActivity | |
| 98 | + | for inhibited != nil || activity != nil { | |
| 99 | + | var apply func() | |
| 100 | + | select { | |
| 101 | + | case <-ctx.Done(): | |
| 102 | + | return | |
| 103 | + | case <-s.display.Stopped(): | |
| 104 | + | return | |
| 105 | + | case on, ok := <-inhibited: | |
| 106 | + | if !ok { | |
| 107 | + | inhibited = nil | |
| 108 | + | continue | |
| 109 | + | } | |
| 110 | + | apply = func() { | |
| 111 | + | wasHeld := s.inhibited() | |
| 112 | + | s.busInhibited = on | |
| 113 | + | s.syncIdle(wasHeld) | |
| 114 | + | } | |
| 115 | + | case _, ok := <-activity: | |
| 116 | + | if !ok { | |
| 117 | + | activity = nil | |
| 118 | + | continue | |
| 119 | + | } | |
| 120 | + | apply = s.userActivity | |
| 121 | + | } | |
| 122 | + | if !s.display.Do(apply) { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | } | |
| 84 | 126 | } | |
| 85 | 127 | ||
| 86 | 128 | // armIdle restarts n's timer, unless an inhibitor holds it. The timer | |
| @@ -127,7 +169,7 @@ func (s *Server) userActivity() { | |||
| 127 | 169 | // syncIdle follows idle inhibitors: while one exists, inhibitable timers | |
| 128 | 170 | // stop; once the last one goes, they start again from zero. | |
| 129 | 171 | func (s *Server) syncIdle(wasHeld bool) { | |
| 130 | - | if held := len(s.idleWindows) > 0; held != wasHeld { | |
| 172 | + | if held := s.inhibited(); held != wasHeld { | |
| 131 | 173 | for _, n := range s.idleNotes { | |
| 132 | 174 | if !n.input { | |
| 133 | 175 | s.armIdle(n) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "time" | 5 | "time" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/go-wayland-bindings/server/extidlenotify" | 7 | "github.com/bnema/go-wayland-bindings/server/extidlenotify" | ||
| @@ -11,8 +12,10 @@ import ( | |||||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 13 | // Idle and output power. ext_idle_notifier_v1: each notification has its | 14 | // Idle and output power. ext_idle_notifier_v1: each notification has its | ||
| 14 | - | // own timeout, restarted by user activity (ports.UserActivity from core); | 15 | + | // own timeout, restarted by user activity (ports.UserActivity from core, |
| 15 | - | // idle inhibitors hold every notification but input-idle ones. | 16 | + | // or Channels.IdleActivity from D-Bus clients); idle inhibitors, surfaces |
| 17 | + | // or D-Bus clients (Channels.IdleInhibited), hold every notification but | |||
| 18 | + | // input-idle ones. | |||
| 16 | // zwlr_output_power_v1: a client such as wlopm turns a display off or on; | 19 | // zwlr_output_power_v1: a client such as wlopm turns a display off or on; | ||
| 17 | // core decides and the scene carries it. | 20 | // core decides and the scene carries it. | ||
| 18 | 21 | ||||
| @@ -80,7 +83,46 @@ func (s *Server) addIdle(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32, | |||||
| 80 | 83 | ||||
| 81 | // idleHeld reports whether an idle inhibitor keeps n from firing. | 84 | // idleHeld reports whether an idle inhibitor keeps n from firing. | ||
| 82 | func (s *Server) idleHeld(n *idleNotification) bool { | 85 | func (s *Server) idleHeld(n *idleNotification) bool { | ||
| 83 | - | return !n.input && len(s.idleWindows) > 0 | 86 | + | return !n.input && s.inhibited() |
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | // inhibited reports whether any idle inhibitor is in effect. | |||
| 90 | + | func (s *Server) inhibited() bool { | |||
| 91 | + | return len(s.idleWindows) > 0 || s.busInhibited | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // forwardBusIdle applies D-Bus idle inhibition and simulated activity on | |||
| 95 | + | // the display goroutine. A closed channel stops only its own forwarding. | |||
| 96 | + | func (s *Server) forwardBusIdle(ctx context.Context) { | |||
| 97 | + | inhibited, activity := s.channels.IdleInhibited, s.channels.IdleActivity | |||
| 98 | + | for inhibited != nil || activity != nil { | |||
| 99 | + | var apply func() | |||
| 100 | + | select { | |||
| 101 | + | case <-ctx.Done(): | |||
| 102 | + | return | |||
| 103 | + | case <-s.display.Stopped(): | |||
| 104 | + | return | |||
| 105 | + | case on, ok := <-inhibited: | |||
| 106 | + | if !ok { | |||
| 107 | + | inhibited = nil | |||
| 108 | + | continue | |||
| 109 | + | } | |||
| 110 | + | apply = func() { | |||
| 111 | + | wasHeld := s.inhibited() | |||
| 112 | + | s.busInhibited = on | |||
| 113 | + | s.syncIdle(wasHeld) | |||
| 114 | + | } | |||
| 115 | + | case _, ok := <-activity: | |||
| 116 | + | if !ok { | |||
| 117 | + | activity = nil | |||
| 118 | + | continue | |||
| 119 | + | } | |||
| 120 | + | apply = s.userActivity | |||
| 121 | + | } | |||
| 122 | + | if !s.display.Do(apply) { | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 84 | } | 126 | } | ||
| 85 | 127 | ||||
| 86 | // armIdle restarts n's timer, unless an inhibitor holds it. The timer | 128 | // armIdle restarts n's timer, unless an inhibitor holds it. The timer | ||
| @@ -127,7 +169,7 @@ func (s *Server) userActivity() { | |||||
| 127 | // syncIdle follows idle inhibitors: while one exists, inhibitable timers | 169 | // syncIdle follows idle inhibitors: while one exists, inhibitable timers | ||
| 128 | // stop; once the last one goes, they start again from zero. | 170 | // stop; once the last one goes, they start again from zero. | ||
| 129 | func (s *Server) syncIdle(wasHeld bool) { | 171 | func (s *Server) syncIdle(wasHeld bool) { | ||
| 130 | - | if held := len(s.idleWindows) > 0; held != wasHeld { | 172 | + | if held := s.inhibited(); held != wasHeld { |
| 131 | for _, n := range s.idleNotes { | 173 | for _, n := range s.idleNotes { | ||
| 132 | if !n.input { | 174 | if !n.input { | ||
| 133 | s.armIdle(n) | 175 | s.armIdle(n) | ||
M internal/adapters/wayland/idle_test.go +52 -0
| @@ -121,6 +121,58 @@ func TestIdleNotification(t *testing.T) { | |||
| 121 | 121 | } | |
| 122 | 122 | } | |
| 123 | 123 | ||
| 124 | + | // A D-Bus idle inhibition (IdleInhibited) holds notifications like an | |
| 125 | + | // inhibitor surface: a running timer stops, input-idle ones still fire, | |
| 126 | + | // and held ones count from zero once it ends. D-Bus activity | |
| 127 | + | // (IdleActivity) resumes them like input. | |
| 128 | + | func TestBusIdleInhibition(t *testing.T) { | |
| 129 | + | dir := t.TempDir() | |
| 130 | + | events := make(chan ports.ClientEvent, 16) | |
| 131 | + | inhibited := make(chan bool) | |
| 132 | + | activity := make(chan struct{}) | |
| 133 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, IdleInhibited: inhibited, IdleActivity: activity}, logging.For(context.Background(), "wayland")) | |
| 134 | + | if err != nil { | |
| 135 | + | t.Fatal(err) | |
| 136 | + | } | |
| 137 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 138 | + | done := make(chan error, 1) | |
| 139 | + | go func() { done <- s.Run(ctx) }() | |
| 140 | + | t.Cleanup(func() { cancel(); <-done }) | |
| 141 | + | c := protocolClient(t, s, dir) | |
| 142 | + | seat := bindProtocol(t, c, "wl_seat") | |
| 143 | + | registerProtocol(t, c, seat) | |
| 144 | + | notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2) | |
| 145 | + | registerProtocol(t, c, notifier) | |
| 146 | + | idled, resumed := uint32(extidlenotify.ExtIdleNotificationV1EventIdled), uint32(extidlenotify.ExtIdleNotificationV1EventResumed) | |
| 147 | + | ||
| 148 | + | // D-Bus activity resumes an idle notification and restarts it. | |
| 149 | + | id, n := newEventProxy(c) | |
| 150 | + | requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(300), seat) | |
| 151 | + | if e := n.next(t, c, 2*time.Second); e[0] != idled { | |
| 152 | + | t.Fatalf("event %v, want idled", e) | |
| 153 | + | } | |
| 154 | + | activity <- struct{}{} | |
| 155 | + | if e := n.next(t, c, 2*time.Second); e[0] != resumed { | |
| 156 | + | t.Fatalf("event %v, want resumed", e) | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // Its timer runs again: the inhibition stops it before it fires. The | |
| 160 | + | // forwarder takes the second send only once the first is applied. | |
| 161 | + | inhibited <- true | |
| 162 | + | inhibited <- true | |
| 163 | + | n.none(t, c, 500*time.Millisecond) | |
| 164 | + | inputID, input := newEventProxy(c) | |
| 165 | + | requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetInputIdleNotification, inputID, uint32(50), seat) | |
| 166 | + | if e := input.next(t, c, 2*time.Second); e[0] != idled { | |
| 167 | + | t.Fatalf("input-idle event %v, want idled", e) | |
| 168 | + | } | |
| 169 | + | n.none(t, c, 100*time.Millisecond) | |
| 170 | + | inhibited <- false | |
| 171 | + | if e := n.next(t, c, 2*time.Second); e[0] != idled { | |
| 172 | + | t.Fatalf("event %v, want idled after the inhibition", e) | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 124 | 176 | // An output power object reports the mode, turns set_mode into a core | |
| 125 | 177 | // event, hears core's decision, and fails when its output goes. | |
| 126 | 178 | func TestOutputPower(t *testing.T) { | |
| @@ -121,6 +121,58 @@ func TestIdleNotification(t *testing.T) { | |||||
| 121 | } | 121 | } | ||
| 122 | } | 122 | } | ||
| 123 | 123 | ||||
| 124 | + | // A D-Bus idle inhibition (IdleInhibited) holds notifications like an | |||
| 125 | + | // inhibitor surface: a running timer stops, input-idle ones still fire, | |||
| 126 | + | // and held ones count from zero once it ends. D-Bus activity | |||
| 127 | + | // (IdleActivity) resumes them like input. | |||
| 128 | + | func TestBusIdleInhibition(t *testing.T) { | |||
| 129 | + | dir := t.TempDir() | |||
| 130 | + | events := make(chan ports.ClientEvent, 16) | |||
| 131 | + | inhibited := make(chan bool) | |||
| 132 | + | activity := make(chan struct{}) | |||
| 133 | + | s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs}, Channels{Events: events, IdleInhibited: inhibited, IdleActivity: activity}, logging.For(context.Background(), "wayland")) | |||
| 134 | + | if err != nil { | |||
| 135 | + | t.Fatal(err) | |||
| 136 | + | } | |||
| 137 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 138 | + | done := make(chan error, 1) | |||
| 139 | + | go func() { done <- s.Run(ctx) }() | |||
| 140 | + | t.Cleanup(func() { cancel(); <-done }) | |||
| 141 | + | c := protocolClient(t, s, dir) | |||
| 142 | + | seat := bindProtocol(t, c, "wl_seat") | |||
| 143 | + | registerProtocol(t, c, seat) | |||
| 144 | + | notifier := bindVersion(t, c, "ext_idle_notifier_v1", 2) | |||
| 145 | + | registerProtocol(t, c, notifier) | |||
| 146 | + | idled, resumed := uint32(extidlenotify.ExtIdleNotificationV1EventIdled), uint32(extidlenotify.ExtIdleNotificationV1EventResumed) | |||
| 147 | + | ||||
| 148 | + | // D-Bus activity resumes an idle notification and restarts it. | |||
| 149 | + | id, n := newEventProxy(c) | |||
| 150 | + | requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetIdleNotification, id, uint32(300), seat) | |||
| 151 | + | if e := n.next(t, c, 2*time.Second); e[0] != idled { | |||
| 152 | + | t.Fatalf("event %v, want idled", e) | |||
| 153 | + | } | |||
| 154 | + | activity <- struct{}{} | |||
| 155 | + | if e := n.next(t, c, 2*time.Second); e[0] != resumed { | |||
| 156 | + | t.Fatalf("event %v, want resumed", e) | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // Its timer runs again: the inhibition stops it before it fires. The | |||
| 160 | + | // forwarder takes the second send only once the first is applied. | |||
| 161 | + | inhibited <- true | |||
| 162 | + | inhibited <- true | |||
| 163 | + | n.none(t, c, 500*time.Millisecond) | |||
| 164 | + | inputID, input := newEventProxy(c) | |||
| 165 | + | requestProtocol(t, c, notifier, extidlenotify.ExtIdleNotifierV1RequestGetInputIdleNotification, inputID, uint32(50), seat) | |||
| 166 | + | if e := input.next(t, c, 2*time.Second); e[0] != idled { | |||
| 167 | + | t.Fatalf("input-idle event %v, want idled", e) | |||
| 168 | + | } | |||
| 169 | + | n.none(t, c, 100*time.Millisecond) | |||
| 170 | + | inhibited <- false | |||
| 171 | + | if e := n.next(t, c, 2*time.Second); e[0] != idled { | |||
| 172 | + | t.Fatalf("event %v, want idled after the inhibition", e) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 124 | // An output power object reports the mode, turns set_mode into a core | 176 | // An output power object reports the mode, turns set_mode into a core | ||
| 125 | // event, hears core's decision, and fails when its output goes. | 177 | // event, hears core's decision, and fails when its output goes. | ||
| 126 | func TestOutputPower(t *testing.T) { | 178 | func TestOutputPower(t *testing.T) { | ||
M internal/adapters/wayland/inhibit.go +1 -1
| @@ -127,7 +127,7 @@ func (s *Server) syncInhibitors() { | |||
| 127 | 127 | for id, on := range diffSets(s.idleWindows, idle) { | |
| 128 | 128 | s.emit(ports.IdleInhibit{Window: id, Active: on}) | |
| 129 | 129 | } | |
| 130 | - | wasHeld := len(s.idleWindows) > 0 | |
| 130 | + | wasHeld := s.inhibited() | |
| 131 | 131 | s.shortcutWindows, s.idleWindows = shortcuts, idle | |
| 132 | 132 | s.syncIdle(wasHeld) | |
| 133 | 133 | } | |
| @@ -127,7 +127,7 @@ func (s *Server) syncInhibitors() { | |||||
| 127 | for id, on := range diffSets(s.idleWindows, idle) { | 127 | for id, on := range diffSets(s.idleWindows, idle) { | ||
| 128 | s.emit(ports.IdleInhibit{Window: id, Active: on}) | 128 | s.emit(ports.IdleInhibit{Window: id, Active: on}) | ||
| 129 | } | 129 | } | ||
| 130 | - | wasHeld := len(s.idleWindows) > 0 | 130 | + | wasHeld := s.inhibited() |
| 131 | s.shortcutWindows, s.idleWindows = shortcuts, idle | 131 | s.shortcutWindows, s.idleWindows = shortcuts, idle | ||
| 132 | s.syncIdle(wasHeld) | 132 | s.syncIdle(wasHeld) | ||
| 133 | } | 133 | } | ||
M internal/adapters/wayland/server.go +11 -1
| @@ -84,6 +84,13 @@ type Channels struct { | |||
| 84 | 84 | OutputHeads <-chan ports.OutputHeads | |
| 85 | 85 | OutputApply chan<- ports.OutputApply | |
| 86 | 86 | OutputApplied <-chan ports.OutputApplied | |
| 87 | + | // IdleInhibited reports whether D-Bus clients (org.freedesktop.ScreenSaver) | |
| 88 | + | // inhibit idle; while true, it holds idle notifications like an inhibitor | |
| 89 | + | // surface does. | |
| 90 | + | IdleInhibited <-chan bool | |
| 91 | + | // IdleActivity carries D-Bus SimulateUserActivity calls: idle | |
| 92 | + | // notifications resume and restart as on input. | |
| 93 | + | IdleActivity <-chan struct{} | |
| 87 | 94 | } | |
| 88 | 95 | ||
| 89 | 96 | type Server struct { | |
| @@ -209,6 +216,8 @@ type Server struct { | |||
| 209 | 216 | // shortcutWindows and idleWindows what core was told. | |
| 210 | 217 | inhibitors []*inhibitor | |
| 211 | 218 | shortcutWindows, idleWindows map[ports.WindowID]bool | |
| 219 | + | // busInhibited is the last IdleInhibited value (idle.go). | |
| 220 | + | busInhibited bool | |
| 212 | 221 | // idleNotes and powers are the idle notifications and output power | |
| 213 | 222 | // objects (idle.go); outputsOff the outputs core turned off. | |
| 214 | 223 | idleNotes []*idleNotification | |
| @@ -437,7 +446,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 437 | 446 | s.started = time.Now() | |
| 438 | 447 | s.ctx = ctx | |
| 439 | 448 | var wg sync.WaitGroup | |
| 440 | - | wg.Add(14) | |
| 449 | + | wg.Add(15) | |
| 441 | 450 | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | |
| 442 | 451 | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | |
| 443 | 452 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | |
| @@ -482,6 +491,7 @@ func (s *Server) Run(ctx context.Context) error { | |||
| 482 | 491 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | |
| 483 | 492 | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | |
| 484 | 493 | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | |
| 494 | + | go func() { defer wg.Done(); s.forwardBusIdle(ctx) }() | |
| 485 | 495 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | |
| 486 | 496 | if s.syncWait != nil { | |
| 487 | 497 | wg.Add(1) | |
| @@ -84,6 +84,13 @@ type Channels struct { | |||||
| 84 | OutputHeads <-chan ports.OutputHeads | 84 | OutputHeads <-chan ports.OutputHeads | ||
| 85 | OutputApply chan<- ports.OutputApply | 85 | OutputApply chan<- ports.OutputApply | ||
| 86 | OutputApplied <-chan ports.OutputApplied | 86 | OutputApplied <-chan ports.OutputApplied | ||
| 87 | + | // IdleInhibited reports whether D-Bus clients (org.freedesktop.ScreenSaver) | |||
| 88 | + | // inhibit idle; while true, it holds idle notifications like an inhibitor | |||
| 89 | + | // surface does. | |||
| 90 | + | IdleInhibited <-chan bool | |||
| 91 | + | // IdleActivity carries D-Bus SimulateUserActivity calls: idle | |||
| 92 | + | // notifications resume and restart as on input. | |||
| 93 | + | IdleActivity <-chan struct{} | |||
| 87 | } | 94 | } | ||
| 88 | 95 | ||||
| 89 | type Server struct { | 96 | type Server struct { | ||
| @@ -209,6 +216,8 @@ type Server struct { | |||||
| 209 | // shortcutWindows and idleWindows what core was told. | 216 | // shortcutWindows and idleWindows what core was told. | ||
| 210 | inhibitors []*inhibitor | 217 | inhibitors []*inhibitor | ||
| 211 | shortcutWindows, idleWindows map[ports.WindowID]bool | 218 | shortcutWindows, idleWindows map[ports.WindowID]bool | ||
| 219 | + | // busInhibited is the last IdleInhibited value (idle.go). | |||
| 220 | + | busInhibited bool | |||
| 212 | // idleNotes and powers are the idle notifications and output power | 221 | // idleNotes and powers are the idle notifications and output power | ||
| 213 | // objects (idle.go); outputsOff the outputs core turned off. | 222 | // objects (idle.go); outputsOff the outputs core turned off. | ||
| 214 | idleNotes []*idleNotification | 223 | idleNotes []*idleNotification | ||
| @@ -437,7 +446,7 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 437 | s.started = time.Now() | 446 | s.started = time.Now() | ||
| 438 | s.ctx = ctx | 447 | s.ctx = ctx | ||
| 439 | var wg sync.WaitGroup | 448 | var wg sync.WaitGroup | ||
| 440 | - | wg.Add(14) | 449 | + | wg.Add(15) |
| 441 | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | 450 | go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }() | ||
| 442 | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | 451 | go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }() | ||
| 443 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | 452 | go func() { defer wg.Done(); s.forwardLeases(ctx) }() | ||
| @@ -482,6 +491,7 @@ func (s *Server) Run(ctx context.Context) error { | |||||
| 482 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | 491 | go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }() | ||
| 483 | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | 492 | go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }() | ||
| 484 | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | 493 | go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }() | ||
| 494 | + | go func() { defer wg.Done(); s.forwardBusIdle(ctx) }() | |||
| 485 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | 495 | go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }() | ||
| 486 | if s.syncWait != nil { | 496 | if s.syncWait != nil { | ||
| 487 | wg.Add(1) | 497 | wg.Add(1) | ||
M internal/app/app.go +17 -1
| @@ -20,6 +20,7 @@ import ( | |||
| 20 | 20 | "github.com/bnema/neferwl/internal/adapters/launcher" | |
| 21 | 21 | "github.com/bnema/neferwl/internal/adapters/libinput" | |
| 22 | 22 | "github.com/bnema/neferwl/internal/adapters/sched" | |
| 23 | + | "github.com/bnema/neferwl/internal/adapters/screensaver" | |
| 23 | 24 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | |
| 24 | 25 | "github.com/bnema/neferwl/internal/adapters/statefile" | |
| 25 | 26 | "github.com/bnema/neferwl/internal/adapters/vulkan" | |
| @@ -119,6 +120,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 119 | 120 | leaseEvents := make(chan ports.LeaseMessage, 32) | |
| 120 | 121 | applyOutput := make(chan ports.OutputApply, 8) | |
| 121 | 122 | appliedOutput := make(chan ports.OutputApplied, 8) | |
| 123 | + | idleInhibited := make(chan bool, 1) | |
| 124 | + | idleActivity := make(chan struct{}, 1) | |
| 122 | 125 | var scales chan ports.ScaleChanged | |
| 123 | 126 | if hw != nil { | |
| 124 | 127 | // Only real sessions save scales: headless runs never touch the config file. | |
| @@ -151,7 +154,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 151 | 154 | } else { | |
| 152 | 155 | captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog) | |
| 153 | 156 | } | |
| 154 | - | server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) | |
| 157 | + | server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput, IdleInhibited: idleInhibited, IdleActivity: idleActivity}, logging.For(ctx, "wayland")) | |
| 155 | 158 | if err != nil { | |
| 156 | 159 | km.Close() | |
| 157 | 160 | return err | |
| @@ -318,6 +321,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 318 | 321 | if hw != nil { | |
| 319 | 322 | workers.Add(1) | |
| 320 | 323 | go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }() | |
| 324 | + | // Only a real session serves the session bus: headless runs would take | |
| 325 | + | // the name from the desktop they run in. | |
| 326 | + | workers.Add(1) | |
| 327 | + | go func() { | |
| 328 | + | defer workers.Done() | |
| 329 | + | reports := screensaver.Reports{Held: idleInhibited, Activity: idleActivity} | |
| 330 | + | screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver")) | |
| 331 | + | }() | |
| 321 | 332 | } | |
| 322 | 333 | var result error | |
| 323 | 334 | select { | |
| @@ -350,6 +361,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 350 | 361 | return result | |
| 351 | 362 | } | |
| 352 | 363 | ||
| 364 | + | // screensaverRetry is the first delay before the screensaver service tries | |
| 365 | + | // the session bus again: without one, only surface inhibitors count, and | |
| 366 | + | // the session goes on either way. | |
| 367 | + | const screensaverRetry = 2 * time.Second | |
| 368 | + | ||
| 353 | 369 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | |
| 354 | 370 | // to the input goroutine and sends it to clients; a repeat-only change just updates | |
| 355 | 371 | // clients. A keymap that fails to build keeps the previous layout. A touchpad | |
| @@ -20,6 +20,7 @@ import ( | |||||
| 20 | "github.com/bnema/neferwl/internal/adapters/launcher" | 20 | "github.com/bnema/neferwl/internal/adapters/launcher" | ||
| 21 | "github.com/bnema/neferwl/internal/adapters/libinput" | 21 | "github.com/bnema/neferwl/internal/adapters/libinput" | ||
| 22 | "github.com/bnema/neferwl/internal/adapters/sched" | 22 | "github.com/bnema/neferwl/internal/adapters/sched" | ||
| 23 | + | "github.com/bnema/neferwl/internal/adapters/screensaver" | |||
| 23 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | 24 | "github.com/bnema/neferwl/internal/adapters/sessionsecurity" | ||
| 24 | "github.com/bnema/neferwl/internal/adapters/statefile" | 25 | "github.com/bnema/neferwl/internal/adapters/statefile" | ||
| 25 | "github.com/bnema/neferwl/internal/adapters/vulkan" | 26 | "github.com/bnema/neferwl/internal/adapters/vulkan" | ||
| @@ -119,6 +120,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 119 | leaseEvents := make(chan ports.LeaseMessage, 32) | 120 | leaseEvents := make(chan ports.LeaseMessage, 32) | ||
| 120 | applyOutput := make(chan ports.OutputApply, 8) | 121 | applyOutput := make(chan ports.OutputApply, 8) | ||
| 121 | appliedOutput := make(chan ports.OutputApplied, 8) | 122 | appliedOutput := make(chan ports.OutputApplied, 8) | ||
| 123 | + | idleInhibited := make(chan bool, 1) | |||
| 124 | + | idleActivity := make(chan struct{}, 1) | |||
| 122 | var scales chan ports.ScaleChanged | 125 | var scales chan ports.ScaleChanged | ||
| 123 | if hw != nil { | 126 | if hw != nil { | ||
| 124 | // Only real sessions save scales: headless runs never touch the config file. | 127 | // Only real sessions save scales: headless runs never touch the config file. | ||
| @@ -151,7 +154,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 151 | } else { | 154 | } else { | ||
| 152 | captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog) | 155 | captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog) | ||
| 153 | } | 156 | } | ||
| 154 | - | server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput}, logging.For(ctx, "wayland")) | 157 | + | server, err := wayland.New(wayland.Options{CaptureAllow: captureAllow, WorkspaceIDs: wsIDs, Security: security, RuntimeDir: runtimeDir, DMABuf: dmabuf, SyncobjNode: renderNode(dmabuf.Device), Keymap: keymap, RepeatRate: opts.Config.Keyboard.RepeatRate, RepeatDelay: opts.Config.Keyboard.RepeatDelay}, wayland.Channels{SecurityChanges: securityChanges, SecurityEvents: securityEvents, Events: client, Commands: commands, Workspaces: workspaces, Contents: contents, Cursors: cursorChanges, Presented: presented, Captures: captures, Captured: captured, OutputFormats: outputFormats, OutputHeads: outputHeads, LeaseRequests: leaseRequests, LeaseEvents: leaseEvents, OutputApply: applyOutput, OutputApplied: appliedOutput, IdleInhibited: idleInhibited, IdleActivity: idleActivity}, logging.For(ctx, "wayland")) |
| 155 | if err != nil { | 158 | if err != nil { | ||
| 156 | km.Close() | 159 | km.Close() | ||
| 157 | return err | 160 | return err | ||
| @@ -318,6 +321,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 318 | if hw != nil { | 321 | if hw != nil { | ||
| 319 | workers.Add(1) | 322 | workers.Add(1) | ||
| 320 | go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }() | 323 | go func() { defer workers.Done(); done <- safe("seat", func() error { return hw.seat.Run(ctx) }) }() | ||
| 324 | + | // Only a real session serves the session bus: headless runs would take | |||
| 325 | + | // the name from the desktop they run in. | |||
| 326 | + | workers.Add(1) | |||
| 327 | + | go func() { | |||
| 328 | + | defer workers.Done() | |||
| 329 | + | reports := screensaver.Reports{Held: idleInhibited, Activity: idleActivity} | |||
| 330 | + | screensaver.Serve(ctx, "", reports, screensaverRetry, logging.For(ctx, "screensaver")) | |||
| 331 | + | }() | |||
| 321 | } | 332 | } | ||
| 322 | var result error | 333 | var result error | ||
| 323 | select { | 334 | select { | ||
| @@ -350,6 +361,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 350 | return result | 361 | return result | ||
| 351 | } | 362 | } | ||
| 352 | 363 | ||||
| 364 | + | // screensaverRetry is the first delay before the screensaver service tries | |||
| 365 | + | // the session bus again: without one, only surface inhibitors count, and | |||
| 366 | + | // the session goes on either way. | |||
| 367 | + | const screensaverRetry = 2 * time.Second | |||
| 368 | + | ||||
| 353 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | 369 | // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it | ||
| 354 | // to the input goroutine and sends it to clients; a repeat-only change just updates | 370 | // to the input goroutine and sends it to clients; a repeat-only change just updates | ||
| 355 | // clients. A keymap that fails to build keeps the previous layout. A touchpad | 371 | // clients. A keymap that fails to build keeps the previous layout. A touchpad | ||
M internal/logging/logging.go +1 -0
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | var components = map[string]bool{ | |
| 19 | 19 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | |
| 20 | 20 | "render": true, "sync": true, "config": true, "app": true, | |
| 21 | + | "screensaver": true, | |
| 21 | 22 | } | |
| 22 | 23 | ||
| 23 | 24 | // categories are debug switches narrower than a component, only on when | |
| @@ -18,6 +18,7 @@ import ( | |||||
| 18 | var components = map[string]bool{ | 18 | var components = map[string]bool{ | ||
| 19 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | 19 | "core": true, "wayland": true, "input": true, "drm": true, "seat": true, | ||
| 20 | "render": true, "sync": true, "config": true, "app": true, | 20 | "render": true, "sync": true, "config": true, "app": true, | ||
| 21 | + | "screensaver": true, | |||
| 21 | } | 22 | } | ||
| 22 | 23 | ||||
| 23 | // categories are debug switches narrower than a component, only on when | 24 | // categories are debug switches narrower than a component, only on when | ||