v0.1.0 07cdc18

lock.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 9

↑↓ moveenter openesc close