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