1package visual
2
3import (
4 "context"
5 "errors"
6 "io"
7 "os"
8 "time"
9
10 "github.com/bnema/neferafk/internal/adapters/visualproc"
11 "github.com/bnema/neferafk/internal/ports"
12 "github.com/bnema/nefergui"
13 "github.com/bnema/zerowrap"
14)
15
16var errLockWithoutAuth = errors.New("session lock ended without authentication success")
17
18// lockRunner is the session-lock seam over nefergui.RunLock.
19type lockRunner interface {
20 RunLock(ctx context.Context, cfg nefergui.LockConfig) error
21}
22
23type guiLocker struct{}
24
25func (guiLocker) RunLock(ctx context.Context, cfg nefergui.LockConfig) error {
26 return nefergui.RunLock(ctx, cfg)
27}
28
29// Config wires the visual process: fd 3 commands in, fd 4 events out.
30type Config struct {
31 Commands io.Reader
32 Events io.Writer
33 Executable string // neferafk binary that hosts `auth-worker`
34 Log zerowrap.Logger
35}
36
37// Run serves the daemon until the lock was released, a reveal finished, or the
38// command pipe closed with nothing held. It returns nil for those exits.
39func Run(ctx context.Context, cfg Config) error {
40 s := &session{
41 log: cfg.Log.WithField("component", "visual"), cmds: cfg.Commands, events: cfg.Events,
42 locker: guiLocker{}, fader: guiFader{}, launcher: execLauncher{exe: cfg.Executable},
43 }
44 return s.run(ctx)
45}
46
47// ExecutablePath is the running binary, for spawning the auth worker.
48func ExecutablePath() (string, error) { return os.Executable() }
49
50type session struct {
51 log zerowrap.Logger
52 cmds io.Reader
53 events io.Writer
54 locker lockRunner
55 fader fadeRunner
56 launcher workerLauncher
57}
58
59type cmdMsg struct {
60 cmd ports.VisualCommand
61 err error
62}
63
64type fadeRun struct {
65 m *fadeModel
66 cancel context.CancelFunc
67 done chan error
68}
69
70type lockRun struct {
71 gen ports.Generation
72 boot ports.AuthBootstrap
73 ctl *authController
74 cancel context.CancelFunc
75 done chan error // RunLock result
76 authDone chan struct{} // auth controller stopped and worker killed
77 locked chan struct{}
78}
79
80func (s *session) readCommands(ctx context.Context, out chan<- cmdMsg) {
81 for {
82 c, err := visualproc.ReadCommand(s.cmds)
83 select {
84 case out <- cmdMsg{cmd: c, err: err}:
85 case <-ctx.Done():
86 wipeCommand(c)
87 return
88 }
89 if err != nil {
90 return
91 }
92 }
93}
94
95func wipeCommand(c ports.VisualCommand) {
96 if l, ok := c.(ports.VisualLock); ok {
97 clear(l.Auth.EnvPIN)
98 }
99}
100
101// run is the owner loop: it alone holds the fade and lock state.
102func (s *session) run(ctx context.Context) error {
103 rctx, stop := context.WithCancel(ctx)
104 defer stop()
105 cmdCh := make(chan cmdMsg)
106 go s.readCommands(rctx, cmdCh)
107 cmdC := (<-chan cmdMsg)(cmdCh)
108 var fd *fadeRun
109 var lk *lockRun
110 stopFade := func() {
111 if fd != nil {
112 fd.cancel()
113 <-fd.done
114 fd = nil
115 }
116 }
117 defer func() { stopFade() }()
118 for {
119 var lockDone <-chan error
120 var lockedC <-chan struct{}
121 var fadeDone <-chan error
122 sig := ctx.Done()
123 if lk != nil {
124 // A held lock ignores termination requests: only unlock ends it.
125 lockDone, lockedC, sig = lk.done, lk.locked, nil
126 }
127 if fd != nil {
128 fadeDone = fd.done
129 }
130 select {
131 case <-sig:
132 return nil
133 case m := <-cmdC:
134 if m.err != nil {
135 cmdC = nil
136 s.log.Info().Msg("daemon command pipe closed")
137 if lk == nil {
138 return nil // fade-only: nothing left to protect
139 }
140 continue // daemon gone: the lock and its auth UI stay up
141 }
142 switch c := m.cmd.(type) {
143 case ports.VisualFade:
144 switch {
145 case fd != nil:
146 if !c.Black || lk == nil {
147 fd.m.send(fadeCmd{black: c.Black, dur: c.Duration})
148 }
149 case lk != nil:
150 // lock surfaces already cover (or will cover) the screen
151 case !c.Black:
152 return nil // nothing to reveal
153 default:
154 fd = s.startFade(ctx)
155 fd.m.send(fadeCmd{black: true, dur: c.Duration})
156 }
157 case ports.VisualLock:
158 if lk != nil {
159 if lk.gen != c.Generation {
160 s.log.Warn().Msg("lock for another generation ignored")
161 }
162 clear(c.Auth.EnvPIN)
163 continue
164 }
165 lk = s.startLock(ctx, c)
166 }
167 case <-lockedC:
168 lk.locked = nil
169 if err := visualproc.WriteEvent(s.events, ports.LockConfirmed{Generation: lk.gen}); err != nil {
170 s.log.Warn().Err(err).Msg("lock confirmation not delivered")
171 }
172 stopFade()
173 case err := <-fadeDone:
174 fd = nil
175 if err != nil && !errors.Is(err, context.Canceled) {
176 s.log.Error().Err(err).Msg("fade failed")
177 if lk == nil {
178 return err
179 }
180 continue
181 }
182 if lk == nil {
183 return nil // reveal finished
184 }
185 case err := <-lockDone:
186 lk.cancel()
187 <-lk.authDone
188 // The UI may have queued a submission after the controller left.
189 lk.ctl.drainSubmits()
190 clear(lk.boot.EnvPIN)
191 gen, succeeded := lk.gen, lk.ctl.succeeded.Load()
192 lk = nil
193 if err != nil {
194 s.log.Error().Err(err).Msg("session lock ended without unlock")
195 return err
196 }
197 if !succeeded {
198 // RunLock returning nil is not an authorization: only the
199 // worker's AuthSuccess may release the lock.
200 s.log.Error().Msg("session lock ended without authentication success")
201 return errLockWithoutAuth
202 }
203 if werr := visualproc.WriteEvent(s.events, ports.LockReleased{Generation: gen}); werr != nil {
204 s.log.Warn().Err(werr).Msg("lock release not delivered")
205 }
206 return nil
207 }
208 }
209}
210
211func (s *session) startFade(ctx context.Context) *fadeRun {
212 fctx, cancel := context.WithCancel(ctx)
213 f := &fadeRun{m: newFadeModel(), cancel: cancel, done: make(chan error, 1)}
214 go func() { f.done <- s.fader.RunFade(fctx, f.m) }()
215 return f
216}
217
218// startLock runs RunLock with the lock view and a private auth controller. The
219// lock context is detached from process signals: it ends only with RunLock.
220func (s *session) startLock(ctx context.Context, c ports.VisualLock) *lockRun {
221 lctx, cancel := context.WithCancel(context.WithoutCancel(ctx))
222 ctl := newAuthController(s.log, s.launcher, c.Auth, workerTimeout)
223 l := &lockRun{
224 gen: c.Generation, boot: c.Auth, ctl: ctl, cancel: cancel,
225 done: make(chan error, 1), authDone: make(chan struct{}), locked: make(chan struct{}, 1),
226 }
227 go func() { defer close(l.authDone); ctl.run(lctx) }()
228 go clockWake(lctx.Done(), ctl.wake)
229 go func() {
230 secret := nefergui.NewSecretBuffer(ports.AuthMaxSecret)
231 err := s.locker.RunLock(lctx, lockConfig(s.log, ctl, secret, l.locked))
232 secret.Wipe()
233 l.done <- err
234 }()
235 return l
236}
237
238// lockConfig wires NeferGUI's lock loop to the auth controller. OnSubmit only
239// copies the secret; the controller wipes the copy after writing it.
240func lockConfig(log zerowrap.Logger, ctl *authController, secret *nefergui.SecretBuffer, locked chan<- struct{}) nefergui.LockConfig {
241 log = log.WithField("component", "visual-lock")
242 return nefergui.LockConfig{
243 Secret: secret,
244 View: func(f *nefergui.Frame, st nefergui.LockState) {
245 lockView(f, st, promptKind(ctl.prompt.Load()), time.Now())
246 },
247 OnLocked: func() {
248 select {
249 case locked <- struct{}{}:
250 default:
251 }
252 },
253 OnOutputError: func(output string, err error) {
254 // A hotplugged output we could not cover; the compositor keeps it
255 // black. Only the output name and the error are logged.
256 log.Warn().Err(err).Str("output", output).Msg("lock surface not created for output")
257 },
258 OnSubmit: ctl.submit,
259 Unlock: ctl.unlock,
260 Status: ctl.status,
261 Wake: ctl.wake,
262 }
263}