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}