refactor/kvconf 3421f16

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

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