v0.3.0 0a8834d

lock.go RawHistoryFolder

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}

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