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/neferwl/internal/adapters/busretry"
16 "github.com/bnema/zerowrap"
17 "github.com/godbus/dbus/v5"
18 "github.com/godbus/dbus/v5/introspect"
19)
20
21const (
22 busName = "org.freedesktop.ScreenSaver"
23 iface = "org.freedesktop.ScreenSaver"
24 // busDaemon is the sender of the bus's own signals. Any client can send
25 // a NameOwnerChanged to us directly, so only this sender is trusted.
26 busDaemon = "org.freedesktop.DBus"
27 // callTimeout bounds the bus calls Run makes itself.
28 callTimeout = 3 * time.Second
29)
30
31// ErrNameTaken is returned by Run when another program owns the name.
32var ErrNameTaken = errors.New(busName + " is owned by another program")
33
34// paths: freedesktop clients use the first, older KDE-era ones the second.
35var paths = []dbus.ObjectPath{"/org/freedesktop/ScreenSaver", "/ScreenSaver"}
36
37var methods = []introspect.Method{
38 {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"}}},
39 {Name: "UnInhibit", Args: []introspect.Arg{{Name: "cookie", Type: "u", Direction: "in"}}},
40 {Name: "GetActive", Args: []introspect.Arg{{Type: "b", Direction: "out"}}},
41 {Name: "GetActiveTime", Args: []introspect.Arg{{Type: "u", Direction: "out"}}},
42 {Name: "SetActive", Args: []introspect.Arg{{Name: "e", Type: "b", Direction: "in"}, {Type: "b", Direction: "out"}}},
43 {Name: "SimulateUserActivity"},
44}
45
46// Reports are what Run tells wayland. Held carries each change of whether
47// any inhibition is in effect; Activity carries SimulateUserActivity calls,
48// coalesced: a send never blocks, so give it a buffer of one.
49type Reports struct {
50 Held chan<- bool
51 Activity chan<- struct{}
52}
53
54// Serve runs the service until ctx ends. When the bus goes or another
55// program owns the name, it tries again after retry, doubling up to
56// a minute, so a restarted bus or a released name is picked up.
57func Serve(ctx context.Context, address string, r Reports, retry time.Duration, log zerowrap.Logger) {
58 busretry.Run(ctx, retry, "D-Bus idle inhibitors", log, func(ctx context.Context) error {
59 s, err := New(ctx, address, log)
60 if err != nil {
61 return err
62 }
63 return s.Run(ctx, r)
64 })
65}
66
67// Service owns one private session bus connection. Run must be called once.
68type Service struct {
69 conn *dbus.Conn
70 log zerowrap.Logger
71 requests chan request
72 done chan struct{}
73}
74
75// request is a method call handed from a godbus goroutine to Run.
76type request struct {
77 sender string
78 cookie uint32 // UnInhibit
79 unhold bool
80 app string
81 reason string
82 reply chan reply
83}
84
85type reply struct {
86 cookie uint32
87 err *dbus.Error
88}
89
90// New connects to the bus at address, the session bus when empty. It never
91// starts a bus.
92func New(ctx context.Context, address string, log zerowrap.Logger) (*Service, error) {
93 var conn *dbus.Conn
94 var err error
95 if address == "" {
96 conn, err = dbus.SessionBusPrivateNoAutoStartup(dbus.WithContext(ctx))
97 } else {
98 conn, err = dbus.Dial(address, dbus.WithContext(ctx))
99 }
100 if err != nil {
101 return nil, fmt.Errorf("session bus: %w", err)
102 }
103 if err = conn.Auth(nil); err == nil {
104 err = conn.Hello()
105 }
106 if err != nil {
107 _ = conn.Close()
108 return nil, fmt.Errorf("session bus: %w", err)
109 }
110 return &Service{conn: conn, log: log, requests: make(chan request), done: make(chan struct{})}, nil
111}
112
113// Run serves the name until ctx ends or the bus goes, and reports to r. If
114// the bus goes while an inhibition is held, it sends false on r.Held before
115// it returns. It returns nil only once ctx ends, and ErrNameTaken at once,
116// with the connection closed, when another program owns the name.
117func (s *Service) Run(ctx context.Context, r Reports) (err error) {
118 defer close(s.done)
119 defer func() { _ = s.conn.Close() }()
120 defer func() {
121 // A bus call cut short by ctx (WithContext closes the connection)
122 // is shutdown, not a failure.
123 if ctx.Err() != nil {
124 err = nil
125 }
126 }()
127 // Method tables, not Export(v): godbus finds Export's methods by
128 // reflection, which stops the linker from pruning unused methods and
129 // grows the binary by ~4 MB.
130 h := handler{s: s, activity: r.Activity}
131 table := map[string]any{
132 "Inhibit": h.Inhibit,
133 "UnInhibit": h.UnInhibit,
134 "GetActive": h.GetActive,
135 "GetActiveTime": h.GetActiveTime,
136 "SetActive": h.SetActive,
137 "SimulateUserActivity": h.SimulateUserActivity,
138 }
139 for _, p := range paths {
140 if err := s.conn.ExportMethodTable(table, p, iface); err != nil {
141 return fmt.Errorf("export %s: %w", p, err)
142 }
143 node := &introspect.Node{Name: string(p), Interfaces: []introspect.Interface{{Name: iface, Methods: methods}}}
144 xml := string(introspect.NewIntrospectable(node))
145 intro := map[string]any{"Introspect": func() (string, *dbus.Error) { return xml, nil }}
146 if err := s.conn.ExportMethodTable(intro, p, "org.freedesktop.DBus.Introspectable"); err != nil {
147 return fmt.Errorf("export %s introspection: %w", p, err)
148 }
149 }
150 owner, err := s.conn.RequestName(busName, dbus.NameFlagDoNotQueue)
151 if err != nil {
152 return fmt.Errorf("request %s: %w", busName, err)
153 }
154 if owner != dbus.RequestNameReplyPrimaryOwner {
155 return ErrNameTaken
156 }
157 // Departures end the inhibitions of crashed clients. The match asks the
158 // bus for lost owners (new owner "") only, but the channel also gets
159 // NameAcquired and any signal a client sends to us directly: left trusts
160 // only the bus's own. A client that leaves before this match exists is
161 // caught by the onBus check on its first cookie.
162 signals := make(chan *dbus.Signal, 16)
163 s.conn.Signal(signals)
164 if err := s.conn.AddMatchSignalContext(ctx, dbus.WithMatchSender(busDaemon), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), dbus.WithMatchArg(2, "")); err != nil {
165 return fmt.Errorf("watch bus names: %w", err)
166 }
167 s.log.Info().Msg("serving " + busName)
168 reg := newRegistry()
169 sent := false
170 report := func() {
171 if h := reg.held(); h != sent {
172 select {
173 case r.Held <- h:
174 sent = h
175 case <-ctx.Done():
176 }
177 }
178 }
179 defer func() {
180 if sent && ctx.Err() == nil {
181 // The bus went, the session goes on: nothing it held keeps it awake.
182 select {
183 case r.Held <- false:
184 case <-ctx.Done():
185 }
186 }
187 }()
188 for {
189 select {
190 case <-ctx.Done():
191 return nil
192 case sig, ok := <-signals:
193 if !ok {
194 if ctx.Err() != nil {
195 return nil // WithContext closed the connection
196 }
197 return errors.New("session bus connection closed")
198 }
199 if name, gone := left(sig); gone {
200 reg.drop(name)
201 report()
202 }
203 case req := <-s.requests:
204 if req.unhold {
205 if reg.uninhibit(req.sender, req.cookie) {
206 s.log.Debug().Str("sender", req.sender).Uint32("cookie", req.cookie).Msg("uninhibit")
207 }
208 req.reply <- reply{}
209 report()
210 continue
211 }
212 cookie, first, ok := reg.inhibit(req.sender)
213 if !ok {
214 req.reply <- reply{err: dbus.MakeFailedError(errors.New("too many inhibitors"))}
215 continue
216 }
217 req.reply <- reply{cookie: cookie}
218 if first && !s.onBus(ctx, req.sender) {
219 // It left before its call reached us (its signal came first),
220 // or the bus could not tell: drop it rather than hold the
221 // session awake for good.
222 s.log.Debug().Str("sender", req.sender).Uint32("cookie", cookie).Msg("inhibitor not on the bus; dropped")
223 reg.drop(req.sender)
224 } else {
225 s.log.Debug().Str("sender", req.sender).Str("app", req.app).Str("reason", req.reason).Uint32("cookie", cookie).Msg("inhibit")
226 }
227 report()
228 }
229 }
230}
231
232// onBus reports whether a unique connection name still has an owner. Its
233// departure signal may already be handled, so an unanswered query is tried
234// once more, then counts as gone: a lost inhibition is better than one that
235// keeps the session awake until the compositor restarts.
236func (s *Service) onBus(ctx context.Context, name string) bool {
237 for range 2 {
238 callCtx, cancel := context.WithTimeout(ctx, callTimeout)
239 var has bool
240 err := s.conn.BusObject().CallWithContext(callCtx, "org.freedesktop.DBus.NameHasOwner", 0, name).Store(&has)
241 cancel()
242 if err == nil {
243 return has
244 }
245 s.log.Debug().Err(err).Str("sender", name).Msg("NameHasOwner failed")
246 if ctx.Err() != nil {
247 break
248 }
249 }
250 return false
251}
252
253// left reports a unique connection name that lost its owner, from the
254// bus's own NameOwnerChanged only.
255func left(sig *dbus.Signal) (string, bool) {
256 if sig.Sender != busDaemon || sig.Name != "org.freedesktop.DBus.NameOwnerChanged" || len(sig.Body) != 3 {
257 return "", false
258 }
259 name, _ := sig.Body[0].(string)
260 owner, _ := sig.Body[2].(string)
261 return name, strings.HasPrefix(name, ":") && owner == ""
262}
263
264// call hands a request to Run, or fails once Run has stopped.
265func (s *Service) call(req request) reply {
266 req.reply = make(chan reply, 1)
267 select {
268 case s.requests <- req:
269 return <-req.reply
270 case <-s.done:
271 return reply{err: dbus.MakeFailedError(errors.New("screensaver service stopped"))}
272 }
273}
274
275// handler is the exported object; godbus runs each call on its own
276// goroutine, so it only forwards to Run.
277type handler struct {
278 s *Service
279 activity chan<- struct{}
280}
281
282func (h handler) Inhibit(sender dbus.Sender, app, reason string) (uint32, *dbus.Error) {
283 r := h.s.call(request{sender: string(sender), app: app, reason: reason})
284 return r.cookie, r.err
285}
286
287func (h handler) UnInhibit(sender dbus.Sender, cookie uint32) *dbus.Error {
288 return h.s.call(request{sender: string(sender), cookie: cookie, unhold: true}).err
289}
290
291// GetActive: there is no screensaver to be active, only idle inhibition.
292func (handler) GetActive() (bool, *dbus.Error) { return false, nil }
293
294// GetActiveTime is the seconds the screensaver has been active: never.
295func (handler) GetActiveTime() (uint32, *dbus.Error) { return 0, nil }
296
297// SetActive cannot start a screensaver there is none of: it reports
298// failure, as the specification allows.
299func (handler) SetActive(bool) (bool, *dbus.Error) { return false, nil }
300
301// SimulateUserActivity counts as input: idle notifications resume and
302// their timers restart. Calls between two reads collapse into one.
303func (h handler) SimulateUserActivity() *dbus.Error {
304 select {
305 case h.activity <- struct{}{}:
306 default:
307 }
308 return nil
309}