v0.3.1 4e89021

guilock.go RawHistoryFolder

1package visual
2
3import (
4	"cmp"
5	"context"
6	"errors"
7	"fmt"
8	"slices"
9	"time"
10	"unicode/utf8"
11
12	"github.com/bnema/neferafk/internal/ports"
13	"github.com/bnema/neferclient"
14	"github.com/bnema/nefergui"
15)
16
17// errLockFinished reports that the compositor refused the session lock or
18// ended it (ext_session_lock_v1.finished). The lock never unlocks in response.
19var errLockFinished = errors.New("session lock finished by the compositor")
20
21// lockStatus is the authentication state shown under the input.
22type lockStatus uint8
23
24const (
25	lockIdle   lockStatus = iota // ready for input
26	lockBusy                     // an attempt is being verified
27	lockFailed                   // the last attempt was rejected
28)
29
30// lockState is what the lock view may know: never the secret itself.
31type lockState struct {
32	Mask   int        // typed code points, for a mask of that many dots
33	Status lockStatus // latest value received on lockConfig.Status
34}
35
36// lockConfig wires the session lock to its caller. Every callback runs on the
37// lock's owner goroutine.
38type lockConfig struct {
39	// View builds the content of the output that hosts it: the one whose
40	// surface has keyboard focus, initially the lowest output. Other outputs
41	// show plain black.
42	View func(*nefergui.Frame, lockState)
43	// OnLocked runs once when the compositor confirms the lock.
44	OnLocked func()
45	// OnOutputError reports an output added after the lock was acquired that
46	// could not be covered; the compositor keeps it black.
47	OnOutputError func(output string, err error)
48	// OnError reports an event failure that does not end the lock (cursor,
49	// key repeat, one bad format entry...). Keyboard setup failures end the
50	// run instead: the caller starts a new lock connection while the session
51	// stays locked.
52	OnError func(err error)
53	// OnSubmit runs when Enter is pressed after locked with a non-empty
54	// secret. It must not block. secret is wiped right after it returns.
55	OnSubmit func(secret []byte)
56	// Unlock, when it receives a value, unlocks the session once locked. It
57	// is the only unlock path. Closing it is not a request.
58	Unlock <-chan struct{}
59	// Status delivers the state shown as lockState.Status.
60	Status <-chan lockStatus
61	// Wake requests a redraw.
62	Wake <-chan struct{}
63}
64
65// Keys handled outside the secret buffer (xkb keysyms).
66const (
67	keysymReturn  = 0xff0d
68	keysymKPEnter = 0xff8d
69	keysymEscape  = 0xff1b
70	keysymU       = 0x75
71	keysymUpperU  = 0x55 // with Shift or Caps Lock
72)
73
74// lockSecretMax bounds the typed secret in code points. The buffer drops a
75// key whose text would pass its capacity; the margin over a frame (one key
76// yields at most a few code points) means a secret that fits a frame never
77// lost a key. Enter refuses a secret whose UTF-8 bytes do not fit a frame.
78const lockSecretMax = ports.AuthMaxSecret + utf8.UTFMax
79
80// guiLocker runs ext-session-lock on its own Wayland connection with
81// neferclient and draws with one NeferGUI renderer per output.
82type guiLocker struct{}
83
84// RunLock covers every output until an unlock requested on cfg.Unlock and
85// returns nil. It returns errLockFinished (wrapped) when the compositor
86// refuses or ends the lock, or the first failure. It never unlocks on failure
87// or cancellation: closing the connection keeps the session locked.
88func (guiLocker) RunLock(ctx context.Context, cfg lockConfig) (err error) {
89	if cfg.View == nil {
90		return errors.New("lock needs a view")
91	}
92	conn, err := neferclient.Connect(ctx, "")
93	if err != nil {
94		return fmt.Errorf("connect: %w", err)
95	}
96	l := &locker{cfg: cfg, secret: neferclient.NewSecretBuffer(lockSecretMax), initial: map[uint32]bool{}}
97	l.screens = newScreens(conn)
98	l.screens.onSetup = l.setupPolicy
99	l.screens.onError = l.errorPolicy
100	defer func() {
101		l.secret.Wipe()
102		err = errors.Join(err, conn.Close(), l.screens.closeAll())
103	}()
104	// Lock at once, even with no output yet: outputs that appear later are
105	// covered by OutputAdded, and the session is locked meanwhile.
106	if l.lock, err = conn.Lock(); err != nil {
107		return fmt.Errorf("lock: %w", err)
108	}
109	outputs := conn.Outputs()
110	slices.SortFunc(outputs, func(a, b neferclient.Output) int { return cmp.Compare(a.Global, b.Global) })
111	for _, o := range outputs {
112		if err = l.cover(o); err != nil {
113			return err
114		}
115		l.initial[o.Global] = true
116	}
117	l.seat = conn.Seat()
118	l.seat.SetSecret(l.secret)
119	defer l.seat.SetSecret(nil)
120	var retry <-chan time.Time
121	for {
122		if err = l.wait(ctx, retry); err != nil {
123			return err
124		}
125		if l.unlock && l.lock.Locked() {
126			l.secret.Wipe()
127			return l.lock.Unlock() // roundtrips: the compositor processed it
128		}
129		pending, err := l.screens.drawAll()
130		if err != nil {
131			return err
132		}
133		retry = nil
134		if pending {
135			retry = time.After(gpuRetry)
136		}
137	}
138}
139
140type locker struct {
141	*screens
142
143	cfg     lockConfig
144	lock    *neferclient.Lock
145	seat    *neferclient.Seat
146	secret  *neferclient.SecretBuffer
147	host    *screen
148	initial map[uint32]bool // outputs present at acquisition: their failures abort
149	status  lockStatus
150	unlock  bool // requested; honoured once locked
151}
152
153// wait blocks for one event and applies it.
154func (l *locker) wait(ctx context.Context, retry <-chan time.Time) error {
155	select {
156	case <-ctx.Done():
157		return ctx.Err()
158	case <-l.conn.Wake():
159		if err := l.conn.Dispatch(l); err != nil {
160			return fmt.Errorf("dispatch: %w", err)
161		}
162	case _, ok := <-l.cfg.Unlock:
163		if ok {
164			l.unlock = true
165		} else {
166			l.cfg.Unlock = nil // closing is not a request
167		}
168	case st, ok := <-l.cfg.Status:
169		if ok {
170			l.status = st
171			l.invalidateHost()
172		} else {
173			l.cfg.Status = nil
174		}
175	case <-l.cfg.Wake:
176		l.invalidateHost()
177	case <-retry:
178	}
179	return l.err
180}
181
182// cover creates the lock surface of an output.
183func (l *locker) cover(o neferclient.Output) error {
184	surf, err := l.lock.NewSurface(o.Global)
185	if err != nil {
186		return fmt.Errorf("lock surface for output %s: %w", o.Name, err)
187	}
188	s := newScreen(l.conn, surf, o, false)
189	s.view = func(f *nefergui.Frame) { l.view(f, s) }
190	l.add(s)
191	if l.host == nil { // outputs are covered in ascending order: the lowest hosts
192		l.setHost(s)
193	}
194	return nil
195}
196
197// view shows the lock content on the host output and plain black elsewhere.
198func (l *locker) view(f *nefergui.Frame, s *screen) {
199	if s != l.host {
200		f.Root(nefergui.Inline(cssBlack))
201		return
202	}
203	l.cfg.View(f, lockState{Mask: l.secret.Len(), Status: l.status})
204}
205
206const cssBlack = "width:100%;height:100%;background-color:#000000"
207
208func (l *locker) setHost(s *screen) {
209	if l.host == s {
210		return
211	}
212	if l.host != nil {
213		l.host.invalidate()
214	}
215	l.host = s
216	s.invalidate()
217}
218
219func (l *locker) invalidateHost() {
220	if l.host != nil {
221		l.host.invalidate()
222	}
223}
224
225// setupPolicy: the first renderer failure on an output present at acquisition
226// aborts the lock. A failure on a hotplugged output, or a rebuild after a
227// feedback change, is reported and that output dropped (the compositor keeps
228// it black); the lock stays.
229func (l *locker) setupPolicy(s *screen, rebuild bool, err error) error {
230	err = fmt.Errorf("lock render path for output %s: %w", s.name, err)
231	if l.initial[s.output] && !rebuild {
232		return err
233	}
234	if l.cfg.OnOutputError != nil {
235		l.cfg.OnOutputError(s.name, err)
236	}
237	return nil
238}
239
240// errorPolicy ends the run when the keyboard cannot work, as nobody could
241// type the secret; other event failures are reported and the lock goes on.
242func (l *locker) errorPolicy(err error) error {
243	if keyboardError(err) {
244		return err
245	}
246	if l.cfg.OnError != nil {
247		l.cfg.OnError(err)
248	}
249	return nil
250}
251
252// forgetHost picks a new host when the host screen goes away.
253func (l *locker) forgetHost() {
254	if l.host == nil || l.byID[l.host.surf.ID()] == l.host {
255		return
256	}
257	l.host = nil
258	for _, s := range l.byID {
259		if l.host == nil || s.output < l.host.output {
260			l.host = s
261		}
262	}
263	if l.host != nil {
264		l.host.invalidate()
265	}
266}
267
268func (l *locker) Configure(id neferclient.SurfaceID, w, h int32) {
269	l.screens.Configure(id, w, h)
270	l.forgetHost()
271}
272
273func (l *locker) FeedbackDone(id neferclient.SurfaceID) {
274	l.screens.FeedbackDone(id)
275	l.forgetHost()
276}
277
278func (l *locker) OutputRemoved(global uint32) {
279	l.screens.OutputRemoved(global)
280	delete(l.initial, global)
281	l.forgetHost()
282}
283
284// OutputAdded covers an output that appeared after the lock was requested.
285func (l *locker) OutputAdded(o *neferclient.Output) {
286	if l.lock == nil || l.err != nil || l.covers(o.Global) {
287		return
288	}
289	if err := l.cover(*o); err != nil && l.cfg.OnOutputError != nil {
290		l.cfg.OnOutputError(o.Name, err)
291	}
292}
293
294// Locked confirms the lock, unless the run already failed in the same batch
295// of events: it is about to return that error.
296func (l *locker) Locked() {
297	if l.err != nil {
298		return
299	}
300	l.invalidateHost()
301	if l.cfg.OnLocked != nil {
302		l.cfg.OnLocked()
303	}
304}
305
306func (l *locker) LockFinished() {
307	l.fail(errors.Join(errLockFinished, l.lock.Close()))
308}
309
310func (l *locker) KeyboardFocus(id neferclient.SurfaceID, focused bool) {
311	if s := l.byID[id]; s != nil && focused {
312		l.setHost(s)
313	}
314}
315
316func (l *locker) SecretChanged(int) { l.invalidateHost() }
317
318// Key handles the keys that are not secret text: Enter submits, Escape and
319// Ctrl+U clear. Before locked, Enter only clears.
320func (l *locker) Key(ev *neferclient.KeyEvent) {
321	if ev.Secret || !ev.Pressed || ev.Repeat || l.lock == nil {
322		return
323	}
324	switch {
325	case ev.Keysym == keysymReturn || ev.Keysym == keysymKPEnter:
326		defer l.clearSecret() // even if OnSubmit panics
327		switch {
328		case !l.lock.Locked() || l.cfg.OnSubmit == nil || l.secret.Len() == 0:
329		case len(l.secret.Bytes()) > ports.AuthMaxSecret:
330			if l.status != lockBusy { // an attempt still running keeps its status
331				l.status = lockFailed // too long to verify: refused like a wrong secret
332			}
333		default:
334			l.cfg.OnSubmit(l.secret.Bytes())
335		}
336	case ev.Keysym == keysymEscape, (ev.Keysym == keysymU || ev.Keysym == keysymUpperU) && ev.Modifiers&neferclient.ModCtrl != 0:
337		l.clearSecret()
338	}
339}
340
341func (l *locker) clearSecret() {
342	l.secret.Wipe()
343	l.invalidateHost()
344}

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