1package headless
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "image"
8 "image/png"
9 "os"
10 "path/filepath"
11 "time"
12
13 "golang.org/x/sys/unix"
14
15 "github.com/bnema/neferwl/internal/adapters/capture"
16 "github.com/bnema/neferwl/internal/adapters/clock"
17 "github.com/bnema/neferwl/internal/adapters/presented"
18 "github.com/bnema/neferwl/internal/adapters/surfaces"
19 "github.com/bnema/neferwl/internal/adapters/syncfile"
20 "github.com/bnema/neferwl/internal/ports"
21 "github.com/bnema/zerowrap"
22)
23
24type Options struct {
25 // Security is the defensive gate. SecurityChanges wakes an idle owner;
26 // the snapshot, not the queued notification payload, is authoritative.
27 Security ports.SessionSecurity
28 SecurityChanges <-chan ports.SecurityState
29 Instance ports.OutputInstance
30 SecurityEvents chan<- ports.SecurityBackendEvent
31 // Cursor, when set, is drawn into screenshots with the image from
32 // LoadCursor at the scene scale and transform. The cursor is drawn in
33 // target space: the image comes rotated and Move takes target pixels.
34 Cursor *Cursor
35 LoadCursor func(c ports.CursorChange, scale float64, t ports.BufferTransform, limit int) (ports.CursorImage, error)
36 Width, Height int
37 ScreenshotDir string
38 HDR bool // virtual HDR output for protocol testing only
39 // RawHDR also writes latest-pq.png, the raw PQ codes of an HDR output
40 // (16-bit), next to the screenshots. Test-only; needs HDR and ScreenshotDir.
41 RawHDR bool
42 Formats chan<- ports.OutputFormats // confirmed color state; nil disables reporting
43 Log zerowrap.Logger
44 NewRenderer func(w, h int) (ports.Renderer, error)
45 // NewCaptureRenderer makes the child renderer of a hidden workspace
46 // capture session (Scene.CaptureScene). Nil: such captures fail closed.
47 NewCaptureRenderer func(w, h int) (ports.Renderer, error)
48 // Name and Presented report what the output has read after each
49 // frame, so wayland can release client buffers (nil: no reports).
50 Name string
51 Presented chan<- ports.OutputPresented
52 Captured chan<- ports.CaptureDone
53 // Clock paces the renderer trim (nil: system clock).
54 Clock ports.Clock
55}
56
57// trimEvery is how often an output lets its renderer free the buffers of
58// windows it no longer draws.
59const trimEvery = 10 * time.Second
60
61func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent, cursor <-chan ports.CursorChange, incoming <-chan ports.CaptureRequest) error {
62 r, err := opts.NewRenderer(opts.Width, opts.Height)
63 if err != nil {
64 return fmt.Errorf("create renderer: %w", err)
65 }
66 defer r.Close()
67 var confirmed *ports.OutputHDR
68 if opts.HDR {
69 r.SetHDR(203)
70 if bufs, err := r.ExportTargets(1, nil, false); err != nil {
71 opts.Log.Warn().Err(err).Str("output", opts.Name).Msg("virtual HDR unavailable; falling back to SDR")
72 r.SetHDR(0)
73 _, _ = r.ExportTargets(0, nil, false)
74 } else {
75 for _, b := range bufs {
76 for _, p := range b.Planes {
77 _ = p.File.Close()
78 }
79 }
80 confirmed = &ports.OutputHDR{MaxLuminance: 1000, MaxFrameAverage: 400, MinLuminance: .005}
81 opts.Log.Info().Str("output", opts.Name).Msg("virtual HDR enabled")
82 }
83 if opts.Formats != nil {
84 select {
85 case opts.Formats <- ports.OutputFormats{Output: opts.Name, HDR: confirmed}:
86 case <-ctx.Done():
87 return nil
88 }
89 }
90 }
91 table := surfaces.New()
92 drawn := table.Map()
93 var scene ports.Scene
94 haveScene, dirty := false, false
95 frame := 0
96 var requestStorage [capture.MaxRequests]ports.CaptureRequest
97 requests := requestStorage[:0]
98 ctx, cancelCaptures := context.WithCancel(ctx)
99 pipeline := capture.NewPipeline(ctx, opts.Captured)
100 pipeline.Security = opts.Security
101 if opts.NewCaptureRenderer != nil {
102 pipeline.EnableOffscreen(opts.NewCaptureRenderer)
103 }
104 defer func() {
105 // An error path out of a gated frame: what it held is failed.
106 pipeline.EndGate(capture.ErrFrameNotPresented)
107 cancelCaptures()
108 pipeline.Close(r)
109 for _, q := range requests {
110 if capture.Handed(q) {
111 continue // already answered, or owned by a worker
112 }
113 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
114 }
115 for {
116 select {
117 case q, ok := <-incoming:
118 if !ok {
119 return
120 }
121 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
122 default:
123 return
124 }
125 }
126 }()
127 var want ports.CursorChange
128 cursorScale := -1.0 // not loaded yet
129 var cursorTransform ports.BufferTransform
130 seen := map[ports.WindowID]uint64{}
131 // reports holds what the channel could not take yet, retried soon.
132 var reports presented.Queue
133 var security ports.SecurityState
134 securityKnown := false
135 failRequests := func(reason string) {
136 // Whatever this frame had handed to a worker is failed with it.
137 pipeline.EndGate(capture.GateVerdict(errors.New(reason)))
138 for _, q := range requests {
139 if !capture.Handed(q) {
140 capture.Fail(ctx, q, fmt.Errorf("%s", reason), opts.Captured)
141 }
142 }
143 clear(requests)
144 requests = requestStorage[:0]
145 }
146 // A transition is its own frame, never a locker-buffer-dependent frame.
147 // Keep its epoch through the fence wait and report only actual completion.
148 checkSecurity := func() error {
149 if opts.Security == nil {
150 return nil
151 }
152 for {
153 next := opts.Security.Snapshot()
154 if securityKnown && next == security {
155 return nil
156 }
157 // No hardware scanout exists: an already applied Off scene is
158 // confirmed software inactivity, independent of its old epoch.
159 inactive := haveScene && scene.Off
160 security, securityKnown = next, true
161 reports.Reset()
162 haveScene, dirty = false, false
163 if !security.Protected {
164 continue
165 }
166 black := ports.Scene{Security: security, Output: opts.Name, OutputWidth: opts.Width, OutputHeight: opts.Height, Scale: 1, Background: "#000000"}
167 black.Off = inactive
168 scene = black
169 kind := ports.ProtectionInactiveOutput
170 if !inactive {
171 kind = ports.ProtectionProtectedFrame
172 done, err := r.Render(black, nil)
173 if err != nil {
174 return fmt.Errorf("render protection: %w", err)
175 }
176 if done != nil {
177 err = syncfile.Wait(ctx, done)
178 _ = done.Close()
179 if err != nil {
180 return fmt.Errorf("protection fence: %w", err)
181 }
182 }
183 }
184 if opts.Security.Snapshot() != security {
185 continue
186 }
187 if opts.SecurityEvents != nil {
188 proof := ports.SecurityOutputProof{Proof: ports.OutputProtection{Generation: security.Generation, Instance: opts.Instance, Kind: kind}}
189 select {
190 case opts.SecurityEvents <- proof:
191 case <-ctx.Done():
192 return ctx.Err()
193 }
194 }
195 // Capture reply backpressure must not postpone the first black
196 // completion/proof. Capture drainage is a separate parent barrier.
197 failRequests("session protected")
198 }
199 }
200 acceptScene := func(s ports.Scene) {
201 if opts.Security != nil && (s.Security != security || s.Security != opts.Security.Snapshot()) {
202 return
203 }
204 // The core keeps the Seq of a scene that draws the same: nothing to do.
205 if haveScene && s.Seq != 0 && s.Seq == scene.Seq && s.Security == scene.Security {
206 return
207 }
208 scene, haveScene, dirty = s, true, true
209 // A closed window's last content goes once no scene draws it.
210 table.Prune(scene)
211 }
212 update := func(c ports.SurfaceContent) {
213 seen[c.ID] = max(seen[c.ID], c.Seq)
214 dirty = dirty || capture.Shows(scene, c.ID)
215 table.Update(c, scene)
216 }
217 clk := opts.Clock
218 if clk == nil {
219 clk = clock.System{}
220 }
221 trim := clk.NewTicker(trimEvery)
222 defer trim.Stop()
223 // One timer for the wait of held requests, reset per pass (a time.After
224 // per loop pass would leave a timer behind each).
225 holdTimer := time.NewTimer(time.Hour)
226 holdTimer.Stop()
227 defer holdTimer.Stop()
228 for {
229 // A frame that left its gate open (any path out but its present) fails
230 // what it held: nothing is delivered for a frame that did not present.
231 pipeline.EndGate(capture.ErrFrameNotPresented)
232 if ctx.Err() != nil {
233 return nil
234 }
235 if err := checkSecurity(); err != nil {
236 if ctx.Err() != nil {
237 return nil
238 }
239 return err
240 }
241 var retry <-chan time.Time
242 if reports.Len() > 0 {
243 retry = time.After(time.Millisecond)
244 }
245 // Requests wait for a scene that shows their indicator, at most
246 // capture.HoldFor (see capture.Hold).
247 var holdDue <-chan time.Time
248 if len(requests) > 0 {
249 var wait time.Duration
250 requests, wait = pipeline.Expire(scene, requests, time.Now())
251 if wait > 0 {
252 holdTimer.Reset(wait)
253 holdDue = holdTimer.C
254 }
255 }
256 if holdDue == nil {
257 holdTimer.Stop()
258 }
259 select {
260 case <-holdDue:
261 continue
262 case <-ctx.Done():
263 return nil
264 case _, ok := <-opts.SecurityChanges:
265 if !ok {
266 opts.SecurityChanges = nil
267 }
268 if err := checkSecurity(); err != nil {
269 if ctx.Err() != nil {
270 return nil
271 }
272 return err
273 }
274 continue
275 case b := <-pipeline.Completed():
276 // The completed token has left the pipeline's queue. Return its
277 // lease before any transition path can error or block.
278 pipeline.Recycle(b, r)
279 if err := checkSecurity(); err != nil {
280 if ctx.Err() != nil {
281 return nil
282 }
283 return err
284 }
285 continue
286 case b := <-pipeline.HiddenCompleted():
287 pipeline.RecycleHidden(b)
288 if err := checkSecurity(); err != nil {
289 if ctx.Err() != nil {
290 return nil
291 }
292 return err
293 }
294 pipeline.Retire(scene)
295 continue
296 case q, ok := <-incoming:
297 if !ok {
298 incoming = nil
299 continue
300 }
301 if err := checkSecurity(); err != nil {
302 // q has left incoming but is not yet owned by requests/workers.
303 capture.Fail(ctx, q, fmt.Errorf("output stopped"), opts.Captured)
304 if ctx.Err() != nil {
305 return nil
306 }
307 return err
308 }
309 if opts.protected() {
310 capture.Fail(ctx, q, fmt.Errorf("session protected"), opts.Captured)
311 } else if scene.Off {
312 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
313 } else if len(requests) == cap(requests) {
314 capture.Fail(ctx, q, fmt.Errorf("output capture batch full"), opts.Captured)
315 } else {
316 q.Since = time.Now()
317 requests = append(requests, q)
318 // Its indicator may already be on screen; else it waits for the
319 // scene that shows it.
320 dirty = dirty || haveScene && capture.IndicatorShown(scene, q)
321 }
322 case <-retry:
323 if err := checkSecurity(); err != nil {
324 if ctx.Err() != nil {
325 return nil
326 }
327 return err
328 }
329 opts.flush(&reports)
330 continue
331 case <-trim.C():
332 if err := checkSecurity(); err != nil {
333 if ctx.Err() != nil {
334 return nil
335 }
336 return err
337 }
338 // An idle output renders nothing: free what windows that left
339 // it held, and the child of an ended session.
340 pipeline.Retire(scene)
341 if err := r.Trim(clk.Now()); err != nil {
342 return fmt.Errorf("trim renderer: %w", err)
343 }
344 continue
345 case s, ok := <-scenes:
346 if !ok {
347 scenes = nil
348 continue
349 }
350 if err := checkSecurity(); err != nil {
351 if ctx.Err() != nil {
352 return nil
353 }
354 return err
355 }
356 acceptScene(s)
357 case c, ok := <-contents:
358 if !ok {
359 contents = nil
360 continue
361 }
362 if err := checkSecurity(); err != nil {
363 if ctx.Err() != nil {
364 return nil
365 }
366 return err
367 }
368 update(c)
369 case c, ok := <-cursor:
370 if !ok {
371 cursor = nil
372 continue
373 }
374 if err := checkSecurity(); err != nil {
375 if ctx.Err() != nil {
376 return nil
377 }
378 return err
379 }
380 // The cursor is only drawn into screenshots: no new frame.
381 want, cursorScale, cursorTransform = c, -1, 0
382 }
383 // Drain all queued updates before presenting one frame.
384 drain:
385 for {
386 select {
387 case s, ok := <-scenes:
388 if !ok {
389 scenes = nil
390 } else {
391 if err := checkSecurity(); err != nil {
392 if ctx.Err() != nil {
393 return nil
394 }
395 return err
396 }
397 acceptScene(s)
398 }
399 case c, ok := <-contents:
400 if !ok {
401 contents = nil
402 } else {
403 if err := checkSecurity(); err != nil {
404 if ctx.Err() != nil {
405 return nil
406 }
407 return err
408 }
409 update(c)
410 }
411 default:
412 break drain
413 }
414 }
415 if err := checkSecurity(); err != nil {
416 if ctx.Err() != nil {
417 return nil
418 }
419 return err
420 }
421 if opts.protected() {
422 failRequests("session protected")
423 }
424 if scene.Off && len(requests) > 0 {
425 for _, q := range requests {
426 capture.Fail(ctx, q, fmt.Errorf("output off"), opts.Captured)
427 }
428 clear(requests)
429 requests = requestStorage[:0]
430 }
431 if !haveScene || !dirty || scene.Off {
432 // Contents not drawn are still read: report them. An output
433 // turned off draws nothing.
434 opts.report(&reports, nil, seen)
435 continue
436 }
437 dirty = false
438 if !opts.protected() && opts.Cursor != nil && opts.LoadCursor != nil && (scene.Scale != cursorScale || scene.Transform != cursorTransform) {
439 cursorScale, cursorTransform = scene.Scale, scene.Transform
440 if img, err := opts.LoadCursor(want, scene.Scale, scene.Transform, 256); err == nil {
441 if !opts.protected() {
442 opts.Cursor.set(img)
443 }
444 } else {
445 opts.Log.Warn().Err(err).Msg("cursor")
446 }
447 }
448 if err := checkSecurity(); err != nil {
449 if ctx.Err() != nil {
450 return nil
451 }
452 return err
453 }
454 if !haveScene {
455 continue
456 }
457 start := time.Now()
458 // Requests the scene cannot serve (exclusion of another session, a
459 // workspace that moved) are failed here; see capture.Pipeline.Split.
460 // A request whose indicator the scene does not show yet stays held.
461 ready, _ := pipeline.Hold(scene, requests, time.Now())
462 if len(scene.CaptureIndicators) > 0 {
463 // The captures of this frame are delivered only once its frame,
464 // with the indicator, is presented (capture.Pipeline.EndGate).
465 pipeline.BeginGate()
466 }
467 normal, excluded, hidden := pipeline.Split(scene, ready)
468 pipeline.Retire(scene)
469 if len(hidden) > 0 {
470 // A hidden workspace: a child renderer draws it. The display's
471 // fences do not cover the child, so wait for it before reporting.
472 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
473 failRequests("security epoch changed")
474 continue
475 }
476 pipeline.SubmitHidden(scene, drawn, hidden)
477 clear(hidden)
478 // A slow child may outlive Wayland's stale-report timeout even
479 // though this owner waits. Publish its non-expiring holds first.
480 if opts.Presented != nil {
481 _, reads, _ := pipeline.CapHiddenSeen(seen)
482 if len(reads) > 0 {
483 reports.Push(ports.OutputPresented{Output: opts.Name, Seen: seen, ChildReads: reads})
484 if reports.Drain(ctx, opts.Presented) != nil {
485 return nil
486 }
487 }
488 }
489 if err := pipeline.WaitHidden(ctx); err != nil {
490 if ctx.Err() != nil {
491 return nil
492 }
493 return fmt.Errorf("workspace frame fence: %w", err)
494 }
495 }
496 // Captures never show the capture indicator: their frame goes first.
497 took, pdone, perr := pipeline.SubmitPlain(r, scene, drawn, normal)
498 if pdone != nil {
499 // The plain render reads client buffers even when its requests
500 // are rejected by a gate transition: own and wait its fence
501 // before any Seen report or protection check can proceed.
502 waitErr := syncfile.Wait(ctx, pdone)
503 _ = pdone.Close()
504 if waitErr != nil {
505 if ctx.Err() != nil {
506 return nil
507 }
508 return fmt.Errorf("plain frame fence: %w", waitErr)
509 }
510 }
511 if perr != nil {
512 return fmt.Errorf("render plain frame: %w", perr)
513 }
514 if took {
515 normal = nil // answered from the frame without the indicator
516 }
517 if len(excluded) > 0 {
518 // Exclude requests: the frame without the excluded surfaces is
519 // drawn first (same renderer, queue order), then the displayed one.
520 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
521 failRequests("security epoch changed")
522 continue
523 }
524 xdone, renderErr := r.Render(pipeline.ExcludedScene(scene), drawn)
525 if xdone != nil {
526 // The excluded-frame composition reads client buffers even when the displayed
527 // frame is skipped by a gate/off transition. Own and wait its
528 // fence before any Seen report or protection check can proceed.
529 waitErr := syncfile.Wait(ctx, xdone)
530 _ = xdone.Close()
531 if waitErr != nil {
532 if ctx.Err() != nil {
533 return nil
534 }
535 return fmt.Errorf("excluded frame fence: %w", waitErr)
536 }
537 }
538 if renderErr != nil {
539 return fmt.Errorf("render excluded frame: %w", renderErr)
540 }
541 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
542 failRequests("security epoch changed")
543 continue
544 }
545 pipeline.SubmitScoped(scene.Security, r, excluded)
546 clear(excluded)
547 }
548 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
549 failRequests("security epoch changed")
550 continue
551 }
552 done, err := r.Render(scene, drawn)
553 if err != nil {
554 return fmt.Errorf("render frame: %w", err)
555 }
556 if done != nil {
557 // No screen paces headless frames: wait for the GPU before
558 // reporting that its buffers were read.
559 err = syncfile.Wait(ctx, done)
560 done.Close()
561 if err != nil {
562 if ctx.Err() != nil {
563 return nil
564 }
565 return fmt.Errorf("frame fence: %w", err)
566 }
567 }
568 if len(normal) > 0 {
569 opts.Log.Debug().Str("output", opts.Name).Int("captures", len(normal)).Msg("capture frame composed")
570 }
571 // Do not capture or report a frame that crossed a transition during
572 // Render/fence wait. The next loop establishes the new black proof.
573 if opts.Security != nil && opts.Security.Snapshot() != scene.Security {
574 failRequests("security epoch changed")
575 continue
576 }
577 pipeline.SubmitScoped(scene.Security, r, normal)
578 // The indicated frame is presented: release what it gated.
579 pipeline.EndGate(nil)
580 requests = capture.Waiting(requests, scene)
581 frame++
582 opts.report(&reports, flipInfo(scene, drawn, table.Kept), seen)
583 if opts.ScreenshotDir != "" && !opts.protected() {
584 shot := r.Pixels()
585 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
586 continue
587 }
588 if shot == nil {
589 opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("screenshot readback unavailable; frame skipped")
590 continue
591 }
592 if opts.Cursor != nil {
593 opts.Cursor.drawSecure(shot, opts.Security)
594 }
595 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
596 continue
597 }
598 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), shot, opts.Security, scene.Security); err != nil {
599 return fmt.Errorf("screenshot: %w", err)
600 }
601 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest.png"), shot, opts.Security, scene.Security); err != nil {
602 return fmt.Errorf("latest screenshot: %w", err)
603 }
604 if opts.RawHDR && opts.HDR {
605 // No cursor on the raw image. Only a confirmed HDR output
606 // has a readable PQ target; else HDRPixels is nil.
607 if opts.protected() || opts.Security != nil && opts.Security.Snapshot() != scene.Security {
608 continue
609 }
610 raw := r.HDRPixels()
611 if raw == nil {
612 opts.Log.Debug().Str("output", opts.Name).Int("frame", frame).Msg("raw HDR readback unavailable; frame skipped")
613 continue
614 }
615 if err := writePNGSecure(filepath.Join(opts.ScreenshotDir, "latest-pq.png"), raw, opts.Security, scene.Security); err != nil {
616 return fmt.Errorf("raw HDR screenshot: %w", err)
617 }
618 }
619 }
620 if ev := opts.Log.Debug(); ev.Enabled() {
621 ev.Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Int("redrawn", r.TakeRedrawn()).Msg("frame")
622 }
623 }
624}
625
626func (opts Options) protected() bool {
627 return opts.Security != nil && opts.Security.Snapshot().Protected
628}
629
630func writePNGSecure(path string, img image.Image, security ports.SessionSecurity, state ports.SecurityState) error {
631 if security != nil && (state.Protected || security.Snapshot() != state) {
632 return nil
633 }
634 f, err := os.CreateTemp(filepath.Dir(path), ".frame-*.png")
635 if err != nil {
636 return err
637 }
638 defer os.Remove(f.Name())
639 if err := png.Encode(f, img); err != nil {
640 _ = f.Close()
641 return err
642 }
643 if err := f.Close(); err != nil {
644 return err
645 }
646 if security != nil && security.Snapshot() != state {
647 return nil
648 }
649 return os.Rename(f.Name(), path)
650}
651
652// report queues what the output read, with flip for a drawn frame, and
653// sends what the channel takes now; the rest is retried soon.
654func (opts Options) report(q *presented.Queue, flip *ports.FlipInfo, seen map[ports.WindowID]uint64) {
655 if opts.Presented == nil {
656 return
657 }
658 if q.Push(ports.OutputPresented{Output: opts.Name, Flip: flip, Seen: seen}) {
659 opts.Log.Warn().Str("output", opts.Name).Msg("wayland is not reading output reports; flips merged")
660 }
661 q.Flush(opts.Presented)
662}
663
664// flush retries reports the channel could not take.
665func (opts Options) flush(q *presented.Queue) {
666 if opts.Presented != nil {
667 q.Flush(opts.Presented)
668 }
669}
670
671// flipInfo describes a drawn frame as a flip at the current CLOCK_MONOTONIC
672// time (software clock, refresh unknown), so presentation feedback and
673// frame callbacks follow headless frames.
674func flipInfo(scene ports.Scene, surfaces map[ports.WindowID]ports.SurfaceContent, kept func(ports.WindowID) bool) *ports.FlipInfo {
675 var ts unix.Timespec
676 _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts)
677 shows := map[ports.WindowID]uint64{}
678 for id, c := range surfaces {
679 // A closed window drawn from its kept content is not shown to
680 // its client.
681 if scene.Shows(id) && !kept(id) {
682 shows[id] = c.Seq
683 }
684 }
685 return &ports.FlipInfo{When: time.Duration(ts.Nano()), Shows: shows}
686}