v0.3.1 4e89021

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 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}

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