detached 245942b

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 UnverifiedB5690EEEBB952194 The key is unknown
14 files · +1020 -9
M .github/workflows/ci.yml +3 -2
@@ -21,11 +21,12 @@ jobs:
2121 XDG_RUNTIME_DIR: /tmp/xdg-runtime
2222 steps:
2323 # 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.
2526 - run: >-
2627 pacman -Syu --noconfirm --needed
2728 git go make gcc shaderc wayland libxkbcommon libinput seatd
28- vulkan-icd-loader vulkan-swrast
29+ vulkan-icd-loader vulkan-swrast dbus
2930 - uses: actions/checkout@v4
3031 - run: |
3132 git config --global --add safe.directory "$GITHUB_WORKSPACE"
@@ -21,11 +21,12 @@ jobs:
21 XDG_RUNTIME_DIR: /tmp/xdg-runtime21 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 --needed27 pacman -Syu --noconfirm --needed
27 git go make gcc shaderc wayland libxkbcommon libinput seatd28 git go make gcc shaderc wayland libxkbcommon libinput seatd
28- vulkan-icd-loader vulkan-swrast29+ vulkan-icd-loader vulkan-swrast dbus
29 - uses: actions/checkout@v430 - 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
7979
8080 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.
8181
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+
8284 ```text
8385 startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
8486 ```
@@ -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 ```text84 ```text
83 startup = swayidle -w timeout 300 wlopm-off resume wlopm-on85 startup = swayidle -w timeout 300 wlopm-off resume wlopm-on
84 ```86 ```
M go.mod +1 -0
@@ -9,6 +9,7 @@ require (
99 github.com/bnema/purego-vulkan v0.6.0
1010 github.com/bnema/wlturbo v0.5.0
1111 github.com/bnema/zerowrap v1.4.1
12+ github.com/godbus/dbus/v5 v5.2.2
1213 github.com/rs/zerolog v1.35.1
1314 github.com/stretchr/testify v1.12.1
1415 golang.org/x/sys v0.48.0
@@ -9,6 +9,7 @@ require (
9 github.com/bnema/purego-vulkan v0.6.09 github.com/bnema/purego-vulkan v0.6.0
10 github.com/bnema/wlturbo v0.5.010 github.com/bnema/wlturbo v0.5.0
11 github.com/bnema/zerowrap v1.4.111 github.com/bnema/zerowrap v1.4.1
12+ github.com/godbus/dbus/v5 v5.2.2
12 github.com/rs/zerolog v1.35.113 github.com/rs/zerolog v1.35.1
13 github.com/stretchr/testify v1.12.114 github.com/stretchr/testify v1.12.1
14 golang.org/x/sys v0.48.015 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 &registry{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 &registry{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 @@
11 package wayland
22
33 import (
4+ "context"
45 "time"
56
67 "github.com/bnema/go-wayland-bindings/server/extidlenotify"
@@ -11,8 +12,10 @@ import (
1112 )
1213
1314 // 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.
1619 // zwlr_output_power_v1: a client such as wlopm turns a display off or on;
1720 // core decides and the scene carries it.
1821
@@ -80,7 +83,46 @@ func (s *Server) addIdle(r *extidlenotify.ExtIdleNotifierV1, id, timeout uint32,
8083
8184 // idleHeld reports whether an idle inhibitor keeps n from firing.
8285 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+ }
84126 }
85127
86128 // armIdle restarts n's timer, unless an inhibitor holds it. The timer
@@ -127,7 +169,7 @@ func (s *Server) userActivity() {
127169 // syncIdle follows idle inhibitors: while one exists, inhibitable timers
128170 // stop; once the last one goes, they start again from zero.
129171 func (s *Server) syncIdle(wasHeld bool) {
130- if held := len(s.idleWindows) > 0; held != wasHeld {
172+ if held := s.inhibited(); held != wasHeld {
131173 for _, n := range s.idleNotes {
132174 if !n.input {
133175 s.armIdle(n)
@@ -1,6 +1,7 @@
1 package wayland1 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 its14 // 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) > 086+ 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 timer128 // 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 timers169 // 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) {
121121 }
122122 }
123123
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+
124176 // An output power object reports the mode, turns set_mode into a core
125177 // event, hears core's decision, and fails when its output goes.
126178 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 core176 // 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() {
127127 for id, on := range diffSets(s.idleWindows, idle) {
128128 s.emit(ports.IdleInhibit{Window: id, Active: on})
129129 }
130- wasHeld := len(s.idleWindows) > 0
130+ wasHeld := s.inhibited()
131131 s.shortcutWindows, s.idleWindows = shortcuts, idle
132132 s.syncIdle(wasHeld)
133133 }
@@ -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) > 0130+ wasHeld := s.inhibited()
131 s.shortcutWindows, s.idleWindows = shortcuts, idle131 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 {
8484 OutputHeads <-chan ports.OutputHeads
8585 OutputApply chan<- ports.OutputApply
8686 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{}
8794 }
8895
8996 type Server struct {
@@ -209,6 +216,8 @@ type Server struct {
209216 // shortcutWindows and idleWindows what core was told.
210217 inhibitors []*inhibitor
211218 shortcutWindows, idleWindows map[ports.WindowID]bool
219+ // busInhibited is the last IdleInhibited value (idle.go).
220+ busInhibited bool
212221 // idleNotes and powers are the idle notifications and output power
213222 // objects (idle.go); outputsOff the outputs core turned off.
214223 idleNotes []*idleNotification
@@ -437,7 +446,7 @@ func (s *Server) Run(ctx context.Context) error {
437446 s.started = time.Now()
438447 s.ctx = ctx
439448 var wg sync.WaitGroup
440- wg.Add(14)
449+ wg.Add(15)
441450 go func() { defer wg.Done(); s.forwardBackendSecurity(ctx) }()
442451 go func() { defer wg.Done(); s.forwardSecurityEvents(ctx) }()
443452 go func() { defer wg.Done(); s.forwardLeases(ctx) }()
@@ -482,6 +491,7 @@ func (s *Server) Run(ctx context.Context) error {
482491 go func() { defer wg.Done(); s.forwardOutputFormats(ctx) }()
483492 go func() { defer wg.Done(); s.forwardOutputHeads(ctx) }()
484493 go func() { defer wg.Done(); s.forwardOutputApplied(ctx) }()
494+ go func() { defer wg.Done(); s.forwardBusIdle(ctx) }()
485495 go func() { defer wg.Done(); s.forwardCaptured(ctx.Done()) }()
486496 if s.syncWait != nil {
487497 wg.Add(1)
@@ -84,6 +84,13 @@ type Channels struct {
84 OutputHeads <-chan ports.OutputHeads84 OutputHeads <-chan ports.OutputHeads
85 OutputApply chan<- ports.OutputApply85 OutputApply chan<- ports.OutputApply
86 OutputApplied <-chan ports.OutputApplied86 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 []*inhibitor217 inhibitors []*inhibitor
211 shortcutWindows, idleWindows map[ports.WindowID]bool218 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 power221 // 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 []*idleNotification223 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 = ctx447 s.ctx = ctx
439 var wg sync.WaitGroup448 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 (
2020 "github.com/bnema/neferwl/internal/adapters/launcher"
2121 "github.com/bnema/neferwl/internal/adapters/libinput"
2222 "github.com/bnema/neferwl/internal/adapters/sched"
23+ "github.com/bnema/neferwl/internal/adapters/screensaver"
2324 "github.com/bnema/neferwl/internal/adapters/sessionsecurity"
2425 "github.com/bnema/neferwl/internal/adapters/statefile"
2526 "github.com/bnema/neferwl/internal/adapters/vulkan"
@@ -119,6 +120,8 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
119120 leaseEvents := make(chan ports.LeaseMessage, 32)
120121 applyOutput := make(chan ports.OutputApply, 8)
121122 appliedOutput := make(chan ports.OutputApplied, 8)
123+ idleInhibited := make(chan bool, 1)
124+ idleActivity := make(chan struct{}, 1)
122125 var scales chan ports.ScaleChanged
123126 if hw != nil {
124127 // 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)
151154 } else {
152155 captureAllow = captureallow.NewStore(captureallow.DefaultPath, captureAllowLog)
153156 }
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"))
155158 if err != nil {
156159 km.Close()
157160 return err
@@ -318,6 +321,14 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
318321 if hw != nil {
319322 workers.Add(1)
320323 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+ }()
321332 }
322333 var result error
323334 select {
@@ -350,6 +361,11 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
350361 return result
351362 }
352363
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+
353369 // relayConfig forwards reloads to core. A layout change builds a new keymap, hands it
354370 // to the input goroutine and sends it to clients; a repeat-only change just updates
355371 // 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.ScaleChanged125 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 err160 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 error333 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 result361 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 it369 // 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 updates370 // 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 touchpad371 // 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 (
1818 var components = map[string]bool{
1919 "core": true, "wayland": true, "input": true, "drm": true, "seat": true,
2020 "render": true, "sync": true, "config": true, "app": true,
21+ "screensaver": true,
2122 }
2223
2324 // 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 when24 // categories are debug switches narrower than a component, only on when

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close