v0.1.0 07cdc18

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 callTimeout = 3 * time.Second
24
25// Adapter is single-owner: Probe, Run and Close must not execute concurrently.
26// Run owns the connection and closes it and any inhibitor on every exit.
27type Adapter struct {
28	bus          bus
29	requirements ports.SystemRequirements
30	owner        string
31	session      dbus.ObjectPath
32	caps         ports.SystemCapabilities
33	signals      chan *dbus.Signal
34	inhibitor    *os.File
35	cycle        uint64
36	preparing    bool
37	closed       bool
38}
39
40// Open connects only when requested. Calling it is unnecessary when all system
41// features are off. Connection failure is an unsupported capability, not a
42// reason to force system initialization at daemon startup.
43func Open(ctx context.Context, requirements ports.SystemRequirements) (*Adapter, error) {
44	if !requirements.Session && !requirements.Sleep {
45		return nil, errors.New("logind not requested")
46	}
47	conn, err := dbus.ConnectSystemBus(dbus.WithContext(ctx))
48	if err != nil {
49		return nil, fmt.Errorf("system bus unavailable: %w", err)
50	}
51	a := newAdapter(connection{conn}, requirements)
52	if err := a.Probe(ctx); err != nil {
53		a.Close()
54		return nil, err
55	}
56	return a, nil
57}
58func newAdapter(b bus, r ports.SystemRequirements) *Adapter {
59	return &Adapter{bus: b, requirements: r, signals: make(chan *dbus.Signal, 32)}
60}
61func (a *Adapter) call(ctx context.Context, path dbus.ObjectPath, method string, args ...any) ([]any, error) {
62	ctx, cancel := context.WithTimeout(ctx, callTimeout)
63	defer cancel()
64	return a.bus.call(ctx, a.owner, path, method, args...)
65}
66func (a *Adapter) property(ctx context.Context, path dbus.ObjectPath, iface, name string) (any, error) {
67	body, err := a.call(ctx, path, "org.freedesktop.DBus.Properties.Get", iface, name)
68	if err != nil {
69		return nil, err
70	}
71	if len(body) != 1 {
72		return nil, errors.New("invalid property reply")
73	}
74	v, ok := body[0].(dbus.Variant)
75	if !ok {
76		return nil, errors.New("invalid property variant")
77	}
78	return v.Value(), nil
79}
80
81// Probe is read-only: no Lock/Unlock/Suspend/Inhibit call. It resolves the
82// unique service owner, PID's session and its real UID; no user override exists.
83func (a *Adapter) Probe(ctx context.Context) error {
84	bounded, cancel := context.WithTimeout(ctx, callTimeout)
85	defer cancel()
86	body, err := a.bus.call(bounded, "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus.GetNameOwner", service)
87	if err != nil {
88		return fmt.Errorf("logind unavailable: %w", err)
89	}
90	if len(body) != 1 {
91		return errors.New("invalid logind owner reply")
92	}
93	owner, ok := body[0].(string)
94	if !ok || !strings.HasPrefix(owner, ":") {
95		return errors.New("invalid unique logind owner")
96	}
97	a.owner = owner
98	a.caps = ports.SystemCapabilities{}
99	if a.requirements.Session {
100		body, err = a.call(ctx, managerPath, managerInterface+".GetSessionByPID", uint32(os.Getpid()))
101		if err != nil {
102			return fmt.Errorf("current process session unavailable: %w", err)
103		}
104		if len(body) != 1 {
105			return errors.New("invalid session reply")
106		}
107		path, ok := body[0].(dbus.ObjectPath)
108		if !ok || !path.IsValid() || !strings.HasPrefix(string(path), "/org/freedesktop/login1/session/") {
109			return errors.New("invalid session path")
110		}
111		value, err := a.property(ctx, path, sessionInterface, "User")
112		if err != nil {
113			return err
114		}
115		fields, ok := value.([]any)
116		if !ok || len(fields) != 2 {
117			return errors.New("invalid session User property")
118		}
119		uid, ok := fields[0].(uint32)
120		if !ok || uid != uint32(os.Getuid()) {
121			return errors.New("session does not belong to real UID")
122		}
123		a.session = path
124		a.caps.Session = true
125	}
126	if a.requirements.Sleep {
127		body, err = a.call(ctx, managerPath, managerInterface+".CanSuspend")
128		if err != nil {
129			return fmt.Errorf("suspend capability unavailable: %w", err)
130		}
131		if len(body) != 1 {
132			return errors.New("invalid CanSuspend reply")
133		}
134		auth, ok := body[0].(string)
135		if !ok {
136			return errors.New("invalid CanSuspend type")
137		}
138		a.caps.SuspendAuthorization = auth
139		// Suspend(false) cannot request an interactive authorization prompt.
140		a.caps.Suspend = auth == "yes"
141		if !a.caps.Suspend {
142			a.caps.Missing = append(a.caps.Missing, "non-interactive suspend: "+auth)
143		}
144		value, err := a.property(ctx, managerPath, managerInterface, "InhibitDelayMaxUSec")
145		if err != nil {
146			return err
147		}
148		usec, ok := value.(uint64)
149		if !ok || usec == 0 || usec > uint64(math.MaxInt64/1000) {
150			return errors.New("invalid inhibitor delay maximum")
151		}
152		a.caps.DelayMax = time.Duration(usec) * time.Microsecond
153		if !a.bus.unixFDs() {
154			a.caps.Missing = append(a.caps.Missing, "D-Bus Unix FD passing")
155			a.caps.Suspend = false
156		}
157	}
158	return nil
159}
160func (a *Adapter) Capabilities() ports.SystemCapabilities {
161	c := a.caps
162	c.Missing = append([]string(nil), c.Missing...)
163	return c
164}
165func (a *Adapter) emit(ctx context.Context, out chan<- ports.SystemEvent, event ports.SystemEvent) error {
166	select {
167	case out <- event:
168		return nil
169	case <-ctx.Done():
170		return ctx.Err()
171	}
172}
173
174// Run starts subscriptions and holds a delay inhibitor only for enabled sleep.
175// A matching SleepReady releases it. Resume reacquires for the next cycle.
176// logind's own maximum bounds the delay even if policy never replies.
177func (a *Adapter) Run(ctx context.Context, commands <-chan ports.SystemCommand, events chan<- ports.SystemEvent) error {
178	defer a.Close()
179	if a.owner == "" {
180		return errors.New("logind must be probed before Run")
181	}
182	a.bus.signals(a.signals)
183	defer a.bus.removeSignals(a.signals)
184	paths := []struct {
185		path  dbus.ObjectPath
186		iface string
187	}{}
188	if a.requirements.Session {
189		paths = append(paths, struct {
190			path  dbus.ObjectPath
191			iface string
192		}{a.session, sessionInterface})
193	}
194	if a.requirements.Sleep {
195		paths = append(paths, struct {
196			path  dbus.ObjectPath
197			iface string
198		}{managerPath, managerInterface})
199	}
200	for _, p := range paths {
201		rule := fmt.Sprintf("type='signal',sender='%s',path='%s',interface='%s'", a.owner, p.path, p.iface)
202		bounded, cancel := context.WithTimeout(ctx, callTimeout)
203		err := a.bus.match(bounded, rule)
204		cancel()
205		if err != nil {
206			return err
207		}
208	}
209	if a.requirements.Sleep && a.bus.unixFDs() {
210		if err := a.inhibit(ctx); err != nil {
211			a.caps.Missing = append(a.caps.Missing, "sleep delay inhibitor")
212			a.caps.Suspend = false
213		}
214	}
215	if err := a.emit(ctx, events, ports.SystemCapabilityReport{Capabilities: a.Capabilities()}); err != nil {
216		return err
217	}
218	var suspendReply <-chan *dbus.Call
219	var suspendCancel context.CancelFunc
220	defer func() {
221		if suspendCancel != nil {
222			suspendCancel()
223		}
224	}()
225	for {
226		select {
227		case reply := <-suspendReply:
228			suspendCancel()
229			suspendReply, suspendCancel = nil, nil
230			if reply == nil {
231				return errors.New("suspend reply channel closed")
232			}
233			if reply.Err != nil {
234				if err := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: reply.Err}); err != nil {
235					return err
236				}
237			}
238		case <-ctx.Done():
239			return ctx.Err()
240		case sig, ok := <-a.signals:
241			if !ok {
242				return errors.New("system bus signal stream closed")
243			}
244			event, ok := parseSignal(sig, a.owner, a.session)
245			if !ok {
246				continue
247			}
248			if prep, ok := event.(ports.SleepPreparation); ok {
249				if prep.Preparing && !a.preparing {
250					a.cycle++
251				}
252				a.preparing = prep.Preparing
253				prep.Cycle, prep.MaxDelay, prep.ReceivedAt = a.cycle, a.caps.DelayMax, time.Now()
254				event = prep
255				if !prep.Preparing {
256					a.release()
257					if err := a.inhibit(ctx); err != nil {
258						if e := a.emit(ctx, events, ports.SystemFailure{Operation: "inhibit", Err: err}); e != nil {
259							return e
260						}
261					}
262				}
263			}
264			if err := a.emit(ctx, events, event); err != nil {
265				return err
266			}
267		case cmd, ok := <-commands:
268			if !ok {
269				return nil
270			}
271			switch c := cmd.(type) {
272			case ports.SystemSleepReady:
273				if a.preparing && c.Cycle == a.cycle {
274					a.release()
275				}
276			case ports.SystemSuspend:
277				var err error
278				if !a.requirements.Sleep || !a.caps.Suspend {
279					err = errors.New("non-interactive suspend unsupported")
280				} else if suspendReply != nil {
281					err = errors.New("suspend request already pending")
282				} else {
283					// Never override block inhibitors, including ones owned by
284					// this UID (which privileged logind policies may otherwise
285					// permit). Query immediately before the ordinary Suspend call.
286					blocked, e := a.property(ctx, managerPath, managerInterface, "BlockInhibited")
287					if e != nil {
288						err = e
289					} else if value, ok := blocked.(string); !ok {
290						err = errors.New("invalid block inhibitor property")
291					} else {
292						for _, item := range strings.Split(value, ":") {
293							if item == "sleep" {
294								err = errors.New("sleep blocked by inhibitor")
295							}
296						}
297					}
298					if err != nil {
299						if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
300							return e
301						}
302						continue
303					}
304					bounded, cancel := context.WithTimeout(ctx, callTimeout)
305					suspendCancel = cancel // released on reply or by the loop's defer
306					suspendReply = a.bus.suspend(bounded, a.owner)
307				}
308				if err != nil {
309					if e := a.emit(ctx, events, ports.SystemFailure{Operation: "suspend", Err: err}); e != nil {
310						return e
311					}
312				}
313			}
314		}
315	}
316}
317
318func parseSignal(sig *dbus.Signal, owner string, session dbus.ObjectPath) (ports.SystemEvent, bool) {
319	if sig == nil || sig.Sender != owner {
320		return nil, false
321	}
322	if sig.Path == session && session != "" && len(sig.Body) == 0 {
323		switch sig.Name {
324		case sessionInterface + ".Lock":
325			return ports.SystemLockRequested{}, true
326		case sessionInterface + ".Unlock":
327			return ports.SystemUnlockObserved{}, true
328		}
329	}
330	if sig.Path == managerPath && sig.Name == managerInterface+".PrepareForSleep" && len(sig.Body) == 1 {
331		preparing, ok := sig.Body[0].(bool)
332		if ok {
333			return ports.SleepPreparation{Preparing: preparing}, true
334		}
335	}
336	return nil, false
337}
338
339func (a *Adapter) inhibit(ctx context.Context) error {
340	if !a.requirements.Sleep || a.inhibitor != nil {
341		return nil
342	}
343	body, err := a.call(ctx, managerPath, managerInterface+".Inhibit", "sleep", "neferafk", "session protection", "delay")
344	if err != nil {
345		return err
346	}
347	if len(body) != 1 {
348		for _, value := range body {
349			if fd, ok := value.(dbus.UnixFD); ok && fd >= 0 {
350				_ = unix.Close(int(fd))
351			}
352		}
353		return errors.New("invalid inhibitor reply")
354	}
355	fd, ok := body[0].(dbus.UnixFD)
356	if !ok || fd < 0 {
357		return errors.New("invalid inhibitor descriptor")
358	}
359	// godbus returns the received descriptor directly, not an os.File and not
360	// an owned duplicate. Atomically duplicate CLOEXEC then close the received FD.
361	dup, err := unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0)
362	_ = unix.Close(int(fd))
363	if err != nil {
364		return err
365	}
366	a.inhibitor = os.NewFile(uintptr(dup), "logind-sleep-delay")
367	return nil
368}
369func (a *Adapter) release() {
370	if a.inhibitor != nil {
371		_ = a.inhibitor.Close()
372		a.inhibitor = nil
373	}
374}
375func (a *Adapter) Close() error {
376	if a.closed {
377		return nil
378	}
379	a.closed = true
380	a.release()
381	return a.bus.close()
382}

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