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