v0.5.1 31f302d

logind.go RawHistoryFolder

1// Package logind observes the current process's session and owns bounded
2// logind calls/inhibitor descriptors. It contains no lock or sleep policy.
3package logind
4
5import (
6	"context"
7	"errors"
8	"fmt"
9	"math"
10	"os"
11	"strings"
12	"time"
13
14	"github.com/bnema/neferafk/internal/ports"
15	"github.com/godbus/dbus/v5"
16	"golang.org/x/sys/unix"
17)
18
19const service = "org.freedesktop.login1"
20const managerPath dbus.ObjectPath = "/org/freedesktop/login1"
21const managerInterface = "org.freedesktop.login1.Manager"
22const sessionInterface = "org.freedesktop.login1.Session"
23const userInterface = "org.freedesktop.login1.User"
24const callTimeout = 3 * time.Second
25
26// Adapter is single-owner: Probe, Run and Close must not execute concurrently.
27// Run owns the connection and closes it and any inhibitor on every exit.
28type Adapter struct {
29	bus          bus
30	requirements ports.SystemRequirements
31	owner        string
32	session      dbus.ObjectPath
33	// sessionSource names how session was found, for diagnostics.
34	sessionSource string
35	caps          ports.SystemCapabilities
36	signals       chan *dbus.Signal
37	inhibitor     *os.File
38	cycle         uint64
39	preparing     bool
40	closed        bool
41}
42
43// Open connects only when requested. Calling it is unnecessary when all system
44// features are off. Connection failure is an unsupported capability, not a
45// reason to force system initialization at daemon startup.
46func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) {
47	if !requirements.Session && !requirements.Sleep {
48		return nil, errors.New("logind not requested")
49	}
50	conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx))
51	if err != nil {
52		return nil, fmt.Errorf("system bus unavailable: %w", err)
53	}
54	a := newAdapter(connection{conn}, requirements)
55	if err := a.Probe(ctx); err != nil {
56		a.Close()
57		return nil, err
58	}
59	return a, nil
60}
61func newAdapter(b bus, r ports.SystemRequirements) *Adapter {
62	return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)}
63}
64func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
65	ctx, cancel := context.WithTimeout(ctx, callTimeout)
66	defer cancel()
67	return a.bus.call(ctx, a.owner, path, method, args...)
68}
69func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) {
70	body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name)
71	if err != nil {
72		return nil, err
73	}
74	if len(body) != 1 {
75		return nil, errors.New("invalid property reply")
76	}
77	v, ok := body[0].(dbus.Variant)
78	if !ok {
79		return nil, errors.New("invalid property variant")
80	}
81	return v.Value(), nil
82}
83
84// Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the
85// unique service owner, the current session (PID, XDG_SESSION_ID or the user's
86// Display session) and its real UID; no user override exists.
87func (a *Adapter) Probe(ctx context.Context) error {
88	bounded, cancel := context.WithTimeout(ctx, callTimeout)
89	defer cancel()
90	body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service)
91	if err != nil {
92		return fmt.Errorf("logind unavailable: %w", err)
93	}
94	if len(body) != 1 {
95		return errors.New("invalid logind owner reply")
96	}
97	owner, ok := body[0].(string)
98	if !ok || !strings.HasPrefix(owner, ":") {
99		return errors.New("invalid unique logind owner")
100	}
101	a.owner = owner
102	a.caps = ports.SystemCapabilities{}
103	a.session, a.sessionSource = "", ""
104	if a.requirements.Session {
105		var source string
106		body, source, err = a.currentSession(ctx)
107		if err != nil {
108			return err
109		}
110		if len(body) != 1 {
111			return errors.New("invalid session reply")
112		}
113		path, ok := body[0].(dbus.ObjectPath)
114		if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") {
115			return errors.New("invalid session path")
116		}
117		value, err := a.property(ctx, path, sessionInterface, "User")
118		if err != nil {
119			return err
120		}
121		fields, ok := value.([]any)
122		if !ok || len(fields) != 2 {
123			return errors.New("invalid session User property")
124		}
125		uid, ok := fields[0].(uint32)
126		if !ok || uid != uint32(os.Getuid()) {
127			return errors.New("session does not belong to real UID")
128		}
129		a.session, a.sessionSource = path, source
130		a.caps.Session = true
131	}
132	if a.requirements.Sleep {
133		body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend")
134		if err != nil {
135			return fmt.Errorf("suspend capability unavailable: %w", err)
136		}
137		if len(body) != 1 {
138			return errors.New("invalid CanSuspend reply")
139		}
140		auth, ok := body[0].(string)
141		if !ok {
142			return errors.New("invalid CanSuspend type")
143		}
144		a.caps.SuspendAuthorization = auth
145		// Suspend(false) cannot request an interactive authorization prompt.
146		a.caps.Suspend = auth == "yes"
147		if !a.caps.Suspend {
148			a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth)
149		}
150		value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec")
151		if err != nil {
152			return err
153		}
154		usec, ok := value.(uint64)
155		if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) {
156			return errors.New("invalid inhibitor delay maximum")
157		}
158		a.caps.DelayMax = time.Duration(usec) * time.Microsecond
159		if !a.bus.unixFDs() {
160			a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing")
161			a.caps.Suspend = false
162		}
163	}
164	return nil
165}
166
167// currentSession resolves the process's session by PID. A compositor started
168// as a systemd user service runs outside the session scope, so its children
169// have no PID session; the session the display manager opened is then taken
170// from XDG_SESSION_ID. A terminal launched by the compositor can inherit a
171// stale XDG_SESSION_ID, so the real UID's Display session is the last resort.
172// Fallback sessions must be live and graphical; Probe checks every result
173// against the real UID. It also returns which source chose the session.
174func (a *Adapter) currentSession(ctx context.Context) ([]any, string, error) {
175	body, err := a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
176	if err == nil {
177		return body, "pid", nil
178	}
179	errs := []error{fmt.Errorf("PID: %w", err)}
180	if id := os.Getenv("XDG_SESSION_ID"); id != "" {
181		path, err := a.sessionByID(ctx, id)
182		if err == nil {
183			err = a.liveGraphical(ctx, path)
184		}
185		if err == nil {
186			return []any{path}, "XDG_SESSION_ID", nil
187		}
188		errs = append(errs, fmt.Errorf("XDG_SESSION_ID: %w", err))
189	}
190	path, err := a.displaySession(ctx)
191	if err == nil {
192		err = a.liveGraphical(ctx, path)
193	}
194	if err == nil {
195		return []any{path}, "user display", nil
196	}
197	errs = append(errs, fmt.Errorf("user display session: %w", err))
198	return nil, "", fmt.Errorf("current process session unavailable: %w", errors.Join(errs...))
199}
200
201func (a *Adapter) sessionByID(ctx context.Context, id string) (dbus.ObjectPath, error) {
202	body, err := a.call(ctx, managerPath, managerInterface+".GetSession", id)
203	if err != nil {
204		return "", err
205	}
206	if len(body) != 1 {
207		return "", errors.New("invalid session reply")
208	}
209	path, ok := body[0].(dbus.ObjectPath)
210	if !ok || !path.IsValid() {
211		return "", errors.New("invalid session path")
212	}
213	return path, nil
214}
215
216// liveGraphical rejects tty, closing and lingering sessions, so a fallback
217// never binds lock signals to a session the user is not looking at. "online"
218// (logged in, not foreground) is accepted so a daemon started while the user
219// is on another VT still binds; with two graphical sessions of the same user
220// a stale XDG_SESSION_ID may thus pick the background one (same UID only).
221func (a *Adapter) liveGraphical(ctx context.Context, path dbus.ObjectPath) error {
222	value, err := a.property(ctx, path, sessionInterface, "Type")
223	if err != nil {
224		return fmt.Errorf("%s: %w", path, err)
225	}
226	if t, _ := value.(string); t != "wayland" && t != "x11" {
227		return fmt.Errorf("%s: session type %q is not graphical", path, t)
228	}
229	value, err = a.property(ctx, path, sessionInterface, "State")
230	if err != nil {
231		return fmt.Errorf("%s: %w", path, err)
232	}
233	if s, _ := value.(string); s != "active" && s != "online" {
234		return fmt.Errorf("%s: session state %q is not live", path, s)
235	}
236	return nil
237}
238
239// displaySession returns the real UID's primary (Display) session from
240// logind. logind prefers a graphical one; liveGraphical enforces it.
241func (a *Adapter) displaySession(ctx context.Context) (dbus.ObjectPath, error) {
242	body, err := a.call(ctx, managerPath, managerInterface+".GetUser", uint32(os.Getuid()))
243	if err != nil {
244		return "", err
245	}
246	if len(body) != 1 {
247		return "", errors.New("invalid user reply")
248	}
249	user, ok := body[0].(dbus.ObjectPath)
250	if !ok || !user.IsValid() {
251		return "", errors.New("invalid user path")
252	}
253	value, err := a.property(ctx, user, userInterface, "Display")
254	if err != nil {
255		return "", err
256	}
257	fields, ok := value.([]any)
258	if !ok || len(fields) != 2 {
259		return "", errors.New("invalid user Display property")
260	}
261	path, ok := fields[1].(dbus.ObjectPath)
262	if !ok || !path.IsValid() || path == "/" {
263		return "", errors.New("no display session")
264	}
265	return path, nil
266}
267
268// Session reports the bound session path and how it was found.
269func (a *Adapter) Session() (path, source string) {
270	return string(a.session), a.sessionSource
271}
272
273func (a *Adapter) Capabilities() ports.SystemCapabilities {
274	c := a.caps
275	c.Missing = append([]string(nil), c.Missing...)
276	return c
277}
278func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error {
279	select {
280	case out <- event:
281		return nil
282	case <-ctx.Done():
283		return ctx.Err()
284	}
285}
286
287// Run starts subscriptions and holds a delay inhibitor only for enabled sleep.
288// A matching SleepReady releases it. Resume reacquires for the next cycle.
289// logind's own maximum bounds the delay even if policy never replies.
290func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error {
291	defer a.Close()
292	if a.owner == "" {
293		return errors.New("logind must be probed before Run")
294	}
295	a.bus.signals(a.signals)
296	defer a.bus.removeSignals(a.signals)
297	paths := []struct {
298		path  dbus.ObjectPath
299		iface string
300	}{}
301	if a.requirements.Session {
302		paths = append(paths, struct {
303			path  dbus.ObjectPath
304			iface string
305		}{a.session, sessionInterface})
306	}
307	if a.requirements.Sleep {
308		paths = append(paths, struct {
309			path  dbus.ObjectPath
310			iface string
311		}{managerPath, managerInterface})
312	}
313	for _, p := range paths {
314		rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface)
315		bounded, cancel := context.WithTimeout(ctx, callTimeout)
316		err := a.bus.match(bounded, rule)
317		cancel()
318		if err != nil {
319			return err
320		}
321	}
322	if a.requirements.Sleep && a.bus.unixFDs() {
323		if err := a.inhibit(ctx); err != nil {
324			a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor")
325			a.caps.Suspend = false
326		}
327	}
328	if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil {
329		return err
330	}
331	var suspendReply <-chan *dbus.Call
332	var suspendCancel context.CancelFunc
333	defer func() {
334		if suspendCancel != nil {
335			suspendCancel()
336		}
337	}()
338	for {
339		select {
340		case reply := <-suspendReply:
341			suspendCancel()
342			suspendReply, suspendCancel = nil, nil
343			if reply == nil {
344				return errors.New("suspend reply channel closed")
345			}
346			if reply.Err != nil {
347				if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil {
348					return err
349				}
350			}
351		case <-ctx.Done():
352			return ctx.Err()
353		case sig, ok := <-a.signals:
354			if !ok {
355				return errors.New("system bus signal stream closed")
356			}
357			event, ok := parseSignal(sig, a.owner, a.session)
358			if !ok {
359				continue
360			}
361			if prep, ok := event.(ports.SleepPreparation); ok {
362				if prep.Preparing && !a.preparing {
363					a.cycle++
364				}
365				a.preparing = prep.Preparing
366				prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now()
367				event = prep
368				if !prep.Preparing {
369					a.release()
370					if err := a.inhibit(ctx); err != nil {
371						if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil {
372							return e
373						}
374					}
375				}
376			}
377			if err := a.emit(ctx, events, event); err != nil {
378				return err
379			}
380		case cmd, ok := <-commands:
381			if !ok {
382				return nil
383			}
384			switch c := cmd.(type) {
385			case ports.SystemSleepReady:
386				if a.preparing && c.Cycle == a.cycle {
387					a.release()
388				}
389			case ports.SystemSuspend:
390				var err error
391				if !a.requirements.Sleep || !a.caps.Suspend {
392					err = errors.New("non-interactive suspend unsupported")
393				} else if suspendReply != nil {
394					err = errors.New("suspend request already pending")
395				} else {
396					// Never override block inhibitors, including ones owned by
397					// this UID (which privileged logind policies may otherwise
398					// permit). Query immediately before the ordinary Suspend call.
399					blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited")
400					if e != nil {
401						err = e
402					} else if value, ok := blocked.(string); !ok {
403						err = errors.New("invalid block inhibitor property")
404					} else {
405						for _, item := range strings.Split(value, ":") {
406							if item == "sleep" {
407								err = errors.New("sleep blocked by inhibitor")
408							}
409						}
410					}
411					if err != nil {
412						if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
413							return e
414						}
415						continue
416					}
417					bounded, cancel := context.WithTimeout(ctx, callTimeout)
418					suspendCancel = cancel // released on reply or by the loop's defer
419					suspendReply = a.bus.suspend(bounded, a.owner)
420				}
421				if err != nil {
422					if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
423						return e
424					}
425				}
426			}
427		}
428	}
429}
430
431func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) {
432	if sig == nil || sig.Sender != owner {
433		return nil, false
434	}
435	if sig.Path == session && session != "" && len(sig.Body) == 0 {
436		switch sig.Name {
437		case sessionInterface + ".Lock":
438			return ports.SystemLockRequested{}, true
439		case sessionInterface + ".Unlock":
440			return ports.SystemUnlockObserved{}, true
441		}
442	}
443	if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 {
444		preparing, ok := sig.Body[0].(bool)
445		if ok {
446			return ports.SleepPreparation{Preparing: preparing}, true
447		}
448	}
449	return nil, false
450}
451
452func (a *Adapter) inhibit(ctx context.Context) error {
453	if !a.requirements.Sleep || a.inhibitor != nil {
454		return nil
455	}
456	body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay")
457	if err != nil {
458		return err
459	}
460	if len(body) != 1 {
461		for _, value := range body {
462			if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 {
463				_ = unix.Close(int(fd))
464			}
465		}
466		return errors.New("invalid inhibitor reply")
467	}
468	fd, ok := body[0].(dbus.UnixFD)
469	if !ok || fd < 0 {
470		return errors.New("invalid inhibitor descriptor")
471	}
472	// godbus returns the received descriptor directly, not an os.File and not
473	// an owned duplicate. Atomically duplicate CLOEXEC then close the received FD.
474	dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
475	_ = unix.Close(int(fd))
476	if err != nil {
477		return err
478	}
479	a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay")
480	return nil
481}
482func (a *Adapter) release() {
483	if a.inhibitor != nil {
484		_ = a.inhibitor.Close()
485		a.inhibitor = nil
486	}
487}
488func (a *Adapter) Close() error {
489	if a.closed {
490		return nil
491	}
492	a.closed = true
493	a.release()
494	return a.bus.close()
495}

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 2

Tags 9

↑↓ moveenter openesc close