1package screencast
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "os"
8 "os/exec"
9 "runtime"
10 "syscall"
11 "time"
12
13 "github.com/bnema/neferwl/internal/adapters/peerexe"
14 "github.com/bnema/purego-pipewire/pipewire"
15 "github.com/bnema/zerobus"
16)
17
18// bus is the part of a zerobus connection the service uses. Only Send and
19// ReadMessage are used after startup: a connection has one encoder and one
20// receive buffer, so Call and Handle are off limits.
21type bus interface {
22 NewReply(m *zerobus.Message, sig string) *zerobus.Encoder
23 NewError(m *zerobus.Message, name, sig string) *zerobus.Encoder
24 NewSignal(path, iface, member, sig string) *zerobus.Encoder
25 NewSignalTo(dest, path, iface, member, sig string) *zerobus.Encoder
26 NewMethodCall(dest, path, iface, member, sig string) (*zerobus.Encoder, uint32)
27 Send() (uint32, error)
28 ReadMessage() (*zerobus.Message, error)
29 Buffered() bool
30 SyscallConn() (syscall.RawConn, error)
31 AddMatch(rule string) error
32 RequestName(name string, flags uint32) (uint32, error)
33 Close() error
34}
35
36// videoSource is the part of a pipewire.VideoSource the producer uses. All
37// calls run on the loop thread.
38type videoSource interface {
39 NodeID() uint32
40 State() int32
41 Dequeue() *pipewire.VideoBuffer
42 Queue(b *pipewire.VideoBuffer, seq uint64, pts time.Duration, corrupted bool)
43 Release(b *pipewire.VideoBuffer)
44 Trigger()
45 Resize(w, h int) error
46 Destroy()
47}
48
49// videoCore is a PipeWire connection driven by the service's own poll loop.
50type videoCore interface {
51 // Fd is the pollable descriptor of the loop.
52 Fd() int
53 // Iterate dispatches pending events; handler callbacks run inside it.
54 Iterate()
55 NewVideoSource(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error)
56 Close()
57}
58
59// peerResolver proves which executable a D-Bus peer runs: Open pins the
60// process behind a pid, Exe reads the executable through that pin.
61type peerResolver interface {
62 Open(pid int) (*os.File, error)
63 Exe(pidfd *os.File) (exe string, pid int, err error)
64}
65
66// runner runs nefercap with args.
67type runner interface {
68 Run(ctx context.Context, args []string) error
69}
70
71// dialer connects the session bus (address empty) or the bus at address.
72type dialer interface {
73 Dial(address string) (bus, error)
74}
75
76type zerobusDialer struct{}
77
78func (zerobusDialer) Dial(address string) (bus, error) {
79 if address == "" {
80 return zerobus.SessionBus()
81 }
82 return zerobus.Dial(address)
83}
84
85// pipewireOpener connects to the PipeWire daemon. Open runs on the goroutine
86// that stays locked to its OS thread: the loop belongs to that thread.
87type pipewireOpener interface {
88 Open() (videoCore, error)
89}
90
91type realPipeWire struct{}
92
93func (realPipeWire) Open() (videoCore, error) { return openPipeWire() }
94
95// pidfdPeers is peerResolver over the pidfd proof of package peerexe.
96type pidfdPeers struct{}
97
98func (pidfdPeers) Open(pid int) (*os.File, error) { return peerexe.Open(pid) }
99
100func (pidfdPeers) Exe(pidfd *os.File) (string, int, error) {
101 exe, pid, err := peerexe.ExeOfPidfd(int(pidfd.Fd()))
102 runtime.KeepAlive(pidfd)
103 return exe, pid, err
104}
105
106// nefercap is the program that takes the screenshots.
107const nefercap = "nefercap"
108
109// execRunner runs nefercap in the session's environment (WAYLAND_DISPLAY
110// names this compositor).
111type execRunner struct{ env []string }
112
113func (r execRunner) Run(ctx context.Context, args []string) error {
114 bin, err := exec.LookPath(nefercap)
115 if err != nil {
116 return err
117 }
118 cmd := exec.CommandContext(ctx, bin, args...)
119 // A cancelled run is joined by the owner: never wait long for pipes
120 // that a grandchild keeps open.
121 cmd.WaitDelay = 2 * time.Second
122 if len(r.env) > 0 {
123 cmd.Env = r.env
124 }
125 out, err := cmd.CombinedOutput()
126 if err != nil {
127 if len(out) > 256 {
128 out = out[len(out)-256:]
129 }
130 return fmt.Errorf("%s: %w: %s", nefercap, err, out)
131 }
132 return nil
133}
134
135// pwLoop is videoCore over a real PipeWire loop, context and core.
136type pwLoop struct {
137 rt pipewire.Runtime
138 loop pipewire.Loop
139 ctx pipewire.Context
140 core pipewire.Core
141}
142
143// openPipeWire connects to the PipeWire daemon.
144func openPipeWire() (videoCore, error) {
145 rt, err := pipewire.Init()
146 if err != nil {
147 return nil, err
148 }
149 p := &pwLoop{rt: rt}
150 if p.loop, err = rt.NewLoop(); err != nil {
151 p.Close()
152 return nil, err
153 }
154 if p.ctx, err = rt.NewContext(p.loop); err != nil {
155 p.Close()
156 return nil, err
157 }
158 if p.core, err = p.ctx.Connect(); err != nil {
159 p.Close()
160 return nil, err
161 }
162 return p, nil
163}
164
165func (p *pwLoop) Fd() int { return p.loop.Fd() }
166
167func (p *pwLoop) Iterate() { p.loop.Iterate() }
168
169func (p *pwLoop) NewVideoSource(cfg pipewire.VideoConfig, h pipewire.VideoHandler) (videoSource, error) {
170 vs, err := p.core.NewVideoSource(cfg, h)
171 if err != nil {
172 return nil, err
173 }
174 if vs == nil {
175 return nil, errors.New("pipewire returned no video source")
176 }
177 return vs, nil
178}
179
180// Close releases the connection, innermost first. Destroy the sources first.
181func (p *pwLoop) Close() {
182 if p.core != nil {
183 p.core.Disconnect()
184 }
185 if p.ctx != nil {
186 p.ctx.Destroy()
187 }
188 if p.loop != nil {
189 p.loop.Destroy()
190 }
191 p.rt.Deinit()
192}