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