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}