1package visualproc
2
3import (
4 "context"
5 "errors"
6 "sync/atomic"
7
8 "github.com/bnema/neferafk/internal/ports"
9 "github.com/bnema/zerowrap"
10)
11
12type live struct {
13 session ports.VisualSession
14 exited atomic.Bool
15 lockGen atomic.Uint64 // generation the visual was last asked to lock; cleared on release
16}
17
18// Manager implements ports.Visual over a VisualLauncher. Fade/Lock/Close/Detach
19// are called from the policy owner goroutine only; forwarding goroutines touch
20// just the atomic exited flag and the event channel. The visual is spawned
21// lazily and never asked to unlock: there is no such command.
22type Manager struct {
23 ctx context.Context
24 cancel context.CancelFunc
25 launcher ports.VisualLauncher
26 out chan<- ports.VisualEvent
27 log zerowrap.Logger
28 cur *live
29}
30
31func NewManager(ctx context.Context, launcher ports.VisualLauncher, out chan<- ports.VisualEvent, log zerowrap.Logger) *Manager {
32 ctx, cancel := context.WithCancel(ctx)
33 return &Manager{ctx: ctx, cancel: cancel, launcher: launcher, out: out, log: log.WithField("component", "visual")}
34}
35
36// current returns the running session or launches one when spawn is true.
37func (m *Manager) current(ctx context.Context, spawn bool, sp ports.VisualSpawn) (*live, error) {
38 if m.cur != nil && m.cur.exited.Load() {
39 if err := m.cur.session.Close(); err != nil {
40 m.log.Warn().Err(err).Msg("closing exited visual session")
41 }
42 m.cur = nil
43 }
44 if m.cur != nil || !spawn {
45 return m.cur, nil
46 }
47 s, err := m.launcher.Launch(ctx, sp)
48 if err != nil {
49 return nil, err
50 }
51 l := &live{session: s}
52 m.cur = l
53 go m.forward(l)
54 m.log.Info().Msg("visual process spawned")
55 return l, nil
56}
57
58// forward relays child events. The exited flag is set before the terminal
59// event is delivered, so a handler reacting to VisualExited already observes it.
60func (m *Manager) forward(l *live) {
61 for ev := range l.session.Events() {
62 switch v := ev.(type) {
63 case ports.VisualExited:
64 l.exited.Store(true)
65 case ports.LockReleased:
66 l.lockGen.CompareAndSwap(uint64(v.Generation), 0)
67 }
68 select {
69 case m.out <- ev:
70 case <-m.ctx.Done():
71 return
72 }
73 }
74 l.exited.Store(true)
75}
76
77// send retries once on a fresh process when the current one died between the
78// liveness check and the write.
79func (m *Manager) send(ctx context.Context, spawn bool, sp ports.VisualSpawn, cmd ports.VisualCommand, mark func(*live)) error {
80 for attempt := 0; attempt < 2; attempt++ {
81 l, err := m.current(ctx, spawn, sp)
82 if err != nil || l == nil {
83 return err
84 }
85 if err = l.session.Send(cmd); err == nil {
86 if mark != nil {
87 mark(l)
88 }
89 return nil
90 }
91 l.exited.Store(true)
92 }
93 return errors.New("visual unreachable")
94}
95
96func (m *Manager) Fade(ctx context.Context, req ports.VisualFade) error {
97 // Reveal never spawns a process.
98 return m.send(ctx, req.Black, req.Spawn, req, nil)
99}
100
101func (m *Manager) Lock(ctx context.Context, req ports.VisualLock) error {
102 defer clear(req.Auth.EnvPIN)
103 if m.cur != nil && !m.cur.exited.Load() && m.cur.lockGen.Load() == uint64(req.Generation) {
104 return nil
105 }
106 return m.send(ctx, true, req.Spawn, req, func(l *live) { l.lockGen.Store(uint64(req.Generation)) })
107}
108
109func (m *Manager) Close() error {
110 defer m.cancel()
111 if m.cur == nil {
112 return nil
113 }
114 err := m.cur.session.Close()
115 m.cur = nil
116 return err
117}
118
119func (m *Manager) Detach() error {
120 defer m.cancel()
121 if m.cur == nil {
122 return nil
123 }
124 err := m.cur.session.Detach()
125 m.cur = nil
126 return err
127}
128
129var _ ports.Visual = (*Manager)(nil)