1// Package app wires adapters to the pure policy. The Owner goroutine is the
2// only place policy state is read or written; adapters talk to it through
3// channels and the ports.Visual port. Nothing here can unlock a session:
4// unlock exists only as a visual-reported LockReleased lifecycle result.
5package app
6
7import (
8 "context"
9 "errors"
10 "fmt"
11 "os"
12 "slices"
13 "time"
14
15 "github.com/bnema/neferafk/internal/core"
16 "github.com/bnema/neferafk/internal/ports"
17 "github.com/bnema/zerowrap"
18)
19
20const maxQueue = 256
21
22// Inputs are the owner's event channels. Any may be nil (feature absent).
23type Inputs struct {
24 Config <-chan ports.ConfigChanged
25 Failures <-chan error
26 Wayland <-chan ports.WaylandEvent
27 System <-chan ports.SystemEvent
28 Visual <-chan ports.VisualEvent
29 Control <-chan ports.ControlRequest
30}
31
32// Outputs are adapter command channels. The owner never blocks on them: sends
33// go through bounded per-channel queues so an adapter that is itself blocked
34// emitting an event cannot deadlock with the owner.
35type Outputs struct {
36 Wayland chan<- ports.WaylandCommand
37 System chan<- ports.SystemCommand
38}
39
40type Options struct {
41 Config ports.Config
42 Visual ports.Visual
43 // LookupEnv reads the daemon's own startup environment for the PIN env
44 // source (the accepted, documented residual custody). Defaults to os.LookupEnv.
45 LookupEnv func(string) (string, bool)
46 Log zerowrap.Logger
47 // RetryMin/RetryMax bound the lock (re)spawn backoff; defaults 250ms/5s.
48 RetryMin, RetryMax time.Duration
49 // Gate, when set, makes Run fail with *UnsupportedError unless the latest
50 // Wayland report and the first System report (if ExpectSystem) are
51 // complete within GateTimeout (default 10s). Missing System support fails
52 // at once; missing Wayland globals may still be advertised late.
53 Gate bool
54 ExpectSystem bool
55 GateTimeout time.Duration
56}
57
58type Owner struct {
59 policy *core.Policy
60 cfg ports.Config
61 startWL ports.WaylandRequirements
62 startSY ports.SystemRequirements
63 startAuth ports.AuthConfig
64 opt Options
65 in Inputs
66 out Outputs
67 log zerowrap.Logger
68 initial []ports.PolicyEffect
69
70 wq []ports.WaylandCommand
71 sq []ports.SystemCommand
72 regs []ports.IdleNotification
73 regGen map[uint64]ports.Generation
74 regOrigin map[uint64]time.Time // when policy first registered each ID
75 idle map[uint32]map[time.Duration]bool // adapter proxies currently idle, per seat and duration
76 seats map[uint32]bool // seats from the latest capability report
77 seatsKnown bool
78 fired map[uint64]bool // registrations already delivered to policy
79 deferred *time.Timer // fires registrations whose origin+After lies ahead
80 lockGen ports.Generation
81 delay time.Duration
82 retry *time.Timer
83 sleeping bool
84 sleepCycle uint64
85 wlCaps *ports.Capabilities
86 sysCaps *ports.SystemCapabilities
87 gated bool
88}
89
90func NewOwner(o Options, in Inputs, out Outputs) (*Owner, error) {
91 if o.Visual == nil {
92 return nil, errors.New("app: visual port required")
93 }
94 if o.LookupEnv == nil {
95 o.LookupEnv = os.LookupEnv
96 }
97 if o.RetryMin <= 0 {
98 o.RetryMin = 250 * time.Millisecond
99 }
100 if o.RetryMax < o.RetryMin {
101 o.RetryMax = max(5*time.Second, o.RetryMin)
102 }
103 if o.GateTimeout <= 0 {
104 o.GateTimeout = 10 * time.Second
105 }
106 p, initial, err := core.New(o.Config, time.Now())
107 if err != nil {
108 return nil, err
109 }
110 w, s := Requirements(o.Config)
111 ow := &Owner{policy: p, cfg: o.Config, startAuth: o.Config.Auth, startWL: w, startSY: s, opt: o, in: in, out: out, log: o.Log.WithField("component", "app"), initial: initial, regGen: map[uint64]ports.Generation{}, regOrigin: map[uint64]time.Time{}, idle: map[uint32]map[time.Duration]bool{}, seats: map[uint32]bool{}, fired: map[uint64]bool{}, delay: o.RetryMin}
112 ow.retry = time.NewTimer(time.Hour)
113 ow.retry.Stop()
114 ow.deferred = time.NewTimer(time.Hour)
115 ow.deferred.Stop()
116 ow.gated = !o.Gate
117 return ow, nil
118}
119
120// Run is the policy owner goroutine. It returns nil on ctx cancellation, an
121// *UnsupportedError when the startup gate fails, or a fatal channel error.
122func (o *Owner) Run(ctx context.Context) error {
123 defer o.retry.Stop()
124 defer o.deferred.Stop()
125 defer o.shutdown()
126 for _, e := range o.initial {
127 if err := o.apply(ctx, e); err != nil {
128 return err
129 }
130 }
131 o.initial = nil
132 var gate <-chan time.Time
133 var gateTimer *time.Timer
134 if o.opt.Gate && !o.gated {
135 gateTimer = time.NewTimer(o.opt.GateTimeout)
136 defer gateTimer.Stop()
137 gate = gateTimer.C
138 }
139 for {
140 var wc chan<- ports.WaylandCommand
141 var wv ports.WaylandCommand
142 if len(o.wq) > 0 {
143 wc, wv = o.out.Wayland, o.wq[0]
144 }
145 var sc chan<- ports.SystemCommand
146 var sv ports.SystemCommand
147 if len(o.sq) > 0 {
148 sc, sv = o.out.System, o.sq[0]
149 }
150 var err error
151 select {
152 case <-ctx.Done():
153 return nil
154 case <-gate:
155 if o.wlCaps != nil && (o.sysCaps != nil || !o.opt.ExpectSystem) {
156 return &UnsupportedError{Missing: unsupported(o.wlCaps, o.sysCaps)}
157 }
158 return &UnsupportedError{Missing: []string{"capability reports not received before timeout"}}
159 case wc <- wv:
160 o.wq = o.wq[1:]
161 case sc <- sv:
162 o.sq = o.sq[1:]
163 case ev, ok := <-o.in.Config:
164 if !ok {
165 return errors.New("config channel closed")
166 }
167 err = o.reload(ctx, ev.Config)
168 case e, ok := <-o.in.Failures:
169 if !ok {
170 o.in.Failures = nil
171 continue
172 }
173 o.log.Warn().Err(e).Msg("config change rejected; keeping last valid configuration")
174 case ev, ok := <-o.in.Wayland:
175 if !ok {
176 return errors.New("wayland channel closed")
177 }
178 err = o.wayland(ctx, ev)
179 case ev, ok := <-o.in.System:
180 if !ok {
181 return errors.New("system channel closed")
182 }
183 err = o.system(ctx, ev)
184 case ev, ok := <-o.in.Visual:
185 if !ok {
186 return errors.New("visual channel closed")
187 }
188 err = o.visual(ctx, ev)
189 case req, ok := <-o.in.Control:
190 if !ok {
191 o.in.Control = nil
192 continue
193 }
194 err = o.control(ctx, req)
195 case <-o.retry.C:
196 err = o.startLock(ctx)
197 case <-o.deferred.C:
198 err = o.fireDue(ctx)
199 }
200 if err != nil {
201 return err
202 }
203 if e := o.checkGate(); e != nil {
204 return e
205 }
206 if o.gated && gateTimer != nil {
207 gateTimer.Stop() // gate passed: the timeout must never fire afterwards
208 gateTimer, gate = nil, nil
209 }
210 if len(o.wq) > maxQueue || len(o.sq) > maxQueue {
211 return errors.New("adapter command queue overflow")
212 }
213 }
214}
215
216func (o *Owner) checkGate() error {
217 if o.gated || o.wlCaps == nil || o.opt.ExpectSystem && o.sysCaps == nil {
218 return nil
219 }
220 if m := unsupported(o.wlCaps, o.sysCaps); len(m) > 0 {
221 if len(o.wlCaps.Missing) > 0 && len(unsupported(nil, o.sysCaps)) == 0 {
222 return nil // wait for a later Wayland report or the gate timeout
223 }
224 o.gated = true
225 return &UnsupportedError{Missing: m}
226 }
227 o.gated = true
228 if w := suspendWarning(o.cfg, o.sysCaps); w != "" {
229 o.log.Warn().Msg(w)
230 }
231 return nil
232}
233
234// shutdown runs on every Run exit. A held (or being acquired) lock is never
235// torn down: the visual is detached and keeps its lock and auth UI.
236func (o *Owner) shutdown() {
237 st := o.policy.State()
238 var err error
239 if st.Acquiring || st.Protected {
240 // Never tear down a lock we hold: leave the visual (and its auth UI) up.
241 err = o.opt.Visual.Detach()
242 } else {
243 err = o.opt.Visual.Close()
244 }
245 if err != nil {
246 o.log.Warn().Err(err).Msg("visual shutdown")
247 }
248}
249
250// handle feeds the policy and applies the resulting effects in order.
251func (o *Owner) handle(ctx context.Context, ev ports.PolicyEvent) error {
252 effects, err := o.policy.Handle(time.Now(), ev)
253 if err != nil {
254 o.log.Warn().Err(err).Msgf("policy rejected %T", ev)
255 return nil
256 }
257 for _, e := range effects {
258 if err := o.apply(ctx, e); err != nil {
259 return err
260 }
261 }
262 return nil
263}
264
265func (o *Owner) apply(ctx context.Context, effect ports.PolicyEffect) error {
266 switch e := effect.(type) {
267 case ports.ArmIdle:
268 regs := map[uint64]ports.Generation{}
269 origins := map[uint64]time.Time{}
270 var ds []time.Duration
271 now := time.Now()
272 for _, n := range e.Notifications {
273 g, ok := o.regGen[n.ID]
274 if !ok {
275 g = e.Generation // retained registrations keep their admitting generation
276 }
277 regs[n.ID] = g
278 origins[n.ID] = now
279 if t, ok := o.regOrigin[n.ID]; ok {
280 origins[n.ID] = t
281 }
282 if !slices.Contains(ds, n.After) {
283 ds = append(ds, n.After)
284 }
285 }
286 fired := map[uint64]bool{}
287 for id := range regs {
288 fired[id] = o.fired[id]
289 }
290 o.regGen, o.regOrigin, o.regs, o.fired = regs, origins, e.Notifications, fired
291 for _, m := range o.idle {
292 for d := range m {
293 if !slices.Contains(ds, d) {
294 delete(m, d) // adapter destroys that proxy; a re-add starts fresh
295 }
296 }
297 }
298 o.armDeferred()
299 // Always sent, even when deadlines are unchanged: the adapter stamps
300 // idle events with the newest generation it was told about, and it
301 // retains proxies (no rebase) when durations are unchanged.
302 o.wq = append(o.wq, ports.SetDeadlines{Generation: uint64(e.Generation), Deadlines: ds})
303 case ports.AcquireLock:
304 o.lockGen = e.Generation
305 o.delay = o.opt.RetryMin
306 return o.startLock(ctx)
307 case ports.SetFade:
308 if err := o.opt.Visual.Fade(ctx, ports.VisualFade{Black: e.Black, Duration: e.Duration, Spawn: o.spawn()}); err != nil {
309 o.log.Warn().Err(err).Bool("black", e.Black).Msg("fade request failed")
310 }
311 case ports.SetOutputPower:
312 o.wq = append(o.wq, ports.OutputPower(e))
313 case ports.Suspend:
314 o.sq = append(o.sq, ports.SystemSuspend{})
315 case ports.SleepReady:
316 if o.sleeping {
317 o.sq = append(o.sq, ports.SystemSleepReady{Cycle: o.sleepCycle})
318 }
319 }
320 return nil
321}
322
323// spawn omits both the current and the startup-captured pin-env names: the
324// startup value is what the daemon holds, whatever a reload later selects.
325func (o *Owner) spawn() ports.VisualSpawn {
326 var names []string
327 for _, a := range []ports.AuthConfig{o.cfg.Auth, o.startAuth} {
328 if a.PINEnv != "" && !slices.Contains(names, a.PINEnv) {
329 names = append(names, a.PINEnv)
330 }
331 }
332 return ports.VisualSpawn{OmitEnv: names}
333}
334
335func (o *Owner) bootstrap() ports.AuthBootstrap {
336 a := o.cfg.Auth
337 b := ports.AuthBootstrap{Generation: uint64(o.lockGen), PINSource: a.PINSource}
338 switch a.PINSource {
339 case ports.PINSourceEnv:
340 if v, ok := o.opt.LookupEnv(a.PINEnv); ok {
341 b.EnvPIN = []byte(v)
342 }
343 case ports.PINSourcePass:
344 b.PINReference = a.PINEntry
345 }
346 return b
347}
348
349func (o *Owner) lockWanted() bool {
350 st := o.policy.State()
351 return st.Acquiring || st.Protected
352}
353
354// startLock asks the visual to hold ext-session-lock for the lock generation,
355// (re)spawning it as needed. Failures back off and retry; the compositor keeps
356// any acquired lock in the meantime.
357func (o *Owner) startLock(ctx context.Context) error {
358 if !o.lockWanted() || o.lockGen == 0 {
359 return nil
360 }
361 err := o.opt.Visual.Lock(ctx, ports.VisualLock{Generation: o.lockGen, Auth: o.bootstrap(), Output: o.cfg.LockOutput, Spawn: o.spawn()})
362 if err != nil {
363 o.log.Warn().Err(err).Uint64("generation", uint64(o.lockGen)).Dur("retry_in", o.delay).Msg("lock visual unavailable")
364 o.armRetry()
365 }
366 return nil
367}
368
369func (o *Owner) armRetry() {
370 o.retry.Reset(o.delay)
371 o.delay = min(o.delay*2, o.opt.RetryMax)
372}
373
374func (o *Owner) reload(ctx context.Context, cfg ports.Config) error {
375 if err := cfg.Validate(); err != nil {
376 o.log.Warn().Err(err).Msg("config reload rejected")
377 return nil
378 }
379 w, s := Requirements(cfg)
380 if w.OutputPower && !o.startWL.OutputPower || s.Sleep && !o.startSY.Sleep {
381 o.log.Warn().Msg("reload enables output-off or sleep support that was not initialised at startup; restart the daemon")
382 }
383 if o.gated && o.cfg.SleepAfter == 0 {
384 if w := suspendWarning(cfg, o.sysCaps); w != "" {
385 o.log.Warn().Msg(w)
386 }
387 }
388 o.cfg = cfg
389 return o.handle(ctx, ports.Reload{Config: cfg})
390}
391
392func (o *Owner) wayland(ctx context.Context, ev ports.WaylandEvent) error {
393 switch v := ev.(type) {
394 case ports.WaylandIdle:
395 // The adapter keeps one proxy per distinct duration, measured from the
396 // last input, while policy registrations (one per origin) are relative
397 // to their own registration time. A registration is delivered once its
398 // duration is idle and its origin+After has passed; later ones wait on
399 // the deferred timer (this is what keeps an equal-valued reload from
400 // rebasing early). Policy drops anything stale.
401 if o.idle[v.Seat] == nil {
402 o.idle[v.Seat] = map[time.Duration]bool{}
403 }
404 o.idle[v.Seat][v.After] = true
405 return o.fireDue(ctx)
406 case ports.WaylandActivity:
407 // Undoes fade and wakes outputs; the policy has no unlock path.
408 // Only the active seat stops being idle; other seats keep their state.
409 delete(o.idle, v.Seat)
410 clear(o.fired)
411 return o.handle(ctx, ports.Activity{})
412 case ports.WaylandCapabilities:
413 c := v.Capabilities
414 if o.gated && o.wlCaps != nil && len(c.Missing) > 0 {
415 o.log.Warn().Strs("missing", c.Missing).Msg("wayland capabilities degraded")
416 }
417 if !o.gated || o.wlCaps == nil {
418 // Until the gate passes the latest report counts: a compositor may
419 // advertise outputs after the daemon connected.
420 o.wlCaps = &c
421 }
422 o.seats, o.seatsKnown = map[uint32]bool{}, true
423 for _, id := range c.Seats {
424 o.seats[id] = true
425 }
426 for id := range o.idle {
427 if !o.seats[id] {
428 delete(o.idle, id) // removed seat no longer counts
429 }
430 }
431 return o.fireDue(ctx)
432 case ports.WaylandPowerFailed:
433 o.log.Warn().Uint64("output", v.Output).Msg("output power control failed")
434 }
435 return nil
436}
437
438// allIdle reports whether every known seat has duration d idle. Known seats are
439// the latest capability report's; nothing fires before the first report.
440func (o *Owner) allIdle(d time.Duration) bool {
441 if !o.seatsKnown || len(o.seats) == 0 {
442 return false
443 }
444 for id := range o.seats {
445 if !o.idle[id][d] {
446 return false
447 }
448 }
449 return true
450}
451
452// fireDue delivers every registration whose duration is idle on all known
453// seats and whose origin+After has passed, and re-arms the deferred timer.
454func (o *Owner) fireDue(ctx context.Context) error {
455 now := time.Now()
456 for _, n := range slices.Clone(o.regs) {
457 if !o.allIdle(n.After) || o.fired[n.ID] || o.regOrigin[n.ID].Add(n.After).After(now) {
458 continue
459 }
460 o.fired[n.ID] = true
461 if err := o.handle(ctx, ports.IdleDue{Generation: o.regGen[n.ID], Notification: n.ID, After: n.After}); err != nil {
462 return err
463 }
464 }
465 o.armDeferred()
466 return nil
467}
468
469func (o *Owner) armDeferred() {
470 o.deferred.Stop()
471 var next time.Time
472 for _, n := range o.regs {
473 if !o.allIdle(n.After) || o.fired[n.ID] {
474 continue
475 }
476 if t := o.regOrigin[n.ID].Add(n.After); next.IsZero() || t.Before(next) {
477 next = t
478 }
479 }
480 if !next.IsZero() {
481 o.deferred.Reset(max(time.Until(next), time.Millisecond))
482 }
483}
484
485func (o *Owner) system(ctx context.Context, ev ports.SystemEvent) error {
486 switch v := ev.(type) {
487 case ports.SystemCapabilityReport:
488 c := v.Capabilities
489 if o.sysCaps == nil {
490 o.sysCaps = &c
491 }
492 case ports.SystemLockRequested:
493 return o.handle(ctx, ports.LockRequested{})
494 case ports.SystemUnlockObserved:
495 // An observation, never authority: only the visual releases the lock.
496 o.log.Info().Msg("logind unlock observed; ignored")
497 case ports.SleepPreparation:
498 o.sleeping, o.sleepCycle = v.Preparing, v.Cycle
499 if v.Preparing {
500 return o.handle(ctx, ports.BeforeSleep{})
501 }
502 case ports.SystemFailure:
503 o.log.Warn().Err(v.Err).Str("operation", v.Operation).Msg("system operation failed")
504 }
505 return nil
506}
507
508func (o *Owner) visual(ctx context.Context, ev ports.VisualEvent) error {
509 switch v := ev.(type) {
510 case ports.LockConfirmed:
511 o.delay = o.opt.RetryMin
512 return o.handle(ctx, v)
513 case ports.LockReleased:
514 if err := o.handle(ctx, v); err != nil {
515 return err
516 }
517 if !o.lockWanted() {
518 o.lockGen = 0
519 o.retry.Stop()
520 }
521 case ports.VisualExited:
522 if o.lockWanted() {
523 // A dead lock visual leaves the session locked; bring it back.
524 o.log.Warn().Err(v.Err).Uint64("generation", uint64(o.lockGen)).Msg("lock visual exited; respawning")
525 o.armRetry()
526 } else {
527 o.log.Info().Err(v.Err).Msg("visual exited")
528 }
529 }
530 return nil
531}
532
533func (o *Owner) control(ctx context.Context, req ports.ControlRequest) error {
534 if req.Reply == nil {
535 return nil // nobody to answer, and nothing to apply
536 }
537 if req.Kind == ports.ControlLock {
538 if err := o.handle(ctx, ports.LockRequested{}); err != nil {
539 return err
540 }
541 } else if req.Kind != ports.ControlStatus {
542 req.Reply <- ports.ControlReply{Err: fmt.Sprintf("unknown request %d", req.Kind)}
543 return nil
544 }
545 st := o.policy.State()
546 req.Reply <- ports.ControlReply{Generation: st.Generation, Acquiring: st.Acquiring, Protected: st.Protected, Faded: st.Faded, Off: st.Off}
547 return nil
548}