v0.4.0 2de4e1d

lock.go RawHistoryFolder

1package ui
2
3import (
4	"cmp"
5	"context"
6	"errors"
7	"fmt"
8	"os"
9	"slices"
10	"strings"
11	"time"
12	"unicode/utf8"
13
14	"github.com/bnema/nefergui/internal/css"
15	"github.com/bnema/nefergui/internal/keyboard"
16	"github.com/bnema/nefergui/internal/platform/wayland"
17	"github.com/bnema/nefergui/internal/presentation/session"
18	"github.com/bnema/nefergui/internal/render"
19	"github.com/bnema/nefergui/internal/text"
20)
21
22// ErrLockFinished reports that the compositor refused the session lock or
23// ended it (ext_session_lock_v1.finished). NeferGUI never unlocks in response.
24var ErrLockFinished = errors.New("nefergui: session lock finished by the compositor")
25
26// SecretBuffer is a caller-owned fixed-capacity byte buffer for typed secret
27// input. Typed bytes never become a Go string and are never stored in the
28// model, tree or render text; the view only sees Len. Bytes beyond the
29// capacity are dropped. It is used from the lock owner loop only.
30type SecretBuffer struct {
31	buf   []byte
32	n     int
33	runes int
34}
35
36// NewSecretBuffer allocates a buffer holding at most capacity bytes (1..4096).
37func NewSecretBuffer(capacity int) *SecretBuffer {
38	if capacity < 1 || capacity > 4096 {
39		panic("nefergui: secret buffer capacity must be between 1 and 4096")
40	}
41	return &SecretBuffer{buf: make([]byte, capacity)}
42}
43
44// Len is the number of typed code points (the mask count).
45func (b *SecretBuffer) Len() int { return b.runes }
46
47// Bytes returns the typed bytes. The slice aliases the buffer: do not retain
48// it past the next edit or Wipe, and never convert it to a string.
49func (b *SecretBuffer) Bytes() []byte { return b.buf[:b.n:b.n] }
50
51// Wipe zeroes the buffer. The caller may call it at any time on the owner loop.
52func (b *SecretBuffer) Wipe() {
53	clear(b.buf)
54	b.n, b.runes = 0, 0
55}
56
57func (b *SecretBuffer) append(p []byte) {
58	if len(p) == 0 || len(p) > len(b.buf)-b.n || !utf8.Valid(p) {
59		return
60	}
61	b.n += copy(b.buf[b.n:], p)
62	b.runes += utf8.RuneCount(p)
63}
64
65func (b *SecretBuffer) backspace() {
66	if b.n == 0 {
67		return
68	}
69	_, size := utf8.DecodeLastRune(b.buf[:b.n])
70	clear(b.buf[b.n-size : b.n])
71	b.n -= size
72	b.runes--
73}
74
75// LockStatus is an opaque caller-driven state shown by the view, for example
76// while an authentication attempt runs. NeferGUI attaches no meaning to it.
77type LockStatus uint8
78
79const (
80	LockIdle   LockStatus = iota // ready for input
81	LockBusy                     // an attempt is being verified
82	LockFailed                   // the last attempt was rejected
83)
84
85// LockState is what the lock view may know: never the secret itself.
86type LockState struct {
87	Mask          int        // typed code points, for a mask of that many bullets
88	Locked        bool       // the compositor confirmed the lock (locked event)
89	Status        LockStatus // latest value received on LockConfig.Status
90	Width, Height int        // logical size of the surface hosting the view
91}
92
93// Bullets returns Mask bullet characters.
94func (s LockState) Bullets() string { return strings.Repeat("•", s.Mask) }
95
96// LockConfig configures RunLock.
97type LockConfig struct {
98	// Display is the Wayland socket name; empty uses the environment.
99	Display string
100	// Styles is an optional author stylesheet path, as for the Styles option.
101	Styles string
102	// Secret receives typed input. Required.
103	Secret *SecretBuffer
104	// View builds the content of the one output that hosts it (the output whose
105	// surface has keyboard focus, initially the first). Required. Other outputs
106	// show plain opaque black.
107	View func(*Frame, LockState)
108	// OnLocked runs once on the owner loop when the compositor sends locked.
109	OnLocked func()
110	// OnSubmit runs on the owner loop when Enter is pressed after locked. It
111	// must not block: verification is asynchronous. secret aliases Secret's
112	// memory and is valid only during the call; it is wiped right after it
113	// returns, so the callee copies or consumes it before returning.
114	OnSubmit func(secret []byte)
115	// Unlock, when it receives a value, asks RunLock to send
116	// unlock_and_destroy, roundtrip and return nil. This is the only unlock
117	// path: it is the caller's explicit request after its own verification. A
118	// request received before the locked event is held until locked; closing
119	// the channel is not a request. A nil channel never unlocks.
120	Unlock <-chan struct{}
121	// Status delivers the state shown as LockState.Status; each received value
122	// schedules a redraw. Optional.
123	Status <-chan LockStatus
124	// Wake requests a redraw for each received value.
125	Wake <-chan struct{}
126	// OnOutputError reports an output added after acquisition that could not
127	// get a lock surface; the compositor keeps it black. Optional, owner loop.
128	OnOutputError func(output string, err error)
129}
130
131type lockSurface struct {
132	w      *wayland.Window
133	output uint32
134	s      *session.Session
135	p      *session.Pump
136}
137
138// RunLock acquires ext-session-lock on one connection and shows opaque black on
139// every output through the regular Vulkan/DMA-BUF/explicit-sync path. Outputs
140// added or removed later gain or lose a lock surface. Keyboard input feeds
141// cfg.Secret only through the secret keyboard path. RunLock returns nil after
142// an unlock requested through cfg.Unlock, ErrLockFinished (wrapped) when the
143// compositor ends or refuses the lock, or the first failure; it never unlocks
144// on failure or cancellation (closing the connection keeps the session locked).
145func RunLock(ctx context.Context, cfg LockConfig) (err error) {
146	if cfg.Secret == nil || cfg.View == nil {
147		return errors.New("nefergui: lock needs Secret and View")
148	}
149	if err = ctx.Err(); err != nil {
150		return err
151	}
152	defer cfg.Secret.Wipe() // also on panic and every early return
153	ctx, stop := context.WithCancel(ctx)
154	defer stop() // ends the wake forwarder with RunLock
155	r := newRuntime()
156	if cfg.Styles != "" {
157		data, e := os.ReadFile(cfg.Styles)
158		if e != nil {
159			return fmt.Errorf("nefergui: styles: %w", e)
160		}
161		r.styles = css.Compile(css.UA(), css.Parse(string(data)))
162	}
163	catalog, err := text.Load(text.SystemSource{})
164	if err != nil {
165		return fmt.Errorf("nefergui: fonts: %w", err)
166	}
167	if len(catalog.Faces) == 0 {
168		return errors.New("nefergui: no usable fonts")
169	}
170	r.setTextEngine(text.NewEngine(catalog))
171	conn, err := wayland.ConnectConnection(ctx, cfg.Display)
172	if err != nil {
173		return fmt.Errorf("nefergui: open platform: %w", err)
174	}
175	l := &locker{cfg: cfg, r: r, conn: conn, ctx: ctx, surfaces: map[*wayland.Window]*lockSurface{}, pending: map[*wayland.Window]pendingLock{}, covered: map[uint32]bool{}, initial: map[uint32]bool{}, gpuWake: make(chan struct{}, 1), scratch: make([]byte, 128)}
176	defer func() {
177		l.closeAll()
178		clear(l.scratch)
179		if l.kb != nil {
180			l.kb.Close()
181		}
182		err = errors.Join(err, conn.Close())
183	}()
184	if l.kb, err = keyboard.New(""); err != nil {
185		return err
186	}
187	l.kb.Focus(false)
188	if err = conn.AcquireLock(); err != nil {
189		return err
190	}
191	// Surfaces for the outputs present now are created before the reader starts
192	// so the compositor can see them while it decides to send locked.
193	if err = l.reconcile(true); err != nil {
194		return err
195	}
196	events := conn.StartReader()
197	// Lock and output events that arrived while the first surfaces were set up
198	// were kept for us: locked must enable unlock, finished must fail the run.
199	for _, ev := range conn.TakeHeld() {
200		if err = l.handle(ev); err != nil {
201			return err
202		}
203	}
204	if cfg.Wake != nil {
205		go r.forwardWake(ctx, cfg.Wake)
206	}
207	return l.loop(events)
208}
209
210// pendingLock is a created lock surface and the output it covers.
211type pendingLock struct {
212	output uint32
213	name   string // connector name, for error reports
214}
215
216type locker struct {
217	cfg      LockConfig
218	r        *runtime
219	conn     *wayland.Connection
220	ctx      context.Context
221	kb       *keyboard.Keyboard
222	scratch  []byte
223	surfaces map[*wayland.Window]*lockSurface
224	pending  map[*wayland.Window]pendingLock // created, not yet configured with feedback
225	covered  map[uint32]bool                 // output globals with a surface or a pending one
226	initial  map[uint32]bool                 // outputs present at acquisition: their failures abort
227	host     *lockSurface
228	locked   bool
229	status   LockStatus
230	unlock   bool // the caller asked for unlock; honoured once locked
231	gpuWake  chan struct{}
232}
233
234func (l *locker) loop(events <-chan wayland.Event) error {
235	for {
236		if err := l.promote(); err != nil {
237			return err
238		}
239		retry := false
240		for _, ls := range l.surfaces {
241			again, err := ls.p.Step()
242			if err != nil {
243				return err
244			}
245			retry = retry || again
246		}
247		var retryC, repeatC <-chan time.Time
248		if retry {
249			retryC = time.After(5 * time.Millisecond)
250		}
251		if at, ok := l.kb.NextRepeat(); ok {
252			repeatC = time.After(time.Until(at))
253		}
254		select {
255		case <-l.ctx.Done():
256			return l.ctx.Err()
257		case <-l.r.wake:
258			l.invalidateHost()
259		case _, ok := <-l.cfg.Unlock:
260			if ok {
261				l.unlock = true
262			} else {
263				l.cfg.Unlock = nil // closing is not a request
264			}
265		case st, ok := <-l.cfg.Status:
266			if ok {
267				l.status = st
268				l.secretChanged()
269			} else {
270				l.cfg.Status = nil
271			}
272		case <-l.gpuWake:
273		case <-retryC:
274		case <-repeatC:
275			if _, err := l.kb.TickSecret(time.Now(), l.scratch, l.consume); err != nil {
276				return err
277			}
278			l.secretChanged()
279		case ev := <-events:
280			if err := l.handle(ev); err != nil {
281				return err
282			}
283		}
284		if l.shouldUnlock() {
285			return l.finishUnlock()
286		}
287	}
288}
289
290func (l *locker) handle(ev wayland.Event) error {
291	switch ev.Kind {
292	case wayland.BufferRelease:
293		if ls := l.surfaces[ev.Window]; ls != nil {
294			return ls.p.BufferReleased(ev.BufferID)
295		}
296		return nil
297	case wayland.OutputRemoved:
298		l.removeOutput(ev.Output.Global)
299	}
300	if err := l.conn.Apply(ev); err != nil {
301		return err
302	}
303	switch ev.Kind {
304	case wayland.LockAcquired:
305		l.locked = true
306		l.invalidateHost()
307		if l.cfg.OnLocked != nil {
308			l.cfg.OnLocked()
309		}
310	case wayland.LockFinished:
311		return ErrLockFinished
312	case wayland.OutputGlobal, wayland.OutputNamed, wayland.OutputAdded:
313		if err := l.reconcile(false); err != nil {
314			return err
315		}
316	}
317	return l.drainInput()
318}
319
320// reconcile gives every named output without one a lock surface. A failure on
321// an initial output aborts the lock. A hotplugged output that cannot get a
322// surface is logged and left alone: the compositor keeps it black regardless.
323func (l *locker) reconcile(initial bool) error {
324	for _, o := range l.conn.Outputs() {
325		if l.covered[o.Global] {
326			continue
327		}
328		l.covered[o.Global] = true // also on failure: never retried for this lifetime
329		w, err := l.conn.NewWindow(l.ctx, 1, 1, false, wayland.SurfaceOptions{Lock: &wayland.LockOptions{Output: o.Global}})
330		if err != nil {
331			if err = l.outputFailure(initial, "lock surface", o.Name, err); err != nil {
332				return err
333			}
334			continue
335		}
336		if initial {
337			l.initial[o.Global] = true
338		}
339		l.pending[w] = pendingLock{output: o.Global, name: o.Name}
340	}
341	return nil
342}
343
344// promote opens the Vulkan session of every window whose configure was
345// acknowledged and whose dmabuf feedback round is complete, in ascending
346// output global order so the first host is deterministic.
347func (l *locker) promote() error {
348	ready := make([]*wayland.Window, 0, len(l.pending))
349	for w := range l.pending {
350		if w.Configured && w.FeedbackDone {
351			ready = append(ready, w)
352		}
353	}
354	slices.SortFunc(ready, func(a, b *wayland.Window) int { return cmp.Compare(l.pending[a].output, l.pending[b].output) })
355	for _, w := range ready {
356		pl := l.pending[w]
357		output := pl.output
358		delete(l.pending, w)
359		ls, err := l.open(w, output) // closes w on failure
360		if err != nil {
361			if err = l.outputFailure(l.initial[output], "lock render path", pl.name, err); err != nil {
362				return err
363			}
364			continue
365		}
366		l.surfaces[w] = ls
367		if l.host == nil {
368			l.setHost(ls)
369		}
370	}
371	return nil
372}
373
374// outputFailure applies the per-output failure policy. A refused or ended lock
375// always aborts (as ErrLockFinished). Otherwise only outputs present at
376// acquisition abort; a hotplugged output is skipped, since the
377// compositor keeps it black without our surface; OnOutputError reports it.
378func (l *locker) outputFailure(initial bool, what, output string, err error) error {
379	if abort := outputFailure(initial, what, output, err); abort != nil {
380		return abort
381	}
382	if l.cfg.OnOutputError != nil {
383		l.cfg.OnOutputError(output, fmt.Errorf("%s: %w", what, err))
384	}
385	return nil
386}
387
388func outputFailure(initial bool, what, output string, err error) error {
389	if errors.Is(err, wayland.ErrLockFinished) {
390		return fmt.Errorf("%w: %w", ErrLockFinished, err)
391	}
392	if initial {
393		return fmt.Errorf("nefergui: %s for output %s: %w", what, output, err)
394	}
395	return nil
396}
397
398// open builds the Vulkan session and frame pump of a configured lock window.
399func (l *locker) open(w *wayland.Window, output uint32) (*lockSurface, error) {
400	s, err := session.OpenWindow(w, false)
401	if err != nil {
402		return nil, err
403	}
404	ls := &lockSurface{w: w, output: output, s: s}
405	if ls.p, err = s.NewPump(l.ctx, l.gpuWake, func() (render.Frame, bool, error) { return l.draw(ls) }); err != nil {
406		_ = s.Close()
407		return nil, err
408	}
409	return ls, nil
410}
411
412func (l *locker) removeOutput(global uint32) {
413	delete(l.covered, global)
414	for w, pl := range l.pending {
415		if pl.output == global {
416			delete(l.pending, w)
417			_ = w.Close()
418		}
419	}
420	for w, ls := range l.surfaces {
421		if ls.output != global {
422			continue
423		}
424		delete(l.surfaces, w)
425		ls.close()
426		if l.host == ls {
427			l.host = nil
428			var next *lockSurface
429			for _, o := range l.surfaces {
430				if next == nil || o.output < next.output {
431					next = o
432				}
433			}
434			if next != nil {
435				l.setHost(next)
436			}
437		}
438	}
439}
440
441func (ls *lockSurface) close() {
442	ls.p.Close()
443	_ = ls.s.Close()
444}
445
446func (l *locker) closeAll() {
447	for w, ls := range l.surfaces {
448		delete(l.surfaces, w)
449		ls.close()
450	}
451	for w := range l.pending {
452		delete(l.pending, w)
453		_ = w.Close()
454	}
455}
456
457func (l *locker) setHost(ls *lockSurface) {
458	if l.host == ls {
459		return
460	}
461	old := l.host
462	l.host = ls
463	if old != nil {
464		old.p.Invalidate()
465	}
466	l.r.Redraw()
467	ls.p.Invalidate()
468}
469
470func (l *locker) invalidateHost() {
471	if l.host != nil {
472		l.host.p.Invalidate()
473	}
474}
475
476// draw shows the view on the host output and plain black elsewhere. An empty
477// frame is the opaque clear color; alpha is not part of the XRGB buffer.
478func (l *locker) draw(ls *lockSurface) (render.Frame, bool, error) {
479	if l.host != ls {
480		return render.Frame{}, true, nil
481	}
482	w := ls.w
483	l.r.route(platformInput{Kind: "resize", Width: float64(w.Width), Height: float64(w.Height), Scale: w.Scale})
484	state := LockState{Mask: l.cfg.Secret.Len(), Locked: l.locked, Status: l.status, Width: int(w.Width), Height: int(w.Height)}
485	if !l.r.Build(func(f *Frame) { l.cfg.View(f, state) }) {
486		return render.Frame{}, false, nil
487	}
488	pw, ph, err := w.PhysicalSize()
489	if err != nil {
490		return render.Frame{}, false, err
491	}
492	frame, err := ls.s.Preparer.Prepare(l.r.output.Display, w.Scale, int(pw), int(ph))
493	return frame, true, err
494}
495
496// drainInput feeds raw keyboard protocol events from every lock window to the
497// secret keyboard. Nothing here creates a string or reaches the UI runtime.
498func (l *locker) drainInput() error {
499	take := func(w *wayland.Window) error {
500		events := w.TakeInputEvents()
501		for i, e := range events {
502			if err := l.input(w, e); err != nil {
503				for _, rest := range events[i+1:] {
504					wayland.CloseEventFD(rest) // never leak keymap FDs
505				}
506				return err
507			}
508		}
509		return nil
510	}
511	for w := range l.surfaces {
512		if err := take(w); err != nil {
513			return err
514		}
515	}
516	for w := range l.pending {
517		if err := take(w); err != nil {
518			return err
519		}
520	}
521	return nil
522}
523
524func (l *locker) input(w *wayland.Window, e wayland.Event) error {
525	switch e.Kind {
526	case wayland.InputKeymap:
527		if e.FD < 0 {
528			return errors.New("nefergui: missing keymap fd")
529		}
530		if e.Format != 1 || e.Size == 0 || e.Size > 16<<20 {
531			wayland.CloseEventFD(e)
532			return nil
533		}
534		return l.kb.ReplaceFD(e.FD, int(e.Size))
535	case wayland.InputModifiers:
536		_ = l.kb.Mask(e.Mods[0], e.Mods[1], e.Mods[2], 0, 0, e.Mods[3]) // no keymap yet: ignore
537	case wayland.InputRepeatInfo:
538		l.kb.RepeatInfo(int(e.Rate), time.Duration(e.Delay)*time.Millisecond)
539	case wayland.InputFocusIn:
540		l.kb.Focus(true)
541		if ls := l.surfaces[w]; ls != nil {
542			l.setHost(ls)
543		}
544	case wayland.InputFocusOut:
545		l.kb.ClearSecret()
546		l.kb.Focus(false)
547	case wayland.InputKey:
548		if err := l.kb.EventSecret(e.Code, e.Pressed, time.Now(), l.scratch, l.consume); err != nil {
549			return err
550		}
551		l.secretChanged()
552	}
553	return nil
554}
555
556func (l *locker) secretChanged() {
557	l.r.Redraw()
558	l.invalidateHost()
559}
560
561// consume applies one secret key: bytes append, Backspace removes a code
562// point, Escape and Ctrl+U clear, Enter submits.
563func (l *locker) consume(k keyboard.SecretKey, chunk []byte) {
564	if !k.Pressed {
565		return
566	}
567	b := l.cfg.Secret
568	switch k.Kind {
569	case keyboard.SecretBytes:
570		b.append(chunk)
571	case keyboard.SecretBackspace:
572		b.backspace()
573	case keyboard.SecretEscape, keyboard.SecretClear:
574		b.Wipe()
575	case keyboard.SecretReturn:
576		// Before the locked event Enter only clears. Submitting never unlocks.
577		defer b.Wipe() // even if OnSubmit panics
578		if l.locked && l.cfg.OnSubmit != nil && b.Len() > 0 {
579			l.cfg.OnSubmit(b.Bytes())
580		}
581	}
582}
583
584// shouldUnlock reports that the caller asked for unlock and the lock is held.
585func (l *locker) shouldUnlock() bool { return l.unlock && l.locked }
586
587// finishUnlock is reached only after the caller sent on cfg.Unlock.
588func (l *locker) finishUnlock() error {
589	if err := l.conn.UnlockAndDestroy(); err != nil { // includes the display roundtrip
590		return err
591	}
592	l.closeAll()
593	return nil
594}

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 1

Tags 12

↑↓ moveenter openesc close