1package app
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8
9 "github.com/bnema/neferwl/internal/adapters/headless"
10 "github.com/bnema/neferwl/internal/ports"
11 "github.com/bnema/zerowrap"
12)
13
14// headlessOptions are the settings of the virtual outputs.
15type headlessOptions struct {
16 sizes [][2]int
17 shots string
18 hdr bool
19 raw bool // also write latest-pq.png (headless.Options.RawHDR)
20}
21
22// runHeadless drives one virtual output per size, named HEADLESS-1, -2, ...
23// With several outputs, screenshots go to a subdirectory per output. Modes
24// are fixed, so apply answers every configuration without backend work.
25//
26// The raw PQ dump (o.raw) needs readable HDR targets, set on these
27// renderers only: the DRM path never reads them back.
28//
29// Virtual outputs have no display to list modifiers, so each renderer is
30// told it drives one (vulkan.Renderer.SetVirtualOutput); without it a real
31// GPU refuses the HDR targets and the output falls back to SDR.
32func runHeadless(ctx context.Context, o headlessOptions, apply *outputApply, ch outputChannels, curs *cursors, newRenderer func(w, h int) (ports.Renderer, error), log zerowrap.Logger) error {
33 newRenderer = virtualRenderer(newRenderer, o.raw)
34 set := newOutputSet(ctx, ch.captured)
35 set.wireSecurity(ch)
36 inventory := ports.OutputHeads{}
37 for i, size := range o.sizes {
38 name := fmt.Sprintf("HEADLESS-%d", i+1)
39 mode := ports.OutputMode{Width: size[0], Height: size[1], RefreshMilli: 60000, Preferred: true}
40 inventory.Heads = append(inventory.Heads, ports.OutputHead{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1], RefreshMilli: 60000}, Modes: []ports.OutputMode{mode}, Current: &mode, Enabled: true})
41 dir := o.shots
42 if dir != "" && len(o.sizes) > 1 {
43 dir = filepath.Join(o.shots, name)
44 if err := os.MkdirAll(dir, 0o755); err != nil {
45 // Outputs already started stop and release their captures.
46 return joinErr(err, set.wait())
47 }
48 }
49 cur := &headless.Cursor{}
50 curs.set(name, cur)
51 opts := headless.Options{Cursor: cur, LoadCursor: loadCursor, Width: size[0], Height: size[1], ScreenshotDir: dir, HDR: o.hdr, RawHDR: o.raw, Formats: ch.formats, Log: log, NewRenderer: newRenderer, NewCaptureRenderer: newRenderer, Name: name, Presented: ch.presented, Captured: ch.captured}
52 started := set.start(ctx, name, func(octx context.Context, sc <-chan ports.Scene, cc <-chan ports.SurfaceContent, cu <-chan ports.CursorChange, cap <-chan ports.CaptureRequest, secure <-chan ports.SecurityState, instance ports.OutputInstance) error {
53 opts.Security, opts.SecurityChanges, opts.Instance = ch.security, secure, instance
54 return headless.Run(octx, opts, sc, cc, cu, cap)
55 }, &opts.SecurityEvents)
56 if !started {
57 return set.wait()
58 }
59 select {
60 case ch.events <- ports.OutputAdded{Info: ports.OutputInfo{Name: name, Width: size[0], Height: size[1]}}:
61 case <-ctx.Done():
62 return set.wait()
63 }
64 }
65 // All virtual lifetimes have registered before this no-lease barrier.
66 if set.state.Protected {
67 set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation})
68 }
69 apply.heads(inventory)
70 // The pointer starts centred on the first output, like libinput's.
71 curs.move("HEADLESS-1", float64(o.sizes[0][0])/2, float64(o.sizes[0][1])/2, false)
72 for {
73 configs, configNext := apply.configOut(ch.configured), apply.config
74 replies, replyNext := apply.replyOut(ch.replies)
75 heads, headsNext := apply.headsOut(ch.heads)
76 contents, contentOut, contentNext, contentOwner := set.contentOut(ch.contents)
77 select {
78 case <-ctx.Done():
79 return set.wait()
80 case state, ok := <-ch.securityChanges:
81 if !ok {
82 ch.securityChanges = nil
83 continue
84 }
85 if set.setSecurity(state) && set.state.Protected {
86 set.securityEvent(ports.SecurityBackendBarrier{Generation: set.state.Generation})
87 }
88 case ev := <-ch.reloads:
89 apply.reload(ev.Config)
90 case req := <-ch.requests:
91 apply.request(req)
92 case configs <- configNext:
93 apply.configSent()
94 case replies <- replyNext:
95 apply.replySent()
96 case heads <- headsNext:
97 apply.headsSent()
98 case s := <-ch.scenes:
99 set.scenes(s)
100 case c := <-contents:
101 set.content(c)
102 case contentOut <- contentNext:
103 contentOwner.contentSent()
104 case q := <-ch.captures:
105 set.routeCapture(q)
106 case c := <-ch.cursorChanges:
107 set.setCursor(c)
108 case stopped := <-set.stopped:
109 if !set.stoppedCurrent(stopped) {
110 continue
111 }
112 // An owner error is not protection or removal evidence.
113 err := set.finish(stopped.name)
114 return joinErr(err, set.wait())
115 }
116 }
117}
118
119// virtualRenderer wraps a renderer factory for headless outputs. Vulkan is
120// the only implementation of these settings; they are not on the port.
121// readback makes the HDR targets readable (HDRPixels).
122func virtualRenderer(newRenderer func(w, h int) (ports.Renderer, error), readback bool) func(w, h int) (ports.Renderer, error) {
123 return func(w, h int) (ports.Renderer, error) {
124 r, err := newRenderer(w, h)
125 if err != nil {
126 return nil, err
127 }
128 if v, ok := r.(interface{ SetVirtualOutput(bool) }); ok {
129 v.SetVirtualOutput(true)
130 }
131 if v, ok := r.(interface{ SetHDRReadback(bool) }); ok && readback {
132 v.SetHDRReadback(true)
133 }
134 return r, nil
135 }
136}
137
138func joinErr(a, b error) error {
139 if a != nil {
140 return a
141 }
142 return b
143}