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.
6package screensaver
7
8import (
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
20const (
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.
33var ErrNameTaken = errors.New(busName + " is owned by another program")
34
35// paths: freedesktop clients use the first, older KDE-era ones the second.
36var paths = []dbus.ObjectPath{"/org/freedesktop/ScreenSaver", "/ScreenSaver"}
37
38var 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.
50type 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.
58func 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.
94type 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.
102type request struct {
103 sender string
104 cookie uint32 // UnInhibit
105 unhold bool
106 app string
107 reason string
108 reply chan reply
109}
110
111type 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.
118func 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.
143func (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.
262func (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.
281func 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.
291func (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.
303type handler struct {
304 s *Service
305 activity chan<- struct{}
306}
307
308func (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
313func (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.
318func (handler) GetActive() (bool, *dbus.Error) { return false, nil }
319
320// GetActiveTime is the seconds the screensaver has been active: never.
321func (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.
325func (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.
329func (h handler) SimulateUserActivity() *dbus.Error {
330 select {
331 case h.activity <- struct{}{}:
332 default:
333 }
334 return nil
335}