v0.1.0 d9218d6

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}
75
76// Open connects to logind or seatd and waits for the first enable.
77func Open(ctx context.Context, log zerowrap.Logger) (*Seat, error) {
78	if err := load(); err != nil {
79		return nil, err
80	}
81	if current != nil {
82		return nil, errors.New("seat already open")
83	}
84	s := &Seat{devices: make(map[int32]int32), log: log}
85	current = s
86	s.mu.Lock()
87	defer s.mu.Unlock()
88	s.handle = openSeat(unsafe.Pointer(&listener), 0)
89	runtime.KeepAlive(&listener)
90	if s.handle == 0 {
91		current = nil
92		return nil, errors.New("libseat_open_seat failed: no logind/seatd session for this TTY (run from a text console you are logged into)")
93	}
94	log.Info().Str("seat", seatName(s.handle)).Msg("seat opened")
95	for !s.active {
96		if err := s.dispatchLocked(ctx, 100); err != nil {
97			s.closeLocked()
98			return nil, fmt.Errorf("waiting for seat enable: %w", err)
99		}
100	}
101	return s, nil
102}
103
104func (s *Seat) changed(active bool) {
105	s.active = active
106	s.log.Info().Bool("active", active).Msg("seat")
107	if !active {
108		// Devices are revoked by the seat manager; ack so the VT can switch.
109		disableSeat(s.handle)
110	}
111	for _, c := range s.subs {
112		select {
113		case c <- active:
114		default:
115			select {
116			case <-c:
117			default:
118			}
119			c <- active
120		}
121	}
122}
123
124// Name returns the seat name, e.g. seat0.
125func (s *Seat) Name() string {
126	s.mu.Lock()
127	defer s.mu.Unlock()
128	return seatName(s.handle)
129}
130
131// Subscribe returns a channel that receives the seat state after every enable/disable.
132// The current state is sent first.
133func (s *Seat) Subscribe() <-chan bool {
134	c := make(chan bool, 1)
135	s.mu.Lock()
136	c <- s.active
137	s.subs = append(s.subs, c)
138	s.mu.Unlock()
139	return c
140}
141
142// Unsubscribe stops sending to a channel from Subscribe.
143func (s *Seat) Unsubscribe(c <-chan bool) {
144	s.mu.Lock()
145	defer s.mu.Unlock()
146	s.subs = slices.DeleteFunc(s.subs, func(x chan bool) bool { return x == c })
147}
148
149func (s *Seat) dispatchLocked(ctx context.Context, timeoutMs int) error {
150	if ctx.Err() != nil {
151		return ctx.Err()
152	}
153	fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
154	if _, err := unix.Poll(fds, timeoutMs); err != nil && !errors.Is(err, unix.EINTR) {
155		return err
156	}
157	if dispatch(s.handle, 0) < 0 {
158		return errors.New("libseat_dispatch failed")
159	}
160	return nil
161}
162
163// Run dispatches seat events until ctx ends. The owner calls Close afterwards,
164// once outputs have restored their CRTC state.
165func (s *Seat) Run(ctx context.Context) error {
166	for ctx.Err() == nil {
167		fds := []unix.PollFd{{Fd: getFD(s.handle), Events: unix.POLLIN}}
168		if _, err := unix.Poll(fds, 100); err != nil && !errors.Is(err, unix.EINTR) {
169			return fmt.Errorf("seat poll: %w", err)
170		}
171		// Dispatch every tick: libseat may queue events without the fd becoming readable.
172		s.mu.Lock()
173		r := dispatch(s.handle, 0)
174		s.mu.Unlock()
175		if r < 0 {
176			return errors.New("libseat_dispatch failed")
177		}
178	}
179	return nil
180}
181
182// OpenDevice opens path through the seat manager and returns its fd.
183func (s *Seat) OpenDevice(path string) (int, error) {
184	s.mu.Lock()
185	defer s.mu.Unlock()
186	var fd int32 = -1
187	id := openDevice(s.handle, path, &fd)
188	if id < 0 {
189		s.log.Warn().Str("path", path).Msg("open device failed")
190		return -1, fmt.Errorf("libseat_open_device %s failed", path)
191	}
192	s.devices[fd] = id
193	s.log.Info().Str("path", path).Int32("fd", fd).Msg("device opened")
194	return int(fd), nil
195}
196
197// CloseDevice releases a fd returned by OpenDevice.
198func (s *Seat) CloseDevice(fd int) {
199	s.mu.Lock()
200	defer s.mu.Unlock()
201	id, ok := s.devices[int32(fd)]
202	if !ok {
203		return
204	}
205	delete(s.devices, int32(fd))
206	closeDevice(s.handle, id)
207	_ = unix.Close(fd)
208	s.log.Info().Int("fd", fd).Msg("device closed")
209}
210
211// SwitchVT asks the seat manager to switch to virtual terminal vt.
212func (s *Seat) SwitchVT(vt int) {
213	s.mu.Lock()
214	defer s.mu.Unlock()
215	s.log.Info().Int("vt", vt).Msg("switch vt")
216	if switchSession(s.handle, int32(vt)) < 0 {
217		s.log.Warn().Int("vt", vt).Msg("switch vt failed")
218	}
219}
220
221// Close releases all devices and the seat; the VT returns to text mode. Safe to call twice.
222func (s *Seat) Close() {
223	s.mu.Lock()
224	defer s.mu.Unlock()
225	s.closeLocked()
226}
227
228func (s *Seat) closeLocked() {
229	if s.handle == 0 {
230		return
231	}
232	for fd, id := range s.devices {
233		closeDevice(s.handle, id)
234		_ = unix.Close(int(fd))
235	}
236	s.devices = map[int32]int32{}
237	closeSeat(s.handle)
238	s.handle = 0
239	current = nil
240	s.log.Info().Msg("seat closed")
241}

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