seat.go RawHistoryFolder

1// Package seat opens a libseat session (logind or seatd) and hands out device fds.
2package seat
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"runtime"
9	"slices"
10	"sync"
11	"unsafe"
12
13	"github.com/bnema/purego"
14	"github.com/bnema/zerowrap"
15	"golang.org/x/sys/unix"
16)
17
18var (
19	loadOnce sync.Once
20	loadErr  error
21
22	openSeat      func(listener unsafe.Pointer, data uintptr) uintptr
23	closeSeat     func(seat uintptr) int32
24	disableSeat   func(seat uintptr) int32
25	openDevice    func(seat uintptr, path string, fd *int32) int32
26	closeDevice   func(seat uintptr, id int32) int32
27	seatName      func(seat uintptr) string
28	switchSession func(seat uintptr, session int32) int32
29	getFD         func(seat uintptr) int32
30	dispatch      func(seat uintptr, timeout int32) int32
31
32	listener [2]uintptr
33	// libseat allows one seat per process; callbacks find it here.
34	current *Seat
35)
36
37func load() error {
38	loadOnce.Do(func() {
39		lib, err := purego.Dlopen("libseat.so.1", purego.RTLD_NOW|purego.RTLD_LOCAL)
40		if err != nil {
41			loadErr = fmt.Errorf("open libseat.so.1: %w", err)
42			return
43		}
44		defer func() {
45			if r := recover(); r != nil {
46				loadErr = fmt.Errorf("libseat: %v", r)
47			}
48		}()
49		reg := func(fn any, name string) { purego.RegisterLibFunc(fn, lib, "libseat_"+name) }
50		reg(&openSeat, "open_seat")
51		reg(&closeSeat, "close_seat")
52		reg(&disableSeat, "disable_seat")
53		reg(&openDevice, "open_device")
54		reg(&closeDevice, "close_device")
55		reg(&seatName, "seat_name")
56		reg(&switchSession, "switch_session")
57		reg(&getFD, "get_fd")
58		reg(&dispatch, "dispatch")
59		listener[0] = purego.NewCallback(func(_, _ uintptr) { current.changed(true) })
60		listener[1] = purego.NewCallback(func(_, _ uintptr) { current.changed(false) })
61	})
62	return loadErr
63}
64
65// Seat owns the libseat handle. libseat is not thread-safe, so every call goes
66// through mu; callbacks only run inside dispatch, which already holds it.
67type Seat struct {
68	mu      sync.Mutex
69	handle  uintptr
70	active  bool
71	devices map[int32]int32 // fd → libseat device id
72	subs    []chan bool
73	log     zerowrap.Logger
74	// beforeDisable runs on a seat disable, before it is acked to the seat
75	// manager: the last moment this process is still DRM master.
76	beforeDisable func()
77	disabler      disabler
78}
79
80// disabler acks a seat disable to the seat manager (libseat_disable_seat),
81// which revokes the devices.
82type disabler interface{ disable() }
83
84type libseatDisabler struct{ s *Seat }
85
86func (d libseatDisabler) disable() { disableSeat(d.s.handle) }
87
88// SetBeforeDisable registers f, run on every seat disable before it is
89// acked. f runs inside libseat's dispatch, with the seat locked: it must be
90// short and must not call the Seat. Call it before Run.
91func (s *Seat) SetBeforeDisable(f func()) {
92	s.mu.Lock()
93	defer s.mu.Unlock()
94	s.beforeDisable = f
95}
96
97// Open connects to logind or seatd and waits for the first enable.
98func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
99	if err := load(); err != nil {
100		return nil, err
101	}
102	if current != nil {
103		return nil, errors.New("seat already open")
104	}
105	s := &Seat{devices: make(map[int32]int32), log: log}
106	s.disabler = libseatDisabler{s}
107	current = s
108	s.mu.Lock()
109	defer s.mu.Unlock()
110	s.handle = openSeat(unsafe.Pointer(&listener), 0)
111	runtime.KeepAlive(&listener)
112	if s.handle == 0 {
113		current = nil
114		return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
115	}
116	log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
117	for !s.active {
118		if err := s.dispatchLocked(ctx, 100); err != nil {
119			s.closeLocked()
120			return nil, fmt.Errorf("waiting for seat enable: %w", err)
121		}
122	}
123	return s, nil
124}
125
126func (s *Seat) changed(active bool) {
127	s.active = active
128	s.log.Info().Bool("active", active).Msg("seat")
129	if !active {
130		// Once acked the devices are revoked and the next master inherits
131		// the planes as they are: let the owner of the outputs reset them.
132		if s.beforeDisable != nil {
133			s.beforeDisable()
134		}
135		// Devices are revoked by the seat manager; ack so the VT can switch.
136		s.disabler.disable()
137	}
138	for _, c := range s.subs {
139		select {
140		case c <- active:
141		default:
142			select {
143			case <-c:
144			default:
145			}
146			c <- active
147		}
148	}
149}
150
151// Name returns the seat name, e.g. seat0.
152func (s *Seat) Name() string {
153	s.mu.Lock()
154	defer s.mu.Unlock()
155	return seatName(s.handle)
156}
157
158// Subscribe returns a channel that receives the seat state after every enable/disable.
159// The current state is sent first.
160func (s *Seat) Subscribe() <-chan bool {
161	c := make(chan bool, 1)
162	s.mu.Lock()
163	c <- s.active
164	s.subs = append(s.subs, c)
165	s.mu.Unlock()
166	return c
167}
168
169// Unsubscribe stops sending to a channel from Subscribe.
170func (s *Seat) Unsubscribe(c <-chan bool) {
171	s.mu.Lock()
172	defer s.mu.Unlock()
173	s.subs = slices.DeleteFunc(s.subs, func(x chan bool) bool { return x == c })
174}
175
176func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
177	if ctx.Err() != nil {
178		return ctx.Err()
179	}
180	fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
181	if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
182		return err
183	}
184	if dispatch(s.handle, 0) < 0 {
185		return errors.New("libseat_dispatch failed")
186	}
187	return nil
188}
189
190// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
191// once outputs have restored their CRTC state.
192func (s *Seat) Run(ctx context.Context) error {
193	for ctx.Err() == nil {
194		fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
195		if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
196			return fmt.Errorf("seat poll: %w", err)
197		}
198		// Dispatch every tick: libseat may queue events without the fd becoming readable.
199		s.mu.Lock()
200		r := dispatch(s.handle, 0)
201		s.mu.Unlock()
202		if r < 0 {
203			return errors.New("libseat_dispatch failed")
204		}
205	}
206	return nil
207}
208
209// OpenDevice opens path through the seat manager and returns its fd.
210func (s *Seat) OpenDevice(path string) (int, error) {
211	s.mu.Lock()
212	defer s.mu.Unlock()
213	var fd int32 = -1
214	id := openDevice(s.handle, path, &fd)
215	if id < 0 {
216		s.log.Warn().Str("path", path).Msg("open device failed")
217		return -1, fmt.Errorf("libseat_open_device %s failed", path)
218	}
219	s.devices[fd] = id
220	s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
221	return int(fd), nil
222}
223
224// CloseDevice releases a fd returned by OpenDevice.
225func (s *Seat) CloseDevice(fd int) {
226	s.mu.Lock()
227	defer s.mu.Unlock()
228	id, ok := s.devices[int32(fd)]
229	if !ok {
230		return
231	}
232	delete(s.devices, int32(fd))
233	closeDevice(s.handle, id)
234	_ = unix.Close(fd)
235	s.log.Info().Int("fd", fd).Msg("device closed")
236}
237
238// SwitchVT asks the seat manager to switch to virtual terminal vt.
239func (s *Seat) SwitchVT(vt int) {
240	s.mu.Lock()
241	defer s.mu.Unlock()
242	s.log.Info().Int("vt", vt).Msg("switch vt")
243	if switchSession(s.handle, int32(vt)) < 0 {
244		s.log.Warn().Int("vt", vt).Msg("switch vt failed")
245	}
246}
247
248// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
249func (s *Seat) Close() {
250	s.mu.Lock()
251	defer s.mu.Unlock()
252	s.closeLocked()
253}
254
255func (s *Seat) closeLocked() {
256	if s.handle == 0 {
257		return
258	}
259	for fd, id := range s.devices {
260		closeDevice(s.handle, id)
261		_ = unix.Close(int(fd))
262	}
263	s.devices = map[int32]int32{}
264	closeSeat(s.handle)
265	s.handle = 0
266	current = nil
267	s.log.Info().Msg("seat closed")
268}

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