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}