v0.4.0 b6be3a8

screensaver.go RawHistoryFolder

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}

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 25

Tags 10

↑↓ moveenter openesc close