feat/hints a471fa4

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/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}

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